726 lines
37 KiB
Markdown
726 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.
|
|
|
|
### 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`](src/secure_mem.c) 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`](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.
|
|
|
|
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, the terminal becomes a live status and control console rendered by [`render_status()`](src/main.c). The top frame shows the program name and version; below it are the Roles and Activity sections, followed by a single status line. Connection instructions are not shown by default — press `d` to display them on demand. Example layout:
|
|
|
|
```text
|
|
n_signer v0.0.53 > Main Menu
|
|
|
|
Roles:
|
|
Role Purpose Curve Derivation path
|
|
main nostr secp256k1 m/44'/1237'/0'/0/0
|
|
nostr_idx_1 nostr secp256k1 m/44'/1237'/1'/0/0
|
|
backup bitcoin secp256k1 m/84'/0'/0'/0/5
|
|
|
|
Activity (latest first):
|
|
16:03:11 allow caller=uid:1000 method=nostr_get_public_key role=main
|
|
16:02:44 prompt caller=uid:1000 method=nostr_sign_event role=ops
|
|
16:02:46 allow caller=uid:1000 method=nostr_sign_event role=ops
|
|
15:59:10 deny caller=uid:1001 method=nostr_sign_event error=unauthorized
|
|
|
|
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
|
|
|
|
l lock/reunlock
|
|
r refresh
|
|
a toggle auto-approve
|
|
d display connections
|
|
q/x quit
|
|
```
|
|
|
|
The **Derivation path** column shows the full BIP-44 path for each role's key. For `nostr_index` roles this is `m/44'/1237'/<n>'/0/0` (NIP-06); for `role_path` roles it's the explicit path.
|
|
|
|
The signer's name (`nsigner_hairy_dog` in this example) appears in the **status line** at the bottom (`signer=nsigner_hairy_dog`). See [§4.1](#41-linux-desktop-abstract-namespace-unix-socket) for how the name is generated.
|
|
|
|
### Connection instructions (press `d`)
|
|
|
|
Pressing `d` clears the screen and shows each active transport as a titled block with the connection string and an example client command. Press any key to return to the status display.
|
|
|
|
Hotkeys (active while the status display is shown):
|
|
|
|
- `a` — toggle auto-approve (prompt) for this session
|
|
- `r` — refresh the display
|
|
- `d` — display connection instructions (press any key to return)
|
|
- `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.
|
|
|
|
```text
|
|
Approval required
|
|
caller: uid:1000
|
|
method: nostr_sign_event
|
|
selector: role=ops
|
|
purpose/curve: nostr/secp256k1
|
|
|
|
[y] allow once [n] deny [a] always allow this session
|
|
```
|
|
|
|
No response is emitted to caller until the local user decides.
|
|
|
|
### 3.4 Shutdown / lock
|
|
|
|
- `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. API
|
|
|
|
`n_signer` exposes a JSON-RPC 2.0-style request/response protocol. Every request is a single JSON object; every response is a single JSON object. This section is the complete, authoritative description of the API.
|
|
|
|
For the migration plan from the legacy verb names, see [`plans/legacy_verb_aliases.md`](plans/legacy_verb_aliases.md).
|
|
|
|
### 4.1 Request format
|
|
|
|
```json
|
|
{ "id": "<string>", "method": "<verb>", "params": [ <arg0>, <arg1>, ..., { <options> } ] }
|
|
```
|
|
|
|
- `id` — caller-supplied string echoed verbatim in the response. Used to match requests to responses.
|
|
- `method` — the verb name (see [§4.2](#42-verbs)).
|
|
- `params` — a JSON array. Positional arguments come first; the **last array element** is conventionally an options object. The options object is optional for most verbs.
|
|
|
|
### 4.2 Response format
|
|
|
|
Success:
|
|
```json
|
|
{ "id": "<string>", "result": <value> }
|
|
```
|
|
|
|
`result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it.
|
|
|
|
Error:
|
|
```json
|
|
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
|
|
```
|
|
|
|
Error codes:
|
|
|
|
| Code | Message | Meaning |
|
|
|-------|-------------------------------|--------------------------------------------------------------------|
|
|
| -32700| `parse_error` | Request was not valid JSON. |
|
|
| -32600| `invalid_request` | Missing `id`, `method`, or `params`, or `params` is not an array. |
|
|
| -32601| `method_not_found` | Unknown verb, or verb not valid for the selected algorithm. |
|
|
| -32602| `invalid_params` | Malformed arguments (bad hex, wrong length, missing field, etc.). |
|
|
| 1001 | `ambiguous_role_selector` | More than one role selector was supplied. |
|
|
| 1002 | `unknown_role` | No role matched the selector. |
|
|
| 1003 | `no_default_role` | No selector given and no `main` role exists. |
|
|
| 1004 | `purpose_mismatch` | Role's purpose is not valid for this verb. |
|
|
| 1005 | `curve_mismatch` | Role's curve is not valid for this verb. |
|
|
| 1006 | `mnemonic_not_loaded` | No mnemonic is loaded in the signer. |
|
|
| 1007 | `no_termination_condition` | `nostr_mine_event` called without `difficulty` or `timeout_sec`. |
|
|
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
|
|
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
|
|
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
|
|
|
|
### 4.3 Verbs
|
|
|
|
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `nostr_index` and implement Nostr-protocol-specific serialization on top of the raw crypto.
|
|
|
|
| Verb | Algorithms | Positional params | Options |
|
|
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
|
|
| `get_public_key` | all key-deriving algorithms | — | `algorithm`, `index` |
|
|
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>` | `algorithm`, `index`, `scheme`* |
|
|
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>`, `<signature_hex>` | `algorithm`, `index`, `scheme`* |
|
|
| `encapsulate` | ml-kem-768 | `<peer_pubkey_hex>` | `algorithm` |
|
|
| `decapsulate` | ml-kem-768 | `<ciphertext_hex>` | `algorithm`, `index` |
|
|
| `derive_shared_secret` | x25519 | `<peer_pubkey_hex>` | `algorithm`, `index` |
|
|
| `derive` | secp256k1 | `<data>` | `algorithm`, `index` (required) |
|
|
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
|
|
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
|
|
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `nostr_index`, `format` |
|
|
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index` |
|
|
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` |
|
|
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
|
|
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
|
|
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
|
|
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
|
|
|
|
\* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
|
|
|
|
#### Enforcement matrix
|
|
|
|
| Verb | Valid algorithms |
|
|
|----------------------------|-----------------------------------------------|
|
|
| `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
|
|
| `encapsulate` / `decapsulate` | ml-kem-768 |
|
|
| `derive_shared_secret` | x25519 |
|
|
| `derive` | secp256k1 |
|
|
| `encrypt` / `decrypt` | otp |
|
|
| `get_public_key` | all key-deriving algorithms |
|
|
| `nostr_*` | secp256k1 (Nostr protocol) |
|
|
|
|
Any unlisted `(verb, algorithm)` pair is rejected with `algorithm_not_supported_for_verb` (1010).
|
|
|
|
### 4.4 Algorithms
|
|
|
|
All keys derive deterministically from the loaded BIP-39 mnemonic. The caller selects an algorithm by name and a derivation `index` (an integer `<n>` substituted into the algorithm's derivation path). OTP is the exception — it does not derive a key, it consumes a bound one-time pad (see [§4.4.3](#443-otp)).
|
|
|
|
#### 4.4.1 Algorithm table
|
|
|
|
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|
|
|-----------------|-----------------|---------------|---------------------------------------|-------------------------------|
|
|
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
|
|
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
|
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
|
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0/0'` → DRBG | 4032 / 1952 |
|
|
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
|
|
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
|
|
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
|
|
|
|
#### 4.4.2 Key derivation
|
|
|
|
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
|
|
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
|
|
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces PQClean's `randombytes()` callback during keygen. Same mnemonic, same index, same key pair every time.
|
|
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
|
|
|
|
The PQ implementations are vendored from [PQClean](https://github.com/PQClean/PQClean) (public domain / CC0). All six algorithms are always compiled in on every target. The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
|
|
|
|
#### 4.4.3 OTP
|
|
|
|
The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response.
|
|
|
|
### 4.5 Examples
|
|
|
|
#### `get_public_key`
|
|
|
|
```json
|
|
{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{ "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" }
|
|
```
|
|
|
|
`key_id` is the first 16 hex characters of the public key — a short display identifier.
|
|
|
|
#### `sign`
|
|
|
|
```json
|
|
{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{ "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" }
|
|
```
|
|
|
|
The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`:
|
|
|
|
```json
|
|
{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }
|
|
```
|
|
|
|
#### `verify`
|
|
|
|
```json
|
|
{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{ "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" }
|
|
```
|
|
|
|
The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library.
|
|
|
|
#### `encapsulate` (ML-KEM-768)
|
|
|
|
```json
|
|
{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{ "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
|
```
|
|
|
|
`peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`.
|
|
|
|
#### `decapsulate` (ML-KEM-768)
|
|
|
|
```json
|
|
{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
|
```
|
|
|
|
#### `derive_shared_secret` (X25519)
|
|
|
|
```json
|
|
{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{ "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" }
|
|
```
|
|
|
|
`peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
|
|
|
|
#### `derive` (secp256k1 HMAC-SHA256)
|
|
|
|
```json
|
|
{ "id": "10", "method": "derive", "params": [ "<data>", { "algorithm": "secp256k1", "index": 0 } ] }
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{ "id": "10", "result": "{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}" }
|
|
```
|
|
|
|
Computes `HMAC-SHA256(privkey, data)` where `privkey` is the secp256k1 private key derived on demand at `(algorithm: "secp256k1", index: N)`. `data` is an arbitrary caller-supplied UTF-8 string. Returns the 32-byte digest as 64 lowercase hex chars.
|
|
|
|
`index` is **required** (no default) — forces conscious selection of which derived key to use as the HMAC key. Omitting it returns `missing_index`.
|
|
|
|
This is a generic key-derived MAC primitive. Callers domain-separate by prefixing their own label into `data` (e.g. `"myapp/identifier-v1:<path>"`). The private key never leaves the signer; only the digest is returned. Use cases include deterministic, per-user, opaque identifiers for NIP-33 parameterized-replaceable events (e.g. bookmark folder `d` tags) where the same logical name must produce the same `d` tag across devices.
|
|
|
|
#### `encrypt` / `decrypt` (OTP)
|
|
|
|
```json
|
|
{ "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
|
{ "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
|
```
|
|
|
|
`encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used.
|
|
|
|
`encrypt` response:
|
|
```json
|
|
{
|
|
"id": "8",
|
|
"result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}"
|
|
}
|
|
```
|
|
|
|
`decrypt` response:
|
|
```json
|
|
{ "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" }
|
|
```
|
|
|
|
If no pad is bound at startup, the error is `-32601` `otp_pad_not_bound`.
|
|
|
|
#### `nostr_get_public_key`
|
|
|
|
```json
|
|
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "nostr_index": 0 } ] }
|
|
```
|
|
|
|
Response (default): a plain 64-hex-char secp256k1 public key string.
|
|
Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
|
|
|
|
#### `nostr_sign_event`
|
|
|
|
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
|
|
|
|
```json
|
|
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "nostr_index": 0 } ] }
|
|
```
|
|
|
|
`<event_json>` is the unsigned event object:
|
|
```json
|
|
{ "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" }
|
|
```
|
|
|
|
Response: the signed event JSON string, with `id` and `sig` populated.
|
|
|
|
#### `nostr_mine_event`
|
|
|
|
Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive.
|
|
|
|
```json
|
|
{
|
|
"id": "12",
|
|
"method": "nostr_mine_event",
|
|
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 } ]
|
|
}
|
|
```
|
|
|
|
| Option | Required | Default | Meaning |
|
|
|---------------|-------------------------------|---------|---------------------------------------------------------------|
|
|
| `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. |
|
|
| `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. |
|
|
| `threads` | no | 1 | Mining threads (clamped to 1..32). |
|
|
|
|
At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned.
|
|
|
|
Response:
|
|
```json
|
|
{
|
|
"id": "12",
|
|
"result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}"
|
|
}
|
|
```
|
|
|
|
Errors:
|
|
- `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given.
|
|
- `1008` `mining_failed` — internal mining error.
|
|
|
|
#### `nostr_nip04_encrypt` / `nostr_nip04_decrypt`
|
|
|
|
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
|
|
|
|
```json
|
|
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
|
|
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
|
|
```
|
|
|
|
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
|
|
|
|
#### `nostr_nip44_encrypt` / `nostr_nip44_decrypt`
|
|
|
|
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
|
|
|
|
```json
|
|
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
|
|
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
|
|
```
|
|
|
|
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
|
|
|
|
### 4.6 Role-based selectors (Nostr verbs)
|
|
|
|
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Supported selectors:
|
|
|
|
| Selector | Meaning |
|
|
|----------------|--------------------------------------------------|
|
|
| `nostr_index` | NIP-06 index `n` → path `m/44'/1237'/<n>'/0/0` |
|
|
| `role` | Name of a pre-registered role entry |
|
|
| `role_path` | Full BIP-44 derivation path (must match a registered role) |
|
|
|
|
Selector resolution order: `role` → `nostr_index` → `role_path` → default role `main`. Conflicting selectors are rejected with `ambiguous_role_selector` (1001). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
|
|
|
|
### 4.7 Pre-approval
|
|
|
|
Pre-approval entries skip the interactive prompt for matching requests. They are configured at startup with `--preapprove`.
|
|
|
|
Algorithm-based:
|
|
```bash
|
|
nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign,verify
|
|
nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsulate
|
|
```
|
|
|
|
Nostr (role-based):
|
|
```bash
|
|
nsigner --preapprove caller=uid:1000,nostr_index=0,verb=nostr_sign_event,nostr_get_public_key
|
|
```
|
|
|
|
A `*` wildcard matches any caller, role, or verb. Index ranges use `min-max` syntax. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).
|
|
|
|
## 5. Transports
|
|
|
|
The API is transport-independent. The same JSON request works over every transport; only the framing differs.
|
|
|
|
| Transport | `--listen` flag | Framing | Caller identity |
|
|
|-----------|--------------------------------|------------------------------------------|----------------------------|
|
|
| Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED` → `uid:<n>` |
|
|
| stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid |
|
|
| qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` |
|
|
| FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) |
|
|
| HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) |
|
|
|
|
### 5.1 HTTP examples
|
|
|
|
Start the signer:
|
|
```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":[{"algorithm":"ed25519","index":0}]}'
|
|
```
|
|
|
|
Sign a Nostr event:
|
|
```bash
|
|
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
|
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
|
|
```
|
|
|
|
OTP encrypt:
|
|
```bash
|
|
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
|
-d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}'
|
|
```
|
|
|
|
### 5.2 Unix socket examples (framed mode)
|
|
|
|
```bash
|
|
# get_public_key
|
|
nsigner --socket-name nsigner client \
|
|
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
|
|
|
# Sign a Nostr event
|
|
nsigner --socket-name nsigner client \
|
|
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
|
|
|
|
# ed25519 sign
|
|
nsigner --socket-name nsigner client \
|
|
'{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}'
|
|
```
|
|
|
|
### 5.3 Linux desktop: abstract namespace Unix socket
|
|
|
|
Primary local transport is AF_UNIX abstract namespace. Each running `nsigner` process binds to a unique abstract name of the form `@nsigner_<word1>_<word2>`, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. `@nsigner_hairy_dog`). This lets multiple signers coexist on one host.
|
|
|
|
Properties: no pathname in filesystem; endpoint lifetime bound to process/kernel namespace; no stale socket files; caller identity via peer credentials (`SO_PEERCRED`); per-launch random name avoids collisions and leaks no seed-derived identifier.
|
|
|
|
Naming rules:
|
|
- Default: random pick at startup, displayed in the status line.
|
|
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name.
|
|
- Collision: if the chosen random name is already bound, `nsigner` retries with a fresh pair up to 8 times before erroring out.
|
|
|
|
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, but caller identity comes from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
|
|
- `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables FIPS/TCP listening (framed JSON, not HTTP).
|
|
- `nsigner --listen http:HOST:PORT` enables HTTP listening for curl-friendly access. CORS headers included for browser access. Defaults to localhost; pass `http:0.0.0.0:PORT` to expose externally.
|
|
- `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 [`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>`.
|
|
|
|
### 5.4 ESP32 MCU: TinyUSB composite (CDC + WebUSB)
|
|
|
|
On Feather S3 TFT, MCU targets run the same core signer modules behind a TinyUSB composite transport: CDC-ACM framed JSON-RPC for serial tooling (`/dev/ttyACM*`) and Vendor/WebUSB framed JSON-RPC for browser tooling. Core signer logic stays the same; only transport/UI bindings change.
|
|
|
|
### 5.5 Caller verification
|
|
|
|
Every transport must provide concrete caller identity before policy evaluation.
|
|
|
|
- Linux AF_UNIX: map peer credentials to caller identity.
|
|
- ESP32 USB bridge: map authenticated host bridge identity to caller identity.
|
|
- Relay session: bind remote peer/session identity before allowing signer verbs.
|
|
|
|
Identity verification and interactive approval are separate layers. Passing identity checks does not bypass prompt requirements.
|
|
|
|
## 6. Platform targets
|
|
|
|
### 6.1 Linux desktop (primary)
|
|
|
|
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
|
|
|
|
### 6.2 ESP32 / MCU
|
|
|
|
MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps transport/UI for constrained hardware. The Feather path currently uses TinyUSB composite USB (CDC + WebUSB) plus TFT/buttons for attended approvals.
|
|
|
|
### 6.3 Qubes OS qube
|
|
|
|
Qubes deployment runs `n_signer` in a dedicated signer qube (e.g. `nostr_signer`) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
|
|
|
|
Three transport paths are supported:
|
|
|
|
**FIPS/TCP** — the signer listens on `tcp:[::]:11111` and FIPS carries traffic between qubes as an IPv6 mesh substrate. See [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md).
|
|
|
|
**HTTP** — the signer listens on `http:127.0.0.1:11111` for curl-friendly access within the same qube. No auth envelopes required (relies on localhost binding + policy/approval prompts).
|
|
|
|
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `nsigner bridge` relay (the `qubes.NsignerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>`. See [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md) for the full design.
|
|
|
|
#### Qrexec bridge setup
|
|
|
|
**In the signer qube** (`nostr_signer`):
|
|
```bash
|
|
bash setup_signer_qube.sh # from packaging/qubes/
|
|
~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
|
```
|
|
|
|
**In dom0**:
|
|
```bash
|
|
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
|
|
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).
|
|
|
|
## 7. Usage
|
|
|
|
### 7.1 Run the program
|
|
|
|
```bash
|
|
nsigner
|
|
```
|
|
|
|
Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the running status display shows the randomly assigned signer name and its abstract socket address.
|
|
|
|
To force a specific socket name (e.g. for scripted clients):
|
|
```bash
|
|
nsigner --name my_test_signer
|
|
```
|
|
|
|
Other transport modes:
|
|
```bash
|
|
nsigner --listen qrexec # Qubes qrexec (single framed request over stdin/stdout)
|
|
nsigner --listen stdio # Generic stdio (single framed request over stdin/stdout)
|
|
nsigner --listen tcp:[::]:11111 # FIPS/TCP (framed JSON, no TUI)
|
|
nsigner --listen http:127.0.0.1:11111 # HTTP (curl-friendly, no TUI)
|
|
```
|
|
|
|
With OTP pad bound:
|
|
```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):
|
|
```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
|
|
```
|
|
|
|
### 7.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":"nostr_get_public_key","params":[]}'
|
|
```
|
|
|
|
If only one signer is running you can omit the override and the client will use the default discovery rule.
|
|
|
|
Example signing request:
|
|
```bash
|
|
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
|
|
```
|
|
|
|
### 7.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
|
|
```
|
|
|
|
### 7.4 Example session
|
|
|
|
Terminal A:
|
|
```text
|
|
$ nsigner
|
|
[unlock] enter mnemonic:
|
|
System is ready and waiting for connections on @nsigner_hairy_dog.
|
|
[prompt] caller=uid:1000 method=nostr_sign_event role=main -> allow? (y/n)
|
|
```
|
|
|
|
Terminal B:
|
|
```text
|
|
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
|
|
{"id":"2","result":"<signed_event_json>"}
|
|
```
|
|
|
|
## 8. Build and versioning
|
|
|
|
Build outputs a single executable artifact.
|
|
|
|
- [`build_static.sh`](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
|
|
```
|