18 KiB
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.candsrc/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(defaultE). - New module function
mnemonic_generate(int word_count, char *out, size_t out_len)insrc/mnemonic.c:getrandom(2)for entropy (16 bytes for 12 words, 32 bytes for 24).- Compute SHA-256 of entropy → first
entropy_bits / 32checksum 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_validatefunction (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, formatsnsigner_<w1>_<w2>.
- BIP-39 English wordlist
- Use the wordlist exposed by
nostr_core_lib(no vendoredbip39_english.cin this repo).
- Use the wordlist exposed by
src/server.cbind logic- Accept the resolved name from caller.
- Bind once. On
EADDRINUSEand no--socket-nameoverride, regenerate a fresh random name and retry up to 8 times. With override, fail immediately and report the override conflict.
src/main.cintegration- 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.
- Resolve name precedence:
nsigner listsubcommand- 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.
- Reads
- 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_runso 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-nameis unchanged; it remains the deterministic override for scripts and tests.
4. Decisions log
2026-05-02 — Mnemonic generation at startup
- Startup offers
[E]nteror[G]eneratechoice; default isE. - Generated mnemonic uses
getrandom(2)+ BIP-39 English wordlist, default 12 words. - Mnemonic is displayed exactly once with a "write this down" warning.
- No confirmation step — user is trusted to copy the words; we proceed straight to session use.
- 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
- Multiple
nsignerinstances can run concurrently on one host. - Default socket name is
@nsigner_<bip39_word1>_<bip39_word2>chosen randomly at each launch. --socket-name <name>overrides the random pick for tests and scripted usage (aliases:--name,-n).nsigner listenumerates running signers via/proc/net/unixfiltered onnsigner_prefix.- 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
- Single foreground process replaces daemon + TUI + client multi-binary model
- No config files — all state entered at runtime, lives in RAM only
- Abstract namespace sockets replace filesystem sockets
- Control socket eliminated — TUI is in-process
- Policy simplified to same-uid default + interactive approval
- Crash = total wipe is a security feature, not a limitation
- Same core modules target both Linux desktop and ESP32 MCU
2026-05-02 — Earlier decisions (retained)
role_pathmust be pre-registered (no ad-hoc derivation)nostr_indexnaming (notrole_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.hdeclaring an opaquensigner_transport_twith:accept(listener) -> connectionrecv_frame(connection) -> bytessend_frame(connection, bytes)peer_identity(connection) -> caller_identity_tclose(connection)/shutdown(listener)
- Generalize
caller_identity_tto 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_framedfromserver.candmain.cinto a single sharedtransport_frame.cso client and server share one framing implementation. - Server main loop becomes transport-agnostic (
while accept; recv; dispatch; send). - Tests: extend
tests/test_integration.cwith 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 execsnsignerin a "stdio transport" mode where stdin/stdout carry the existing length-prefixed frame protocol. - New CLI:
nsigner --listen stdio(andnsigner --listen qrexec, behaving identically;qrexecvalue is for documentation/intent). - Caller identity comes from qrexec environment (
QREXEC_REMOTE_DOMAIN) and is mapped tocaller_identity_t.kind=qubes. - Reference policy file under
packaging/qubes/policy.d/40-nsigner.policyshowingask/allowper 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.mdand toCLIENT_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-::1unless--allow-remote, see T3). - Caller identity for loopback:
tcp_local { addr }. Approval prompt still mandatory. nsigner listextended 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.mdsection 2 withtcp:discovery rules and section 3 confirming framing parity.
Implementation checklist (Tier-1 delivery):
- Parse
--listen tcp:HOST:PORTinsrc/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:PORTrequest 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 inCLIENT_IMPLEMENTATION.md. - Approval prompt now displays
caller=npub:abcd…wxyzinstead ofuid:1000. - Tests: integration test that exercises happy path, wrong-pubkey, replayed-challenge, expired-challenge.
- Docs: dedicated "Remote TCP deployment" section in
README.mdwith 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
fips0is up); peers reach it viafd00::/8IPv6 derived from the signer's npub. - Optional
caller_kind=fipsenrichment: a small sidecar query (fipsctl show sessionsstyle) maps the connecting IPv6 address to a peer npub and feeds it intocaller_identity_t.fips { peer_npub }. If unavailable, fall back totcp_remoteidentity. - 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 indocuments/CLIENT_IMPLEMENTATION.md) on the other, and exercises the same verb matrix. - Docs: new
documents/FIPS_DEPLOYMENT.mddeep-dive describing identity mapping, npub-as-caller, and operator setup. Cross-link fromREADME.mdsection 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_serialwithasserted_callerbecause 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.mdsection 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=fipspath 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?