Files
n_signer/plans/nsigner.md

293 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# nsigner Implementation Plan
This document is the implementation roadmap for `n_signer`.
Authoritative behavior documentation: [README.md](../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`](../src/mnemonic.c:1):
- `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`](../tests/test_mnemonic.c:1):
- `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](../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`](../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`](../src/server.c) and [`src/main.c`](../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`](../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`](../README.md) and to [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md).
### 7.2 Phase T2 — TCP transport
Smallest IP-based step; on-ramp for non-Linux clients and for FIPS later.
- New CLI: `nsigner --listen tcp:HOST:PORT` (IPv4 literal) or `nsigner --listen tcp:[IPv6]:PORT`.
- Current behavior: accepts operator-selected local/remote bind addresses (including `[::]` and `fd..`), pending later transport hardening.
- Caller identity for TCP: endpoint address/port in caller descriptor. 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` (and one IPv6 case), captures the bound port, runs the same NIP-04/NIP-44/sign_event matrix as AF_UNIX.
- Docs: extend [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) section 2 with `tcp:` discovery rules and section 3 confirming framing parity.
Implementation checklist (Tier-1 delivery):
- [x] Parse `--listen tcp:HOST:PORT` in [`src/main.c`](../src/main.c).
- [x] Parse and validate literal IPv4/IPv6 listen targets in [`src/server.c`](../src/server.c).
- [x] Bind/listen non-blocking TCP sockets and run server loop without TUI dependence.
- [x] Keep existing framed JSON-RPC protocol unchanged via shared [`src/transport_frame.c`](../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`](../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 a chosen TCP endpoint 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`](../documents/CLIENT_IMPLEMENTATION.md)) on the other, and exercises the same verb matrix.
- Docs: new [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md) deep-dive describing identity mapping, npub-as-caller, and operator setup. Cross-link from [`README.md`](../README.md) section 7 (Transport).
Execution tasks for initial FIPS trial:
- [x] Deliver T2 TCP listener as FIPS substrate prerequisite.
- [x] Document signer/caller qube deployment flow in [`documents/FIPS_DEPLOYMENT.md`](../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/`](../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`](../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?