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.
8b41aee to
10a84a1
Compare
10a84a1 to
546e1ce
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.
546e1ce to
fec67b5
Compare
fec67b5 to
1ee6dc8
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.
1ee6dc8 to
3bb246b
Compare
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
| int test_mldsa_make_public_key(void) | ||
| { | ||
| EXPECT_DECLS; | ||
| #if defined(WOLFSSL_HAVE_MLDSA) && defined(WOLFSSL_MLDSA_PRIVATE_KEY) && \ |
There was a problem hiding this comment.
🟠 [High] Three new ML-DSA tests use certs_test.h buffers without the NO_SIGN/NO_VERIFY guards that declare them
🚫 BLOCK bug
Three new tests in tests/api/test_mldsa.c reference wolfssl/certs_test.h buffers whose declarations sit behind narrower guards than the tests themselves, so a sign-disabled ML-DSA build hits undeclared-identifier errors.
The arrays and their real guards (verified in certs_test.h at the PR head):
bench_mldsa_{44,65,87}_key/sizeof_*— inside#ifndef WOLFSSL_MLDSA_NO_SIGN(44 at 5017-5280, 65 at 5422-5832, 87 at 6038-6534).bench_mldsa_{44,65,87}_pubkey/sizeof_*— inside#ifndef WOLFSSL_MLDSA_NO_VERIFY(44 at 5282-5420, 65 at 5834-6036, 87 at 6536-6802).mldsa44_priv_only/sizeof_mldsa44_priv_only— inside#ifndef WOLFSSL_MLDSA_NO_SIGN(6952-7496).
The three affected sites:
test_mldsa_make_public_key(7769-7770) — guard isWOLFSSL_HAVE_MLDSA && WOLFSSL_MLDSA_PRIVATE_KEY && !WOLFSSL_MLDSA_ASSIGN_KEY && !WOLFSSL_MLDSA_NO_MAKE_KEY, but the body uses both the_keyand_pubkeyarrays, so it needs both new conditions.test_mldsa_verify_pubkeyset_guard(7961-7962) — guard is... && !WOLFSSL_MLDSA_NO_VERIFY && !WOLFSSL_NO_ML_DSA_44, but the body importsbench_mldsa_44_key; needs!WOLFSSL_MLDSA_NO_SIGN.test_mldsa_private_key_export_derive_fail(8067-8070) and its static OOM-allocator callback block (8004-8007) — neither guards onWOLFSSL_MLDSA_NO_SIGN, but the body decodesmldsa44_priv_only.
--enable-mldsa=44,make,verify (configure.ac -> -DWOLFSSL_MLDSA_NO_SIGN) reaches all three: WOLFSSL_MLDSA_PRIVATE_KEY stays defined because WOLFSSL_MLDSA_NO_MAKE_KEY is not derived from WOLFSSL_MLDSA_NO_SIGN (dilithium.h:295-330), so every guard above is satisfied while the arrays are absent.
The omission is inconsistent within the same file: the sibling test test_mldsa_private_key_export_derives_public_key (7852-7856) already guards on both !WOLFSSL_MLDSA_NO_SIGN and !WOLFSSL_MLDSA_NO_VERIFY. This PR also adds !defined(WOLFSSL_MLDSA_NO_SIGN) gating to tests/suites.c, which shows sign-disabled builds are exercised.
Recommendation: Widen each guard to match the buffers its test consumes:
/* test_mldsa.c:7769 — test_mldsa_make_public_key */
#if defined(WOLFSSL_HAVE_MLDSA) && defined(WOLFSSL_MLDSA_PRIVATE_KEY) && \
!defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY) && \
!defined(WOLFSSL_MLDSA_NO_SIGN) && !defined(WOLFSSL_MLDSA_NO_VERIFY)
/* test_mldsa.c:7961 — test_mldsa_verify_pubkeyset_guard */
#if defined(WOLFSSL_HAVE_MLDSA) && defined(WOLFSSL_MLDSA_PRIVATE_KEY) && \
!defined(WOLFSSL_MLDSA_NO_VERIFY) && !defined(WOLFSSL_MLDSA_NO_SIGN) && \
!defined(WOLFSSL_NO_ML_DSA_44)For test_mldsa_private_key_export_derive_fail, add !defined(WOLFSSL_MLDSA_NO_SIGN) to both the function guard at 8067-8070 and the static-callback block guard at 8004-8007.
Worth adding a WOLFSSL_MLDSA_NO_SIGN-only CI build to catch this class of breakage.
| * @param [in] aValid Non-zero if `a` already holds expanded `rho` matrix. | ||
| * @return 0 on success, negative on error. | ||
| */ | ||
| static int mldsa_calc_t_std(wc_MlDsaKey* key, const byte* rho, sword32* s1, |
There was a problem hiding this comment.
🟠 [High] Build break: new mldsa_calc_t_std() calls mldsa_expand_a(), which is not compiled in MAKE_KEY_SMALL_MEM builds
💡 SUGGEST
The PR factors the standard t = NTT^-1(A o NTT(s1)) + s2 computation into a new static helper mldsa_calc_t_std(), guarded only by #if !defined(WOLFSSL_MLDSA_NO_MAKE_KEY) || defined(WOLFSSL_MLDSA_CHECK_KEY) (line 7767). It calls mldsa_expand_a() unconditionally at line 7774. But mldsa_expand_a()'s guard (lines 2786-2793, untouched by this PR) explicitly excludes the small-memory keygen build: its first arm is (!defined(WOLFSSL_MLDSA_NO_MAKE_KEY) && !defined(WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM)). mldsa_calc_t_std() has no MAKE_KEY_SMALL_MEM exclusion term.
The helper is genuinely reachable in that config: the PR also changed wc_MlDsaKey_CheckKey's guard (line 11808) to defined(WOLFSSL_MLDSA_CHECK_KEY) || (!defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY)), and CheckKey calls mldsa_calc_t_std() at lines 11899/11901.
Triggering config (WOLFSSL_MLDSA_NO_CHECK_KEY is required because dilithium.h:325-330 auto-defines WOLFSSL_MLDSA_CHECK_KEY otherwise):
-DWOLFSSL_HAVE_MLDSA -DWOLFSSL_MLDSA_NO_CHECK_KEY -DWOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM -DWOLFSSL_MLDSA_SIGN_SMALL_MEM -DWOLFSSL_MLDSA_VERIFY_SMALL_MEM (no WC_MLDSA_CACHE_MATRIX_A).
Guard evaluation: mldsa_expand_a (2786) => (T && F)=F || F || (T && F)=F || (T && (F||F))=F => NOT compiled. mldsa_calc_t_std (7767) => !NO_MAKE_KEY = T => compiled. Result: implicit declaration of the static mldsa_expand_a — a hard error on clang >=16 / gcc >=14 and an undefined static symbol elsewhere. Every other callee of mldsa_calc_t_std (mldsa_vec_ntt_small_full, mldsa_matrix_mul, mldsa_vec_invntt_full, mldsa_vec_add, mldsa_vec_red) IS available in that config; mldsa_expand_a is the only missing one.
This is new: pre-PR, wc_MlDsaKey_CheckKey was #ifdef WOLFSSL_MLDSA_CHECK_KEY-only and mldsa_calc_t_std did not exist, so nothing referenced mldsa_expand_a in this configuration.
Secondary effect in the same family: whenever MAKE_KEY_SMALL_MEM is set and CHECK_KEY is not, mldsa_calc_t_std() is also completely unused (both keygen and MakePublicKey take the mldsa_calc_t_small_mem() path), tripping -Wunused-function under -Werror.
Recommendation: Either widen mldsa_expand_a's guard at line 2786 to track mldsa_calc_t_std's availability, e.g. add || (!defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY)); or tighten mldsa_calc_t_std's guard to the exact set of configs that call it:
#if (!defined(WOLFSSL_MLDSA_NO_MAKE_KEY) &&
!defined(WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM)) ||
defined(WOLFSSL_MLDSA_CHECK_KEY)
and make wc_MlDsaKey_CheckKey use the streaming path in small-mem builds. Add a MAKE_KEY_SMALL_MEM + NO_CHECK_KEY CI job.
| #endif | ||
|
|
||
| #if defined(WOLFSSL_MLDSA_CHECK_KEY) || \ | ||
| (!defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY)) || \ |
There was a problem hiding this comment.
🟠 [High] Build break: outer guard for mldsa_vec_decode_eta_bits not widened, so the new CheckKey/MakePublicKey arm is dead
💡 SUGGEST
The PR widened the inner guard around mldsa_vec_decode_eta_bits (line 1279) with the new arm (!defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY)), but left the enclosing guard at line 1144 untouched:
#if !defined(WOLFSSL_MLDSA_NO_SIGN) || defined(WOLFSSL_MLDSA_CHECK_KEY)
Because the widened inner #if is nested inside the un-widened outer #if, the new arm is dead: when WOLFSSL_MLDSA_NO_SIGN is defined and WOLFSSL_MLDSA_CHECK_KEY is not, mldsa_decode_eta_2_bits, mldsa_decode_eta_4_bits and mldsa_vec_decode_eta_bits are all compiled out.
This is a missed spot in the same edit: for the sibling helper mldsa_vec_decode_t0 the PR did widen the outer guard (line 1488 now carries the (!ASSIGN_KEY && !NO_MAKE_KEY) arm). Both outer guards should have been changed identically.
Meanwhile wc_MlDsaKey_CheckKey (guard at 11808) calls mldsa_vec_decode_eta_bits at 11889-11890, and the new wc_MlDsaKey_MakePublicKey calls it at 10587-10588 and 10652-10654.
Triggering config: -DWOLFSSL_HAVE_MLDSA -DWOLFSSL_MLDSA_NO_SIGN -DWOLFSSL_MLDSA_NO_CHECK_KEY (equivalently --enable-mldsa=44,make,verify, which emits -DWOLFSSL_MLDSA_NO_SIGN at configure.ac:7800, plus WOLFSSL_MLDSA_NO_CHECK_KEY in user_settings.h). Evaluation with NO_MAKE_KEY/ASSIGN_KEY/NO_VERIFY undefined and CHECK_KEY suppressed:
- line 1144:
(!NO_SIGN)=F || (CHECK_KEY)=F=> FALSE => not compiled wc_MlDsaKey_CheckKey(11808):F || (T && T)=> TRUE => compiledwc_MlDsaKey_MakePublicKey(10412):!NO_MAKE_KEY && !ASSIGN_KEY=> TRUE => compiled
Result: implicit declaration / undefined static mldsa_vec_decode_eta_bits. Every other helper CheckKey needs is present in that config; this is the only missing one. Pre-PR the guards were consistent, because wc_MlDsaKey_CheckKey was #ifdef WOLFSSL_MLDSA_CHECK_KEY — exactly the second arm of line 1144.
Recommendation: Widen the outer guard at line 1144 exactly as the outer guard at line 1488 was widened:
#if !defined(WOLFSSL_MLDSA_NO_SIGN) || defined(WOLFSSL_MLDSA_CHECK_KEY) || \
(!defined(WOLFSSL_MLDSA_ASSIGN_KEY) && !defined(WOLFSSL_MLDSA_NO_MAKE_KEY))
and add a CI config exercising --enable-mldsa=make,verify with WOLFSSL_MLDSA_NO_CHECK_KEY.
| int allocatorsSet = 0; | ||
| int targetMatch = 0; | ||
| /* Match t0Scratch allocation size. */ | ||
| size_t t0ScratchSz = (size_t)PARAMS_ML_DSA_44_K * |
There was a problem hiding this comment.
🟠 [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
💡 SUGGEST
mldsa_oom_malloc_cb only counts an allocation when size == mldsa_oom_match_size, and the test computes the expected sizes itself:
- t0Scratch:
PARAMS_ML_DSA_44_K * (MLDSA_D * MLDSA_N / 8U)= 4 * 416 = 1664 (matches wc_mldsa.c:10494-10495 / 10573). - s1/s2/t/a:
S1_SIZE + 2*S2_SIZE + A_SIZE= 4096 + 8192 + 16384 = 28672 (matches wc_mldsa.c:10500-10504 / 10531).
Those values are right for the default build, but the size the allocator callback receives is not the size XMALLOC was called with. Verified in wolfcrypt/src/memory.c:344-354:
#ifdef WOLFSSL_CHECK_MEM_ZERO
/* Space for requested size. */
size += MEM_ALIGN;
#endif
if (malloc_function) { res = malloc_function(size); }
MEM_ALIGN is 16 (memory.c:192), so with -DWOLFSSL_CHECK_MEM_ZERO the callback sees 1680 and 28688 — never 1664/28672. mldsa_oom_match_count stays 0, targetMatch becomes 0, and ExpectIntGE(targetMatch, 1) at line 8110 fails. Worse, mldsa_oom_fail_at_match = 0 then disables injection entirely, so ExpectIntNE(wc_MlDsaKey_MakePublicKey(key), 0) also fails because the call succeeds.
This is not hypothetical: .github/workflows/os-check.yml:158 runs the job with ["--enable-all", "CPPFLAGS=-DWOLFSSL_CHECK_MEM_ZERO"], and --enable-all turns on ML-DSA 44/65/87 with make/sign/verify — exactly the configuration in which this test body is compiled.
The same class of breakage applies to -DWOLFSSL_MEM_FAIL_COUNT (memory.c:337-341 returns NULL before the callback runs, so mldsa_oom_failed never becomes 1 while the allocation still fails) and -DWOLFSSL_FORCE_MALLOC_FAIL_TEST.
Secondary robustness note: matching purely on byte size uses a process-global hook, so any unrelated allocation of the same size in the window is counted. In the default build nothing else allocates 1664 or 28672 bytes there (wc_MlDsaKey_CheckKey allocates s1Sz + 4*s2Sz + aSz = 36864, wc_mldsa.c:11852-11856), but the coupling is silent: any future change to MakePublicKey's allocation layout turns this into a failure rather than a stale-but-passing test.
Recommendation: Stop matching on exact byte size. Either (a) match with size >= mldsa_oom_match_size plus a tolerance, or (b) drop the size heuristic and fail the Nth allocation made while a test-controlled flag is set around the wc_MlDsaKey_MakePublicKey() call only. At minimum add !defined(WOLFSSL_CHECK_MEM_ZERO) && !defined(WOLFSSL_MEM_FAIL_COUNT) && !defined(WOLFSSL_FORCE_MALLOC_FAIL_TEST) to the guard at test_mldsa.c:8067-8070 so the all-check-mem-zero CI job does not regress.
| } | ||
| #endif | ||
|
|
||
| #ifdef ECC_TIMING_RESISTANT |
There was a problem hiding this comment.
🟡 [Medium] ECC decode-time public key derivation is inert in default hardened builds
💡 SUGGEST question
The helper bails out early when ECC_TIMING_RESISTANT is defined and key->rng == NULL. ECC_TIMING_RESISTANT is enabled by --enable-harden, which is on by default, and key->rng is only populated by an explicit wc_ecc_set_rng() call. Grepping the tree shows no wolfSSL internal caller sets an RNG on an ecc_key before wc_EccPrivateKeyDecode() - the only wc_ecc_set_rng() sites (src/internal.c:5913, src/sniffer.c:2821, wolfcrypt/src/hpke.c, wolfcrypt/src/pkcs7.c) all operate on keys that are already decoded or freshly generated. So in the default build and in every internal key-load path, the new derivation never runs and the key stays ECC_PRIVATEKEY_ONLY. The new test only exercises the derive branch because it explicitly calls wc_ecc_set_rng() first. Meanwhile ecc_make_pub_sw() documents rng as "optional RNG for a timing-resistant point multiply, may be NULL", so the skip is a conservative choice rather than a hard requirement.
Suggestion: Either fall back to wc_ecc_make_pub_ex(key, NULL, NULL) when no RNG is available (matching what ecc_verify_hash already does at ecc.c:9643), or set a global/default RNG on the key inside the decode path.
There was a problem hiding this comment.
Added the wc_ecc_make_pub_ex(key, NULL, NULL) fallback, following the verify hash convention. A default RNG could cause entropy blocking or memory management issues, which would get rid of the point of having ECC_TIMING_RESISTANT as a guard in the first place.
Note that not passing in an RNG value into this will allow for a minor timing/power side channel on the scalar multiply, but we now have the WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE opt-out.
|
|
||
| key->pubKeySet = 1; | ||
|
|
||
| /* Recompute/compare t1 (like CheckKey) to catch glitches. |
There was a problem hiding this comment.
🟡 [Medium] MakePublicKey always doubles the matrix work with an unconditional CheckKey
💡 SUGGEST convention
After deriving t1, the function unconditionally re-runs the whole expand_a + matrix_mul + invntt pipeline (via wc_MlDsaKey_CheckKey(), or a streaming re-derive under WOLFSSL_MLDSA_MAKE_KEY_SMALL_MEM) purely as fault detection, and in the WC_MLDSA_CACHE_MATRIX_A case it forces key->aSet = 0 so the cached matrix cannot be reused. That is roughly a 2x cost on every private-key-only decode-then-export, plus an extra t0ScratchSz + t1Sz allocation in the small-mem path where memory is the scarce resource. The codebase already has a dedicated opt-in macro for exactly this trade-off, WC_MLDSA_FAULT_HARDEN (used at wc_mldsa.c:8787), which this new code ignores.
Suggestion: Wrap the verification block in #ifdef WC_MLDSA_FAULT_HARDEN so builds that have not opted into fault hardening pay the derivation cost once.
There was a problem hiding this comment.
Added fault hardening wrappers
| #endif | ||
|
|
||
| #ifdef WOLFSSL_MLDSA_CHECK_KEY | ||
| #if defined(WOLFSSL_MLDSA_CHECK_KEY) || \ |
There was a problem hiding this comment.
🟡 [Medium] WOLFSSL_MLDSA_NO_CHECK_KEY no longer removes wc_MlDsaKey_CheckKey
💡 SUGGEST api
dilithium.h:325-330 auto-defines WOLFSSL_MLDSA_CHECK_KEY whenever public+private key support is present and WOLFSSL_MLDSA_NO_CHECK_KEY is not set. Therefore the only situation in which the new guard WOLFSSL_MLDSA_CHECK_KEY || (!WOLFSSL_MLDSA_ASSIGN_KEY && !WOLFSSL_MLDSA_NO_MAKE_KEY) differs from the old one is a build that explicitly set WOLFSSL_MLDSA_NO_CHECK_KEY to save code space. Those builds now get wc_MlDsaKey_CheckKey() compiled and exported anyway, plus its transitive helpers (mldsa_vec_decode_t0, mldsa_vec_decode_t1, mldsa_vec_sub, ...) whose guards were widened in the same way. The user-facing meaning of WOLFSSL_MLDSA_NO_CHECK_KEY silently changed with no documentation update.
Suggestion: Introduce an internal-only helper (for example mldsa_check_key_internal()) compiled under the wider guard, and keep the public wc_MlDsaKey_CheckKey() symbol under WOLFSSL_MLDSA_CHECK_KEY alone; or gate the derivation-time verification on WC_MLDSA_FAULT_HARDEN so WOLFSSL_MLDSA_NO_CHECK_KEY keeps its meaning.
There was a problem hiding this comment.
Added fault hardening wrapper so CheckKey only happens in those cases
| } | ||
| } | ||
|
|
||
| #if !defined(WOLFSSL_MLDSA_VERIFY_ONLY) && !defined(WOLFSSL_MLDSA_ASSIGN_KEY) && \ |
There was a problem hiding this comment.
🟡 [Medium] Export/DER functions now mutate key state, breaking their read-only contract
💡 SUGGEST api
wc_MlDsaKey_ExportPubRaw(), wc_MlDsaKey_PublicKeyToDer() and wc_MlDsaKey_KeyToDer() were effectively read-only accessors. They now perform a full key derivation that writes key->p, sets key->pubKeySet, and (under WC_MLDSA_CACHE_PRIV_VECTORS) overwrites key->s1/key->s2/key->t0 and clears key->privVecsSet; under WC_MLDSA_CACHE_MATRIX_A it also rewrites key->a and toggles key->aSet. A long-lived server key shared read-only across threads for DER export would now race on those cached buffers, and a concurrent wc_MlDsaKey_SignCtx() on the same key could observe half-rewritten s1/s2. The doxygen for these three functions was not updated to note the side effect (only wc_MlDsaKey_PrivateKeyDecode's was).
Suggestion: Document the mutation in doxygen for all three functions, e.g. "When only the private key is set, the public key is derived and cached in key as a side effect; the key must not be shared across threads during this call."
There was a problem hiding this comment.
Documented the policy change.
| #endif /* WOLFSSL_ASN_TEMPLATE */ | ||
| #ifdef HAVE_ECC | ||
|
|
||
| /* Like HAVE_ECC_MAKE_PUB, but excludes CAAM and FIPS/selftest. */ |
There was a problem hiding this comment.
🟡 [Medium] No opt-out for the new decode-time ECC scalar multiply
💡 SUGGEST api
WOLFSSL_ECC_DERIVE_PUB_BEST_EFFORT is derived purely from feature/hardware macros, so any build where derivation is possible gets an unconditional point multiply on every SEC1 private key decode with no compile-time way to turn it off. wc_EccPrivateKeyDecode() is on the key-loading path for wolfSSL_CTX_use_PrivateKey_*, PKCS#8/PKCS#12 import and wc_CryptoCb key setup, and constrained targets loading several keys will pay a measurable startup cost for a public point they may never need. Every other similarly costly opt-in behaviour in this area (WOLFSSL_VALIDATE_ECC_IMPORT, WOLFSSL_VALIDATE_ECC_KEYGEN) is guarded by its own macro.
Suggestion:
| /* Like HAVE_ECC_MAKE_PUB, but excludes CAAM and FIPS/selftest. */ | |
| #if !defined(NO_ECC_MAKE_PUB) && !defined(WOLFSSL_NO_ECC_DERIVE_PUB_ON_DECODE) && \ | |
| !defined(WOLFSSL_ATECC508A) && ... |
| #endif | ||
| mldsa_vec_encode_t0_t1(t, params->k, t0Scratch, t1); | ||
|
|
||
| /* Note: MakePublicKey doesn't recompute 'tr'; it must match |
There was a problem hiding this comment.
🟡 [Medium] Derived ML-DSA public key is never checked against the stored tr hash
💡 SUGGEST bug
The comment acknowledges that tr is not recomputed and "must match the authentic hash already in the private key blob", but nothing verifies that assumption. The subsequent wc_MlDsaKey_CheckKey() compares the derived t1 against the private key's t0 and the public seed against rho - it never touches tr. So a private key blob whose tr field is corrupt or was written by a non-conforming encoder yields pubKeySet = 1 with a public key that looks fine, while every signature produced afterwards computes mu = H(tr || M) with the wrong tr and fails verification against that very public key. Since the derived key->p is complete at this point, checking SHAKE256(key->p, pkSz) == tr is a single cheap hash and would catch it immediately.
Suggestion:
| /* Note: MakePublicKey doesn't recompute 'tr'; it must match | |
| byte trCalc[MLDSA_TR_SZ]; | |
| const byte* tr = key->k + MLDSA_PUB_SEED_SZ + MLDSA_K_SZ; | |
| ret = mldsa_shake256(&key->shake, key->p, params->pkSz, trCalc, | |
| MLDSA_TR_SZ); | |
| if ((ret == 0) && (XMEMCMP(trCalc, tr, MLDSA_TR_SZ) != 0)) { | |
| ret = PUBLIC_KEY_E; | |
| } |
There was a problem hiding this comment.
Added unconditional tr validation
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.
…ubkey derivation logs
81bbb62 to
9e69c56
Compare
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