Files
n_signer/plans/nsigner.md

18 KiB
Raw Blame History

nsigner Implementation Plan

This document is the implementation roadmap for n_signer.

Authoritative behavior documentation: README.md.

1. Architecture pivot: program not daemon

This plan adopts a single foreground program model instead of a daemon model to reduce operational complexity and eliminate persistent runtime artifacts.

  • Single foreground process, not a background daemon
  • No config files — all state entered at runtime via TUI
  • No control socket — TUI is built into the program
  • No separate binaries — one binary with subcommands
  • Abstract namespace sockets — no filesystem artifacts

2. Module inventory (what exists, what stays, what goes)

Module Fate
src/secure_mem.{h,c} KEEP unchanged
src/mnemonic.{h,c} KEEP unchanged
src/role_table.{h,c} KEEP data structures, REMOVE file-loading code
src/selector.{h,c} KEEP unchanged
src/enforcement.{h,c} KEEP unchanged
src/dispatcher.{h,c} KEEP unchanged
src/cjson/ KEEP unchanged
src/policy.{h,c} REFACTOR: remove file loading, add interactive approval, hardcode same-uid default
src/daemon.{h,c} REPLACE with src/server.{h,c}: abstract namespace socket, poll-based, integrated with TUI
src/control.{h,c} DELETE (control socket no longer needed)
src/tui.c DELETE as separate binary (functionality moves into src/main.c)
src/client.c DELETE as separate binary (becomes subcommand in src/main.c)
src/main.c REWRITE: startup TUI + server loop + status display + client subcommand
config/ DELETE entirely
tests/test_daemon_integration.c DELETE or rewrite
tests/test_full_flow.c REWRITE for new architecture
tests/test_policy.c UPDATE for new policy API

3. Implementation phases (new)

Phase A — Cleanup and removal

  • Delete config/, src/control.{h,c}, src/tui.c, src/client.c
  • Remove file-loading functions from src/role_table.c and src/policy.c
  • Remove old daemon integration tests

Phase B — Server module (replaces daemon)

  • Add src/server.{h,c}: abstract namespace socket, poll-based accept loop
  • Keep peer-cred extraction behavior on abstract socket
  • Add integration point for TUI approval callbacks

Phase C — Unified main with built-in TUI

  • Startup phase: mnemonic prompt (echo off), word count confirm, optional role enrollment
  • Transition to running phase: status display, activity log, hotkey handling
  • Approval prompt overlay when policy requires it
  • Client subcommand: nsigner client '<json>' connects to abstract socket, sends request, prints response
  • Signal handling: SIGINT/SIGTERM → zeroize and exit

Phase D — Policy simplification

  • Default policy: same-uid as process = allow all verbs on all roles, no prompt
  • Unknown caller: display approval prompt in TUI, user decides per-request or per-session
  • No file-based policy at all

Phase E — Integration test

  • Single test that launches the program (with mnemonic piped via stdin for automation), sends requests via client subcommand, verifies responses

Phase G2 — Mnemonic generation at startup

Goal: let the user generate a brand-new BIP-39 mnemonic at startup instead of typing an existing one. The generated mnemonic is shown once and used for the session; no confirmation step is required.

Steps:

  • Extend startup TUI: prompt user with [E]nter existing mnemonic / [G]enerate new mnemonic (default E).
  • New module function mnemonic_generate(int word_count, char *out, size_t out_len) in src/mnemonic.c:
    • getrandom(2) for entropy (16 bytes for 12 words, 32 bytes for 24).
    • Compute SHA-256 of entropy → first entropy_bits / 32 checksum bits.
    • Concatenate entropy + checksum bits → slice into 11-bit groups.
    • Look up each group in the BIP-39 English wordlist → space-separated mnemonic.
    • Zeroize the entropy buffer immediately after use.
  • Default word count: 12 (no extra prompt).
  • TUI display:
    • Numbered word list (1..12).
    • Loud warning: "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN."
    • "Press any key to continue" — explicit no-confirmation policy per spec.
  • After display, the in-memory mnemonic is fed straight into the existing seed-derivation path; no separate buffer or persistence layer is introduced.
  • Tests in tests/test_mnemonic.c:
    • mnemonic_generate(12) returns 12 valid BIP-39 English words.
    • mnemonic_generate(24) returns 24 valid BIP-39 English words.
    • Output validates against the existing mnemonic_validate function (round-trip).
    • Two consecutive calls return different mnemonics with overwhelming probability.

