Skip to content

wolfcrypt/mldsa: reduce w0/ct0 before check_low, matching the z step - #11113

Open
SemyonAndreyev wants to merge 3 commits into
wolfSSL:masterfrom
q2quantum:fix/mldsa-w0-reduction
Open

wolfcrypt/mldsa: reduce w0/ct0 before check_low, matching the z step#11113
SemyonAndreyev wants to merge 3 commits into
wolfSSL:masterfrom
q2quantum:fix/mldsa-w0-reduction

Conversation

@SemyonAndreyev

@SemyonAndreyev SemyonAndreyev commented Aug 7, 2026

Copy link
Copy Markdown

Description

The z path in mldsa_sign_with_seed_mu() (wolfcrypt/src/wc_mldsa.c) calls mldsa_poly_red() on its NTT⁻¹ output before mldsa_vec_check_low(). The w0 and ct0 paths in the same function compute the same kind of NTT⁻¹ output but call check_low() directly, without that reduction step. This PR aligns both with the existing z pattern already present in the same function, so all three bound checks receive a canonically-reduced input.

mldsa_sign_with_seed_mu() has a second implementation of this same logic, compiled instead of the default one under WOLFSSL_MLDSA_SIGN_SMALL_MEM (a real, documented, non-default build option for low-memory signing). That variant already reduces w0t/z the same way before their checks, but had the identical missing reduction on ct0 — the third commit here fixes that too, so ct0 is now handled consistently with w0/z in both variants, not just the default one.

No change in behavior on any input that currently passes check_low — the bound values here (hi) are well below the range mldsa_poly_red actually affects, so a reduction can only turn some current rejections into acceptances, one step earlier in the existing rejection-sampling loop (FIPS 204 Algorithm 2, steps 23/27).

Three commits: w0 (default path), ct0 (default path), ct0 (small-mem path) — same one-line change pattern each, independently revertible/bisectable if useful during review.

Testing

Verified against the existing native test suite (./configure --enable-mldsa && make && make check) in a clean environment (fresh GitHub Actions ubuntu-latest checkout, no local state): identical result before and after this change, and with each commit applied individually — 10 TOTAL / 5 PASS / 5 SKIP / 0 FAIL in all cases, byte-for-byte identical make check output.

The small-mem variant isn't exercised by that default build (it's behind a build flag with no ./configure switch, only settable via CFLAGS/user_settings.h), so it was verified separately: built with CFLAGS=-DWOLFSSL_MLDSA_SIGN_SMALL_MEM_PRECALC on a fresh runner, make check gives the identical 10/5/5/0 result both with and without the third commit — no regression from enabling that variant, and none from this fix within it.

