Files
n_signer/README.md

36 KiB
Raw Permalink Blame History

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. There is no persistence layer to scrape post-crash.

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:

  1. 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 from getrandom(2), display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
  2. Build in-memory role/selector state from the mnemonic.
  3. Interactive transport selection (if no --listen flag 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)
  4. Index whitelist (optional): restrict which nostr_index values this session can access.
  5. OTP pad selection (optional): auto-scans attached USB drives for OTP pads and offers to bind one. See plans/otp_nostr_integration.md.
  6. Pick the abstract socket name (random BIP-39 pair, or --socket-name / --name / -n override).
  7. Initialize transport endpoints and bind the socket.
  8. Switch to running status display, with the signer name and socket address shown in the Connections section and status line (see §3.2).

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. 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 Connections, Roles, and Activity sections, followed by a single status line. Example layout (single Unix listener, one role derived):

n_signer v0.0.2                                          > Main Menu

Connections:
Server: unix @nsigner_hairy_dog
  Client: nsigner --socket-name nsigner_hairy_dog client '<json>'
OTP pad: 333e9902db839d9d... (offset 288 / 1048576 bytes)

Roles:
Role              Purpose      Curve        Selector
main              nostr        secp256k1    role:main
ops               nostr        secp256k1    nostr_index:7
backup            bitcoin      secp256k1    role_path:m/84'/0'/0'/0/5

Activity (latest first):
16:03:11  allow  caller=uid:1000  method=get_public_key role=main
16:02:44  prompt caller=uid:1000  method=sign_event role=ops
16:02:46  allow  caller=uid:1000  method=sign_event role=ops
15:59:10  deny   caller=uid:1001  method=sign_event error=unauthorized

session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF

The signer's name (nsigner_hairy_dog in this example) appears in two places: the Connections section as the abstract socket address (Server: unix @nsigner_hairy_dog), and the status line at the bottom (signer=nsigner_hairy_dog). See §7.1 for how the name is generated.

When multiple transports are active (interactive transport selection), the Connections section lists each one with its client command. HTTP and FIPS/TCP entries show curl / nsigner --listen ... client examples respectively.

Hotkeys (active while the status display is shown):

  • a — toggle auto-approve (prompt) for this session
  • r — refresh the display
  • l — lock / re-unlock the session
  • q — 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.

Example:

Approval required
caller: uid:1000
method: 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

  • q quits the process: all session state is destroyed.
  • l locks 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. Mnemonic-rooted role model

n_signer derives many role-scoped keys from one mnemonic root. Requests select a role using explicit selectors, then enforcement checks operation compatibility.

4.1 Nostr shorthand: nostr_index

Nostr shorthand keeps the explicit index selector:

m/44'/1237'/<nostr_index>'/0/0

Use nostr_index only for Nostr-indexed roles.

4.2 Full derivation path: role_path

For non-Nostr or advanced layouts, caller may use full role_path selector.

Security rule: role_path must match a pre-registered role entry. Unregistered ad-hoc derivation requests are rejected.

4.3 What memorizing your seed phrase gets you

A single memorized mnemonic can deterministically recover multiple key domains through role definitions, not just one identity.

Examples include Nostr roles, Bitcoin branches, and future application-specific paths. See plans/seed_phrase_uses.md for the maintained use-case catalog and caveats.

4b. Crypto palette

n_signer supports six cryptographic algorithms, all derived deterministically from the same BIP-39 mnemonic. Each algorithm is bound to a specific (purpose, curve) pair and a distinct BIP-44 derivation path.

4b.1 Algorithms

Algorithm Curve label Purpose FIPS standard Key sizes (priv / pub) Derivation path
secp256k1 secp256k1 nostr 32 / 32 bytes m/44'/1237'/<n>'/0/0 (NIP-06)
ed25519 ed25519 ssh 32 / 32 bytes m/44'/102001'/<n>'/0'/0' (SLIP-0010)
x25519 x25519 age 32 / 32 bytes m/44'/102002'/<n>'/0'/0' (SLIP-0010)
ML-DSA-65 ml-dsa-65 pq-sig FIPS 204 4032 / 1952 bytes m/44'/102003'/<n>'/0'/0' → seed → DRBG → PQClean keygen
SLH-DSA-128s slh-dsa-128s pq-sig FIPS 205 64 / 32 bytes m/44'/102004'/<n>'/0'/0' → seed → DRBG → PQClean keygen
ML-KEM-768 ml-kem-768 pq-kem FIPS 203 2400 / 1184 bytes m/44'/102005'/<n>'/0'/0' → seed → DRBG → PQClean keygen