Phase H — Multi-instance signer naming (random BIP-39)

Goal: allow multiple nsigner processes to coexist on one host by giving each instance a unique, human-readable abstract socket name.

Approach: at startup, pick two random BIP-39 English words and bind to @nsigner_<word1>_<word2> (e.g. @nsigner_hairy_dog).

Steps:

  • New module src/socket_name.{h,c}
    • int socket_name_random(char *out, size_t out_len);
    • Calls getrandom(2) for 3 bytes (24 bits), splits into two 11-bit indices, looks up words from the BIP-39 English wordlist, formats nsigner_<w1>_<w2>.
  • BIP-39 English wordlist
    • Use the wordlist exposed by nostr_core_lib (no vendored bip39_english.c in this repo).
  • src/server.c bind logic
    • Accept the resolved name from caller.
    • Bind once. On EADDRINUSE and no --socket-name override, regenerate a fresh random name and retry up to 8 times. With override, fail immediately and report the override conflict.
  • src/main.c integration
    • Resolve name precedence: --socket-name (explicit) > random pick.
    • After mnemonic acceptance, derive/pick the name and pass it to the server.
    • TUI startup banner shows the friendly name and the socket address prominently so the user knows what to point clients at.
  • nsigner list subcommand
    • Reads /proc/net/unix, filters entries whose path starts with \0nsigner_, prints them as @nsigner_<w1>_<w2> along with inode/state if useful.
    • No protocol change; purely a discovery helper.
  • Tests
    • tests/test_socket_name.c — format check, both indices land in [0, 2048), two consecutive calls produce different names with overwhelming probability.
    • tests/test_integration.c — launch with explicit --socket-name nsigner_test_run so the integration test is deterministic and isolated.

Privacy/UX notes:

  • Random names leak nothing about the seed; the trade-off is the user must read the banner each launch to know which socket to address.
  • ~4.2M combinations × small concurrent process count = collision essentially never observed in practice; retry path exists for correctness.
  • Behavior of --socket-name is unchanged; it remains the deterministic override for scripts and tests.

4. Decisions log

2026-05-02 — Mnemonic generation at startup

  1. Startup offers [E]nter or [G]enerate choice; default is E.
  2. Generated mnemonic uses getrandom(2) + BIP-39 English wordlist, default 12 words.
  3. Mnemonic is displayed exactly once with a "write this down" warning.
  4. No confirmation step — user is trusted to copy the words; we proceed straight to session use.
  5. The in-memory lifecycle is identical to a typed mnemonic (same secure buffer, same crash-wipe semantics).

2026-05-02 — Random per-launch signer naming

  1. Multiple nsigner instances can run concurrently on one host.
  2. Default socket name is @nsigner_<bip39_word1>_<bip39_word2> chosen randomly at each launch.
  3. --socket-name <name> overrides the random pick for tests and scripted usage (aliases: --name, -n).
  4. nsigner list enumerates running signers via /proc/net/unix filtered on nsigner_ prefix.
  5. Random naming was chosen over deterministic-from-mnemonic for v1 to avoid leaking any seed-derived identifier in the abstract namespace; deterministic naming may be revisited later.

2026-05-02 — Program not daemon pivot

  1. Single foreground process replaces daemon + TUI + client multi-binary model
  2. No config files — all state entered at runtime, lives in RAM only
  3. Abstract namespace sockets replace filesystem sockets
  4. Control socket eliminated — TUI is in-process
  5. Policy simplified to same-uid default + interactive approval
  6. Crash = total wipe is a security feature, not a limitation
  7. Same core modules target both Linux desktop and ESP32 MCU

2026-05-02 — Earlier decisions (retained)

  • role_path must be pre-registered (no ad-hoc derivation)
  • nostr_index naming (not role_index)
  • Multi-curve support from start (secp256k1, ed25519, x25519)
  • README.md is authoritative behavior spec

5. Open questions

  • ESP32 transport shim interface definition and frame format details.
  • NIP-46 relay transport integration timeline.
  • Role enrollment UX depth at startup vs. minimal defaults.
  • Optional policy granularity beyond same-uid + interactive prompt (e.g. per-verb/per-role session rules).

