Repository navigation
Conversation
The non-blocking resume check added a WC_MLDSA_NONBLOCK-only clause to the rho'' derivation condition. Without non-blocking support the condition reduced to if ((ret == 0)), which clang rejects with -Werror,-Wparentheses-equality.
The smallest memory signer expands y one polynomial at a time and never builds mldsa_vec_expand_mask(), the only caller of the batched NEON lanes wc_mldsa_gen_y_4/5/7_arm. With --enable-armasm they were still compiled and failed -Werror,-Wunused-function. Guard them like the AVX2 lanes, which already exclude WOLFSSL_MLDSA_SIGN_SMALLEST_MEM, and drop the smallest memory signer from the conditions of the SHA-3 instruction wrappers mlkem_sha3_blocksx3() and mlkem_shake256_blocksx3_seed_64(), which those lanes call. A pq-all row builds AArch64 NEON with sha3-crypto, the smallest memory signer and verifier, small memory key generation and no key check, the combination that leaves all three lane users out, and runs testwolfcrypt under qemu.
The AVX2 eta and t0/t1 vector encoders stored 16 bytes for each 12, 13 or 10 they produced, so the last store of a vector ran 4, 3 or 6 bytes past its end. The eta-4 encoder also took no count and always encoded six polynomials, which for the five polynomials of an ML-DSA-65 s1 wrote a whole polynomial past it. Callers only got away with it through the order they encode in and the padding on the key buffers. Regenerate wc_mldsa_asm.S and wc_mldsa_asm.asm from the scripts generator, which now stores the last chunk of each polynomial exactly and gives the eta-4 encoder a polynomial count, and pass the count. New test-only wrappers let an API test check each vector length against a reference encoding with guard bytes after the output.
The small memory key generation held the whole s2 vector only to encode it into the private key and decode one row of it again per row of t. Expand s2 a polynomial at a time into the scratch polynomial of A and encode it straight into the private key. The encoding lies past rho' and K, so it does not overwrite the seeds the later phases still read. mldsa_calc_t_row() decodes the row of s2 into the same scratch once the row of A is done, which also takes a polynomial out of mldsa_make_pub_key(). s1 is expanded with the C sampler, so the multi-lane s1/s2 generators are no longer built with WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM. That costs 4 to 8% of key generation time with the AArch64 NEON code; the C build changes by under 3%. Heap for ML-DSA-44/65/87 key generation drops from 10057/13129/17225 to 6985/8009/10057 bytes. Keys and signatures are unchanged.
The plain small memory signer allocated a hint polynomial w and an A sampling block of its own. w is only live in the hint loop, where the polynomial of A is dead, and the sampling block is only live while A is generated, when c is dead, and while c is sampled, when A is dead. Alias them. The PRECALC signers keep their layout, as their A is live throughout. Heap for ML-DSA-44/65/87 signing drops from 13897/16969/21321 to 12032/15104/19456 bytes. Signatures are unchanged.
The smallest memory signer now holds the k accumulators of w and three polynomials: y, A and c. - z is computed in place in the polynomial of A. - The A sampling block lives in c during the column walk, and y is the SampleInBall block. - w1 is not stored. Each row is encoded and absorbed into key->shake, which is idle once the column walk ends, and w is decomposed again in the hint loop instead of keeping w0. Heap for ML-DSA-44/65/87 signing drops from 9801/11849/14153 to 7168/9216/11264 bytes. Signatures are unchanged. WC_MLDSA_NONBLOCK now accepts WOLFSSL_MLDSA_SIGN_SMALLEST_MEM and WOLFSSL_MLDSA_VERIFY_SMALLEST_MEM. The signer yields after each column of the walk and after each rejected round, like the small memory signer. The accumulators persist in the working allocation; yChk and the result of the early y check move there too (40/48/64 bytes more heap), as a resumed call starts from a fresh stack. The w1 hash runs within one slice, since each call derives rho'' with key->shake first. The verifier already streamed z and needed only the guard lifted. A rejected attempt now wipes the signature buffer and the working polynomials before the next one, so a yield never exposes a rejected z. key->shake is now wiped rather than re-initialized on the way out: the re-initialization runs on every yield and dropped the key's heap and device id. A resumed call that fails before it picks up the working allocation still frees it, and an attempt the early y check rejected skips building the commitment. mldsa_nonblock_test now also signs eight messages with seeded key generation and signing through both paths and requires identical bytes; skipping one rejection candidate on resume passes the old test and fails this one. The yield counts must differ between messages, so at least one resume follows a rejection. Two pq-all rows build the smallest memory non-blocking paths, with and without the early checks and fault hardening.
c has only tau non-zero coefficients, yet the small memory verifier held NTT(c) as a full polynomial for the whole row loop. Sample c into the free w polynomial, keep its positions and signs (2 * tau bytes), and rebuild NTT(c) in the scratch polynomial of A at the start of each row, before A needs it. Heap for ML-DSA-44/65/87 verification drops from 8777/9801/12105 to 7831/8875/11201 bytes with VERIFY_SMALL_MEM and from 5705/5705/5961 to 4759/4779/5057 with VERIFY_SMALLEST_MEM. With VERIFY_NO_MALLOC the key pins the 120-byte sparse form instead of c, and the SampleInBall block shares key->h, so wc_MlDsaKey shrinks by 1040 bytes. The cost is one small NTT per row instead of one per verification, about 5 to 10% of the verify time in the C build. Subtracting c t1 directly after the inverse NTT saves the same memory but measured 12 to 20% slower. WC_MLDSA_CACHE_PUB_VECTORS keeps NTT(c) whole, since its t1 is cached in the NTT domain. The non-blocking verify no longer yields after the now empty NTT(c) phase. Two pq-all rows run the pinned sparse form, for all levels and for ML-DSA-44 alone; the existing VERIFY_NO_MALLOC row caches the public vectors and keeps NTT(c) whole.
There was a problem hiding this comment.
🔵 Needs a closer look
Cryptographic buffer aliasing, streamed commitment hashing, and resumable signing across C and assembly backends warrant independent review and cross-backend known-answer, sanitizer, and nonblocking validation, which was not performed here.
No open issues 🎉
What changed in this PR
This PR reduces heap usage in wolfCrypt’s ML-DSA small-memory paths and extends non-blocking support to the smallest-memory signer and verifier, while preserving key and signature formats.
Changes:
- Stream key-generation intermediates, reuse signing buffers, and store verification challenges sparsely.
- Enable resumable smallest-memory operations and correct AVX2 encoder write lengths.
- Add deterministic signature comparisons, encoder guard-byte tests, and build configurations for the new combinations.
| File | Description |
|---|---|
wolfssl/wolfcrypt/wc_mldsa.h |
Updates sparse verification storage and encoder declarations. |
wolfcrypt/test/test.c |
Compares seeded blocking and non-blocking signatures. |
wolfcrypt/src/wc_mldsa.c |
Implements memory reductions, resumable signing, and build fixes. |
wolfcrypt/src/wc_mldsa_asm.S |
Updates AVX2 encoders to respect vector counts and exact output lengths. |
wolfcrypt/src/wc_mldsa_asm.asm |
Applies matching AVX2 encoder changes for Windows assembly. |
tests/api/test_mldsa.h |
Registers the exact-length encoder test. |
tests/api/test_mldsa.c |
Adds encoding correctness and output-boundary checks. |
.github/configs/pq-all.json |
Adds smallest-memory, non-blocking, ARM, and sparse-verification coverage. |
Review effort: Balanced
|
|
@claude review |
There was a problem hiding this comment.
I reviewed this PR and found no bugs. It reworks ML-DSA key generation, signing and verification, and regenerates a large amount of x86 assembly, so a human with ML-DSA expertise should also look at it.
What was reviewed:
- Buffer aliasing in the small-memory signer and the restructured smallest-memory signer: z computed in place in A, w1 encoded per row into key->shake, and the new non-blocking yield and resume points.
- The sparse-c verifier and the
wc_MlDsaKeylayout change underMLDSA_VERIFY_SPARSE_C(cSparse replaces c, block is removed, key->h is reused as the SampleInBall block). - The AVX2 encoders' exact-length stores with the new
dargument, and the new encoder-length and seeded nonblock-vs-blocking tests. I only read these. No builds, tests or asm regeneration were run.
Extended reasoning...
The change touches core wolfCrypt ML-DSA code: wc_mldsa.c (+489/-), wc_mldsa.h (struct layout under the verify no-malloc and small-mem macros), and about 2000 lines of regenerated AVX2 assembly in wc_mldsa_asm.S and wc_mldsa_asm.asm. These are post-quantum signature paths where buffer aliasing, in-place computation and resumable non-blocking state have to stay correct across many build-macro combinations. The bug hunt ran to a dry streak and found nothing. Even so, the size of the change, its cryptographic sensitivity and the regenerated assembly call for a human look rather than automatic approval. A Copilot review is on the timeline, and its content was not visible here.
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #12113
Scan targets checked: wolfcrypt-src, wolfcrypt-bugs, wolfssl-src, wolfssl-bugs
Not reviewed (no scan target covers them): .github/configs/pq-all.json, tests/api/test_mldsa.h, wolfcrypt/src/wc_mldsa_asm.S, wolfcrypt/src/wc_mldsa_asm.asm
Coverage: 4 of 4 in-scope changed file(s) opened by the reviewer
Fenrir result: Approved ✅
No new issues found in the changed files.
Advisory only — this automated result does not count as a GitHub approval.
Review tier: Max
Description
Cuts heap in the ML-DSA small memory keygen, sign and verify paths, and lets
WC_MLDSA_NONBLOCKrun with the smallest memory signer and verifier. Keys and signatures are unchanged.FRDM-RW612 (Cortex-M33, 260 MHz, Zephyr, Thumb2 asm), ML-DSA-44/65/87, n=1000:
Keygen heap includes the key buffers (
WOLFSSL_MLDSA_DYNAMIC_KEYS). This PR saves 3072 / 5120 / 7168 B in small keygen, 1865 B in SMALL sign, 2633 / 2633 / 2889 B in SMALLEST sign and 946 / 926 / 904 B in both verifies. Peak stack, sign/verify: 2888 / 2240, 1536 / 1320 and 1048 / 1328 B.Changes
-Wparentheses-equalityand armasm unused gen_y build breaksTesting
mldsa_nonblock_testcompares seeded non-blocking and blocking signaturestest_mldsa_vec_encode_exact_lenchecks encoder lengths with guard bytes