All six algorithms are always compiled in on every target (host x86_64 static binary and ESP32 firmware). The PQ implementations are vendored from PQClean (public domain / CC0).

4b.2 Key derivation

All keys derive deterministically from the BIP-39 mnemonic:

  • 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. This produces deterministic PQ key pairs from the mnemonic — same mnemonic, same role, same key pair every time. See documents/SECURITY.md §17 for the security argument.

The new algorithms use BIP-44 coin types 102001102005 (unregistered in SLIP-44, chosen to avoid collisions with real cryptocurrencies). All non-secp256k1 paths are fully hardened per SLIP-0010.

4b.3 Post-quantum context

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. ML-KEM-768 protects key agreement against this threat. ML-DSA-65 and SLH-DSA-128s protect signatures against future quantum forgery.

All three are FIPS-standardized (FIPS 203, 204, 205) and are provided as additional options, not replacements for secp256k1. Nostr continues to use secp256k1 exclusively. PQ algorithms are opt-in per role.

Note: OpenSSH does not yet support PQ signing keys. The pq-sig purpose is forward-looking — the primitives are ready for when the ecosystem adopts them.

4b.4 Structured get_public_key response

For secp256k1, get_public_key returns the plain 64-hex-char public key string (backward compatible). An optional {"format": "structured"} in the options object requests a structured JSON response.

For all other algorithms (ed25519, x25519, ML-DSA-65, SLH-DSA-128s, ML-KEM-768), get_public_key always returns a structured JSON object (serialized as a string):

{"algorithm": "ml-dsa-65", "public_key": "<hex>", "key_id": "<16 hex chars>"}

The key_id is a short display identifier (first 16 hex chars of the public key). PQ public keys are large (ML-DSA-65: 3904 hex chars; ML-KEM-768: 2368 hex chars).

4c. Algorithm-based API

In addition to the role-based API, n_signer supports an algorithm-based API where the caller specifies the algorithm and derivation index directly, without needing to know role names. This is the preferred API for new clients.

4c.1 Verbs

Verb Description Algorithm parameter Key parameter
sign Sign arbitrary bytes algorithm index
verify Verify a signature algorithm index
encapsulate KEM encapsulation algorithm public_key (peer's)
decapsulate KEM decapsulation algorithm index
derive_shared_secret ECDH key agreement (x25519) algorithm index + peer_public_key
get_public_key Get public key (algorithm-based) algorithm index

4c.2 Algorithm names

String Algorithm Key type
secp256k1 secp256k1 (Schnorr/ECDSA) Signature
ed25519 ed25519 Signature
ml-dsa-65 ML-DSA-65 (FIPS 204) Signature
slh-dsa-128s SLH-DSA-128s (FIPS 205) Signature
x25519 X25519 (ECDH) Key agreement
ml-kem-768 ML-KEM-768 (FIPS 203) KEM

4c.3 Request examples

Sign with ed25519:

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

Response: {"id":"1","result":{"signature":"<hex>","algorithm":"ed25519","key_id":"<16hex>"}}

Sign with secp256k1 (ECDSA scheme):

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

The scheme parameter is optional for secp256k1: "schnorr" (default, BIP-340) or "ecdsa".

Get public key (algorithm-based):

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

Response: {"id":"3","result":{"algorithm":"ml-dsa-65","public_key":"<hex>","key_id":"<16hex>"}}

KEM encapsulate:

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

KEM decapsulate:

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

ECDH shared secret (x25519):

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

4c.4 Verb aliases

These verb aliases are also available. When used with the algorithm parameter, they map to the algorithm-based verbs:

Alias Canonical verb Default algorithm
sign_data sign (from algorithm parameter)
ssh_sign sign ed25519
verify_signature verify (from algorithm parameter)
kem_encapsulate encapsulate ml-kem-768
kem_decapsulate decapsulate ml-kem-768

Without the algorithm parameter, these aliases fall through to the role-based path (backward compatible).

4c.5 Algorithm-based preapprove

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

4c.6 Enforcement matrix

Verb Valid algorithms
sign / verify secp256k1, ed25519, ml-dsa-65, slh-dsa-128s
encapsulate / decapsulate ml-kem-768
derive_shared_secret x25519
get_public_key all algorithms
sign_event / nip44_* / nip04_* / mine_event secp256k1 (Nostr protocol)

5. Wire contract (JSON-RPC)

The full, authoritative API reference now lives in api.md. The summary below is kept for orientation; for request/response shapes, error codes, and worked examples, consult api.md.

Request shape is JSON-RPC with NIP-46-style methods and optional trailing selector options.

{ "id": "1", "method": "get_public_key", "params": [] }
{ "id": "2", "method": "sign_event", "params": ["<event_json>", { "role": "main" }] }
{ "id": "3", "method": "sign_event", "params": ["<event_json>", { "nostr_index": 7 }] }
{ "id": "4", "method": "sign_event", "params": ["<event_json>", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] }
{ "id": "5", "method": "mine_event", "params": ["<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 }] }

Implemented signer verbs in this build:

Nostr verbs (secp256k1 only, use role or nostr_index selector):

  • get_public_key
  • sign_event
  • nip04_encrypt / nip04_decrypt
  • nip44_encrypt / nip44_decrypt
  • mine_event — add NIP-13 proof-of-work and sign (see below)

Algorithm-based verbs (use algorithm + index selector, no role needed):

Verb Valid algorithms Description
sign secp256k1, ed25519, ml-dsa-65, slh-dsa-128s Sign arbitrary bytes. Returns {"signature":"<hex>","algorithm":"<alg>","key_id":"<16hex>"}.
verify secp256k1, ed25519, ml-dsa-65, slh-dsa-128s Verify a signature. Returns {"valid":true/false}.
encapsulate ml-kem-768 Encapsulate against a peer's ML-KEM-768 public key. Returns {"ciphertext":"<hex>","shared_secret":"<hex>","algorithm":"ml-kem-768"}.
decapsulate ml-kem-768 Decapsulate a ciphertext using the derived ML-KEM-768 private key. Returns {"shared_secret":"<hex>","algorithm":"ml-kem-768"}.
derive_shared_secret x25519 ECDH key agreement. Returns {"shared_secret":"<hex>","algorithm":"x25519"}.
get_public_key all algorithms Returns the public key for the specified algorithm+index.

General encryption verbs (use curve parameter to select encryption method):

Verb curve value Description
encrypt otp One-time pad encryption (requires --otp-pad-dir + --otp-pad). Returns ASCII-armored or binary ciphertext.
decrypt otp One-time pad decryption. Returns plaintext (base64).
encrypt secp256k1 NIP-44 (default) or NIP-04 encryption. Params: [peer_pubkey, message, {nip_version: 4|44}].
decrypt secp256k1 NIP-44 (default) or NIP-04 decryption. Params: [peer_pubkey, ciphertext, {nip_version: 4|44}].
encrypt x25519 ECDH + symmetric encryption (not yet implemented — use derive_shared_secret + your own cipher).
encrypt ml-kem-768 KEM-based hybrid encryption (not yet implemented — use encapsulate/decapsulate + your own cipher).

OTP verb aliases (also available, same as encrypt/decrypt with curve: "otp"):

Verb Description
otp_encrypt Encrypt plaintext (base64) with the bound OTP pad. Returns ASCII-armored or binary ciphertext.
otp_decrypt Decrypt ciphertext with the bound OTP pad. Returns plaintext (base64).

See plans/otp_nostr_integration.md for the full OTP design.

Example sign request (algorithm-based):

{ "id": "6", "method": "sign", "params": ["<message_hex>", { "algorithm": "ed25519", "index": 0 }] }

Example otp_encrypt request:

{ "id": "7", "method": "otp_encrypt", "params": ["<plaintext_base64>", { "encoding": "ascii" }] }

mine_event — NIP-13 Proof-of-Work

Mines proof-of-work (adds a nonce tag per NIP-13) and signs the event in one step. The mining runs in a detached thread so the server stays responsive.

Parameters (in options object):

Option Required Default Description
difficulty One of difficulty/timeout 0 (no target) Target leading zero bits. Stops early if reached.
timeout_sec One of difficulty/timeout 600 (safety) Time budget in seconds. Always returns best result found.
threads No 1 Number of mining threads (max 32).

At least one of difficulty or timeout_sec must be specified. If both are given, mining stops when either condition is met. The response always includes the best event found — timeout is not an error.

Response format:

{
  "id": "5",
  "result": {
    "event": "<signed event JSON with nonce tag>",
    "achieved_difficulty": 18,
    "target_difficulty": 20,
    "target_reached": false,
    "elapsed_sec": 30,
    "attempts": 4523456
  }
}

Error codes:

  • 1007no_termination_condition (neither difficulty nor timeout_sec specified)
  • 1008mining_failed (internal error)

Selector resolution order:

  1. role
  2. nostr_index
  3. role_path
  4. default role main

Conflicting selectors are rejected (ambiguous_role_selector).

Representative error codes:

  • invalid_request
  • method_not_found
  • ambiguous_role_selector
  • unknown_role
  • purpose_mismatch
  • curve_mismatch
  • unauthorized
  • approval_denied
  • internal_error

6. Purpose and curve enforcement (role-based API)

Selector resolution chooses which role. Enforcement decides whether the requested method is valid for that role.

Note: The algorithm-based API (§4c) bypasses purpose/curve enforcement entirely — the caller specifies the algorithm directly, and the dispatcher derives the key on demand. Purpose values are only used by the role-based API for enforcement.

6.1 Purpose values (role-based API only)

Purpose Description
nostr Nostr identities (secp256k1, NIP-06)
bitcoin Bitcoin key trees (secp256k1, BIP-44)
ssh SSH signing keys (ed25519)
age age-style encryption identities (x25519)
fips FIPS mesh/service identities
pq-sig Post-quantum signatures (ML-DSA-65, SLH-DSA-128s)
pq-kem Post-quantum key encapsulation (ML-KEM-768)

6.2 Curve values

Curve Algorithms
secp256k1 ECDSA/Schnorr for Nostr, Bitcoin
ed25519 Ed25519 for SSH signatures
x25519 X25519 for key agreement (age)
ml-dsa-65 ML-DSA-65 (FIPS 204, lattice-based PQ signatures)
slh-dsa-128s SLH-DSA-128s (FIPS 205, hash-based PQ signatures)
ml-kem-768 ML-KEM-768 (FIPS 203, lattice-based PQ KEM)

6.3 Enforcement matrix (role-based API)

Verb Required purpose Required curve
sign_event nostr secp256k1
mine_event nostr secp256k1
nip04_encrypt / nip04_decrypt nostr secp256k1
nip44_encrypt / nip44_decrypt nostr secp256k1
get_public_key any any (must match role's declared curve)
sign_data / ssh_sign ssh or pq-sig ed25519, ml-dsa-65, or slh-dsa-128s
verify_signature ssh or pq-sig ed25519, ml-dsa-65, or slh-dsa-128s
kem_encapsulate / kem_decapsulate pq-kem ml-kem-768

The algorithm-based API (§4c) does not use this matrix — the caller specifies the algorithm directly, and enforcement is based on the verb+algorithm combination (see §4c.6).

Example (role-based):

  • sign_event requires purpose="nostr" and curve="secp256k1".
  • If caller selects a Bitcoin-role key for sign_event, request fails with purpose_mismatch.

This prevents cross-protocol misuse inside one mnemonic-rooted signer process. Fail-closed: any unlisted (verb, purpose, curve) combination is rejected.

7. Transport

7.1 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 between concurrent instances and leaks no seed-derived identifier

Naming rules:

  • Default: random pick at startup, displayed in the Connections section and status line of the running display.
  • Override: --socket-name <name> (alias: --name <name> / -n <name>) forces a specific name (useful for scripts and tests).
  • Collision: if the chosen random name is already bound by another process, nsigner retries with a fresh pair up to 8 times before erroring out with a hint to use an explicit name override.

Discovery:

  • nsigner list enumerates currently bound nsigner_* abstract sockets by reading /proc/net/unix.
  • nsigner --listen stdio runs one framed JSON-RPC request/response over stdin/stdout.
  • nsigner --listen qrexec is the same stdio framing mode, but caller identity can be derived from QREXEC_REMOTE_DOMAIN (displayed as qubes:<source-vm>).
  • nsigner --listen tcp:IPv4:PORT or tcp:[IPv6]:PORT enables FIPS/TCP listening for non-AF_UNIX clients (for example tcp:127.0.0.1:11111, tcp:[::]:11111, or tcp:[fd00::1234]:11111). Uses framed JSON protocol (not HTTP).
  • nsigner --listen http:HOST:PORT enables HTTP listening for curl-friendly access (for example http:127.0.0.1:11111). Uses standard HTTP POST with JSON body — no custom framing. CORS headers included for browser access. Defaults to localhost; pass http:0.0.0.0:PORT to 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 the qubes.NsignerRpc service entrypoint. See §8.3 and plans/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 as qubes:<vm> — matching the native qrexec identity path. This enables a persistent signer (mnemonic in mlock'd RAM) to receive qrexec-routed requests without spawning a fresh process per call.

curl examples (HTTP mode)

Start the signer in HTTP mode:

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

Sign a Nostr event:

curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
  -d '{"id":"1","method":"sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"}]}'

General encrypt (OTP, requires --otp-pad-dir and --otp-pad):

curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
  -d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"curve":"otp","encoding":"ascii"}]}'

General encrypt (NIP-44, secp256k1):

curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
  -d '{"id":"1","method":"encrypt","params":["<peer_pubkey_hex>","Hello!",{"role":"main"}]}'

General decrypt (OTP):

curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
  -d '{"id":"1","method":"decrypt","params":["-----BEGIN OTP MESSAGE-----...",{"curve":"otp","encoding":"ascii"}]}'

OTP verb aliases (same as encrypt/decrypt with curve=otp):

curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
  -d '{"id":"1","method":"otp_encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"encoding":"ascii"}]}'

Unix socket examples (framed mode)

# Get public key
nsigner --socket-name nsigner client '{"id":"1","method":"get_public_key","params":[{"role":"main"}]}'

# Sign event
nsigner --socket-name nsigner client '{"id":"1","method":"sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"}]}'

# Encrypt (OTP)
nsigner --socket-name nsigner client '{"id":"1","method":"encrypt","params":["SGVsbG8=",{"curve":"otp","encoding":"ascii"}]}'

# Encrypt (NIP-44)
nsigner --socket-name nsigner client '{"id":"1","method":"encrypt","params":["<peer_pubkey>","Hello!",{"role":"main"}]}'

7.2 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*)
  • Vendor/WebUSB framed JSON-RPC for browser tooling

Core signer logic stays the same; only transport/UI bindings change.

7.3 NIP-46 relay flow (both platforms)

NIP-46 request/response flow can be bridged on both desktop and MCU transports. The JSON-RPC signer contract remains consistent while the outer carrier differs.

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

8. Platform targets

8.1 Linux desktop (primary)

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

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

8.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. Uses framed JSON protocol. See documents/FIPS_DEPLOYMENT.md.

HTTP — the signer listens on http:127.0.0.1:11111 for curl-friendly access within the same qube. Uses standard HTTP POST with JSON body. No auth envelopes required (relies on localhost binding + policy/approval prompts). Add --listen http:127.0.0.1:11111 or select option 4 in the interactive transport menu.

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> (from QREXEC_REMOTE_DOMAIN), relayed via a trusted preamble. See plans/qrexec_persistent_bridge.md for the full design.

Qrexec bridge setup

In the signer qube (nostr_signer):

# Install nsigner and the qrexec service
bash setup_signer_qube.sh    # from packaging/qubes/

# Start the persistent signer (mnemonic entered at terminal, in mlock'd RAM)
~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted

In dom0:

# Install policy and tag the signer qube
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:

# JavaScript example (uses qrexec-client-vm, no auth envelope needed)
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.

9. Usage

9.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 in the Connections section and the bottom status line (see §3.2).

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

nsigner --name my_test_signer

Qubes/qrexec service mode (single framed request over stdin/stdout):

nsigner --listen qrexec

Generic stdio transport mode (single framed request over stdin/stdout):

nsigner --listen stdio

FIPS/TCP transport mode (framed JSON, no TUI; serves requests until terminated):

nsigner --listen tcp:[::]:11111

HTTP transport mode (curl-friendly, no TUI; serves requests until terminated):

nsigner --listen http:127.0.0.1:11111

With OTP pad bound (auto-detects pads on USB drives in interactive mode):

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; used as the qubes.NsignerRpc service):

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

9.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":"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":"sign_event","params":["<event_json>",{"role":"main"}]}'

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

9.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=sign_event role=main -> allow? (y/n)

Terminal B:

$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
{"id":"2","result":"<signed_event_json>"}

10. Build and versioning

Build outputs a single executable artifact.

Local dev build:

make dev
./build/nsigner --version

Static build:

./build_static.sh
./build/nsigner_static_x86_64 --version