v0.0.48 - Added OTP one-time pad encryption (otp_encrypt/otp_decrypt verbs), HTTP listener mode (--listen http:HOST:PORT), interactive OTP pad auto-scan on USB drives, raised SERVER_MAX_MSG_SIZE to 16MB, updated README with curl examples and current API documentation

This commit is contained in:
Laan Tungir
2026-07-19 11:07:07 -04:00
parent a7c6de2dcd
commit 05c055503d
41 changed files with 4225 additions and 114 deletions

176
README.md
View File

@@ -20,7 +20,8 @@ This is a **program, not a daemon**:
- purpose/curve enforcement
- request dispatch
- interactive terminal UI
- transport adapter(s)
- 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.
@@ -74,9 +75,16 @@ When started, `n_signer` immediately enters terminal input mode:
- 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. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
4. Initialize transport endpoints and bind the socket.
5. Switch to running status display, with the signer name and socket address shown in the banner.
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.
@@ -93,7 +101,15 @@ After unlock, terminal becomes a live status and control console. Example layout
```text
n_signer v0.x | foreground session active
transport: unix-abstract:@nsigner
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
@@ -277,11 +293,11 @@ Response: `{"id":"3","result":{"algorithm":"ml-dsa-65","public_key":"<hex>","key
{"id":"6","method":"derive_shared_secret","params":["<peer_pubkey_hex>",{"algorithm":"x25519","index":0}]}
```
### 4c.4 Old verb aliases
### 4c.4 Verb aliases
Old verb names map to the new algorithm-based verbs when used with the `algorithm` parameter:
These verb aliases are also available. When used with the `algorithm` parameter, they map to the algorithm-based verbs:
| Old verb | New verb | Default algorithm |
| Alias | Canonical verb | Default algorithm |
|---|---|---|
| `sign_data` | `sign` | (from `algorithm` parameter) |
| `ssh_sign` | `sign` | `ed25519` |
@@ -289,7 +305,7 @@ Old verb names map to the new algorithm-based verbs when used with the `algorith
| `kem_encapsulate` | `encapsulate` | `ml-kem-768` |
| `kem_decapsulate` | `decapsulate` | `ml-kem-768` |
Without the `algorithm` parameter, old verbs fall through to the role-based path (backward compatible).
Without the `algorithm` parameter, these aliases fall through to the role-based path (backward compatible).
### 4c.5 Algorithm-based preapprove
@@ -322,7 +338,7 @@ Request shape is JSON-RPC with NIP-46-style methods and optional trailing select
Implemented signer verbs in this build:
**Nostr verbs** (require `purpose="nostr"`, `curve="secp256k1"`):
**Nostr verbs** (secp256k1 only, use `role` or `nostr_index` selector):
- `get_public_key`
- `sign_event`
@@ -330,27 +346,36 @@ Implemented signer verbs in this build:
- `nip44_encrypt` / `nip44_decrypt`
- `mine_event` — add NIP-13 proof-of-work and sign (see below)
**Multi-algorithm verbs**:
**Algorithm-based verbs** (use `algorithm` + `index` selector, no role needed):
| Verb | Required purpose | Required curve | Description |
|---|---|---|---|
| `get_public_key` | any | any | Returns the role's public key. secp256k1 returns plain hex; other algorithms return structured JSON (see [§4b.4](#4b4-structured-get_public_key-response)). |
| `sign_data` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` | Sign arbitrary bytes (not a Nostr event). Returns `{"signature":"<hex>","algorithm":"<alg>"}`. |
| `verify_signature` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` | Verify a signature. Returns `{"valid":true/false}`. |
| `ssh_sign` | `ssh` | `ed25519` | Sign an SSH authentication challenge in ed25519 format. Returns `{"signature":"<hex>","algorithm":"ed25519"}`. |
| `kem_encapsulate` | `pq-kem` | `ml-kem-768` | Encapsulate against a peer's ML-KEM-768 public key. Returns `{"ciphertext":"<hex>","shared_secret":"<hex>","algorithm":"ml-kem-768"}`. |
| `kem_decapsulate` | `pq-kem` | `ml-kem-768` | Decapsulate a ciphertext using the role's ML-KEM-768 private key. Returns `{"shared_secret":"<hex>","algorithm":"ml-kem-768"}`. |
| 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. |
Example `sign_data` request:
**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_data", "params": ["<message_hex>", { "role": "pq-ml-dsa" }] }
{ "id": "6", "method": "sign", "params": ["<message_hex>", { "algorithm": "ed25519", "index": 0 }] }
```
Example `kem_encapsulate` request:
Example `otp_encrypt` request:
```jsonc
{ "id": "7", "method": "kem_encapsulate", "params": ["<peer_pubkey_hex>", { "role": "pq-kem-main" }] }
{ "id": "7", "method": "otp_encrypt", "params": ["<plaintext_base64>", { "encoding": "ascii" }] }
```
### `mine_event` — NIP-13 Proof-of-Work
@@ -409,11 +434,13 @@ Representative error codes:
- `approval_denied`
- `internal_error`
## 6. Purpose and curve enforcement
## 6. Purpose and curve enforcement (role-based API)
Selector resolution chooses *which* role. Enforcement decides *whether the requested method is valid* for that role.
### 6.1 Purpose values
**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 |
|---|---|
@@ -436,7 +463,7 @@ Selector resolution chooses *which* role. Enforcement decides *whether the reque
| `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
### 6.3 Enforcement matrix (role-based API)
| Verb | Required purpose | Required curve |
|---|---|---|
@@ -445,18 +472,16 @@ Selector resolution chooses *which* role. Enforcement decides *whether the reque
| `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` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
| `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` |
| `ssh_sign` | `ssh` | `ed25519` |
| `kem_encapsulate` | `pq-kem` | `ml-kem-768` |
| `kem_decapsulate` | `pq-kem` | `ml-kem-768` |
| `kem_encapsulate` / `kem_decapsulate` | `pq-kem` | `ml-kem-768` |
Example:
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"`.
- `mine_event` requires `purpose="nostr"` and `curve="secp256k1"` (same as `sign_event`).
- If caller selects a Bitcoin-role key for `sign_event`, request fails with `purpose_mismatch`.
- If caller selects a `pq-sig`/`ml-dsa-65` 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.
@@ -487,10 +512,55 @@ 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 TCP listening for non-AF_UNIX clients (for example `tcp:127.0.0.1:8080`, `tcp:[::]:8080`, or `tcp:[fd00::1234]:8080`).
- `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:
@@ -528,9 +598,11 @@ MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps t
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).
Two transport paths are supported:
Three transport paths are supported:
**FIPS/TCP** — the signer listens on `tcp:[::]:8080` and FIPS carries traffic between qubes as an IPv6 mesh substrate. See [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md).
**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.
@@ -592,10 +664,22 @@ Generic stdio transport mode (single framed request over stdin/stdout):
nsigner --listen stdio
```
TCP transport mode (no TUI; serves requests until terminated):
FIPS/TCP transport mode (framed JSON, no TUI; serves requests until terminated):
```bash
nsigner --listen tcp:[::]:8080
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):
@@ -687,15 +771,19 @@ Static build:
## 11. Implemented adjuncts and future work
### Implemented PoC
### 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.
- **`--listen http:[addr]:port` mode.** Today the TCP listener speaks 4-byte big-endian length-prefixed framed JSON-RPC, which is correct for low-overhead local IPC but is not directly reachable from web browsers (`fetch`, `XMLHttpRequest`, `curl`). A small additional listener that wraps the same dispatcher in minimal HTTP/1.1 (`POST /rpc`, `Content-Type: application/json`, `Content-Length`-framed body, JSON response) would let standard HTTP clients talk to `nsigner` without any custom framing code. The existing auth envelope (`kind:27235`) and JSON-RPC contract are unchanged; only the outer framing differs. CORS allow on the response would let browser extensions and (with TLS) HTTPS pages reach a remote `nsigner` over FIPS or any other carrier. See the discussion in [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md) section 9 ("Next hardening steps").
- **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.
@@ -717,4 +805,12 @@ These items are designed and worth doing, but are not in the current implementat
- [`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