- otppad_embedded: bit-compatible port of libotppad (2386/2386 host tests pass) - otp_pad_sd: SdFat-direct SD card pad reader (FAT-only, ASCII armor + binary .otp) - pad_gen.ino: TRNG-sourced 1 MB pad generator using i.MX RT1062 TRNG registers - Linker script: moved .rodata from DTCM to FLASH (EXCLUDE_FILE ed25519), reclaiming 124 KB DTCM, free stack 5.9 KB -> 130.9 KB - check_stack.sh: build-time FlexRAM stack gauge, wired into build_signer.sh - test_otp_sd.py: 8/9 hardware tests pass (ASCII + binary round-trips, offset advance, tamper detection; 10 KB plaintext times out on perf) - test_classical.py: 16/16 pass with new memory layout (ed25519 OK) - Memory evaluation document: plans/teensy41_memory_evaluation.md
17 KiB
Plan: Real SD-card OTP pad for the Teensy 4.1 signer
Goal
Replace the Teensy 4.1 firmware's throwaway HKDF-derived 1024-byte in-RAM OTP
pad (firmware/teensy41/signer/src/otp_pad.cpp)
with a real SD-card pad that reads <chksum>.pad / <chksum>.state from
the Teensy's built-in SD slot, bit-compatible with the otp project and the
host n_signer (src/otp_pad.c) via libotppad.
This means the firmware's encrypt/decrypt verbs must gain:
- Padmé padding (ISO/IEC 9797-1 Method 2) with exponential bucketing.
- ASCII armored output (
-----BEGIN OTP MESSAGE-----+ base64) and binary.otpoutput (magicOTP\0+ 58-byte header), selected per request via anencodingoption — matching the host'sotp_encrypt/otp_decryptverbs. - Per-pad offset persistence in
<chksum>.state(atomic write-temp-then-rename on the SD card). - Pad binding at startup (mount SD, find pad by chksum, verify checksum, read offset).
The interactive "look for existing pads and ask the user to confirm one" UI flow is the last phase. Until then, a debug auto-bind path lets us test encrypt/decrypt round-trips over USB CDC without touching the screen.
Non-goals
- Multi-device offset coordination (deferred in
plans/otp_nostr_integration.md). - Nostr kind-30078 event wrapping (caller's job, same as host).
- Production pad entropy: the test pad is
/dev/urandom-sourced, fine for validating the format and round-trips.
Architecture
flowchart LR
SD[(SD card exFAT<br/>pads/chksum.pad<br/>pads/chksum.state)] -->|SD.begin BUILTIN_SDCARD| M[otp_pad_sd.cpp<br/>mount + bind + seek/read]
M -->|otppad_embedded| L[libotppad port<br/>XOR, base64, Padme, armor, checksum]
L --> E[otp_pad_encrypt/decrypt<br/>encoding: ascii or binary]
E --> D[dispatch.cpp<br/>encrypt/decrypt verbs]
D -->|USB CDC framed JSON-RPC| Host[test_otp_sd.py]
Boot[signer.ino boot flow] -->|after seed| M
Boot -.->|last phase| UI[ui_pick_pad<br/>LVGL list + confirm]
Module layout
firmware/teensy41/signer/src/otp_pad_sd.h— new public API:otp_pad_sd_mount(),otp_pad_sd_bind(chksum),otp_pad_sd_bind_first()(debug),otp_pad_sd_unbind(),otp_pad_sd_encrypt(pt, len, encoding, out, out_len),otp_pad_sd_decrypt(input, len, encoding, out, out_len),otp_pad_sd_ready(),otp_pad_sd_chksum(),otp_pad_sd_offset(),otp_pad_sd_size().firmware/teensy41/signer/src/otp_pad_sd.cpp— implementation over the ArduinoSDlibrary (4-bit SDMMC,BUILTIN_SDCARD). Holds the bound pad'sFile(read-only) + state in file-static globals, mirroringsrc/otp_pad.c'sotp_pad_state_t.firmware/teensy41/signer/src/otppad_embedded.h/.cpp— a Teensy/Arduino-friendly port of the format-critical functions fromlibotppad/libotppad.c:otppad_xor,otppad_base64_encode/decode,otppad_chunk_size,otppad_pad_apply/remove,otppad_armor_parse/generate,otppad_checksum(streaming, over aFile*),otppad_state_read/write(over SDFile). No POSIXFILE*/malloc/strtokdependencies that don't exist on Teensy; usesmalloc/free(available via newlib) and ArduinoString/manual parsing where needed. Bit-identical output to libotppad — same constants, same byte order, same header layout.
Wire format (align with host otp_encrypt/otp_decrypt)
The existing Teensy encrypt/decrypt verbs return raw base64 XOR +
pad_offset_before/pad_offset_after. The host verbs return ASCII armor or a
binary .otp blob with the offset embedded. To be bit-compatible and reusable
with the existing tools/otp_roundtrip_test.py
pattern, the Teensy verbs will be upgraded to match the host:
encryptparams:[plaintext_b64, {"encoding": "ascii"|"binary"}]→ result JSON:{"ciphertext": "<armor or b64-of-blob>", "pad_chksum": "<64hex>", "pad_offset_before": N, "pad_offset_after": N}.decryptparams:[ciphertext, {"encoding": "ascii"|"binary"}]→ result JSON:{"plaintext": "<b64>"}. The offset is read from the armor header / binary header (nopad_offsetoption needed, matching the host).
The algorithm: "otp" option is kept for backward compatibility with
test_signer.py but is optional.
Memory budget
The Teensy 4.1 has ~110 KB free heap (RAM2/DMAMEM) and ~9.6 KB free DTCM stack. The pad is never loaded whole. Each request:
- Decodes base64 plaintext into a DMAMEM scratch buffer (max chunk = 4 MB on host; cap at 64 KB on Teensy to fit heap — plenty for Nostr event content).
- Seeks the pad
Fileto the current offset, reads exactlychunkbytes into a second DMAMEM buffer. - XORs in place, encodes output, zeroizes scratch, advances offset in
.state.
All large buffers go in DMAMEM (RAM2), matching the existing crypto
workspace pattern in signer.ino.
Phased implementation
Phase 0 — Cleanup
- Delete
firmware/teensy41/otp_card_probe/(the throwaway probe sketch). - Delete
firmware/teensy41/sd_test/(bring-up sketch, superseded). - Confirm the smaller card is still readable by re-running the probe logic once inside the real firmware's SD mount (no separate sketch).
Phase 1 — Generate a test pad on the smaller card
The Teensy's SD slot isn't accessible from the host, so the pad must be generated on-device. Two options (pick one):
- A. One-time pad-generator sketch
firmware/teensy41/pad_gen/pad_gen.ino: mounts the SD card, writes<chksum>.pad(e.g. 1 MB from the Teensy's TRNG /analogReadnoise +LibRandomif available, else/dev/urandom-equivalent PRNG seeded fromENTROPYregisters), computes the XOR checksum, writes<chksum>.statewithoffset=32\n. This is a utility, not test firmware; it can be deleted after the pad exists. Uses the same checksum algorithm astools/make_test_pad.cso the pad is bit-compatible. - B. Host generation via USB reader: if a USB SD reader is available, pop
the card, run
make_test_pad <mount>/pads 1048576on the host, reinsert.
Default: A (no USB reader assumed). The generator is clearly marked as a setup utility and removed in Phase 0 of a future cleanup once the pad exists.
- Write
firmware/teensy41/pad_gen/pad_gen.ino(1 MB pad, 32-byte header, checksum-named,offset=32\nstate). - Flash + run it; record the generated
<chksum>for use in tests.
Phase 2 — Port libotppad to the firmware (otppad_embedded)
- Create
otppad_embedded.hdeclaring the format-critical functions. - Port
otppad_xor,otppad_base64_encode/decode(reuse the existingb64_encode/b64_decodein dispatch.cpp if bit-identical, else port libotppad's tables). - Port
otppad_chunk_size,otppad_pad_apply,otppad_pad_remove(Padmé). - Port
otppad_armor_parse/otppad_armor_generate(replacestrtokwith manual line splitting; replacesnprintfwith Arduinosprintf). - Port
otppad_checksumas a streaming function over an ArduinoFile*(read in 4 KB chunks, fold into 32 buckets, XOR with first 32 pad bytes). - Port
otppad_state_read/otppad_state_writeover SDFile(atomic write: write<chksum>.state.tmp,SD.renameover<chksum>.state). - Add a host-buildable unit test
firmware/teensy41/signer/tests/host_test_otppad_embedded.cthat linksotppad_embedded.ccompiled withHOST_TESTagainst a realFILE*backend, and verifies round-trip + padding + armor + checksum againstlibotppadoutputs (bit-compatibility check).
Phase 3 — otp_pad_sd.cpp (bind + seek/read + encrypt/decrypt)
- Create
otp_pad_sd.hwith the bind/encrypt/decrypt API above. - Implement
otp_pad_sd_mount()—SD.begin(BUILTIN_SDCARD), report failure over Serial. - Implement
otp_pad_sd_bind(chksum)— open<chksum>.padread-only, verify checksum viaotppad_checksum, read offset from.state(default to 32 if missing), store pad size + chksum in globals. - Implement
otp_pad_sd_bind_first()— scan root for*.pad, bind the first one (debug auto-bind path). - Implement
otp_pad_sd_encrypt— Padmé-pad, seek+read pad slice, XOR, encode (ascii/binary), advance offset atomically, zeroize scratch. - Implement
otp_pad_sd_decrypt— parse armor/binary header, seek+read pad slice, XOR, strip Padmé, zeroize scratch. Does not advance offset (decrypt is non-consuming, matching host). - Implement
otp_pad_sd_unbind— closeFile, zeroize state. - All scratch buffers in
DMAMEM; cap chunk at 64 KB.
Phase 4 — Wire into dispatch + boot flow (debug auto-bind)
- In
signer.inoapply_mnemonic(): after seed derivation, remove theotp_pad_init(g_seed, ...)HKDF call. Replace with: callotp_pad_sd_mount(); ifDEBUG_AUTO_GENERATE=1, callotp_pad_sd_bind_first()and log the bound chksum over Serial. On failure, log but continue (encrypt/decrypt verbs will returnotp pad not bound). - In
dispatch.cppencrypt/decryptverbs: replace the in-RAMotp_pad_apply/seekpath with calls tootp_pad_sd_encrypt/otp_pad_sd_decrypt. Parseencodingoption ("ascii"default,"binary"). Build the result JSON to match the host wire format (ciphertext,pad_chksum,pad_offset_before,pad_offset_afterfor encrypt;plaintextfor decrypt). Keepalgorithm: "otp"optional for backward compat. - Remove the old
otp_pad.cpp/otp_pad.hHKDF implementation (superseded byotp_pad_sd.*).
Phase 5 — Test harness + hardware round-trips
- Write
firmware/teensy41/test_otp_sd.py— over USB CDC, framed JSON-RPC:get_info(sanity).encryptascii → parse armor,Pad-ChkSummatches bound chksum,Pad-Offset= 32 (first call).decryptascii → recovered plaintext matches.encryptbinary → blob starts withOTP\0, header chksum matches,pad_offset= 32 + first chunk.decryptbinary → recovered plaintext matches.- Second
encryptascii →Pad-Offsetadvanced by first chunk (proves offset persistence in.state). - Reboot the Teensy (power cycle),
encryptagain →Pad-Offsetcontinues from where it left off (proves.statesurvives power cycle). - Large plaintext (e.g. 10 KB) → Padmé bucket doubles to 16 KB, round-trip OK.
- Tamper test: flip one byte in the armor base64 → decrypt returns error (padding removal fails) or wrong plaintext (detected).
- Run it against the flashed firmware; iterate on failures.
Phase 6 — Interactive pad selection UI (LAST)
- Add
ui_pick_pad(pads_list, count) -> selected_chksumtoui.h/ui.cpp: an LVGL list screen showing each pad's chksum prefix + size + used%, with a "Use this pad" / "Skip OTP" choice. Blocks (pumps LVGL) until the user picks. - In
signer.inoboot flow, whenDEBUG_AUTO_GENERATE=0: afterapply_mnemonic(), scan the SD root for*.pad, build the list, callui_pick_pad(). If the user picks one,otp_pad_sd_bind(chksum). If "Skip OTP" or no pads found, continue without a bound pad. - Keep
DEBUG_AUTO_GENERATE=1→otp_pad_sd_bind_first()as the test path so Phase 5 tests still run headless.
Test commands (Phase 5)
# Build + flash the real firmware
bash firmware/teensy41/build_signer.sh --flash
# OTP SD round-trip suite
python3 firmware/teensy41/test_otp_sd.py --port /dev/ttyACM0
# Host-side bit-compatibility check for otppad_embedded
cc -O2 -Wall -Wextra -D HOST_TEST -o host_test_otppad_embedded \
firmware/teensy41/signer/src/otppad_embedded.c \
firmware/teensy41/signer/tests/host_test_otppad_embedded.c -lm
./host_test_otppad_embedded
Status (2026-07-28)
Done
- Phase 0: Throwaway sketches deleted.
- Phase 1:
pad_gen.inowritten, compiled, flashed, and run on the smaller card. Generated a 1 MB TRNG-sourced pad4ec4e221...b0ca78.pad+.state(offset=32). The Teensy 4.1's hardware TRNG (TRNG_ENT0..15registers) works — two runs produced different pads. - Phase 2:
otppad_embedded.{h,c}ported from libotppad. Host bit-compat testhost_test_otppad_embedded.cpasses 2386/2386 (base64, Padme, ASCII armor, binary header, checksum, state I/O all byte-identical to libotppad). - Phase 3:
otp_pad_sd.{h,cpp}implemented (mount, bind, bind_first, encrypt/decrypt with ascii+binary encodings, atomic offset advance, malloc scratch buffers). Code is complete. - Phase 4:
signer.inoanddispatch.cppwired to the newotp_pad_sdAPI with host-compatible wire format. Oldotp_pad.{cpp,h}deleted.
BLOCKER: <SD.h> crashes the signer firmware
Including <SD.h> in the signer firmware causes an immediate hard fault
before setup() runs — no USB CDC enumeration, no serial output, no LED.
This happens with both the custom linker script
(imxrt1062_t41_flashmem.ld)
and the default Teensy linker script. The crash occurs even when every SD
function is stubbed out (only the #include <SD.h> is present).
The same <SD.h> works fine in standalone sketches:
pad_gen.ino— mounts SD, writes a 1 MB pad, reads it back, verifies checksum. Runs perfectly.- The deleted
sd_test.ino/otp_card_probe.ino— mounted the 1 TB card, listed files, wrote+read a 64-byte test file. All worked.
Root cause (likely): The Teensy 4.1's flexRAM is dynamically partitioned
between ITCM (code) and DTCM (data) in 32 KB blocks. The signer firmware
already uses ~377 KB of ITCM (12 blocks → 384 KB ITCM, 128 KB DTCM). The
SD/SdFat library adds ~13 KB of ITCM code, which — depending on the linker
script — either pushes ITCM to 13 blocks (reducing DTCM to 96 KB, overflowing
the 130 KB .data section) or doesn't change the block count but the
additional .data/BSS overflows DTCM. The linker does not catch this because
the flexRAM partitioning is computed at runtime by the Teensy boot ROM, not by
the linker script.
Current workaround: otp_pad_sd.cpp
has OTP_SD_ENABLED 0 — all SD functions are stubbed, <SD.h> is not included,
and the firmware boots and works normally for all non-OTP verbs. The
encrypt/decrypt verbs return otp pad not bound (no SD pad).
Path forward (to unblock)
- Route SdFat code to FLASH via
.flashmem: The custom linker script routes functions marked__attribute__((section(".flashmem")))to FLASH instead of ITCM. The SD/SdFat library functions are not marked.flashmem, so they land in ITCM. Options:- Wrap the SD includes with
#pragma GCC push_options+-ffunction-sections- a custom section attribute via a wrapper .cpp that re-exports the SD
calls from a
.flashmem-marked translation unit.
- a custom section attribute via a wrapper .cpp that re-exports the SD
calls from a
- Fork/patch SdFat to add
.flashmemattributes (heavy).
- Wrap the SD includes with
- Use SdFat directly with
SdSpiConfiginstead of the ArduinoSDwrapper, with a minimal config that reduces the code footprint. - Reduce the signer's own ITCM usage to make room for the SD library's ~13 KB (e.g., move more crypto code to FLASH).
- Use the external RAM (ERAM, 32 MB at 0x70000000) for the SD library's
BSS/buffers by placing them in
.bss.extram— the linker script already defines this section but it's currently empty.
Risks / open questions
- SD library flexRAM crash (see BLOCKER above) — the main blocker.
- exFAT rename atomicity:
SD.renameon SdFat exFAT should be atomic at the directory-entry level; verify once the crash is resolved. - Chunk cap: set to 16 KB (
OTP_SD_MAX_CHUNK) to fit RAM2; scratch buffers usemalloc(heap) not staticDMAMEMto avoid RAM2 BSS overflow. - Checksum over a 1 MB pad on-device: streaming 4 KB reads, fast enough. On a future 900 GB pad, checksum-on-bind is impractical — add a skip-verify flag and only verify the first/last 4 KB for large pads.
- Pad generation entropy: the Teensy 4.1's hardware TRNG
(
TRNG_ENT0..15registers) is used directly inpad_gen.inoand works.