Files
signer/plans/signer_client_plan.md
T

13 KiB

Plan: signer-client — Rust CLI for the nsigner daemon

Goal

A standalone Rust command-line client signer-client that connects to a running nsigner process over its framed transports (Unix abstract socket, TCP, serial, qrexec) and exposes the full JSON-RPC verb surface over stdin/stdout so that signed events can be piped directly into nak publish.

This is a Rust port of the C n_signer_client.c (~855 lines). It reuses the existing nsigner library crate for transport framing, socket discovery, and verb/error constants — the new code is the typed-verb client layer + CLI parsing + non-Unix transports.

Deliverable & placement

  • New binary target signer-client declared in Cargo.toml:
    [[bin]]
    name = "signer-client"
    path = "src/client/main.rs"
    
  • New module tree under src/client/:
  • New doc: src/client/README.md — usage, verbs, pipe-to-nak recipes (port of n_signer_client_README.md).
  • The existing client subcommand in src/main.rs stays as a thin raw-passthrough convenience; it is not removed.

What already exists (reuse, don't re-port)

Concern Existing Rust module Reuse
4-byte BE framing transport.rs send_framed / recv_framed yes, generic over Read/Write
Abstract Unix connect transport.rs connect_abstract_unix yes
Socket list / discover socket_name.rs list_sockets / discover_single_socket yes
Verb constants enforcement.rs VERB_* yes (import, don't redeclare)
RPC error codes error.rs RpcError constants yes (for interpreting server errors)
Auth envelope verify (server) auth_envelope.rs reference only — client needs the build side

What is new (the port)

  1. CLI parsing — clap derive structs mirroring the C argv loop in n_signer_client.c: global options, selector options, algorithm options, mine-event options, and a Verb enum.
  2. ClientTransport — enum wrapping the four connection types behind a unified send/recv interface (the C nsigner_transport_t vtable).
    • Unix: connect_abstract_unix (already in crate).
    • TCP: std::net::TcpStream + framed I/O (server already speaks framed JSON over TCP per server.rs).
    • Serial: std::fs::OpenOptions on /dev/ttyACM* + framed I/O over the file handle (matches C nsigner_transport_open_serial).
    • Qrexec: spawn qrexec-client-vm <qube> <service> via std::process, pipe framed JSON over its stdin/stdout (matches C nsigner_transport_open_qrexec).
  3. NsignerClient — low-level RPC caller: builds {"id","method","params"} JSON, sends framed, receives framed, splits result vs error, holds last_error. Mirrors C nsigner_client_t / nsigner_client_call.
  4. NsignerSigner — high-level typed-verb layer. Holds a NsignerClient plus the resolved selector (role + role_path) and auth state. One method per verb, each building the correct params array + options object and parsing the typed result. Mirrors C nostr_signer_t / nostr_signer_nsigner_from_client.
  5. Client-side auth envelope builder — for TCP/qrexec: construct a NIP-42 kind-22242 auth event from the --auth-privkey, sign it, and prepend it to the request frame. The server-side verifier in auth_envelope.rs defines the wire shape; the builder produces the matching shape.
  6. stdin/stdout contract — per-verb payload sourcing (argv-or-stdin) and single-line newline-terminated output, exactly as in the C client.

CLI shape

signer-client [global options] <verb> [verb args...]

Global options

Flag Default Meaning
--socket-name, -n <name> auto-discover Abstract socket name without @
--timeout <ms> 5000 Transport timeout
--tcp <host:port> none TCP transport (requires --auth-privkey)
--serial <device> none USB CDC-ACM serial transport
--qrexec <qube:service> none Qubes qrexec transport
--auth-privkey <32-byte hex> none Auth envelope privkey for TCP
--auth-label <text> none Auth envelope label

Selector options (nostr verbs)

Flag Meaning JSON emitted
--role <name> Named path-role {"role":"<name>"}
--path <path> Full BIP-44 derivation path {"role_path":"<path>"}

Algorithm options (algorithm verbs)

Flag Default Meaning
--algorithm, -a <alg> none secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp
--index <N> 0 Algorithm derivation index
--scheme <schnorr|ecdsa> schnorr secp256k1 sign/verify only
--encoding <ascii|binary> ascii OTP encrypt/decrypt only
--format <plain|structured> plain get-public-key output shape

Mine-event options

Flag Meaning
--difficulty <N> Target leading zero bits
--threads <N> Mining threads (default 1)
--timeout-sec <N> Mining timeout in seconds

Verb surface (full)

Mirrors n_signer_client.c dispatch and the verb table in enforcement.rs.

Utility

Verb stdout
list Running nsigner abstract sockets (one per line)

Metadata

Verb RPC method stdout
get-info get_info raw result JSON

Nostr verbs (role-based; require --role + --path)

Verb RPC method stdin/argv stdout
get-public-key nostr_get_public_key none pubkey hex (or structured JSON with --format structured)
sign-event nostr_sign_event event JSON argv or stdin signed event JSON
mine-event nostr_mine_event event JSON argv or stdin signed mined event JSON
nip04-encrypt <peer> nostr_nip04_encrypt plaintext argv or stdin ciphertext
nip04-decrypt <peer> nostr_nip04_decrypt ciphertext argv or stdin plaintext
nip44-encrypt <peer> nostr_nip44_encrypt plaintext argv or stdin ciphertext
nip44-decrypt <peer> nostr_nip44_decrypt ciphertext argv or stdin plaintext

Algorithm-based verbs (use --algorithm + --index)

Verb RPC method argv stdout
get-public-key get_public_key none structured JSON
sign <msg-hex> sign hex bytes structured JSON
verify <msg-hex> <sig-hex> verify hex bytes valid/invalid (exit 0/1)
derive <data> derive UTF-8 argv or stdin structured JSON
encapsulate <peer-pubkey-hex> encapsulate hex structured JSON
decapsulate <ciphertext-hex> decapsulate hex structured JSON
derive-shared-secret <peer-pubkey-hex> derive_shared_secret hex shared secret hex
encrypt <plaintext> encrypt plaintext argv or stdin ciphertext
decrypt <ciphertext> decrypt ciphertext argv or stdin plaintext

Generic escape hatch

Verb RPC method input stdout
call <method> <method> JSON params array stdin or argv raw result JSON

stdin/stdout contract (pipe-friendly)

  • All payload output → stdout, single line, newline-terminated.
  • All diagnostics → stderr.
  • Exit codes: 0 success, 1 invalid (verify only), 2 error.
  • sign-event, nip04-*, nip44-*, derive, encrypt, decrypt read payload from argv if present, else one stdin line.
  • sign, verify, encapsulate, decapsulate, derive-shared-secret take hex from argv only.
  • call reads JSON params array from stdin (one line) or argv.

Selector handling

The nostr_* verbs select a secp256k1 NIP-06 key via the options object (trailing element of params):

  • --role <name> --path <path> → {"role":"<name>","role_path":"<path>"} (both required for nostr verbs; client-side error if either missing).
  • Algorithm verbs: --algorithm + --index populate the options object instead; --scheme adds "scheme" for secp256k1 sign/verify; --encoding adds "encoding" for OTP encrypt/decrypt.
  • --index is only valid with --algorithm (client rejects otherwise).

Transport

  • Unix (default): connect_abstract_unix(name). Auto-discover via discover_single_socket() when no --socket-name and no explicit transport given; error if zero or >1 found.
  • TCP: TcpStream::connect((host, port)); requires --auth-privkey (32-byte hex). Auth envelope built client-side and prepended.
  • Serial: open /dev/ttyACM* via OpenOptions::read_write + framed I/O over the file.
  • Qrexec: spawn qrexec-client-vm <qube> <service>, pipe framed JSON over child stdin/stdout.
  • All four share the same send_framed/recv_framed after construction.

Auth envelope (client side)

For TCP (and qrexec when --auth-privkey is given):

  1. Derive secp256k1 keypair from the 32-byte --auth-privkey.
  2. Build a NIP-42 kind-22242 event with:
    • created_at = now
    • tags = [["challenge","<request-hash>"]] (or label tag from --auth-label)
    • content = the JSON-RPC request body (or its sha256, per server contract in auth_envelope.rs).
  3. Sign the event (Schnorr), serialize, and send as a framed preamble before the actual request frame.

The exact envelope shape is read from the server-side verifier auth_envelope.rs to guarantee wire compatibility.

Architecture

flowchart TD
  CLI[cli.rs<br/>clap parse] --> Main[main.rs]
  Main -->|open| Tr[transport.rs<br/>ClientTransport enum]
  Tr -->|unix| Unix[connect_abstract_unix]
  Tr -->|tcp| Tcp[TcpStream + auth.rs]
  Tr -->|serial| Serial[OpenOptions /dev/ttyACM]
  Tr -->|qrexec| Qrexec[spawn qrexec-client-vm]
  Tr --> Rpc[rpc.rs<br/>NsignerClient]
  Rpc --> Signer[signer.rs<br/>NsignerSigner typed verbs]
  Signer -->|build params| Disp[nsigner daemon<br/>dispatcher.rs]
  Disp -->|result/error| Signer
  Signer -->|one line| Stdout[stdout]

Implementation order (todos for Code mode)

  1. Add [[bin]] target to Cargo.toml; create src/client/ skeleton with mod declarations in src/client/main.rs.
  2. Implement cli.rs: clap Cli + Verb enum + all option structs + print_usage.
  3. Implement transport.rs: ClientTransport enum with open_unix, open_tcp, open_serial, open_qrexec; unified send/recv via the existing send_framed/recv_framed. Include parse_host_port and parse_qube_service helpers.
  4. Implement rpc.rs: NsignerClient struct holding the transport, call(method, params) -> Result<Value, String>, last_error, and framed send/recv using serde_json.
  5. Implement signer.rs: NsignerSigner with selector state + one method per verb (get_info, get_public_key, sign_event, mine_event, nip04/44 encrypt/decrypt, sign, verify, derive, encapsulate, decapsulate, derive_shared_secret, otp encrypt/decrypt). Each builds the params array + options object and parses the typed result.
  6. Implement auth.rs: client-side auth envelope builder (secp256k1 keypair from hex, kind-22242 event, sign, serialize) matching the server verifier shape.
  7. Implement main.rs: wire CLI → transport → client → signer → verb dispatch → stdout. Include stdin-line reader, hex helpers, exit codes (0/1/2), and the list / call verbs.
  8. Write src/client/README.md (port of n_signer_client_README.md).
  9. Smoke test: build, run signer-client list, get-info, --role main --path ... get-public-key, sign-event pipe-to-stdout, --algorithm ed25519 sign, verify valid/invalid exit codes.

Testing

  • Unit tests in rpc.rs / signer.rs: build-params correctness using serde_json::json! assertions (no socket needed).
  • Integration test tests/client_smoke.rs: spawn nsigner --mnemonic-stdin --listen unix --socket-name nsigner_test with a fixed test mnemonic in a thread, then run the client verbs against it and assert stdout shape + exit codes. Tear down the server.
  • Manual pipe-to-nak check for sign-event.

Out of scope

  • No TUI, no approval UI — the human attendant lives in the running nsigner process; the client is a thin wire caller.
  • No key storage, no mnemonic handling.
  • No HTTP-listener client (HTTP is a server-side listener mode; the client uses the framed transports).
  • No NIP-46 bunker mode.
  • No changes to the existing client subcommand in src/main.rs (kept as a raw-passthrough convenience).