# 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. ## 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`](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, socket address, and OTP pad status shown in the banner. 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`](plans/mnemonic_startup_input.md) for the full behavior contract. ### 3.2 Running phase (status display + signer) After unlock, terminal becomes a live status and control console. Example layout: ```text n_signer v0.x | foreground session active Connections Server: unix @nsigner (bridge-source-trusted) Client: nsigner --socket-name nsigner client '' Qrexec: qrexec-client-vm qubes.NsignerRpc Server: HTTP 127.0.0.1:11111 Client: curl -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' -d '' OTP pad: 333e9902db839d9d... (offset 288 / 1048576 bytes) session: unlocked (RAM-only) Roles ----- main purpose=nostr curve=secp256k1 selector=role:main ops purpose=nostr curve=secp256k1 selector=nostr_index:7 backup purpose=bitcoin curve=secp256k1 selector=role_path:m/84'/0'/0'/0/5 Pending approvals ----------------- (none) 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 Hotkeys ------- a toggle auto-approve(prompt) for this session r refresh l lock/reunlock 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: ```text 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'/'/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`](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'/'/0/0` (NIP-06) | | ed25519 | `ed25519` | `ssh` | — | 32 / 32 bytes | `m/44'/102001'/'/0'/0'` (SLIP-0010) | | x25519 | `x25519` | `age` | — | 32 / 32 bytes | `m/44'/102002'/'/0'/0'` (SLIP-0010) | | ML-DSA-65 | `ml-dsa-65` | `pq-sig` | FIPS 204 | 4032 / 1952 bytes | `m/44'/102003'/'/0'/0'` → seed → DRBG → PQClean keygen | | SLH-DSA-128s | `slh-dsa-128s` | `pq-sig` | FIPS 205 | 64 / 32 bytes | `m/44'/102004'/'/0'/0'` → seed → DRBG → PQClean keygen | | ML-KEM-768 | `ml-kem-768` | `pq-kem` | FIPS 203 | 2400 / 1184 bytes | `m/44'/102005'/'/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](https://github.com/PQClean/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`](documents/SECURITY.md) §17 for the security argument. The new algorithms use BIP-44 coin types `102001`–`102005` (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): ```json {"algorithm": "ml-dsa-65", "public_key": "", "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 New 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:** ```json {"id":"1","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]} ``` Response: `{"id":"1","result":{"signature":"","algorithm":"ed25519","key_id":"<16hex>"}}` **Sign with secp256k1 (ECDSA scheme):** ```json {"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):** ```json {"id":"3","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]} ``` Response: `{"id":"3","result":{"algorithm":"ml-dsa-65","public_key":"","key_id":"<16hex>"}}` **KEM encapsulate:** ```json {"id":"4","method":"encapsulate","params":["",{"algorithm":"ml-kem-768"}]} ``` **KEM decapsulate:** ```json {"id":"5","method":"decapsulate","params":["",{"algorithm":"ml-kem-768","index":0}]} ``` **ECDH shared secret (x25519):** ```json {"id":"6","method":"derive_shared_secret","params":["",{"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) Request shape is JSON-RPC with NIP-46-style methods and optional trailing selector options. ```jsonc { "id": "1", "method": "get_public_key", "params": [] } { "id": "2", "method": "sign_event", "params": ["", { "role": "main" }] } { "id": "3", "method": "sign_event", "params": ["", { "nostr_index": 7 }] } { "id": "4", "method": "sign_event", "params": ["", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] } { "id": "5", "method": "mine_event", "params": ["", { "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":"","algorithm":"","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":"","shared_secret":"","algorithm":"ml-kem-768"}`. | | `decapsulate` | ml-kem-768 | Decapsulate a ciphertext using the derived ML-KEM-768 private key. Returns `{"shared_secret":"","algorithm":"ml-kem-768"}`. | | `derive_shared_secret` | x25519 | ECDH key agreement. Returns `{"shared_secret":"","algorithm":"x25519"}`. | | `get_public_key` | all algorithms | Returns the public key for the specified algorithm+index. | **OTP verbs** (one-time pad encryption, requires `--otp-pad-dir` + `--otp-pad`): | 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`](plans/otp_nostr_integration.md) for the full OTP design. Example `sign` request (algorithm-based): ```jsonc { "id": "6", "method": "sign", "params": ["", { "algorithm": "ed25519", "index": 0 }] } ``` Example `otp_encrypt` request: ```jsonc { "id": "7", "method": "otp_encrypt", "params": ["", { "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:** ```json { "id": "5", "result": { "event": "", "achieved_difficulty": 18, "target_difficulty": 20, "target_reached": false, "elapsed_sec": 30, "attempts": 4523456 } } ``` **Error codes:** - `1007` — `no_termination_condition` (neither difficulty nor timeout_sec specified) - `1008` — `mining_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](#4c6-enforcement-matrix)). 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__`, 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 TUI banner. - Override: `--socket-name ` (alias: `--name ` / `-n `) 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:`). - `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 ` 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](#83-qubes-os-qube) and [`plans/qrexec_persistent_bridge.md`](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":""}` preamble before the request, and the caller identity is composed as `qubes:` — 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: ```bash nsigner --listen http:127.0.0.1:11111 --allow-all ``` Get a public key: ```bash 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: ```bash 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"}]}' ``` OTP encrypt (requires `--otp-pad-dir` and `--otp-pad`): ```bash 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"}]}' ``` OTP decrypt: ```bash curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ -d '{"id":"1","method":"otp_decrypt","params":["-----BEGIN OTP MESSAGE-----...","{"encoding":"ascii"}]}' ``` #### Unix socket examples (framed mode) ```bash # 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"}]}' # OTP encrypt nsigner --socket-name nsigner client '{"id":"1","method":"otp_encrypt","params":["SGVsbG8=",{"encoding":"ascii"}]}' ``` ### 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`](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:` (from `QREXEC_REMOTE_DOMAIN`), relayed via a trusted preamble. See [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md) for the full design. #### Qrexec bridge setup **In the signer qube** (`nostr_signer`): ```bash # 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**: ```bash # 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**: ```bash # 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/`](packaging/qubes/). See also [`documents/QUBES_OS.md`](documents/QUBES_OS.md) and [`documents/qubes_client_examples.md`](documents/qubes_client_examples.md). ## 9. Usage ### 9.1 Run the program ```bash nsigner ``` Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the TUI banner shows the randomly assigned signer name and its abstract socket address. To force a specific socket name (e.g. for scripted clients): ```bash nsigner --name my_test_signer ``` Qubes/qrexec service mode (single framed request over stdin/stdout): ```bash nsigner --listen qrexec ``` Generic stdio transport mode (single framed request over stdin/stdout): ```bash nsigner --listen stdio ``` FIPS/TCP transport mode (framed JSON, no TUI; serves requests until terminated): ```bash nsigner --listen tcp:[::]:11111 ``` HTTP transport mode (curl-friendly, no TUI; serves requests until terminated): ```bash nsigner --listen http:127.0.0.1:11111 ``` With OTP pad bound (auto-detects pads on USB drives in interactive mode): ```bash 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): ```bash nsigner bridge --to nsigner ``` Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble): ```bash 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: ```bash 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: ```bash nsigner -n nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["",{"role":"main"}]}' ``` ### 9.3 List running signers ```bash nsigner list ``` Prints the abstract socket names of any currently running `nsigner` instances, e.g.: ```text @nsigner_hairy_dog @nsigner_brave_canyon ``` ### 9.4 Example session Terminal A: ```text $ nsigner [unlock] enter mnemonic: [ok] session unlocked signer name : hairy dog socket : @nsigner_hairy_dog [listen] unix-abstract:@nsigner_hairy_dog [prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n) ``` Terminal B: ```text $ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["",{"role":"main"}]}' {"id":"2","result":""} ``` ## 10. Build and versioning Build outputs a single executable artifact. - [`build_static.sh`](build_static.sh): musl-static build path - [`Makefile`](Makefile): local build targets - [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain - [`resources/tui_continuous/tui_continuous.h`](resources/tui_continuous/tui_continuous.h) + [`resources/tui_continuous/tui_continuous.c`](resources/tui_continuous/tui_continuous.c): vendored continuous-TUI component (from `~/lt/aesthetics`, API baseline `TUI_CONTINUOUS_VERSION 0.0.9`) - [`increment_and_push.sh`](increment_and_push.sh): version/tag/release workflow - [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`) Local dev build: ```bash make dev ./build/nsigner --version ``` Static build: ```bash ./build_static.sh ./build/nsigner_static_x86_64 --version ``` ## 11. Implemented adjuncts and future work ### Implemented features - **HTTP listener mode (`--listen http:HOST:PORT`).** Curl-friendly HTTP POST with JSON body. CORS headers included. See [§7.1](#71-linux-desktop-abstract-namespace-unix-socket) for curl examples. - **OTP one-time pad encryption (`otp_encrypt` / `otp_decrypt` verbs).** Information-theoretically secure encryption using one-time pads from the [`otp`](../otp) project. Pad material on USB drive, auto-detected at startup. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md). - **Algorithm-based API (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`).** Caller specifies algorithm + index, no role needed. See [§4c](#4c-algorithm-based-api). - **MCU / USB signer (Feather ESP32-S3 Reverse TFT).** Working PoC in [`firmware/feather_s3_tft`](firmware/feather_s3_tft). Single TinyUSB composite USB device exposes both CDC-ACM and WebUSB Vendor interfaces. Same dispatcher serves both transports with auth envelope verification, on-device TFT prompts, and physical button approval. See [`firmware/README.md`](firmware/README.md) and [`plans/feather_tinyusb_composite.md`](plans/feather_tinyusb_composite.md). ### Future work (deferred) These items are designed and worth doing, but are not in the current implementation scope. They are listed here so they are not lost. - **HTTPS / WSS (TLS) listener modes.** `--listen https:HOST:PORT` and `--listen wss:HOST:PORT` with `--tls-cert` / `--tls-key`. See [`plans/http_wss_listener.md`](plans/http_wss_listener.md). - **WebSocket listener mode (`--listen ws:HOST:PORT`).** For browser access with JSON text frames. See [`plans/http_wss_listener.md`](plans/http_wss_listener.md). - **NIP-46 bunker / relay transport.** Tracked in [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md). - **Browser extension.** Tracked in [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md). NIP-07 surface forwarding to a running `nsigner` instance. ## 12. Document map - [`README.md`](README.md): authoritative behavior specification for the foreground single-program model - [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md): client integration contract and framing behavior - [`documents/QUBES_OS.md`](documents/QUBES_OS.md): Qubes OS deployment/integration checklist for dedicated signer qubes - [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md): Tier-1 FIPS deployment runbook using loopback TCP listener - [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md): design for the qrexec → unix-socket bridge transport (persistent signer, no mnemonic on disk) - [`packaging/qubes/`](packaging/qubes/): qrexec service script, dom0 policy, and setup scripts for Qubes deployment - [`examples/n_signer_qube_example_qrexec.js`](examples/n_signer_qube_example_qrexec.js): JavaScript caller example using qrexec (no network, no auth envelope) - [`examples/n_signer_qube_example_fips.js`](examples/n_signer_qube_example_fips.js): JavaScript caller example using FIPS/TCP with auth envelope - [`examples/get_pubkey_tcp.c`](examples/get_pubkey_tcp.c): C caller example using TCP transport with auth envelope - [`plans/nsigner.md`](plans/nsigner.md): implementation plan and sequencing - [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md): seed phrase domain/use catalog and caveats - [`plans/post_quantum_crypto.md`](plans/post_quantum_crypto.md): post-quantum and multi-algorithm crypto expansion plan (all 8 phases) - [`firmware/feather_s3_tft`](firmware/feather_s3_tft): Feather ESP32-S3 Reverse TFT firmware (TinyUSB composite CDC + WebUSB signer PoC) - [`plans/feather_tinyusb_composite.md`](plans/feather_tinyusb_composite.md): firmware Phase 7b plan and outcome (TinyUSB composite USB transport) - [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md): browser extension exposing NIP-07 over `nsigner` - [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md): deferred NIP-46 relay-mode signer transport - [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md): OTP one-time pad integration plan and design - [`plans/http_wss_listener.md`](plans/http_wss_listener.md): HTTP/WS/WSS listener modes plan - [`libotppad/`](libotppad/libotppad.h): shared OTP pad format library (XOR, ASCII armor, binary .otp, Padmé padding, state files, checksum) - [`src/otp_pad.c`](src/otp_pad.c) / [`src/otp_pad.h`](src/otp_pad.h): OTP pad state management for n_signer - [`src/http_listener.c`](src/http_listener.c) / [`src/http_listener.h`](src/http_listener.h): minimal HTTP/1.1 parser for HTTP listener mode - [`tools/make_test_pad.c`](tools/make_test_pad.c): generate a test OTP pad on a USB drive - [`tools/otp_roundtrip_test.py`](tools/otp_roundtrip_test.py): end-to-end OTP encrypt/decrypt round-trip test - [`examples/otp_nostr_30078.py`](examples/otp_nostr_30078.py): example — encrypt with OTP, wrap in Nostr kind 30078 event, sign - [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration