231 lines
11 KiB
Markdown
231 lines
11 KiB
Markdown
# 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> [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`](../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 <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:
|
|
|
|
```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)).
|