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

274
plans/http_wss_listener.md Normal file
View File

@@ -0,0 +1,274 @@
# Plan: HTTP and WebSocket (WSS) Listener Modes for n_signer
## Goal
Add two new listener modes to n_signer so that standard HTTP clients (curl) and
WebSocket clients (browsers, relay-style tools) can send JSON-RPC requests
directly, without the custom 4-byte length-prefixed framing.
## Current state
n_signer supports these listener modes:
- `unix` — abstract namespace Unix socket, 4-byte framed JSON
- `stdio` — stdin/stdout, 4-byte framed JSON, one request per invocation
- `qrexec` — same as stdio but with `QREXEC_REMOTE_DOMAIN` caller identity
- `tcp:HOST:PORT` — TCP, 4-byte framed JSON
All modes use the same dispatcher (`dispatcher_handle_request`) which takes a
JSON string and returns a JSON string. The only difference between modes is the
transport framing and caller-identity extraction.
## Proposed modes
### HTTP mode: `--listen http:HOST:PORT`
**Protocol:** HTTP/1.1 POST with JSON body → JSON response.
```
POST / HTTP/1.1
Content-Type: application/json
{"id":"1","method":"otp_encrypt","params":["SGVsbG8=",{"encoding":"ascii"}]}
HTTP/1.1 200 OK
Content-Type: application/json
{"id":"1","result":"{...}"}
```
**curl example:**
```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"}]}'
```
**Implementation:**
- Parse the HTTP request line + headers (minimal parser — just enough for POST)
- Read the Content-Length bytes as the JSON-RPC request
- Call `dispatcher_handle_request()`
- Write the HTTP response with the JSON result
**Difficulty: Low.** The HTTP parser can be minimal (~100 lines). No need for
a full HTTP server — just POST with a JSON body. No chunked encoding, no
keep-alive, no static file serving. One request per connection (connection
close after response), or simple keep-alive loop.
**Caller identity:** `tcp:<peer-addr>` (same as TCP mode). No `QREXEC_REMOTE_DOMAIN`.
**HTTPS variant:** `--listen https:HOST:PORT` wraps the HTTP listener in TLS.
Requires `--tls-cert <path>` and `--tls-key <path>` flags. OpenSSL is already
linked. The HTTP parser stays the same; TLS is layered underneath (accept
connection → TLS handshake → then HTTP). See Phase 3 below.
### WebSocket mode: `--listen wss:HOST:PORT` (or `ws:HOST:PORT`)
**Protocol:** WebSocket with JSON text frames.
```
Client → Server: text frame with JSON-RPC request
Server → Client: text frame with JSON-RPC response
```
**Browser example:**
```javascript
const ws = new WebSocket("ws://127.0.0.1:11111");
ws.onopen = () => {
ws.send(JSON.stringify({id:"1", method:"get_public_key", params:[{role:"main"}]}));
};
ws.onmessage = (e) => {
console.log(JSON.parse(e.data));
};
```
**Implementation:**
- HTTP upgrade handshake (Sec-WebSocket-Key → Sec-WebSocket-Accept)
- WebSocket frame parser (opcode, mask, payload length — 7/16/64 bit)
- Text frames (opcode 0x1) carry JSON-RPC requests
- Response as text frames (opcode 0x1, unmasked from server)
- One JSON-RPC request per text frame; one response per request
**Difficulty: Medium.** The WebSocket handshake is straightforward (SHA-1 +
base64 of the key + magic GUID). The frame parser needs to handle:
- 7-bit, 16-bit, and 64-bit payload lengths
- Client-to-server masking (XOR with 4-byte mask)
- Ping/pong frames (opcode 0x9/0xA)
- Close frame (opcode 0x8)
- Fragmentation (continuation frames, opcode 0x0) — can defer/skip for v1
A minimal implementation is ~200-300 lines. No need for a full RFC 6455
compliance — just enough for curl, browsers, and common WebSocket clients.
**WSS (TLS) variant:** `wss:HOST:PORT` wraps the WebSocket in TLS. This
requires linking against OpenSSL (already a dependency) and adding TLS
handshake + certificate handling. Difficulty: Medium-High due to cert
management. For v1, `ws:` (plaintext) is sufficient for localhost/FIPS-mesh
use; `wss:` can be added later.
**Caller identity:** `ws:<peer-addr>` or `wss:<peer-addr>`.
## Security concerns
### 1. Network exposure
**Unix sockets** are only accessible from the same host/qube — no network
exposure. **HTTP/WS** listeners bind to a TCP port, which is potentially
reachable from other hosts/qubes on the network.
**Mitigation:**
- Default bind address: `127.0.0.1` (localhost only), not `[::]` (all
interfaces). The user must explicitly pass `--listen http:0.0.0.0:11111` to
expose it.
- The existing policy/approval system is the real gate — network access alone
doesn't grant signing. Unknown callers get `PROMPT_EVERY_REQUEST` by default.
### 2. No authentication in HTTP/WS mode
The current TCP mode has no authentication either — it relies on the
policy/approval prompt. HTTP/WS would be the same. Anyone who can reach the
port can send requests, but they still need approval for sensitive operations.
**Mitigation:**
- The `--auth required` mode (auth envelopes) could be extended to HTTP/WS.
The client would include a signed auth envelope in an HTTP header
(e.g. `X-Nsigner-Auth: <envelope>`) or as a WebSocket subprotocol header.
- For v1, rely on localhost binding + policy/approval prompts, same as TCP.
### 3. CORS for browser access
If the WebSocket listener is accessed from a browser running on a different
origin, CORS headers are needed for the HTTP upgrade handshake.
**Mitigation:**
- Add `Access-Control-Allow-Origin: *` to the WebSocket upgrade response
(the policy/approval system is the real security boundary, not CORS).
- Or restrict to same-origin only (no CORS header → browser blocks
cross-origin).
### 4. Request size limits — MUST be raised for OTP blob encryption
The current `SERVER_MAX_MSG_SIZE` is 65536 bytes (64KB). This is too small
for OTP encryption of images or blobs:
- A 48KB image → base64 in JSON param = ~64KB → hits the limit
- A 1MB image → base64 = ~1.33MB → way over
- OTP Padmé padding rounds up to the next power of 2, and the response
contains the base64-encoded ciphertext, which is even larger
**This is a pre-existing limitation that affects ALL transport modes**, not
just HTTP/WS. It should be fixed before or alongside the HTTP/WS work.
**Proposed change:** raise `SERVER_MAX_MSG_SIZE` to 16MB (16777216). This
accommodates:
- Up to ~12MB plaintext (base64 = ~16MB in the JSON param)
- The OTP response (base64-encoded padded ciphertext) for the same
- The `stdin_buf[SERVER_MAX_MSG_SIZE + 1]` stack buffer in `client_main`
should be changed to a heap allocation to avoid a 16MB stack frame
The `transport_recv_framed` function already takes a max-size parameter, so
the change is: update the constant, change the stack buffer to malloc, and
verify the dispatcher doesn't have other hardcoded size assumptions.
**For HTTP/WS:** enforce the same `SERVER_MAX_MSG_SIZE` limit via
`Content-Length` checking in the HTTP parser.
### 5. TLS for WSS
For `wss:` mode, TLS is required. This means:
- Certificate management (self-signed for local/FIPS mesh, or CA-signed for
public-facing).
- The signer would need a `--tls-cert` and `--tls-key` flag.
- Pinning: clients should verify the cert fingerprint, not just trust the CA.
**For v1:** skip WSS/TLS. Provide `ws:` (plaintext) only, suitable for
localhost and FIPS mesh (which has its own network-level isolation). Add
`wss:` later when there's a real public-facing use case.
### 6. Connection flooding
HTTP/WS listeners are susceptible to connection flooding (many open
connections, slowloris-style attacks). The current single-threaded poll loop
handles one connection at a time, which naturally limits flood impact but
also limits throughput.
**Mitigation:**
- Connection timeout (close idle connections after N seconds).
- Max connections limit.
- For v1, the single-threaded model is fine — it's a signer, not a web server.
## Implementation plan
### Phase 0: Raise SERVER_MAX_MSG_SIZE (prerequisite)
- [ ] Change `SERVER_MAX_MSG_SIZE` from 65536 to 16777216 (16MB) in all
`.c` files where it's defined (the headerless-decls pattern means it's
redefined in every file).
- [ ] Change the `stdin_buf[SERVER_MAX_MSG_SIZE + 1]` stack buffer in
`client_main` to a heap allocation (malloc/free) to avoid a 16MB stack
frame.
- [ ] Verify `transport_recv_framed` handles the larger size correctly (it
already takes max-size as a parameter).
- [ ] Test with a large OTP encrypt/decrypt round-trip (e.g. 1MB plaintext).
### Phase 1: HTTP listener mode
- [ ] Add `NSIGNER_LISTEN_HTTP` to the listen mode enum.
- [ ] Parse `--listen http:HOST:PORT` in the CLI flag parser.
- [ ] Implement a minimal HTTP/1.1 parser in `src/http_listener.c`:
- Read request line (method, path, HTTP version)
- Read headers (only need Content-Length)
- Read body (Content-Length bytes)
- Reject non-POST methods with 405
- Reject oversized bodies with 413
- [ ] Call `dispatcher_handle_request()` with the body, write JSON response
with `200 OK` + `Content-Type: application/json`.
- [ ] Caller identity: `http:<peer-ip>:<peer-port>`.
- [ ] Default bind: `127.0.0.1` (not `[::]`).
- [ ] Add to interactive transport menu as option 4.
- [ ] Test with curl.
### Phase 2: WebSocket listener mode
- [ ] Add `NSIGNER_LISTEN_WS` to the listen mode enum.
- [ ] Parse `--listen ws:HOST:PORT` in the CLI flag parser.
- [ ] Implement WebSocket handshake + frame parser in `src/ws_listener.c`:
- HTTP upgrade handshake (Sec-WebSocket-Key → Accept)
- Frame parser (opcode, mask, payload length)
- Text frames → JSON-RPC request → dispatcher → text frame response
- Ping/pong handling
- Close frame handling
- [ ] Caller identity: `ws:<peer-ip>:<peer-port>`.
- [ ] Default bind: `127.0.0.1`.
- [ ] Add to interactive transport menu as option 5.
- [ ] Test with a browser or `websocat`.
### Phase 3: TLS mode (HTTPS + WSS)
- [ ] Add `--tls-cert <path>` and `--tls-key <path>` CLI flags.
- [ ] Add `--listen https:HOST:PORT` — HTTP listener wrapped in TLS.
- [ ] Add `--listen wss:HOST:PORT` — WebSocket listener wrapped in TLS.
- [ ] TLS handshake via OpenSSL (already a dependency):
- Load cert/key from files
- `SSL_CTX_new(TLS_server_method())`
- `SSL_CTX_use_certificate_file()` / `SSL_CTX_use_PrivateKey_file()`
- Per-connection: `SSL_new()``SSL_set_fd()``SSL_accept()`
- Replace read/write calls with `SSL_read()` / `SSL_write()`
- [ ] Self-signed cert generation helper (or document `openssl req` command).
- [ ] Certificate pinning guidance for clients (verify fingerprint, not just CA).
- [ ] curl usage: `curl --cacert <cert.pem>` or `curl -k` (insecure, for testing).
- [ ] Not strictly needed for localhost/FIPS mesh use, but good for defense in depth.
## Difficulty assessment
| Mode | Difficulty | New code | Dependencies | Risk |
|---|---|---|---|---|
| HTTP | Low | ~150 lines | None (minimal parser) | Low — simple POST/JSON |
| WS | Medium | ~300 lines | None (minimal frame parser) | Medium — frame parsing edge cases |
| HTTPS | Medium | ~150 + ~80 TLS | OpenSSL (already linked) | Medium — cert management |
| WSS | Medium-High | ~300 + ~80 TLS | OpenSSL (already linked) | Higher — cert + WS edge cases |
## Recommendation
Start with HTTP mode (Phase 1) — it's the simplest and immediately enables
curl access. Add WebSocket (Phase 2) if browser access is needed. Defer WSS
(Phase 3) until there's a real public-facing use case.

