Fix ML-DSA MakePublicKey derivation, verify guards, and ASN derivation testing - #10985
Fix ML-DSA MakePublicKey derivation, verify guards, and ASN derivation testing#10985stenslae wants to merge 1 commit into
Conversation
|
retest this please |
|
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
Frauschi
left a comment
There was a problem hiding this comment.
🐺 Skoll Code Review
Overall recommendation: REQUEST_CHANGES
Findings: 6 total — 4 posted, 2 skipped
Posted findings
- [High] wc_MlDsaKey_MakePublicKey mishandles buffers in the small-mem path (aSet, h, s1) —
wolfcrypt/src/wc_mldsa.c:10413-10665 - [Medium] wc_EccPrivateKeyDecode now unconditionally derives the public point —
wolfcrypt/src/asn.c:32722-32745 - [Low] Dead/empty guard branch before t0Scratch allocation —
wolfcrypt/src/wc_mldsa.c:10458-10467 - [Medium] Duplicated t = A o NTT(s1) + s2 kernel - extract shared helper with keygen —
wolfcrypt/src/wc_mldsa.c:10479-10635
Skipped findings
- [Medium] No test coverage for small-mem / cache-macro configurations of MakePublicKey
- [Medium] ML-DSA secret vectors left unzeroed in wc_MlDsaKey_MakePublicKey under SMALL_MEM + CACHE_PRIV_VECTORS
Review generated by Skoll via Claude/Codex
b6324ad to
b72a8ed
Compare
106f551 to
fa80057
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
Findings: 3
3 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
fa80057 to
e292a3c
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
Findings: 3
3 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
e292a3c to
ceb0a7d
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
ceb0a7d to
3d9668e
Compare
3d9668e to
2527a37
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
3bb246b to
38cd398
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
No new issues found in the changed files. ✅
Frauschi
left a comment
There was a problem hiding this comment.
🐺 Skoll Code Review
Overall recommendation: REQUEST_CHANGES
Findings: 28 total — 11 posted, 17 skipped
Posted findings
- [High] Three new ML-DSA tests use certs_test.h buffers without the NO_SIGN/NO_VERIFY guards that declare them —
tests/api/test_mldsa.c:7769-7770 - [High] Build break: new mldsa_calc_t_std() calls mldsa_expand_a(), which is not compiled in MAKE_KEY_SMALL_MEM builds —
wolfcrypt/src/wc_mldsa.c:7767-7788 - [High] Build break: outer guard for mldsa_vec_decode_eta_bits not widened, so the new CheckKey/MakePublicKey arm is dead —
wolfcrypt/src/wc_mldsa.c:1279 - [High] New OOM test matches allocations by exact byte size; WOLFSSL_CHECK_MEM_ZERO inflates every size by 16, failing the all-check-mem-zero CI job —
tests/api/test_mldsa.c:8079-8110 - [Medium] ECC decode-time public key derivation is inert in default hardened builds —
wolfcrypt/src/asn.c:32632-32637 - [Medium] Best-effort ML-DSA derivation converts MEMORY_E into BAD_FUNC_ARG —
wolfcrypt/src/wc_mldsa.c:11951-11959 - [Medium] MakePublicKey always doubles the matrix work with an unconditional CheckKey —
wolfcrypt/src/wc_mldsa.c:10627-10687 - [Medium] WOLFSSL_MLDSA_NO_CHECK_KEY no longer removes wc_MlDsaKey_CheckKey —
wolfcrypt/src/wc_mldsa.c:11808-11809 - [Medium] Export/DER functions now mutate key state, breaking their read-only contract —
wolfcrypt/src/wc_mldsa.c:12044-12049 - [Medium] No opt-out for the new decode-time ECC scalar multiply —
wolfcrypt/src/asn.c:32607-32614 - [Medium] Derived ML-DSA public key is never checked against the stored tr hash —
wolfcrypt/src/wc_mldsa.c:10622-10623
Skipped findings
- [Medium] OOM tests leak custom allocators when an earlier assertion fails
- [Medium] ECC OOM test infers the derivation allocation from an allocation-count delta between two different DER inputs
- [Medium] Unrelated WOLFSSL_MLDSA_SMALL overflow fixes shipped without dedicated tests
- [Medium] key->aSet restored to 1 over a partially-overwritten matrix A after a CheckKey failure
- [Medium] MAKE_KEY_SMALL_MEM derivation self-check never validates the derived key against the private key's stored t0
- [Medium] ecc_make_pub_ex no longer promotes key->type on WC_PENDING_E, stalling async ECDSA verify with a private-only key
- [Low] File-static helper uses the reserved wc_ public-API prefix
- [Low] Duplicate pubKeySet check in wrappers and mldsa_verify_with_mu
- [Low] Early bare returns in mldsa_verify_with_mu deviate from the file's single-exit style
- [Low] ECC best-effort public-key derivation is inert in the default hardened build, so no in-tree caller benefits
- [Low] WOLFSSL_MLDSA_NO_CHECK_KEY is no longer honoured: wc_MlDsaKey_CheckKey is force-compiled and force-executed
- [Low] wc_MlDsaKey_MakePublicKey holds its full scratch allocation across the CheckKey call, roughly doubling peak heap
- [Low] NULL dereference in test_mldsa_make_public_key when the key allocation fails
- [Low] tests/suites.c ML-DSA/SLH-DSA TLS 1.3 blocks gained !WOLFSSL_MLDSA_NO_SIGN but still lack !WOLFSSL_MLDSA_NO_VERIFY
- [Low] ECC OOM test derives its failure index from a different DER input, so it can pass without exercising the derivation path
- [Info] Derivation helper retries wc_ecc_make_pub_ex on WC_PENDING_E, contradicting the documented async contract, and its async guard does not match the WC_PENDING_E producer
- [Info] wc_MlDsaKey_MakePublicKey allocates key->s1 with params->aSz and skips the XMEMSET the import path performs
Review generated by Skoll via Claude/Codex
732b1ea to
c189863
Compare
c189863 to
81bbb62
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
Findings: 2
2 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
81bbb62 to
9e69c56
Compare
…ubkey derivation logs
9e69c56 to
afe7a9f
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #10985
Scan targets checked: wolfcrypt-bugs, wolfcrypt-rs-bugs, wolfcrypt-src, wolfssl-bugs, wolfssl-src
Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
Frauschi
left a comment
There was a problem hiding this comment.
🐺 Skoll Code Review
Overall recommendation: REQUEST_CHANGES
Findings: 23 total — 20 posted, 3 skipped
Posted findings
- [High] New ECC derive test's #if is missing four terms, so it fails in the opt-out, no-key-export and crypto-cb-only builds —
tests/api/test_asn.c:2854-2860 - [High] test_wc_MldsaDecisionCoverage2 PUBLIC_KEY_E expectation is not gated on the conditions that enable the KeyToDer auto-derive; fails with --enable-mldsa=sign,verify —
tests/api/test_mldsa.c:31734-31749 - [High] t0Scratch allocated without the 8-byte AVX2 tail slack, giving a 3-byte heap overflow on every private-only public-key derivation —
wolfcrypt/src/wc_mldsa.c:10694-10749 - [High] Fault-harden verify buffers packed back-to-back: 6-byte heap overflow and an always-failing self-check under AVX2 —
wolfcrypt/src/wc_mldsa.c:10777-10813 - [Medium] wc_MlDsaKey_KeyToDer lost its BAD_FUNC_ARG default: an unrecognised key->level now returns 0 —
wolfcrypt/src/wc_mldsa.c:13436-13488 - [Medium] Best-effort ECC public-key derivation is a no-op in every default (hardened) build —
wolfcrypt/src/asn.c:33143-33150 - [Medium] Unconditional extra base-point scalar multiply on every ECC private-key decode, including paths that discard the key immediately —
wolfcrypt/src/asn.c:33290-33294 - [Medium] EccDerivePubBestEffort is duplicated verbatim into asn_orig.c although asn_orig.c is #included into asn.c (same translation unit) —
wolfcrypt/src/asn_orig.c:7598-7654 - [Medium] Async ECC derive: retry loop keys off the wrong enable macro, and ecc_make_pub_ex no longer upgrades key->type on WC_PENDING_E —
wolfcrypt/src/asn.c:33152-33166 - [Medium] ECC OOM sub-test derives its baseline allocation count from a different DER blob, so the whole block can silently no-op —
tests/api/test_asn.c:2957-3001 - [Medium] WC_MLDSA_FAULT_HARDEN silently overrides WOLFSSL_MLDSA_NO_CHECK_KEY, and wc_mldsa.h re-derives the condition instead of consuming the new macro —
wolfcrypt/src/wc_mldsa.c:254-263 - [Medium] Widened CheckKey guard newly exposes a NULL key->a dereference to configurations where CheckKey previously did not exist —
wolfcrypt/src/wc_mldsa.c:12036-12066 - [Medium] Coverage gaps: PublicKeyToDer auto-derive, ML-DSA 65/87 OOM, cached-vector invalidation, MakePublicKey small-mem and fault-harden paths —
tests/api/test_mldsa.c:7761-8290 - [Medium] Under WOLF_CRYPTO_CB_FIND the devId gate is ineffective, so every SEC1 private-key decode fires an unsolicited EC_MAKE_PUB crypto callback —
wolfcrypt/src/asn.c:33136-33141 - [Low] Documentation gaps for the new decode-time derivation: undocumented macro, inaccurate guard comment, un-mirrored Japanese doxygen —
wolfcrypt/src/asn.c:33118 - [Low] MakePublicKey invalidates the private-vector cache in MAKE_KEY_SMALL_MEM builds where it never touched it —
wolfcrypt/src/wc_mldsa.c:10715-10720 - [Low] Unguarded struct pokes dereference key after a failed allocation or a skipped Init —
tests/api/test_mldsa.c:7787-7789 - [Low] test_mldsa_make_public_key_tr_mismatch comment claims to exercise the WC_MLDSA_FAULT_HARDEN recheck, which that path can never reach —
tests/api/test_mldsa.c:8248-8253 - [Low] ECC private-key decode now leaves an unblinded plaintext copy of the private scalar in key->ku (WOLFSSL_ECC_BLIND_K builds) —
wolfcrypt/src/asn.c:33153-33165 - [Info] *New PUBLIC_KEY_E early return in mldsa_verify_with_mu leaves res untouched, unlike every other error path in the function —
wolfcrypt/src/wc_mldsa.c:9837-9839
Skipped findings
- [Low] Coding-standard violations: comment punctuation, over-80-column lines and new bare scope blocks
- [Low] WC_RNG is not zeroed before use, so the unconditional wc_FreeRng at the end operates on an uninitialised stack struct when wc_InitRng fails
- [Info] Fault-harden branch documents a false invariant about matrix A after a failed CheckKey, and its aSet assignment is dead code
Review generated by Skoll via Claude/Codex
| int test_wc_EccPrivateKeyDecode_derive_pub(void) | ||
| { | ||
| EXPECT_DECLS; | ||
| #if !defined(NO_ASN) && defined(HAVE_ECC) && !defined(NO_ECC_MAKE_PUB) && \ |
There was a problem hiding this comment.
🟠 [High] New ECC derive test's #if is missing four terms, so it fails in the opt-out, no-key-export and crypto-cb-only builds
🚫 BLOCK test
Sites: tests/api/test_asn.c:2854-2860; tests/api/test_asn.c:2883-2887; tests/api/test_asn.c:2853-2860; tests/api/test_asn.c:2853-2917
The production helper is compiled only when WOLFSSL_ECC_DERIVE_PUB_BEST_EFFORT is defined, and its #if (wolfcrypt/src/asn.c:33116-33122 and wolfcrypt/src/asn_orig.c:7601-7607) starts with !defined(NO_ECC_MAKE_PUB) && !defined(WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE) && .... The new test copies 12 of the 13 terms (NO_ECC_MAKE_PUB, ATECC508A/608A, TA100, CRYPTOCELL, SILABS, KCAPI, QNX_CAAM, IMXRT1170_CAAM, HAVE_FIPS, HAVE_SELFTEST) but silently drops the new opt-out macro. With WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE defined — the only documented way to turn the new behaviour off — wc_EccPrivateKeyDecode() never calls EccDerivePubBestEffort(), the key stays ECC_PRIVATEKEY_ONLY, and the test still runs and asserts ExpectIntEQ(privOnlyKey.type, ECC_PRIVATEKEY) at lines 2898 and 2917. Guaranteed test failure in a supported configuration introduced by this same PR.
Also, at tests/api/test_asn.c:2883-2887 (High, review scan) — New ECC derive test calls wc_EccPrivateKeyToDer/wc_ecc_export_x963 without requiring HAVE_ECC_KEY_EXPORT:
wc_EccPrivateKeyToDer is defined inside #ifdef HAVE_ECC_KEY_EXPORT (wolfcrypt/src/asn.c:33400-33674) and wc_ecc_export_x963 is declared inside #ifdef HAVE_ECC_KEY_EXPORT (wolfssl/wolfcrypt/ecc.h:931-940). settings.h:3589-3592 only defines HAVE_ECC_KEY_EXPORT when NO_ECC_KEY_EXPORT is absent, and NO_ECC_KEY_EXPORT is a shipped option used by examples/configs/user_settings_min_ecc.h:90 and examples/configs/user_settings_baremetal.h:111. The new test's #if permits HAVE_ECC && USE_CERT_BUFFERS_256 with NO_ECC_KEY_EXPORT set, in which case tests/api/test_asn.c no longer compiles (implicit declaration) or fails to link. No other test in this file uses either symbol, so there is no existing guard being inherited.
Also, at tests/api/test_asn.c:2853-2860 (Medium, bugs scan) — New ECC derive test's #if omits WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE, so the PR's own opt-out macro fails its own test:
This PR introduces WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE as the documented opt-out for the new derive-on-decode behaviour: it appears in the WOLFSSL_ECC_DERIVE_PUB_BEST_EFFORT feature guard in both asn.c:33118 and asn_orig.c:7601, and defining it compiles the helper and its call site out entirely.
The new test's #if block mirrors HAVE_ECC_MAKE_PUB's exclusion list (NO_ECC_MAKE_PUB, ATECC508A/608A, TA100, CRYPTOCELL, SILABS_SE_ACCEL, KCAPI_ECC, QNX/IMXRT CAAM, FIPS, selftest) but does not exclude WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE.
So in an opt-out build the test still compiles and runs, wc_EccPrivateKeyDecode() correctly leaves the key as ECC_PRIVATEKEY_ONLY, and the unconditional assertion ExpectIntEQ(privOnlyKey.type, ECC_PRIVATEKEY); at test_asn.c:2917 fails, along with the export/compare block that follows it. The #ifndef ECC_TIMING_RESISTANT branch at test_asn.c:2898 fails the same way.
Also raised by the bugs scan (tests/api/test_asn.c:2853-2860):
The test calls wc_ecc_export_x963() at test_asn.c:2884, 2900 and 2919. That prototype is declared inside #ifdef HAVE_ECC_KEY_EXPORT (wolfssl/wolfcrypt/ecc.h:932-940). HAVE_ECC_KEY_EXPORT is auto-defined by settings.h:3589-3593 unless the user sets NO_ECC_KEY_EXPORT.
The test's guard does not include defined(HAVE_ECC_KEY_EXPORT), so a NO_ECC_KEY_EXPORT build compiles the test body against an undeclared function — an implicit-declaration error under C99+/-Werror. Narrow config, but it is a supported opt-out.
Also, at tests/api/test_asn.c:2853-2917 (Low, bugs scan) — New ECC derive test's #if omits WOLF_CRYPTO_CB_ONLY_ECC, where the derive can never succeed:
The test body mirrors HAVE_ECC_MAKE_PUB's own exclusion list (ecc.c:322-328) but does not exclude WOLF_CRYPTO_CB_ONLY_ECC.
In a WOLF_CRYPTO_CB_ONLY_ECC build HAVE_ECC_MAKE_PUB is still defined, so the helper compiles, but ecc_make_pub_ex()'s software branch is replaced by err = NO_VALID_DEVID (ecc.c:5736-5743) for any key with INVALID_DEVID and no device that claims the op. The derive therefore always fails, the key stays ECC_PRIVATEKEY_ONLY, and the assertion at test_asn.c:2917 (ExpectIntEQ(privOnlyKey.type, ECC_PRIVATEKEY);, reached after wc_ecc_set_rng() at 2911 so the RNG gate is satisfied) fails, together with the export/compare block that follows.
Suggestion:
| #if !defined(NO_ASN) && defined(HAVE_ECC) && !defined(NO_ECC_MAKE_PUB) && \ | |
| #if !defined(NO_ASN) && defined(HAVE_ECC) && !defined(NO_ECC_MAKE_PUB) && \ | |
| !defined(WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE) && \ | |
| defined(HAVE_ECC_KEY_EXPORT) && \ | |
| defined(USE_CERT_BUFFERS_256) && !defined(HAVE_FIPS) && \ |
Recommendation: Add !defined(WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE) to the test guard. Better: publish WOLFSSL_ECC_DERIVE_PUB_BEST_EFFORT from a public header (e.g. wolfssl/wolfcrypt/asn_public.h) so the test keys off one symbol instead of duplicating a 13-term guard that has already drifted once.
[tests/api/test_asn.c:2883-2887] Add defined(HAVE_ECC_KEY_EXPORT) to the test's #if guard (and to the OOM-callback definition guard above it, which shares the same term list).
[tests/api/test_asn.c:2853-2860] Add !defined(WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE) to the test's #if. Cleaner still: promote WOLFSSL_ECC_DERIVE_PUB_BEST_EFFORT out of asn.c/asn_orig.c into a header so the test keys off a single symbol instead of re-deriving a 12-term guard that must be kept in sync in three places.
[tests/api/test_asn.c:2853-2917] Add !defined(WOLF_CRYPTO_CB_ONLY_ECC) to the test's #if and to the WOLFSSL_ECC_DERIVE_PUB_BEST_EFFORT guard in asn.c and asn_orig.c.
| @@ -31203,15 +31734,19 @@ int test_wc_MldsaDecisionCoverage2(void) | |||
| #if !defined(WOLFSSL_MLDSA_NO_ASN1) && defined(WOLFSSL_MLDSA_PUBLIC_KEY) | |||
There was a problem hiding this comment.
🟠 [High] test_wc_MldsaDecisionCoverage2 PUBLIC_KEY_E expectation is not gated on the conditions that enable the KeyToDer auto-derive; fails with --enable-mldsa=sign,verify
🚫 BLOCK test
wc_MlDsaKey_KeyToDer() only auto-derives under #if !defined(WOLFSSL_MLDSA_VERIFY_ONLY) && !defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY) (wolfcrypt/src/wc_mldsa.c:13442-13447), but the changed assertion sits in a block guarded only by !defined(WOLFSSL_MLDSA_NO_ASN1) && defined(WOLFSSL_MLDSA_PUBLIC_KEY) nested in defined(WOLFSSL_MLDSA_PRIVATE_KEY) && !defined(WOLFSSL_NO_ML_DSA_44). Per dilithium.h:317-330, WOLFSSL_MLDSA_NO_MAKE_KEY alone leaves both WOLFSSL_MLDSA_PRIVATE_KEY and WOLFSSL_MLDSA_PUBLIC_KEY defined and does not derive WOLFSSL_MLDSA_VERIFY_ONLY, so the block still compiles but KeyToDer falls straight to the prvKeySet && pubKeySet check and returns BAD_FUNC_ARG, not PUBLIC_KEY_E. configure.ac:2153 initialises ENABLED_MLDSA_MAKE_KEY=no and configure.ac:8156-8158 emits -DWOLFSSL_MLDSA_NO_MAKE_KEY, so ./configure --enable-mldsa=sign,verify is exactly that configuration. A WOLFSSL_MLDSA_ASSIGN_KEY user_settings build fails the same way.
Also raised by the bugs scan (tests/api/test_mldsa.c:31733-31750):
The PR changes the expected result of wc_MlDsaKey_KeyToDer() on a private-only key from BAD_FUNC_ARG to PUBLIC_KEY_E, on the reasoning that KeyToDer now auto-derives the public key and the zero-filled test key fails the 'tr' hash compare.
That reasoning only holds when the auto-derive is compiled in. The derive block in wc_MlDsaKey_KeyToDer() (wc_mldsa.c:13442-13447) is guarded by !WOLFSSL_MLDSA_VERIFY_ONLY && !WOLFSSL_MLDSA_ASSIGN_KEY && !WOLFSSL_MLDSA_NO_MAKE_KEY. The test block is guarded only by !defined(WOLFSSL_MLDSA_NO_ASN1) && defined(WOLFSSL_MLDSA_PUBLIC_KEY).
Concrete failing config: define WOLFSSL_MLDSA_NO_MAKE_KEY with sign and verify left enabled (a sign+verify, no-keygen build). Then:
WOLFSSL_MLDSA_PUBLIC_KEYis still defined, via!NO_MAKE_KEY || !NO_VERIFYat dilithium.h:317-319, so the test block compiles and runs.wc_MlDsaKey_KeyToDer()skips the derive,key->pubKeySetstays 0, and control reaches theelse { ret = BAD_FUNC_ARG; }at wc_mldsa.c:13483.- The test asserts
PUBLIC_KEY_Eand fails.
The same applies to WOLFSSL_MLDSA_ASSIGN_KEY builds.
Suggestion:
| #if !defined(WOLFSSL_MLDSA_NO_ASN1) && defined(WOLFSSL_MLDSA_PUBLIC_KEY) | |
| #if !defined(WOLFSSL_MLDSA_VERIFY_ONLY) && \ | |
| !defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY) | |
| ExpectIntEQ(wc_MlDsaKey_KeyToDer(&key, der, (word32)sizeof(der)), | |
| WC_NO_ERR_TRACE(PUBLIC_KEY_E)); | |
| #else | |
| ExpectIntEQ(wc_MlDsaKey_KeyToDer(&key, der, (word32)sizeof(der)), | |
| WC_NO_ERR_TRACE(BAD_FUNC_ARG)); | |
| #endif |
Recommendation: Select the expected value with a nested #if mirroring the auto-derive guard, keeping the BAD_FUNC_ARG assertion in the #else arm so both configurations stay covered.
| } | ||
| #endif /* WC_MLDSA_FIXED_ARRAY && WC_MLDSA_CACHE_PRIV_VECTORS && !SMALL_MEM */ | ||
|
|
||
| if (ret == 0) { |
There was a problem hiding this comment.
🟠 [High] t0Scratch allocated without the 8-byte AVX2 tail slack, giving a 3-byte heap overflow on every private-only public-key derivation
💡 SUGGEST
The new wc_MlDsaKey_MakePublicKey() allocates the t0 encoding scratch buffer at exactly the logical encoded size and hands it to mldsa_vec_encode_t0_t1():
t0ScratchSz = (unsigned int)params->k * (MLDSA_D * MLDSA_N / 8); /* k * 416 */
t0Scratch = (byte*)XMALLOC(t0ScratchSz, key->heap, DYNAMIC_TYPE_MLDSA);
...
mldsa_vec_encode_t0_t1(t, params->k, t0Scratch, t1);
t0ScratchSz is correct for the C encoder (mldsa_vec_encode_t0_t1_c, wc_mldsa.c:1382, writes 13 bytes per 8 coefficients = 416 bytes/poly). But under USE_INTEL_SPEEDUP the dispatcher at wc_mldsa.c:1490-1503 routes to wc_mldsa_vec_encode_t0_t1_avx2(), which emits a 16-byte store for every 13-byte group and advances only 13 (wolfcrypt/src/wc_mldsa_asm.S:21860-21863):
vmovdqu %xmm2, (%rdx)
addq $13, %rdx
I walked the asm loop to its back-edge (addq $0x400,%rdi; jnz L_mldsa_encode_t0_t1_avx2_loop, wc_mldsa_asm.S:~22540) and there is no tail special-case: the final store of the final polynomial lands at t0Scratch + k*416 - 13 and writes 16 bytes, i.e. 3 bytes past the allocation. For ML-DSA-44 (k=4): size 1664, last store covers offsets 1651..1666. The spilled bytes are zero because lanes 13-15 of L_mldsa_encode_t0_t1_avx2_shuff_13_even/_odd are 0xff (wc_mldsa_asm.S:21752-21771), so it is a 3-byte zero-write past the heap chunk.
This is exactly the over-store that the rest of the file already compensates for: byte k[MLDSA_MAX_KEY_SIZE+8] / byte p[MLDSA_MAX_PUB_KEY_SIZE+8] under USE_INTEL_SPEEDUP (wc_mldsa.h:615-623), and secSz += 8; / pubSz += 8; in mldsa_alloc_priv_buf() / mldsa_alloc_pub_buf() (wc_mldsa.c:423-425, 457-459). t0Scratch is the first t0 destination introduced without that slack. The t1 destination here (key->p + 32) is safe because key->p already carries the +8.
Trigger: any --enable-intelasm x86-64 build with AVX2 at runtime that decodes a private-only ML-DSA key and then calls wc_MlDsaKey_ExportPubRaw(), wc_MlDsaKey_PublicKeyToDer() or wc_MlDsaKey_KeyToDer() — all three now auto-derive. ASAN or a hardened allocator will trap.
Recommendation: Allocate the same 8-byte tail slack the key buffers already carry:
t0Scratch = (byte*)XMALLOC(t0ScratchSz + 8, key->heap,
DYNAMIC_TYPE_MLDSA);
and widen the cleanup to ForceZero(t0Scratch, t0ScratchSz + 8);. Gating the +8 on USE_INTEL_SPEEDUP would also work, but unconditional padding matches how the static arrays are declared.
| #ifdef WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM | ||
| /* CheckKey() expands full matrix A, defeating small-mem. | ||
| * Re-derive streaming and compare t0/t1 bytes instead. */ | ||
| { |
There was a problem hiding this comment.
🟠 [High] Fault-harden verify buffers packed back-to-back: 6-byte heap overflow and an always-failing self-check under AVX2
💡 SUGGEST
The WC_MLDSA_FAULT_HARDEN + WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM re-derivation path packs the t0 and t1 verification buffers into a single allocation with no slack between or after them:
t1Sz = (unsigned int)params->pkSz - MLDSA_PUB_SEED_SZ; /* k * 320 */
t0Verify = (byte*)XMALLOC(t0ScratchSz + t1Sz, key->heap, DYNAMIC_TYPE_MLDSA);
t1Verify = t0Verify + t0ScratchSz;
...
mldsa_vec_encode_t0_t1(t, params->k, t0Verify, t1Verify);
Both sizes are logically right, but the AVX2 encoder writes each 13-byte t0 group and each 10-byte t1 group with a 16-byte vmovdqu (wc_mldsa_asm.S:21860/21876). Two distinct defects follow, both provable by offset arithmetic. For ML-DSA-44 (k=4, t0ScratchSz=1664, t1Sz=1280, allocation 2944, valid 0..2943):
-
Trailing overflow. The last t1 store is at
t1Verify + 1280 - 10= offset 2934 and writes 16 bytes, covering 2934..2949 — 6 bytes past the end of the allocation. -
Cross-buffer clobber, so the check can never pass. The last t0 store is at offset
1664 - 13= 1651 and writes 16 bytes covering 1651..1666.t1Verifystarts at 1664, so offsets 1664..1666 (t1Verify[0..2]) are overwritten with the zero-padded tail of the t0 lane. Because the asm iterates polynomial-by-polynomial and stores t0 before t1 within each group, this final t0 spill happens long aftert1Verify[0..2]were legitimately written in the first polynomial. The subsequentXMEMCMP(t1Verify, t1, t1Sz)therefore compares three zeroed bytes against the real packed t1 bytes and fails unlesst1[0..2]happen to be zero (~2^-24). Net effect: in an AVX2 build withWC_MLDSA_FAULT_HARDENandWOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM,wc_MlDsaKey_MakePublicKey()returnsPUBLIC_KEY_Efor a perfectly valid key — the opt-in fault-hardening check is effectively always-fail.
No pre-existing caller places a t0 and a t1 destination adjacently or without the +8 slack, so both issues are new in this diff.
Recommendation: Pad both sub-buffers so the 16-byte stores stay inside the allocation and t0 cannot bleed into t1:
t0Verify = (byte*)XMALLOC(t0ScratchSz + 8 + t1Sz + 8,
key->heap, DYNAMIC_TYPE_MLDSA);
...
t1Verify = t0Verify + t0ScratchSz + 8;
...
ForceZero(t0Verify, t0ScratchSz + 8 + t1Sz + 8);
Two separate XMALLOCs work equally well provided each carries the 8-byte tail slack.
| int wc_MlDsaKey_KeyToDer(wc_MlDsaKey* key, byte* output, word32 len) | ||
| { | ||
| int ret = WC_NO_ERR_TRACE(BAD_FUNC_ARG); | ||
| int ret = 0; |
There was a problem hiding this comment.
🟡 [Medium] wc_MlDsaKey_KeyToDer lost its BAD_FUNC_ARG default: an unrecognised key->level now returns 0
💡 SUGGEST bug
The initializer changed from int ret = WC_NO_ERR_TRACE(BAD_FUNC_ARG); to int ret = 0; so the derive guard could run. But the if (key->level == WC_ML_DSA_44) ... else if 65 ... else if 87 chain has no terminating else, and the previous code relied on the BAD_FUNC_ARG initializer as the default. With the new initializer, a key that has prvKeySet && pubKeySet but a level outside {44, 65, 87} (and no matching FIPS204-draft level) falls through every branch and the function returns 0. Since SetAsymKeyDer() returns a positive byte count on success, a return of 0 is indistinguishable from 'encoded 0 bytes' for the standard if (ret < 0) fail; caller pattern — a size-query caller would then allocate nothing and a write caller would treat an empty DER as valid. Reachability is low (the import paths reject bad levels), but this removes a defensive default that was there before this diff. Note wc_MlDsaKey_ExportPubRaw at wc_mldsa.c:12193-12196 keeps exactly such an else { ret = BAD_FUNC_ARG; } arm, so the convention in this file is to have one.
Also, at wolfcrypt/src/wc_mldsa.c:13436-13488 (Low, bugs scan) — wc_MlDsaKey_KeyToDer lost its BAD_FUNC_ARG default: an unmatched key->level now returns 0 (success, zero bytes):
The restructure changed the initializer from int ret = WC_NO_ERR_TRACE(BAD_FUNC_ARG); to int ret = 0; so the new derive step can run first, and wrapped the body in if (ret == 0) { if (key && prvKeySet && pubKeySet) { <level chain> } else { ret = BAD_FUNC_ARG; } }.
The added else { ret = BAD_FUNC_ARG; } only covers the outer condition. The inner level dispatch chain at 13470-13481 (if (level == WC_ML_DSA_44) ... else if (65) ... else if (87)) has no terminal else, so when none matches, ret is still 0 and the function returns 0 with output untouched. The documented contract is 'Size of the encoded DER in bytes on success', and every in-tree caller uses ExpectIntGT(..., 0) or assigns the result to a length — a return of 0 reads as 'succeeded, wrote 0 bytes' rather than an error. Before this commit the BAD_FUNC_ARG initializer made that path a hard error.
All three sibling exporters kept their safety net: wc_MlDsaKey_ExportPubRaw (wc_mldsa.c:12193-12196), wc_MlDsaKey_PublicKeyToDer (13393-13396) and wc_MlDsaKey_PrivateKeyToDer (still int ret = WC_NO_ERR_TRACE(BAD_FUNC_ARG);) all terminate the same chain with else { /* Level not set. */ ret = BAD_FUNC_ARG; }. KeyToDer is now the lone outlier.
Reachability is narrow and I state it honestly: key->level is assigned in exactly one place, wc_MlDsaKey_SetParams (wc_mldsa.c:11662, key->level = level % WC_ML_DSA_DRAFT;), and only after mldsa_get_params() has accepted the level, so a key built through the public API always holds 2/3/5. This is a removed defensive default rather than a currently exploitable path — but it is a real behavioural change in this diff.
Suggestion:
| int ret = 0; | |
| else if (key->level == WC_ML_DSA_87) { | |
| ret = SetAsymKeyDer(key->k, WC_MLDSA_87_KEY_SIZE, key->p, | |
| WC_MLDSA_87_PUB_KEY_SIZE, output, len, ML_DSA_87k); | |
| } | |
| else { | |
| /* Level not set. */ | |
| ret = BAD_FUNC_ARG; | |
| } |
Recommendation: Add a terminating else { ret = BAD_FUNC_ARG; } to the level chain, matching wc_MlDsaKey_ExportPubRaw.
[wolfcrypt/src/wc_mldsa.c:13436-13488] Restore the safety net, matching wc_MlDsaKey_PublicKeyToDer:
else if (key->level == WC_ML_DSA_87) {
ret = SetAsymKeyDer(key->k, WC_MLDSA_87_KEY_SIZE, key->p,
WC_MLDSA_87_PUB_KEY_SIZE, output, len, ML_DSA_87k);
}
else {
/* Level not set. */
ret = BAD_FUNC_ARG;
}
| mldsa_vec_decode_eta_bits(s1p, params->eta, s1, params->l); | ||
| mldsa_vec_decode_eta_bits(s2p, params->eta, s2, params->k); | ||
|
|
||
| #ifdef WC_MLDSA_CACHE_PRIV_VECTORS |
There was a problem hiding this comment.
🔵 [Low] MakePublicKey invalidates the private-vector cache in MAKE_KEY_SMALL_MEM builds where it never touched it
🔧 NIT bug
Every other WC_MLDSA_CACHE_PRIV_VECTORS block in this function is written as #if defined(WC_MLDSA_CACHE_PRIV_VECTORS) && !defined(WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM) (lines 10592-10612 and the cleanup at 10853-10871), because in small-mem mode s1/s2/t come from the locally allocated allocPtr (10679-10688) and key->s1/key->s2/key->t0 are never written. The privVecsSet invalidation is gated on plain #ifdef WC_MLDSA_CACHE_PRIV_VECTORS, so with WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM + WC_MLDSA_CACHE_PRIV_VECTORS it clears a cache whose contents are still valid, and the comment above it ('s1/s2/t0 are overwritten here (wrong domain)') is false for that configuration. Not a memory-safety issue — privVecsSet exists whenever WC_MLDSA_CACHE_PRIV_VECTORS is set, independently of small-mem — the only effect is that the next sign recomputes the NTT-domain vectors unnecessarily.
Also raised by the bugs scan (wolfcrypt/src/wc_mldsa.c:10715-10720):
The scratch buffers are aliased onto the private-vector cache only under the pair WC_MLDSA_CACHE_PRIV_VECTORS && !WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM — see the reuse block at wc_mldsa.c:10592-10612, guarded exactly that way. Under WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM, s1/s2/t come from the locally malloc'd allocPtr (10678-10688) and key->s1 / key->s2 / key->t0 are never written.
But the invalidation is guarded by a bare #ifdef WC_MLDSA_CACHE_PRIV_VECTORS. So in a WC_MLDSA_CACHE_PRIV_VECTORS + WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM build, wc_MlDsaKey_MakePublicKey() throws away a still-valid cache that wc_MlDsaKey_ImportPrivRaw() just populated via mldsa_make_priv_vecs() (wc_mldsa.c:12508). The next wc_MlDsaKey_SignCtx() then re-runs the full decode + NTT of s1/s2/t0, because mldsa_sign_with_seed_mu() only recomputes them if (!key->privVecsSet) (wc_mldsa.c:8585).
No wrong results — just a silent per-key performance regression, and a guard that no longer matches the guard on the aliasing it exists to compensate for, which is the kind of drift that turns into a correctness bug on the next edit.
Suggestion:
| #ifdef WC_MLDSA_CACHE_PRIV_VECTORS | |
| #if defined(WC_MLDSA_CACHE_PRIV_VECTORS) && \ | |
| !defined(WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM) | |
| key->privVecsSet = 0; | |
| #endif |
Recommendation: Match the other blocks in the same function by adding && !defined(WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM).
| ExpectIntEQ(wc_MlDsaKey_MakePublicKey(key), WC_NO_ERR_TRACE(BAD_FUNC_ARG)); | ||
|
|
||
| /* Public key set but private key still not set - still BAD_FUNC_ARG. */ | ||
| key->pubKeySet = 1; |
There was a problem hiding this comment.
🔵 [Low] Unguarded struct pokes dereference key after a failed allocation or a skipped Init
🔧 NIT test
key->pubKeySet = 1; and key->pubKeySet = 0; are plain statements, not wrapped in Expect* and not inside the if (key != NULL) pattern used a few lines above in the same test. Because Expect* short-circuits after any prior failure (tests/unit.h:184-195), a failed XMALLOC makes ExpectNotNull(key) fail, all following ExpectIntEQ calls become no-ops, and these two assignments dereference NULL. Same class at tests/api/test_mldsa.c:8279-8281 in test_mldsa_make_public_key_tr_mismatch: if (key != NULL) { key->k[...] ^= 0xFF; } checks key but not key->k, and under WOLFSSL_MLDSA_DYNAMIC_KEYS key->k is a heap pointer that is still NULL if the preceding ExpectIntEQ(wc_MlDsaKey_ImportPrivRaw(...)) was skipped.
Also raised by the bugs scan (tests/api/test_mldsa.c:7787-7789):
key is heap-allocated at test_mldsa.c:7769 and only checked via ExpectNotNull(key) at 7770. Every Expect* macro short-circuits on prior failure (Expect() wraps its body in if (EXPECT_SUCCESS()), tests/unit.h:184-195), and the XMEMSET that follows is explicitly guarded by if (key != NULL).
But these two statements are bare assignments, not Expect macros, so they run unconditionally and dereference a possibly-NULL pointer:
key->pubKeySet = 1;
ExpectIntEQ(wc_MlDsaKey_MakePublicKey(key), WC_NO_ERR_TRACE(BAD_FUNC_ARG));
key->pubKeySet = 0;
Only reachable under allocation failure, hence Low. Note the sibling test test_mldsa_make_public_key_tr_mismatch does get this right — its key->k[...] ^= 0xFF; poke is wrapped in if (key != NULL).
Suggestion:
| key->pubKeySet = 1; | |
| if (EXPECT_SUCCESS() && (key != NULL)) { | |
| key->pubKeySet = 1; | |
| ExpectIntEQ(wc_MlDsaKey_MakePublicKey(key), | |
| WC_NO_ERR_TRACE(BAD_FUNC_ARG)); | |
| key->pubKeySet = 0; | |
| } |
Recommendation: Wrap both assignments in if (key != NULL) (or if (EXPECT_SUCCESS() && key != NULL)), and in the tr_mismatch test guard on key->prvKeySet rather than just key != NULL.
| return EXPECT_RESULT(); | ||
| } | ||
|
|
||
| /* Corrupt the 'tr' hash embedded in a private-only key blob so the derived |
There was a problem hiding this comment.
🔵 [Low] test_mldsa_make_public_key_tr_mismatch comment claims to exercise the WC_MLDSA_FAULT_HARDEN recheck, which that path can never reach
🔧 NIT test
The WC_MLDSA_FAULT_HARDEN block in wc_MlDsaKey_MakePublicKey is entered only under if (ret == 0) after the tr compare (wolfcrypt/src/wc_mldsa.c:10772-10773). Because this test deliberately forces the tr compare to fail, the recheck is skipped entirely, so the test only shows that ret is still PUBLIC_KEY_E — never that the recheck runs or that it 'must not override' anything. Nothing in the diff exercises the FAULT_HARDEN success path or its PUBLIC_KEY_E/MEMORY_E arms.
Recommendation: Reword the comment to say the recheck is deliberately not reached on this path, and add a separate case (authentic key in a WC_MLDSA_FAULT_HARDEN build) that actually drives the recheck.
| } | ||
| #endif | ||
|
|
||
| do { |
There was a problem hiding this comment.
🔵 [Low] ECC private-key decode now leaves an unblinded plaintext copy of the private scalar in key->ku (WOLFSSL_ECC_BLIND_K builds)
🔧 NIT
The PR adds a best-effort public-key derivation to wc_EccPrivateKeyDecode() via the new EccDerivePubBestEffort() helper, which calls wc_ecc_make_pub_ex(). In builds with WOLFSSL_ECC_BLIND_K, ecc_make_pub_sw() reaches the scalar exclusively through ecc_get_k(), whose body is mp_xor_ct(key->k, key->kb, key->dp->size, key->ku); return key->ku; (wolfcrypt/src/ecc.c:375-379). That unblinds the scalar into the persistent key->ku member of the ecc_key struct. Nothing on the make-pub path re-blinds or clears ku; the only wipe is mp_forcezero(key->ku) in wc_ecc_free() (wolfcrypt/src/ecc.c:8568-8570). Before this commit, wc_EccPrivateKeyDecode() ended at wc_ecc_import_private_key_ex(), which stores the scalar blinded via ecc_blind_k_rng() (ecc.c:12231-12233) and touches only key->k and key->kb — ku was left in its zero-initialized state (mp_init_multi/alt_fp_init, ecc.c:6470-6486). So the diff newly makes every SEC1 private-key decode leave the plaintext scalar resident in the key struct for the key's entire lifetime, defeating the purpose of k-blinding for keys that are loaded but not yet used. The residue is created even when derivation fails: the first statement of ecc_make_pub_sw() is the mp_iszero(ecc_get_k(key)) range check, so ku is populated before any error return. Reachability: WOLFSSL_ECC_BLIND_K with ECC_TIMING_RESISTANT off (derive always runs), or with it on when the caller set an RNG via wc_ecc_set_rng() before decoding — a sequence the PR's own new test test_wc_EccPrivateKeyDecode_derive_pub performs. The identical helper is duplicated at wolfcrypt/src/asn_orig.c:7611-7676 (call site asn_orig.c:7787), so the non-template build is affected the same way. Honest caveat: a later software sign or ECDH on the same key also calls ecc_get_k() and leaves the same residue (ecc_sign_hash_sw re-blinds k/kb but not ku), so for keys that are actually used this widens the exposure window rather than creating a wholly new one; for decoded-but-unused keys it is new. Consequence: a core dump, swap page, or heap-disclosure bug yields the plaintext ECDSA private scalar that a WOLFSSL_ECC_BLIND_K build was specifically configured to keep out of plaintext memory. Limitation: the sandbox denied compiler invocation, so this is source-level analysis only — no assembly or runtime confirmation.
Recommendation: Scrub the unblinded scratch scalar once the derive completes. Preferred fix in wolfcrypt/src/ecc.c: at every exit of ecc_make_pub_sw() (success and error) add #ifdef WOLFSSL_ECC_BLIND_K\n mp_forcezero(key->ku);\n#endif, which also clears the analogous residue left by sign/ECDH. If the fix must stay local to this PR, add the same guarded mp_forcezero(key->ku) at the end of EccDerivePubBestEffort() in both wolfcrypt/src/asn.c (after the if (pubRet != 0) log block, ~line 33170) and wolfcrypt/src/asn_orig.c (~line 7676), so a decoded-but-unused key does not carry the plaintext scalar for its lifetime.
| int valid = 0; | ||
| sword32 hi; | ||
|
|
||
| if (!key->pubKeySet) { |
There was a problem hiding this comment.
*⚪ [Info] New PUBLIC_KEY_E early return in mldsa_verify_with_mu leaves res untouched, unlike every other error path in the function
🔧 NIT
Both variants of mldsa_verify_with_mu() gained if (!key->pubKeySet) { return PUBLIC_KEY_E; } as the first statement (wc_mldsa.c:9837-9839 non-small-mem, 10008-10010 small-mem). The guard itself is a genuine fix — under WOLFSSL_MLDSA_DYNAMIC_KEYS, key->p is NULL when pubKeySet == 0 and the code immediately below dereferences it — and nothing is allocated at that point, so no cleanup is skipped.
The wrinkle is the out-parameter. Every other failure exit (BUFFER_E from the sigLen check, SIG_VERIFY_E from mldsa_check_hint(), MEMORY_E from the XMALLOC) falls through to the common tail *res = valid; with valid initialised to 0, so *res is always driven to 0. The new return PUBLIC_KEY_E; bypasses that tail, and the four wc_-level guards (wc_mldsa.c:11263, 11319, 11381, 11423) likewise set ret = PUBLIC_KEY_E without touching *res. A caller that seeds res = 1 and only inspects the out-parameter would read a stale 1.
I checked every in-tree consumer and none is affected: src/tls13.c:11763 (int res = 0; plus a ret check), pkcs7.c:5637, asn.c:18191/18200 (via ConfirmSignature), and the FIPS PCT in wc_mldsa.c. Reporting as Info: a latent API-contract inconsistency, not an exploitable defect today.
Recommendation: Drive the out-parameter before returning, in both variants:
if (!key->pubKeySet) {
*res = 0;
return PUBLIC_KEY_E;
}
and consider the same in the four wc_MlDsaKey_Verify*/VerifyMu guards so the API never leaves *res at a caller-supplied value on a hard error.
Description
Refactored ML-DSA public key derivation to fix memory allocation issues in SMALL_MEM builds, and adds guards to ensure verification isn't attempted without a valid public key. Introduced ECC best-effort public key derivation when decoding private-only SEC1 keys. Added additional CI testing.
Notes:
public key is set.
wolfSSL/wolfssh#1120
Testing
Added test coverage.
Checklist