817 lines
37 KiB
Markdown
817 lines
37 KiB
Markdown
# 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 '<json>'
|
||
Qrexec: qrexec-client-vm <target_qube> 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 '<json>'
|
||
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'/<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`](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](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": "<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 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":"<hex>","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":"<hex>","key_id":"<16hex>"}}`
|
||
|
||
**KEM encapsulate:**
|
||
```json
|
||
{"id":"4","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]}
|
||
```
|
||
|
||
**KEM decapsulate:**
|
||
```json
|
||
{"id":"5","method":"decapsulate","params":["<ciphertext_hex>",{"algorithm":"ml-kem-768","index":0}]}
|
||
```
|
||
|
||
**ECDH shared secret (x25519):**
|
||
```json
|
||
{"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)
|
||
|
||
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": ["<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. |
|
||
|
||
**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": ["<message_hex>", { "algorithm": "ed25519", "index": 0 }] }
|
||
```
|
||
|
||
Example `otp_encrypt` request:
|
||
|
||
```jsonc
|
||
{ "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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
- `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_<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 TUI banner.
|
||
- 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](#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":"<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:
|
||
```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:<source-vm>` (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":["<event_json>",{"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":["<event_json>",{"role":"main"}]}'
|
||
{"id":"2","result":"<signed_event_json>"}
|
||
```
|
||
|
||
## 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
|