12 KiB
Full n_signer Transport Support in the Setup Wizard
Context
n_signer (upgraded to v0.1.5) now exposes four transports and a new
nostr_mine_event verb. nostr_core_lib (v0.6.6, the client lib didactyl links
against) already exposes all four transport factories and discovery helpers, but
didactyl's interactive setup wizard and config/main wiring only recognize
nsigner_unix and nsigner_tcp. The existing-agent recovery path still
requires an nsec and uses raw-key NIP-44, which defeats the point of remote
signer modes.
This plan closes those gaps so an operator can stand up a new agent or recover an existing one using any n_signer transport, with the nsec never entering the agent process.
Current State (verified)
nostr_core_lib/nostr_core/nostr_signer.hexposes:nostr_signer_nsigner_unix,nostr_signer_nsigner_tcp,nostr_signer_nsigner_serial,nostr_signer_nsigner_fdsnostr_signer_nsigner_set_auth(TCP auth envelope)
nostr_core_lib/nostr_core/nsigner_transport.hexposes:nsigner_transport_list_unix(enumerate@nsigner*abstract sockets)nsigner_transport_list_serial(enumerate/dev/ttyACM*)
nostr_core_lib/nostr_core/nostr_signer.croutes the four core verbs (get_public_key, sign_event, nip04/nip44 encrypt/decrypt) throughnostr_signer_tfor all backends.nostr_mine_eventis NOT wired.src/config.hsigner_config_thas fields formode,socket_name,role,timeout_ms,auth_privkey_hex,tcp_host,tcp_port,emergency_local_nsec. No serial device or fds fields.src/config.csigner_mode_is_known()/signer_mode_is_remote()only acceptlocal,nsigner_unix,nsigner_tcp.src/main.capply_signer_overrides()andconstruct_signer()only handlelocal,nsigner_unix,nsigner_tcp.src/setup_wizard.cnew_agent_identity_step()optionshardcodesnsigner_unix, prompts for socket/role/timeout, does a connectivity check, and offers no auto-discovery or TCP/serial/fds choice.src/setup_wizard.cexisting_agent_flow()requires an nsec and usesnostr_nip44_decrypt/nostr_nip44_encryptwith the raw private key for kind-30078 config recovery (Phase 3 item 13 DEFERRED notes).src/setup_wizard.cinstall_system_service_with_dedicated_user()buildskey_argsfornsigner_unixandnsigner_tcponly.
Architecture
flowchart TD
A[Wizard Main Menu] --> B{New or Existing?}
B -->|New| C[Identity Step]
B -->|Existing| D[Signer-or-nsec Step]
C --> E[Signer Transport Picker]
D --> E
E --> F{Transport}
F -->|Unix| G[List @nsigner sockets via nsigner_transport_list_unix]
F -->|TCP| H[Prompt host:port + optional auth privkey]
F -->|Serial| I[List /dev/ttyACM* via nsigner_transport_list_serial]
F -->|FDs| J[Prompt read_fd/write_fd]
F -->|Local nsec| K[Legacy raw-key path]
G --> L[Connectivity check via nostr_signer_get_public_key]
H --> L
I --> L
J --> L
K --> M[Derive keys from nsec]
L --> N[Populate cfg.signer + cfg.keys.public_key_hex]
M --> N
N --> O[Continue wizard flow]
Changes by File
1. src/config.h — extend signer_config_t
- Update the
modecomment to list all five modes:local | nsigner_unix | nsigner_tcp | nsigner_serial | nsigner_fds. - Add fields:
char serial_device[OW_MAX_URL_LEN]— path fornsigner_serial.int fds_read_fd,int fds_write_fd— fornsigner_fds(CLI-only; not persisted to genesis since fds are runtime-only).
- Add a new
OW_MAX_SIGNER_DEVICE_LENconstant (reuseOW_MAX_URL_LEN).
2. src/config.c — accept the new modes
signer_mode_is_known(): addnsigner_serial,nsigner_fds.signer_mode_is_remote(): addnsigner_serial,nsigner_fds.parse_signer_config(): parseserial_devicestring. Do NOT parsefds_read_fd/fds_write_fdfrom JSON (they are CLI-only); document this.- Update the error message string to list all five modes.
config_set_defaults()(signer block): leavemode="local", zero the new fields.
3. src/main.c — construct signer for all transports
signer_mode_is_remote_local(): addnsigner_serial,nsigner_fds.apply_signer_overrides():- Add
--signer-serial <device>CLI flag → sets modensigner_serialandcfg->signer.serial_device. - Add
--signer-fds <read_fd:write_fd>CLI flag → sets modensigner_fdsand parses the two fds. - Update the validation block and error message to list all five modes.
- Add
construct_signer():- Add
nsigner_serialbranch →nostr_signer_nsigner_serial( cfg->signer.serial_device, role, timeout_ms). - Add
nsigner_fdsbranch →nostr_signer_nsigner_fds( cfg->signer.fds_read_fd, cfg->signer.fds_write_fd, role, timeout_ms). - Update the "Unknown signer mode" fallback message.
- The existing connectivity-check + pubkey-discovery block already works for
all remote backends (it calls
nostr_signer_get_public_key), so no change needed there.
- Add
- Update
print_usage()help text to document--signer-serialand--signer-fds.
4. src/setup_wizard.c — new signer transport picker
Replace the hardcoded nsigner_unix block in new_agent_identity_step()
option s with a new helper prompt_signer_transport(cfg) that:
- Renders a sub-menu:
unix socket (auto-discover or named)tcp (host:port + optional auth privkey)serial USB device (auto-discover or path)fd pair (read_fd:write_fd — advanced)back
- For
u: callnsigner_transport_list_unix()to enumerate running n_signer abstract sockets; if any are found, list them numbered and let the operator pick one or type a custom name; if none found, prompt for a name (empty = auto-discover at boot). - For
t: prompthost:portand an optional 64-char hex auth privkey (echo-suppressed). Store intocfg->signer.tcp_host/tcp_port/ auth_privkey_hex. - For
s: callnsigner_transport_list_serial()to enumerate/dev/ttyACM*; list them and let the operator pick or type a custom path. Store intocfg->signer.serial_device. - For
f: promptread_fd:write_fd(advanced; mainly for qrexec-style deployments). Store intocfg->signer.fds_read_fd/fds_write_fd. - Common: prompt
role(defaultmain) andtimeout_ms(default15000). - Set
cfg->signer.modeto the chosen mode. - Connectivity check: construct an ephemeral
nostr_signer_t*via the matching factory, callnostr_signer_get_public_key()to populatecfg->keys.public_key_hex, thennostr_signer_free(). On failure, print the error and loop back to the transport picker. - Guard all nsigner_* branches with
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)and emit the existing "compiled without n_signer client support" warning otherwise.
Add a parallel entry point to existing_agent_flow() so the operator can
choose "recover via a running n_signer" instead of entering an nsec. See item 5.
5. src/setup_wizard.c — existing-agent recovery via signer verbs
Refactor existing_agent_flow():
- New first step: choose identity source:
nsec (legacy)sign with a running n_signer (callsprompt_signer_transport(cfg))
- When the signer path is chosen:
- The connectivity check populates
cfg->keys.public_key_hexfromnostr_signer_get_public_key(). - Keep the ephemeral signer handle alive for the recovery queries instead of freeing it immediately (pass it through to the recovery helpers).
- The connectivity check populates
- Replace the raw-key NIP-44 decrypt in
fetch_and_decrypt_self_config_wizard()with a signer-aware variant: when a signer is available, callnostr_signer_nip44_decrypt(signer, cfg->keys.public_key_hex, ciphertext, &plaintext). Fall back to the raw-key path only whensigner == NULL(local mode). The peer pubkey for self-encrypt is the agent's own pubkey. - Replace the raw-key NIP-44 encrypt in
publish_encrypted_self_config_wizard()the same way. - Remove/replace the Phase 3 item 13 DEFERRED comments at those two sites since the signer path is now implemented for the wizard.
- The
nostr_handler_init(&tmp)calls in the recovery helpers currently copy the config and re-derive keys; ensure they pick upcfg->signerso the handler uses the process signer for its own encrypt/decrypt. (Verifynostr_handleralready threads the signer through; if not, pass the ephemeral wizard signer via a newnostr_handler_set_signer()call for the duration of the recovery queries, then clear it beforecleanup().)
6. src/setup_wizard.c — systemd unit key_args for new modes
In install_system_service_with_dedicated_user(), extend the key_args
builder:
nsigner_serial:--signer nsigner_serial --signer-serial <device> --signer-role <role> --signer-timeout <ms>nsigner_fds: skip systemd install (fds are runtime-only and cannot be embedded in ExecStart). Print a clear message that fds mode is not installable as a systemd service and the operator must wire the fd-passing themselves (e.g. via a wrapper unit withSockets=or qrexec). Return non-fatal error so the wizard offers "boot now" instead.- Update the summary print block (
Signer: %s ...) to mention serial/fds.
7. src/main.c — --signer-serial / --signer-fds arg parsing
Already covered in item 3. Ensure argv parsing loop handles the two new
flags and their values, and that --signer-fds parses read:write with
atoi validation (both >= 0).
8. nostr_core_lib — wire nostr_mine_event (optional, stretch)
This is a library-side change. If we want didactyl to be able to mine PoW events through a remote signer:
- Add
int nostr_signer_mine_event(nostr_signer_t*, const cJSON* unsigned_event, int difficulty, int threads, int timeout_sec, cJSON** signed_event_out)tonostr_signer.h. - Implement the remote branch in
nostr_signer.ccallingnsigner_client_call(client, "nostr_mine_event", params, &result)with the options object{difficulty, threads, timeout_sec}. - Local branch: call the existing
nostr_mine_eventhelper innip013.c. - This is not required for the wizard task; include only if the operator wants PoW through n_signer. Recommend deferring to a follow-up.
9. Docs
- Update
docs/GENESIS.mdsigner block example to show all five modes and the newserial_devicefield. - Update
README.mdquick-start example #5 to mention TCP/serial variants. - Add a short section to
docs/CONTEXT.md(or a newdocs/SIGNER.md) describing the five modes, when to use each, and the security tradeoff (local keeps nsec in-process; remote modes keep it in n_signer).
Verification
make deps && make— must compile cleanly with-DNOSTR_ENABLE_NSIGNER_CLIENT=1.- Run
./didactyl --help— confirm--signer-serialand--signer-fdsappear. - Start a local n_signer on an abstract unix socket; run
./didactyl(interactive), choose New agent → sign with n_signer → unix → pick the discovered socket → confirm connectivity check prints the pubkey. - Repeat with
--signer-tcp 127.0.0.1:11111style flow via the wizard TCP option. - Existing-agent flow: with a running n_signer, choose Existing → sign with n_signer → confirm kind-30078 config is decrypted via the signer (no nsec entered). Verify the recovered LLM/admin fields appear.
- Negative: kill n_signer mid-wizard → connectivity check fails → wizard loops back to the transport picker.
nsigner_serial/nsigner_fdsmodes: validate config load via a genesis file withsigner.mode: "nsigner_serial"andserial_device: "/dev/ttyACM0"; confirmconstruct_signer()attempts the serial factory (will fail without hardware, but the mode must be accepted and reach the factory, not be rejected as "unknown signer mode").
Out of Scope
- Wiring
nostr_mine_eventinto didactyl call sites (only the lib hook in item 8, and only if explicitly requested). - Changing the
nostr_handlersigner threading model beyond what the wizard recovery path needs. - Windows support for abstract sockets / serial enumeration (Linux-only, as today).