Files
didactyl/plans/wizard_full_nsigner_transports.md
T

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.h exposes:
    • nostr_signer_nsigner_unix, nostr_signer_nsigner_tcp, nostr_signer_nsigner_serial, nostr_signer_nsigner_fds
    • nostr_signer_nsigner_set_auth (TCP auth envelope)
  • nostr_core_lib/nostr_core/nsigner_transport.h exposes:
    • nsigner_transport_list_unix (enumerate @nsigner* abstract sockets)
    • nsigner_transport_list_serial (enumerate /dev/ttyACM*)
  • nostr_core_lib/nostr_core/nostr_signer.c routes the four core verbs (get_public_key, sign_event, nip04/nip44 encrypt/decrypt) through nostr_signer_t for all backends. nostr_mine_event is NOT wired.
  • src/config.h signer_config_t has fields for mode, socket_name, role, timeout_ms, auth_privkey_hex, tcp_host, tcp_port, emergency_local_nsec. No serial device or fds fields.
  • src/config.c signer_mode_is_known() / signer_mode_is_remote() only accept local, nsigner_unix, nsigner_tcp.
  • src/main.c apply_signer_overrides() and construct_signer() only handle local, nsigner_unix, nsigner_tcp.
  • src/setup_wizard.c new_agent_identity_step() option s hardcodes nsigner_unix, prompts for socket/role/timeout, does a connectivity check, and offers no auto-discovery or TCP/serial/fds choice.
  • src/setup_wizard.c existing_agent_flow() requires an nsec and uses nostr_nip44_decrypt/nostr_nip44_encrypt with the raw private key for kind-30078 config recovery (Phase 3 item 13 DEFERRED notes).
  • src/setup_wizard.c install_system_service_with_dedicated_user() builds key_args for nsigner_unix and nsigner_tcp only.

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 mode comment to list all five modes: local | nsigner_unix | nsigner_tcp | nsigner_serial | nsigner_fds.
  • Add fields:
    • char serial_device[OW_MAX_URL_LEN] — path for nsigner_serial.
    • int fds_read_fd, int fds_write_fd — for nsigner_fds (CLI-only; not persisted to genesis since fds are runtime-only).
  • Add a new OW_MAX_SIGNER_DEVICE_LEN constant (reuse OW_MAX_URL_LEN).

2. src/config.c — accept the new modes

  • signer_mode_is_known(): add nsigner_serial, nsigner_fds.
  • signer_mode_is_remote(): add nsigner_serial, nsigner_fds.
  • parse_signer_config(): parse serial_device string. Do NOT parse fds_read_fd/fds_write_fd from JSON (they are CLI-only); document this.
  • Update the error message string to list all five modes.
  • config_set_defaults() (signer block): leave mode="local", zero the new fields.

3. src/main.c — construct signer for all transports

  • signer_mode_is_remote_local(): add nsigner_serial, nsigner_fds.
  • apply_signer_overrides():
    • Add --signer-serial <device> CLI flag → sets mode nsigner_serial and cfg->signer.serial_device.
    • Add --signer-fds <read_fd:write_fd> CLI flag → sets mode nsigner_fds and parses the two fds.
    • Update the validation block and error message to list all five modes.
  • construct_signer():
    • Add nsigner_serial branch → nostr_signer_nsigner_serial( cfg->signer.serial_device, role, timeout_ms).
    • Add nsigner_fds branch → 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.
  • Update print_usage() help text to document --signer-serial and --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:

  1. Renders a sub-menu:
    • u nix socket (auto-discover or named)
    • t cp (host:port + optional auth privkey)
    • s erial USB device (auto-discover or path)
    • f d pair (read_fd:write_fd — advanced)
    • b ack
  2. For u: call nsigner_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).
  3. For t: prompt host:port and an optional 64-char hex auth privkey (echo-suppressed). Store into cfg->signer.tcp_host/tcp_port/ auth_privkey_hex.
  4. For s: call nsigner_transport_list_serial() to enumerate /dev/ttyACM*; list them and let the operator pick or type a custom path. Store into cfg->signer.serial_device.
  5. For f: prompt read_fd:write_fd (advanced; mainly for qrexec-style deployments). Store into cfg->signer.fds_read_fd/fds_write_fd.
  6. Common: prompt role (default main) and timeout_ms (default 15000).
  7. Set cfg->signer.mode to the chosen mode.
  8. Connectivity check: construct an ephemeral nostr_signer_t* via the matching factory, call nostr_signer_get_public_key() to populate cfg->keys.public_key_hex, then nostr_signer_free(). On failure, print the error and loop back to the transport picker.
  9. 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:
    • n sec (legacy)
    • s ign with a running n_signer (calls prompt_signer_transport(cfg))
  • When the signer path is chosen:
    • The connectivity check populates cfg->keys.public_key_hex from nostr_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).
  • Replace the raw-key NIP-44 decrypt in fetch_and_decrypt_self_config_wizard() with a signer-aware variant: when a signer is available, call nostr_signer_nip44_decrypt(signer, cfg->keys.public_key_hex, ciphertext, &plaintext). Fall back to the raw-key path only when signer == 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 up cfg->signer so the handler uses the process signer for its own encrypt/decrypt. (Verify nostr_handler already threads the signer through; if not, pass the ephemeral wizard signer via a new nostr_handler_set_signer() call for the duration of the recovery queries, then clear it before cleanup().)

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 with Sockets= 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) to nostr_signer.h.
  • Implement the remote branch in nostr_signer.c calling nsigner_client_call(client, "nostr_mine_event", params, &result) with the options object {difficulty, threads, timeout_sec}.
  • Local branch: call the existing nostr_mine_event helper in nip013.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.md signer block example to show all five modes and the new serial_device field.
  • Update README.md quick-start example #5 to mention TCP/serial variants.
  • Add a short section to docs/CONTEXT.md (or a new docs/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

  1. make deps && make — must compile cleanly with -DNOSTR_ENABLE_NSIGNER_CLIENT=1.
  2. Run ./didactyl --help — confirm --signer-serial and --signer-fds appear.
  3. 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.
  4. Repeat with --signer-tcp 127.0.0.1:11111 style flow via the wizard TCP option.
  5. 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.
  6. Negative: kill n_signer mid-wizard → connectivity check fails → wizard loops back to the transport picker.
  7. nsigner_serial / nsigner_fds modes: validate config load via a genesis file with signer.mode: "nsigner_serial" and serial_device: "/dev/ttyACM0"; confirm construct_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_event into didactyl call sites (only the lib hook in item 8, and only if explicitly requested).
  • Changing the nostr_handler signer threading model beyond what the wizard recovery path needs.
  • Windows support for abstract sockets / serial enumeration (Linux-only, as today).