Files
n_signer/client/n_signer_client_PLAN.md

11 KiB

Plan: n_signer_client — Linux CLI for n_signer

Goal

A standalone Linux command-line client n_signer_client that connects to a running n_signer process over its abstract UNIX socket (and optionally the other framed transports) and exposes the full verb surface over stdin/stdout so that signed events can be piped directly into nak publish.

Deliverable & placement

The project lives in client/ alongside the existing demo clients (demo_c99.c, demo_javascript.js, demo_python.py):

The binary links nostr_core_lib exactly like the existing examples examples/sign_event_client.c and examples/get_public_key_client.c. It uses:

  • nsigner_transport_open_unix (and optionally _tcp, _serial, _qrexec) from nostr_core_lib/nostr_core/nsigner_transport.h
  • nsigner_client_new / nsigner_client_free from nostr_core_lib/nostr_core/nsigner_client.h
  • nsigner_client_call (takes ownership of params)
  • nsigner_client_set_auth for TCP mode

CLI shape

n_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 Use TCP transport (requires --auth-privkey)
--serial <device> none Use USB CDC-ACM serial transport
--qrexec <qube:service> none Use Qubes qrexec transport
--auth-privkey <32-byte hex> none Auth envelope privkey for TCP
--auth-label <text> none Auth envelope label

Selector options (apply to nostr_* verbs; --role and --path are mutually exclusive):

Flag Meaning JSON emitted
--role <name> Named path-role registered in the signer's wizard {"role":"<name>"}
--path <full-path> Full BIP-44 derivation path {"role_path":"<full-path>"}
--index <N> Optional variable-segment index for a named path-role (only valid with --role) adds "index":N to the role object

Algorithm options (apply to algorithm-based verbs):

