Files
n_signer/plans/otp_nostr_integration.md

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 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.
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>.pad raw random bytes, with a 32-byte header reserved (matches ../otp/src/pads.c offset=32 initial reservation).
  • State file format: <chksum>.state containing offset=<n>\n (matches ../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 parse_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:

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/urandom with a 32-byte reserved header, using tools/make_test_pad.c.
  • Computed the pad's 256-bit XOR checksum (matching the otp project's ../otp/src/crypto.c:242 calculate_checksum algorithm) and named the pad file by that checksum.
  • Wrote the initial .state file (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.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; 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.
  • 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):
    { "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_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/enforcement.c) 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.
  • 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 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/ and link from README.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 .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.