# 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`](n_signer_client.c) — single-file C99 program. - New file: [`client/n_signer_client_README.md`](n_signer_client_README.md) — dedicated README just for this client (usage, verbs, pipe-to-nak recipes, build instructions). The existing [`client/README.md`](README.md) stays 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`](../examples/sign_event_client.c) and [`examples/get_public_key_client.c`](../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 args...] ``` Global options: | Flag | Default | Meaning | |---|---|---| | `--socket-name`, `-n ` | auto-discover | Abstract socket name without `@` | | `--timeout ` | `5000` | Transport timeout | | `--tcp ` | none | Use TCP transport (requires `--auth-privkey`) | | `--serial ` | none | Use USB CDC-ACM serial transport | | `--qrexec ` | none | Use Qubes qrexec transport | | `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP | | `--auth-label ` | none | Auth envelope label | Selector options (apply to `nostr_*` verbs; `--role` and `--path` are mutually exclusive): | Flag | Meaning | JSON emitted | |---|---|---| | `--role ` | Named path-role registered in the signer's wizard | `{"role":""}` | | `--path ` | Full BIP-44 derivation path | `{"role_path":""}` | | `--index ` | 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 ` | none | `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` | | `--index ` | `0` | Algorithm derivation index (substituted into the alg's path) | | `--scheme ` | `schnorr` | secp256k1 `sign`/`verify` only | | `--encoding ` | `base64` | OTP `encrypt`/`decrypt` only | | `--format ` | `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`](../src/main.c)). ## Verb surface (full) Per [`README.md`](../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 ` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext | | `nip04-decrypt ` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext | | `nip44-encrypt ` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext | | `nip44-decrypt ` | `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 ` | `sign` | hex bytes | structured JSON `{"signature":...,"algorithm":...,"key_id":...}` | | `verify ` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) | | `derive ` | `derive` | UTF-8 data (argv or stdin) | structured JSON `{"algorithm":...,"key_id":...,"digest":...}` | | `encapsulate ` | `encapsulate` | hex | structured JSON `{"ciphertext":...,"shared_secret":...}` | | `decapsulate ` | `decapsulate` | hex | structured JSON `{"shared_secret":...}` | | `derive-shared-secret ` | `derive_shared_secret` | hex | shared secret hex | | `encrypt ` | `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: ```bash 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: `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-privkey` 32-byte hex; calls `nsigner_client_set_auth` with `--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`](../Makefile): ```make 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 ```mermaid 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`](../plans/nip46_bunker_mode.md)).