Files
n_signer/plans/teensy41_signer_remaining_fixes.md
T

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/ttyACM016 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.py20/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:

  1. Build a host-side full crypto_sign path with a fixed seed (deterministic DRBG so both host and Teensy produce the same rho/key/tr).
  2. Add instrumentation to dump intermediate polynomials (y, w, w1, c, z, r0) at each rejection iteration on both sides.
  3. Compare to find the first divergence.
  4. The most likely culprits:
    • 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

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)