Compare commits

...
2 Commits
4 changed files with 200 additions and 418 deletions
@@ -240,14 +240,22 @@ FLASHMEM_ATTR void poly_uniform_4x(poly *r0, poly *r1, poly *r2, poly *r3,
}
/* 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
* re-absorb the seed WITH a monotonic re-squeeze counter appended, so each
* re-squeeze produces fresh bytes. Without this, re-absorbing the same
* seed produces the same output and the SampleInBall rejection loop
* can hang forever (the do/while never finds r <= i). This was the
* ml-dsa-65 sign hang. */
FLASHMEM_ATTR void poly_challenge(poly *c, const uint8_t seed[ML_DSA_65_CRHBYTES]) {
uint8_t buf[ML_DSA_65_CRHBYTES];
uint8_t buf[ML_DSA_65_CRHBYTES + 4];
uint8_t *out = s_poly_shake_out; /* 136*8 = 1088 B -> DMAMEM (was stack) */
size_t outlen = 136 * 8;
size_t pos = 0;
int signbit, b;
int i;
uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */
shake256ctx ctx;
memcpy(buf, seed, ML_DSA_65_CRHBYTES);
@@ -280,8 +288,15 @@ FLASHMEM_ATTR void poly_challenge(poly *c, const uint8_t seed[ML_DSA_65_CRHBYTES
uint32_t r;
do {
if (pos >= outlen) {
/* Re-squeeze with a monotonic counter so each re-squeeze
* produces fresh bytes (domain separation). */
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);
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES + 4);
shake256_squeeze(&ctx, out, outlen);
shake256_release(&ctx);
pos = 0;
@@ -295,14 +310,31 @@ FLASHMEM_ATTR void poly_challenge(poly *c, const uint8_t seed[ML_DSA_65_CRHBYTES
/* 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);
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++;
@@ -322,6 +354,7 @@ FLASHMEM_ATTR void poly_eta(poly *r, const uint8_t seed[ML_DSA_65_CRHBYTES],
size_t outlen = 136 * 4;
size_t pos = 0;
int coeff_idx = 0;
uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */
shake256ctx ctx;
memcpy(buf, seed, ML_DSA_65_CRHBYTES);
@@ -341,8 +374,13 @@ FLASHMEM_ATTR void poly_eta(poly *r, const uint8_t seed[ML_DSA_65_CRHBYTES],
while (coeff_idx < N) {
uint8_t t;
if (pos >= outlen) {
/* squeeze more with counter */
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)(j & 0xFF) + (uint8_t)(pos & 0xFF);
/* Re-squeeze with a monotonic counter so each re-squeeze
* produces fresh bytes (domain separation). */
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, sizeof(buf));
shake256_squeeze(&ctx, out, outlen);
@@ -372,6 +410,7 @@ FLASHMEM_ATTR void poly_uniform_gamma1(poly *r, const uint8_t seed[ML_DSA_65_CRH
size_t outlen = 136 * 8;
size_t bit_pos = 0;
int coeff_idx = 0;
uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */
shake256ctx ctx;
memcpy(buf, seed, ML_DSA_65_CRHBYTES);
@@ -396,8 +435,13 @@ FLASHMEM_ATTR void poly_uniform_gamma1(poly *r, const uint8_t seed[ML_DSA_65_CRH
int bits_to_read = 20 - bits_read;
if (bits_to_read > bits_avail) bits_to_read = bits_avail;
if (byte_idx >= outlen) {
/* Squeeze more */
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)(j & 0xFF) + (uint8_t)(bit_pos & 0xFF);
/* Re-squeeze with a monotonic counter so each re-squeeze
* produces fresh bytes (domain separation). */
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, sizeof(buf));
shake256_squeeze(&ctx, out, outlen);
Executable
BIN
View File
Binary file not shown.
+146 -408
View File
@@ -1,418 +1,156 @@
# Plan: Teensy 4.1 Signer Remaining Fixes
# Teensy 4.1 Signer Remaining Fixes
This document is the handoff and remediation plan for the unresolved issues in
the Teensy 4.1 signer after the first full hardware suite. It is based on the
current implementation under [`firmware/teensy41/signer/`](../firmware/teensy41/signer/),
the hardware suite in [`firmware/teensy41/test_signer.py`](../firmware/teensy41/test_signer.py),
and the latest reported result: **14 passing / 5 failing**.
**Status as of v0.1.5 (2026-07-27)**
## Current baseline
## Background
### Passing behavior
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.
- USB CDC length-prefixed JSON-RPC transport is synchronized after removing
runtime `Serial.print*` traffic from the framed channel.
- `get_info` works.
- secp256k1 `get_public_key`, Schnorr `sign`, `verify`, and `derive` work.
- X25519 `get_public_key` and `derive_shared_secret` work.
- SLH-DSA-128s key generation and signing work.
- ML-DSA-65 and ML-KEM-768 key generation work.
- Auto-generate and auto-approve test switches work.
- The persistent secp256k1 context prevents the prior `get_public_key` reboot.
## What's fixed (v0.1.1 → v0.1.5)
### Remaining failures
| 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 |
| Priority | Failure | Current symptom |
|---|---|---|
| P0 | Ed25519 implementation | Public key and signature are all zero; verify fails |
| P0 | OTP initialization and round-trip semantics | `encrypt` reports `otp pad not initialized`; the current single monotonic offset also cannot decrypt a just-produced ciphertext correctly without explicit offset handling |
| P1 | ML-DSA-65 performance | Signing exceeds the hardware-test timeout |
| P1 | ML-KEM-768 performance | Encapsulation exceeds the hardware-test timeout |
| P1 | Build reproducibility / memory headroom | The successful build relies on a custom linker command and leaves very little RAM1/stack headroom |
| P1 | Hardware-suite robustness | Current serial timeout logic can hang indefinitely after a timeout and the suite does not yet verify every returned signature on host/device |
| P2 | Debug configuration hygiene | `DEBUG_AUTO_GENERATE` and `DEBUG_AUTO_APPROVE` are enabled in source and need an explicit test/production configuration path |
| P2 | Documentation drift | [`README_crypto.md`](../firmware/teensy41/signer/src/README_crypto.md) still describes old filenames and an obsolete simple build command |
### Verified on hardware
## Fix order and rationale
`python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0`
**16 passed, 0 failed** in one uninterrupted boot:
```mermaid
flowchart TD
B[Freeze reproducible baseline] --> E[Fix Ed25519 correctness]
B --> O[Fix OTP initialization and offset contract]
E --> P[Replace PQ schoolbook multiplication]
O --> P
P --> M[Re-balance RAM and linker placement]
M --> T[Run full deterministic hardware suite]
T --> R[Restore production security defaults]
R --> D[Update documentation and release gate]
```
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 ✅
```
Correctness fixes come before performance work. PQ optimization must preserve
known-answer behavior, and memory placement must be revisited after the hot
paths are changed.
---
## P0 — Establish a reproducible baseline
### Problem
The current successful build uses the custom linker script
[`imxrt1062_t41_flashmem.ld`](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld),
but the normal Arduino command does not select it automatically. The old
[`build_opt.h`](../firmware/teensy41/signer/build_opt.h) referenced in prior
notes is not present. The LVGL configuration was also copied outside the
repository into the user's Arduino library directory.
### Actions
- [ ] Add a repository-owned build/flash script, for example
`firmware/teensy41/build_signer.sh`, that:
- copies [`lv_conf.h`](../firmware/teensy41/signer/lv_conf.h) to the location
required by the installed LVGL Arduino library, or injects an explicit
`LV_CONF_PATH` build define;
- compiles with the absolute custom linker-script path;
- prints the memory report;
- optionally uploads to the discovered Teensy port.
- [ ] Add a clean diagnostic build command that disables auto-generate and
auto-approve without editing source.
- [ ] Record the exact Arduino CLI, Teensy core, LVGL, and compiler versions.
- [ ] Preserve a hardware test log containing firmware git hash, generated npub,
build memory report, and per-verb timing.
### Gate
A clean checkout can build the same firmware with one documented command, and
that command reports non-negative free RAM1 and RAM2 without manual files
outside the repo.
---
## P0 — Fix Ed25519 correctness
### Evidence and likely root cause
- SLIP-0010 derivation is likely correct because X25519 works through the same
derivation framework in [`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp).
- Ed25519 fails specifically in [`ed25519_publickey()`](../firmware/teensy41/signer/src/ed25519.c:890)
and signing returns zero values.
- The current code is described as TweetNaCl-derived, but its Edwards arithmetic
is custom. In particular, [`ed_double()`](../firmware/teensy41/signer/src/ed25519.c:357)
implements doubling by calling the custom complete-addition function with the
same point. This needs validation against the original implementation.
- The current verify equation in [`ed25519_verify()`](../firmware/teensy41/signer/src/ed25519.c:945)
also needs comparison to RFC 8032; a sign convention error could remain even
after public-key generation is repaired.
### Actions
- [ ] Create a host-buildable Ed25519 KAT target using the same source file and
RFC 8032 vectors:
- empty-message vector;
- one-byte `0x72` vector;
- verify valid signatures and reject a one-bit mutation.
- [ ] Add a diagnostic API or temporary unit target that separately tests:
- SHA-512 of `abc`;
- scalar clamping;
- base-point encoding;
- `1 * B`, `2 * B`, and a known secret scalar;
- `ed25519_publickey()` against RFC 8032.
- [ ] Compare [`ed_add()`](../firmware/teensy41/signer/src/ed25519.c:327),
[`ed_double()`](../firmware/teensy41/signer/src/ed25519.c:357),
[`ed_scalarmult()`](../firmware/teensy41/signer/src/ed25519.c:365), and
[`ed_frombytes()`](../firmware/teensy41/signer/src/ed25519.c:410) line by
line against a known-good audited source.
- [ ] Prefer replacement over repairing ad hoc formulas if practical. Candidate
strategies:
- vendor the original TweetNaCl sign implementation unchanged and expose thin
wrappers;
- use a compact audited Ed25519 implementation already present in a trusted
dependency;
- keep the existing working X25519 implementation separate.
- [ ] Ensure secret and expanded-key buffers are zeroized on all paths.
- [ ] Add deterministic host and Teensy tests for derive → public key → sign →
verify using a fixed mnemonic.
### Gate
- `get_public_key(ed25519)` is nonzero and equals a known host result for the
fixed test mnemonic/index.
- RFC 8032 vectors pass.
- Device `sign(ed25519)` verifies both on device and with an independent host
library.
- A corrupted signature is rejected.
---
## P0 — Fix OTP initialization and round-trip semantics
### Initialization bug
[`otp_pad_init()`](../firmware/teensy41/signer/src/otp_pad.cpp:25) exists but is
not called after seed derivation in [`apply_mnemonic()`](../firmware/teensy41/signer/signer.ino:260).
This causes [`otp_pad_ready()`](../firmware/teensy41/signer/src/otp_pad.cpp:74)
to fail in the dispatch path.
### Deeper contract problem
The current [`otp_pad_apply()`](../firmware/teensy41/signer/src/otp_pad.cpp:53)
always consumes the current global offset. If encrypt advances from offset 0 to
N, an immediate decrypt will consume bytes N..2N rather than bytes 0..N. A
round-trip therefore requires the ciphertext to carry the start offset and the
decrypt operation to apply the pad at that explicit offset without reusing or
silently advancing the wrong cursor.
### Actions
- [ ] Call `otp_pad_init(g_seed, g_seed_len)` in
[`apply_mnemonic()`](../firmware/teensy41/signer/signer.ino:260) after seed
derivation succeeds and before `g_signer_ready` is set.
- [ ] On failure, wipe derived signer state and abort startup.
- [ ] Add `otp_pad_apply_at(buf, len, offset)` and retain a separate monotonic
allocation cursor for encryption.
- [ ] Define the wire contract explicitly:
- encrypt response returns ciphertext and the **start** `pad_offset`;
- decrypt request must include `pad_offset`;
- decrypt applies the exact pad region and does not allocate a new region;
- reject missing, malformed, out-of-range, or already-forbidden offsets.
- [ ] Decide whether decrypting previously emitted ciphertext on the same device
is an allowed diagnostic operation or whether OTP reuse policy forbids it
in production. If diagnostics allow it, guard that mode explicitly.
- [ ] Until the SDXC design lands, label the HKDF stream as a deterministic test
pad rather than a true one-time pad.
- [ ] Add power-cycle and exhaustion tests.
### Gate
- `otp_pad_ready()` is true after boot.
- Encrypt returns the start offset.
- Decrypt with that offset reproduces the plaintext.
- Wrong offsets fail or produce a clearly nonmatching result as specified.
- Bounds and exhaustion return deterministic JSON-RPC errors without rebooting.
---
## P1 — Replace ML-DSA-65 schoolbook multiplication
### Evidence
[`mldsa65_sign.c`](../firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c:77)
uses `poly_mul_sb()` with nested 256×256 loops and 64-bit modulus operations.
Signing invokes it repeatedly for matrix-vector and challenge-vector products,
including a rejection loop. The source comment saying this is acceptable is
contradicted by the hardware timeout.
A nominal NTT implementation already exists in
[`mldsa65_ntt.c`](../firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_ntt.c)
and pointwise primitives exist in
[`mldsa65_poly.c`](../firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c:57),
but the top-level implementation bypasses them.
### Actions
- [ ] Do not merely move `poly_mul_sb()` to ITCM as the final fix; that may
improve speed but preserves the wrong complexity and consumes scarce
RAM1.
- [ ] Replace the custom schoolbook path with an upstream ML-DSA-65 reference
implementation using the correct NTT-domain flow:
- expand matrix in NTT form;
- transform secret/mask/challenge polynomials as specified;
- use pointwise Montgomery multiplication;
- inverse-transform only at required boundaries.
- [ ] Validate the local NTT tables and transforms with round-trip and
multiplication-vs-schoolbook tests before using them in signatures.
- [ ] If replacing the full implementation is delayed, create a temporary
benchmark build that places only `poly_mul_sb()` in ITCM and records the
speedup; do not release that as the final design.
- [ ] Preserve deterministic key derivation from the mnemonic DRBG.
- [ ] Add keygen/sign/verify KATs and mutation rejection tests.
### Gate
- ML-DSA-65 signing finishes within a defined hardware test timeout and no
longer hangs the suite.
- The device-generated signature verifies independently on the host.
- The device rejects a mutated signature.
- Repeated operations do not corrupt DMAMEM work buffers.
---
## P1 — Replace ML-KEM-768 schoolbook multiplication
### Evidence
[`ml_kem_768_poly_mul_negacyclic()`](../firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c:86)
uses O(n²) schoolbook multiplication. Encapsulation and decapsulation call it
many times through [`indcpa.c`](../firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c:167).
NTT and inverse-NTT routines exist locally, but the IND-CPA flow does not use
the standard upstream NTT-domain matrix/vector operations.
### Actions
- [ ] Replace the custom multiplication path with the upstream ML-KEM-768
reference flow and constants, not an improvised cyclic-NTT twist.
- [ ] Keep matrix and secret vectors in NTT representation where specified.
- [ ] Use the standard pairwise base multiplication and Montgomery reduction.
- [ ] Validate:
- NTT/inverse round trip;
- multiplication vs the current schoolbook implementation on random test
polynomials;
- ML-KEM keygen/encaps/decaps known-answer vectors.
- [ ] Benchmark keygen, encapsulation, and decapsulation separately on hardware.
- [ ] Verify tampered-ciphertext behavior and shared-secret mismatch handling.
### Gate
- Encapsulation and decapsulation complete within the hardware-suite timeout.
- Encapsulation and decapsulation produce identical shared secrets.
- Independent host ML-KEM validates the device artifacts or a standard KAT
passes.
- Tampered ciphertext does not yield the original shared secret.
---
## P1 — Memory and linker hardening
### Problem
The current linker placement was introduced to recover stack space, but the
last report still left limited RAM1 headroom. Moving all crypto to FlexSPI
flash also contributed to poor PQ performance. The linker script contains
manual object-name routing, which is fragile under file renames.
### Actions
- [ ] After Ed25519 and PQ replacements, remeasure FLASH, ITCM, DTCM, RAM2,
heap, and minimum stack watermark.
- [ ] Keep large immutable tables in flash.
- [ ] Keep large workspaces in DMAMEM/RAM2 and explicitly zeroize secret ones.
- [ ] Put only measured hot, compact routines in ITCM.
- [ ] Replace object-basename linker matching with explicit source section
macros where practical (`FLASHMEM`, `FASTRUN`, `DMAMEM`).
- [ ] Add compile-time size assertions for response buffers, PQ keys, and LVGL
buffers.
- [ ] Add runtime guards/canaries or a stack watermark diagnostic build.
- [ ] Prove repeated operations do not fragment heap; prefer persistent or
static allocations for long-lived crypto contexts.
### Gate
- No negative or marginal memory report.
- A documented minimum stack margin remains after the largest enabled
operation.
- Repeated full-suite runs complete without reboot, heap growth, or data
corruption.
---
## P1 — Strengthen the hardware suite
### Problems in current test harness
- [`send_request()`](../firmware/teensy41/test_signer.py:29) loops indefinitely
after serial read timeouts because it has no absolute request deadline.
- Ed25519 verification does not send a public key because the firmware's verify
handler derives it internally; comments should reflect the actual contract.
- ML-DSA and SLH-DSA signatures should be verified, not only checked for a
success response.
- Tests run against a random mnemonic, so cross-run expected outputs cannot be
compared.
- Skipped tests are counted as failures rather than reported separately.
### Actions
- [ ] Add an absolute per-request deadline and classify timeout vs disconnect vs
invalid frame.
- [ ] Add `--quick`, `--full`, and per-algorithm timeout profiles.
- [ ] Add a deterministic test seed/mnemonic build mode. Keep random mode as a
separate smoke test.
- [ ] Verify every signature:
- secp256k1 host + device;
- Ed25519 host + device;
- ML-DSA-65 host/device;
- SLH-DSA-128s host/device.
- [ ] Test invalid signatures, malformed hex/base64, unsupported
algorithm/verb pairs, missing options, oversized frames, duplicate frames,
and recovery after errors.
- [ ] Test indices 0 and 1 and compare repeated deterministic derivations.
- [ ] Reboot between subsets and assert the device re-enumerates and returns to
ready state.
- [ ] Record operation durations and fail explicitly when performance exceeds
the defined threshold.
### Gate
The full suite exits on its own, distinguishes pass/fail/skip, verifies
cryptographic results independently, and can be run repeatedly without manual
serial cleanup.
---
## P2 — Debug and production profiles
### Actions
- [ ] Define build-time profiles rather than editing source:
- `test`: fixed or random auto-generated mnemonic, optional auto-approve;
- `production`: interactive mnemonic entry, approvals required, auth policy
explicit, debug serial disabled after transport starts.
- [ ] Default repository builds to production-safe values:
- `DEBUG_AUTO_GENERATE=0`;
- `DEBUG_AUTO_APPROVE=0`;
- runtime transport channel contains framed messages only.
- [ ] Ensure no mnemonic, seed, private key, or signature secret is logged.
- [ ] Add a conspicuous on-screen label when a test build has auto-approval.
### Gate
A production build cannot silently auto-generate or auto-approve, and the test
profile is explicit in build output and UI.
---
## P2 — Documentation updates
### Actions
- [ ] Update [`README_crypto.md`](../firmware/teensy41/signer/src/README_crypto.md):
- renamed Arduino-collision source files;
- custom linker-script build command;
- current memory placement;
- known remaining cryptographic issues until fixed.
- [ ] Update [`firmware/teensy41/README.md`](../firmware/teensy41/README.md)
with production/test build commands and latest suite status.
- [ ] Link this plan from the implementation and bring-up documents.
- [ ] Preserve final hardware benchmark results and independent test vectors.
### Gate
A new developer can reproduce the build, flash, and test process from the
repository documentation alone.
---
## Recommended implementation sequence
1. Build reproducibility script + deterministic test profile.
2. OTP initialization and explicit offset contract.
3. Replace/fix Ed25519 and pass RFC 8032 vectors.
4. Replace ML-KEM schoolbook multiplication and pass KAT + round trip.
5. Replace ML-DSA schoolbook multiplication and pass KAT + sign/verify.
6. Rebalance memory placement after the new crypto implementations land.
7. Expand and stabilize the hardware suite.
8. Restore production defaults and update documentation.
## Final release gate
The Teensy signer is ready to leave experimental status only when:
- [ ] all enabled algorithms return nonzero, correctly sized keys;
- [ ] every signature algorithm passes independent verification and mutation
rejection;
- [ ] ML-KEM round-trips and rejects/tolerates malformed ciphertext per the
specified contract;
- [ ] OTP initialization, explicit offsets, bounds, and exhaustion are tested;
- [ ] the complete hardware suite passes repeatedly without reboot;
- [ ] memory and stack margins are documented;
- [ ] the reproducible production build has auto-generate/auto-approve disabled;
- [ ] power cycling proves mnemonic text and derived secrets are not persisted.
`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()`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c:434)
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`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c:199)
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`](firmware/teensy41/signer/src/otp_pad.cpp) /
[`otp_pad.h`](firmware/teensy41/signer/src/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
```bash
# 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`](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_impl.h`](firmware/teensy41/signer/src/secp256k1/src/ecmult_impl.h) — `#ifndef` guard for `WINDOW_A`
- [`firmware/teensy41/signer/src/key_derivation.h`](firmware/teensy41/signer/src/key_derivation.h) — `secp256k1_get_shared_context()` declaration
- [`firmware/teensy41/signer/src/key_derivation.cpp`](firmware/teensy41/signer/src/key_derivation.cpp) — `secp256k1_get_shared_context()` definition
- [`firmware/teensy41/signer/src/dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp) — `is_nip44` fix, shared context, crash diagnostics (`stamp_op`)
- [`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/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_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/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_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)
+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) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 1
#define NSIGNER_VERSION_PATCH 4
#define NSIGNER_VERSION "v0.1.4"
#define NSIGNER_VERSION_PATCH 5
#define NSIGNER_VERSION "v0.1.5"
/* NSIGNER_HEADERLESS_DECLS_END */