Files
n_signer/api.md

19 KiB

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. For the migration plan from the legacy verb names, see plans/legacy_verb_aliases.md.


1. API format

1.1 Request

{ "id": "<string>", "method": "<verb>", "params": [ <arg0>, <arg1>, ..., { <options> } ] }
  • id — caller-supplied string echoed verbatim in the response. Used to match requests to responses.
  • method — the verb name (see §2).
  • 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)

{ "id": "<string>", "result": <value> }

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)

{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }

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). 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 <message_hex> algorithm, index, scheme*
verify secp256k1, ed25519, ml-dsa-65, slh-dsa-128s <message_hex>, <signature_hex> algorithm, index, scheme*
encapsulate ml-kem-768 <peer_pubkey_hex> algorithm
decapsulate ml-kem-768 <ciphertext_hex> algorithm, index
derive_shared_secret x25519 <peer_pubkey_hex> algorithm, index
encrypt otp <plaintext_base64> algorithm, encoding
decrypt otp <ciphertext> algorithm, encoding
nostr_get_public_key secp256k1 (NIP-06) nostr_index, format
nostr_sign_event secp256k1 (NIP-06) <event_json> nostr_index
nostr_mine_event secp256k1 (NIP-06) <event_json> nostr_index, difficulty, timeout_sec, threads
nostr_nip04_encrypt secp256k1 (NIP-06) <peer_pubkey_hex>, <plaintext> 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).

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

{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }

Response:

{ "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

{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }

Response:

{ "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":

{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }

4.3 verify

{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }

Response:

{ "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)

{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }

Response:

{ "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)

{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }

Response:

{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }

4.6 derive_shared_secret (X25519)

{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }

Response:

{ "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)

{ "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:

{
  "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:

{ "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

{ "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.

{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "nostr_index": 0 } ] }

<event_json> is the unsigned event object:

{ "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.

{
  "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:

{
  "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.

{ "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.

{ "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_PEERCREDuid:<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:

nsigner --listen http:127.0.0.1:11111 --allow-all

Get a public key:

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:

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:

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)

# 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

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

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).