17 KiB
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 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 eventcontent(kind30078). - Binary (
.otpstructured 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_signergains two new verbs:otp_encryptandotp_decrypt.- Pad material lives on a USB drive (file path supplied at startup).
n_signerreads only the slice it needs, XORs inmlock'd RAM, and writes the new offset back to the pad's.statefile 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
.otpformat, selected per request via anencodingoption. ASCII armor is base64-safe for JSON and Nostr eventcontent; binary.otpis for Blossom blob uploads. - The caller is responsible for building and publishing the
30078event;n_signeronly does the OTP transform and (separately) signs the event when asked.
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). 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>.padraw random bytes, with a 32-byte header reserved (matches../otp/src/pads.coffset=32initial reservation). - State file format:
<chksum>.statecontainingoffset=<n>\n(matches../otp/src/pads.c:284read_state_offset). - ASCII armor format:
-----BEGIN OTP MESSAGE-----withPad-ChkSumandPad-Offsetheaders (matches../otp/src/crypto.cparse_ascii_message/generate_ascii_armor). - Padding: exponential bucketing + ISO/IEC 9797-1 Method 2 (Padmé) from
../otp/src/padding.c.
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)parse_ascii_message/generate_ascii_armorcalculate_chunk_size/apply_padme_padding/remove_padme_padding(../otp/src/padding.c)read_state_offset/write_state_offset(../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):
{
"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):
{
"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
- Created
pads/directory on the mounted USB drive. - Generated a 1 MB test pad from
/dev/urandomwith a 32-byte reserved header, usingtools/make_test_pad.c. - Computed the pad's 256-bit XOR checksum (matching the
otpproject's../otp/src/crypto.c:242calculate_checksumalgorithm) and named the pad file by that checksum. - Wrote the initial
.statefile (offset=32\n). - 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/padding.c, and../otp/src/pads.c. - Add a
libotppad.hpublic 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
otpproject to link againstlibotppadinstead 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 tosrc/main.c; store the path in a newotp_pad_state_talongside 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 restartingn_signer. - Implement
otp_pad_open(chksum)/otp_pad_read_slice(offset, len)/otp_pad_advance_offset(delta)helpers in a newsrc/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 viastatvfs).
Phase 3 — otp_encrypt / otp_decrypt verbs
- Add
VERB_OTP_ENCRYPT "otp_encrypt"andVERB_OTP_DECRYPT "otp_decrypt"tosrc/dispatcher.c. - Request shapes (plaintext is base64 in the JSON param for both text and
binary; the pad is the one bound at startup, so no
padfield is needed):{ "id": "1", "method": "otp_encrypt", "params": [ "<plaintext-base64>", { "encoding": "ascii|binary" } ] }{ "id": "2", "method": "otp_decrypt", "params": [ "<ciphertext-ascii-armor-or-base64-otp-blob>", { "encoding": "ascii|binary" } ] } otp_encryptflow: padmé-pad plaintext → seek to offset → read slice → XOR inmlock'd scratch → advance offset → return ciphertext in requested encoding (ascii→ ASCII-armored string,binary→ base64-encoded.otpblob in the JSON result).otp_decryptflow: accept either ASCII armor or base64-encoded binary.otpblob → parse header → seek toPad-Offset→ read slice → XOR inmlock'd scratch → strip padding → return plaintext (in requested encoding).- Add an
encodingoption to both verbs:"encoding": "ascii"(default) or"encoding": "binary". Forotp_encrypt, controls output format. Forotp_decrypt, tells the signer what format the input is in (auto-detection by magic bytesOTP\0is a fallback). - Wire both verbs into the policy/enforcement table
(
src/policy.c,src/enforcement.c) with per-session grant approval: the firstotp_encrypt/otp_decryptcall 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 sessionhotkey behavior already inREADME.md. - 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
otpCLI (./otp generate 1MB) into a directory, pointn_signerat it with--otp-pad-dir, runotp_encryptround-trips from a client. - Add an integration test in
tests/test_integration.cthat: startsn_signerwith a temp pad dir, callsotp_encryptthenotp_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_signercan be decrypted by the standaloneotpCLI (cross-compatibility).
Phase 5 — Nostr 30078 client example
- Add
examples/otp_nostr_30078.cshowing: callotp_encrypt→ build a kind 30078 event with the ASCII armor ascontent→ callsign_event→ print the signed event for publishing. - Add a matching
examples/otp_nostr_30078_decrypt.cshowing: fetch a 30078 event → callotp_decryptwith itscontent→ print recovered plaintext. - Document the workflow in
documents/and link fromREADME.md.
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.statefile (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
- Plaintext encoding in
otp_encryptparams. Accept base64 in the JSON param for both text and binary plaintext; the signer decodes it. Output encoding is theasciivsbinaryoption described above. - One pad per session. The pad is bound at
n_signerstartup via--otp-pad-dir+--otp-padand cannot be switched without restarting the signer. Simpler and safer for v1. - No pad-heartbeat event in v1. The
.statefile on the USB drive is the local source of truth; thePad-Offsetheader in each ciphertext records the slice used. Multi-device pad sharing and any coordination event are deferred. (Kind30078is a Nostr application-data convention, not part of the OTP spec.) - Per-session grant approval. The first
otp_encrypt/otp_decryptcall 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 sessionbehavior. - Qubes USB strategy. PCI USB controller passthrough (Option A) is the target;
qvm-blockfromsys-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-blkbackis passed.