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:
340
plans/otp_nostr_integration.md
Normal file
340
plans/otp_nostr_integration.md
Normal 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.
|
||||
Reference in New Issue
Block a user