Medium severity6.4OSV Advisory· Published Jan 10, 2026· Updated Apr 15, 2026
CVE-2026-22705
CVE-2026-22705
Description
RustCrypto: Signatures offers support for digital signatures, which provide authentication of data using public-key cryptography. Prior to version 0.1.0-rc.2, a timing side-channel was discovered in the Decompose algorithm which is used during ML-DSA signing to generate hints for the signature. This issue has been patched in version 0.1.0-rc.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
ml-dsacrates.io | < 0.1.0-rc.3 | 0.1.0-rc.3 |
Affected products
1- Range: dsa/v0.2.0, dsa/v0.3.0, dsa/v0.4.0, …
Patches
1035d9eef9848ml-dsa: use Barrett reduction instead of integer division to prevent side-channels (#1144)
2 files changed · +119 −32
ml-dsa/src/algebra.rs+50 −2 modified@@ -54,17 +54,65 @@ pub(crate) trait Decompose { fn decompose<TwoGamma2: Unsigned>(self) -> (Elem, Elem); } +/// Constant-time division by a compile-time constant divisor. +/// +/// This trait provides a constant-time alternative to the hardware division +/// instruction, which has variable timing based on operand values. +/// Uses Barrett reduction to compute `x / M` where M is a compile-time constant. +pub(crate) trait ConstantTimeDiv: Unsigned { + /// Bit shift for Barrett reduction, chosen to provide sufficient precision + const CT_DIV_SHIFT: usize; + /// Precomputed multiplier: ceil(2^SHIFT / M) + const CT_DIV_MULTIPLIER: u64; + + /// Perform constant-time division of x by `Self::U32` + /// Requires: x < Q (the field modulus, ~2^23) + #[allow(clippy::inline_always)] // Required for constant-time guarantees in crypto code + #[inline(always)] + fn ct_div(x: u32) -> u32 { + // Barrett reduction: q = (x * MULTIPLIER) >> SHIFT + // This gives us floor(x / M) for x < 2^SHIFT / MULTIPLIER * M + let x64 = u64::from(x); + let quotient = (x64 * Self::CT_DIV_MULTIPLIER) >> Self::CT_DIV_SHIFT; + // SAFETY: quotient is guaranteed to fit in u32 because: + // - x < Q (~2^23), so quotient = x / M < x < 2^23 < 2^32 + #[allow(clippy::cast_possible_truncation, clippy::as_conversions)] + let result = quotient as u32; + result + } +} + +impl<M> ConstantTimeDiv for M +where + M: Unsigned, +{ + // Use a shift that provides enough precision for the ML-DSA field (Q ~ 2^23) + // We need SHIFT > log2(Q) + log2(M) to ensure accuracy + // With Q < 2^24 and M < 2^20, SHIFT = 48 is sufficient + const CT_DIV_SHIFT: usize = 48; + + // Precompute the multiplier at compile time + // We add (M-1) before dividing to get ceiling division, ensuring we never underestimate + #[allow(clippy::integer_division_remainder_used)] + const CT_DIV_MULTIPLIER: u64 = (1u64 << Self::CT_DIV_SHIFT).div_ceil(M::U64); +} + impl Decompose for Elem { // Algorithm 36 Decompose + // + // This implementation uses constant-time division to avoid timing side-channels. + // The original algorithm used hardware division which has variable timing based + // on operand values, potentially leaking secret information during signing. fn decompose<TwoGamma2: Unsigned>(self) -> (Elem, Elem) { let r_plus = self.clone(); let r0 = r_plus.mod_plus_minus::<TwoGamma2>(); if r_plus - r0 == Elem::new(BaseField::Q - 1) { (Elem::new(0), r0 - Elem::new(1)) } else { - let mut r1 = r_plus - r0; - r1.0 /= TwoGamma2::U32; + let diff = r_plus - r0; + // Use constant-time division instead of hardware division + let r1 = Elem::new(TwoGamma2::ct_div(diff.0)); (r1, r0) } }
ml-dsa/src/ntt.rs+69 −30 modified@@ -50,28 +50,46 @@ pub(crate) trait Ntt { fn ntt(&self) -> Self::Output; } +/// Constant-time NTT butterfly layer. +/// +/// Uses const generics to ensure loop bounds are compile-time constants, +/// avoiding UDIV instructions from runtime `step_by` calculations. +#[allow(clippy::inline_always)] // Required for constant-time guarantees in crypto code +#[inline(always)] +fn ntt_layer<const LEN: usize, const ITERATIONS: usize>(w: &mut [Elem; 256], m: &mut usize) { + for i in 0..ITERATIONS { + let start = i * 2 * LEN; + *m += 1; + let z = ZETA_POW_BITREV[*m]; + for j in start..(start + LEN) { + let t = z * w[j + LEN]; + w[j + LEN] = w[j] - t; + w[j] = w[j] + t; + } + } +} + impl Ntt for Polynomial { type Output = NttPolynomial; // Algorithm 41 NTT + // + // This implementation uses const-generic helper functions to ensure all loop + // bounds are compile-time constants, avoiding potential UDIV instructions. fn ntt(&self) -> Self::Output { - let mut w = self.0.clone(); - + let mut w: [Elem; 256] = self.0.clone().into(); let mut m = 0; - for len in [128, 64, 32, 16, 8, 4, 2, 1] { - for start in (0..256).step_by(2 * len) { - m += 1; - let z = ZETA_POW_BITREV[m]; - - for j in start..(start + len) { - let t = z * w[j + len]; - w[j + len] = w[j] - t; - w[j] = w[j] + t; - } - } - } - NttPolynomial::new(w) + ntt_layer::<128, 1>(&mut w, &mut m); + ntt_layer::<64, 2>(&mut w, &mut m); + ntt_layer::<32, 4>(&mut w, &mut m); + ntt_layer::<16, 8>(&mut w, &mut m); + ntt_layer::<8, 16>(&mut w, &mut m); + ntt_layer::<4, 32>(&mut w, &mut m); + ntt_layer::<2, 64>(&mut w, &mut m); + ntt_layer::<1, 128>(&mut w, &mut m); + + NttPolynomial::new(w.into()) } } @@ -89,30 +107,51 @@ pub(crate) trait NttInverse { fn ntt_inverse(&self) -> Self::Output; } +/// Constant-time inverse NTT butterfly layer. +/// +/// Uses const generics to ensure loop bounds are compile-time constants, +/// avoiding UDIV instructions from runtime `step_by` calculations. +#[allow(clippy::inline_always)] // Required for constant-time guarantees in crypto code +#[inline(always)] +fn ntt_inverse_layer<const LEN: usize, const ITERATIONS: usize>( + w: &mut [Elem; 256], + m: &mut usize, +) { + for i in 0..ITERATIONS { + let start = i * 2 * LEN; + *m -= 1; + let z = -ZETA_POW_BITREV[*m]; + for j in start..(start + LEN) { + let t = w[j]; + w[j] = t + w[j + LEN]; + w[j + LEN] = z * (t - w[j + LEN]); + } + } +} + impl NttInverse for NttPolynomial { type Output = Polynomial; // Algorithm 42 NTT^{−1} + // + // This implementation uses const-generic helper functions to ensure all loop + // bounds are compile-time constants, avoiding potential UDIV instructions. fn ntt_inverse(&self) -> Self::Output { const INVERSE_256: Elem = Elem::new(8_347_681); - let mut w = self.0.clone(); - + let mut w: [Elem; 256] = self.0.clone().into(); let mut m = 256; - for len in [1, 2, 4, 8, 16, 32, 64, 128] { - for start in (0..256).step_by(2 * len) { - m -= 1; - let z = -ZETA_POW_BITREV[m]; - - for j in start..(start + len) { - let t = w[j]; - w[j] = t + w[j + len]; - w[j + len] = z * (t - w[j + len]); - } - } - } - INVERSE_256 * &Polynomial::new(w) + ntt_inverse_layer::<1, 128>(&mut w, &mut m); + ntt_inverse_layer::<2, 64>(&mut w, &mut m); + ntt_inverse_layer::<4, 32>(&mut w, &mut m); + ntt_inverse_layer::<8, 16>(&mut w, &mut m); + ntt_inverse_layer::<16, 8>(&mut w, &mut m); + ntt_inverse_layer::<32, 4>(&mut w, &mut m); + ntt_inverse_layer::<64, 2>(&mut w, &mut m); + ntt_inverse_layer::<128, 1>(&mut w, &mut m); + + INVERSE_256 * &Polynomial::new(w.into()) } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-hcp2-x6j4-29j7ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-22705ghsaADVISORY
- github.com/RustCrypto/signatures/commit/035d9eef98486ecd00a8bf418c7817eb14dd6558nvdWEB
- github.com/RustCrypto/signatures/pull/1144nvdWEB
- github.com/RustCrypto/signatures/security/advisories/GHSA-hcp2-x6j4-29j7nvdWEB
- rustsec.org/advisories/RUSTSEC-2025-0144.htmlghsaWEB
News mentions
0No linked articles in our index yet.