# CLIENT_IMPLEMENTATION.md ## 1. Purpose This document is the client-integration spec for `nsigner`. It is written for agent/tool authors implementing robust request flows against the local signer process. Reference implementation in this repository: - Reusable C client library: `client/nsigner_client.h` + `client/nsigner_client.c` - Minimal usage examples: `examples/get_public_key_client.c` and `examples/sign_event_client.c` - Auth envelope builder used by the client: `src/auth_envelope.h` (`auth_envelope_build_for_request`) --- ## 2. Discovery and socket targeting `nsigner` currently supports three transport families: - Linux AF_UNIX **abstract namespace** sockets. - Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange. - TCP framed mode (`--listen tcp:IPv4:PORT` or `--listen tcp:[IPv6]:PORT`). For AF_UNIX: - Socket names are exposed in `/proc/net/unix` with a leading `@`. - Typical runtime names: `@nsigner_hairy_dog`, `@nsigner_brave_canyon`. - Clients pass the socket name **without** `@` to CLI flags (example: `nsigner_hairy_dog`). ### 2.1 Discovery rules Use one of these patterns: 1. Explicit target (recommended): pass `--socket-name` / `--name` / `-n`. 2. Enumerate with `nsigner list` and select one. 3. Auto-discovery only when exactly one signer is running. ### 2.2 Enumerating running signers ```bash nsigner list ``` Expected output format (one per line): ```text @nsigner @nsigner_hairy_dog @nsigner_brave_canyon ``` Clients should accept both `@nsigner` and `@nsigner_*` names. ### 2.3 Stdio / qrexec mode In server mode: - `nsigner --listen stdio`: reads exactly one framed request from stdin and writes one framed response to stdout. - `nsigner --listen qrexec`: same behavior, but caller identity may be tagged from `QREXEC_REMOTE_DOMAIN` as `qubes:`. This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX). ### 2.4 qrexec authentication posture (`--auth`) qrexec now supports configurable auth-envelope handling: - `--listen qrexec --auth off` (default): legacy behavior. Requests are authorized as `qubes:` only. - `--listen qrexec --auth optional`: if the request includes an `auth` field, it is verified using the same auth envelope rules as TCP. On success, caller identity is upgraded to `qubes:+pubkey:`. - `--listen qrexec --auth required`: every request must carry a valid `auth` envelope, and caller identity is `qubes:+pubkey:`. When `--auth optional` is used, a malformed or invalid `auth` object is rejected with auth-layer errors (`2010..2017`) rather than silently falling back to `qubes:`. ### 2.5 TCP mode authentication (required) For TCP transport, requests MUST include an `auth` object containing a signed Nostr-style event envelope. - Missing `auth` returns `{"error":{"code":2014,"message":"auth_envelope_required"}}`. - Signature verification, method/id/body binding, timestamp skew checks, and replay checks are enforced before policy lookup. - On success, caller identity is normalized to `pubkey:` for policy checks. --- ## 3. Transport framing Signer requests/responses use a length-prefixed frame format over the socket: - Prefix: 4-byte unsigned big-endian length `N` - Payload: `N` bytes UTF-8 JSON text - One JSON-RPC object per frame ### 3.1 Framing pseudocode Write: 1. Serialize JSON to bytes 2. Compute `len(payload)` 3. Send `uint32_be(length)` then payload bytes Read: 1. Read exactly 4 bytes 2. Parse big-endian payload length 3. Read exactly `length` bytes 4. Parse JSON Do not assume line-delimited JSON. --- ## 4. JSON-RPC contract ### 4.1 Request shape ```json { "id": "1", "method": "get_public_key", "params": [] } ``` Methods are NIP-46 style verbs. ### 4.2 Implemented methods - `get_public_key` - `sign_event` - `nip04_encrypt` - `nip04_decrypt` - `nip44_encrypt` - `nip44_decrypt` ### 4.2b Algorithm-based verbs (new) In addition to the role-based verbs above, the signer supports algorithm-based verbs where the caller specifies `algorithm` and `index` directly: - `sign` — sign arbitrary bytes (params: `[message_hex, {algorithm, index, scheme?}]`) - `verify` — verify a signature (params: `[message_hex, signature_hex, {algorithm, index, scheme?}]`) - `encapsulate` — KEM encapsulation (params: `[peer_pubkey_hex, {algorithm}]`) - `decapsulate` — KEM decapsulation (params: `[ciphertext_hex, {algorithm, index}]`) - `derive_shared_secret` — ECDH key agreement (params: `[peer_pubkey_hex, {algorithm, index}]`) - `derive` — `HMAC-SHA256(privkey, data)` key-derived MAC (params: `[data, {algorithm:"secp256k1", index}]`; `index` required). Returns `{algorithm, key_id, digest}` where `digest` is 64 hex chars. Use for deterministic opaque identifiers (e.g. NIP-33 `d` tags) keyed by the derived private key. - `get_public_key` with `algorithm` parameter — returns structured JSON Algorithm names: `secp256k1`, `ed25519`, `ml-dsa-65`, `slh-dsa-128s`, `x25519`, `ml-kem-768` For secp256k1 `sign`/`verify`, the optional `scheme` parameter selects `"schnorr"` (default, BIP-340) or `"ecdsa"`. Old verb aliases (`sign_data`, `ssh_sign`, `verify_signature`, `kem_encapsulate`, `kem_decapsulate`) map to the new verbs when used with the `algorithm` parameter. Without `algorithm`, they fall through to the role-based path. See [README.md §4c](../README.md) for full details. ### 4.3 Selector options The last param may include selector options: - `role` - `nostr_index` - `role_path` Resolution order: 1. `role` 2. `nostr_index` 3. `role_path` 4. default role `main` Conflicting selector fields must be rejected as `ambiguous_role_selector`. ### 4.4 Selector example ```json { "id": "2", "method": "sign_event", "params": [ "", { "role": "main" } ] } ``` --- ## 5. Error handling contract Representative error names clients must handle: - `invalid_request` - `method_not_found` - `ambiguous_role_selector` - `unknown_role` - `purpose_mismatch` - `curve_mismatch` - `unauthorized` - `approval_denied` - `internal_error` - `auth_envelope_malformed` (2010) - `auth_body_mismatch` (2011) - `auth_signature_invalid` (2012) - `auth_kind_invalid` (2013) - `auth_envelope_required` (2014) - `auth_envelope_mismatch` (2015) - `auth_envelope_stale` (2016) - `auth_replay_detected` (2017) ### 5.1 Recovery guidance - `invalid_request`: client bug or malformed payload; fix request and retry. - `method_not_found`: version mismatch; feature-detect and downgrade behavior. - `ambiguous_role_selector`: send exactly one selector strategy. - `unknown_role`: selector did not resolve; verify role inventory/config. - `purpose_mismatch` / `curve_mismatch`: selected key is incompatible with method; pick compatible selector. - `unauthorized`: caller identity disallowed by policy; do not blind-retry. - `approval_denied`: user rejected prompt; treat as final unless user initiates retry. - `internal_error`: bounded retry with backoff; surface diagnostics. - `auth_envelope_*` / `auth_*` (2010-2017): fix request signing/auth envelope generation; do not blind-retry unchanged payloads. --- ## 6. Approval semantics Approval has two layers: 1. Policy/identity checks (caller and method authorization) 2. User prompt (interactive allow/deny) Important behavior: - Passing identity checks does **not** bypass prompts. - Non-interactive/no-TTY contexts can resolve to deny by policy/test configuration. - Clients must treat `approval_denied` as a normal, expected outcome. ### 6.1 UX recommendation If a signing call is denied, return control to the user and let them explicitly retry. --- ## 7. Multi-instance, concurrency, and timeouts ### 7.1 Multi-instance safety - Multiple signers can coexist using different socket names. - Always pin requests to a selected signer name once chosen. - Avoid “discover per request” after initial bind in long-running clients. ### 7.2 Connection strategy - Keep one connection per in-flight request path, or serialize requests if your client runtime is simple. - Validate response `id` correlation before completing promise/future. ### 7.3 Timeout guidance Use separate timeouts: - connect timeout (short) - write timeout (short) - read timeout (longer, because human approval may be required) For prompt-requiring methods, use a read timeout that accounts for user interaction. --- ## 8. Security expectations for clients - Treat socket access as sensitive local capability. - Do not log plaintext secrets, private keys, or full decrypted payloads. - Redact request params for encrypt/decrypt methods in normal logs. - Validate method-level expectations before sending (selector + purpose compatibility). - Use least-privilege execution context for any process that can reach the signer socket. --- ## 9. Reference code snippets ## 9.1 C (frame write/read skeleton) ```c #include #include #include #include static int write_all(int fd, const void *buf, size_t len) { const unsigned char *p = (const unsigned char *)buf; while (len > 0) { ssize_t n = write(fd, p, len); if (n <= 0) return -1; p += (size_t)n; len -= (size_t)n; } return 0; } static int read_all(int fd, void *buf, size_t len) { unsigned char *p = (unsigned char *)buf; while (len > 0) { ssize_t n = read(fd, p, len); if (n <= 0) return -1; p += (size_t)n; len -= (size_t)n; } return 0; } int send_json_frame(int fd, const char *json) { uint32_t n = (uint32_t)strlen(json); uint32_t be = htonl(n); if (write_all(fd, &be, 4) != 0) return -1; if (write_all(fd, json, n) != 0) return -1; return 0; } ``` ## 9.2 Python (Unix abstract socket + frame) ```python import json import socket import struct def send_rpc(socket_name: str, obj: dict) -> dict: payload = json.dumps(obj, separators=(",", ":")).encode("utf-8") frame = struct.pack(">I", len(payload)) + payload s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: s.connect("\0" + socket_name) # abstract namespace s.sendall(frame) hdr = recv_exact(s, 4) ln = struct.unpack(">I", hdr)[0] body = recv_exact(s, ln) return json.loads(body.decode("utf-8")) finally: s.close() def recv_exact(s: socket.socket, n: int) -> bytes: out = bytearray() while len(out) < n: chunk = s.recv(n - len(out)) if not chunk: raise ConnectionError("unexpected EOF") out.extend(chunk) return bytes(out) ``` ## 9.3 TypeScript (Node.js net + frame) ```ts import net from "node:net"; export async function sendRpc(socketName: string, request: unknown): Promise { const payload = Buffer.from(JSON.stringify(request), "utf8"); const header = Buffer.alloc(4); header.writeUInt32BE(payload.length, 0); return await new Promise((resolve, reject) => { const socket = net.createConnection({ path: `\u0000${socketName}` }); let chunks: Buffer[] = []; let needed = 4; let mode: "header" | "body" = "header"; socket.on("connect", () => { socket.write(Buffer.concat([header, payload])); }); socket.on("data", (data) => { chunks.push(data); let buf = Buffer.concat(chunks); while (buf.length >= needed) { const part = buf.subarray(0, needed); buf = buf.subarray(needed); if (mode === "header") { needed = part.readUInt32BE(0); mode = "body"; } else { socket.end(); resolve(JSON.parse(part.toString("utf8"))); return; } } chunks = [buf]; }); socket.on("error", reject); socket.on("end", () => { // no-op; resolution occurs when full body is parsed }); }); } ``` --- ## 10. End-to-end transcripts ## 10.1 Happy path: get public key Request: ```json { "id": "1", "method": "get_public_key", "params": [] } ``` Response: ```json { "id": "1", "result": "" } ``` ## 10.2 Happy path: sign event with explicit role Request: ```json { "id": "2", "method": "sign_event", "params": ["", { "role": "main" }] } ``` Response: ```json { "id": "2", "result": "" } ``` ## 10.3 Error path: unknown role Request: ```json { "id": "3", "method": "sign_event", "params": ["", { "role": "does_not_exist" }] } ``` Response: ```json { "id": "3", "error": { "code": 1002, "message": "unknown_role" } } ``` ## 10.4 Error path: ambiguous selector Request: ```json { "id": "4", "method": "sign_event", "params": [ "", { "role": "main", "nostr_index": 0 } ] } ``` Response: ```json { "id": "4", "error": { "message": "ambiguous_role_selector" } } ``` ## 10.5 Encrypt/decrypt round trip (NIP-44) Encrypt request: ```json { "id": "5", "method": "nip44_encrypt", "params": ["", "hello", { "role": "main" }] } ``` Encrypt response: ```json { "id": "5", "result": "" } ``` Decrypt request: ```json { "id": "6", "method": "nip44_decrypt", "params": ["", "", { "role": "main" }] } ``` Decrypt response: ```json { "id": "6", "result": "hello" } ``` --- ## 11. Post-Quantum and Multi-Algorithm Support n_signer supports six cryptographic algorithms, all derived deterministically from the same BIP-39 mnemonic via distinct derivation paths: | Algorithm | Purpose | Curve string | Purpose string | Derivation path | |---|---|---|---|---| | `secp256k1` | Nostr (sign_event, NIP-04/44) | `secp256k1` | `nostr` | `m/44'/1237'/'/0/0` (NIP-06) | | `ed25519` | SSH signing, general signatures | `ed25519` | `ssh` | `m/44'/102001'/'/0'/0'` (SLIP-0010) | | `x25519` | Key agreement (age, ECDH) | `x25519` | `age` | `m/44'/102002'/'/0'/0'` (SLIP-0010) | | `ml-dsa-65` | Post-quantum signatures (FIPS 204) | `ml-dsa-65` | `pq-sig` | `m/44'/102003'/'/0'/0'` → seed → PQClean keygen | | `slh-dsa-128s` | Post-quantum hash-based signatures (FIPS 205) | `slh-dsa-128s` | `pq-sig` | `m/44'/102004'/'/0'/0'` → seed → PQClean keygen | | `ml-kem-768` | Post-quantum key encapsulation (FIPS 203) | `ml-kem-768` | `pq-kem` | `m/44'/102005'/'/0'/0'` → seed → PQClean keygen | The `102XXX` coin types are unregistered in SLIP-44 and reserved by n_signer for PQ/SSH/age algorithm families. All non-secp256k1 paths use SLIP-0010 all-hardened derivation. ### 11.1 Algorithm key sizes | Algorithm | Pub key | Priv key | Signature | Ciphertext | Shared secret | |---|---|---|---|---|---| | secp256k1 | 32 bytes | 32 bytes | 64 bytes | — | — | | ed25519 | 32 bytes | 32 bytes | 64 bytes | — | — | | x25519 | 32 bytes | 32 bytes | — | — | 32 bytes | | ML-DSA-65 | 1952 bytes | 4032 bytes | 3309 bytes | — | — | | SLH-DSA-128s | 32 bytes | 64 bytes | 7856 bytes | — | — | | ML-KEM-768 | 1184 bytes | 2400 bytes | — | 1088 bytes | 32 bytes | PQ public keys and signatures are much larger than classical ones. Clients must allocate buffers accordingly (ML-DSA-65 pubkey hex = 3904 chars; SLH-DSA-128s signature hex = 15712 chars; ML-KEM-768 pubkey hex = 2368 chars). ### 11.2 New verbs | Verb | Purpose | Allowed (purpose, curve) | Description | |---|---|---|---| | `sign_data` | pq-sig, ssh | (pq-sig, ml-dsa-65), (pq-sig, slh-dsa-128s), (ssh, ed25519) | Sign arbitrary bytes (not a Nostr event) | | `verify_signature` | pq-sig, ssh | same as `sign_data` | Verify a signature against the role's public key | | `ssh_sign` | ssh | (ssh, ed25519) | Sign an SSH authentication challenge (ed25519) | | `kem_encapsulate` | pq-kem | (pq-kem, ml-kem-768) | Encapsulate: generate ciphertext + shared secret from a peer's ML-KEM public key | | `kem_decapsulate` | pq-kem | (pq-kem, ml-kem-768) | Decapsulate: recover shared secret from ciphertext using the role's ML-KEM private key | The existing Nostr verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`) remain restricted to `purpose=nostr + curve=secp256k1`. ### 11.3 Structured `get_public_key` response format `get_public_key` is a universal verb — it works for all six algorithms. **For secp256k1 (backward compatibility):** the result is a plain hex string (the existing format). Existing Nostr clients are unaffected. ```json { "id": "1", "result": "<64-char hex pubkey>" } ``` **For secp256k1 with `format: "structured"` option:** new clients can request the structured format for consistency: Request: ```json { "id": "1", "method": "get_public_key", "params": [{ "role": "main", "format": "structured" }] } ``` Response: ```json { "id": "1", "result": "{\"algorithm\":\"secp256k1\",\"public_key\":\"\",\"key_id\":\"<16 hex>\"}" } ``` **For all other algorithms (ed25519, x25519, ML-DSA-65, SLH-DSA-128s, ML-KEM-768):** the result is always a structured JSON object serialized as a string: ```json { "id": "1", "result": { "algorithm": "ml-dsa-65", "public_key": "", "key_id": "" } } ``` The `key_id` is the first 16 hex characters of the public key — a short display identifier similar to an SSH key fingerprint. The `result` field is a JSON string (the object serialized), so clients must parse it twice: once for the JSON-RPC envelope, once for the result object. ### 11.4 Example: `sign_data` (ML-DSA-65) Request: ```json { "id": "10", "method": "sign_data", "params": ["68656c6c6f", { "role": "pq_sig" }] } ``` Response: ```json { "id": "10", "result": "{\"signature\":\"\",\"algorithm\":\"ml-dsa-65\"}" } ``` The first param is the message bytes as hex. The signature is hex-encoded (3309 bytes = 6618 hex chars for ML-DSA-65). ### 11.5 Example: `verify_signature` (ed25519) Request: ```json { "id": "11", "method": "verify_signature", "params": ["", "", { "role": "ssh_main" }] } ``` Response: ```json { "id": "11", "result": "{\"valid\":true}" } ``` The signature is verified against the role's derived public key. ### 11.6 Example: `ssh_sign` (ed25519) Request: ```json { "id": "12", "method": "ssh_sign", "params": ["", { "role": "ssh_main" }] } ``` Response: ```json { "id": "12", "result": "{\"signature\":\"\",\"algorithm\":\"ed25519\"}" } ``` The first param is the SSH session ID (or challenge) as hex. The signature is a raw ed25519 signature (64 bytes = 128 hex chars). ### 11.7 Example: `kem_encapsulate` (ML-KEM-768) Request: ```json { "id": "13", "method": "kem_encapsulate", "params": ["", { "role": "kem_main" }] } ``` Response: ```json { "id": "13", "result": "{\"ciphertext\":\"\",\"shared_secret\":\"\",\"algorithm\":\"ml-kem-768\"}" } ``` The first param is the peer's ML-KEM-768 public key as hex (1184 bytes = 2368 hex chars). The response contains the ciphertext (1088 bytes = 2176 hex chars) and the shared secret (32 bytes = 64 hex chars). The encapsulating party keeps the shared secret; the ciphertext is sent to the decapsulating party. ### 11.8 Example: `kem_decapsulate` (ML-KEM-768) Request: ```json { "id": "14", "method": "kem_decapsulate", "params": ["", { "role": "kem_main" }] } ``` Response: ```json { "id": "14", "result": "{\"shared_secret\":\"\",\"algorithm\":\"ml-kem-768\"}" } ``` The first param is the ciphertext from `kem_encapsulate` (1088 bytes = 2176 hex chars). The decapsulated shared secret will match the encapsulating party's shared secret. ### 11.9 Example clients See the `examples/` directory for working C clients demonstrating the new verbs: - [`examples/pq_sign_example.c`](../examples/pq_sign_example.c) — ML-DSA-65 `get_public_key` + `sign_data` - [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) — ML-KEM-768 `get_public_key` + `kem_encapsulate` + `kem_decapsulate` (verifies shared secrets match) - [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) — ed25519 `get_public_key` + `ssh_sign` --- ## 12. Compatibility notes - If you are writing an autonomous agent client, pin to explicit socket name and explicit role selector. - Keep method support feature-detected (`method_not_found` fallback). - Treat approval as asynchronous human gating even for local calls. - Track and surface signer name, request id, and method for auditability.