Limitation, stated directly rather than left implicit: I attempted to write a targeted unit test exercising mldsa_poly_red()/mldsa_vec_check_low() directly at the specific boundary value where the missing reduction changes the outcome (a witness value from static bounds analysis of this code path). Both functions are static in wc_mldsa.c, and tests/unit-mcdc/ (the framework wolfSSL uses for whitebox coverage of static internals) is documented as external tooling with its own build driver, separate from the normal ./configure && make build — I wasn't able to wire a standalone test into that framework in reasonable time. So this PR's correctness argument rests on (a) the round-trip sign/verify test suite showing no regression in either build variant, and (b) the bound-value reasoning above (the change can only affect the rejection-sampling loop's iteration count, never signature validity), not on a dedicated unit test isolating the exact value this reduction protects.

Checklist

  • added tests — see Testing section above for why a targeted unit test wasn't added, and what stands in for it
  • updated/added doxygen — not applicable, no public API change
  • updated appropriate READMEs — not applicable
  • Updated manual and documentation — not applicable

Semyon Andreev and others added 2 commits August 8, 2026 00:49
The z path in mldsa_sign_with_seed_mu() calls mldsa_poly_red() on its
NTT^-1 output before check_low(). The w0 path computes the same kind of
NTT^-1 output but calls check_low() directly, without that reduction
step. This aligns w0 with the existing z pattern already present in the
same function, so both bound checks receive a canonically-reduced input.
The WOLFSSL_MLDSA_SIGN_SMALL_MEM variant of this same computation already
reduces its w0t the same way, one further point of internal consistency.

No change in behavior on any input that currently passes check_low: the
bound values here are well below the range mldsa_poly_red actually
affects, so a reduction can only turn some current rejections into
acceptances, one step earlier in the existing rejection-sampling loop
(FIPS 204 Algorithm 2, step 23).
The z path in mldsa_sign_with_seed_mu() calls mldsa_poly_red() on its
NTT^-1 output before check_low(). The ct0 path computes the same kind
of NTT^-1 output (via mldsa_mul_invntt) but calls check_low() directly,
without that reduction step. This aligns ct0 with the existing z/w0
pattern in the same function.

No change in behavior on any input that currently passes check_low --
gamma2 (the bound used here) is well below the range mldsa_poly_red
actually affects, so a reduction can only turn some current rejections
into acceptances, one step earlier in the existing rejection-sampling
loop (FIPS 204 Algorithm 2, step 27).
@wolfSSL-Bot

Copy link
Copy Markdown

Can one of the admins verify this patch?

…th too

The default-build fix in this PR (previous commit) covers the fused
mldsa_mul_invntt() call in mldsa_sign_with_seed_mu()'s main path. The
same function also has a second implementation, compiled instead of the
first under WOLFSSL_MLDSA_SIGN_SMALL_MEM (a real, documented,
non-default build option for low-memory signing -- see the option
reference at the top of this file), which does the same mul+invntt
manually as two separate calls:

    mldsa_mul(ct0, c, t0 + r * MLDSA_N);
    mldsa_invntt(ct0);
    valid = mldsa_check_low(ct0, params->gamma2);

mldsa_mul_invntt(r, c, v) is itself defined as exactly
"mldsa_mul(r, c, v); mldsa_invntt(r);" -- so this is the identical
computation as the already-fixed path, missing the identical reduction
before the identical check. w0 and z already reduce before their checks
in this same small-mem path; only ct0 was missing it, same asymmetry as
the default path.

Verified on a clean GitHub Actions runner (independent of the local
machine) in the actual target configuration: `./configure --enable-mldsa`
with CFLAGS=-DWOLFSSL_MLDSA_SIGN_SMALL_MEM_PRECALC, native `make check`
gives an identical 10 TOTAL / 5 PASS / 5 SKIP / 0 FAIL both before and
after this change -- no regression, matching the pattern already
established for the default-path fix.
@dgarske

dgarske commented Aug 7, 2026

Copy link
Copy Markdown
Member

Hi @SemyonAndreev , thank you for this report. I've assigned this to @SparkiDev to review. Because this is a small change we will treat as a bug report. In order to accept contributions we require a signed contributor agreement. Can I ask how you found this? Can you tell us more about your project and region? Are you planning any further contributions?
Thanks, David Garske, wolfSSL

@SemyonAndreyev

Copy link
Copy Markdown
Author

Hi @dgarske, thanks for the fast turnaround, and for putting this in front of @SparkiDev.

Contributor agreement — happy to sign. Should I request it from support@wolfssl.com, or can you
send it to q2quantum.app@gmail.com? Handling the change as a bug report works for us; the goal was to
get the inconsistency to whoever owns this code, in whatever form is easiest on your side.

How we found it. We build static analysis tooling for reduction placement in ML-DSA
implementations. It parses the signing path and flags candidate sites where a bound check runs on a
value that never went through the reduction step used elsewhere in the same function — that scan is
what we ran across codebases. For a flagged site, we separately confirm it with a boundary-value proof
(Z3, over the coefficient range) showing the check's outcome actually differs between the reduced and
unreduced input, rather than treating the structural flag alone as a finding.

We ran the scan against four independent ML-DSA codebases: OpenSSL 3.5, mldsa-native, TQ42 Cryptography
and wolfSSL. Only wolfSSL returned candidate sites, all inside mldsa_sign_with_seed_mu(), all the same
kind — a bound check whose input is not reduced, in a function where the neighboring path reduces first.

One more site, found the same way. While preparing this, we noticed the same function has a second
implementation compiled under WOLFSSL_MLDSA_SIGN_SMALL_MEM (low-memory signing). w0t and z already
reduce correctly there, but ct0 had the identical gap. Pushed a third commit fixing it — verified with
a clean build and the native test suite both with and without the fix, on a separate CI runner, before
pushing.

Project and region. We are Q² quantum ecosystem (https://www.linkedin.com/company/q2-quantum-ecosystem/),
a small independent research team working out of Tbilisi, Georgia. We maintain a map of the quantum
technology sector and build verification tooling for post-quantum implementations; this analyzer is the
first tool to come out of that work. There is no commercial product around it today.

Further contributions. Yes. We are continuing on ML-DSA and expect to cover more of the standard,
and anything we find in wolfSSL will come to you the same way. If it helps the review, I can send the
full analysis privately — SMT certificates for the two sites the tool flagged and confirmed, plus the
write-up for the third (found by direct code reading, verified by the clean before/after test runs
above rather than a fresh certificate).

If the fix lands as your own commit rather than through this PR, that is fine. A mention of where the
report came from would be appreciated, but we will not press it.

Semyon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants