v0.1.13 - Change default index to 0 in wizard (when 0 is in range/set)
This commit is contained in:
@@ -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> [verb args...]
|
||||
```
|
||||
|
||||
Global options:
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
|
||||
| `--role <name>` | none | Selector `{"role":"<name>"}` (Nostr verbs) |
|
||||
| `--nostr-index <N>` | none | Selector `{"nostr_index":N}` (mutually exclusive with `--role`) |
|
||||
| `--algorithm <alg>` | none | Algorithm-based verbs: `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
|
||||
| `--index <N>` | `0` | Algorithm key index |
|
||||
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 sign/verify scheme |
|
||||
| `--format <plain\|structured>` | `plain` | `get-public-key` output shape |
|
||||
| `--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 |
|
||||
|
||||
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 <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 | signature hex |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | `{"algorithm":...,"key_id":...,"digest":...}` |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | `{"ciphertext":...,"shared_secret":...}` |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | `{"shared_secret":...}` |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
|
||||
### 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).
|
||||
- `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 <name>` → `{"role":"<name>"}` in the options object.
|
||||
- `--nostr-index <N>` → `{"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)).
|
||||
+21
-7
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user