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):
- New file:
client/n_signer_client.c— single-file C99 program. - New file:
client/n_signer_client_README.md— dedicated README just for this client (usage, verbs, pipe-to-nak recipes, build instructions). The existingclient/README.mdstays as-is (it documents the nostr_core_lib migration). - New Makefile target producing
build/n_signer_client.
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) fromnostr_core_lib/nostr_core/nsigner_transport.hnsigner_client_new/nsigner_client_freefromnostr_core_lib/nostr_core/nsigner_client.hnsigner_client_call(takes ownership ofparams)nsigner_client_set_authfor 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:
0on success, non-zero on transport/RPC error (usensigner_client_last_errorfor the message). Forverify, exit0= valid,1= invalid,2= error. sign-eventreads 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-encryptread plaintext from argv or stdin.nip04-decrypt/nip44-decryptread ciphertext from argv or stdin.sign/verify/encapsulate/decapsulate/derive-shared-secrettake hex from argv (binary payloads, not pipe-friendly text).derivetakes UTF-8 data from argv or stdin.encrypt/decrypttake their payload from argv or stdin (base64 by default per the server contract).callreads a JSONparamsarray 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 with2005 index_out_of_rangeif 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_selector1001).
Resolution order on the server: role → role_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:port→nsigner_transport_open_tcp(requires--auth-privkey32-byte hex; callsnsigner_client_set_authwith--auth-label).--serial /dev/ttyACM0→nsigner_transport_open_serial.--qrexec qube:service→nsigner_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
- 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 withid,pubkey,sig.- Pipe to
nak event/nak publishto 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.
- Optional bash script
tests/test_n_signer_client.shthat:- Spawns
nsigner --socket-name nsigner_test --listen unix --mnemonic-stdinwith a fixed test mnemonic. - Runs each verb and asserts on stdout shape.
- Tears down the server.
- Spawns
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
nsignerprocess; the client is just a thin wire caller. - No key storage, no mnemonic handling.
- No HTTP listener client (the
http_listeneris server-side; the client uses the framed transports). - No NIP-46 bunker mode (covered separately by
plans/nip46_bunker_mode.md).