6. Immediate next work

  • Add regression tests for prompt-overlay behavior and running-phase hotkeys.
  • Expand integration coverage for NIP-04/NIP-44 edge cases and negative-path errors.
  • Document and test static artifact size budgets across targets.
  • Define MCU transport adapter contract to prepare desktop/firmware parity.

7. Transport expansion roadmap

Goal: keep one signer core, swap transports underneath without touching dispatcher, policy, or role layers. The wire contract in CLIENT_IMPLEMENTATION.md (4-byte length-prefixed JSON-RPC) stays identical across every transport; only listener and caller_identity_t change.

7.0 Prerequisite — transport abstraction (Phase T0)

Before adding any new transport, factor a small adapter contract out of src/server.c and src/main.c.

  • New header src/transport.h declaring an opaque nsigner_transport_t with:
    • accept(listener) -> connection
    • recv_frame(connection) -> bytes
    • send_frame(connection, bytes)
    • peer_identity(connection) -> caller_identity_t
    • close(connection) / shutdown(listener)
  • Generalize caller_identity_t to a tagged union of:
    • unix_peer { uid, pid, comm } (current behavior)
    • qubes { source_qube_name }
    • tcp_local { addr }
    • tcp_remote { addr, authenticated_pubkey }
    • fips { peer_npub }
    • usb_serial { device_path, asserted_caller }
  • Move recv_framed / send_framed from server.c and main.c into a single shared transport_frame.c so client and server share one framing implementation.
  • Server main loop becomes transport-agnostic (while accept; recv; dispatch; send).
  • Tests: extend tests/test_integration.c with a transport-loopback fake to validate the abstraction without binding any real socket.

This refactor is purely internal — no observable change.

7.1 Phase T1 — Qubes OS qrexec transport

Use Qubes' native inter-qube primitive instead of inventing one.

  • Add a qrexec service script (e.g. qubes.NsignerRpc) that execs nsigner in a "stdio transport" mode where stdin/stdout carry the existing length-prefixed frame protocol.
  • New CLI: nsigner --listen stdio (and nsigner --listen qrexec, behaving identically; qrexec value is for documentation/intent).
  • Caller identity comes from qrexec environment (QREXEC_REMOTE_DOMAIN) and is mapped to caller_identity_t.kind=qubes.
  • Reference policy file under packaging/qubes/policy.d/40-nsigner.policy showing ask / allow per source qube.
  • No new attack surface inside nsigner: dom0 enforces who can even invoke the service.
  • Tests: a unit test that injects fake qrexec env vars and a stdio framing harness; an integration script that documents end-to-end install in a Qubes VM (manual, not in CI).
  • Docs: add a "Qubes deployment" section to README.md and to CLIENT_IMPLEMENTATION.md.

7.2 Phase T2 — TCP loopback transport

Smallest IP-based step; on-ramp for non-Linux clients and for FIPS later.

  • New CLI: nsigner --listen tcp:127.0.0.1:PORT.
  • Default-deny non-loopback binds (reject 0.0.0.0 / non-127.x / non-::1 unless --allow-remote, see T3).
  • Caller identity for loopback: tcp_local { addr }. Approval prompt still mandatory.
  • nsigner list extended to enumerate active TCP listeners (from internal registry; not from /proc/net/tcp).
  • Same framing as AF_UNIX path; no protocol changes.
  • Tests: integration coverage that spawns a child signer with --listen tcp:127.0.0.1:0, captures the bound port, runs the same NIP-04/NIP-44/sign_event matrix as AF_UNIX.
  • Docs: extend documents/CLIENT_IMPLEMENTATION.md section 2 with tcp: discovery rules and section 3 confirming framing parity.

Implementation checklist (Tier-1 delivery):

  • Parse --listen tcp:HOST:PORT in src/main.c.
  • Reject invalid/non-loopback listen targets in src/server.c.
  • Bind/listen non-blocking TCP sockets and run server loop without TUI dependence.
  • Keep existing framed JSON-RPC protocol unchanged via shared src/transport_frame.c.
  • Add integration test coverage for tcp:127.0.0.1:PORT request flow.

7.3 Phase T3 — TCP remote with TLS + caller-pubkey auth