View File

@@ -44,7 +44,7 @@ Each selected transport gets configured with sensible defaults:
|---|---|---|
| 1. Unix socket | `--listen unix` | socket name `nsigner` (or random if collision) |
| 2. Qrexec bridge | `--listen unix --bridge-source-trusted` | socket name `nsigner`, accepts qrexec preamble |
| 3. TCP | `--listen tcp:[::]:8080` | bind all interfaces, port 8080 |
| 3. TCP | `--listen tcp:[::]:11111` | bind all interfaces, port 11111 |
| 4. Qrexec one-shot | `--listen qrexec` | one request via stdin, then exit |
**Choices 1 and 2 can coexist** — they're both unix listeners, just with different identity handling. Choice 2 implies choice 1's socket. If both are selected, the bridge-source-trusted flag is set on the single unix listener (it handles both local and bridge connections).
@@ -66,7 +66,7 @@ Currently `main()` creates one `server_ctx_t` and polls `server.listen_fd` + `ST
```text
Connections
listen: unix @nsigner (bridge-source-trusted)
listen: tcp [::]:8080
listen: tcp [::]:11111
client: nsigner --socket-name nsigner client '<json>'
```
@@ -116,7 +116,7 @@ After confirmation, the selected listeners are started and the status display re
### 3.3 Transport setup from menu selection
- [ ] Unix: `server_init` + `server_start` with `NSIGNER_LISTEN_UNIX`, socket name `nsigner`.
- [ ] Qrexec bridge: same as unix + `server_set_bridge_source_trusted(&server, 1)`.
- [ ] TCP: `server_init` + `server_start` with `NSIGNER_LISTEN_TCP`, target `tcp:[::]:8080`.
- [ ] TCP: `server_init` + `server_start` with `NSIGNER_LISTEN_TCP`, target `tcp:[::]:11111`.
- [ ] Qrexec one-shot: existing `NSIGNER_LISTEN_QREXEC` path (stdin, one request, exit).
### 3.4 TUI status display
@@ -126,7 +126,7 @@ After confirmation, the selected listeners are started and the status display re
### 3.5 Testing
- [ ] Interactive: start nsigner with no `--listen`, select unix+TCP, verify both listeners work.
- [ ] Interactive: select qrexec bridge, verify bridge connections get `qubes:<vm>` identity.
- [ ] CLI: `--listen tcp:[::]:8080` still works (skips menu).
- [ ] CLI: `--listen tcp:[::]:11111` still works (skips menu).
- [ ] CLI: `--listen unix --bridge-source-trusted` still works (skips menu).
- [ ] Non-TTY: `--mnemonic-stdin` without `--listen` defaults to unix (no menu).
@@ -138,6 +138,6 @@ After confirmation, the selected listeners are started and the status display re
2. **Should there be a hotkey to add/remove transports at runtime?** E.g. press `t` in the TUI to bring up the transport menu again. This is a nice-to-have but adds complexity. **Decision: defer — the menu is startup-only for now.**
3. **TCP port selection in the menu?** The menu could ask for a port number if TCP is selected. **Decision: default to 8080, let the user override with `--listen tcp:...` if they need a different port. Keep the menu simple.**
3. **TCP port selection in the menu?** The menu could ask for a port number if TCP is selected. **Decision: default to 11111, let the user override with `--listen tcp:...` if they need a different port. Keep the menu simple.**
4. **Socket name selection?** Same — default to `nsigner`, override with `--socket-name` if needed. **Decision: default, no prompt.**

View File

@@ -0,0 +1,340 @@
# Plan: OTP-encrypted Nostr events via n_signer
## Goal
Store encrypted blobs on Nostr (kind `30078` replaceable parameterized events) that are
**information-theoretically secure** — unbreakable by any computer, quantum or classical,
forever — because they are encrypted with a one-time pad (OTP) sourced from the
[`otp`](../otp) project.
A client program sends plaintext (or a ciphertext) to `n_signer` over its existing
JSON-RPC transport. `n_signer` performs the OTP XOR against pad material it reads from a
USB drive, advances the per-pad offset, and returns the ciphertext (or plaintext). The
caller then wraps the result in a Nostr `30078` event and signs/publishes it via the
existing `sign_event` verb.
Both output encodings are supported, matching the standalone `otp` tool:
- **ASCII armored** (`-----BEGIN OTP MESSAGE-----` + base64): text-safe, for embedding
directly in Nostr event `content` (kind `30078`).
- **Binary** (`.otp` structured header + raw encrypted bytes): for uploading to Blossom
servers as a blob and referencing from a Nostr event by SHA-256 hash. The caller
receives the binary blob base64-encoded in the JSON-RPC response and decodes it
before uploading.
For testing, pad material can be generated from local entropy (`/dev/urandom` or
keyboard entropy) using the `otp` tool. The eventual production target is a USB drive
holding the pad, accessed by `n_signer` at runtime. A future microcontroller hardware
signer that carries the pad onboard is explicitly out of scope for this plan and is
tracked separately.
## Design summary
- `n_signer` gains two new verbs: `otp_encrypt` and `otp_decrypt`.
- Pad material lives on a USB drive (file path supplied at startup). `n_signer` reads
only the slice it needs, XORs in `mlock`'d RAM, and writes the new offset back to the
pad's `.state` file on the USB drive.
- The pad is **not** loaded whole into RAM; it is seeked-and-read per request. This
preserves the spirit of the zero-filesystem-footprint model (no pad material is ever
copied onto the host disk; the only on-disk artifact is the offset counter on the USB
drive itself, which is required for multi-device coordination).
- The encrypted payload is returned in either ASCII-armored or binary `.otp` format,
selected per request via an `encoding` option. ASCII armor is base64-safe for JSON
and Nostr event `content`; binary `.otp` is for Blossom blob uploads.
- The caller is responsible for building and publishing the `30078` event; `n_signer`
only does the OTP transform and (separately) signs the event when asked.
```mermaid
flowchart LR
Client[Client program] -->|otp_encrypt JSON-RPC| NS[n_signer]
NS -->|seek + read slice| USB[USB pad file .pad]
NS -->|read/advance offset| State[USB .state file]
NS -->|XOR in mlock RAM| CT[Ciphertext blob]
NS -->|return ascii-armored| Client
Client -->|wrap in 30078 event| Event[Nostr event JSON]
Client -->|sign_event| NS
NS -->|schnorr sig| Client
Client -->|publish| Relay[Nostr relay]
```
## Architecture decisions
### 1. New verbs, not a new transport
OTP operations are just new verbs on the existing dispatcher
([`src/dispatcher.c`](src/dispatcher.c:1)). They use the same framed JSON-RPC,
policy, approval, and enforcement machinery as `sign_event` / `nip44_encrypt`. No new
transport is needed.
### 2. Pad storage on USB, not in mnemonic RAM
The OTP pad is far too large to live in `mlock`'d RAM (gigabytes) and is not
mnemonic-derived. It lives on a USB drive mounted at a path `n_signer` is told at
startup via a new `--otp-pad-dir <path>` flag. `n_signer` opens the pad file
read-only, seeks to the current offset, reads exactly `chunk_size` bytes (after
Padmé padding), XORs against the (padded) plaintext in a small `mlock`'d scratch
buffer, and writes the advanced offset back to `<chksum>.state` on the USB drive.
This is a deliberate, narrow exception to the "zero filesystem footprint" rule: the
only filesystem artifact `n_signer` touches is the offset counter on the USB drive
itself, which is mandatory for pad-reuse avoidance across devices. No pad bytes and
no plaintext ever touch the host disk.
### 2a. Qubes OS USB access strategy
On Qubes, the signer qube must have sole access to the pad-bearing USB drive. The
chosen strategy is **PCI USB controller passthrough** (Option A): an entire USB
controller is assigned to the signer qube via `qvm-pci attach`, so dom0, `sys-usb`,
and every other qube are blind to the pad device. The drive appears as a normal
`/dev/sd*` inside the signer qube.
Fallback if no spare controller is available: **`qvm-block attach` from `sys-usb`**
(Option B), accepting that `sys-usb` briefly enumerates the device and block I/O
transits dom0's blkback (a traffic-analysis concern, not a plaintext-leak concern
since pad bytes stay encrypted-on-disk).
Code-level guard (Option D): `n_signer` refuses to open `--otp-pad-dir` unless the
underlying device is a directly-owned PCI device (`/dev/sd*` from a passthrough
controller) when running under Qubes; blkback devices (`/dev/xvdi`) are rejected
unless `--otp-allow-blkback` is explicitly passed. This makes "sole access" a
code-level invariant, not just an operator convention.
Offset writes use atomic write-temp-then-rename so a crash mid-write cannot corrupt
the `.state` file. The offset is advanced **only after** the XOR succeeds and the
ciphertext is handed back to the caller.
### 3. Reuse the `otp` project's file formats and padding
- Pad file format: `<chksum>.pad` raw random bytes, with a 32-byte header reserved
(matches [`../otp/src/pads.c`](../otp/src/pads.c:1) `offset=32` initial reservation).
- State file format: `<chksum>.state` containing `offset=<n>\n` (matches
[`../otp/src/pads.c:284`](../otp/src/pads.c:284) `read_state_offset`).
- ASCII armor format: `-----BEGIN OTP MESSAGE-----` with `Pad-ChkSum` and
`Pad-Offset` headers (matches [`../otp/src/crypto.c`](../otp/src/crypto.c:1)
`parse_ascii_message` / `generate_ascii_armor`).
- Padding: exponential bucketing + ISO/IEC 9797-1 Method 2 (Padmé) from
[`../otp/src/padding.c`](../otp/src/padding.c:1).
This means pads generated by the standalone `otp` CLI are bit-compatible with pads
consumed by `n_signer`, and ciphertexts produced by either tool are interchangeable.
### 4. Code sharing strategy
Rather than vendoring a copy of the `otp` source into `n_signer`, extract the
format-critical functions into a small shared static library `libotppad` that both
projects link against. Candidates to extract:
- `universal_xor_operation` ([`../otp/src/crypto.c:35`](../otp/src/crypto.c:35))
- `parse_ascii_message` / `generate_ascii_armor`
- `calculate_chunk_size` / `apply_padme_padding` / `remove_padme_padding`
([`../otp/src/padding.c`](../otp/src/padding.c:1))
- `read_state_offset` / `write_state_offset`
([`../otp/src/pads.c:284`](../otp/src/pads.c:284))
- `calculate_checksum` (pad identification)
`n_signer` then only needs to implement: USB pad directory config, the two new
dispatcher verbs, the seek-read-XOR-write-offset loop, and approval/policy wiring.
### 5. Nostr event shape (kind 30078)
The caller builds the event; `n_signer` does not. Two payload patterns:
**A. ASCII armor in event content** (text-safe, self-contained):
```json
{
"kind": 30078,
"content": "-----BEGIN OTP MESSAGE-----\nVersion: v0.3.53\nPad-ChkSum: <64hex>\nPad-Offset: <n>\n\n<base64>\n-----END OTP MESSAGE-----",
"tags": [
["d", "<caller-chosen-d-tag>"],
["otp-pad", "<16-char chksum prefix>"],
["otp-version", "v0.3.53"],
["otp-encoding", "ascii"]
],
...
}
```
**B. Binary `.otp` uploaded to Blossom, referenced by hash** (for binaries/large blobs):
```json
{
"kind": 30078,
"content": "",
"tags": [
["d", "<caller-chosen-d-tag>"],
["otp-pad", "<16-char chksum prefix>"],
["otp-version", "v0.3.53"],
["otp-encoding", "binary"],
["blob", "<sha256-hex>", "<mimetype>", "<size-bytes>"],
["url", "<blossom-url>"]
],
...
}
```
In both cases the `Pad-Offset` (in the ASCII armor header, or in the binary `.otp`
file header) is what a decrypting device uses to seek into its copy of the same pad.
The `otp-pad` tag lets a reader find the right pad without parsing the payload first.
The `otp-encoding` tag tells the reader whether to look in `content` or follow the
`blob`/`url` tags to Blossom.
### 6. Multi-device offset coordination (deferred)
Out of scope for v1. The `.state` file on the device's USB drive is the local source
of truth for how far that device has consumed the pad, and the `Pad-Offset` header
in each ciphertext's ASCII armor / binary header records which slice was used. That
is sufficient for single-device operation.
If multi-device pad sharing is added later (two devices holding copies of the same
`.pad`), a dedicated signed coordination event would be needed so devices never
reuse a slice. That event does not have to be kind `30078` and is not designed here.
Note: kind `30078` is a Nostr application-data convention, not part of the OTP spec —
the `otp` project has no Nostr code today.
## Phased implementation
### Phase 0 — Test pad on the USB drive ✅ Done
- [x] Created `pads/` directory on the mounted USB drive.
- [x] Generated a 1 MB test pad from `/dev/urandom` with a 32-byte reserved header,
using [`tools/make_test_pad.c`](tools/make_test_pad.c:1).
- [x] Computed the pad's 256-bit XOR checksum (matching the `otp` project's
[`../otp/src/crypto.c:242`](../otp/src/crypto.c:242) `calculate_checksum`
algorithm) and named the pad file by that checksum.
- [x] Wrote the initial `.state` file (`offset=32\n`).
- [x] Verified the checksum matches the filename via an independent re-computation.
**Actual test pad on this qube:**
| Field | Value |
|---|---|
| USB drive | SanDisk 3.2 Gen1, 466 GB, `/dev/sda1` |
| Mount point | `/media/user/Music` (FAT32, label "Music") |
| Pad directory | `/media/user/Music/pads` |
| Pad file | `333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.pad` |
| State file | `333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.state` |
| Size | 1,048,576 bytes (1 MB) |
| Chksum prefix | `333e9902db839d9d` |
| Initial offset | 32 (header reserved) |
Note: the drive is currently mounted at `/media/user/Music` (its FAT32 label is
"Music"), not at `/media/user/USBDISK`. For `n_signer` testing, pass
`--otp-pad-dir /media/user/Music/pads`. This pad is for local-entropy testing only;
production pads will be generated with the `otp` CLI (keyboard/TRNG entropy) or a
future hardware signer.
### Phase 1 — Shared `libotppad` extraction
- [ ] Create `libotppad/` directory with the format-critical functions extracted from
[`../otp/src/crypto.c`](../otp/src/crypto.c:1), [`../otp/src/padding.c`](../otp/src/padding.c:1),
and [`../otp/src/pads.c`](../otp/src/pads.c:1).
- [ ] Add a `libotppad.h` public header declaring: `universal_xor_operation`,
`parse_ascii_message`, `generate_ascii_armor`, `calculate_chunk_size`,
`apply_padme_padding`, `remove_padme_padding`, `read_state_offset`,
`write_state_offset`, `calculate_checksum`.
- [ ] Refactor the `otp` project to link against `libotppad` instead of its own copies.
- [ ] Add unit tests for `libotppad` (round-trip encrypt/decrypt, padding edge cases,
state file read/write).
### Phase 2 — `n_signer` USB pad directory support
- [ ] Add `--otp-pad-dir <path>` CLI flag to [`src/main.c`](src/main.c:1); store the
path in a new `otp_pad_state_t` alongside the existing mnemonic/role state.
- [ ] Add a `--otp-pad <chksum-or-prefix>` flag (or interactive selector) to pick
**the single pad** that is active for this session. One pad per session — the
pad is bound at startup and cannot be switched without restarting `n_signer`.
- [ ] Implement `otp_pad_open(chksum)` / `otp_pad_read_slice(offset, len)` /
`otp_pad_advance_offset(delta)` helpers in a new `src/otp_pad.c`.
- [ ] Validate the pad's checksum matches the requested chksum before first use.
- [ ] Refuse to operate if the pad directory is on the same filesystem as `/` (require
it to be a removable mount — best-effort check via `statvfs`).
### Phase 3 — `otp_encrypt` / `otp_decrypt` verbs
- [ ] Add `VERB_OTP_ENCRYPT "otp_encrypt"` and `VERB_OTP_DECRYPT "otp_decrypt"` to
[`src/dispatcher.c`](src/dispatcher.c:1).
- [ ] Request shapes (plaintext is base64 in the JSON param for both text and
binary; the pad is the one bound at startup, so no `pad` field is needed):
```json
{ "id": "1", "method": "otp_encrypt",
"params": [ "<plaintext-base64>", { "encoding": "ascii|binary" } ] }
```
```json
{ "id": "2", "method": "otp_decrypt",
"params": [ "<ciphertext-ascii-armor-or-base64-otp-blob>", { "encoding": "ascii|binary" } ] }
```
- [ ] `otp_encrypt` flow: padmé-pad plaintext → seek to offset → read slice → XOR in
`mlock`'d scratch → advance offset → return ciphertext in requested encoding
(`ascii` → ASCII-armored string, `binary` → base64-encoded `.otp` blob in the
JSON result).
- [ ] `otp_decrypt` flow: accept either ASCII armor or base64-encoded binary `.otp`
blob → parse header → seek to `Pad-Offset` → read slice → XOR in `mlock`'d
scratch → strip padding → return plaintext (in requested encoding).
- [ ] Add an `encoding` option to both verbs: `"encoding": "ascii"` (default) or
`"encoding": "binary"`. For `otp_encrypt`, controls output format. For
`otp_decrypt`, tells the signer what format the input is in (auto-detection by
magic bytes `OTP\0` is a fallback).
- [ ] Wire both verbs into the policy/enforcement table
([`src/policy.c`](src/policy.c:1), [`src/enforcement.c`](src/enforcement.c:1))
with **per-session grant** approval: the first `otp_encrypt` / `otp_decrypt`
call for the session prompts the user; once granted, subsequent calls on the
same pad in the same session do not re-prompt. This matches the `[a] always
allow this session` hotkey behavior already in [`README.md`](README.md:1).
- [ ] Add approval-prompt display fields: pad chksum prefix, offset before/after,
plaintext length bucket.
### Phase 4 — Local-entropy test path
- [ ] Document the test workflow: generate a small pad with the `otp` CLI
(`./otp generate 1MB`) into a directory, point `n_signer` at it with
`--otp-pad-dir`, run `otp_encrypt` round-trips from a client.
- [ ] Add an integration test in [`tests/test_integration.c`](tests/test_integration.c:1)
that: starts `n_signer` with a temp pad dir, calls `otp_encrypt` then
`otp_decrypt`, asserts round-trip equality, and asserts the offset advanced by
the padded chunk size.
- [ ] Add a test that confirms a ciphertext produced by `n_signer` can be decrypted by
the standalone `otp` CLI (cross-compatibility).
### Phase 5 — Nostr 30078 client example
- [ ] Add `examples/otp_nostr_30078.c` showing: call `otp_encrypt` → build a kind 30078
event with the ASCII armor as `content` → call `sign_event` → print the signed
event for publishing.
- [ ] Add a matching `examples/otp_nostr_30078_decrypt.c` showing: fetch a 30078 event
→ call `otp_decrypt` with its `content` → print recovered plaintext.
- [ ] Document the workflow in [`documents/`](documents/) and link from
[`README.md`](README.md:1).
### Phase 6 (deferred) — USB-bound pad hardening
- [ ] Detect removable-mount requirement more strictly (udev properties).
- [ ] Optional: read-only mount enforcement, pad integrity re-check on each request.
- [ ] Optional: per-pad `mlock`'d offset cache so a crash mid-request does not corrupt
the `.state` file (write-offset-after-success-only is already the plan).
### Phase 7 (explicitly deferred) — Microcontroller hardware signer with onboard pad
Tracked separately. When it lands, the `--otp-pad-dir` path is replaced by a transport
call (serial/WebUSB) to a pad-serving firmware, and the verbs stay identical.
## Decisions
1. **Plaintext encoding in `otp_encrypt` params.** Accept base64 in the JSON param
for both text and binary plaintext; the signer decodes it. Output encoding is the
`ascii` vs `binary` option described above.
2. **One pad per session.** The pad is bound at `n_signer` startup via
`--otp-pad-dir` + `--otp-pad` and cannot be switched without restarting the
signer. Simpler and safer for v1.
3. **No pad-heartbeat event in v1.** The `.state` file on the USB drive is the local
source of truth; the `Pad-Offset` header in each ciphertext records the slice
used. Multi-device pad sharing and any coordination event are deferred. (Kind
`30078` is a Nostr application-data convention, not part of the OTP spec.)
4. **Per-session grant approval.** The first `otp_encrypt` / `otp_decrypt` call in a
session prompts the user; once granted, subsequent calls on the same pad do not
re-prompt. Matches the existing `[a] always allow this session` behavior.
5. **Qubes USB strategy.** PCI USB controller passthrough (Option A) is the target;
`qvm-block` from `sys-usb` (Option B) is the fallback. For development/testing,
the USB drive is already accessible in this qube at `/media/user/Music` (see
Phase 0). Code-level guard (Option D) rejects blkback devices unless
`--otp-allow-blkback` is passed.