Flag Default Meaning
--algorithm <alg> none secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp
--index <N> 0 Algorithm derivation index (substituted into the alg's path)
--scheme <schnorr|ecdsa> schnorr secp256k1 sign/verify only
--encoding <base64|hex> base64 OTP encrypt/decrypt only
--format <plain|structured> plain nostr_get_public_key output shape

Note on --index overload: when --algorithm is set, --index is the algorithm derivation index. When --role is set (and no --algorithm), --index is the named path-role's variable-segment index. These two contexts never overlap because algorithm verbs and nostr_* verbs are distinct.

Auto-discovery: when no --socket-name and no explicit transport is given, enumerate via nsigner_transport_list_unix and proceed only if exactly one nsigner* socket exists (mirror discover_single_socket_name in src/main.c).

Verb surface (full)

Per README.md §4.3 verb table. The options object is always the trailing element of the params array.

Metadata

Verb RPC method stdout
get-info get_info raw result JSON (name, version, verbs, algorithms)

Nostr verbs (role-based; selector from --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 from argv or one stdin line signed event JSON, one line
mine-event nostr_mine_event event JSON from argv or stdin; options --difficulty, --threads, --timeout-sec signed mined event JSON
nip04-encrypt <peer-pubkey> nostr_nip04_encrypt plaintext from argv or stdin ciphertext
nip04-decrypt <peer-pubkey> nostr_nip04_decrypt ciphertext from argv or stdin plaintext
nip44-encrypt <peer-pubkey> nostr_nip44_encrypt plaintext from argv or stdin ciphertext
nip44-decrypt <peer-pubkey> nostr_nip44_decrypt ciphertext from argv or stdin plaintext

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

Verb RPC method argv stdout
get-public-key get_public_key none structured JSON {"algorithm":...,"public_key":...,"key_id":...}
sign <msg-hex> sign hex bytes structured JSON {"signature":...,"algorithm":...,"key_id":...}
verify <msg-hex> <sig-hex> verify hex bytes valid / invalid (exit 0/1)
derive <data> derive UTF-8 data (argv or stdin) structured JSON {"algorithm":...,"key_id":...,"digest":...}
encapsulate <peer-pubkey-hex> encapsulate hex structured JSON {"ciphertext":...,"shared_secret":...}
decapsulate <ciphertext-hex> decapsulate hex structured JSON {"shared_secret":...}
derive-shared-secret <peer-pubkey-hex> derive_shared_secret hex shared secret hex
encrypt <plaintext> encrypt plaintext (base64 by default; --encoding hex) ciphertext
decrypt <ciphertext> decrypt ciphertext plaintext

Generic escape hatch

Verb RPC method input stdout
call <method> <method> JSON params array from stdin (one line) or argv raw result JSON

This keeps the client future-proof for any new server verb without a CLI rewrite.

stdin/stdout contract (pipe-friendly)

  • All payload output goes to stdout as a single line, newline-terminated.
  • All diagnostics go to stderr.
  • Exit code: 0 on success, non-zero on transport/RPC error (use nsigner_client_last_error for the message). For verify, exit 0 = valid, 1 = invalid, 2 = error.
  • sign-event reads event JSON from argv if present, else reads exactly one line from stdin. This is the pipe-to-nak path:
echo '{"kind":1,"content":"hello","tags":[],"created_at":1700000000}' \
  | n_signer_client --role main sign-event \
  | nak publish
  • nip04-encrypt / nip44-encrypt read plaintext from argv or stdin.
  • nip04-decrypt / nip44-decrypt read ciphertext from argv or stdin.
  • sign / verify / encapsulate / decapsulate / derive-shared-secret take hex from argv (binary payloads, not pipe-friendly text).
  • derive takes UTF-8 data from argv or stdin.
  • encrypt / decrypt take their payload from argv or stdin (base64 by default per the server contract).
  • call reads a JSON params array from stdin (one line) or argv.

Selector handling (per README §4.6)

The nostr_* verbs select a secp256k1 NIP-06 key via the options object. The client builds the options object from the selector flags:

  • --role <name>{"role":"<name>"} (named path-role; the derivation path is hidden from the client by the signer).
  • --role <name> --index <N>{"role":"<name>","index":N} (named path-role with variable-segment index; rejected with 2005 index_out_of_range if out of the role's range).
  • --path <full-path>{"role_path":"<full-path>"} (raw BIP-44 path; must match a registered role's path template or be explicitly allowed).
  • Default (no selector): server uses the default role main.
  • Conflicting selectors → client-side error (do not send; the server would reject with ambiguous_role_selector 1001).

Resolution order on the server: rolerole_path → default main. The client enforces mutual exclusivity of the selector flags before sending.

For algorithm verbs, --algorithm and --index populate the options object instead; --scheme adds "scheme" for secp256k1 sign/verify; --encoding adds "encoding" for OTP encrypt/decrypt.

Transport

  • Default: UNIX abstract socket via nsigner_transport_open_unix(name, timeout_ms).
  • --tcp host:portnsigner_transport_open_tcp (requires --auth-privkey 32-byte hex; calls nsigner_client_set_auth with --auth-label).
  • --serial /dev/ttyACM0nsigner_transport_open_serial.
  • --qrexec qube:servicensigner_transport_open_qrexec.
  • The vtable is uniform so all four transports share the same call path after construction.

Build

Add to Makefile:

N_SIGNER_CLIENT_TARGET := $(BUILD_DIR)/n_signer_client

clients: $(N_SIGNER_CLIENT_TARGET)

$(N_SIGNER_CLIENT_TARGET): $(CLIENT_DIR)/n_signer_client.c
	@mkdir -p $(BUILD_DIR)
	$(CC) $(CFLAGS) $(CLIENT_DIR)/n_signer_client.c -o $(N_SIGNER_CLIENT_TARGET) $(LDFLAGS)

Add clients to the all aggregate and to the test-client target so it is built alongside the examples.

Testing

  1. Manual smoke test against a running nsigner:
    • n_signer_client get-info → signer metadata JSON.
    • n_signer_client --role main get-public-key → 64-hex pubkey.
    • echo '{"kind":1,"content":"hello","tags":[],"created_at":1}' | n_signer_client --role main sign-event → signed event with id, pubkey, sig.
    • Pipe to nak event / nak publish to verify the signed event is well-formed.
    • n_signer_client --algorithm ed25519 --index 0 sign 68656c6c6f → structured sig JSON.
    • n_signer_client --role myrole get-public-key → pubkey for the named path-role.
  2. Optional bash script tests/test_n_signer_client.sh that:
    • Spawns nsigner --socket-name nsigner_test --listen unix --mnemonic-stdin with a fixed test mnemonic.
    • Runs each verb and asserts on stdout shape.
    • Tears down the server.

Mermaid flow

flowchart LR
  A[stdin or argv event JSON] --> B[n_signer_client sign-event]
  B --> C[nsigner_transport_open_unix]
  C --> D[nsigner_client_call nostr_sign_event]
  D --> E[nsigner @nsigner socket]
  E --> F[signed event JSON result]
  F --> G[stdout one line]
  G --> H[nak publish]

Out of scope

  • No TUI, no approval UI — the human attendant lives in the running nsigner process; the client is just a thin wire caller.
  • No key storage, no mnemonic handling.
  • No HTTP listener client (the http_listener is server-side; the client uses the framed transports).
  • No NIP-46 bunker mode (covered separately by plans/nip46_bunker_mode.md).