# n_signer API `n_signer` exposes a JSON-RPC 2.0-style request/response protocol. Every request is a single JSON object; every response is a single JSON object. This document is the complete, authoritative description of the API. For the security model, transports, and operational behavior, see [`README.md`](README.md). For the migration plan from the legacy verb names, see [`plans/legacy_verb_aliases.md`](plans/legacy_verb_aliases.md). --- ## 1. API format ### 1.1 Request ```json { "id": "", "method": "", "params": [ , , ..., { } ] } ``` - `id` — caller-supplied string echoed verbatim in the response. Used to match requests to responses. - `method` — the verb name (see [§2](#2-verbs)). - `params` — a JSON array. Positional arguments come first; the **last array element** is conventionally an options object. The options object is optional for most verbs. ### 1.2 Response (success) ```json { "id": "", "result": } ``` `result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it. ### 1.3 Response (error) ```json { "id": "", "error": { "code": , "message": "" } } ``` ### 1.4 Error codes | Code | Message | Meaning | |-------|-------------------------------|--------------------------------------------------------------------| | -32700| `parse_error` | Request was not valid JSON. | | -32600| `invalid_request` | Missing `id`, `method`, or `params`, or `params` is not an array. | | -32601| `method_not_found` | Unknown verb, or verb not valid for the selected algorithm. | | -32602| `invalid_params` | Malformed arguments (bad hex, wrong length, missing field, etc.). | | 1001 | `ambiguous_role_selector` | More than one role selector was supplied. | | 1002 | `unknown_role` | No role matched the selector. | | 1003 | `no_default_role` | No selector given and no `main` role exists. | | 1004 | `purpose_mismatch` | Role's purpose is not valid for this verb. | | 1005 | `curve_mismatch` | Role's curve is not valid for this verb. | | 1006 | `mnemonic_not_loaded` | No mnemonic is loaded in the signer. | | 1007 | `no_termination_condition` | `nostr_mine_event` called without `difficulty` or `timeout_sec`. | | 1008 | `mining_failed` | Internal error during proof-of-work mining. | | 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. | | 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. | --- ## 2. Verbs All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§3](#3-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `nostr_index` and implement Nostr-protocol-specific serialization on top of the raw crypto. | Verb | Algorithms | Positional params | Options | |-------------------------|-----------------------------------------------|----------------------------------|----------------------------------| | `get_public_key` | all key-deriving algorithms | — | `algorithm`, `index` | | `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `` | `algorithm`, `index`, `scheme`* | | `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | ``, `` | `algorithm`, `index`, `scheme`* | | `encapsulate` | ml-kem-768 | `` | `algorithm` | | `decapsulate` | ml-kem-768 | `` | `algorithm`, `index` | | `derive_shared_secret` | x25519 | `` | `algorithm`, `index` | | `encrypt` | otp | `` | `algorithm`, `encoding` | | `decrypt` | otp | `` | `algorithm`, `encoding` | | `nostr_get_public_key` | secp256k1 (NIP-06) | — | `nostr_index`, `format` | | `nostr_sign_event` | secp256k1 (NIP-06) | `` | `nostr_index` | | `nostr_mine_event` | secp256k1 (NIP-06) | `` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` | | `nostr_nip04_encrypt` | secp256k1 (NIP-06) | ``, `` | `nostr_index` | | `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` | | `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` | | `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` | \* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`. ### 2.1 Enforcement matrix | Verb | Valid algorithms | |----------------------------|-----------------------------------------------| | `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | | `encapsulate` / `decapsulate` | ml-kem-768 | | `derive_shared_secret` | x25519 | | `encrypt` / `decrypt` | otp | | `get_public_key` | all key-deriving algorithms | | `nostr_*` | secp256k1 (Nostr protocol) | Any unlisted `(verb, algorithm)` pair is rejected with `algorithm_not_supported_for_verb` (1010). --- ## 3. Algorithms All keys derive deterministically from the loaded BIP-39 mnemonic. The caller selects an algorithm by name and a derivation `index` (an integer `<n>` substituted into the algorithm's derivation path). OTP is the exception — it does not derive a key, it consumes a bound one-time pad (see [§3.3](#33-otp)). ### 3.1 Algorithm table | Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) | |-----------------|-----------------|---------------|---------------------------------------|-------------------------------| | `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 | | `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 | | `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 | | `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0/0'` → DRBG | 4032 / 1952 | | `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 | | `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 | | `otp` | One-time pad | — | (no key — bound USB pad) | n/a | ### 3.2 Key derivation - **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar. - **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key. - **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces PQClean's `randombytes()` callback during keygen. Same mnemonic, same index, same key pair every time. - **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests. ### 3.3 OTP The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response. --- ## 4. Examples ### 4.1 `get_public_key` ```json { "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] } ``` Response: ```json { "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" } ``` `key_id` is the first 16 hex characters of the public key — a short display identifier. ### 4.2 `sign` ```json { "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] } ``` Response: ```json { "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" } ``` The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`: ```json { "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] } ``` ### 4.3 `verify` ```json { "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] } ``` Response: ```json { "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" } ``` The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library. ### 4.4 `encapsulate` (ML-KEM-768) ```json { "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] } ``` Response: ```json { "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" } ``` `peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`. ### 4.5 `decapsulate` (ML-KEM-768) ```json { "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] } ``` Response: ```json { "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" } ``` ### 4.6 `derive_shared_secret` (X25519) ```json { "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] } ``` Response: ```json { "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" } ``` `peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305). ### 4.7 `encrypt` / `decrypt` (OTP) ```json { "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] } { "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] } ``` `encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used. `encrypt` response: ```json { "id": "8", "result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}" } ``` `decrypt` response: ```json { "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" } ``` If no pad is bound at startup, the error is `-32601` `otp_pad_not_bound`. ### 4.8 `nostr_get_public_key` ```json { "id": "10", "method": "nostr_get_public_key", "params": [ { "nostr_index": 0 } ] } ``` Response (default): a plain 64-hex-char secp256k1 public key string. Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`. ### 4.9 `nostr_sign_event` Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event. ```json { "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "nostr_index": 0 } ] } ``` `<event_json>` is the unsigned event object: ```json { "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" } ``` Response: the signed event JSON string, with `id` and `sig` populated. ### 4.10 `nostr_mine_event` Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive. ```json { "id": "12", "method": "nostr_mine_event", "params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 } ] } ``` | Option | Required | Default | Meaning | |---------------|-------------------------------|---------|---------------------------------------------------------------| | `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. | | `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. | | `threads` | no | 1 | Mining threads (clamped to 1..32). | At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned. Response: ```json { "id": "12", "result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}" } ``` Errors: - `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given. - `1008` `mining_failed` — internal mining error. ### 4.11 `nostr_nip04_encrypt` / `nostr_nip04_decrypt` NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload. ```json { "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] } { "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] } ``` `encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string. ### 4.12 `nostr_nip44_encrypt` / `nostr_nip44_decrypt` NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format. ```json { "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] } { "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] } ``` `encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string. --- ## 5. Transports The API is transport-independent. The same JSON request works over every transport; only the framing differs. | Transport | `--listen` flag | Framing | Caller identity | |-----------|--------------------------------|------------------------------------------|----------------------------| | Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED` → `uid:<n>` | | stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid | | qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` | | FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) | | HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) | ### 5.1 HTTP examples Start the signer: ```bash nsigner --listen http:127.0.0.1:11111 --allow-all ``` Get a public key: ```bash curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ -d '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}' ``` Sign a Nostr event: ```bash curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ -d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}' ``` OTP encrypt: ```bash curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ -d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}' ``` ### 5.2 Unix socket examples (framed mode) ```bash # get_public_key nsigner --socket-name nsigner client \ '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}' # Sign a Nostr event nsigner --socket-name nsigner client \ '{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}' # ed25519 sign nsigner --socket-name nsigner client \ '{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}' ``` --- ## 6. Pre-approval Pre-approval entries skip the interactive prompt for matching requests. They are configured at startup with `--preapprove`. ### 6.1 Algorithm-based ```bash nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign,verify nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsulate ``` ### 6.2 Nostr ```bash nsigner --preapprove caller=uid:1000,nostr_index=0,verb=nostr_sign_event,nostr_get_public_key ``` A `*` wildcard matches any caller, role, or verb. Index ranges use `min-max` syntax. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).