n_signer
n_signer is a single statically-linked program that holds signing key material in locked memory and signs on request.
It runs in the foreground, attached to your terminal. The terminal is the trust anchor and control surface. Nothing touches disk at runtime. If the process crashes or exits, all in-memory state is gone.
This is a program, not a daemon:
- no hidden background process
- no detached service lifecycle
- no runtime config or state files
- no persistence to recover after compromise
1. What it is
n_signer is one musl-static binary that combines:
- mnemonic handling
- role selection and derivation
- purpose/curve enforcement
- request dispatch
- interactive terminal UI
- transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP)
- OTP one-time pad encryption (optional, with USB pad)
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
2. Security model
2.1 Zero filesystem footprint
At runtime, n_signer writes nothing to disk: no config files, no logs, no PID files, no lock files, no socket pathname artifacts. On Linux desktop, local IPC uses abstract namespace Unix sockets (@name semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
2.2 Crash = total wipe
All sensitive and operational state exists only in-process RAM (mlock'd where applicable): mnemonic-derived key material, role table, policy/approval decisions for the live session, activity display buffer. If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design.
2.3 Single binary, no external dependencies
Runtime target is one statically-linked musl executable: no shared libraries required at runtime, no interpreter/runtime VM dependency, no sidecar services, no helper daemon binaries. This reduces deployment variability and shrinks the runtime trust surface.
2.4 Always-attended operation
n_signer is intentionally human-attended. It stays attached to a terminal and can require explicit keystroke approval for unknown callers or sensitive actions. Human presence is part of the security model.
2.5 Secret memory backing: mlock today, memfd_secret where supported
Sensitive buffers (mnemonic, master seed, per-role private keys) live in mlock'd RAM via secure_buf_alloc and are zeroized with secure_memzero on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.
memfd_secret(2) (Linux CONFIG_SECRETMEM) is a stronger backing: pages are invisible to /proc/pid/mem, ptrace, kcore, and hibernation dumps, and are kernel-guaranteed to be wiped on exit. It is the intended tier-1 upgrade for the secure_buf_alloc path on hosts that can materialize secretmem pages — bare metal and KVM guests.
It is not used yet because Qubes OS VMs are Xen guests: the memfd_secret syscall succeeds and returns a valid /secretmem fd, but the first page-fault into the mapping raises SIGBUS (the Xen hypervisor cannot back the restricted pages). Since Qubes integration is a primary deployment target, the mlock path remains correct and universal. A future implementation will probe memfd_secret by writing a canary byte into a trial mapping and fall back to mlock on SIGBUS/ENOSYS, enabling the stronger backing automatically on non-Xen hosts.
3. How it works
3.1 Startup phase (TUI input mode)
When started, n_signer immediately enters terminal input mode:
- Choose mnemonic source:
[E]nter existing mnemonic(default) or[G]enerate new mnemonic.- On
E: prompt for mnemonic with terminal echo disabled, then validate. - On
G: generate a fresh 12-word BIP-39 mnemonic fromgetrandom(2), display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
- On
- Build in-memory role/selector state from the mnemonic.
- Interactive transport selection (if no
--listenflag given and stdin is a TTY): choose one or more of: Local Unix socket, Qubes qrexec bridge, FIPS/TCP listener (framed JSON), HTTP listener (curl-friendly). - Index whitelist (optional): restrict which
nostr_indexvalues this session can access. - OTP pad selection (optional): auto-scans attached USB drives for OTP pads and offers to bind one. See
plans/otp_nostr_integration.md. - Pick the abstract socket name (random BIP-39 pair, or
--socket-name/--name/-noverride). - Initialize transport endpoints and bind the socket.
- Switch to running status display.
No startup files are read or written. The mnemonic — typed or generated — lives only in mlock'd memory and is zeroized on shutdown or crash.
For parent-process launchers, startup can also be non-interactive:
--mnemonic-stdin: read one mnemonic line from stdin at startup, then continue normally.--mnemonic-fd N: read one mnemonic line from inherited file descriptorNat startup.
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. See plans/mnemonic_startup_input.md for the full behavior contract.
3.2 Running phase (status display + signer)
After unlock, the terminal becomes a live status and control console rendered by render_status(). The top frame shows the program name and version; below it are the Roles and Activity sections, followed by a single status line. Connection instructions are not shown by default — press d to display them on demand. Example layout:
n_signer v0.0.53 > Main Menu
Roles:
Role Purpose Curve Derivation path
main nostr secp256k1 m/44'/1237'/0'/0/0
nostr_idx_1 nostr secp256k1 m/44'/1237'/1'/0/0
backup bitcoin secp256k1 m/84'/0'/0'/0/5
Activity (latest first):
16:03:11 allow caller=uid:1000 method=nostr_get_public_key role=main
16:02:44 prompt caller=uid:1000 method=nostr_sign_event role=ops
16:02:46 allow caller=uid:1000 method=nostr_sign_event role=ops
15:59:10 deny caller=uid:1001 method=nostr_sign_event error=unauthorized
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
l lock/reunlock
r refresh
a toggle auto-approve
d display connections
q/x quit
The Derivation path column shows the full BIP-44 path for each role's key. For nostr_index roles this is m/44'/1237'/<n>'/0/0 (NIP-06); for role_path roles it's the explicit path.
The signer's name (nsigner_hairy_dog in this example) appears in the status line at the bottom (signer=nsigner_hairy_dog). See §4.1 for how the name is generated.
Connection instructions (press d)
Pressing d clears the screen and shows each active transport as a titled block with the connection string and an example client command. Press any key to return to the status display.
Hotkeys (active while the status display is shown):
a— toggle auto-approve (prompt) for this sessionr— refresh the displayd— display connection instructions (press any key to return)l— lock / re-unlock the sessionq— quit
3.3 Approval prompts
When a request needs confirmation, n_signer interrupts the status view with a prompt and waits for a local keystroke.
Approval required
caller: uid:1000
method: nostr_sign_event
selector: role=ops
purpose/curve: nostr/secp256k1
[y] allow once [n] deny [a] always allow this session
No response is emitted to caller until the local user decides.
3.4 Shutdown / lock
qquits the process: all session state is destroyed.llocks the session in-place: signing stops until mnemonic is re-entered.- terminal close or process termination has the same effect as quit: total state wipe.
4. 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 section is the complete, authoritative description of the API.
For the migration plan from the legacy verb names, see plans/legacy_verb_aliases.md.
4.1 Request format
{ "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 §4.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.
4.2 Response format
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.
Error:
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
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. |
4.3 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 §4.4). 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 |
derive |
secp256k1 | <data> |
algorithm, index (required) |
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".
Enforcement matrix
| Verb | Valid algorithms |
|---|---|
sign / verify |
secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
encapsulate / decapsulate |
ml-kem-768 |
derive_shared_secret |
x25519 |
derive |
secp256k1 |
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).
4.4 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 §4.4.3).
4.4.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 |
4.4.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.
The PQ implementations are vendored from PQClean (public domain / CC0). All six algorithms are always compiled in on every target. The three post-quantum algorithms address the harvest-now-decrypt-later threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
4.4.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.5 Examples
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.
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" } ] }
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.
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.
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\"}" }
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).
derive (secp256k1 HMAC-SHA256)
{ "id": "10", "method": "derive", "params": [ "<data>", { "algorithm": "secp256k1", "index": 0 } ] }
Response:
{ "id": "10", "result": "{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}" }
Computes HMAC-SHA256(privkey, data) where privkey is the secp256k1 private key derived on demand at (algorithm: "secp256k1", index: N). data is an arbitrary caller-supplied UTF-8 string. Returns the 32-byte digest as 64 lowercase hex chars.
index is required (no default) — forces conscious selection of which derived key to use as the HMAC key. Omitting it returns missing_index.
This is a generic key-derived MAC primitive. Callers domain-separate by prefixing their own label into data (e.g. "myapp/identifier-v1:<path>"). The private key never leaves the signer; only the digest is returned. Use cases include deterministic, per-user, opaque identifiers for NIP-33 parameterized-replaceable events (e.g. bookmark folder d tags) where the same logical name must produce the same d tag across devices.
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.
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>"}.
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.
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.
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.
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.
4.6 Role-based selectors (Nostr verbs)
The nostr_* verbs select a secp256k1 NIP-06 key via the options object. Supported selectors:
| Selector | Meaning |
|---|---|
nostr_index |
NIP-06 index n → path m/44'/1237'/<n>'/0/0 |
role |
Name of a pre-registered role entry |
role_path |
Full BIP-44 derivation path (must match a registered role) |
Selector resolution order: role → nostr_index → role_path → default role main. Conflicting selectors are rejected with ambiguous_role_selector (1001). The role's (purpose, curve) must be (nostr, secp256k1) — any other combination is rejected with purpose_mismatch (1004) or curve_mismatch (1005).
4.7 Pre-approval
Pre-approval entries skip the interactive prompt for matching requests. They are configured at startup with --preapprove.
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
Nostr (role-based):
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).
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}]}'
5.3 Linux desktop: abstract namespace Unix socket
Primary local transport is AF_UNIX abstract namespace. Each running nsigner process binds to a unique abstract name of the form @nsigner_<word1>_<word2>, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. @nsigner_hairy_dog). This lets multiple signers coexist on one host.
Properties: no pathname in filesystem; endpoint lifetime bound to process/kernel namespace; no stale socket files; caller identity via peer credentials (SO_PEERCRED); per-launch random name avoids collisions and leaks no seed-derived identifier.
Naming rules:
- Default: random pick at startup, displayed in the status line.
- Override:
--socket-name <name>(alias:--name <name>/-n <name>) forces a specific name. - Collision: if the chosen random name is already bound,
nsignerretries with a fresh pair up to 8 times before erroring out.
Discovery:
nsigner listenumerates currently boundnsigner_*abstract sockets by reading/proc/net/unix.nsigner --listen stdioruns one framed JSON-RPC request/response over stdin/stdout.nsigner --listen qrexecis the same stdio framing, but caller identity comes fromQREXEC_REMOTE_DOMAIN(displayed asqubes:<source-vm>).nsigner --listen tcp:IPv4:PORTortcp:[IPv6]:PORTenables FIPS/TCP listening (framed JSON, not HTTP).nsigner --listen http:HOST:PORTenables HTTP listening for curl-friendly access. CORS headers included for browser access. Defaults to localhost; passhttp:0.0.0.0:PORTto expose externally.nsigner bridge --to <socket-name>is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as thequbes.NsignerRpcservice entrypoint. Seeplans/qrexec_persistent_bridge.md.--bridge-source-trusted(unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed{"qrexec_source":"<vm>"}preamble before the request, and the caller identity is composed asqubes:<vm>.
5.4 ESP32 MCU: TinyUSB composite (CDC + WebUSB)
On Feather S3 TFT, MCU targets run the same core signer modules behind a TinyUSB composite transport: CDC-ACM framed JSON-RPC for serial tooling (/dev/ttyACM*) and Vendor/WebUSB framed JSON-RPC for browser tooling. Core signer logic stays the same; only transport/UI bindings change.
5.5 Caller verification
Every transport must provide concrete caller identity before policy evaluation.
- Linux AF_UNIX: map peer credentials to caller identity.
- ESP32 USB bridge: map authenticated host bridge identity to caller identity.
- Relay session: bind remote peer/session identity before allowing signer verbs.
Identity verification and interactive approval are separate layers. Passing identity checks does not bypass prompt requirements.
6. Platform targets
6.1 Linux desktop (primary)
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
6.2 ESP32 / MCU
MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps transport/UI for constrained hardware. The Feather path currently uses TinyUSB composite USB (CDC + WebUSB) plus TFT/buttons for attended approvals.
6.3 Qubes OS qube
Qubes deployment runs n_signer in a dedicated signer qube (e.g. nostr_signer) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
Three transport paths are supported:
FIPS/TCP — the signer listens on tcp:[::]:11111 and FIPS carries traffic between qubes as an IPv6 mesh substrate. See documents/FIPS_DEPLOYMENT.md.
HTTP — the signer listens on http:127.0.0.1:11111 for curl-friendly access within the same qube. No auth envelopes required (relies on localhost binding + policy/approval prompts).
Qubes qrexec bridge (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless nsigner bridge relay (the qubes.NsignerRpc qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is qubes:<source-vm>. See plans/qrexec_persistent_bridge.md for the full design.
Qrexec bridge setup
In the signer qube (nostr_signer):
bash setup_signer_qube.sh # from packaging/qubes/
~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted
In dom0:
bash setup_dom0.sh nostr_signer # from packaging/qubes/
The dom0 policy allows trusted caller qubes without a popup (memory isolation is the real security boundary) and asks for confirmation from any other qube. The signer's own approval prompt at the nostr_signer terminal is the operation-level gate.
From a caller qube:
node examples/n_signer_qube_example_qrexec.js nostr_signer
Setup scripts and policy are in packaging/qubes/. See also documents/QUBES_OS.md and documents/qubes_client_examples.md.
7. Usage
7.1 Run the program
nsigner
Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the running status display shows the randomly assigned signer name and its abstract socket address.
To force a specific socket name (e.g. for scripted clients):
nsigner --name my_test_signer
Other transport modes:
nsigner --listen qrexec # Qubes qrexec (single framed request over stdin/stdout)
nsigner --listen stdio # Generic stdio (single framed request over stdin/stdout)
nsigner --listen tcp:[::]:11111 # FIPS/TCP (framed JSON, no TUI)
nsigner --listen http:127.0.0.1:11111 # HTTP (curl-friendly, no TUI)
With OTP pad bound:
nsigner --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --allow-all
Qrexec bridge mode (stateless relay to a persistent signer's unix socket):
nsigner bridge --to nsigner
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
7.2 Send a request (client mode)
From another terminal, target the signer by its socket name:
nsigner --socket-name nsigner_hairy_dog client '{"id":"1","method":"nostr_get_public_key","params":[]}'
If only one signer is running you can omit the override and the client will use the default discovery rule.
Example signing request:
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
7.3 List running signers
nsigner list
Prints the abstract socket names of any currently running nsigner instances, e.g.:
@nsigner_hairy_dog
@nsigner_brave_canyon
7.4 Example session
Terminal A:
$ nsigner
[unlock] enter mnemonic:
System is ready and waiting for connections on @nsigner_hairy_dog.
[prompt] caller=uid:1000 method=nostr_sign_event role=main -> allow? (y/n)
Terminal B:
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
{"id":"2","result":"<signed_event_json>"}
8. Build and versioning
Build outputs a single executable artifact.
build_static.sh: musl-static build pathMakefile: local build targetsDockerfile.alpine-musl: reproducible Alpine musl toolchainresources/tui_continuous/tui_continuous.h+resources/tui_continuous/tui_continuous.c: vendored continuous-TUI component (from~/lt/aesthetics, API baselineTUI_CONTINUOUS_VERSION 0.0.9)increment_and_push.sh: version/tag/release workflowsrc/main.c: version macros (NSIGNER_VERSION*)
Local dev build:
make dev
./build/nsigner --version
Static build:
./build_static.sh
./build/nsigner_static_x86_64 --version