Plan: signer-client — Rust CLI for the signer daemon
Goal
A standalone Rust command-line client signer-client that connects to a running
signer 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 signer 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:
- 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)
- 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.
ClientTransport — enum wrapping the four connection types behind a
unified send/recv interface (the C signer_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 signer_transport_open_serial).
- Qrexec: spawn
qrexec-client-vm <qube> <service> via std::process,
pipe framed JSON over its stdin/stdout (matches C
signer_transport_open_qrexec).
SignerClient — low-level RPC caller: builds {"id","method","params"}
JSON, sends framed, receives framed, splits result vs error, holds
last_error. Mirrors C signer_client_t / signer_client_call.
SignerSigner — high-level typed-verb layer. Holds a SignerClient
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_signer_from_client.
- 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.
- stdin/stdout contract — per-verb payload sourcing (argv-or-stdin) and
single-line newline-terminated output, exactly as in the C client.
CLI shape
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 signer 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):
- Derive secp256k1 keypair from the 32-byte
--auth-privkey.
- 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).
- 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
Implementation order (todos for Code mode)
- Add
[[bin]] target to Cargo.toml; create src/client/ skeleton with
mod declarations in src/client/main.rs.
- Implement
cli.rs: clap Cli + Verb enum + all option structs +
print_usage.
- 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.
- Implement
rpc.rs: SignerClient struct holding the transport,
call(method, params) -> Result<Value, String>, last_error, and
framed send/recv using serde_json.
- Implement
signer.rs: SignerSigner 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.
- Implement
auth.rs: client-side auth envelope builder (secp256k1
keypair from hex, kind-22242 event, sign, serialize) matching the server
verifier shape.
- 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.
- Write
src/client/README.md (port of n_signer_client_README.md).
- 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 signer --mnemonic-stdin --listen unix --socket-name signer_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
signer 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).