diff --git a/plans/n_signer_client.md b/plans/n_signer_client.md new file mode 100644 index 0000000..eff048b --- /dev/null +++ b/plans/n_signer_client.md @@ -0,0 +1,179 @@ +# 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 + +- New file: [`client/n_signer_client.c`](../client/n_signer_client.c) — single-file C99 program. +- New Makefile target producing `build/n_signer_client`. +- Updated [`client/README.md`](../client/README.md) with usage and the pipe-to-nak recipe. + +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`](../resources/nostr_core_lib/nostr_core/nsigner_transport.h) (and optionally `_tcp`, `_serial`, `_qrexec`) +- [`nsigner_client_new`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) / [`nsigner_client_free`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) +- [`nsigner_client_call`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) (takes ownership of `params`) +- [`nsigner_client_set_auth`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) 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 `@` | +| `--role ` | none | Selector `{"role":""}` (Nostr verbs) | +| `--nostr-index ` | none | Selector `{"nostr_index":N}` (mutually exclusive with `--role`) | +| `--algorithm ` | none | Algorithm-based verbs: `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` | +| `--index ` | `0` | Algorithm key index | +| `--scheme ` | `schnorr` | secp256k1 sign/verify scheme | +| `--format ` | `plain` | `get-public-key` output shape | +| `--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 | + +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) + +### Nostr verbs (role-based; selector from `--role` / `--nostr-index`) + +| 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; `--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 | signature hex | +| `verify ` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) | +| `derive ` | `derive` | UTF-8 data (argv or stdin) | `{"algorithm":...,"key_id":...,"digest":...}` | +| `encapsulate ` | `encapsulate` | hex | `{"ciphertext":...,"shared_secret":...}` | +| `decapsulate ` | `decapsulate` | hex | `{"shared_secret":...}` | +| `derive-shared-secret ` | `derive_shared_secret` | hex | shared secret hex | + +### Generic escape hatch + +| Verb | RPC method | input | stdout | +|---|---|---|---| +| `call ` | `` | 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). +- `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 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. +- `call` reads a JSON `params` array from stdin (one line) or argv. + +## Selector handling + +- `--role ` → `{"role":""}` in the options object. +- `--nostr-index ` → `{"nostr_index":N}` (mutually exclusive with `--role`). +- Default: no selector (server picks default role `main`). +- For algorithm verbs, `--algorithm` and `--index` populate the options object + instead; `--scheme` adds `"scheme"` for secp256k1 sign/verify. + +## 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-public-key` → 64-hex pubkey. + - `echo '{"kind":1,"content":"hello","tags":[],"created_at":1}' | n_signer_client 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 sign 68656c6c6f` → 64-byte sig hex. +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`](nip46_bunker_mode.md)). diff --git a/src/main.c b/src/main.c index 585b57d..a89141e 100644 --- a/src/main.c +++ b/src/main.c @@ -800,8 +800,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 1 -#define NSIGNER_VERSION_PATCH 12 -#define NSIGNER_VERSION "v0.1.12" +#define NSIGNER_VERSION_PATCH 13 +#define NSIGNER_VERSION "v0.1.13" /* NSIGNER_HEADERLESS_DECLS_END */ @@ -2336,9 +2336,23 @@ static void prompt_named_path_roles(role_table_t *role_table) { int is_fixed = (range_lo < 0); /* fixed path, no variable segment */ if (!is_fixed) { - /* Default index — only for templated paths */ + /* Default index — only for templated paths. + * Default to 0 if it's in the allowed range/set, otherwise range_lo. */ + int suggested_default; + if (allowed_count > 0) { + /* Set form: check if 0 is in the set */ + int j, has_zero = 0; + for (j = 0; j < allowed_count; j++) { + if (allowed_indices[j] == 0) { has_zero = 1; break; } + } + suggested_default = has_zero ? 0 : range_lo; + } else { + /* Range form: 0 is valid if range_lo <= 0 <= range_hi */ + suggested_default = (range_lo <= 0 && range_hi >= 0) ? 0 : range_lo; + } + char default_idx_str[16]; - printf(" Default index [%d]: ", range_lo); + printf(" Default index [%d]: ", suggested_default); fflush(stdout); if (read_line_stdin(default_idx_str, sizeof(default_idx_str)) != 0) return; { @@ -2349,14 +2363,14 @@ static void prompt_named_path_roles(role_table_t *role_table) { } } if (default_idx_str[0] == '\0') { - default_index = range_lo; + default_index = suggested_default; } else { char *endp = NULL; long di = strtol(default_idx_str, &endp, 10); if (*endp != '\0' || di < range_lo || di > range_hi) { printf(" Default index out of range [%d-%d], using %d.\n", - range_lo, range_hi, range_lo); - default_index = range_lo; + range_lo, range_hi, suggested_default); + default_index = suggested_default; } else { default_index = (int)di; }