signer

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

signer is one binary that combines:

  • mnemonic handling (BIP-39)
  • role selection and derivation (BIP-32 / SLIP-0010)
  • purpose/curve enforcement
  • request dispatch
  • interactive terminal UI (ratatui)
  • transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP, stdio)
  • OTP one-time pad encryption (optional, with USB pad)
  • post-quantum algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)

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, 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, activity display buffer. If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. Sensitive buffers are zeroized with zeroize on drop.

2.3 Single binary, no external runtime dependencies

The release build (opt-level = "z", lto = true, panic = "abort", strip = true) produces one optimized binary. No shared libraries are required at runtime beyond the system libc.

2.4 Always-attended operation

signer is intentionally human-attended. It stays attached to a terminal and the role-name-as-password model means a caller must already know the role name (the "password") to reach a key. Human presence is part of the security model.

2.5 Secret memory backing: mlock

Sensitive buffers (mnemonic, master seed, per-role private keys) live in mlock'd RAM and are zeroized on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.

3. How it works

3.1 Startup phase (TUI input mode)

When started interactively, signer immediately enters a startup popup:

  1. Seed entry popup — enter an existing BIP-39 mnemonic, or type g to generate a fresh 12-word mnemonic (displayed numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then press Enter to continue).
  2. On successful load, derive keys for any pre-registered roles (the default main role is auto-registered), start the server with the default transport (Unix), and transition to the main screen.
  3. Invalid mnemonics show an error line and retry (max 10 attempts).

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 descriptor N at startup.

These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. Non-interactive modes run headless (no TUI).

3.2 Running phase (main screen)

After unlock, the terminal becomes a live status and control console. The title Signer v0.0.1 is centered on its own line. Below it, collapsed-border panes show:

  • Information — signer name, transport addresses (Unix / Qube / FIPS / HTTP), OTP pad status.
  • Transport — four toggle lines ([x] U̲nix Socket, [ ] Qube b̲ridge, [ ] F̲IPS, [ ] H̲TTP). The [x] / [ ] indicator shows on/off state. The underlined key letter toggles a transport on/off. The last active transport cannot be disabled. Toggling restarts the server immediately.
  • Roles — table of registered roles (Role, Purpose, Curve). A̲dd opens the add-role popup; D̲elete removes the selected role immediately.
  • Activity — scrollable, newest first. Each entry is a timestamped log line in the format <caller_id> <curve> <key_path>. Cl̲ear wipes the log.
  • Bottom barHe̲lp Q̲uit.

Command hints show only the word with the key letter underlined (e.g. "Quit" with Q underlined, "Qube bridge" with B underlined). The underlined letter is the actual key binding, which may not be the first letter.

Focus and activation: Tab / Shift-Tab cycles focus through all nine commands on the main screen — the four transports, Add, Delete, Clear, Help, Quit. The focused command is reverse-highlighted. Enter activates the focused command. Pressing a command's underlined key letter fires it directly regardless of focus. Up/Down (or j/k) scroll the activity log.

3.3 Help screen

Press L from the main screen to open a full-screen, scrollable Help overlay describing what the app does, what transports are, what roles are, and listing the key commands. A Navigation section at the end explains how to leave the screen:

  • Up/Down or j/k — scroll
  • Page Up / Page Down — scroll by a page
  • ESC or B — back to the main screen
  • Q — quit the program

The bottom bar shows Ba̲ck Q̲uit so the exit path is always visible.

3.4 Add-role popup

Press A from the main screen to open the add-role popup. The flow is:

  1. Preset menu — choose from 10 presets (Standard Nostr, hardened range, agent range, SSH, Age, ML-DSA-65, SLH-DSA-128s, ML-KEM-768, OTP, Custom).
  2. Name entry — InputField pre-filled with a default role name.
  3. Curve select — custom roles only.
  4. Path entry — InputField pre-filled with the preset's derivation path template.
  5. OTP dir / name — OTP roles only.
  6. Confirm — register the role, derive its key immediately, and return to the main screen automatically.

ESC at any stage cancels and returns to the main screen.

3.5 Shutdown

  • Q (or ESC on the main screen) quits the process: all session state is destroyed.
  • Terminal close or process termination has the same effect: total state wipe.

4. API

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.

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.3).
  • 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.
2003 path_not_allowed role_path does not match any registered role or allowed path.
2005 index_out_of_range index outside the named role's [lo,hi] range.
2008 role_required role is required when using role_path.
2009 path_required role_path is required for roles with variable path templates.

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 role + role_path and implement Nostr-protocol-specific serialization on top of the raw crypto.

