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:
1007no_termination_condition— neitherdifficultynortimeout_secgiven.1008mining_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_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:
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).