- 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
16 KiB
Teensy 4.1 Signer — Remaining Fixes
Status as of v0.1.6 (2026-07-27)
Background
The Teensy 4.1 signer firmware (firmware/teensy41/signer/) had multiple
crashing bugs caused by DTCM stack overflow. The Teensy 4.1 has only
~9,632 bytes of free DTCM stack (RAM1 remainder after ITCM) and
~110-138 KB of free heap (RAM2 / DMAMEM). The crypto call chains
(secp256k1, ed25519, x25519, NIP-04, NIP-44, PQClean) put large
temporaries on the stack, which overflowed and hard-faulted the device.
What's fixed (v0.1.1 → v0.1.6)
| Verb(s) | Root cause | Fix | Version |
|---|---|---|---|
nostr_nip04_encrypt/decrypt |
secp256k1_ecmult_const allocated two 16-entry secp256k1_ge tables (~3.5 KB) on the stack per ECDH call |
ECMULT_CONST_GROUP_SIZE 5→4 (tables 16→8, ~1.7 KB saved) |
v0.1.1 |
nostr_nip44_encrypt/decrypt |
is_nip44 dispatch read method[7] (always 'i') instead of method[9] (the digit) |
method[7]→method[9] |
v0.1.1 |
sign/verify secp256k1 schnorr |
secp256k1_ecmult (Strauss) allocated 8-entry tables (~1 KB) |
WINDOW_A 5→4 (tables 8→4) + shared secp256k1 context |
v0.1.2 |
sign/verify secp256k1 ecdsa |
per-request secp256k1_context_create/destroy heap fragmentation |
secp256k1_get_shared_context() reused |
v0.1.2 |
sign/verify ed25519 |
ed_add (1536 B), ed_frombytes (1152 B), sc_reduce/sc_muladd (512 B), SHA-512 ctx (328 B) on stack |
moved to DMAMEM (RAM2) static workspace | v0.1.3 |
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_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
python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0 →
16 passed, 0 failed in one uninterrupted boot:
get_info ✅ secp256k1 pubkey ✅ ed25519 pubkey ✅ x25519 pubkey ✅
schnorr sign ✅ schnorr verify ✅ ecdsa sign ✅ ecdsa verify ✅
ed25519 sign ✅ ed25519 verify ✅ x25519 shared secret ✅ derive ✅
nostr_get_public_key ✅ nostr_sign_event ✅ nip04 round-trip ✅ nip44 round-trip ✅
python3 firmware/teensy41/test_signer.py → 20/24 (all classical + Nostr +
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
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
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).
3. encapsulate/decapsulate ml-kem-768 — was a cascade, NOT a bug
The v0.1.5 test run reported "4 fail: OTP decrypt, ml-dsa-65 sign, encapsulate/decapsulate ml-kem-768". Investigation in v0.1.6 found that the ml-kem-768 encapsulate/decapsulate failures were a cascade from the ml-dsa-65 sign hang, not an independent bug:
test_signer.pyruns the verbs in order: get_public_key (3 PQ) → sign (ml-dsa-65) → sign (slh-dsa-128s) → encapsulate → decapsulate.send_request()has a 30-second timeout. When ml-dsa-65 sign hung (the v0.1.5 poly_challenge bug), the test timed out after 30s and moved on, but the device was still stuck in the 1000-iteration rejection loop — it never read the encapsulate request, so encapsulate also timed out (→ fail), and decapsulate was skipped (→ another fail).- 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.
Host-side verification: New
host_test_mlkem768.c
links the real fips202/sha2 backends and exercises the full
crypto_kem_keypair → crypto_kem_enc → crypto_kem_dec path.
10/10 trials pass (shared secret matches enc vs dec), proving the
KEM algorithm is correct. The hardware failure was purely the cascade
from the ml-dsa-65 hang.
1. sign ml-dsa-65 — FIXED (v0.1.6)
Root cause: poly_challenge (FIPS 204 SampleInBall) in
mldsa65_poly.c
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.
Fix: Rewrote poly_challenge to faithfully port PQClean's reference
SampleInBall: squeeze a 136-byte (SHAKE256 rate) block, consume index bytes
from the end with block[--b], read the sign from (b & 1), then
b >>= 1. Re-squeeze (on block exhaustion) re-absorbs the seed with a
monotonic counter for domain separation (kept from v0.1.5).
Host-side verification: The new
host_test_mldsa65_sign.c
links the real fips202/sha2 backends
(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.
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), not a pad
derivation bug.
Fix:
- Added
otp_pad_seek(offset)to rewind the pad offset. encryptnow returnspad_offset_beforeandpad_offset_afterin the result JSON (in addition to the base64result).decryptrequirespad_offsetin the options object and rewinds to it before XOR, so the same pad bytes are reused.test_signer.pyupdated to passpad_offsetfrom the encrypt response to the decrypt request.
Build memory (v0.1.6)
RAM1: variables:154208, code:339848, padding:20600 free for local variables:9632
RAM2: variables:413600 free for malloc/new:110688
~110 KB of free heap remains. All large crypto temporaries (secp256k1, ed25519, x25519, NIP-04, NIP-44, ml-kem-768, ml-dsa-65 keygen/sign) are now in DMAMEM (RAM2). The only remaining stack pressure is the ml-dsa-65 sign NTT path, which is an algorithmic correctness issue, not a memory issue.
Test commands
# Build + flash
bash firmware/teensy41/build_signer.sh --flash
# Classical + Nostr suite (16 tests, all pass)
python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0
# NIP-04 + NIP-44 round-trip
python3 firmware/teensy41/test_nip04.py --port /dev/ttyACM0
# Full suite (24 tests, all pass after v0.1.6)
python3 firmware/teensy41/test_signer.py --port /dev/ttyACM0
# NTT host-side correctness test
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_ntt \
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/tests/host_test_ntt.c \
firmware/teensy41/signer/tests/host_test_ntt_stubs.c -lm
./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.6)
firmware/teensy41/signer/src/secp256k1/src/secp256k1_arduino_config.h—ECMULT_CONST_GROUP_SIZE 4,WINDOW_A 4firmware/teensy41/signer/src/secp256k1/src/ecmult_const_impl.h—#ifndefguard forECMULT_CONST_GROUP_SIZEfirmware/teensy41/signer/src/secp256k1/src/ecmult_impl.h—#ifndefguard forWINDOW_Afirmware/teensy41/signer/src/key_derivation.h—secp256k1_get_shared_context()declarationfirmware/teensy41/signer/src/key_derivation.cpp—secp256k1_get_shared_context()definitionfirmware/teensy41/signer/src/dispatch.cpp—is_nip44fix, shared context, crash diagnostics (stamp_op)firmware/teensy41/signer/src/ed25519.c—ed_add/ed_frombytes/sc_reduce/sc_muladd/SHA-512 ctx moved to DMAMEMfirmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c— NTT working polys +buf[4096]to DMAMEMfirmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c— enc/dec NTT polys to DMAMEMfirmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c— SHAKEout[]to DMAMEM, re-squeeze domain separation; v0.1.6:poly_challengerewritten to faithfully port PQClean's SampleInBall dual-purposebcounterfirmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c—poly cto DMAMEM, rejection-loop counter; v0.1.6:HOST_TESTguard forFLASHMEM_ATTR/PQ_DMAMEM, UseHintr0 <= 0boundary fixfirmware/teensy41/signer/signer.ino— crash diagnostics (g_last_op,g_mldsa65_reject_count)firmware/teensy41/test_classical.py— classical + Nostr hardware testfirmware/teensy41/test_nip04.py— NIP-04 + NIP-44 hardware testfirmware/teensy41/test_signer.py— full suite (reordered: classical+Nostr first, PQ last); v0.1.6: OTP decrypt now passespad_offsetfrom encrypt response
v0.1.6 (OTP + ml-dsa-65 sign)
firmware/teensy41/signer/src/otp_pad.h— addedotp_pad_seek()declarationfirmware/teensy41/signer/src/otp_pad.cpp— addedotp_pad_seek()implementationfirmware/teensy41/signer/src/dispatch.cpp— encrypt/decrypt:decryptrewinds viaotp_pad_seek(options.pad_offset); encrypt returnspad_offset_before/pad_offset_afterfirmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c—poly_challengerewritten (PQClean SampleInBall port)firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c—HOST_TESTguard, UseHintr0 <= 0boundary fixfirmware/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— 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 hangfirmware/teensy41/test_signer.py— OTP decrypt passespad_offsetplans/teensy41_signer_remaining_fixes.md— this document (v0.1.6 status)