Verb Algorithms Positional params Options
get_info n/a (metadata)
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) role, role_path, format
nostr_sign_event secp256k1 (NIP-06) <event_json> role, role_path
nostr_mine_event secp256k1 (NIP-06) <event_json> role, role_path, difficulty, timeout_sec, threads
nostr_nip04_encrypt secp256k1 (NIP-06) <peer_pubkey_hex>, <plaintext> role, role_path
nostr_nip04_decrypt secp256k1 (NIP-06) <peer_pubkey_hex>, <ciphertext> role, role_path
nostr_nip44_encrypt secp256k1 (NIP-06) <peer_pubkey_hex>, <plaintext> role, role_path
nostr_nip44_decrypt secp256k1 (NIP-06) <peer_pubkey_hex>, <ciphertext> role, role_path

* 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' (BIP-32) 32 / 1952
slh-dsa-128s PQ signature FIPS 205 m/44'/102004'/<n>'/0'/0' (BIP-32) 64 / 32
ml-kem-768 PQ KEM FIPS 203 m/44'/102005'/<n>'/0'/0' (BIP-32) 64 / 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 the v2 FIPS seeded derivation (see plans/pq_seeded_derivation_plan.md): BIP-32 child bytes at the exact seed length required by each algorithm feed the seeded keygen APIs directly — no DRBG expansion. ML-DSA-65 takes one 32-byte child; SLH-DSA-128s takes two children concatenated (first 48 of 64 bytes, split as sk.seed ∥ sk.prf ∥ pk.seed); ML-KEM-768 takes two children concatenated (all 64 bytes, split as d ∥ z). Same mnemonic, same index, same key pair every time — and the same keys as the nostr_quantum_preparation web app (verified against its pinned test vectors by tests/pq_conformance.rs). PQ private keys are stored in seed form. The PQ implementations are the pure-Rust crates ml-dsa, ml-kem, and slh-dsa (SLH-DSA uses the SHA2-128s parameter set). 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.
  • 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.

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_info

Returns signer metadata: name, implementation, version, api, and the supported verbs / algorithms arrays. Safe to call before the mnemonic is loaded — clients use it to feature-detect.

{ "id": "0", "method": "get_info", "params": [] }

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

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 method_not_found (otp_pad_not_bound).

nostr_get_public_key

{ "id": "10", "method": "nostr_get_public_key", "params": [ { "role": "main" } ] }

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>", { "role": "main" } ] }

<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, "role": "main" } ]
}
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.

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>", { "role": "main" } ] }
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }

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>", { "role": "main" } ] }
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }

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
role Name of a pre-registered role entry (required)
role_path Full BIP-44 derivation path (required)

Selector resolution: both role and role_path are required together — they form a single combined selector. The server verifies that the supplied role_path matches the role's registered template (expanding any wildcard). There is no resolution order and no default role: omitting either field is rejected (2008 role_required / 2009 path_required). The role's (purpose, curve) must be (nostr, secp256k1) — any other combination is rejected with purpose_mismatch (1004) or curve_mismatch (1005).

Named path-roles

In the add-role popup, you define named path-roles that bind a role name (which acts as an access token for clients) to a derivation path template. The derivation path template is hidden from clients — they only know the role name and send the full concrete role_path with each request.

The popup presents a preset menu of 10 options covering the common role types. You can still define custom roles manually via the "Custom path" option.

Preset menu:
  1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
  2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
  3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
  4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
  5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
  6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
  7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
  8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
  9. OTP role (one-time pad encryption)
  10. Custom path

Purpose is auto-detected from the path prefix (e.g. m/44'/1237' → nostr, m/44'/102001' → ssh). The path template is pre-filled from the chosen preset and can be edited inline.

