9.2 KiB
Teensy 4.1 Signer — Remaining Fixes
Status as of v0.1.5 (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.5)
| 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 |
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).
What's still broken
1. sign ml-dsa-65 — hangs (rejection loop never accepts)
Symptom: sign with {"algorithm":"ml-dsa-65","index":0} hangs — no
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:
- Stack overflow: FIXED (v0.1.4). No more CFSR=0x82 hard fault.
- SHAKE re-squeeze infinite loop: FIXED (v0.1.5). Re-squeeze now uses a monotonic counter for domain separation.
- 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:
The 1000-iteration rejection loop in
mldsa65_sign.c::crypto_sign()
never accepts a candidate. Every iteration fails one of the norm checks
(z_reject, r0_reject, ct0_reject, hint_count > OMEGA). Since the
NTT core is correct, the divergence is in the full sign path — most
likely the matvec_mul_KL / scalar_mul_L / scalar_mul_K wrappers in
mldsa65_sign.c
or the decomposition/rejection checks themselves.
Next step:
- Build a host-side full
crypto_signpath with a fixed seed (deterministic DRBG so both host and Teensy produce the samerho/key/tr). - Add instrumentation to dump intermediate polynomials (
y,w,w1,c,z,r0) at each rejection iteration on both sides. - Compare to find the first divergence.
- The most likely culprits:
matvec_mul_KL: doespoly_ntt+poly_pointwise_invmontgomery+poly_invntt_tomontproduce the correctw = A * y?scalar_mul_L/scalar_mul_K: do they produce the correctc * s1/c * s2/c * t0?- The decomposition (
w1/w0split) or the rejection bounds (GAMMA1 - BETA,GAMMA2 - BETA,OMEGA).
2. decrypt OTP — plaintext mismatch
Symptom: decrypt with {"algorithm":"otp"} returns a result but the
decrypted plaintext does not match the original. encrypt works (produces a
ciphertext + pad offset).
Likely cause: The OTP pad offset advances differently on encrypt vs
decrypt, or the pad derivation from the seed produces a different offset
after the encrypt call. This is a pre-existing bug in
otp_pad.cpp /
otp_pad.h, unrelated to the
PQ/stack work.
Next step: Compare the pad offset before and after encrypt, and verify
decrypt uses the same offset. The OTP pad is XOR-based, so a mismatch means
the offset is wrong or the pad bytes differ.
Build memory (v0.1.5)
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, 20 pass)
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
Files changed (v0.1.1 → v0.1.5)
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 separationfirmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c—poly cto DMAMEM, rejection-loop counterfirmware/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)