Only after T2 is solid.

  • New CLI: nsigner --listen tcp:0.0.0.0:PORT --allow-remote --tls-cert <pem> --tls-key <pem>.
  • Mandatory: TLS for any non-loopback bind. Refuse to start otherwise.
  • Caller authentication: client must sign a per-connection challenge with its declared npub (Schnorr/secp256k1) before any signer verb is dispatched. Identity becomes tcp_remote { addr, authenticated_pubkey }.
  • Failure modes: transport_tls_required, caller_auth_failed, caller_auth_timeout — all surfaced with new error names in dispatcher and documented in CLIENT_IMPLEMENTATION.md.
  • Approval prompt now displays caller=npub:abcd…wxyz instead of uid:1000.
  • Tests: integration test that exercises happy path, wrong-pubkey, replayed-challenge, expired-challenge.
  • Docs: dedicated "Remote TCP deployment" section in README.md with strong "do not expose to the public internet without firewalling" warning.

7.4 Phase T4 — FIPS substrate integration

FIPS is a substrate for an existing TCP listener, not a new transport in nsigner code.

  • Deployment topology: nsigner binds TCP loopback inside the FIPS network namespace (or on a host where fips0 is up); peers reach it via fd00::/8 IPv6 derived from the signer's npub.
  • Optional caller_kind=fips enrichment: a small sidecar query (fipsctl show sessions style) maps the connecting IPv6 address to a peer npub and feeds it into caller_identity_t.fips { peer_npub }. If unavailable, fall back to tcp_remote identity.
  • nsigner does not embed FIPS, does not depend on libfips, and does not require Rust.
  • New optional flag: --peer-id-source fips:/var/run/fips/fips.sock (path/method TBD per FIPS API).
  • Tests: a Docker-compose fixture borrowed from resources/fips/testing/ that boots two FIPS nodes, runs nsigner on one, runs a Python client (per snippet in documents/CLIENT_IMPLEMENTATION.md) on the other, and exercises the same verb matrix.
  • Docs: new documents/FIPS_DEPLOYMENT.md deep-dive describing identity mapping, npub-as-caller, and operator setup. Cross-link from README.md section 7 (Transport).

Execution tasks for initial FIPS trial:

  • Deliver T2 TCP loopback listener as FIPS substrate prerequisite.
  • Document signer/caller qube deployment flow in documents/FIPS_DEPLOYMENT.md.
  • Add two-node operator validation script (manual) using fipsctl + framed JSON-RPC client.
  • Evaluate optional caller identity enrichment from FIPS session metadata.

7.5 Phase T5 — USB / serial transport

Two distinct sub-tracks; do not conflate.

  • T5a (firmware-side, MCU): ESP32/USB-CDC. Already in the firmware/ track. Same dispatcher; transport adapter is UART read/write loop. caller_identity_t.kind=usb_serial with asserted_caller because the host claims the identity.
  • T5b (host-side optional): nsigner --listen serial:/dev/ttyACM0,baud=115200. Useful for desktop signer reachable by a USB-tethered client. Same frame protocol over the serial line. Marks identity as asserted (low trust) and forces approval prompt.
  • USB-as-Ethernet (gadget mode, RNDIS/ECM) is not a separate transport — it reduces to T2/T3.
  • Tests: loopback pty pair (openpty) for T5b unit/integration coverage; firmware-side covered in firmware track.

7.6 Cross-cutting concerns

Apply once per phase as needed:

  • Transport-aware approval prompt: clear visual indication of transport kind and identity (uid vs qube vs npub vs serial-asserted). No silent identity-source confusion.
  • Per-transport policy gates: deny-by-default for new identity kinds until operator explicitly enables them in policy.
  • Discovery (nsigner list) becomes per-transport pluggable (proc/net/unix today, internal registry for tcp, qrexec service announce for qubes, fips peer table for fips).
  • Audit logging: include transport kind and identity descriptor in every approval/decision record.
  • Error name parity: every new transport introduces only well-named errors (extend the table in CLIENT_IMPLEMENTATION.md section 5).

7.7 Decision points (open)

  • D1: Land T0 (refactor) before any transport, or in parallel with T1?
  • D2: Bundle T2 and T3 as one phase, or hard split (loopback-only first, then remote-with-TLS later)?
  • D3: T4 FIPS — embed an explicit caller_kind=fips path in nsigner now, or treat FIPS as plain TCP and revisit identity enrichment after a working deployment?
  • D4: T5b host-side serial — in scope for desktop nsigner, or strictly firmware track?
  • D5: Qubes packaging — ship packaging/qubes/ artifacts in this repo, or document only and let operators wire it up?