Path template syntax:

  • Wildcard: m/44'/1237'/*'/0'/0' — any non-negative integer, hardened. No range limit.
  • Range: m/44'/1237'/0-3/1/0 — index 0..3, hardened if segment ends with ' (e.g. 0-3')
  • Set: m/44'/1237'/1+34+54/1/0 — specific indices 1, 34, 54
  • Fixed path: m/44'/1237'/0'/0/0 — no variable segment, single fixed key
  • The first segment that is a plain number, range (N-M), set (A+B+C), or wildcard (*) becomes the variable. Segments with ' (like 44', 1237') are treated as literal hardened constants.

The role name itself acts as a password: any caller that knows the role name and supplies a matching role_path is served without attendant interaction.

Clients request keys by supplying both role and the full concrete role_path:

{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}

→ derives m/44'/1237'/0'/1/0, verified against the myrole template.

{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}

2003 path_not_allowed (5 is outside the registered template, if the template was a fixed path or limited range).

{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}

1002 unknown_role (name not registered).

{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}

2009 path_required (role_path is required).

{"id":"5","method":"nostr_get_public_key","params":[{"role_path":"m/44'/1237'/0'/0/0"}]}

2008 role_required (role is required when using role_path).

4.7 Non-interactive role registration

Roles can be registered from the command line with --register-role (repeatable), avoiding the popup entirely. The spec format is <name>:<curve>:<path-template>. If <curve> is empty, the curve is auto-detected from the path prefix.

signer --register-role main:secp256k1:m/44'/1237'/0'/0/0
signer --register-role nostr_range::m/44'/1237'/*'/0/0
signer --register-role ssh::m/44'/102001'/0'/0'/0'

If no --register-role is given in non-interactive mode, a default main role (m/44'/1237'/0'/0/0, secp256k1, nostr) is auto-registered.

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:

signer --listen http:127.0.0.1:11111 --mnemonic-stdin

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"},{"role":"main"}]}'

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
signer --socket-name signer01 client \
  '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'

# Sign a Nostr event
signer --socket-name signer01 client \
  '{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'

# ed25519 sign
signer --socket-name signer01 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 signer process binds to a unique abstract name of the form @signer_<word1>_<word2>, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. @signer_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 Information section.
  • Override: --socket-name <name> (alias: --name <name> / -n <name>) forces a specific name.

Discovery:

  • signer list enumerates currently bound signer_* abstract sockets by reading /proc/net/unix.
  • signer --listen stdio runs one framed JSON-RPC request/response over stdin/stdout.
  • signer --listen qrexec is the same stdio framing, but caller identity comes from QREXEC_REMOTE_DOMAIN (displayed as qubes:<source-vm>).
  • signer --listen tcp:[::]:11111 enables FIPS/TCP listening (framed JSON, not HTTP).
  • signer --listen http:127.0.0.1:11111 enables HTTP listening for curl-friendly access. CORS headers included for browser access. Defaults to localhost; pass http:0.0.0.0:PORT to expose externally.
  • signer 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 the qubes.signerRpc service entrypoint.
  • --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 as qubes:<vm>.

5.4 Caller verification

Every transport must provide concrete caller identity before policy evaluation.

  • Linux AF_UNIX: map peer credentials to caller identity.
  • Relay session: bind remote peer/session identity before allowing signer verbs.

Identity verification and the role-name-as-password gate are separate layers. Passing identity checks does not bypass the role-name requirement.

6. Platform targets

6.1 Linux desktop (primary)

Primary deployment is a local, foreground terminal program with abstract namespace socket transport.

6.2 Qubes OS qube

Qubes deployment runs 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.

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 + the role-name-as-password gate).

Qubes qrexec bridge (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless signer bridge relay (the qubes.signerRpc qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is qubes:<source-vm>.

Qrexec bridge setup

In the signer qube (nostr_signer):

signer --listen unix --socket-name signer --bridge-source-trusted

From a caller qube (via the qrexec service):

signer bridge --to signer

7. Usage

7.1 Run the program

signer

Program starts in attached foreground mode and shows the seed-entry popup. After mnemonic acceptance, the main screen shows the randomly assigned signer name and its abstract socket address.

To force a specific socket name (e.g. for scripted clients):

signer --name my_test_signer

Other transport modes:

signer --listen qrexec              # Qubes qrexec (single framed request over stdin/stdout)
signer --listen stdio               # Generic stdio (single framed request over stdin/stdout)
signer --listen tcp:[::]:11111      # FIPS/TCP (framed JSON, no TUI)
signer --listen http:127.0.0.1:11111  # HTTP (curl-friendly, no TUI)

With OTP pad bound:

signer --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --mnemonic-stdin

Qrexec bridge mode (stateless relay to a persistent signer's unix socket):

signer bridge --to signer

Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):

signer --listen unix --socket-name signer --bridge-source-trusted

7.2 Send a request (client mode)

The signer client subcommand sends a hand-built JSON-RPC object over the socket:

signer --socket-name signer01 client \
  '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'

Read the request from stdin with -:

echo '{"id":"1","method":"get_info","params":[]}' | signer client -

If only one signer is running you can omit the --socket-name override and the client will use the default discovery rule.

7.3 List running signers

signer list

Prints the names of any currently running signer instances, e.g.:

signer_hairy_dog
signer_brave_canyon

7.4 Example session

Terminal A:

$ signer
signer v0.0.2
[seed entry popup → enter mnemonic]
[main screen shows: signer name signer_hairy_dog, Unix address active]

Terminal B:

$ signer --socket-name signer_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
{"id":"2","result":"<signed_event_json>"}

8. Build

8.1 Dependencies

The build expects the local nostr_core_lib_rust checkout (sibling directory) for the nostr-core and nostr-nips path dependencies, and the vendored ratatui submodule for the TUI.

git submodule update --init ratatui

8.2 Local dev build

Native builds are intended for local development and use the host Rust toolchain and libc:

cargo build
./target/debug/signer --version

8.3 Portable static musl release build

Portable release binaries are built in Docker for x86_64 Linux using the x86_64-unknown-linux-musl target. This avoids a runtime dependency on the target system's glibc version. The build expects the sibling nostr_core_lib_rust checkout described above.

./build_musl.sh

The artifacts are written directly to dist/:

  • signer
  • signer_client

Verify the output:

file dist/signer
ldd dist/signer
./dist/signer --version

ldd should report that the executable is not dynamically linked. Static musl removes the glibc runtime dependency, but the binaries still require a compatible Linux kernel, x86_64 CPU, terminal environment, Qubes/qrexec environment where applicable, and sufficient RLIMIT_MEMLOCK for locked secret memory.

To deploy the portable binaries locally:

./deploy_local.sh --musl

8.4 Native release build

The release profile is tuned for a small, optimized, stripped binary:

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
cargo build --release
./target/release/signer --version

8.5 Tests

cargo test

The portable build runs the release compilation and static-linkage checks inside Docker. Runtime smoke tests should be performed on the intended Qubes/Linux deployment environment, including Unix sockets, TCP/HTTP, qrexec, TUI startup, signing, and mlock behavior.

9. Project layout

Path Purpose
src/main.rs CLI parsing, subcommands (client, bridge, list), server startup
src/lib.rs Crate root, module declarations, VERSION
src/tui.rs ratatui TUI: seed-entry popup, main screen, add-role popup, help overlay
src/server.rs Multi-transport server with poll loop, caller identity, request framing
src/dispatcher.rs Verb dispatch and JSON-RPC response construction
src/role_table.rs Role registry, path-template parsing, purpose/curve enforcement
src/selector.rs Role selector resolution (role + role_path)
src/enforcement.rs Verb/algorithm/purpose/curve enforcement matrix
Dockerfile.musl Reproducible Docker environment for static musl releases
build_musl.sh Builds and validates portable x86_64 musl binaries
src/key_store.rs BIP-32 / SLIP-0010 key derivation and storage
src/mnemonic.rs BIP-39 mnemonic loading and seed derivation
src/pq_crypto.rs Post-quantum keygen (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
src/pq_drbg.rs SHAKE-256 DRBG (retained port; not used for PQ keygen — see the v2 seeded derivation)
src/alg_cache.rs Per-algorithm derived-key cache
src/otp_pad.rs One-time pad binding, offset tracking, encrypt/decrypt
src/miner.rs NIP-13 proof-of-work mining for nostr_mine_event
src/auth_envelope.rs Auth envelope verification and nonce cache
src/transport.rs Length-prefixed framing, abstract Unix socket connect
src/http.rs HTTP listener transport
src/socket_name.rs Random socket-name generation and discovery
src/secure_mem.rs mlock / zeroize helpers
src/error.rs signerError enum
ratatui/ Vendored ratatui submodule (TUI framework)
plans/ Design and migration plans

10. Differences from the C n_signer

This Rust port preserves the security model and API of the original C n_signer while differing in implementation details:

  • Language: Rust instead of C. Memory safety is enforced by the type system; sensitive buffers use zeroize instead of hand-rolled secure_memzero.
  • PQ crypto: pure-Rust crates (ml-dsa, ml-kem, slh-dsa) instead of vendored PQClean C.
  • TUI: ratatui instead of the vendored tui_continuous C component. The TUI flow is redesigned (see plans/tui_flow_redesign.md): a single main screen with a startup seed-entry popup, an add-role popup, and a scrollable Help overlay, replacing the original linear setup wizard.
  • Approval prompts: the C version's interactive approval prompts (y/n/e/a) are not yet ported; the Rust port uses the role-name-as-password gate as the primary access control. Interactive approval is a future addition.
  • No firmware targets: the C project also targets ESP32 / MCU hardware via TinyUSB. This Rust port targets Linux desktop and Qubes OS only.
  • No --preapprove flag: pre-approval entries are not yet implemented in the Rust port.
  • No --allow-all flag: the Rust port does not carry the C project's --allow-all development shortcut; access control is the role-name-as-password gate plus caller identity.
S
Description
No description provided
Readme
1.2 MiB
v0.0.21
Latest
2026-08-22 11:57:22 +00:00
Languages
Rust 91.9%
Shell 8.1%