Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7369646fb | ||
|
|
b1f8076b1d |
@@ -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
Binary file not shown.
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user