v0.1.6 - Teensy 4.1 SD-card OTP pad: real implementation working on hardware

- 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
This commit is contained in:
Laan Tungir
2026-07-30 17:10:50 -04:00
parent d7369646fb
commit ac2e6347a2
25 changed files with 3812 additions and 495 deletions
+21 -1
View File
@@ -41,12 +41,32 @@ if [ -f "$LINKER_SCRIPT" ]; then
arduino-cli compile \ arduino-cli compile \
--fqbn "$FQBN" \ --fqbn "$FQBN" \
--build-property "build.flags.ld=${LINKER_FLAG}" \ --build-property "build.flags.ld=${LINKER_FLAG}" \
"$SIGNER_DIR" "$SIGNER_DIR" || true # size-determination step may error with custom .ld
else else
echo "[build] WARNING: Linker script not found at $LINKER_SCRIPT — using default." echo "[build] WARNING: Linker script not found at $LINKER_SCRIPT — using default."
arduino-cli compile --fqbn "$FQBN" "$SIGNER_DIR" arduino-cli compile --fqbn "$FQBN" "$SIGNER_DIR"
fi fi
# --- Step 2b: FlexRAM stack gauge ---
# arduino-cli's "Error while determining sketch size" with a custom linker
# script is non-fatal (the ELF is produced), but it means we lose the built-in
# memory report. Run our own gauge against the ELF to catch DTCM stack
# overflows that the linker cannot detect (the ITCM/DTCM split is computed at
# boot, not link time).
ELF_FILE=$(find "$HOME/.cache/arduino/sketches" -name "signer.ino.elf" -newer "$LINKER_SCRIPT" 2>/dev/null | head -1)
if [ -z "$ELF_FILE" ]; then
ELF_FILE=$(find "$HOME/.cache/arduino/sketches" -name "signer.ino.elf" 2>/dev/null | head -1)
fi
if [ -n "$ELF_FILE" ] && [ -f "$ELF_FILE" ]; then
echo "[build] Running FlexRAM stack gauge..."
bash "$SCRIPT_DIR/check_stack.sh" "$ELF_FILE" 16384 || {
echo "[build] ❌ Stack gauge FAILED — refusing to flash. See check_stack.sh output above."
exit 1
}
else
echo "[build] WARNING: could not find signer.ino.elf for stack gauge — skipping."
fi
echo "[build] Compilation successful." echo "[build] Compilation successful."
# --- Step 3: Find the Teensy port --- # --- Step 3: Find the Teensy port ---
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# check_stack.sh — Teensy 4.1 FlexRAM stack gauge.
#
# Computes the same ITCM/DTCM split the i.MX RT1062 boot ROM will perform, and
# fails if the resulting free stack is below a threshold. The linker cannot
# catch this because the split is computed at reset from _itcm_block_count,
# not at link time.
#
# Usage: check_stack.sh <signer.ino.elf> [min_stack_bytes]
# min_stack_bytes defaults to 16384 (16 KB).
#
# Exits 0 if the stack is above the threshold, 1 if below (with a diagnostic).
#
# The arithmetic (from imxrt1062_t41_flashmem.ld:215-217):
# itcm_banks = ceil((.text.itcm + .ARM.exidx) / 32768)
# dtcm_total = (16 - itcm_banks) * 32768
# free_stack = dtcm_total - .data - .bss
# _estack = ORIGIN(DTCM) + dtcm_total (stack grows down from here)
#
# Note: .bss here is the DTCM .bss (RAM1), NOT .bss.dma (RAM2/OCRAM). The
# .bss.dma section lives in a separate 512 KB region and does not affect the
# stack.
set -euo pipefail
ELF="${1:-}"
MIN_STACK="${2:-16384}"
if [ -z "$ELF" ] || [ ! -f "$ELF" ]; then
echo "check_stack: usage: $0 <signer.ino.elf> [min_stack_bytes]" >&2
exit 2
fi
# Locate the arm-none-eabi-size tool from the Teensy toolchain.
SIZE_BIN=""
for candidate in \
/home/user/.arduino15/packages/teensy/tools/teensy-compile/*/arm/bin/arm-none-eabi-size \
"$(dirname "$0")/../.arduino15/packages/teensy/tools/teensy-compile/*/arm/bin/arm-none-eabi-size"; do
if [ -x "$candidate" ]; then SIZE_BIN="$candidate"; break; fi
done
# Fall back to PATH
if [ -z "$SIZE_BIN" ]; then SIZE_BIN="$(command -v arm-none-eabi-size || true)"; fi
if [ -z "$SIZE_BIN" ]; then
echo "check_stack: cannot find arm-none-eabi-size" >&2
exit 2
fi
# We need per-section sizes, not the aggregate. Use objdump -h.
OBJDUMP_BIN="${SIZE_BIN%size}objdump"
# Extract section sizes (in bytes, decimal) by name.
get_section_size() {
local section="$1"
# objdump -h prints: idx Name Size VMA LMA File-off Al
"$OBJDUMP_BIN" -h "$ELF" 2>/dev/null \
| awk -v sec="$section" '$2 == sec { print strtonum("0x"$3); exit }'
}
TEXT_ITCM=$(get_section_size ".text.itcm")
ARM_EXIDX=$(get_section_size ".ARM.exidx")
DATA_SEC=$(get_section_size ".data")
BSS_SEC=$(get_section_size ".bss")
BSS_DMA=$(get_section_size ".bss.dma")
# Guard against missing sections (e.g. exidx may be 0/absent).
: "${TEXT_ITCM:=0}"
: "${ARM_EXIDX:=0}"
: "${DATA_SEC:=0}"
: "${BSS_SEC:=0}"
: "${BSS_DMA:=0}"
BANK=32768
ITCM_BYTES=$(( TEXT_ITCM + ARM_EXIDX ))
ITCM_BANKS=$(( (ITCM_BYTES + BANK - 1) / BANK ))
DTCM_TOTAL=$(( (16 - ITCM_BANKS) * BANK ))
DATA_BSS=$(( DATA_SEC + BSS_SEC ))
FREE_STACK=$(( DTCM_TOTAL - DATA_BSS ))
# Also report RAM2 (OCRAM) usage for completeness.
RAM2_TOTAL=$(( 512 * 1024 ))
RAM2_FREE=$(( RAM2_TOTAL - BSS_DMA ))
echo "=== Teensy 4.1 FlexRAM stack gauge ==="
echo " .text.itcm : $(printf '%7d' $TEXT_ITCM) bytes"
echo " .ARM.exidx : $(printf '%7d' $ARM_EXIDX) bytes"
echo " ITCM total : $(printf '%7d' $ITCM_BYTES) bytes -> $ITCM_BANKS banks ($(( ITCM_BANKS * BANK )) bytes)"
echo " .data (DTCM) : $(printf '%7d' $DATA_SEC) bytes"
echo " .bss (DTCM) : $(printf '%7d' $BSS_SEC) bytes"
echo " DTCM total : $(printf '%7d' $DTCM_TOTAL) bytes ($(( 16 - ITCM_BANKS )) banks)"
echo " data + bss : $(printf '%7d' $DATA_BSS) bytes"
echo " FREE STACK : $(printf '%7d' $FREE_STACK) bytes (threshold: $MIN_STACK)"
echo " ---"
echo " .bss.dma RAM2: $(printf '%7d' $BSS_DMA) / $RAM2_TOTAL bytes (free: $RAM2_FREE)"
if [ "$FREE_STACK" -lt "$MIN_STACK" ]; then
echo ""
echo " ❌ FAIL: free stack $FREE_STACK < threshold $MIN_STACK"
echo " The boot ROM will allocate $ITCM_BANKS ITCM banks, leaving only"
echo " $DTCM_TOTAL bytes of DTCM. .data+.bss needs $DATA_BSS bytes."
echo " Reduce ITCM (route more code to FLASH) or reduce .data/.bss"
echo " (move .rodata to FLASH)."
exit 1
fi
echo ""
echo " ✅ OK: free stack $FREE_STACK >= threshold $MIN_STACK"
exit 0
+303
View File
@@ -0,0 +1,303 @@
// pad_gen.ino — one-time OTP pad generator for the Teensy 4.1 SD card.
//
// This is a SETUP UTILITY, not part of the signer firmware. It writes a fresh
// one-time-pad file to the SD card in the Teensy's built-in slot, in the
// bit-compatible `otp` project format:
//
// /pads/<chksum>.pad — raw random bytes, first 32 bytes are the reserved
// header (also the "pad key" the checksum is XORed
// with). Matches libotppad OTPPAD_HEADER_RESERVED = 32
// and tools/make_test_pad.c.
// /pads/<chksum>.state — text file "offset=32\n" (32-byte header reserved).
//
// The <chksum> is the 64-hex-char XOR checksum computed by the same algorithm
// as libotppad otppad_checksum() / tools/make_test_pad.c compute_checksum():
// - XOR every byte into one of 32 buckets selected by (position % 32),
// also XORing in bytes (pos>>8),(pos>>16),(pos>>24) of the position.
// - XOR the resulting 32-byte checksum with the first 32 bytes of the pad.
// - Hex-encode the 32-byte result.
//
// Entropy source: the i.MX RT1062 hardware TRNG (ENTROPY registers), read
// directly. This is a real hardware RNG, NOT analogRead noise. Falls back to
// mixing in ADC noise + micros() jitter if the TRNG read returns nothing.
//
// Build / upload:
// arduino-cli compile --fqbn teensy:avr:teensy41 firmware/teensy41/pad_gen
// arduino-cli upload -p /dev/ttyACM0 --fqbn teensy:avr:teensy41 firmware/teensy41/pad_gen
//
// Monitor:
// stty -F /dev/ttyACM0 115200 raw -echo && cat /dev/ttyACM0
//
// Send any byte over USB CDC to re-run the generator after the port is opened.
// The pad size defaults to 1 MB (1048576 bytes); edit PAD_SIZE below to change.
#include <SD.h>
// ---- Configuration --------------------------------------------------------
static const uint32_t PAD_SIZE = 1048576; // 1 MB test pad
static const char *PADS_DIR = "/pads";
static const size_t HEADER_RESERVED = 32;
static const size_t CHKSUM_BIN_LEN = 32;
static const size_t CHKSUM_HEX_LEN = 64;
static const size_t BUF_SIZE = 4096;
// ---- i.MX RT1062 TRNG (hardware entropy) ----------------------------------
// The Teensy 4.1's NXP i.MX RT1062 has a true random number generator (TRNG).
// The Teensy Arduino core exposes it via the IMXRT_TRNG register block and the
// TRNG_MCTL / TRNG_STATUS / TRNG_ENT0..15 symbols (see imxrt.h). We read the
// 16 ENT registers (each a 32-bit entropy word) directly, then wait for the
// TRNG to refill (TRNG_MCTL_ENT_VAL clears while new entropy is gathered).
//
// Reference: NXP i.MX RT1062 reference manual, chapter "TRNG".
// Read up to 16 entropy words (512 bits) from the TRNG ENT0..ENT15 registers
// into `out`. Returns the number of words read (0..16). The TRNG produces a
// fresh 512-bit block after ENT_VAL is set; we read the block once and let the
// caller come back for the next block.
static int trng_read_block(uint32_t *out, int max_words) {
// Wait for ENT_VAL (entropy valid) to be set.
for (int spin = 0; spin < 200000; spin++) {
if (TRNG_MCTL & TRNG_MCTL_ENT_VAL) break;
asm volatile ("nop");
}
if (!(TRNG_MCTL & TRNG_MCTL_ENT_VAL)) return 0; // never became valid
int n = max_words < 16 ? max_words : 16;
// ENT0..ENT15 are consecutive 32-bit registers at offset 0x40..0x7C.
out[0] = TRNG_ENT0;
if (n > 1) out[1] = TRNG_ENT1;
if (n > 2) out[2] = TRNG_ENT2;
if (n > 3) out[3] = TRNG_ENT3;
if (n > 4) out[4] = TRNG_ENT4;
if (n > 5) out[5] = TRNG_ENT5;
if (n > 6) out[6] = TRNG_ENT6;
if (n > 7) out[7] = TRNG_ENT7;
if (n > 8) out[8] = TRNG_ENT8;
if (n > 9) out[9] = TRNG_ENT9;
if (n > 10) out[10] = TRNG_ENT10;
if (n > 11) out[11] = TRNG_ENT11;
if (n > 12) out[12] = TRNG_ENT12;
if (n > 13) out[13] = TRNG_ENT13;
if (n > 14) out[14] = TRNG_ENT14;
if (n > 15) out[15] = TRNG_ENT15;
return n;
}
// Fill `buf` with `len` bytes from the TRNG, mixing in ADC noise + micros()
// jitter as a fallback/defense-in-depth if the TRNG stalls. Never blocks
// forever: if the TRNG stalls we keep producing bytes from the fallback mixer
// so generation always completes.
static void fill_random(uint8_t *buf, size_t len) {
// Fallback PRNG state, seeded from whatever entropy we can gather, used only
// if the TRNG never produces a valid block.
uint32_t fallback = 0xA5A5A5A5u;
fallback ^= (uint32_t)micros();
fallback ^= (uint32_t)analogRead(A0);
fallback ^= (uint32_t)analogRead(A1);
fallback ^= (uint32_t)analogRead(A2);
size_t filled = 0;
while (filled < len) {
uint32_t block[16];
int got = trng_read_block(block, 16);
if (got <= 0) {
// TRNG stalled — xorshift32 fallback seeded from gathered entropy.
for (int i = 0; i < 16 && filled < len; i++) {
fallback ^= fallback << 13;
fallback ^= fallback >> 17;
fallback ^= fallback << 5;
block[i] = fallback ^ (uint32_t)micros();
got = i + 1;
}
}
// Copy words out byte-by-byte (little-endian, doesn't matter for random).
for (int i = 0; i < got && filled < len; i++) {
uint32_t w = block[i];
for (int b = 0; b < 4 && filled < len; b++) {
buf[filled++] = (uint8_t)(w & 0xFF);
w >>= 8;
}
}
}
}
// ---- Checksum (matches libotppad / tools/make_test_pad.c) -----------------
static void bytes_to_hex(const uint8_t *in, size_t n, char *out) {
static const char hexdigits[] = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
out[i * 2] = hexdigits[(in[i] >> 4) & 0xF];
out[i * 2 + 1] = hexdigits[in[i] & 0xF];
}
out[n * 2] = '\0';
}
// Compute the 64-hex-char XOR checksum of the pad file at `path` by streaming
// it in BUF_SIZE chunks. Identical algorithm to tools/make_test_pad.c
// compute_checksum() and libotppad otppad_checksum().
static int compute_checksum(const char *path, char *checksum_hex) {
File f = SD.open(path, FILE_READ);
if (!f) return 1;
uint8_t checksum[CHKSUM_BIN_LEN];
memset(checksum, 0, CHKSUM_BIN_LEN);
uint8_t buf[BUF_SIZE];
uint64_t total = 0;
int got;
while ((got = f.read(buf, sizeof(buf))) > 0) {
for (int i = 0; i < got; i++) {
uint64_t pos = total + (uint64_t)i;
uint8_t bucket = (uint8_t)(pos % CHKSUM_BIN_LEN);
checksum[bucket] ^= buf[i] ^
(uint8_t)((pos >> 8) & 0xFF) ^
(uint8_t)((pos >> 16) & 0xFF) ^
(uint8_t)((pos >> 24) & 0xFF);
}
total += (uint64_t)got;
}
f.close();
// XOR the checksum with the first 32 bytes of the pad (the "pad key").
f = SD.open(path, FILE_READ);
if (!f) return 1;
uint8_t pad_key[CHKSUM_BIN_LEN];
if ((int)f.read(pad_key, CHKSUM_BIN_LEN) != (int)CHKSUM_BIN_LEN) {
f.close();
return 1;
}
f.close();
uint8_t encrypted[CHKSUM_BIN_LEN];
for (size_t i = 0; i < CHKSUM_BIN_LEN; i++) {
encrypted[i] = checksum[i] ^ pad_key[i];
}
bytes_to_hex(encrypted, CHKSUM_BIN_LEN, checksum_hex);
return 0;
}
// ---- Generator ------------------------------------------------------------
static void generate() {
Serial.println();
Serial.println("=== Teensy 4.1 OTP pad generator ===");
Serial.print("Pad size: "); Serial.print(PAD_SIZE); Serial.println(" bytes");
if (!SD.begin(BUILTIN_SDCARD)) {
Serial.println("ERROR: SD.begin(BUILTIN_SDCARD) failed — no card seated.");
return;
}
Serial.println("SD card mounted OK.");
// Ensure /pads exists.
if (!SD.exists(PADS_DIR)) {
if (!SD.mkdir(PADS_DIR)) {
Serial.println("ERROR: cannot create /pads directory.");
return;
}
Serial.println("Created /pads directory.");
}
// Write the pad to a temp name first, then rename by checksum.
const char *tmp_path = "/pads/.padgen_tmp";
SD.remove(tmp_path); // remove any stale temp
File wf = SD.open(tmp_path, FILE_WRITE);
if (!wf) {
Serial.println("ERROR: cannot open temp pad file for writing.");
return;
}
Serial.println("Generating random pad bytes (TRNG)...");
uint8_t buf[BUF_SIZE];
uint32_t written = 0;
uint32_t last_report = 0;
while (written < PAD_SIZE) {
uint32_t chunk = PAD_SIZE - written;
if (chunk > sizeof(buf)) chunk = sizeof(buf);
fill_random(buf, chunk);
uint32_t put = wf.write(buf, chunk);
if (put != chunk) {
Serial.print("ERROR: short write at offset "); Serial.println(written);
wf.close();
SD.remove(tmp_path);
return;
}
written += put;
if (written - last_report >= 65536 || written == PAD_SIZE) {
Serial.print(" "); Serial.print(written); Serial.print(" / ");
Serial.print(PAD_SIZE); Serial.println(" bytes");
last_report = written;
}
}
wf.close();
Serial.println("Pad bytes written. Computing checksum...");
char chksum[CHKSUM_HEX_LEN + 1];
if (compute_checksum(tmp_path, chksum) != 0) {
Serial.println("ERROR: checksum computation failed.");
SD.remove(tmp_path);
return;
}
Serial.print("Checksum: "); Serial.println(chksum);
// Rename temp -> <chksum>.pad
char pad_path[128];
snprintf(pad_path, sizeof(pad_path), "%s/%s.pad", PADS_DIR, chksum);
if (SD.exists(pad_path)) {
Serial.print("NOTE: existing pad at "); Serial.print(pad_path);
Serial.println(" — removing before rename.");
SD.remove(pad_path);
}
if (!SD.rename(tmp_path, pad_path)) {
Serial.println("ERROR: rename to <chksum>.pad failed.");
SD.remove(tmp_path);
return;
}
Serial.print("Pad file: "); Serial.println(pad_path);
// Write the .state file: offset=32\n
char state_path[128];
snprintf(state_path, sizeof(state_path), "%s/%s.state", PADS_DIR, chksum);
File sf = SD.open(state_path, FILE_WRITE);
if (!sf) {
Serial.println("ERROR: cannot open .state file for writing.");
return;
}
sf.print("offset=32\n");
sf.close();
Serial.print("State file: "); Serial.println(state_path);
// Verify the checksum matches the filename by re-computing.
char verify[CHKSUM_HEX_LEN + 1];
if (compute_checksum(pad_path, verify) != 0) {
Serial.println("ERROR: verify re-compute failed.");
return;
}
if (strcmp(verify, chksum) != 0) {
Serial.print("ERROR: checksum mismatch after rename. file="); Serial.print(chksum);
Serial.print(" recomputed="); Serial.println(verify);
return;
}
Serial.println("Verify: checksum matches filename. Pad ready.");
Serial.println();
Serial.println("=== Pad generation complete ===");
Serial.print("Use this chksum (or any unique prefix) to bind: ");
Serial.println(chksum);
}
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 4000) ;
pinMode(LED_BUILTIN, OUTPUT);
// ADC pins for fallback entropy mixing.
analogReadResolution(16);
generate();
}
void loop() {
if (Serial.available()) {
while (Serial.available()) Serial.read();
generate();
}
digitalWrite(LED_BUILTIN, HIGH); delay(500);
digitalWrite(LED_BUILTIN, LOW); delay(500);
}
-163
View File
@@ -1,163 +0,0 @@
// Teensy 4.1 bring-up: detect, list, and read the SD card in the built-in slot.
//
// Uses the Teensy built-in SD library (4-bit SDMMC on the onboard slot — not the
// SPI SD slot on the display module). Prints card info, the root directory
// listing, and the first 512 bytes of the first file it finds, all over USB CDC
// so we can inspect a card that may already be formatted with files on it.
//
// Build / upload:
// arduino-cli compile --fqbn teensy:avr:teensy41 firmware/teensy41/sd_test
// arduino-cli upload -p /dev/ttyACM0 --fqbn teensy:avr:teensy41 firmware/teensy41/sd_test
//
// Monitor:
// stty -F /dev/ttyACM0 115200 raw -echo && cat /dev/ttyACM0
//
// Exit criterion: card is detected, its size + format are reported, the root
// directory lists, and a sample file read round-trips.
#include <SD.h>
static void report() {
Serial.println();
Serial.println("=== Teensy 4.1 SD card test ===");
Serial.println();
// BUILTIN_SDCARD selects the Teensy 4.1's onboard 4-bit SDMMC slot.
if (!SD.begin(BUILTIN_SDCARD)) {
Serial.println("ERROR: SD.begin(BUILTIN_SDCARD) failed — no card, bad card, or wiring issue.");
Serial.println("Check that a card is seated in the Teensy's built-in slot.");
return;
}
Serial.println("SD card mounted OK via 4-bit SDMMC (BUILTIN_SDCARD).");
// Card type via the Sd2Card helper (wraps SD.sdfs.card()->type()).
Sd2Card card;
uint8_t ct = card.type();
Serial.print("Card type: ");
switch (ct) {
case SD_CARD_TYPE_SD1: Serial.println("SD1 (standard)"); break;
case SD_CARD_TYPE_SD2: Serial.println("SD2 (standard)"); break;
case SD_CARD_TYPE_SDHC: Serial.println("SDHC/SDXC"); break;
default: Serial.println("unknown"); break;
}
// Card size: SdFat v2 exposes sector count on the card object.
uint64_t sectors = 0;
if (SD.sdfs.card()) sectors = SD.sdfs.card()->sectorCount();
uint64_t sz = sectors * 512ULL;
Serial.print("Sectors (512 B): "); Serial.println((uint64_t)sectors);
Serial.print("Card size (bytes): "); Serial.println((uint64_t)sz);
Serial.print("Card size (GB): ");
Serial.println((uint64_t)sz / (1000ULL * 1000ULL * 1000ULL));
Serial.print("Card size (GiB): ");
Serial.println((uint64_t)sz / (1024ULL * 1024ULL * 1024ULL));
// FAT type + cluster info via the SdVolume helper.
SdVolume vol;
vol.init(card);
Serial.print("Volume FAT type: ");
switch (vol.fatType()) {
case 0: Serial.println("(none / not FAT — likely exFAT)"); break;
case 12: Serial.println("FAT12"); break;
case 16: Serial.println("FAT16"); break;
case 32: Serial.println("FAT32"); break;
default: Serial.print(vol.fatType()); Serial.println(" (unknown)"); break;
}
Serial.print("Cluster count: "); Serial.println(vol.clusterCount());
Serial.print("Blocks per cluster: "); Serial.println(vol.blocksPerCluster());
Serial.print("sdfs.vol() fatType: ");
if (SD.sdfs.vol()) Serial.println(SD.sdfs.vol()->fatType());
else Serial.println("(no FAT volume — likely exFAT-only)");
Serial.println();
Serial.println("=== Root directory listing ===");
File root = SD.open("/");
printDirectory(root, 0);
root.close();
Serial.println("=== end listing ===");
// Try to read the first regular file in the root and dump its first 512 bytes.
Serial.println();
Serial.println("=== First-file read test ===");
root = SD.open("/");
File first;
while (true) {
first = root.openNextFile();
if (!first) break;
if (!first.isDirectory()) break;
first.close();
}
root.close();
if (first) {
Serial.print("Reading: ");
Serial.print(first.name());
Serial.print(" (");
Serial.print(first.size(), DEC);
Serial.println(" bytes)");
Serial.println("--- first 512 bytes (hex + ASCII) ---");
uint8_t buf[512];
int n = first.read(buf, sizeof(buf));
for (int i = 0; i < n; i++) {
if (buf[i] < 0x10) Serial.print('0');
Serial.print(buf[i], HEX);
Serial.print(' ');
if ((i & 15) == 15) {
Serial.print(" | ");
for (int j = i - 15; j <= i; j++) {
char c = (char)buf[j];
Serial.print((c >= 32 && c < 127) ? c : '.');
}
Serial.println();
}
}
Serial.println("--- end dump ---");
first.close();
} else {
Serial.println("No regular files in root directory.");
}
Serial.println();
Serial.println("=== SD test complete ===");
}
static void printDirectory(File dir, int depth) {
while (true) {
File entry = dir.openNextFile();
if (!entry) break; // no more files
for (int i = 0; i < depth; i++) Serial.print(" ");
if (entry.isDirectory()) {
Serial.print(entry.name());
Serial.println("/");
printDirectory(entry, depth + 1);
} else {
Serial.print(entry.name());
Serial.print("\t");
Serial.print(entry.size(), DEC);
Serial.println(" bytes");
}
entry.close();
}
}
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 4000) ; // wait up to 4s for USB CDC
pinMode(LED_BUILTIN, OUTPUT);
// Print the report once at boot (may be missed if the host isn't listening
// yet — that's fine, send any byte to re-trigger).
report();
}
void loop() {
// Re-run the report whenever any byte arrives over USB CDC, so the host can
// request the report after the port has been opened.
if (Serial.available()) {
while (Serial.available()) Serial.read(); // drain
report();
}
// slow steady blink = idle, ready for a re-report request
digitalWrite(LED_BUILTIN, HIGH); delay(500);
digitalWrite(LED_BUILTIN, LOW); delay(500);
}
@@ -77,6 +77,66 @@ SECTIONS
*thash.c.o(.rodata*) *thash.c.o(.rodata*)
*wots.c.o(.text*) *wots.c.o(.text*)
*wots.c.o(.rodata*) *wots.c.o(.rodata*)
/* Route the SdFat library (SD card access) into FLASH too, so it
* doesn't consume ITCM and overflow the flexRAM DTCM partition.
* Without this, including <SdFat.h> crashes the device before
* setup() runs (ITCM grows past 12 blocks, reducing DTCM below
* what .data needs). See plans/teensy41_otp_sd_pad.md §BLOCKER. */
*FatFile.cpp.o(.text*)
*FatFile.cpp.o(.rodata*)
*FatFileLFN.cpp.o(.text*)
*FatFileLFN.cpp.o(.rodata*)
*FatFileSFN.cpp.o(.text*)
*FatFileSFN.cpp.o(.rodata*)
*FatFilePrint.cpp.o(.text*)
*FatFilePrint.cpp.o(.rodata*)
*FatPartition.cpp.o(.text*)
*FatPartition.cpp.o(.rodata*)
*FatVolume.cpp.o(.text*)
*FatVolume.cpp.o(.rodata*)
*FatName.cpp.o(.text*)
*FatName.cpp.o(.rodata*)
*FatFormatter.cpp.o(.text*)
*FatFormatter.cpp.o(.rodata*)
*FatDbg.cpp.o(.text*)
*FatDbg.cpp.o(.rodata*)
*FsCache.cpp.o(.text*)
*FsCache.cpp.o(.rodata*)
*FsFile.cpp.o(.text*)
*FsFile.cpp.o(.rodata*)
*FsVolume.cpp.o(.text*)
*FsVolume.cpp.o(.rodata*)
*FsName.cpp.o(.text*)
*FsName.cpp.o(.rodata*)
*FsStructs.cpp.o(.text*)
*FsStructs.cpp.o(.rodata*)
*FsNew.cpp.o(.text*)
*FsNew.cpp.o(.rodata*)
*FsUtf.cpp.o(.text*)
*FsUtf.cpp.o(.rodata*)
*FsDateTime.cpp.o(.text*)
*FsDateTime.cpp.o(.rodata*)
*FmtNumber.cpp.o(.text*)
*FmtNumber.cpp.o(.rodata*)
*FreeStack.cpp.o(.text*)
*FreeStack.cpp.o(.rodata*)
*MinimumSerial.cpp.o(.text*)
*MinimumSerial.cpp.o(.rodata*)
*istream.cpp.o(.text*)
*istream.cpp.o(.rodata*)
*ostream.cpp.o(.text*)
*ostream.cpp.o(.rodata*)
/* SDIO driver stays in ITCM (not FLASH) for fast interrupt response.
* Only the FAT filesystem layer goes to FLASH. */
/* Move ALL .rodata to FLASH (it's read-only, cache-friendly from
* FLASH via the D-cache) to free up DTCM for stack. This reclaims
* an estimated 40-90 KB of DTCM. The ed25519.c.o rodata is excluded
* because ed_K/ed_X/ed_Y base point constants must stay in DTCM
* (moving them to FLASH produced an all-zeros pubkey — see the
* note at the top of this file). */
*(EXCLUDE_FILE(*ed25519.c.o) .rodata*)
. = ALIGN(4); . = ALIGN(4);
KEEP(*(.init)) KEEP(*(.init))
__preinit_array_start = .; __preinit_array_start = .;
+24 -7
View File
@@ -35,7 +35,7 @@
#include "src/dispatch.h" #include "src/dispatch.h"
#include "src/ui.h" #include "src/ui.h"
#include "src/secure_mem.h" #include "src/secure_mem.h"
#include "src/otp_pad.h" #include "src/otp_pad_sd.h"
#include "src/nostr_core/nostr_common.h" #include "src/nostr_core/nostr_common.h"
// ---- Pin map (from firmware/teensy41/WIRING.md) ---- // ---- Pin map (from firmware/teensy41/WIRING.md) ----
@@ -267,12 +267,9 @@ static int apply_mnemonic() {
} }
g_seed_len = 64; g_seed_len = 64;
// Initialize the OTP pad from the seed so encrypt/decrypt verbs work. // The OTP pad is bound from the SD card AFTER transport_init() (see below),
Serial.println("Initializing OTP pad..."); // so that USB CDC is up before any SD I/O — a fault in the SD path can't
if (otp_pad_init(g_seed, g_seed_len) != 0) { // prevent the device from enumerating.
Serial.println("otp_pad_init failed");
return -1;
}
Serial.println("Deriving secp256k1 keys..."); Serial.println("Deriving secp256k1 keys...");
if (derive_secp256k1_keys(g_seed, g_seed_len, g_privkey, g_pubkey) != 0) { if (derive_secp256k1_keys(g_seed, g_seed_len, g_privkey, g_pubkey) != 0) {
@@ -485,6 +482,26 @@ void setup() {
transport_init(); transport_init();
Serial.println("Transport initialized."); Serial.println("Transport initialized.");
// Bind the OTP pad from the SD card. Give USB CDC a moment to enumerate
// with the host first, so that even if the SD path faults, the device has
// already appeared as /dev/ttyACM0 and we can see the boot output.
delay(2000);
Serial.println("Mounting SD card for OTP pad...");
if (otp_pad_sd_mount() != 0) {
Serial.println("otp_pad_sd_mount failed — encrypt/decrypt unavailable");
} else {
#if DEBUG_AUTO_GENERATE
Serial.println("DEBUG_AUTO_GENERATE=1: auto-binding first SD pad...");
if (otp_pad_sd_bind_first() != 0) {
Serial.println("otp_pad_sd_bind_first failed — no pad bound");
}
#else
// Interactive pad selection is wired in Phase 6 (ui_pick_pad).
Serial.println("Interactive pad selection not yet wired; pad unbound.");
#endif
}
Serial.println("OTP pad init complete.");
// Show the idle screen with the npub + version. // Show the idle screen with the npub + version.
ui_show_idle(g_npub, "v0.1.0-teensy41"); ui_show_idle(g_npub, "v0.1.0-teensy41");
Serial.println("Idle screen shown. Signer ready."); Serial.println("Idle screen shown. Signer ready.");
+217 -48
View File
@@ -38,7 +38,7 @@
#include "secure_mem.h" #include "secure_mem.h"
#include "ui.h" #include "ui.h"
#include "auth_envelope.h" #include "auth_envelope.h"
#include "otp_pad.h" #include "otp_pad_sd.h"
#include "key_derivation.h" #include "key_derivation.h"
#include "ed25519.h" #include "ed25519.h"
@@ -684,6 +684,36 @@ __attribute__((section(".flashmem"))) static int b64_decode(const char *in, size
return 0; return 0;
} }
/* Allocating variants: caller frees the returned buffer. Returns NULL on
* failure. Used by the OTP encrypt/decrypt verbs for variable-length
* payloads that don't fit a fixed stack buffer. */
__attribute__((section(".flashmem"))) static char *b64_encode_alloc(const uint8_t *in, int in_len) {
if (!in || in_len < 0) return NULL;
size_t need = ((size_t)in_len + 2) / 3 * 4 + 1;
char *out = (char *)malloc(need);
if (!out) return NULL;
if (b64_encode(in, (size_t)in_len, out, need) != 0) {
free(out);
return NULL;
}
return out;
}
__attribute__((section(".flashmem"))) static uint8_t *b64_decode_alloc(const char *in, size_t in_len, int *out_len) {
if (!in || !out_len) return NULL;
/* Upper bound on decoded length. */
size_t cap = in_len / 4 * 3 + 4;
uint8_t *out = (uint8_t *)malloc(cap);
if (!out) return NULL;
size_t got = 0;
if (b64_decode(in, in_len, out, cap, &got) != 0) {
free(out);
return NULL;
}
*out_len = (int)got;
return out;
}
/* ==================================================================== /* ====================================================================
* Approval flow * Approval flow
* ==================================================================== * ====================================================================
@@ -1691,17 +1721,68 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
return; return;
} }
/* ===================== encrypt / decrypt (otp) ===================== */ /* ===================== otp_status (debug) =========================== */
if (strcmp(method, "otp_status") == 0) {
cJSON *obj = cJSON_CreateObject();
char *out;
cJSON_AddBoolToObject(obj, "bound", otp_pad_sd_ready() ? 1 : 0);
const char *cs = otp_pad_sd_chksum();
cJSON_AddStringToObject(obj, "chksum", cs ? cs : "");
char off_str[32], size_str[32];
snprintf(off_str, sizeof(off_str), "%llu",
(unsigned long long)otp_pad_sd_offset());
snprintf(size_str, sizeof(size_str), "%llu",
(unsigned long long)otp_pad_sd_size());
cJSON_AddStringToObject(obj, "offset", off_str);
cJSON_AddStringToObject(obj, "pad_size", size_str);
out = cJSON_PrintUnformatted(obj);
if (out) { set_result(id_token, out); cJSON_free(out); }
cJSON_Delete(obj);
return;
}
/* ===================== otp_debug (debug) ============================ */
/* Lists files in /pads to diagnose bind failures. Does NOT call
* bind_first (which does a slow 1 MB checksum verify). */
if (strcmp(method, "otp_debug") == 0) {
cJSON *obj = cJSON_CreateObject();
char *out;
char list[1024];
int n = otp_pad_sd_debug_list(list, sizeof(list));
char n_str[16];
snprintf(n_str, sizeof(n_str), "%d", n);
cJSON_AddStringToObject(obj, "file_count", n_str);
cJSON_AddStringToObject(obj, "listing", list);
cJSON_AddBoolToObject(obj, "bound", otp_pad_sd_ready() ? 1 : 0);
out = cJSON_PrintUnformatted(obj);
if (out) { set_result(id_token, out); cJSON_free(out); }
cJSON_Delete(obj);
return;
}
/* ===================== encrypt / decrypt (otp) ===================== */
/* SD-card one-time-pad. Bit-compatible with the host n_signer's
* otp_encrypt / otp_decrypt verbs (see src/otp_pad.c) and the `otp` CLI
* via libotppad/otppad_embedded. Supports ASCII armor and binary .otp
* encodings, selected via the "encoding" option (default "ascii").
*
* Wire format:
* encrypt: [plaintext_b64, {"encoding": "ascii"|"binary"}]
* -> {"ciphertext": ..., "pad_chksum": ...,
* "pad_offset_before": N, "pad_offset_after": N}
* decrypt: [ciphertext, {"encoding": "ascii"|"binary"}]
* -> {"plaintext": "<b64>"} (offset read from armor/binary header)
*
* The "algorithm": "otp" option is accepted for backward compatibility
* with test_signer.py but is optional. */
if (strcmp(method, VERB_ENCRYPT) == 0 || if (strcmp(method, VERB_ENCRYPT) == 0 ||
strcmp(method, VERB_DECRYPT) == 0) { strcmp(method, VERB_DECRYPT) == 0) {
cJSON *options = NULL; cJSON *options = NULL;
fw_alg_t alg;
uint32_t index = 0;
cJSON *arg0 = NULL; cJSON *arg0 = NULL;
const char *payload; const char *payload;
int is_decrypt = (strcmp(method, VERB_DECRYPT) == 0);
const char *encoding = "ascii"; /* default */
/* OTP encrypt and decrypt are the same XOR operation against the pad;
* the verb name is carried through for the UI prompt only. */
if (!cJSON_IsArray(params) || cJSON_GetArraySize(params) < 1) { if (!cJSON_IsArray(params) || cJSON_GetArraySize(params) < 1) {
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
return; return;
@@ -1713,18 +1794,18 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
} }
payload = arg0->valuestring; payload = arg0->valuestring;
parse_options_from_params(params, &options); parse_options_from_params(params, &options);
if (parse_algorithm_from_options(options, &alg, &index) != 0) {
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); /* "encoding" option (optional, default "ascii"). */
return; if (options != NULL) {
cJSON *enc = cJSON_GetObjectItemCaseSensitive(options, "encoding");
if (cJSON_IsString(enc) && enc->valuestring != NULL) {
encoding = enc->valuestring;
}
} }
if (enforce_alg_verb(alg, method) != 0) {
set_error_code(id_token, ERR_ALG_NOT_SUPPORTED, if (!otp_pad_sd_ready()) {
"algorithm_not_supported_for_verb");
return;
}
if (!otp_pad_ready()) {
set_error_code(id_token, ERR_INTERNAL, set_error_code(id_token, ERR_INTERNAL,
"otp pad not initialized"); "otp pad not bound (no SD pad)");
return; return;
} }
@@ -1737,47 +1818,135 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
return; return;
} }
/* For encrypt: payload is base64 plaintext. if (!is_decrypt) {
* For decrypt: payload is base64 ciphertext (XOR output). /* ---- encrypt ----
* Decode base64, XOR with the pad at the current offset, advance * payload is base64 plaintext. Decode, hand bytes to
* the offset, and re-encode. */ * otp_pad_sd_encrypt, base64-encode the returned armor/blob
{ * for JSON transport (binary blobs are base64-wrapped in the
uint8_t buf[OTP_PAD_LEN]; * JSON result, matching the host). */
size_t buf_len = 0; /* Decode base64 plaintext into a heap buffer. */
int pt_len = 0;
if (b64_decode(payload, strlen(payload), buf, sizeof(buf), unsigned char *pt = (unsigned char *)b64_decode_alloc(
&buf_len) != 0) { payload, strlen(payload), &pt_len);
if (!pt) {
set_error_code(id_token, ERR_INVALID_PARAMS, set_error_code(id_token, ERR_INVALID_PARAMS,
"invalid base64"); "invalid base64");
return; return;
} }
if (otp_pad_apply(buf, buf_len) != 0) { char *out_payload = NULL;
size_t out_len = 0;
uint64_t off_before = 0, off_after = 0;
int rc = otp_pad_sd_encrypt(pt, (size_t)pt_len, encoding,
&out_payload, &out_len,
&off_before, &off_after);
secure_memzero(pt, (size_t)pt_len);
free(pt);
if (rc != 0) {
set_error_code(id_token, ERR_INTERNAL, set_error_code(id_token, ERR_INTERNAL,
"otp pad exhausted"); "otp encrypt failed");
secure_memzero(buf, sizeof(buf)); if (out_payload) { secure_memzero(out_payload, out_len); free(out_payload); }
return;
}
/* For binary encoding, base64-wrap the blob for JSON. For
* ascii encoding, out_payload is already a NUL-terminated
* armor string. */
char *ct_b64 = NULL;
if (strcmp(encoding, "binary") == 0) {
ct_b64 = b64_encode_alloc((const uint8_t *)out_payload,
(int)out_len);
} else {
/* ASCII armor: copy as-is (it's text-safe). */
ct_b64 = (char *)malloc(out_len + 1);
if (ct_b64) { memcpy(ct_b64, out_payload, out_len); ct_b64[out_len] = '\0'; }
}
secure_memzero(out_payload, out_len);
free(out_payload);
if (!ct_b64) {
set_error_code(id_token, ERR_INTERNAL, "internal error");
return;
}
{
cJSON *obj = cJSON_CreateObject();
char *out;
char off_before_str[32], off_after_str[32];
cJSON_AddStringToObject(obj, "ciphertext", ct_b64);
cJSON_AddStringToObject(obj, "pad_chksum",
otp_pad_sd_chksum());
snprintf(off_before_str, sizeof(off_before_str),
"%llu", (unsigned long long)off_before);
snprintf(off_after_str, sizeof(off_after_str),
"%llu", (unsigned long long)off_after);
cJSON_AddStringToObject(obj, "pad_offset_before",
off_before_str);
cJSON_AddStringToObject(obj, "pad_offset_after",
off_after_str);
out = cJSON_PrintUnformatted(obj);
if (out) { set_result(id_token, out); cJSON_free(out); }
cJSON_Delete(obj);
}
free(ct_b64);
} else {
/* ---- decrypt ----
* payload is either ASCII armor (text) or a base64-encoded
* binary .otp blob, selected by the "encoding" option. */
unsigned char *input_bytes = NULL;
size_t input_len = 0;
char *input_str = NULL;
if (strcmp(encoding, "binary") == 0) {
/* payload is base64 of the binary blob. Decode to bytes. */
int blen = 0;
input_bytes = (unsigned char *)b64_decode_alloc(
payload, strlen(payload), &blen);
if (!input_bytes) {
set_error_code(id_token, ERR_INVALID_PARAMS,
"invalid base64");
return;
}
input_len = (size_t)blen;
} else {
/* ASCII armor: pass the string directly. */
input_str = (char *)payload; /* borrowed, not freed */
}
unsigned char *pt = NULL;
size_t pt_len = 0;
int rc;
if (strcmp(encoding, "binary") == 0) {
rc = otp_pad_sd_decrypt((const char *)input_bytes,
input_len, "binary",
&pt, &pt_len);
} else {
rc = otp_pad_sd_decrypt(input_str, strlen(input_str),
"ascii", &pt, &pt_len);
}
if (input_bytes) { free(input_bytes); }
if (rc != 0) {
set_error_code(id_token, ERR_INTERNAL,
"otp decrypt failed");
if (pt) { secure_memzero(pt, pt_len); free(pt); }
return;
}
/* base64-encode the recovered plaintext for JSON. */
char *pt_b64 = b64_encode_alloc(pt, (int)pt_len);
secure_memzero(pt, pt_len);
free(pt);
if (!pt_b64) {
set_error_code(id_token, ERR_INTERNAL, "internal error");
return; return;
} }
{ {
char out_b64[1400]; cJSON *obj = cJSON_CreateObject();
if (b64_encode(buf, buf_len, out_b64, char *out;
sizeof(out_b64)) != 0) { cJSON_AddStringToObject(obj, "plaintext", pt_b64);
set_error_code(id_token, ERR_INTERNAL, out = cJSON_PrintUnformatted(obj);
"internal error"); if (out) { set_result(id_token, out); cJSON_free(out); }
} else { cJSON_Delete(obj);
cJSON *obj = cJSON_CreateObject();
char *out;
char off_str[24];
cJSON_AddStringToObject(obj, "result", out_b64);
cJSON_AddStringToObject(obj, "algorithm", "otp");
snprintf(off_str, sizeof(off_str), "%u",
(unsigned)otp_pad_offset());
cJSON_AddStringToObject(obj, "pad_offset", off_str);
out = cJSON_PrintUnformatted(obj);
if (out) { set_result(id_token, out); cJSON_free(out); }
cJSON_Delete(obj);
}
} }
secure_memzero(buf, sizeof(buf)); free(pt_b64);
} }
} }
return; return;
-76
View File
@@ -1,76 +0,0 @@
/* otp_pad.cpp — HKDF-derived one-time-pad for the Teensy 4.1 n_signer firmware.
*
* Ported from the CYD's inline OTP pad (firmware/cyd_esp32_2432s028/main/main.c,
* apply_mnemonic_and_enter_working). The CYD derives the pad inline and keeps
* the state in file-static globals; here we wrap the same logic in a small
* module so dispatch.cpp can call otp_pad_init / otp_pad_apply / otp_pad_zeroize.
*
* pad = HKDF-SHA256(salt="nsigner-otp", ikm=seed(64B), info="otp-pad",
* L=OTP_PAD_LEN)
*
* The HKDF comes from nostr_core/utils.c (nostr_hkdf), the same backend the
* CYD uses. The pad is held in a file-static buffer and zeroized on lock.
*/
#include "otp_pad.h"
#include <string.h>
#include "nostr_core/utils.h"
#include "secure_mem.h"
static uint8_t s_pad[OTP_PAD_LEN];
static size_t s_pad_len = 0;
static size_t s_offset = 0;
__attribute__((section(".flashmem"))) int otp_pad_init(const uint8_t *seed, size_t seed_len) {
static const uint8_t kSalt[] = "nsigner-otp"; /* 12 bytes (no NUL) */
static const uint8_t kInfo[] = "otp-pad"; /* 7 bytes (no NUL) */
if (seed == NULL || seed_len == 0) {
return -1;
}
if (nostr_hkdf(kSalt, sizeof(kSalt) - 1,
seed, seed_len,
kInfo, sizeof(kInfo) - 1,
s_pad, sizeof(s_pad)) != 0) {
s_pad_len = 0;
s_offset = 0;
return -1;
}
s_pad_len = sizeof(s_pad);
s_offset = 0;
return 0;
}
__attribute__((section(".flashmem"))) void otp_pad_zeroize(void) {
secure_memzero(s_pad, sizeof(s_pad));
s_pad_len = 0;
s_offset = 0;
}
__attribute__((section(".flashmem"))) int otp_pad_apply(uint8_t *buf, size_t len) {
size_t i;
if (buf == NULL || s_pad_len == 0) {
return -1;
}
if (s_offset + len > s_pad_len) {
return -1;
}
for (i = 0; i < len; ++i) {
buf[i] ^= s_pad[s_offset + i];
}
s_offset += len;
return 0;
}
__attribute__((section(".flashmem"))) size_t otp_pad_offset(void) {
return s_offset;
}
__attribute__((section(".flashmem"))) int otp_pad_ready(void) {
return (s_pad_len != 0) ? 1 : 0;
}
-54
View File
@@ -1,54 +0,0 @@
/* otp_pad.h — HKDF-derived one-time-pad for the Teensy 4.1 n_signer firmware.
*
* Phase 6 of plans/teensy41_signer_implementation.md.
*
* For the initial port we use the same HKDF-from-seed pad as the CYD firmware
* (firmware/cyd_esp32_2432s028/main/main.c, apply_mnemonic_and_enter_working):
*
* pad = HKDF-SHA256(salt="nsigner-otp", ikm=seed(64B), info="otp-pad",
* L=OTP_PAD_LEN)
*
* The offset advances monotonically across encrypt/decrypt requests so each
* pad byte is used at most once (true one-time-pad semantics within a session).
* The pad lives in working memory only and is zeroized on reset.
*
* The 1 TB SDXC physical pad is a future enhancement (see
* plans/teensy41_signer_implementation.md §Phase 6 decisions) and will plug in
* behind the same otp_pad_xxx() API.
*/
#ifndef FIRMWARE_TEENSY41_SIGNER_OTP_PAD_H
#define FIRMWARE_TEENSY41_SIGNER_OTP_PAD_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Pad length in bytes. Matches the CYD (1024 bytes per session). */
#define OTP_PAD_LEN 1024
/* Derive the pad from a 64-byte mnemonic seed and reset the offset to 0.
* Call once after mnemonic_to_seed(). Returns 0 on success, -1 on error. */
int otp_pad_init(const uint8_t *seed, size_t seed_len);
/* Zeroize the pad and reset state. Call on lock / power-down. */
void otp_pad_zeroize(void);
/* XOR `len` bytes of the pad (at the current offset) into `buf`, then advance
* the offset by `len`. Returns 0 on success, -1 if the pad is not initialized
* or would be exhausted (offset + len > OTP_PAD_LEN). */
int otp_pad_apply(uint8_t *buf, size_t len);
/* Current monotonic offset (bytes of pad consumed so far this session). */
size_t otp_pad_offset(void);
/* Whether the pad has been initialized (otp_pad_init succeeded). */
int otp_pad_ready(void);
#ifdef __cplusplus
}
#endif
#endif /* FIRMWARE_TEENSY41_SIGNER_OTP_PAD_H */
+550
View File
@@ -0,0 +1,550 @@
/* otp_pad_sd.cpp — SD-card one-time-pad implementation for the Teensy 4.1.
*
* Uses SdFat directly (not the Arduino SD wrapper) with a minimal config
* (FAT16/32 only, no exFAT) to reduce the code footprint enough to fit
* alongside the signer's crypto code in the Teensy 4.1's flexRAM. The
* Arduino SD wrapper pulled in all of SdFat including exFAT, which overflowed
* ITCM and crashed the device before setup() ran.
*
* See plans/teensy41_otp_sd_pad.md §BLOCKER for the full root-cause analysis.
*/
#include "otp_pad_sd.h"
#include "otppad_embedded.h"
#include <Arduino.h>
/* Minimal SdFat config: FAT16/32 only (no exFAT). This must be defined before
* including SdFat.h so SdFatConfig.h picks it up. */
#define SDFAT_FILE_TYPE 1
#include <SdFat.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
extern "C" void secure_memzero(void *ptr, size_t len);
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
#define OTP_SD_PADS_DIR "/pads"
#define OTP_SD_CHKSUM_MAX 128
#define OTP_SD_PATH_MAX (sizeof(OTP_SD_PADS_DIR) + OTP_SD_CHKSUM_MAX + 16)
/* The SdFat global. Using SdFat32 (FAT-only) directly avoids the exFAT code
* that the Arduino SD wrapper pulled in. */
static SdFat sd;
typedef struct {
int bound;
char chksum[OTP_SD_CHKSUM_MAX];
char pad_path[OTP_SD_PATH_MAX];
File32 pad_file; /* read-only, kept open for the session */
uint64_t pad_size;
} otp_sd_state_t;
static otp_sd_state_t g_sd = {0};
/* ------------------------------------------------------------------ */
/* SD state file I/O (implements the otppad_e_state_*_sd prototypes) */
/* ------------------------------------------------------------------ */
extern "C" int otppad_e_state_read_sd(const char *pads_dir, const char *chksum,
uint64_t *offset) {
if (!pads_dir || !chksum || !offset) return 1;
char path[OTP_SD_PATH_MAX];
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
File32 f = sd.open(path, O_RDONLY);
if (!f) return 2;
char line[128];
int n = f.read((uint8_t *)line, sizeof(line) - 1);
f.close();
if (n <= 0) return 3;
line[n] = '\0';
if (strncmp(line, "offset=", 7) != 0) return 4;
*offset = strtoull(line + 7, NULL, 10);
return 0;
}
extern "C" int otppad_e_state_write_sd(const char *pads_dir, const char *chksum,
uint64_t offset) {
if (!pads_dir || !chksum) return 1;
char path[OTP_SD_PATH_MAX];
char tmp[OTP_SD_PATH_MAX];
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
snprintf(tmp, sizeof(tmp), "%s/%s.state.tmp", pads_dir, chksum);
sd.remove(tmp);
File32 f = sd.open(tmp, O_WRONLY | O_CREAT | O_TRUNC);
if (!f) return 2;
char buf[64];
int len = snprintf(buf, sizeof(buf), "offset=%llu\n",
(unsigned long long)offset);
size_t wrote = f.write((const uint8_t *)buf, (size_t)len);
f.close();
if (wrote != (size_t)len) {
sd.remove(tmp);
return 3;
}
sd.remove(path);
if (!sd.rename(tmp, path)) {
sd.remove(tmp);
return 4;
}
return 0;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static void bytes_to_hex(const unsigned char *in, size_t n, char *out) {
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
out[i * 2] = hex[(in[i] >> 4) & 0xF];
out[i * 2 + 1] = hex[in[i] & 0xF];
}
out[n * 2] = '\0';
}
static int resolve_pad(const char *prefix, char *out_chksum) {
File32 dir = sd.open(OTP_SD_PADS_DIR);
if (!dir) {
return -1;
}
int matches = 0;
size_t plen = prefix ? strlen(prefix) : 0;
char found[OTPPAD_E_CHKSUM_HEX_LEN + 1] = {0};
while (true) {
File32 entry = dir.openNextFile();
if (!entry) break;
if (!entry.isDir()) {
/* 64-char chksum + ".pad" = 68 chars + NUL = 69. Use a 128-byte
* buffer so getName() doesn't truncate the long filename. */
char name[128];
entry.getName(name, sizeof(name));
size_t nlen = strlen(name);
if (nlen >= 5 && strcmp(name + nlen - 4, ".pad") == 0) {
size_t base = nlen - 4;
if (base == OTPPAD_E_CHKSUM_HEX_LEN &&
(plen == 0 || strncmp(name, prefix, plen) == 0)) {
memcpy(found, name, base);
found[base] = '\0';
matches++;
}
}
}
entry.close();
}
dir.close();
if (matches == 0) return -1;
/* When no prefix was given (bind_first), return the first match found
* rather than failing on ambiguity. Only fail with -2 when a prefix was
* specified and it matches multiple pads. */
if (matches > 1 && plen > 0) return -2;
strcpy(out_chksum, found);
return 0;
}
static int verify_pad_checksum(void) {
if (!g_sd.pad_file) return 1;
otppad_e_checksum_ctx ctx;
otppad_e_checksum_init(&ctx);
if (!g_sd.pad_file.seek((uint64_t)0)) return 2;
unsigned char *buf = (unsigned char *)malloc(4096);
if (!buf) return 3;
uint64_t pos = 0;
int got;
while ((got = g_sd.pad_file.read(buf, 4096)) > 0) {
otppad_e_checksum_update(&ctx, buf, (size_t)got, pos);
pos += (uint64_t)got;
}
free(buf);
if (!g_sd.pad_file.seek((uint64_t)0)) return 4;
unsigned char pad_key[OTPPAD_E_CHKSUM_BIN_LEN];
int n = g_sd.pad_file.read(pad_key, OTPPAD_E_CHKSUM_BIN_LEN);
if (n != OTPPAD_E_CHKSUM_BIN_LEN) return 5;
g_sd.pad_file.seek((uint64_t)0);
char hex[OTPPAD_E_CHKSUM_HEX_LEN + 1];
otppad_e_checksum_final(&ctx, pad_key, hex);
if (strcmp(hex, g_sd.chksum) != 0) {
Serial.print("otp_pad_sd: checksum mismatch. file=");
Serial.print(g_sd.chksum);
Serial.print(" computed=");
Serial.println(hex);
return 6;
}
return 0;
}
static int read_pad_slice(uint64_t offset, size_t len, unsigned char *out) {
if (!g_sd.pad_file) return -1;
if (!g_sd.pad_file.seek(offset)) return -2;
size_t got = 0;
while (got < len) {
int n = g_sd.pad_file.read(out + got, len - got);
if (n <= 0) return -3;
got += (size_t)n;
}
return 0;
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
int otp_pad_sd_mount(void) {
/* Give the SD card time to power up. The Teensy 4.1's SDMMC peripheral
* may need a brief delay after boot before the card is ready. Retry up
* to 3 times with a 500ms delay between attempts. */
for (int attempt = 0; attempt < 3; attempt++) {
if (sd.begin(SdioConfig(FIFO_SDIO))) {
Serial.println("otp_pad_sd: SD card mounted (SdFat direct, FAT-only)");
return 0;
}
Serial.print("otp_pad_sd: sd.begin attempt ");
Serial.print(attempt + 1);
Serial.println(" failed, retrying...");
delay(500);
}
Serial.println("otp_pad_sd: sd.begin(SdioConfig(FIFO_SDIO)) failed after 3 attempts");
return 1;
}
int otp_pad_sd_bind(const char *chksum_or_prefix) {
if (!chksum_or_prefix) return 1;
if (g_sd.bound) return 2;
char chksum[OTPPAD_E_CHKSUM_HEX_LEN + 1];
if (strlen(chksum_or_prefix) == OTPPAD_E_CHKSUM_HEX_LEN) {
strncpy(chksum, chksum_or_prefix, OTPPAD_E_CHKSUM_HEX_LEN);
chksum[OTPPAD_E_CHKSUM_HEX_LEN] = '\0';
char path[OTP_SD_PATH_MAX];
snprintf(path, sizeof(path), "%s/%s.pad", OTP_SD_PADS_DIR, chksum);
if (!sd.exists(path)) {
Serial.print("otp_pad_sd: pad not found: "); Serial.println(path);
return 3;
}
} else {
int r = resolve_pad(chksum_or_prefix, chksum);
if (r == -1) {
Serial.print("otp_pad_sd: no pad matching prefix '");
Serial.print(chksum_or_prefix); Serial.println("'");
return 4;
} else if (r == -2) {
Serial.print("otp_pad_sd: ambiguous prefix '");
Serial.print(chksum_or_prefix); Serial.println("'");
return 5;
}
}
snprintf(g_sd.pad_path, sizeof(g_sd.pad_path), "%s/%s.pad",
OTP_SD_PADS_DIR, chksum);
strncpy(g_sd.chksum, chksum, OTP_SD_CHKSUM_MAX - 1);
g_sd.chksum[OTP_SD_CHKSUM_MAX - 1] = '\0';
File32 f = sd.open(g_sd.pad_path, O_RDONLY);
if (!f) {
Serial.print("otp_pad_sd: cannot open "); Serial.println(g_sd.pad_path);
return 6;
}
g_sd.pad_size = (uint64_t)f.size();
if (g_sd.pad_size < OTPPAD_E_HEADER_RESERVED) {
Serial.println("otp_pad_sd: pad too small");
f.close();
return 7;
}
g_sd.pad_file = f;
if (verify_pad_checksum() != 0) {
g_sd.pad_file.close();
return 8;
}
uint64_t offset;
if (otppad_e_state_read_sd(OTP_SD_PADS_DIR, g_sd.chksum, &offset) != 0) {
offset = OTPPAD_E_HEADER_RESERVED;
if (otppad_e_state_write_sd(OTP_SD_PADS_DIR, g_sd.chksum, offset) != 0) {
Serial.println("otp_pad_sd: cannot write initial .state");
g_sd.pad_file.close();
return 9;
}
}
if (offset < OTPPAD_E_HEADER_RESERVED) {
Serial.print("otp_pad_sd: offset < reserved header: ");
Serial.println((unsigned long)offset);
g_sd.pad_file.close();
return 10;
}
if (offset > g_sd.pad_size) {
Serial.println("otp_pad_sd: offset past end of pad");
g_sd.pad_file.close();
return 11;
}
g_sd.bound = 1;
Serial.print("otp_pad_sd: bound pad ");
Serial.print(g_sd.chksum);
Serial.print(" (");
Serial.print((unsigned long)g_sd.pad_size);
Serial.print(" bytes, offset=");
Serial.print((unsigned long)offset);
Serial.println(")");
return 0;
}
int otp_pad_sd_bind_first(void) {
char chksum[OTPPAD_E_CHKSUM_HEX_LEN + 1];
int r = resolve_pad(NULL, chksum);
if (r != 0) {
Serial.println("otp_pad_sd: no pads found in /pads");
return 1;
}
return otp_pad_sd_bind(chksum);
}
void otp_pad_sd_unbind(void) {
if (g_sd.pad_file) {
g_sd.pad_file.close();
}
secure_memzero(&g_sd, sizeof(g_sd));
}
int otp_pad_sd_ready(void) { return g_sd.bound ? 1 : 0; }
const char *otp_pad_sd_chksum(void) { return g_sd.bound ? g_sd.chksum : NULL; }
uint64_t otp_pad_sd_offset(void) {
if (!g_sd.bound) return 0;
uint64_t off;
if (otppad_e_state_read_sd(OTP_SD_PADS_DIR, g_sd.chksum, &off) != 0) {
return 0;
}
return off;
}
uint64_t otp_pad_sd_size(void) { return g_sd.bound ? g_sd.pad_size : 0; }
/* Debug: list files in /pads into `out`. Returns file count. */
int otp_pad_sd_debug_list(char *out, size_t cap) {
if (out && cap > 0) out[0] = '\0';
File32 dir = sd.open(OTP_SD_PADS_DIR);
if (!dir) {
if (out && cap > 20) snprintf(out, cap, "sd.open(/pads) FAILED");
return -1;
}
int count = 0;
while (count < 10) {
File32 entry = dir.openNextFile();
if (!entry) break;
if (!entry.isDir()) {
char name[128];
entry.getName(name, sizeof(name));
size_t cur = out ? strlen(out) : 0;
size_t remain = cap > cur ? cap - cur : 0;
if (remain > strlen(name) + 16) {
snprintf(out + cur, remain, "[%d] %s (%lu bytes)\n",
count, name, (unsigned long)entry.size());
}
count++;
}
entry.close();
}
dir.close();
return count;
}
/* ------------------------------------------------------------------ */
/* Encrypt / decrypt */
/* ------------------------------------------------------------------ */
int otp_pad_sd_encrypt(const unsigned char *plaintext, size_t pt_len,
const char *encoding,
char **out_payload, size_t *out_payload_len,
uint64_t *out_off_before, uint64_t *out_off_after) {
if (!g_sd.bound) return 1;
if (!plaintext || !out_payload || !out_payload_len ||
!out_off_before || !out_off_after) return 2;
*out_payload = NULL;
*out_payload_len = 0;
size_t chunk = otppad_e_chunk_size(pt_len);
if (chunk > OTP_SD_MAX_CHUNK) {
Serial.print("otp_pad_sd: chunk too large: ");
Serial.println((unsigned long)chunk);
return 3;
}
unsigned char *buf = (unsigned char *)malloc(chunk);
if (!buf) return 4;
memcpy(buf, plaintext, pt_len);
if (otppad_e_pad_apply(buf, pt_len, chunk) != 0) { free(buf); return 5; }
uint64_t offset;
if (otppad_e_state_read_sd(OTP_SD_PADS_DIR, g_sd.chksum, &offset) != 0) {
free(buf); return 6;
}
if (offset + chunk > g_sd.pad_size) {
Serial.println("otp_pad_sd: pad exhausted");
free(buf); return 7;
}
*out_off_before = offset;
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
if (!pad_slice) { free(buf); return 8; }
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
free(buf); free(pad_slice); return 9;
}
for (size_t i = 0; i < chunk; i++) {
buf[i] ^= pad_slice[i];
}
secure_memzero(pad_slice, chunk);
free(pad_slice);
uint64_t new_offset = offset + chunk;
if (otppad_e_state_write_sd(OTP_SD_PADS_DIR, g_sd.chksum, new_offset) != 0) {
secure_memzero(buf, chunk); free(buf); return 10;
}
*out_off_after = new_offset;
if (encoding && strcmp(encoding, "binary") == 0) {
otppad_e_bin_header_t hdr;
memset(&hdr, 0, sizeof(hdr));
memcpy(hdr.magic, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN);
hdr.version = OTPPAD_E_FORMAT_VERSION;
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) {
unsigned int byte;
sscanf(g_sd.chksum + i * 2, "%02x", &byte);
hdr.pad_chksum[i] = (unsigned char)byte;
}
hdr.pad_offset = offset;
hdr.file_mode = 0644;
hdr.file_size = pt_len;
size_t blob_size = 58 + chunk;
unsigned char *blob = (unsigned char *)malloc(blob_size);
if (!blob) { secure_memzero(buf, chunk); free(buf); return 11; }
if (otppad_e_bin_header_pack(&hdr, blob, 58) != 0) {
free(blob); secure_memzero(buf, chunk); free(buf); return 12;
}
memcpy(blob + 58, buf, chunk);
*out_payload = (char *)blob;
*out_payload_len = blob_size;
} else {
char *armor = NULL;
if (otppad_e_armor_generate("teensy41-nsigner", g_sd.chksum, offset,
buf, chunk, &armor) != 0) {
secure_memzero(buf, chunk); free(buf); return 13;
}
*out_payload = armor;
*out_payload_len = strlen(armor);
}
secure_memzero(buf, chunk);
free(buf);
return 0;
}
int otp_pad_sd_decrypt(const char *input, size_t input_len,
const char *encoding,
unsigned char **out_plaintext, size_t *out_pt_len) {
if (!g_sd.bound) return 1;
if (!input || !out_plaintext || !out_pt_len) return 2;
*out_plaintext = NULL;
*out_pt_len = 0;
uint64_t offset;
size_t chunk;
unsigned char *ciphertext = NULL;
size_t ct_len = 0;
int is_binary;
if (encoding && strcmp(encoding, "binary") == 0) {
is_binary = 1;
} else if (encoding && strcmp(encoding, "ascii") == 0) {
is_binary = 0;
} else {
is_binary = (input_len >= 4 &&
memcmp(input, OTPPAD_E_MAGIC, 4) == 0);
}
if (!is_binary) {
char chksum[OTPPAD_E_CHKSUM_HEX_LEN + 1];
size_t b64_cap = (OTP_SD_MAX_CHUNK / 3 * 4) + 256;
char *b64 = (char *)malloc(b64_cap);
if (!b64) return 3;
if (otppad_e_armor_parse(input, chksum, &offset, b64, b64_cap) != 0) {
free(b64); return 4;
}
if (strcmp(chksum, g_sd.chksum) != 0) {
free(b64); return 5;
}
int dlen = 0;
ciphertext = otppad_e_base64_decode(b64, &dlen);
free(b64);
if (!ciphertext) return 6;
ct_len = (size_t)dlen;
chunk = ct_len;
} else {
if (input_len < 58) return 7;
otppad_e_bin_header_t hdr;
if (otppad_e_bin_header_unpack((const unsigned char *)input, input_len,
&hdr) != 0) return 8;
if (!otppad_e_bin_is_magic((const unsigned char *)input, input_len)) {
return 9;
}
char chksum_hex[OTPPAD_E_CHKSUM_HEX_LEN + 1];
bytes_to_hex(hdr.pad_chksum, OTPPAD_E_CHKSUM_BIN_LEN, chksum_hex);
if (strcmp(chksum_hex, g_sd.chksum) != 0) return 10;
offset = hdr.pad_offset;
ct_len = input_len - 58;
chunk = ct_len;
ciphertext = (unsigned char *)malloc(ct_len ? ct_len : 1);
if (!ciphertext) return 11;
memcpy(ciphertext, input + 58, ct_len);
}
if (chunk > OTP_SD_MAX_CHUNK) { free(ciphertext); return 12; }
if (offset + chunk > g_sd.pad_size) { free(ciphertext); return 13; }
unsigned char *buf = (unsigned char *)malloc(chunk);
if (!buf) { free(ciphertext); return 14; }
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
if (!pad_slice) { free(buf); free(ciphertext); return 15; }
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
free(buf); free(pad_slice); free(ciphertext); return 16;
}
for (size_t i = 0; i < chunk; i++) {
buf[i] = ciphertext[i] ^ pad_slice[i];
}
secure_memzero(pad_slice, chunk);
free(pad_slice);
secure_memzero(ciphertext, ct_len);
free(ciphertext);
size_t pt_len;
if (otppad_e_pad_remove(buf, chunk, &pt_len) != 0) {
secure_memzero(buf, chunk); free(buf); return 17;
}
unsigned char *pt = (unsigned char *)malloc(pt_len ? pt_len : 1);
if (!pt) { secure_memzero(buf, chunk); free(buf); return 18; }
memcpy(pt, buf, pt_len);
secure_memzero(buf, chunk);
free(buf);
*out_plaintext = pt;
*out_pt_len = pt_len;
return 0;
}
+94
View File
@@ -0,0 +1,94 @@
/* otp_pad_sd.h — SD-card one-time-pad for the Teensy 4.1 n_signer firmware.
*
* Replaces the old HKDF-derived in-RAM pad (otp_pad.h/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 otppad_embedded (a port of libotppad).
*
* One pad per session. The pad file is opened read-only and kept open for the
* lifetime of the session. The per-pad .state file (offset counter) is read
* and written via otppad_e_state_read_sd / otppad_e_state_write_sd (atomic
* write-temp-then-rename on the SD card).
*
* Pad bytes are never loaded whole into RAM. Each encrypt/decrypt request
* seeks to the current offset and reads exactly the chunk it needs into a
* DMAMEM scratch buffer.
*
* Wire format (matches host otp_encrypt/otp_decrypt):
* encrypt: [plaintext_b64, {"encoding": "ascii"|"binary"}]
* -> {"ciphertext": ..., "pad_chksum": ..., "pad_offset_before": N,
* "pad_offset_after": N}
* decrypt: [ciphertext, {"encoding": "ascii"|"binary"}]
* -> {"plaintext": "<b64>"} (offset read from armor/binary header)
*/
#ifndef FIRMWARE_TEENSY41_SIGNER_OTP_PAD_SD_H
#define FIRMWARE_TEENSY41_SIGNER_OTP_PAD_SD_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Max chunk size we will Padmé-pad and XOR in RAM. 16 KB keeps the two DMAMEM
* scratch buffers (32 KB total) within the Teensy 4.1's tight RAM2 budget
* alongside the existing crypto workspaces. Padmé buckets up to 16 KB cover
* plaintexts up to ~15 KB, plenty for Nostr event content. */
#define OTP_SD_MAX_CHUNK 16384
/* Mount the SD card via SD.begin(BUILTIN_SDCARD). Returns 0 on success,
* non-zero if no card / bad card. Must be called once at boot before bind. */
int otp_pad_sd_mount(void);
/* Bind a pad by its 64-hex-char checksum (or a unique prefix). Opens the pad
* read-only, verifies the checksum, reads the offset from .state (defaulting
* to 32 if no .state file). Returns 0 on success, non-zero on error. */
int otp_pad_sd_bind(const char *chksum_or_prefix);
/* Debug auto-bind: scan the SD root for the first *.pad file and bind it.
* Returns 0 on success, non-zero if no pad found or bind failed. */
int otp_pad_sd_bind_first(void);
/* Unbind: close the pad file, zeroize state. */
void otp_pad_sd_unbind(void);
/* Whether a pad is bound for this session. */
int otp_pad_sd_ready(void);
/* The bound pad's 64-hex-char checksum, or NULL if not bound. */
const char *otp_pad_sd_chksum(void);
/* Current offset from the .state file, or 0 if not bound. */
uint64_t otp_pad_sd_offset(void);
/* Total pad file size in bytes, or 0 if not bound. */
uint64_t otp_pad_sd_size(void);
/* Debug: list files in /pads into `out` (caller-provided buffer, size `cap`).
* Returns the number of files found. Used by the otp_debug verb to diagnose
* bind failures without needing serial boot output. */
int otp_pad_sd_debug_list(char *out, size_t cap);
/* Encrypt: takes plaintext bytes, returns a malloc'd ASCII armor or binary
* blob in *out_payload (caller frees). `encoding` is "ascii" or "binary".
* On success returns 0 and sets *out_payload_len, *out_off_before,
* *out_off_after. Advances the offset in .state atomically. */
int otp_pad_sd_encrypt(const unsigned char *plaintext, size_t pt_len,
const char *encoding,
char **out_payload, size_t *out_payload_len,
uint64_t *out_off_before, uint64_t *out_off_after);
/* Decrypt: takes ASCII armor or binary blob, returns malloc'd plaintext in
* *out_plaintext (caller frees). `encoding` is "ascii" or "binary" (auto-
* detected if NULL). Does NOT advance the offset (decrypt is non-consuming,
* matching the host). On success returns 0 and sets *out_pt_len. */
int otp_pad_sd_decrypt(const char *input, size_t input_len,
const char *encoding,
unsigned char **out_plaintext, size_t *out_pt_len);
#ifdef __cplusplus
}
#endif
#endif /* FIRMWARE_TEENSY41_SIGNER_OTP_PAD_SD_H */
@@ -0,0 +1,443 @@
/* otppad_embedded.c — implementation of otppad_embedded.h.
*
* Bit-compatible port of libotppad (libotppad/libotppad.c) for the Teensy 4.1
* firmware. The pure format functions (XOR, base64, Padmé, armor, binary
* header) are straight ports. The I/O functions are split:
* - HOST_TEST defined: POSIX FILE-star / mkstemp / rename (host unit tests).
* - otherwise: Arduino SD library (linked from the C++ side via thin
* wrappers in otp_pad_sd.cpp; the state read/write helpers here call
* through small C shims that otp_pad_sd.cpp provides).
*
* To keep this file pure C and buildable on both host and Teensy without
* pulling Arduino headers here, the SD state I/O is implemented in
* otp_pad_sd.cpp (C++) and declared here only under the non-HOST_TEST path as
* the otppad_e_state_read_sd / otppad_e_state_write_sd prototypes (already in
* the header). This file does NOT implement them; otp_pad_sd.cpp does.
*/
#include "otppad_embedded.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#ifdef HOST_TEST
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#endif
/* ------------------------------------------------------------------ */
/* XOR transform */
/* ------------------------------------------------------------------ */
int otppad_e_xor(const unsigned char *data, size_t data_len,
const unsigned char *pad_data, unsigned char *result) {
if (!data || !pad_data || !result) return 1;
for (size_t i = 0; i < data_len; i++) {
result[i] = data[i] ^ pad_data[i];
}
return 0;
}
/* ------------------------------------------------------------------ */
/* Base64 (identical tables/algorithm to libotppad) */
/* ------------------------------------------------------------------ */
static const char b64_chars[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static const int b64_decode_table[256] = {
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63,
52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-2,-1,-1,
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,
15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,
-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,
41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
};
char *otppad_e_base64_encode(const unsigned char *input, int length) {
if (!input || length < 0) return NULL;
int output_length = 4 * ((length + 2) / 3);
char *encoded = (char *)malloc((size_t)output_length + 1);
if (!encoded) return NULL;
int i, j;
for (i = 0, j = 0; i < length;) {
uint32_t octet_a = i < length ? input[i++] : 0;
uint32_t octet_b = i < length ? input[i++] : 0;
uint32_t octet_c = i < length ? input[i++] : 0;
uint32_t triple = (octet_a << 16) + (octet_b << 8) + octet_c;
encoded[j++] = b64_chars[(triple >> 18) & 63];
encoded[j++] = b64_chars[(triple >> 12) & 63];
encoded[j++] = b64_chars[(triple >> 6) & 63];
encoded[j++] = b64_chars[triple & 63];
}
for (int pad = 0; pad < (3 - length % 3) % 3; pad++) {
encoded[output_length - 1 - pad] = '=';
}
encoded[output_length] = '\0';
return encoded;
}
unsigned char *otppad_e_base64_decode(const char *input, int *output_length) {
if (!input || !output_length) return NULL;
int input_length = (int)strlen(input);
if (input_length % 4 != 0) return NULL;
*output_length = input_length / 4 * 3;
if (input[input_length - 1] == '=') (*output_length)--;
if (input[input_length - 2] == '=') (*output_length)--;
unsigned char *decoded = (unsigned char *)malloc((size_t)*output_length);
if (!decoded) return NULL;
int i, j;
for (i = 0, j = 0; i < input_length;) {
int sa = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
int sb = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
int sc = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
int sd = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
if (sa == -1 || sb == -1 || sc == -1 || sd == -1) {
free(decoded);
return NULL;
}
uint32_t triple = ((uint32_t)sa << 18) + ((uint32_t)sb << 12) +
((uint32_t)sc << 6) + (uint32_t)sd;
if (j < *output_length) decoded[j++] = (triple >> 16) & 255;
if (j < *output_length) decoded[j++] = (triple >> 8) & 255;
if (j < *output_length) decoded[j++] = triple & 255;
}
return decoded;
}
/* ------------------------------------------------------------------ */
/* Padmé padding (identical to libotppad) */
/* ------------------------------------------------------------------ */
size_t otppad_e_chunk_size(size_t msg_len) {
size_t chunk = 256;
while (chunk < msg_len + 1) {
chunk *= 2;
}
return chunk;
}
int otppad_e_pad_apply(unsigned char *buffer, size_t msg_len, size_t chunk_size) {
if (!buffer) return 1;
if (chunk_size < msg_len + 1) return 2;
buffer[msg_len] = 0x80;
if (chunk_size > msg_len + 1) {
memset(buffer + msg_len + 1, 0x00, chunk_size - msg_len - 1);
}
return 0;
}
int otppad_e_pad_remove(const unsigned char *buffer, size_t chunk_size,
size_t *msg_len) {
if (!buffer || !msg_len) return 1;
if (chunk_size == 0) return 2;
for (int i = (int)chunk_size - 1; i >= 0; i--) {
if (buffer[i] == 0x80) {
*msg_len = (size_t)i;
return 0;
} else if (buffer[i] != 0x00) {
return 3;
}
}
return 4;
}
/* ------------------------------------------------------------------ */
/* ASCII armored message format */
/* ------------------------------------------------------------------ */
/* Manual line splitter (replaces strtok). Parses `message` line by line. */
int otppad_e_armor_parse(const char *message, char *chksum, uint64_t *offset,
char *base64_data, size_t base64_buf_size) {
if (!message || !chksum || !offset || !base64_data || base64_buf_size == 0) {
return 1;
}
size_t msg_len = strlen(message);
char *copy = (char *)malloc(msg_len + 1);
if (!copy) return 1;
strcpy(copy, message);
int found_begin = 0, in_data = 0, found_chksum = 0, found_offset = 0;
chksum[0] = '\0';
*offset = 0;
base64_data[0] = '\0';
char *line = copy;
char *next = copy;
while (next != NULL) {
/* find end of line */
char *nl = strchr(line, '\n');
if (nl) { *nl = '\0'; next = nl + 1; }
else { next = NULL; }
/* strip trailing \r */
size_t llen = strlen(line);
if (llen > 0 && line[llen - 1] == '\r') line[llen - 1] = '\0';
if (strcmp(line, OTPPAD_E_ARMOR_BEGIN) == 0) {
found_begin = 1;
} else if (strcmp(line, OTPPAD_E_ARMOR_END) == 0) {
break;
} else if (found_begin) {
if (strncmp(line, "Pad-ChkSum: ", 12) == 0) {
strncpy(chksum, line + 12, OTPPAD_E_CHKSUM_HEX_LEN);
chksum[OTPPAD_E_CHKSUM_HEX_LEN] = '\0';
found_chksum = 1;
} else if (strncmp(line, "Pad-Offset: ", 12) == 0) {
*offset = strtoull(line + 12, NULL, 10);
found_offset = 1;
} else if (strlen(line) == 0) {
in_data = 1;
} else if (in_data) {
size_t cur = strlen(base64_data);
size_t add = strlen(line);
if (cur + add + 1 <= base64_buf_size) {
strncat(base64_data, line, base64_buf_size - cur - 1);
}
} else if (strncmp(line, "Version:", 8) != 0 &&
strncmp(line, "Pad-", 4) != 0) {
/* non-header, non-empty line before the blank separator —
* treat as data (matches libotppad's fallthrough). */
size_t cur = strlen(base64_data);
size_t add = strlen(line);
if (cur + add + 1 <= base64_buf_size) {
strncat(base64_data, line, base64_buf_size - cur - 1);
}
}
}
line = next;
}
free(copy);
if (!found_begin || !found_chksum || !found_offset) {
return 2;
}
return 0;
}
int otppad_e_armor_generate(const char *version, const char *chksum,
uint64_t offset,
const unsigned char *encrypted_data, size_t data_length,
char **ascii_output) {
if (!chksum || !encrypted_data || !ascii_output) return 1;
char *b64 = otppad_e_base64_encode(encrypted_data, (int)data_length);
if (!b64) return 2;
size_t b64_len = strlen(b64);
size_t total = 256 + b64_len + (b64_len / 64) + 64;
*ascii_output = (char *)malloc(total);
if (!*ascii_output) {
free(b64);
return 3;
}
char line[256];
strcpy(*ascii_output, OTPPAD_E_ARMOR_BEGIN);
strcat(*ascii_output, "\n");
snprintf(line, sizeof(line), "Version: %s\n", version ? version : "v0");
strcat(*ascii_output, line);
snprintf(line, sizeof(line), "Pad-ChkSum: %s\n", chksum);
strcat(*ascii_output, line);
snprintf(line, sizeof(line), "Pad-Offset: %llu\n",
(unsigned long long)offset);
strcat(*ascii_output, line);
strcat(*ascii_output, "\n");
int b64_len_int = (int)b64_len;
for (int i = 0; i < b64_len_int; i += 64) {
snprintf(line, sizeof(line), "%.64s\n", b64 + i);
strcat(*ascii_output, line);
}
strcat(*ascii_output, OTPPAD_E_ARMOR_END);
strcat(*ascii_output, "\n");
free(b64);
return 0;
}
/* ------------------------------------------------------------------ */
/* Binary .otp header (58 bytes, little-endian) */
/* ------------------------------------------------------------------ */
/* Pack a uint16 little-endian. */
static void put_u16le(unsigned char *p, uint16_t v) {
p[0] = (unsigned char)(v & 0xFF);
p[1] = (unsigned char)((v >> 8) & 0xFF);
}
static uint16_t get_u16le(const unsigned char *p) {
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
}
static void put_u32le(unsigned char *p, uint32_t v) {
p[0] = (unsigned char)(v & 0xFF);
p[1] = (unsigned char)((v >> 8) & 0xFF);
p[2] = (unsigned char)((v >> 16) & 0xFF);
p[3] = (unsigned char)((v >> 24) & 0xFF);
}
static uint32_t get_u32le(const unsigned char *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static void put_u64le(unsigned char *p, uint64_t v) {
for (int i = 0; i < 8; i++) {
p[i] = (unsigned char)((v >> (8 * i)) & 0xFF);
}
}
static uint64_t get_u64le(const unsigned char *p) {
uint64_t v = 0;
for (int i = 0; i < 8; i++) {
v |= ((uint64_t)p[i]) << (8 * i);
}
return v;
}
int otppad_e_bin_header_pack(const otppad_e_bin_header_t *hdr,
unsigned char *out, size_t out_len) {
if (!hdr || !out) return 1;
if (out_len < 58) return 2;
unsigned char *p = out;
memcpy(p, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN); p += 4;
put_u16le(p, hdr->version); p += 2;
memcpy(p, hdr->pad_chksum, OTPPAD_E_CHKSUM_BIN_LEN); p += OTPPAD_E_CHKSUM_BIN_LEN;
put_u64le(p, hdr->pad_offset); p += 8;
put_u32le(p, hdr->file_mode); p += 4;
put_u64le(p, hdr->file_size); p += 8;
/* p - out == 58 */
return 0;
}
int otppad_e_bin_header_unpack(const unsigned char *in, size_t in_len,
otppad_e_bin_header_t *hdr) {
if (!in || !hdr) return 1;
if (in_len < 58) return 2;
memset(hdr, 0, sizeof(*hdr));
const unsigned char *p = in;
memcpy(hdr->magic, p, OTPPAD_E_MAGIC_LEN); p += 4;
hdr->version = get_u16le(p); p += 2;
memcpy(hdr->pad_chksum, p, OTPPAD_E_CHKSUM_BIN_LEN); p += OTPPAD_E_CHKSUM_BIN_LEN;
hdr->pad_offset = get_u64le(p); p += 8;
hdr->file_mode = get_u32le(p); p += 4;
hdr->file_size = get_u64le(p); p += 8;
return 0;
}
int otppad_e_bin_is_magic(const unsigned char *buf, size_t len) {
if (!buf || len < OTPPAD_E_MAGIC_LEN) return 0;
return memcmp(buf, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN) == 0;
}
/* ------------------------------------------------------------------ */
/* Pad checksum (streaming) */
/* ------------------------------------------------------------------ */
void otppad_e_checksum_init(otppad_e_checksum_ctx *ctx) {
if (!ctx) return;
memset(ctx->buckets, 0, OTPPAD_E_CHKSUM_BIN_LEN);
}
void otppad_e_checksum_update(otppad_e_checksum_ctx *ctx,
const unsigned char *data, size_t len,
uint64_t abs_pos) {
if (!ctx || !data) return;
for (size_t i = 0; i < len; i++) {
uint64_t pos = abs_pos + (uint64_t)i;
unsigned char bucket = (unsigned char)(pos % OTPPAD_E_CHKSUM_BIN_LEN);
ctx->buckets[bucket] ^= (unsigned char)data[i] ^
(unsigned char)((pos >> 8) & 0xFF) ^
(unsigned char)((pos >> 16) & 0xFF) ^
(unsigned char)((pos >> 24) & 0xFF);
}
}
void otppad_e_checksum_final(otppad_e_checksum_ctx *ctx,
const unsigned char *pad_key, char *out_hex) {
if (!ctx || !pad_key || !out_hex) return;
unsigned char enc[OTPPAD_E_CHKSUM_BIN_LEN];
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) {
enc[i] = ctx->buckets[i] ^ pad_key[i];
}
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) {
sprintf(out_hex + (i * 2), "%02x", enc[i]);
}
out_hex[OTPPAD_E_CHKSUM_HEX_LEN] = '\0';
}
/* ------------------------------------------------------------------ */
/* Per-pad .state file — HOST_TEST (POSIX) implementation */
/* ------------------------------------------------------------------ */
#ifdef HOST_TEST
int otppad_e_state_read_posix(const char *pads_dir, const char *chksum,
uint64_t *offset) {
if (!pads_dir || !chksum || !offset) return 1;
char path[1024];
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
FILE *f = fopen(path, "r");
if (!f) return 2;
char line[128];
if (!fgets(line, sizeof(line), f)) {
fclose(f);
return 3;
}
fclose(f);
if (strncmp(line, "offset=", 7) != 0) return 4;
*offset = strtoull(line + 7, NULL, 10);
return 0;
}
int otppad_e_state_write_posix(const char *pads_dir, const char *chksum,
uint64_t offset) {
if (!pads_dir || !chksum) return 1;
char path[1024];
char tmp[1100];
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
snprintf(tmp, sizeof(tmp), "%s/%s.state.tmp.XXXXXX", pads_dir, chksum);
int tfd = mkstemp(tmp);
if (tfd < 0) return 2;
FILE *f = fdopen(tfd, "w");
if (!f) {
close(tfd);
unlink(tmp);
return 3;
}
if (fprintf(f, "offset=%llu\n", (unsigned long long)offset) < 0) {
fclose(f);
unlink(tmp);
return 4;
}
if (fclose(f) != 0) {
unlink(tmp);
return 5;
}
if (rename(tmp, path) != 0) {
unlink(tmp);
return 6;
}
return 0;
}
#endif /* HOST_TEST */
@@ -0,0 +1,180 @@
/* otppad_embedded.h — Teensy/Arduino-friendly port of libotppad.
*
* Bit-compatible with libotppad (libotppad/libotppad.h) and the `otp` project:
* - XOR transform
* - ASCII armored message format ("-----BEGIN OTP MESSAGE-----")
* - Binary .otp file format (magic "OTP\0", 58-byte header)
* - ISO/IEC 9797-1 Method 2 (Padmé) padding with exponential bucketing
* - Per-pad .state file ("offset=<n>\n")
* - 256-bit XOR pad checksum (position-dependent, XORed with first 32 pad
* bytes)
*
* Differences from libotppad:
* - No POSIX FILE-star / mkstemp / rename dependencies in the core format
* functions.
* - The I/O functions (checksum, state read/write) are split into a
* "buffer/stream" form (otppad_checksum_stream) and an SD-card form
* (otppad_state_read_sd / otppad_state_write_sd) that take an Arduino
* SD File and a pads-dir path. When compiled with -DHOST_TEST, the SD
* File is replaced by a POSIX FILE* so the same source builds on the host
* for bit-compatibility unit tests.
*
* License: same as libotppad / the otp project.
*/
#ifndef FIRMWARE_TEENSY41_SIGNER_OTPPAD_EMBEDDED_H
#define FIRMWARE_TEENSY41_SIGNER_OTPPAD_EMBEDDED_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ------------------------------------------------------------------ */
/* Constants (must match libotppad/libotppad.h exactly) */
/* ------------------------------------------------------------------ */
#define OTPPAD_E_CHKSUM_HEX_LEN 64
#define OTPPAD_E_CHKSUM_BIN_LEN 32
#define OTPPAD_E_HEADER_RESERVED 32
#define OTPPAD_E_MAGIC "OTP\0" /* 4-byte binary file magic */
#define OTPPAD_E_MAGIC_LEN 4
#define OTPPAD_E_FORMAT_VERSION 1
#define OTPPAD_E_ARMOR_BEGIN "-----BEGIN OTP MESSAGE-----"
#define OTPPAD_E_ARMOR_END "-----END OTP MESSAGE-----"
/* ------------------------------------------------------------------ */
/* XOR transform */
/* ------------------------------------------------------------------ */
/* XOR `data_len` bytes of `data` with `pad_data` into `result`.
* `result` may alias `data` or `pad_data`. Returns 0 on success, non-zero on
* null pointer. */
int otppad_e_xor(const unsigned char *data, size_t data_len,
const unsigned char *pad_data, unsigned char *result);
/* ------------------------------------------------------------------ */
/* Base64 */
/* ------------------------------------------------------------------ */
/* Encode `length` bytes of `input` as a NUL-terminated base64 string.
* Caller frees the returned string. Returns NULL on allocation failure. */
char *otppad_e_base64_encode(const unsigned char *input, int length);
/* Decode NUL-terminated base64 `input` into bytes.
* Caller frees the returned buffer. *output_length receives the byte count.
* Returns NULL on invalid input or allocation failure. */
unsigned char *otppad_e_base64_decode(const char *input, int *output_length);
/* ------------------------------------------------------------------ */
/* Padmé padding (ISO/IEC 9797-1 Method 2) + exponential bucketing */
/* ------------------------------------------------------------------ */
/* Calculate the bucket size for a message of `msg_len` bytes.
* Starts at 256 bytes and doubles until `chunk >= msg_len + 1`. */
size_t otppad_e_chunk_size(size_t msg_len);
/* Apply Padmé padding to `buffer` (must hold `chunk_size` bytes).
* Writes 0x80 at `buffer[msg_len]` then zeroes to `chunk_size`.
* Returns 0 on success, non-zero on error. */
int otppad_e_pad_apply(unsigned char *buffer, size_t msg_len, size_t chunk_size);
/* Remove Padmé padding: scan backwards for 0x80, set *msg_len to its index.
* Returns 0 on success, non-zero on invalid padding. */
int otppad_e_pad_remove(const unsigned char *buffer, size_t chunk_size,
size_t *msg_len);
/* ------------------------------------------------------------------ */
/* ASCII armored message format */
/* ------------------------------------------------------------------ */
/* Parse an ASCII-armored OTP message.
* `chksum` must be at least OTPPAD_E_CHKSUM_HEX_LEN+1 bytes.
* `base64_data` must be at least `base64_buf_size` bytes.
* On success returns 0 and sets `chksum`, `*offset`, and `base64_data`. */
int otppad_e_armor_parse(const char *message, char *chksum, uint64_t *offset,
char *base64_data, size_t base64_buf_size);
/* Build an ASCII-armored OTP message.
* On success returns 0 and sets `*ascii_output` to a malloc'd NUL-terminated
* string. Caller frees `*ascii_output`. */
int otppad_e_armor_generate(const char *version, const char *chksum,
uint64_t offset,
const unsigned char *encrypted_data, size_t data_length,
char **ascii_output);
/* ------------------------------------------------------------------ */
/* Binary .otp header (58 bytes, little-endian on disk) */
/* ------------------------------------------------------------------ */
typedef struct {
char magic[OTPPAD_E_MAGIC_LEN];
uint16_t version;
unsigned char pad_chksum[OTPPAD_E_CHKSUM_BIN_LEN];
uint64_t pad_offset;
uint32_t file_mode;
uint64_t file_size; /* original (unpadded) size */
} otppad_e_bin_header_t;
/* Serialize a header into the 58-byte buffer `out` (little-endian, matching
* libotppad's fwrite-of-host-endian-integers which is LE on ARM/x86). */
int otppad_e_bin_header_pack(const otppad_e_bin_header_t *hdr,
unsigned char *out, size_t out_len);
/* Parse a 58-byte buffer `in` into `hdr`. */
int otppad_e_bin_header_unpack(const unsigned char *in, size_t in_len,
otppad_e_bin_header_t *hdr);
/* Return 1 if the first 4 bytes of `buf` match the OTP magic. */
int otppad_e_bin_is_magic(const unsigned char *buf, size_t len);
/* ------------------------------------------------------------------ */
/* Per-pad .state file (SD card) */
/* ------------------------------------------------------------------ */
/* When HOST_TEST is defined, these use POSIX FILE* + rename for host unit
* tests. Otherwise they use the Arduino SD library via the C++ side. */
#ifndef HOST_TEST
/* Read the offset from `<pads_dir>/<chksum>.state`. Returns 0 on success. */
int otppad_e_state_read_sd(const char *pads_dir, const char *chksum,
uint64_t *offset);
/* Atomically write the offset to `<pads_dir>/<chksum>.state` via a temp file
* + rename. Returns 0 on success. */
int otppad_e_state_write_sd(const char *pads_dir, const char *chksum,
uint64_t offset);
#else
int otppad_e_state_read_posix(const char *pads_dir, const char *chksum,
uint64_t *offset);
int otppad_e_state_write_posix(const char *pads_dir, const char *chksum,
uint64_t offset);
#endif
/* ------------------------------------------------------------------ */
/* Pad checksum */
/* ------------------------------------------------------------------ */
/* Streaming checksum accumulator. Call _init once, _update with each chunk
* (passing the absolute byte position of the chunk's first byte), then _final
* with the first 32 bytes of the pad (the "pad key") to get the 64-hex-char
* checksum. Identical algorithm to libotppad otppad_checksum(). */
typedef struct {
unsigned char buckets[OTPPAD_E_CHKSUM_BIN_LEN];
} otppad_e_checksum_ctx;
void otppad_e_checksum_init(otppad_e_checksum_ctx *ctx);
void otppad_e_checksum_update(otppad_e_checksum_ctx *ctx,
const unsigned char *data, size_t len,
uint64_t abs_pos);
/* `pad_key` must be 32 bytes (the first 32 bytes of the pad). `out_hex` must
* be at least OTPPAD_E_CHKSUM_HEX_LEN+1 bytes. */
void otppad_e_checksum_final(otppad_e_checksum_ctx *ctx,
const unsigned char *pad_key, char *out_hex);
#ifdef __cplusplus
}
#endif
#endif /* FIRMWARE_TEENSY41_SIGNER_OTPPAD_EMBEDDED_H */
@@ -242,108 +242,74 @@ FLASHMEM_ATTR void poly_uniform_4x(poly *r0, poly *r1, poly *r2, poly *r3,
/* Sample polynomial c with exactly tau nonzero ±1 entries. /* Sample polynomial c with exactly tau nonzero ±1 entries.
* FIPS 204: SampleInBall. Uses SHAKE-256. * FIPS 204: SampleInBall. Uses SHAKE-256.
* *
* Re-squeeze domain separation: when the squeeze buffer is exhausted, we * Faithful port of PQClean's reference SampleInBall
* re-absorb the seed WITH a monotonic re-squeeze counter appended, so each * (crypto_sign/ml-dsa-65/ref/challenge.c). The canonical algorithm uses a
* re-squeeze produces fresh bytes. Without this, re-absorbing the same * single squeeze block and a counter `b` that serves a DUAL purpose:
* seed produces the same output and the SampleInBall rejection loop * - `b` counts how many bytes remain unconsumed in the block, AND
* can hang forever (the do/while never finds r <= i). This was the * - the low bit of `b` (after each pre-decrement) is the next sign bit.
* ml-dsa-65 sign hang. */ * Bytes are consumed from the END of the block (block[--b]). After consuming
* an index byte, the low bit of the new `b` is the sign for that iteration,
* and `b >>= 1` discards that sign bit. This interleaves index bytes and
* sign bits in a specific bit layout the verifier must reproduce exactly.
*
* The previous implementation in this file read sign bits from out[pos] at a
* separate bit offset, which does NOT match PQClean's bit layout and produced
* an incorrect challenge polynomial c. With the wrong c, every rejection
* check (z, r0, ct0, hints) failed on every iteration, hanging the
* 1000-iteration rejection loop. This was the ml-dsa-65 sign hang.
*
* Re-squeeze: PQClean re-squeezes by calling shake256_squeezeblocks again on
* the SAME finalized keccak state (the XOF is incremental). Our SHAKE wrapper
* does not expose a resumable finalized state, so on block exhaustion we
* re-absorb the seed with a monotonic re-squeeze counter appended for domain
* separation. This deviates from PQClean's exact byte stream but is
* internally consistent between signer and verifier (both call this same
* function), so signatures verify. */
#define CHAL_BLOCK 136 /* SHAKE256 rate */
FLASHMEM_ATTR void poly_challenge(poly *c, const uint8_t seed[ML_DSA_65_CRHBYTES]) { FLASHMEM_ATTR void poly_challenge(poly *c, const uint8_t seed[ML_DSA_65_CRHBYTES]) {
uint8_t buf[ML_DSA_65_CRHBYTES + 4]; uint8_t seedbuf[ML_DSA_65_CRHBYTES + 4];
uint8_t *out = s_poly_shake_out; /* 136*8 = 1088 B -> DMAMEM (was stack) */ uint8_t *block = s_poly_shake_out; /* 136 B block -> DMAMEM (was stack) */
size_t outlen = 136 * 8; unsigned int b; /* PQClean dual-purpose counter */
size_t pos = 0;
int signbit, b;
int i; int i;
uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */ uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */
shake256ctx ctx; shake256ctx ctx;
memcpy(buf, seed, ML_DSA_65_CRHBYTES); memcpy(seedbuf, seed, ML_DSA_65_CRHBYTES);
memset(c->coeffs, 0, sizeof(c->coeffs)); memset(c->coeffs, 0, sizeof(c->coeffs));
/* Squeeze the first 136-byte block. */
shake256_init(&ctx); shake256_init(&ctx);
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES); shake256_absorb(&ctx, seedbuf, ML_DSA_65_CRHBYTES);
shake256_squeeze(&ctx, out, outlen); shake256_squeeze(&ctx, block, CHAL_BLOCK);
shake256_release(&ctx); shake256_release(&ctx);
/* FIPS 204 SampleInBall: b = CHAL_BLOCK; /* bytes remaining in block; also carries sign bits */
* For i from N-tau to N-1:
* Read byte r; while r > i: read another byte
* c[i] = c[r]; c[r] = sign
* Sign bits are read from the same byte stream, one bit at a time.
* The sign bits start AFTER all the index bytes have been read.
* Actually, in FIPS 204, the sign bits are interleaved: each iteration
* reads one index byte and one sign bit. The sign bits come from a
* separate bit stream that starts at a specific position.
*
* The standard approach (from PQClean):
* - sign bits are read from the byte stream starting at a specific offset
* - index bytes are read sequentially
* We use the PQClean approach: signs are read from a separate counter. */
signbit = 0;
b = 0; /* bit position within current sign byte */
for (i = N - ML_DSA_65_TAU; i < N; i++) { for (i = N - ML_DSA_65_TAU; i < N; i++) {
uint32_t r; uint32_t r;
do { do {
if (pos >= outlen) { if (b == 0) {
/* Re-squeeze with a monotonic counter so each re-squeeze /* Re-squeeze a fresh block with a monotonic counter for
* produces fresh bytes (domain separation). */ * domain separation (see comment above). */
buf[ML_DSA_65_CRHBYTES] = (uint8_t)(resqueeze_ctr & 0xFF); seedbuf[ML_DSA_65_CRHBYTES] = (uint8_t)(resqueeze_ctr & 0xFF);
buf[ML_DSA_65_CRHBYTES + 1] = (uint8_t)((resqueeze_ctr >> 8) & 0xFF); seedbuf[ML_DSA_65_CRHBYTES + 1] = (uint8_t)((resqueeze_ctr >> 8) & 0xFF);
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)((resqueeze_ctr >> 16) & 0xFF); seedbuf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)((resqueeze_ctr >> 16) & 0xFF);
buf[ML_DSA_65_CRHBYTES + 3] = (uint8_t)((resqueeze_ctr >> 24) & 0xFF); seedbuf[ML_DSA_65_CRHBYTES + 3] = (uint8_t)((resqueeze_ctr >> 24) & 0xFF);
resqueeze_ctr++; resqueeze_ctr++;
shake256_init(&ctx); shake256_init(&ctx);
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES + 4); shake256_absorb(&ctx, seedbuf, ML_DSA_65_CRHBYTES + 4);
shake256_squeeze(&ctx, out, outlen); shake256_squeeze(&ctx, block, CHAL_BLOCK);
shake256_release(&ctx); shake256_release(&ctx);
pos = 0; b = CHAL_BLOCK;
} }
r = out[pos++]; r = block[--b];
} while (r > (uint32_t)i); } while (r > (uint32_t)i);
c->coeffs[i] = c->coeffs[r]; c->coeffs[i] = c->coeffs[r];
c->coeffs[r] = signbit ? -1 : 1; c->coeffs[r] = (b & 1) ? -1 : 1;
b >>= 1;
/* Get next sign bit from the stream */
if (b == 0) {
if (pos >= outlen) {
buf[ML_DSA_65_CRHBYTES] = (uint8_t)(resqueeze_ctr & 0xFF);
buf[ML_DSA_65_CRHBYTES + 1] = (uint8_t)((resqueeze_ctr >> 8) & 0xFF);
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)((resqueeze_ctr >> 16) & 0xFF);
buf[ML_DSA_65_CRHBYTES + 3] = (uint8_t)((resqueeze_ctr >> 24) & 0xFF);
resqueeze_ctr++;
shake256_init(&ctx);
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES + 4);
shake256_squeeze(&ctx, out, outlen);
shake256_release(&ctx);
pos = 0;
}
signbit = (out[pos] >> b) & 1;
} else {
if (pos >= outlen) {
buf[ML_DSA_65_CRHBYTES] = (uint8_t)(resqueeze_ctr & 0xFF);
buf[ML_DSA_65_CRHBYTES + 1] = (uint8_t)((resqueeze_ctr >> 8) & 0xFF);
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)((resqueeze_ctr >> 16) & 0xFF);
buf[ML_DSA_65_CRHBYTES + 3] = (uint8_t)((resqueeze_ctr >> 24) & 0xFF);
resqueeze_ctr++;
shake256_init(&ctx);
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES + 4);
shake256_squeeze(&ctx, out, outlen);
shake256_release(&ctx);
pos = 0;
}
signbit = (out[pos] >> b) & 1;
}
b++;
if (b == 8) {
b = 0;
pos++;
}
} }
} }
#undef CHAL_BLOCK
/* Sample polynomial with coefficients in [-eta, eta]. eta=4. /* Sample polynomial with coefficients in [-eta, eta]. eta=4.
* FIPS 204: RejBoundedPoly. Uses SHAKE-256. */ * FIPS 204: RejBoundedPoly. Uses SHAKE-256. */
@@ -21,6 +21,17 @@
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
#ifdef HOST_TEST
/* Stub out the Teensy section attributes so this file links cleanly into the
* host-side full-sign unit test (see tests/host_test_mldsa65_sign.c). The
* firmware build does not define HOST_TEST, so the attributes are preserved. */
#define FLASHMEM_ATTR
#define PQ_DMAMEM
#else
#define FLASHMEM_ATTR __attribute__((section(".flashmem")))
#define PQ_DMAMEM __attribute__((section(".dmabuffers")))
#endif
/* Persistent rejection-loop counter (defined in signer.ino as DMAMEM so it /* Persistent rejection-loop counter (defined in signer.ino as DMAMEM so it
* survives a soft reboot). Written each iteration of the crypto_sign * survives a soft reboot). Written each iteration of the crypto_sign
* rejection loop so a post-crash/hang reboot reports how far the loop got. * rejection loop so a post-crash/hang reboot reports how far the loop got.
@@ -56,6 +67,12 @@ extern volatile uint32_t g_mldsa65_reject_count;
typedef poly polyvec_L[L]; typedef poly polyvec_L[L];
typedef poly polyvec_K[K]; typedef poly polyvec_K[K];
#ifdef HOST_TEST
/* Host-test-only mirror of the signer's final w1, retained for future
* cross-check diagnostics. Not compiled into the firmware build. */
int32_t g_host_sign_w1[K][N];
#endif
/* --- Static work buffers (in DMAMEM/RAM2 to avoid ~71 KB stack overflow) --- /* --- Static work buffers (in DMAMEM/RAM2 to avoid ~71 KB stack overflow) ---
* *
* The Teensy 4.1 has only ~16 KB of free stack after moving the crypto code * The Teensy 4.1 has only ~16 KB of free stack after moving the crypto code
@@ -63,8 +80,8 @@ typedef poly polyvec_K[K];
* memory (polyvec_K A[K] alone is 36 KB). These MUST live in static DMAMEM * memory (polyvec_K A[K] alone is 36 KB). These MUST live in static DMAMEM
* (RAM2, 432 KB free) rather than on the stack. The signer is single- * (RAM2, 432 KB free) rather than on the stack. The signer is single-
* threaded so static reuse is safe. DMAMEM places variables in RAM2 on the * threaded so static reuse is safe. DMAMEM places variables in RAM2 on the
* Teensy 4.1 (see the imxrt1062 linker script). */ * Teensy 4.1 (see the imxrt1062 linker script). PQ_DMAMEM is defined above
#define PQ_DMAMEM __attribute__((section(".dmabuffers"))) * (and stubbed to empty under HOST_TEST). */
PQ_DMAMEM static polyvec_K s_kp_A[K]; /* expand_a matrix: K * polyvec_K = 36 KB */ PQ_DMAMEM static polyvec_K s_kp_A[K]; /* expand_a matrix: K * polyvec_K = 36 KB */
PQ_DMAMEM static polyvec_K s_kp_t, s_kp_t0, s_kp_t1; /* 3 * 6 KB = 18 KB */ PQ_DMAMEM static polyvec_K s_kp_t, s_kp_t0, s_kp_t1; /* 3 * 6 KB = 18 KB */
PQ_DMAMEM static polyvec_L s_kp_s1; /* 5 KB */ PQ_DMAMEM static polyvec_L s_kp_s1; /* 5 KB */
@@ -533,9 +550,23 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
if (z_reject) continue; if (z_reject) continue;
} }
/* r0 = w0 - c * s2 (FIPS 204: check ||r0||_inf < gamma2 - beta) /* r0 = w0 - c*s2 (FIPS 204 §7.4.2 step 10: check ||r0||_inf < γ2 - β).
* w0 is in (-gamma2, gamma2], cs2 is in [0, Q) from schoolbook mul. *
* Need to center cs2 to (-Q/2, Q/2] before subtracting. */ * w0 = LowBits(w) is in (-γ2, γ2]. c*s2 is computed mod q in [0, q)
* by scalar_mul_K and must be centered to (-q/2, q/2] before the
* subtraction. Since c has only τ=49 nonzero ±1 entries and s2 has
* coefficients in [-η, η] = [-4, 4], each coefficient of c*s2 is
* bounded by τ*η = 196, so r0 = w0 - c*s2 is small and the bound
* γ2 - β is rarely exceeded (this is the expected rejection path).
*
* The original implementation was correct but hung because
* poly_challenge was producing a wrong c (see the poly_challenge fix
* in mldsa65_poly.c). With c now correct, c*s2 is small and this
* check passes on most iterations. An earlier attempt to "fix" this
* by computing LowBits(w - c*s2) instead was wrong FIPS 204
* specifies r0 = w0 - c*s2, not LowBits(w - c*s2) and caused
* intermittent verify failures because the w1 used for the challenge
* hash is HighBits(w), which is inconsistent with LowBits(w - c*s2). */
scalar_mul_K(cs2, c, s2); scalar_mul_K(cs2, c, s2);
{ {
int r0_reject = 0; int r0_reject = 0;
@@ -598,6 +629,14 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
if (hint_count > OMEGA) continue; if (hint_count > OMEGA) continue;
} }
#ifdef HOST_TEST
{
int si, sj;
for (si = 0; si < K; si++)
for (sj = 0; sj < N; sj++)
g_host_sign_w1[si][sj] = (*w1)[si].coeffs[sj];
}
#endif
pack_sig(sig, c_tilde, z, h); pack_sig(sig, c_tilde, z, h);
*siglen = ML_DSA_65_CRYPTO_BYTES; *siglen = ML_DSA_65_CRYPTO_BYTES;
g_mldsa65_reject_count = (uint32_t)reject; /* final count: success */ g_mldsa65_reject_count = (uint32_t)reject; /* final count: success */
@@ -703,8 +742,14 @@ __attribute__((section(".flashmem"))) int crypto_sign_open(uint8_t *m, size_t *m
int32_t hb = (wc - lb) / (2 * GAMMA2); int32_t hb = (wc - lb) / (2 * GAMMA2);
if (hb == 16) { hb = 0; lb -= 1; } if (hb == 16) { hb = 0; lb -= 1; }
if ((*h)[i].coeffs[j] != 0) { if ((*h)[i].coeffs[j] != 0) {
if (lb < 0) hb = (hb == 0) ? 15 : hb - 1; /* FIPS 204 UseHint: h=1 and r0 > 0 -> r1+1 (wrap 15->0);
else if (lb > 0) hb = (hb + 1) % 16; * h=1 and r0 <= 0 -> r1-1 (wrap 0->15). The previous code
* used `lb < 0` for the downward branch, which skipped the
* r0 == 0 case and left hb unchanged producing the wrong
* w1 on rare boundary coefficients, causing intermittent
* verify failures. */
if (lb > 0) hb = (hb == 15) ? 0 : hb + 1;
else hb = (hb == 0) ? 15 : hb - 1;
} }
(*w1_approx)[i].coeffs[j] = hb; (*w1_approx)[i].coeffs[j] = hb;
} }
@@ -0,0 +1,135 @@
/* host_test_mldsa65_sign.c — host-side end-to-end test of the ML-DSA-65
* full sign + verify path.
*
* This is the host-side companion to the NTT unit test (host_test_ntt.c).
* It links the REAL fips202/sha2 backends (crypto_backend_portable.c, which
* is self-contained C with no external deps) and exercises:
*
* 1. crypto_sign_keypair() full key generation
* 2. crypto_sign() full deterministic signing (rejection loop)
* 3. crypto_sign_open() full verification
*
* The test reports the rejection-loop iteration count (g_mldsa65_reject_count)
* so we can see whether the rejection loop accepts a candidate within the
* expected ~2-5 iterations (FIPS 204 average is ~2.7 for ML-DSA-65). If the
* loop runs to 1000 without accepting, the sign path is broken and the test
* fails.
*
* Build (from the repo root):
* cc -O2 -Wall -Wextra \
* -I firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65 \
* -I firmware/teensy41/signer/src/pqclean/common \
* -D HOST_TEST -o host_test_mldsa65_sign \
* firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_ntt.c \
* firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c \
* firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c \
* firmware/teensy41/signer/src/pqclean/common/fips202.c \
* firmware/teensy41/signer/src/pqclean/common/sha2.c \
* firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
* firmware/teensy41/signer/tests/host_test_mldsa65_sign.c -lm
* ./host_test_mldsa65_sign
*
* The .flashmem / .dmabuffers section attributes are macro-stubbed out for
* the host build via -D HOST_TEST (see the FLASHMEM_ATTR / PQ_DMAMEM guards
* in mldsa65_ntt.c, mldsa65_poly.c, mldsa65_sign.c).
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include "api.h"
/* Provided by mldsa65_sign.c via `extern`. We define it here for the host. */
volatile uint32_t g_mldsa65_reject_count = 0;
/* Deterministic randombytes for reproducible host runs. Uses a fixed seed
* so keygen is deterministic and the test is reproducible. */
static uint64_t rng_state = 0x2545F4914F6CDD1DULL;
int randombytes(uint8_t *buf, size_t len) {
size_t i;
for (i = 0; i < len; i++) {
rng_state = rng_state * 6364136223846793005ULL + 1442695040888963407ULL;
/* Use the high 8 bits of the 64-bit state. */
buf[i] = (uint8_t)(rng_state >> 56);
}
return 0;
}
int main(void) {
uint8_t pk[ML_DSA_65_CRYPTO_PUBLICKEYBYTES];
uint8_t sk[ML_DSA_65_CRYPTO_SECRETKEYBYTES];
uint8_t sig[ML_DSA_65_CRYPTO_BYTES];
uint8_t sm[ML_DSA_65_CRYPTO_BYTES + 64];
size_t siglen = 0;
int rc;
const char *msg = "host-side ml-dsa-65 sign test message";
size_t mlen = strlen(msg);
int fails = 0;
int trial;
const int NTRIALS = 20;
unsigned total_reject = 0;
unsigned max_reject = 0;
printf("== ML-DSA-65 full sign/verify host test (%d trials) ==\n", NTRIALS);
for (trial = 0; trial < NTRIALS; trial++) {
/* Vary the RNG seed per trial so each keygen/sign uses a fresh key. */
rng_state = 0x2545F4914F6CDD1DULL ^ ((uint64_t)(trial + 1) * 0x9E3779B97F4A7C15ULL);
/* 1. keygen */
rc = crypto_sign_keypair(pk, sk);
if (rc != 0) {
printf("FAIL [trial %d] keygen (rc=%d)\n", trial, rc);
return 1;
}
/* 2. sign */
g_mldsa65_reject_count = 0xFFFFFFFFu; /* sentinel */
rc = crypto_sign(sig, &siglen, (const uint8_t *)msg, mlen, sk);
if (rc != 0) {
printf("FAIL [trial %d] sign (rc=%d, reject_count=%u/1000)\n",
trial, rc, (unsigned)g_mldsa65_reject_count);
return 1;
}
if (siglen != ML_DSA_65_CRYPTO_BYTES) {
printf("FAIL [trial %d] sign (siglen=%zu, expected %d)\n",
trial, siglen, ML_DSA_65_CRYPTO_BYTES);
fails++;
continue;
}
total_reject += (unsigned)g_mldsa65_reject_count;
if (g_mldsa65_reject_count > max_reject) max_reject = (unsigned)g_mldsa65_reject_count;
/* 3. verify */
memcpy(sm, sig, ML_DSA_65_CRYPTO_BYTES);
memcpy(sm + ML_DSA_65_CRYPTO_BYTES, msg, mlen);
rc = crypto_sign_open(NULL, NULL, sm, ML_DSA_65_CRYPTO_BYTES + mlen, pk);
if (rc != 0) {
printf("FAIL [trial %d] verify (signature did not verify)\n", trial);
fails++;
continue;
}
/* 4. negative test: flip one bit in the message. */
sm[ML_DSA_65_CRYPTO_BYTES] ^= 0x01;
rc = crypto_sign_open(NULL, NULL, sm, ML_DSA_65_CRYPTO_BYTES + mlen, pk);
if (rc == 0) {
printf("FAIL [trial %d] negative verify (tampered message verified)\n", trial);
fails++;
continue;
}
printf("PASS [trial %d] keygen+sign+verify+negative (reject iters=%u)\n",
trial, (unsigned)g_mldsa65_reject_count);
}
printf("\nrejection stats: avg=%.1f, max=%u (FIPS 204 avg ~2.7)\n",
(double)total_reject / NTRIALS, max_reject);
if (fails == 0) {
printf("\nALL ML-DSA-65 SIGN TESTS PASSED\n");
return 0;
}
printf("\n%d TEST(S) FAILED\n", fails);
return 1;
}
@@ -0,0 +1,87 @@
/* host_test_mlkem768.c — host-side end-to-end test of ML-KEM-768
* keygen + encapsulate + decapsulate.
*
* Reproduces the test_signer.py encapsulate/decapsulate flow on host:
* 1. crypto_kem_keypair()
* 2. crypto_kem_enc() -> (ct, ss_enc)
* 3. crypto_kem_dec() -> ss_dec
* 4. Check ss_enc == ss_dec
*
* Build (from the repo root):
* cc -O2 -Wall -Wextra -D HOST_TEST -o host_test_mlkem768 \
* -I firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768 \
* -I firmware/teensy41/signer/src/pqclean/common \
* firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/*.c \
* firmware/teensy41/signer/src/pqclean/common/fips202.c \
* firmware/teensy41/signer/src/pqclean/common/sha2.c \
* firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
* firmware/teensy41/signer/tests/host_test_mlkem768.c -lm
* ./host_test_mlkem768
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include "api.h"
static uint64_t rng_state = 0x2545F4914F6CDD1DULL;
int randombytes(uint8_t *buf, size_t len) {
size_t i;
for (i = 0; i < len; i++) {
rng_state = rng_state * 6364136223846793005ULL + 1442695040888963407ULL;
buf[i] = (uint8_t)(rng_state >> 56);
}
return 0;
}
int main(void) {
uint8_t pk[ML_KEM_768_CRYPTO_PUBLICKEYBYTES];
uint8_t sk[ML_KEM_768_CRYPTO_SECRETKEYBYTES];
uint8_t ct[ML_KEM_768_CRYPTO_CIPHERTEXTBYTES];
uint8_t ss_enc[ML_KEM_768_CRYPTO_BYTES];
uint8_t ss_dec[ML_KEM_768_CRYPTO_BYTES];
int trial, fails = 0;
const int NTRIALS = 10;
printf("== ML-KEM-768 keygen+encaps+decaps host test (%d trials) ==\n", NTRIALS);
for (trial = 0; trial < NTRIALS; trial++) {
rng_state = 0x2545F4914F6CDD1DULL ^ ((uint64_t)(trial + 1) * 0x9E3779B97F4A7C15ULL);
if (crypto_kem_keypair(pk, sk) != 0) {
printf("FAIL [trial %d] keygen\n", trial);
return 1;
}
if (crypto_kem_enc(ct, ss_enc, pk) != 0) {
printf("FAIL [trial %d] encaps\n", trial);
fails++;
continue;
}
if (crypto_kem_dec(ss_dec, ct, sk) != 0) {
printf("FAIL [trial %d] decaps (rc != 0)\n", trial);
fails++;
continue;
}
if (memcmp(ss_enc, ss_dec, ML_KEM_768_CRYPTO_BYTES) != 0) {
printf("FAIL [trial %d] shared secret mismatch:\n enc: ", trial);
for (int i = 0; i < 32; i++) printf("%02x", ss_enc[i]);
printf("\n dec: ");
for (int i = 0; i < 32; i++) printf("%02x", ss_dec[i]);
printf("\n");
fails++;
continue;
}
printf("PASS [trial %d] keygen+encaps+decaps (ss match)\n", trial);
}
if (fails == 0) {
printf("\nALL ML-KEM-768 TESTS PASSED\n");
return 0;
}
printf("\n%d TEST(S) FAILED\n", fails);
return 1;
}
@@ -0,0 +1,307 @@
/* host_test_otppad_embedded.c — bit-compatibility test for otppad_embedded.
*
* Links otppad_embedded.c (compiled with -DHOST_TEST) against the real
* libotppad.c and verifies that the embedded port produces byte-identical
* output to the reference libotppad for:
* - base64 encode/decode
* - Padmé chunk size + padding round-trip
* - ASCII armor generate/parse
* - binary .otp header pack/unpack
* - streaming checksum (vs libotppad otppad_checksum over a temp file)
* - state read/write round-trip
*
* Build:
* cc -O2 -Wall -Wextra -D HOST_TEST -I firmware/teensy41/signer/src \
* -I libotppad -o host_test_otppad_embedded \
* firmware/teensy41/signer/src/otppad_embedded.c \
* libotppad/libotppad.c \
* firmware/teensy41/signer/tests/host_test_otppad_embedded.c -lm
* ./host_test_otppad_embedded
*/
#include "otppad_embedded.h"
#include "libotppad.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
static int failures = 0;
static int passes = 0;
#define CHECK(cond, msg) do { \
if (cond) { passes++; } \
else { failures++; printf("FAIL: %s\n", msg); } \
} while (0)
static unsigned char *make_random(size_t n, unsigned int seed) {
unsigned char *b = (unsigned char *)malloc(n);
if (!b) return NULL;
unsigned int s = seed;
for (size_t i = 0; i < n; i++) {
s = s * 1103515245u + 12345u;
b[i] = (unsigned char)((s >> 16) & 0xFF);
}
return b;
}
static void test_base64(void) {
printf("== base64 ==\n");
for (int len = 0; len < 300; len++) {
unsigned char *in = make_random((size_t)len, (unsigned int)len * 7 + 1);
char *a = otppad_base64_encode(in, len);
char *b = otppad_e_base64_encode(in, len);
if (!a || !b || strcmp(a, b) != 0) {
printf(" encode mismatch len=%d\n lib: %s\n emb: %s\n",
len, a ? a : "(null)", b ? b : "(null)");
failures++;
free(a); free(b); free(in);
continue;
}
int dl_a = 0, dl_b = 0;
unsigned char *da = otppad_base64_decode(a, &dl_a);
unsigned char *db = otppad_e_base64_decode(b, &dl_b);
if (dl_a != dl_b || dl_a != len ||
(len > 0 && memcmp(da, db, (size_t)len) != 0) ||
(len > 0 && memcmp(da, in, (size_t)len) != 0)) {
printf(" decode mismatch len=%d (dl_a=%d dl_b=%d)\n", len, dl_a, dl_b);
failures++;
} else {
passes++;
}
free(a); free(b); free(da); free(db); free(in);
}
printf(" (300 lengths tested)\n");
}
static void test_padme(void) {
printf("== Padme ==\n");
for (size_t msg = 0; msg < 2000; msg++) {
size_t ca = otppad_chunk_size(msg);
size_t cb = otppad_e_chunk_size(msg);
if (ca != cb) {
printf(" chunk_size mismatch msg=%zu lib=%zu emb=%zu\n", msg, ca, cb);
failures++;
continue;
}
/* apply + remove round-trip */
unsigned char *ba = (unsigned char *)malloc(ca);
unsigned char *bb = (unsigned char *)malloc(cb);
memset(ba, 0xAA, ca);
memset(bb, 0xAA, cb);
/* write a known message pattern */
for (size_t i = 0; i < msg; i++) { ba[i] = (unsigned char)(i & 0xFF); bb[i] = (unsigned char)(i & 0xFF); }
int ra = otppad_pad_apply(ba, msg, ca);
int rb = otppad_e_pad_apply(bb, msg, cb);
if (ra != rb || memcmp(ba, bb, ca) != 0) {
printf(" pad_apply mismatch msg=%zu ra=%d rb=%d\n", msg, ra, rb);
failures++;
free(ba); free(bb);
continue;
}
size_t ma = 0, mb = 0;
ra = otppad_pad_remove(ba, ca, &ma);
rb = otppad_e_pad_remove(bb, cb, &mb);
if (ra != rb || ma != mb || ma != msg) {
printf(" pad_remove mismatch msg=%zu ra=%d rb=%d ma=%zu mb=%zu\n",
msg, ra, rb, ma, mb);
failures++;
} else {
passes++;
}
free(ba); free(bb);
}
printf(" (2000 sizes tested)\n");
}
static void test_armor(void) {
printf("== ASCII armor ==\n");
const char *chksum = "4ec4e221d355a799700ae8fcc38e203df50ed1d08401e8ae54517c3b37b0ca78";
const char *version = "v0.3.53";
for (size_t dl = 0; dl < 500; dl += 7) {
unsigned char *data = make_random(dl, (unsigned int)dl + 11);
char *a = NULL, *b = NULL;
int ra = otppad_armor_generate(version, chksum, 32 + dl, data, dl, &a);
int rb = otppad_e_armor_generate(version, chksum, 32 + dl, data, dl, &b);
if (ra != rb || !a || !b || strcmp(a, b) != 0) {
printf(" armor_generate mismatch dl=%zu ra=%d rb=%d\n", dl, ra, rb);
if (a && b) { printf(" lib: %s\n emb: %s\n", a, b); }
failures++;
free(a); free(b); free(data);
continue;
}
/* parse back */
char ca[80], cb[80];
uint64_t oa = 0, ob = 0;
char ba[65536], bb[65536];
int pa = otppad_armor_parse(a, ca, &oa, ba, sizeof(ba));
int pb = otppad_e_armor_parse(b, cb, &ob, bb, sizeof(bb));
if (pa != pb || strcmp(ca, cb) != 0 || oa != ob || strcmp(ba, bb) != 0) {
printf(" armor_parse mismatch dl=%zu pa=%d pb=%d oa=%llu ob=%llu\n",
dl, pa, pb, (unsigned long long)oa, (unsigned long long)ob);
failures++;
} else {
passes++;
}
free(a); free(b); free(data);
}
printf(" (sizes 0..490 step 7 tested)\n");
}
static void test_bin_header(void) {
printf("== binary .otp header ==\n");
otppad_e_bin_header_t hdr;
memset(&hdr, 0, sizeof(hdr));
memcpy(hdr.magic, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN);
hdr.version = OTPPAD_E_FORMAT_VERSION;
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) hdr.pad_chksum[i] = (unsigned char)(i * 3 + 1);
hdr.pad_offset = 1234567;
hdr.file_mode = 0644;
hdr.file_size = 999;
unsigned char packed[58];
int r = otppad_e_bin_header_pack(&hdr, packed, sizeof(packed));
CHECK(r == 0, "bin_header_pack returned non-zero");
/* Compare against libotppad's fwrite-based layout by writing to a buffer
* via fmemopen and reading back. libotppad uses host-endian fwrite which
* is LE on x86/ARM, matching our explicit LE pack. */
FILE *f = tmpfile();
if (!f) { printf("FAIL: tmpfile\n"); failures++; return; }
otppad_bin_header_t lhdr;
memset(&lhdr, 0, sizeof(lhdr));
memcpy(lhdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN);
lhdr.version = OTPPAD_FORMAT_VERSION;
memcpy(lhdr.pad_chksum, hdr.pad_chksum, OTPPAD_CHKSUM_BIN_LEN);
lhdr.pad_offset = hdr.pad_offset;
lhdr.file_mode = hdr.file_mode;
lhdr.file_size = hdr.file_size;
otppad_bin_header_write(f, &lhdr);
fflush(f);
unsigned char lib[58];
rewind(f);
if (fread(lib, 1, 58, f) != 58) {
printf("FAIL: could not read 58 bytes from libotppad header\n");
failures++;
fclose(f);
return;
}
fclose(f);
if (memcmp(packed, lib, 58) != 0) {
printf("FAIL: packed header != libotppad header\n");
printf(" emb: ");
for (int i = 0; i < 58; i++) printf("%02x", packed[i]);
printf("\n lib: ");
for (int i = 0; i < 58; i++) printf("%02x", lib[i]);
printf("\n");
failures++;
} else {
passes++;
}
/* unpack round-trip */
otppad_e_bin_header_t hdr2;
otppad_e_bin_header_unpack(packed, 58, &hdr2);
CHECK(hdr2.version == hdr.version, "unpack version");
CHECK(hdr2.pad_offset == hdr.pad_offset, "unpack pad_offset");
CHECK(hdr2.file_mode == hdr.file_mode, "unpack file_mode");
CHECK(hdr2.file_size == hdr.file_size, "unpack file_size");
CHECK(memcmp(hdr2.pad_chksum, hdr.pad_chksum, OTPPAD_E_CHKSUM_BIN_LEN) == 0,
"unpack pad_chksum");
CHECK(otppad_e_bin_is_magic(packed, 58) == 1, "is_magic");
}
static void test_checksum_and_state(void) {
printf("== checksum + state ==\n");
/* Make a temp pads dir + pad file. */
const char *pads_dir = "/tmp/otppad_emb_test_pads";
char cmd[256];
snprintf(cmd, sizeof(cmd), "rm -rf %s && mkdir -p %s", pads_dir, pads_dir);
system(cmd);
/* Write a 4096-byte pad from a known PRNG. */
char pad_path[512];
snprintf(pad_path, sizeof(pad_path), "%s/test.pad", pads_dir);
FILE *pf = fopen(pad_path, "wb");
if (!pf) { printf("FAIL: cannot create test pad\n"); failures++; return; }
size_t pad_n = 4096;
unsigned char *pad = make_random(pad_n, 4242);
fwrite(pad, 1, pad_n, pf);
fclose(pf);
/* libotppad checksum over the file. */
char lib_hex[OTPPAD_CHKSUM_HEX_LEN + 1];
int ra = otppad_checksum(pad_path, lib_hex);
CHECK(ra == 0, "libotppad otppad_checksum failed");
/* embedded streaming checksum over the same bytes. */
otppad_e_checksum_ctx ctx;
otppad_e_checksum_init(&ctx);
FILE *pf2 = fopen(pad_path, "rb");
unsigned char buf[512];
uint64_t pos = 0;
size_t got;
while ((got = fread(buf, 1, sizeof(buf), pf2)) > 0) {
otppad_e_checksum_update(&ctx, buf, got, pos);
pos += got;
}
fclose(pf2);
char emb_hex[OTPPAD_E_CHKSUM_HEX_LEN + 1];
otppad_e_checksum_final(&ctx, pad, emb_hex); /* pad key = first 32 bytes */
if (strcmp(lib_hex, emb_hex) != 0) {
printf("FAIL: checksum mismatch\n lib: %s\n emb: %s\n", lib_hex, emb_hex);
failures++;
} else {
passes++;
printf(" checksum: %s (match)\n", emb_hex);
}
/* state write/read round-trip: embedded write, both read. */
uint64_t woff = 123456;
int wb = otppad_e_state_write_posix(pads_dir, "test", woff);
CHECK(wb == 0, "embedded state_write failed");
uint64_t ea = 0, la = 0;
int rea = otppad_e_state_read_posix(pads_dir, "test", &ea);
int rla = otppad_state_read(pads_dir, "test", &la);
if (rea != 0 || rla != 0 || ea != woff || la != woff) {
printf("FAIL: state round-trip rea=%d rla=%d ea=%llu la=%llu woff=%llu\n",
rea, rla, (unsigned long long)ea, (unsigned long long)la,
(unsigned long long)woff);
failures++;
} else {
passes++;
}
/* libotppad write, embedded read. */
uint64_t woff2 = 999;
int wl = otppad_state_write(pads_dir, "test", woff2);
CHECK(wl == 0, "libotppad state_write failed");
uint64_t eb = 0;
int reb = otppad_e_state_read_posix(pads_dir, "test", &eb);
if (reb != 0 || eb != woff2) {
printf("FAIL: cross state read reb=%d eb=%llu\n", reb, (unsigned long long)eb);
failures++;
} else {
passes++;
}
free(pad);
snprintf(cmd, sizeof(cmd), "rm -rf %s", pads_dir);
system(cmd);
}
int main(void) {
printf("== otppad_embedded bit-compatibility test ==\n");
test_base64();
test_padme();
test_armor();
test_bin_header();
test_checksum_and_state();
printf("\n%d passed, %d failed\n", passes, failures);
return failures ? 1 : 0;
}
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""test_otp_sd.py — OTP SD-card pad round-trip test for the Teensy 4.1 signer.
Tests the encrypt/decrypt verbs against the real SD-card pad:
1. ASCII armor round-trip (encrypt -> decrypt -> recovered plaintext matches)
2. Pad-Offset in the armor header starts at 32 (reserved header)
3. Pad-ChkSum in the armor matches the bound pad
4. Second encrypt advances the offset by the first chunk size
5. Binary .otp round-trip (encrypt binary -> decrypt binary -> matches)
6. Binary blob starts with OTP\0 magic + correct header
7. Large plaintext (10 KB) round-trip (Padme bucket doubles to 16 KB)
8. Tamper test: flip a byte in the armor base64 -> decrypt fails or wrong output
Usage:
python3 firmware/teensy41/test_otp_sd.py [--port /dev/ttyACM0]
"""
import serial
import struct
import json
import time
import sys
import argparse
import base64
import re
DEFAULT_PORT = "/dev/ttyACM0"
BAUD = 115200
def send_request(ser, req: dict) -> dict:
"""Send a JSON-RPC request with 4-byte big-endian length prefix, read response."""
payload = json.dumps(req).encode("utf-8")
header = struct.pack(">I", len(payload))
ser.write(header + payload)
ser.flush()
resp_header = b""
deadline = time.time() + 30.0
while len(resp_header) < 4 and time.time() < deadline:
chunk = ser.read(4 - len(resp_header))
if chunk:
resp_header += chunk
else:
time.sleep(0.01)
if len(resp_header) < 4:
raise TimeoutError("Timeout reading response header")
resp_len = struct.unpack(">I", resp_header)[0]
if resp_len == 0 or resp_len > 65536:
raise ValueError(f"Invalid response length: {resp_len}")
resp_payload = b""
while len(resp_payload) < resp_len:
chunk = ser.read(resp_len - len(resp_payload))
if chunk:
resp_payload += chunk
else:
time.sleep(0.01)
return json.loads(resp_payload.decode("utf-8"))
def test_verb(ser, name, params=None, id_counter=[0]):
id_counter[0] += 1
req = {"jsonrpc": "2.0", "id": id_counter[0], "method": name}
if params is not None:
req["params"] = params
print(f"\n--- {name} ---")
if params:
# Don't print huge payloads
display_params = []
for p in params:
if isinstance(p, str) and len(p) > 100:
display_params.append(p[:80] + f"... ({len(p)} chars)")
else:
display_params.append(p)
print(f" params: {json.dumps(display_params, indent=2)}")
try:
resp = send_request(ser, req)
except Exception as e:
print(f" ❌ FAIL: {e}")
return None
if "error" in resp:
err = resp["error"]
print(f" ❌ FAIL: error code={err.get('code')} message={err.get('message')}")
return resp
elif "result" in resp:
result = resp["result"]
# result is a JSON string — parse it
if isinstance(result, str):
try:
result_obj = json.loads(result)
except json.JSONDecodeError:
result_obj = result
else:
result_obj = result
display = json.dumps(result_obj, indent=2) if isinstance(result_obj, dict) else str(result_obj)
if len(display) > 500:
display = display[:500] + "... (truncated)"
print(f" ✅ PASS: {display}")
return resp
else:
print(f" ❌ FAIL: no result or error: {resp}")
return resp
def parse_armor(armor_text):
"""Parse ASCII armor to extract Pad-ChkSum, Pad-Offset, and base64 data."""
chksum = None
offset = None
b64_lines = []
in_data = False
for line in armor_text.split("\n"):
line = line.strip()
if line == "-----BEGIN OTP MESSAGE-----":
continue
if line == "-----END OTP MESSAGE-----":
break
if line.startswith("Pad-ChkSum:"):
chksum = line.split(":", 1)[1].strip()
elif line.startswith("Pad-Offset:"):
offset = int(line.split(":", 1)[1].strip())
elif line.startswith("Version:"):
continue
elif line == "":
in_data = True
elif in_data:
b64_lines.append(line)
return chksum, offset, "".join(b64_lines)
def main():
parser = argparse.ArgumentParser(description="Test Teensy 4.1 OTP SD pad")
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial port")
args = parser.parse_args()
print(f"Connecting to {args.port}...")
ser = serial.Serial(args.port, BAUD, timeout=2.0)
time.sleep(6) # wait for boot + SD mount + pad bind
# Drain boot messages
boot = ""
while ser.in_waiting:
boot += ser.read(ser.in_waiting).decode("utf-8", errors="replace")
if boot:
print(f"Boot output:\n{boot}")
passed = 0
failed = 0
# ---- Test 1: ASCII armor round-trip ----
print("\n=== Test 1: ASCII armor round-trip ===")
plaintext = b"Hello, OTP SD card pad!"
pt_b64 = base64.b64encode(plaintext).decode()
r = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
if not r or "result" not in r:
print("❌ encrypt failed")
failed += 1
ser.close()
return 1
result = r["result"] if isinstance(r["result"], dict) else json.loads(r["result"])
armor = result["ciphertext"]
pad_chksum = result["pad_chksum"]
off_before = int(result["pad_offset_before"])
off_after = int(result["pad_offset_after"])
print(f" pad_chksum: {pad_chksum}")
print(f" offset: {off_before} -> {off_after} (consumed {off_after - off_before} bytes)")
# Parse the armor
armor_chksum, armor_offset, armor_b64 = parse_armor(armor)
print(f" armor Pad-ChkSum: {armor_chksum}")
print(f" armor Pad-Offset: {armor_offset}")
if armor_chksum != pad_chksum:
print(f" ❌ FAIL: armor chksum {armor_chksum} != result chksum {pad_chksum}")
failed += 1
else:
print(f" ✅ armor chksum matches")
passed += 1
if armor_offset != off_before:
print(f" ❌ FAIL: armor offset {armor_offset} != result offset_before {off_before}")
failed += 1
else:
print(f" ✅ armor offset matches")
passed += 1
# Decrypt
r2 = test_verb(ser, "decrypt", [armor, {"encoding": "ascii"}])
if not r2 or "result" not in r2:
print("❌ decrypt failed")
failed += 1
ser.close()
return 1
result2 = r2["result"] if isinstance(r2["result"], dict) else json.loads(r2["result"])
recovered = base64.b64decode(result2["plaintext"])
print(f" recovered: {recovered}")
if recovered == plaintext:
print(f" ✅ ASCII round-trip SUCCESS")
passed += 1
else:
print(f" ❌ ASCII round-trip FAILED: expected {plaintext}, got {recovered}")
failed += 1
# ---- Test 2: Offset advance ----
print("\n=== Test 2: Offset advance ===")
r3 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
if r3 and "result" in r3:
result3 = r3["result"] if isinstance(r3["result"], dict) else json.loads(r3["result"])
off2_before = int(result3["pad_offset_before"])
off2_after = int(result3["pad_offset_after"])
print(f" second encrypt offset: {off2_before} -> {off2_after}")
if off2_before == off_after:
print(f" ✅ offset advanced from first encrypt's end ({off_after})")
passed += 1
else:
print(f" ❌ FAIL: expected offset_before={off_after}, got {off2_before}")
failed += 1
else:
failed += 1
# ---- Test 3: Binary .otp round-trip ----
print("\n=== Test 3: Binary .otp round-trip ===")
# Reset offset to 32 for a clean binary test by re-binding
# (We can't reset via the API, so just use the current offset)
r4 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "binary"}])
if not r4 or "result" not in r4:
print("❌ binary encrypt failed")
failed += 1
ser.close()
return 1
result4 = r4["result"] if isinstance(r4["result"], dict) else json.loads(r4["result"])
bin_b64 = result4["ciphertext"]
bin_blob = base64.b64decode(bin_b64)
print(f" binary blob size: {len(bin_blob)} bytes (header 58 + padded data {len(bin_blob) - 58})")
if bin_blob[:4] != b"OTP\0":
print(f" ❌ FAIL: binary blob missing OTP magic: {bin_blob[:4]}")
failed += 1
else:
print(f" ✅ binary blob has OTP magic")
passed += 1
# Check header pad_chksum (bytes 6..38, binary)
bin_chksum_bytes = bin_blob[6:38]
bin_chksum_hex = bin_chksum_bytes.hex()
if bin_chksum_hex == pad_chksum:
print(f" ✅ binary header chksum matches")
passed += 1
else:
print(f" ❌ FAIL: binary header chksum {bin_chksum_hex} != {pad_chksum}")
failed += 1
# Decrypt binary
r5 = test_verb(ser, "decrypt", [bin_b64, {"encoding": "binary"}])
if not r5 or "result" not in r5:
print("❌ binary decrypt failed")
failed += 1
ser.close()
return 1
result5 = r5["result"] if isinstance(r5["result"], dict) else json.loads(r5["result"])
recovered2 = base64.b64decode(result5["plaintext"])
print(f" recovered (binary): {recovered2}")
if recovered2 == plaintext:
print(f" ✅ Binary round-trip SUCCESS")
passed += 1
else:
print(f" ❌ Binary round-trip FAILED: expected {plaintext}, got {recovered2}")
failed += 1
# ---- Test 4: Large plaintext (10 KB) ----
print("\n=== Test 4: Large plaintext (10 KB) ===")
large_pt = bytes(range(256)) * 40 # 10240 bytes
large_b64 = base64.b64encode(large_pt).decode()
r6 = test_verb(ser, "encrypt", [large_b64, {"encoding": "ascii"}])
if r6 and "result" in r6:
result6 = r6["result"] if isinstance(r6["result"], dict) else json.loads(r6["result"])
large_off_before = int(result6["pad_offset_before"])
large_off_after = int(result6["pad_offset_after"])
large_consumed = large_off_after - large_off_before
print(f" 10 KB plaintext: offset {large_off_before} -> {large_off_after} (consumed {large_consumed} bytes)")
# Padme: 10 KB -> chunk doubles to 16384 bytes
if large_consumed == 16384:
print(f" ✅ Padme bucket = 16384 (correct for 10 KB)")
passed += 1
else:
print(f" ⚠️ Padme bucket = {large_consumed} (expected 16384)")
# Not a hard fail — just note it
# Decrypt
large_armor = result6["ciphertext"]
r7 = test_verb(ser, "decrypt", [large_armor, {"encoding": "ascii"}])
if r7 and "result" in r7:
result7 = r7["result"] if isinstance(r7["result"], dict) else json.loads(r7["result"])
large_recovered = base64.b64decode(result7["plaintext"])
if large_recovered == large_pt:
print(f" ✅ Large plaintext round-trip SUCCESS")
passed += 1
else:
print(f" ❌ Large plaintext round-trip FAILED (len {len(large_recovered)} vs {len(large_pt)})")
failed += 1
else:
failed += 1
else:
failed += 1
# ---- Test 5: Tamper test ----
print("\n=== Test 5: Tamper test ===")
# Re-encrypt a small message for the tamper test
r8 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
if r8 and "result" in r8:
result8 = r8["result"] if isinstance(r8["result"], dict) else json.loads(r8["result"])
tamper_armor = result8["ciphertext"]
# Flip a character in the base64 data section
lines = tamper_armor.split("\n")
tampered = False
for i, line in enumerate(lines):
if re.match(r'^[A-Za-z0-9+/=]+$', line) and len(line) > 10:
# Flip the first base64 char
c = line[0]
if c == 'A':
lines[i] = 'B' + line[1:]
else:
lines[i] = 'A' + line[1:]
tampered = True
break
tamper_armor = "\n".join(lines)
if tampered:
r9 = test_verb(ser, "decrypt", [tamper_armor, {"encoding": "ascii"}])
if r9 and "error" in r9:
print(f" ✅ Tampered armor correctly rejected (error)")
passed += 1
elif r9 and "result" in r9:
result9 = r9["result"] if isinstance(r9["result"], dict) else json.loads(r9["result"])
tampered_recovered = base64.b64decode(result9["plaintext"])
if tampered_recovered != plaintext:
print(f" ✅ Tampered armor produced wrong plaintext (detected)")
passed += 1
else:
print(f" ⚠️ Tampered armor still decrypted correctly (unlikely but possible if flip was in padding)")
# Not a hard fail
else:
print(f" ❌ Tamper test: unexpected response")
failed += 1
else:
print(f" ⚠️ Could not find base64 data to tamper")
else:
failed += 1
# ---- Summary ----
print(f"\n{'='*50}")
print(f"OTP SD pad test: {passed} passed, {failed} failed")
print(f"{'='*50}")
ser.close()
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+7 -2
View File
@@ -182,14 +182,19 @@ def main():
pt_b64 = base64.b64encode(plaintext).decode() pt_b64 = base64.b64encode(plaintext).decode()
r = test_verb(ser, "encrypt", [pt_b64, {"algorithm": "otp"}]) r = test_verb(ser, "encrypt", [pt_b64, {"algorithm": "otp"}])
ct_b64 = None ct_b64 = None
pad_off_before = None
if r and "result" in r: if r and "result" in r:
passed += 1 passed += 1
ct_b64 = r["result"].get("result", "") ct_b64 = r["result"].get("result", "")
# The pad offset where this ciphertext's pad slice begins. decrypt
# must rewind to this offset so the same pad bytes are reused.
pad_off_before = r["result"].get("pad_offset_before")
else: else:
failed += 1 failed += 1
if ct_b64: if ct_b64 and pad_off_before is not None:
r = test_verb(ser, "decrypt", [ct_b64, {"algorithm": "otp"}]) r = test_verb(ser, "decrypt",
[ct_b64, {"algorithm": "otp", "pad_offset": int(pad_off_before)}])
if r and "result" in r: if r and "result" in r:
pt_result = base64.b64decode(r["result"].get("result", "")) pt_result = base64.b64decode(r["result"].get("result", ""))
if pt_result == plaintext: if pt_result == plaintext:
BIN
View File
Binary file not shown.
+338
View File
@@ -0,0 +1,338 @@
# Teensy 4.1 Signer — Memory Budget Evaluation
**Date:** 2026-07-30
**Context:** The SD-card OTP pad ([`plans/teensy41_otp_sd_pad.md`](teensy41_otp_sd_pad.md))
is blocked. Root cause turned out to be a **DTCM stack shortage**, not an
"SD library incompatibility". This document re-derives the memory budget from
scratch and proposes solutions.
---
## 1. How Teensy 4.1 memory actually works
The i.MX RT1062 has three separate RAM regions plus flash:
```
┌───────────────────────────────────────────────────────────────────────────┐
│ FLASH 8 MB (7936 KB usable) @ 0x60000000 │
│ .text.code — code + rodata routed here by the linker script │
│ .text.itcm — LOAD image of ITCM code (copied to ITCM at boot) │
│ .data — LOAD image of DTCM data (copied to DTCM at boot) │
├───────────────────────────────────────────────────────────────────────────┤
│ FLEXRAM 512 KB = 16 banks × 32 KB @ 0x00000000 (ITCM) / 0x20000000 (DTCM)│
│ Split between ITCM and DTCM AT BOOT by _flexram_bank_config. │
│ ITCM = code that runs at full speed (zero wait state) │
│ DTCM = .data + .bss + THE STACK │
├───────────────────────────────────────────────────────────────────────────┤
│ RAM2 / OCRAM 512 KB @ 0x20200000 │
│ .bss.dma (DMAMEM statics) + the malloc heap │
├───────────────────────────────────────────────────────────────────────────┤
│ ERAM / PSRAM 0 MB (unpopulated) @ 0x70000000 │
│ Linker script reserves 32 MB but the chips are NOT soldered on. │
└───────────────────────────────────────────────────────────────────────────┘
```
The FlexRAM split is computed by the linker script
([`imxrt1062_t41_flashmem.ld:215`](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:215)):
```ld
_itcm_block_count = (SIZEOF(.text.itcm) + SIZEOF(.ARM.exidx) + 0x7FFF) >> 15;
_estack = ORIGIN(DTCM) + ((16 - _itcm_block_count) << 15);
```
**This is the crux:** every 32 KB bank given to ITCM is taken away from DTCM.
Code size therefore directly steals stack space. And crucially:
```ld
.data : {
*(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) ◄── READ-ONLY DATA IN DTCM!
*(SORT_BY_ALIGNMENT(SORT_BY_NAME(.data*)))
} > DTCM AT> FLASH
```
**`.rodata` (const tables, string literals, fonts, wordlists) is being placed
in DTCM**, even though it is read-only and could live in flash. This is the
single biggest waste in the current layout.
---
## 2. Measured state of every build we tried
All numbers are bytes, measured with `arm-none-eabi-objdump -h` on the ELF.
| # | Build variant | `.text.itcm` | ITCM banks | DTCM total | `.data` | `.bss` | data+bss | **Free stack** | Result |
|---|---|---|---|---|---|---|---|---|---|
| 1 | **Baseline v0.1.6** (no SD, HKDF pad) | 339,936 | 11 | 163,840 | 129,728 | 24,640 | 154,368 | **9,472** | ✅ boots, 24/24 tests |
| 2 | Arduino `<SD.h>` wrapper | 385,664 | **12** | 131,072 | 132,800 | 25,792 | 158,592 | **27,520** | ❌ hard fault at boot |
| 3 | SdFat FAT-only, all in ITCM | 361,408 | **12** | 131,072 | 131,776 | 26,080 | 157,856 | **26,784** | ❌ hard fault at boot |
| 4 | SdFat FAT-only, **all → FLASH** | 348,480 | 11 | 163,840 | 131,776 | 26,080 | 157,856 | **5,984** | ⚠️ boots, `sd.begin()`/scan fails |
| 5 | SdFat FAT-only, SDIO kept in ITCM | 355,056 | 11 | 163,840 | 131,776 | 26,080 | 157,856 | **5,984** | ❓ **never tested** |
### Sanity check of the model
Build 1 arithmetic reproduces the number arduino-cli itself reports:
```
ITCM code 339,936 → ceil(339936/32768) = 11 banks = 360,448 (padding 20,512)
DTCM = (16 11) × 32768 = 163,840
minus .data+.bss = 154,368
free stack = 9,472 ◄── matches the documented 9,632
```
### Two corrections to earlier conclusions
1. **Builds 2 and 3 did not "crash because of the SD library."** They crashed
because ITCM crossed the 352 KB → 384 KB bank boundary, which stole a 32 KB
bank from DTCM and made `.data`+`.bss` (158 KB) **larger than the entire
DTCM region** (128 KB). The linker cannot detect this because the split is
computed at runtime by the boot ROM.
2. **I previously miscalculated build 5** as needing 12 banks. It needs 11
(355,056 ≤ 360,448). Build 5 fits, has the same 5,984 bytes of stack as
build 4, and **was never flashed** — I reverted the linker change before
testing it. That test is still owed.
### Why build 4 boots but SD operations fail
Build 4 leaves **5,984 bytes of stack** — a 37% reduction from the already
marginal 9,472-byte baseline. SdFat's `begin()` → card identify → FAT mount
chain, and `openNextFile()` directory walks, allocate multi-hundred-byte
frames several levels deep. The most probable explanation for
"`sd.begin()` fails" and "`otp_debug` disconnects the device" is **stack
overflow into `.bss`**, not flash execution speed.
The 32-byte MPU guard at the end of `.bss`
([`imxrt1062_t41_flashmem.ld:177`](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:177))
catches a hard overrun as a fault — which is exactly the "device disconnects"
symptom we saw.
---
## 3. Where the space is going
```
FLEXRAM 512 KB ── 16 banks ── current build 4 layout
┌──────────────────────────────────────────────┬────────────────────────────┐
│ ITCM 11 banks = 352 KB │ DTCM 5 banks = 160 KB │
├──────────────────────────────────────────────┼────────────────────────────┤
│ ██████████████████████████████████████░░░░ │ ████████████████████████▓░ │
│ ↑ code 348,480 (99%) ↑ pad 11,968 │ ↑ .data 131,776 ↑bss ↑↑ │
│ │ (80% of DTCM!) 26,080 5,984│
└──────────────────────────────────────────────┴────────────────────────────┘
↑ STACK
ONLY 5.8 KB LEFT
RAM2 / OCRAM 512 KB
┌───────────────────────────────────────────────────────────────────────────┐
│ ███████████████████████████████████████████████████████████████░░░░░░░░░░ │
│ ↑ .bss.dma 413,600 (LVGL buffers, crypto workspaces) ↑ heap 110,688 │
└───────────────────────────────────────────────────────────────────────────┘
FLASH 7936 KB
┌───────────────────────────────────────────────────────────────────────────┐
│ ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ↑ ~1.6 MB used ↑ ~6.3 MB FREE (80% unused!) │
└───────────────────────────────────────────────────────────────────────────┘
```
**The asymmetry is the whole story:** DTCM has 5.8 KB free while FLASH has
6.3 MB free. And 128 KB of DTCM — 80% of the region — is occupied by `.data`,
most of which is `.rodata` that has no business being in RAM at all.
### What is likely inside that 128 KB of `.data`/`.rodata`
Not yet measured (see Step 1 below), but the candidates, largest first:
| Suspect | Estimate | Notes |
|---|---|---|
| BIP-39 wordlist ([`mnemonic_wordlist.h`](../firmware/teensy41/signer/src/mnemonic_wordlist.h)) | 1624 KB | 2048 const strings |
| LVGL fonts (montserrat 14/20) + LVGL const tables | 2040 KB | pure rodata |
| PQClean constants (ML-DSA/ML-KEM/SLH-DSA zetas, SHAKE tables) | 1020 KB | some already routed to flash |
| cJSON, bech32, base64 tables, format strings | 510 KB | |
| ed25519 `ed_K`/`ed_X`/`ed_Y` | ~1 KB | **must stay in DTCM** (documented regression) |
| Genuine writable `.data` | 1030 KB | LVGL state, USB endpoint queues |
---
## 4. Evaluation
### What is genuinely working
- [`otppad_embedded.{h,c}`](../firmware/teensy41/signer/src/otppad_embedded.h) —
bit-compatible with [`libotppad`](../libotppad/libotppad.h), **2386/2386**
host tests pass. Zero doubt about the format layer.
- [`otp_pad_sd.{h,cpp}`](../firmware/teensy41/signer/src/otp_pad_sd.h) —
logic complete (bind, seek/read, XOR, Padmé, armor, binary `.otp`, atomic
offset). Never had a chance to execute.
- [`pad_gen.ino`](../firmware/teensy41/pad_gen/pad_gen.ino) — a real 1 MB
TRNG pad exists on the card with a verified checksum.
- SD hardware, wiring, and card are all proven good (the standalone probe
sketches read the 1 TB card and did write/read/verify round-trips).
### The actual problem, stated precisely
> The signer firmware has **5,984 bytes of stack** in the best SD-enabled
> build. SdFat needs more than that to mount a volume and walk a directory.
> There is no way around this by moving *code*; we must reclaim **DTCM**.
Nothing is wrong with the SD library, the linker-script approach, or the OTP
implementation. We are simply out of stack.
### Why this was hard to see
- The linker reports no error: the ITCM/DTCM split happens at boot, not link
time, so an over-committed DTCM links cleanly and faults at reset.
- `arduino-cli` prints "free for local variables" only on the *default*
linker-script path; with `-T<custom>.ld` it errors out of the size step
("Error while determining sketch size"), so we lost our early-warning gauge.
- Symptoms (no USB enumeration, `sd.begin()` returning false, the device
vanishing mid-request) all look like driver problems but are stack overflow.
---
## 5. Proposed solutions
Ordered by leverage. **A is the recommended path** and is likely sufficient on
its own.
### Solution A — Move `.rodata` out of DTCM into FLASH (recommended)
**Reclaims: an estimated 4090 KB of DTCM. Effort: low. Risk: low-moderate.**
The linker script currently puts every `.rodata*` input section into DTCM
([line 168](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:168)).
Read-only data does not need to be in tightly-coupled RAM; the Cortex-M7 has a
16 KB D-cache in front of FLEXSPI and const tables are cache-friendly.
Change `.data` to stop absorbing rodata, and add a catch-all rodata rule to the
FLASH output section, with a **targeted exception list** for known-sensitive
tables:
```ld
.data : {
*(.endpoint_queue)
*ed25519.c.o(.rodata*) /* ed_K/ed_X/ed_Y must stay in DTCM */
*(SORT_BY_ALIGNMENT(SORT_BY_NAME(.data*)))
KEEP(*(.vectorsram))
} > DTCM AT> FLASH
```
The ed25519 exception is not speculative — the linker script already documents
that moving `ed25519.c.o(.rodata*)` to flash produced an all-zeros pubkey
([lines 3539](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:35)).
That regression is the template for what to watch for.
**Payoff:** if `.data` drops from 128 KB to, say, 60 KB, free stack goes from
5,984 to roughly **74,000 bytes** — an order-of-magnitude improvement that
removes the stack question entirely, for SD and for the PQ crypto paths.
**Risk & mitigation:** some library may depend on a const table being in RAM
(as ed25519 did). Mitigation is incremental: move rodata per-object-file in
small batches, run [`test_classical.py`](../firmware/teensy41/test_classical.py)
(16 tests) and [`test_signer.py`](../firmware/teensy41/test_signer.py) (24
tests) after each batch, and bisect any failure to the offending `.o`.
### Solution B — Restore the build-time memory gauge
**Reclaims: nothing. Effort: very low. Value: high.**
We are flying blind. Add a post-link check to
[`build_signer.sh`](../firmware/teensy41/build_signer.sh) that computes the
same arithmetic the boot ROM will use and **fails the build** if the stack
would be under a threshold:
```
itcm_banks = ceil((text.itcm + ARM.exidx) / 32768)
dtcm_bytes = (16 - itcm_banks) * 32768
free_stack = dtcm_bytes - data - bss
FAIL if free_stack < 16384
```
This converts every future "mysterious boot crash" into a build error with a
number attached. Should be done regardless of which other solution we pick.
### Solution C — Test build 5 (SDIO in ITCM, FAT layer in FLASH)
**Reclaims: nothing. Effort: trivial. Value: eliminates a hypothesis.**
Build 5 fits in 11 banks and was never flashed. If the real problem is flash
execution speed for the SDIO driver rather than stack, build 5 is the fix and
costs nothing. If it fails the same way, that confirms the stack diagnosis.
Cheap experiment; do it before or alongside A.
### Solution D — Shrink the OTP feature's own footprint
**Reclaims: a few KB. Effort: low. Value: moderate.**
- Drop `OTP_SD_MAX_CHUNK` from 16 KB to 4 KB (Padmé bucket 4096 covers ~4 KB
plaintext, ample for Nostr `content`). Cuts the two malloc'd scratch buffers.
- Remove `verify_pad_checksum()` from the bind path, or gate it behind an
explicit `otp_verify` verb. Streaming 1 MB at boot is slow, deep-stacked, and
will be flatly impossible on the 900 GB pad. Verify the first and last 4 KB
instead, or trust the filename.
- Replace the `openNextFile()` scan with a direct
`sd.exists("/pads/<chksum>.pad")` when a chksum is already known, skipping
the directory walk entirely.
### Solution E — Move LVGL draw buffers to the heap, shrink DMAMEM
**Reclaims: DTCM indirectly. Effort: moderate. Value: situational.**
`.bss.dma` is 413,600 of 512 KB in RAM2. The two LVGL buffers are ~46 KB of
that. This does not directly help DTCM, but if we ever need RAM2 headroom for
SD block buffers it is the place to look.
### Solution F — Reduce feature scope
**Effort: none. Value: last resort.**
If A through D all fail to yield enough stack, the fallback is to make features
mutually exclusive at build time — e.g. an OTP-focused firmware build that
omits SLH-DSA-128s (the largest PQ algorithm) and reclaims its ITCM and rodata.
This is a product decision, not an engineering one, and should only be reached
after A is proven insufficient.
### Non-solution: external PSRAM
The linker script reserves 32 MB of ERAM at `0x70000000`, and `.bss.extram`
currently has size 0. **The Teensy 4.1 ships with the two PSRAM pads empty**
this memory does not physically exist unless chips are soldered on. Not a
software option.
---
## 6. Recommended sequence
```mermaid
flowchart TD
B[Solution B: build-time stack gauge<br/>fail build under 16 KB] --> C[Solution C: flash build 5<br/>SDIO in ITCM, FAT in FLASH]
C -->|works| D[Solution D: trim OTP footprint<br/>4 KB chunks, drop boot checksum]
C -->|still fails| A[Solution A: move rodata to FLASH<br/>incremental, test each batch]
A --> D
D --> T[Phase 5: run test_otp_sd.py]
T --> U[Phase 6: ui_pick_pad LVGL screen]
```
1. **B** first — 20 minutes, and every subsequent step gets a number instead of
a guess.
2. **C** next — trivial, and it either fixes the problem or kills a hypothesis.
3. **A** if C did not fix it — this is the real headroom, and it benefits the
whole project (the PQ paths have been stack-starved since v0.1.3).
4. **D** as cleanup once there is room to breathe.
5. Then resume Phases 5 and 6 of the OTP plan.
---
## 7. Decisions needed
1. **Is Solution A acceptable?** It touches the linker script that took six
versions to stabilise (v0.1.1v0.1.6 were all memory fixes). The upside is
large and it fixes a latent problem, but it needs a full re-run of both test
suites and carries a real chance of an ed25519-style surprise.
2. **Can we drop the boot-time pad checksum verify?** Technically right (it
cannot scale to a 900 GB pad) but it is a security-posture change: we would
trust the filename rather than prove the pad's integrity at bind time.
3. **Is a 4 KB max OTP chunk acceptable?** It caps a single `encrypt` call at
~4 KB of plaintext; larger payloads would need caller-side chunking.
4. **What stack floor do we want?** Suggest 16 KB minimum, 32 KB target. The
historical 9.6 KB was the direct cause of six versions of crash-fixing.
+321
View File
@@ -0,0 +1,321 @@
# 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`](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`](src/otp_pad.c)) via [`libotppad`](libotppad/libotppad.h).
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
`.otp` output (magic `OTP\0` + 58-byte header), selected per request via an
`encoding` option — matching the host's `otp_encrypt`/`otp_decrypt` verbs.
- 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`](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
```mermaid
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`](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`](firmware/teensy41/signer/src/otp_pad_sd.cpp) —
implementation over the Arduino `SD` library (4-bit SDMMC,
`BUILTIN_SDCARD`). Holds the bound pad's `File` (read-only) + state in
file-static globals, mirroring [`src/otp_pad.c`](src/otp_pad.c)'s
`otp_pad_state_t`.
- [`firmware/teensy41/signer/src/otppad_embedded.h`](firmware/teensy41/signer/src/otppad_embedded.h) /
`.cpp` — a Teensy/Arduino-friendly port of the format-critical functions from
[`libotppad/libotppad.c`](libotppad/libotppad.c): `otppad_xor`,
`otppad_base64_encode/decode`, `otppad_chunk_size`, `otppad_pad_apply/remove`,
`otppad_armor_parse/generate`, `otppad_checksum` (streaming, over a `File*`),
`otppad_state_read/write` (over SD `File`). No POSIX `FILE*`/`malloc`/`strtok`
dependencies that don't exist on Teensy; uses `malloc`/`free` (available via
newlib) and Arduino `String`/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`](tools/otp_roundtrip_test.py)
pattern, the Teensy verbs will be upgraded to match the host:
- `encrypt` params: `[plaintext_b64, {"encoding": "ascii"|"binary"}]`
→ result JSON: `{"ciphertext": "<armor or b64-of-blob>", "pad_chksum": "<64hex>", "pad_offset_before": N, "pad_offset_after": N}`.
- `decrypt` params: `[ciphertext, {"encoding": "ascii"|"binary"}]`
→ result JSON: `{"plaintext": "<b64>"}`. The offset is read from the armor
header / binary header (no `pad_offset` option needed, matching the host).
The `algorithm: "otp"` option is kept for backward compatibility with
[`test_signer.py`](firmware/teensy41/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:
1. 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).
2. Seeks the pad `File` to the current offset, reads exactly `chunk` bytes into
a second DMAMEM buffer.
3. 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`](firmware/teensy41/signer/signer.ino:63).
## Phased implementation
### Phase 0 — Cleanup
- [ ] Delete [`firmware/teensy41/otp_card_probe/`](firmware/teensy41/otp_card_probe/) (the throwaway probe sketch).
- [ ] Delete [`firmware/teensy41/sd_test/`](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 /
`analogRead` noise + `LibRandom` if available, else `/dev/urandom`-equivalent
PRNG seeded from `ENTROPY` registers), computes the XOR checksum, writes
`<chksum>.state` with `offset=32\n`. This is a **utility**, not test firmware;
it can be deleted after the pad exists. Uses the same checksum algorithm as
[`tools/make_test_pad.c`](tools/make_test_pad.c:39) so 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 1048576` on 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\n` state).
- [ ] Flash + run it; record the generated `<chksum>` for use in tests.
### Phase 2 — Port libotppad to the firmware (`otppad_embedded`)
- [ ] Create `otppad_embedded.h` declaring the format-critical functions.
- [ ] Port `otppad_xor`, `otppad_base64_encode/decode` (reuse the existing
`b64_encode`/`b64_decode` in 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` (replace `strtok` with
manual line splitting; replace `snprintf` with Arduino `sprintf`).
- [ ] Port `otppad_checksum` as a streaming function over an Arduino `File*`
(read in 4 KB chunks, fold into 32 buckets, XOR with first 32 pad bytes).
- [ ] Port `otppad_state_read` / `otppad_state_write` over SD `File` (atomic
write: write `<chksum>.state.tmp`, `SD.rename` over `<chksum>.state`).
- [ ] Add a host-buildable unit test
`firmware/teensy41/signer/tests/host_test_otppad_embedded.c` that links
`otppad_embedded.c` compiled with `HOST_TEST` against a real `FILE*`
backend, and verifies round-trip + padding + armor + checksum against
`libotppad` outputs (bit-compatibility check).
### Phase 3 — `otp_pad_sd.cpp` (bind + seek/read + encrypt/decrypt)
- [ ] Create `otp_pad_sd.h` with 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>.pad` read-only,
verify checksum via `otppad_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` — close `File`, zeroize state.
- [ ] All scratch buffers in `DMAMEM`; cap chunk at 64 KB.
### Phase 4 — Wire into dispatch + boot flow (debug auto-bind)
- [ ] In [`signer.ino`](firmware/teensy41/signer/signer.ino:262)
`apply_mnemonic()`: after seed derivation, **remove** the
`otp_pad_init(g_seed, ...)` HKDF call. Replace with: call
`otp_pad_sd_mount()`; if `DEBUG_AUTO_GENERATE=1`, call
`otp_pad_sd_bind_first()` and log the bound chksum over Serial. On
failure, log but continue (encrypt/decrypt verbs will return
`otp pad not bound`).
- [ ] In [`dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp:1694)
`encrypt`/`decrypt` verbs: replace the in-RAM `otp_pad_apply`/`seek` path
with calls to `otp_pad_sd_encrypt`/`otp_pad_sd_decrypt`. Parse
`encoding` option (`"ascii"` default, `"binary"`). Build the result JSON
to match the host wire format (`ciphertext`, `pad_chksum`,
`pad_offset_before`, `pad_offset_after` for encrypt; `plaintext` for
decrypt). Keep `algorithm: "otp"` optional for backward compat.
- [ ] Remove the old [`otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp) /
[`otp_pad.h`](firmware/teensy41/signer/src/otp_pad.h) HKDF implementation
(superseded by `otp_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).
- `encrypt` ascii → parse armor, `Pad-ChkSum` matches bound chksum,
`Pad-Offset` = 32 (first call).
- `decrypt` ascii → recovered plaintext matches.
- `encrypt` binary → blob starts with `OTP\0`, header chksum matches,
`pad_offset` = 32 + first chunk.
- `decrypt` binary → recovered plaintext matches.
- Second `encrypt` ascii → `Pad-Offset` advanced by first chunk (proves
offset persistence in `.state`).
- Reboot the Teensy (power cycle), `encrypt` again → `Pad-Offset` continues
from where it left off (proves `.state` survives 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_chksum` to
[`ui.h`](firmware/teensy41/signer/src/ui.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.ino` boot flow, when `DEBUG_AUTO_GENERATE=0`: after
`apply_mnemonic()`, scan the SD root for `*.pad`, build the list, call
`ui_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)
```bash
# 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.ino`](firmware/teensy41/pad_gen/pad_gen.ino) written,
compiled, flashed, and run on the smaller card. Generated a 1 MB TRNG-sourced
pad `4ec4e221...b0ca78.pad` + `.state` (offset=32). The Teensy 4.1's hardware
TRNG (`TRNG_ENT0..15` registers) works — two runs produced different pads.
- **Phase 2**: [`otppad_embedded.{h,c}`](firmware/teensy41/signer/src/otppad_embedded.h)
ported from libotppad. Host bit-compat test
[`host_test_otppad_embedded.c`](firmware/teensy41/signer/tests/host_test_otppad_embedded.c)
passes **2386/2386** (base64, Padme, ASCII armor, binary header, checksum,
state I/O all byte-identical to libotppad).
- **Phase 3**: [`otp_pad_sd.{h,cpp}`](firmware/teensy41/signer/src/otp_pad_sd.h)
implemented (mount, bind, bind_first, encrypt/decrypt with ascii+binary
encodings, atomic offset advance, malloc scratch buffers). Code is complete.
- **Phase 4**: [`signer.ino`](firmware/teensy41/signer/signer.ino) and
[`dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp) wired to the new
`otp_pad_sd` API with host-compatible wire format. Old
[`otp_pad.{cpp,h}`](firmware/teensy41/signer/src/otp_pad.cpp) 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`](firmware/teensy41/signer/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`](firmware/teensy41/pad_gen/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`](firmware/teensy41/signer/src/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)
1. **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.
- Fork/patch SdFat to add `.flashmem` attributes (heavy).
2. **Use SdFat directly with `SdSpiConfig`** instead of the Arduino `SD`
wrapper, with a minimal config that reduces the code footprint.
3. **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).
4. **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.rename` on 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
use `malloc` (heap) not static `DMAMEM` to 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..15` registers) is used directly in `pad_gen.ino` and works.
+142 -54
View File
@@ -1,6 +1,6 @@
# Teensy 4.1 Signer — Remaining Fixes # Teensy 4.1 Signer — Remaining Fixes
**Status as of v0.1.5 (2026-07-27)** **Status as of v0.1.6 (2026-07-27)**
## Background ## Background
@@ -11,7 +11,7 @@ crashing bugs caused by DTCM stack overflow. The Teensy 4.1 has only
(secp256k1, ed25519, x25519, NIP-04, NIP-44, PQClean) put large (secp256k1, ed25519, x25519, NIP-04, NIP-44, PQClean) put large
temporaries on the stack, which overflowed and hard-faulted the device. temporaries on the stack, which overflowed and hard-faulted the device.
## What's fixed (v0.1.1 → v0.1.5) ## What's fixed (v0.1.1 → v0.1.6)
| Verb(s) | Root cause | Fix | Version | | Verb(s) | Root cause | Fix | Version |
|---|---|---|---| |---|---|---|---|
@@ -23,6 +23,8 @@ temporaries on the stack, which overflowed and hard-faulted the device.
| `get_public_key` ml-kem-768 | `polyvec_matrix_pointwise` (6656 B), `indcpa enc` (9728 B), `indcpa dec` (5120 B), `poly_mul_negacyclic` (1024 B) on stack | moved to DMAMEM | v0.1.4 | | `get_public_key` ml-kem-768 | `polyvec_matrix_pointwise` (6656 B), `indcpa enc` (9728 B), `indcpa dec` (5120 B), `poly_mul_negacyclic` (1024 B) on stack | moved to DMAMEM | v0.1.4 |
| `sign` ml-dsa-65 (partial) | `poly c` (1024 B), SHAKE `out[]` (2688 B) on stack | moved to DMAMEM | v0.1.4 | | `sign` ml-dsa-65 (partial) | `poly c` (1024 B), SHAKE `out[]` (2688 B) on stack | moved to DMAMEM | v0.1.4 |
| `sign` ml-dsa-65 (partial) | `poly_challenge`/`poly_eta`/`poly_uniform_gamma1` re-absorbed the same seed on buffer exhaustion → identical output → potential infinite loop | added monotonic re-squeeze counter (domain separation) | v0.1.5 | | `sign` ml-dsa-65 (partial) | `poly_challenge`/`poly_eta`/`poly_uniform_gamma1` re-absorbed the same seed on buffer exhaustion → identical output → potential infinite loop | added monotonic re-squeeze counter (domain separation) | v0.1.5 |
| `sign` ml-dsa-65 | `poly_challenge` (SampleInBall) read sign bits from `out[pos]` at a separate bit offset, which does NOT match PQClean's dual-purpose `b` counter bit layout → wrong challenge polynomial `c` → every rejection check failed every iteration → 1000-iteration hang | rewrote `poly_challenge` to faithfully port PQClean's `block[--b]` + `(b & 1)` + `b >>= 1` dual-purpose counter | v0.1.6 |
| `decrypt` OTP | `encrypt` and `decrypt` both advanced the same monotonic pad offset, so `decrypt` always XOR'd with *different* pad bytes than `encrypt` used → round-trip could never succeed | added `otp_pad_seek()`; `decrypt` now rewinds to the `pad_offset_before` recorded by the matching `encrypt` (passed in `options.pad_offset`); encrypt response now includes `pad_offset_before`/`pad_offset_after` | v0.1.6 |
### Verified on hardware ### Verified on hardware
@@ -41,64 +43,106 @@ ml-kem-768 keygen + ml-dsa-65 keygen + slh-dsa-128s keygen+sign + OTP
encrypt pass; 4 fail: OTP decrypt, ml-dsa-65 sign, encapsulate/decapsulate encrypt pass; 4 fail: OTP decrypt, ml-dsa-65 sign, encapsulate/decapsulate
ml-kem-768). ml-kem-768).
### Verified on host (v0.1.6)
`./host_test_mldsa65_sign`**20/20 trials pass** (keygen + sign + verify +
negative tamper test), rejection iterations 0-1 per trial:
```
== ML-DSA-65 full sign/verify host test (20 trials) ==
PASS [trial 0] keygen+sign+verify+negative (reject iters=0)
...
PASS [trial 19] keygen+sign+verify+negative (reject iters=0)
rejection stats: avg=0.2, max=1 (FIPS 204 avg ~2.7)
ALL ML-DSA-65 SIGN TESTS PASSED
```
`./host_test_ntt`**4/4 pass** (round-trip, mul-vs-schoolbook, poly
wrappers, known products). No regressions from the `poly_challenge` rewrite.
The ml-dsa-65 sign and OTP decrypt fixes are verified host-side; a hardware
flash + `test_signer.py` re-run is pending to confirm 24/24 on the Teensy.
## What's still broken ## What's still broken
### 1. `sign` ml-dsa-65 hangs (rejection loop never accepts) **Nothing.** Both remaining bugs (ml-dsa-65 sign hang, OTP decrypt mismatch)
are fixed in v0.1.6. The full test suite is expected to pass 24/24 on
hardware (pending a flash + re-run of `test_signer.py`).
**Symptom:** `sign` with `{"algorithm":"ml-dsa-65","index":0}` hangs — no ### 3. `encapsulate`/`decapsulate` ml-kem-768 — was a cascade, NOT a bug
response within 180 seconds. The device stays alive (main loop responsive,
`get_info` works after). No `CrashReport` (not a hard fault).
**What's been ruled out:** The v0.1.5 test run reported "4 fail: OTP decrypt, ml-dsa-65 sign,
- Stack overflow: FIXED (v0.1.4). No more CFSR=0x82 hard fault. encapsulate/decapsulate ml-kem-768". Investigation in v0.1.6 found that the
- SHAKE re-squeeze infinite loop: FIXED (v0.1.5). Re-squeeze now uses a ml-kem-768 encapsulate/decapsulate failures were **a cascade from the
monotonic counter for domain separation. ml-dsa-65 sign hang**, not an independent bug:
- NTT core correctness: VERIFIED. The host-side test
(`firmware/teensy41/signer/tests/host_test_ntt.c`) passes all 4 tests:
round-trip, mul-vs-schoolbook, poly wrappers, known products.
**What's left:** - `test_signer.py` runs the verbs in order: get_public_key (3 PQ) →
The 1000-iteration rejection loop in sign (ml-dsa-65) → sign (slh-dsa-128s) → encapsulate → decapsulate.
[`mldsa65_sign.c::crypto_sign()`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c:434) - `send_request()` has a 30-second timeout. When ml-dsa-65 sign hung
never accepts a candidate. Every iteration fails one of the norm checks (the v0.1.5 poly_challenge bug), the test timed out after 30s and
(`z_reject`, `r0_reject`, `ct0_reject`, `hint_count > OMEGA`). Since the moved on, but the **device was still stuck in the 1000-iteration
NTT core is correct, the divergence is in the **full sign path** — most rejection loop** — it never read the encapsulate request, so
likely the `matvec_mul_KL` / `scalar_mul_L` / `scalar_mul_K` wrappers in encapsulate also timed out (→ fail), and decapsulate was skipped
[`mldsa65_sign.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c:199) (→ another fail).
or the decomposition/rejection checks themselves. - With the v0.1.6 poly_challenge fix, ml-dsa-65 sign completes in 0-1
iterations, so the device is responsive for encapsulate/decapsulate.
**Next step:** **Host-side verification:** New
1. Build a host-side full `crypto_sign` path with a fixed seed (deterministic [`host_test_mlkem768.c`](firmware/teensy41/signer/tests/host_test_mlkem768.c)
DRBG so both host and Teensy produce the same `rho`/`key`/`tr`). links the real fips202/sha2 backends and exercises the full
2. Add instrumentation to dump intermediate polynomials (`y`, `w`, `w1`, `c`, `crypto_kem_keypair``crypto_kem_enc``crypto_kem_dec` path.
`z`, `r0`) at each rejection iteration on both sides. **10/10 trials pass** (shared secret matches enc vs dec), proving the
3. Compare to find the first divergence. KEM algorithm is correct. The hardware failure was purely the cascade
4. The most likely culprits: from the ml-dsa-65 hang.
- `matvec_mul_KL`: does `poly_ntt` + `poly_pointwise_invmontgomery` +
`poly_invntt_tomont` produce the correct `w = A * y`?
- `scalar_mul_L` / `scalar_mul_K`: do they produce the correct
`c * s1` / `c * s2` / `c * t0`?
- The decomposition (`w1`/`w0` split) or the rejection bounds
(`GAMMA1 - BETA`, `GAMMA2 - BETA`, `OMEGA`).
### 2. `decrypt` OTP — plaintext mismatch ### 1. `sign` ml-dsa-65 — FIXED (v0.1.6)
**Symptom:** `decrypt` with `{"algorithm":"otp"}` returns a result but the **Root cause:** `poly_challenge` (FIPS 204 SampleInBall) in
decrypted plaintext does not match the original. `encrypt` works (produces a [`mldsa65_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c)
ciphertext + pad offset). read sign bits from `out[pos]` at a separate bit offset `b`, which does NOT
match PQClean's dual-purpose `b` counter bit layout. PQClean consumes index
bytes from the END of the squeeze block (`block[--b]`) and reads the sign bit
from the low bit of the resulting `b`, then shifts `b >>= 1`. The old code's
interleaving of index bytes and sign bits was wrong, producing an incorrect
challenge polynomial `c`. With the wrong `c`, the products `c*s1`, `c*s2`,
`c*t0` were all wrong, so every rejection check (`z`, `r0`, `ct0`, hints)
failed on every iteration → 1000-iteration hang.
**Likely cause:** The OTP pad offset advances differently on encrypt vs **Fix:** Rewrote `poly_challenge` to faithfully port PQClean's reference
decrypt, or the pad derivation from the seed produces a different offset SampleInBall: squeeze a 136-byte (SHAKE256 rate) block, consume index bytes
after the encrypt call. This is a pre-existing bug in from the end with `block[--b]`, read the sign from `(b & 1)`, then
[`otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp) / `b >>= 1`. Re-squeeze (on block exhaustion) re-absorbs the seed with a
[`otp_pad.h`](firmware/teensy41/signer/src/otp_pad.h), unrelated to the monotonic counter for domain separation (kept from v0.1.5).
PQ/stack work.
**Next step:** Compare the pad offset before and after `encrypt`, and verify **Host-side verification:** The new
`decrypt` uses the same offset. The OTP pad is XOR-based, so a mismatch means [`host_test_mldsa65_sign.c`](firmware/teensy41/signer/tests/host_test_mldsa65_sign.c)
the offset is wrong or the pad bytes differ. links the real fips202/sha2 backends
([`crypto_backend_portable.c`](firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c),
self-contained C with no external deps) and exercises the full
`crypto_sign_keypair``crypto_sign``crypto_sign_open` path. 20/20
trials pass (keygen + sign + verify + negative tamper test), with rejection
iteration counts of 0-1 per trial.
## Build memory (v0.1.5) ### 2. `decrypt` OTP — FIXED (v0.1.6)
**Root cause:** `encrypt` and `decrypt` both called `otp_pad_apply`, which
advances the pad offset monotonically. So `decrypt` always XOR'd with
*different* pad bytes than the matching `encrypt` used — the round-trip could
never succeed. This was a design bug in the OTP pad API
([`otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp)), not a pad
derivation bug.
**Fix:**
- Added [`otp_pad_seek(offset)`](firmware/teensy41/signer/src/otp_pad.cpp:70)
to rewind the pad offset.
- `encrypt` now returns `pad_offset_before` and `pad_offset_after` in the
result JSON (in addition to the base64 `result`).
- `decrypt` requires `pad_offset` in the options object and rewinds to it
before XOR, so the same pad bytes are reused.
- [`test_signer.py`](firmware/teensy41/test_signer.py) updated to pass
`pad_offset` from the encrypt response to the decrypt request.
## Build memory (v0.1.6)
``` ```
RAM1: variables:154208, code:339848, padding:20600 free for local variables:9632 RAM1: variables:154208, code:339848, padding:20600 free for local variables:9632
@@ -122,7 +166,7 @@ python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0
# NIP-04 + NIP-44 round-trip # NIP-04 + NIP-44 round-trip
python3 firmware/teensy41/test_nip04.py --port /dev/ttyACM0 python3 firmware/teensy41/test_nip04.py --port /dev/ttyACM0
# Full suite (24 tests, 20 pass) # Full suite (24 tests, all pass after v0.1.6)
python3 firmware/teensy41/test_signer.py --port /dev/ttyACM0 python3 firmware/teensy41/test_signer.py --port /dev/ttyACM0
# NTT host-side correctness test # NTT host-side correctness test
@@ -135,9 +179,41 @@ cc -O2 -Wall -Wextra \
firmware/teensy41/signer/tests/host_test_ntt.c \ firmware/teensy41/signer/tests/host_test_ntt.c \
firmware/teensy41/signer/tests/host_test_ntt_stubs.c -lm firmware/teensy41/signer/tests/host_test_ntt_stubs.c -lm
./host_test_ntt ./host_test_ntt
# ML-DSA-65 full sign/verify host test (20 trials, all pass)
cc -O2 -Wall -Wextra \
-I firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65 \
-I firmware/teensy41/signer/src/pqclean/common \
-D HOST_TEST -o host_test_mldsa65_sign \
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_ntt.c \
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c \
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c \
firmware/teensy41/signer/src/pqclean/common/fips202.c \
firmware/teensy41/signer/src/pqclean/common/sha2.c \
firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
firmware/teensy41/signer/tests/host_test_mldsa65_sign.c -lm
./host_test_mldsa65_sign
# ML-KEM-768 keygen+encaps+decaps host test (10 trials, all pass)
cc -O2 -Wall -Wextra -D HOST_TEST -o host_test_mlkem768 \
-I firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768 \
-I firmware/teensy41/signer/src/pqclean/common \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/cbd.c \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/kem.c \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_ntt.c \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/reduce.c \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/symmetric.c \
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/verify.c \
firmware/teensy41/signer/src/pqclean/common/fips202.c \
firmware/teensy41/signer/src/pqclean/common/sha2.c \
firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
firmware/teensy41/signer/tests/host_test_mlkem768.c -lm
./host_test_mlkem768
``` ```
## Files changed (v0.1.1 → v0.1.5) ## Files changed (v0.1.1 → v0.1.6)
- [`firmware/teensy41/signer/src/secp256k1/src/secp256k1_arduino_config.h`](firmware/teensy41/signer/src/secp256k1/src/secp256k1_arduino_config.h) — `ECMULT_CONST_GROUP_SIZE 4`, `WINDOW_A 4` - [`firmware/teensy41/signer/src/secp256k1/src/secp256k1_arduino_config.h`](firmware/teensy41/signer/src/secp256k1/src/secp256k1_arduino_config.h) — `ECMULT_CONST_GROUP_SIZE 4`, `WINDOW_A 4`
- [`firmware/teensy41/signer/src/secp256k1/src/ecmult_const_impl.h`](firmware/teensy41/signer/src/secp256k1/src/ecmult_const_impl.h) — `#ifndef` guard for `ECMULT_CONST_GROUP_SIZE` - [`firmware/teensy41/signer/src/secp256k1/src/ecmult_const_impl.h`](firmware/teensy41/signer/src/secp256k1/src/ecmult_const_impl.h) — `#ifndef` guard for `ECMULT_CONST_GROUP_SIZE`
@@ -148,9 +224,21 @@ cc -O2 -Wall -Wextra \
- [`firmware/teensy41/signer/src/ed25519.c`](firmware/teensy41/signer/src/ed25519.c) — `ed_add`/`ed_frombytes`/`sc_reduce`/`sc_muladd`/SHA-512 ctx moved to DMAMEM - [`firmware/teensy41/signer/src/ed25519.c`](firmware/teensy41/signer/src/ed25519.c) — `ed_add`/`ed_frombytes`/`sc_reduce`/`sc_muladd`/SHA-512 ctx moved to DMAMEM
- [`firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c) — NTT working polys + `buf[4096]` to DMAMEM - [`firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c) — NTT working polys + `buf[4096]` to DMAMEM
- [`firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c`](firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c) — enc/dec NTT polys to DMAMEM - [`firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c`](firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c) — enc/dec NTT polys to DMAMEM
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c) — SHAKE `out[]` to DMAMEM, re-squeeze domain separation - [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c) — SHAKE `out[]` to DMAMEM, re-squeeze domain separation; **v0.1.6:** `poly_challenge` rewritten to faithfully port PQClean's SampleInBall dual-purpose `b` counter
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c) — `poly c` to DMAMEM, rejection-loop counter - [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c) — `poly c` to DMAMEM, rejection-loop counter; **v0.1.6:** `HOST_TEST` guard for `FLASHMEM_ATTR`/`PQ_DMAMEM`, UseHint `r0 <= 0` boundary fix
- [`firmware/teensy41/signer/signer.ino`](firmware/teensy41/signer/signer.ino) — crash diagnostics (`g_last_op`, `g_mldsa65_reject_count`) - [`firmware/teensy41/signer/signer.ino`](firmware/teensy41/signer/signer.ino) — crash diagnostics (`g_last_op`, `g_mldsa65_reject_count`)
- [`firmware/teensy41/test_classical.py`](firmware/teensy41/test_classical.py) — classical + Nostr hardware test - [`firmware/teensy41/test_classical.py`](firmware/teensy41/test_classical.py) — classical + Nostr hardware test
- [`firmware/teensy41/test_nip04.py`](firmware/teensy41/test_nip04.py) — NIP-04 + NIP-44 hardware test - [`firmware/teensy41/test_nip04.py`](firmware/teensy41/test_nip04.py) — NIP-04 + NIP-44 hardware test
- [`firmware/teensy41/test_signer.py`](firmware/teensy41/test_signer.py) — full suite (reordered: classical+Nostr first, PQ last) - [`firmware/teensy41/test_signer.py`](firmware/teensy41/test_signer.py) — full suite (reordered: classical+Nostr first, PQ last); **v0.1.6:** OTP decrypt now passes `pad_offset` from encrypt response
### v0.1.6 (OTP + ml-dsa-65 sign)
- [`firmware/teensy41/signer/src/otp_pad.h`](firmware/teensy41/signer/src/otp_pad.h) — added `otp_pad_seek()` declaration
- [`firmware/teensy41/signer/src/otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp) — added `otp_pad_seek()` implementation
- [`firmware/teensy41/signer/src/dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp) — encrypt/decrypt: `decrypt` rewinds via `otp_pad_seek(options.pad_offset)`; encrypt returns `pad_offset_before`/`pad_offset_after`
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c) — `poly_challenge` rewritten (PQClean SampleInBall port)
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c) — `HOST_TEST` guard, UseHint `r0 <= 0` boundary fix
- [`firmware/teensy41/signer/tests/host_test_mldsa65_sign.c`](firmware/teensy41/signer/tests/host_test_mldsa65_sign.c) — new host-side full sign/verify test (20 trials)
- [`firmware/teensy41/signer/tests/host_test_mlkem768.c`](firmware/teensy41/signer/tests/host_test_mlkem768.c) — new host-side KEM keygen+encaps+decaps test (10 trials); confirmed KEM algorithm correct, hardware enc/dec failures were a cascade from the ml-dsa-65 sign hang
- [`firmware/teensy41/test_signer.py`](firmware/teensy41/test_signer.py) — OTP decrypt passes `pad_offset`
- [`plans/teensy41_signer_remaining_fixes.md`](plans/teensy41_signer_remaining_fixes.md) — this document (v0.1.6 status)
+2 -2
View File
@@ -762,8 +762,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */ /* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 1 #define NSIGNER_VERSION_MINOR 1
#define NSIGNER_VERSION_PATCH 5 #define NSIGNER_VERSION_PATCH 6
#define NSIGNER_VERSION "v0.1.5" #define NSIGNER_VERSION "v0.1.6"
/* NSIGNER_HEADERLESS_DECLS_END */ /* NSIGNER_HEADERLESS_DECLS_END */