VYPR
Medium severity5.3OSV Advisory· Published Jan 28, 2026· Updated Apr 15, 2026

CVE-2026-24850

CVE-2026-24850

Description

The ML-DSA crate is a Rust implementation of the Module-Lattice-Based Digital Signature Standard (ML-DSA). Starting in version 0.0.4 and prior to version 0.1.0-rc.4, the ML-DSA signature verification implementation in the RustCrypto ml-dsa crate incorrectly accepts signatures with repeated (duplicate) hint indices. According to the ML-DSA specification (FIPS 204 / RFC 9881), hint indices within each polynomial must be strictly increasing. The current implementation uses a non-strict monotonic check (<= instead of <), allowing duplicate indices. This is a regression bug. The original implementation was correct, but a commit in version 0.0.4 inadvertently changed the strict < comparison to <=, introducing the vulnerability. Version 0.1.0-rc.4 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
ml-dsacrates.io
>= 0.0.4, < 0.1.0-rc.40.1.0-rc.4

Affected products

1

Patches

2
400961412be2

ml-dsa: fix Wycheproof verification test failures (#1187)

https://github.com/RustCrypto/signaturesTony ArcieriJan 27, 2026via ghsa
2 files changed · +6 27
  • ml-dsa/src/hint.rs+5 6 modified
    @@ -115,17 +115,15 @@ where
             y
         }
     
    -    fn monotonic(a: &[usize]) -> bool {
    -        a.iter().enumerate().all(|(i, x)| i == 0 || a[i - 1] <= *x)
    -    }
    -
         pub(crate) fn bit_unpack(y: &EncodedHint<P>) -> Option<Self> {
             let (indices, cuts) = P::split_hint(y);
             let cuts: Array<usize, P::K> = cuts.iter().map(|x| usize::from(*x)).collect();
     
             let indices: Array<usize, P::Omega> = indices.iter().map(|x| usize::from(*x)).collect();
             let max_cut: usize = cuts.iter().copied().max().unwrap();
    -        if !Self::monotonic(&cuts)
    +
    +        // cuts must be monotonic but can repeat
    +        if !cuts.windows(2).all(|w| w[0] <= w[1])
                 || max_cut > indices.len()
                 || indices[max_cut..].iter().copied().max().unwrap_or(0) > 0
             {
    @@ -137,7 +135,8 @@ where
             for (i, &end) in cuts.iter().enumerate() {
                 let indices = &indices[start..end];
     
    -            if !Self::monotonic(indices) {
    +            // indices must be strictly increasing
    +            if !indices.windows(2).all(|w| w[0] < w[1]) {
                     return None;
                 }
     
    
  • ml-dsa/tests/wycheproof.rs+1 21 modified
    @@ -103,9 +103,6 @@ macro_rules! mldsa_sign_seed_test {
     
     macro_rules! mldsa_verify_test {
         ($name:ident, $json_file:expr, $keypair:ident) => {
    -        mldsa_verify_test!($name, $json_file, $keypair, []);
    -    };
    -    ($name:ident, $json_file:expr, $keypair:ident, $skip:expr) => {
             #[test]
             fn $name() {
                 let tests = load_json_file!($json_file);
    @@ -116,11 +113,6 @@ macro_rules! mldsa_verify_test {
                         for test in &group.tests {
                             println!("Test #{}: {} ({:?})", test.id, &test.comment, &test.result);
     
    -                        if $skip.contains(&test.id) {
    -                            println!("Test #{} is in skip list, skipping!", test.id);
    -                            continue;
    -                        }
    -
                             if let Some(sig) = test
                                 .sig
                                 .as_slice()
    @@ -150,10 +142,6 @@ macro_rules! mldsa_verify_test {
         };
     }
     
    -// TODO(tarcieri): debug these test failures
    -const SIGNATURE_WITH_A_REPEATED_HINT_TEST_ID: usize = 18;
    -const PUBLIC_KEY_WITH_T1_COMPONENT_SET_TO_ZERO_TEST_ID: usize = 68;
    -
     mldsa_sign_seed_test!(
         mldsa_44_sign_seed_test,
         "mldsa_44_sign_seed_test.json",
    @@ -169,14 +157,6 @@ mldsa_sign_seed_test!(
         "mldsa_87_sign_seed_test.json",
         MlDsa87
     );
    -mldsa_verify_test!(
    -    mldsa_44_verify_test,
    -    "mldsa_44_verify_test.json",
    -    MlDsa44,
    -    [
    -        SIGNATURE_WITH_A_REPEATED_HINT_TEST_ID,
    -        PUBLIC_KEY_WITH_T1_COMPONENT_SET_TO_ZERO_TEST_ID
    -    ]
    -);
    +mldsa_verify_test!(mldsa_44_verify_test, "mldsa_44_verify_test.json", MlDsa44);
     mldsa_verify_test!(mldsa_65_verify_test, "mldsa_65_verify_test.json", MlDsa65);
     mldsa_verify_test!(mldsa_87_verify_test, "mldsa_87_verify_test.json", MlDsa87);
    
b01c3b73dd08

Make ML-DSA signature decoding follow the spec (#895)

https://github.com/RustCrypto/signaturesRichard BarnesFeb 12, 2025via ghsa
3 files changed · +93 5
  • ml-dsa/src/algebra.rs+50 0 modified
    @@ -210,3 +210,53 @@ impl<K: ArraySize> AlgebraExt for Vector<K> {
             )
         }
     }
    +
    +#[cfg(test)]
    +mod test {
    +    use super::*;
    +
    +    use crate::{MlDsa65, ParameterSet};
    +
    +    type Mod = <MlDsa65 as ParameterSet>::TwoGamma2;
    +    const MOD: u32 = Mod::U32;
    +    const MOD_ELEM: Elem = Elem::new(MOD);
    +
    +    #[test]
    +    fn mod_plus_minus() {
    +        for x in 0..MOD {
    +            // BaseField::Q {
    +            let x = Elem::new(x);
    +            let x0 = x.mod_plus_minus::<Mod>();
    +
    +            // Outputs from mod+- should be in the half-open interval (-gamma2, gamma2]
    +            let positive_bound = x0.0 <= MOD / 2;
    +            let negative_bound = x0.0 > BaseField::Q - MOD / 2;
    +            assert!(positive_bound || negative_bound);
    +
    +            // The output should be equivalent to the input, mod 2 * gamma2.  We add 2 * gamma2
    +            // before comparing so that both values are "positive", avoiding interactions between
    +            // the mod-Q and mod-M operations.
    +            let xn = x + MOD_ELEM;
    +            let x0n = x0 + MOD_ELEM;
    +            assert_eq!(xn.0 % MOD, x0n.0 % MOD);
    +        }
    +    }
    +
    +    #[test]
    +    fn decompose() {
    +        for x in 0..MOD {
    +            let x = Elem::new(x);
    +            let (x1, x0) = x.decompose::<Mod>();
    +
    +            // The low-order output from decompose() is a mod+- output, optionally minus one.  So
    +            // they should be in the closed interval [-gamma2, gamma2].
    +            let positive_bound = x0.0 <= MOD / 2;
    +            let negative_bound = x0.0 >= BaseField::Q - MOD / 2;
    +            assert!(positive_bound || negative_bound);
    +
    +            // The low-order and high-order outputs should combine to form the input.
    +            let xx = (MOD * x1.0 + x0.0) % BaseField::Q;
    +            assert_eq!(xx, x.0);
    +        }
    +    }
    +}
    
  • ml-dsa/src/hint.rs+4 4 modified
    @@ -22,18 +22,18 @@ fn use_hint<TwoGamma2: Unsigned>(h: bool, r: Elem) -> Elem {
         let gamma2 = TwoGamma2::U32 / 2;
         if h && r0.0 <= gamma2 {
             Elem::new((r1.0 + 1) % m)
    -    } else if h && r0.0 > BaseField::Q - gamma2 {
    +    } else if h && r0.0 >= BaseField::Q - gamma2 {
             Elem::new((r1.0 + m - 1) % m)
         } else if h {
             // We use the Elem encoding even for signed integers.  Since r0 is computed
    -        // mod+- 2*gamma2, it is guaranteed to be in (gamma2, gamma2].
    +        // mod+- 2*gamma2 (possibly minus 1), it is guaranteed to be in [-gamma2, gamma2].
             unreachable!();
         } else {
             r1
         }
     }
     
    -#[derive(Clone, PartialEq)]
    +#[derive(Clone, PartialEq, Debug)]
     pub struct Hint<P>(pub Array<Array<bool, U256>, P::K>)
     where
         P: SignatureParams;
    @@ -116,7 +116,7 @@ where
         }
     
         fn monotonic(a: &[usize]) -> bool {
    -        a.iter().enumerate().all(|(i, x)| i == 0 || a[i - 1] < *x)
    +        a.iter().enumerate().all(|(i, x)| i == 0 || a[i - 1] <= *x)
         }
     
         pub fn bit_unpack(y: &EncodedHint<P>) -> Option<Self> {
    
  • ml-dsa/src/lib.rs+39 1 modified
    @@ -89,7 +89,7 @@ pub use crate::util::B32;
     pub use signature::Error;
     
     /// An ML-DSA signature
    -#[derive(Clone, PartialEq)]
    +#[derive(Clone, PartialEq, Debug)]
     pub struct Signature<P: MlDsaParams> {
         c_tilde: Array<u8, P::Lambda>,
         z: Vector<P::L>,
    @@ -899,4 +899,42 @@ mod test {
             sign_verify_round_trip_test::<MlDsa65>();
             sign_verify_round_trip_test::<MlDsa87>();
         }
    +
    +    fn many_round_trip_test<P>()
    +    where
    +        P: MlDsaParams,
    +    {
    +        use rand::Rng;
    +
    +        const ITERATIONS: usize = 1000;
    +
    +        let mut rng = rand::thread_rng();
    +        let mut seed = B32::default();
    +
    +        for _i in 0..ITERATIONS {
    +            let seed_data: &mut [u8] = seed.as_mut();
    +            rng.fill(seed_data);
    +
    +            let kp = P::key_gen_internal(&seed);
    +            let sk = kp.signing_key;
    +            let vk = kp.verifying_key;
    +
    +            let M = b"Hello world";
    +            let rnd = Array([0u8; 32]);
    +            let sig = sk.sign_internal(&[M], &rnd);
    +
    +            let sig_enc = sig.encode();
    +            let sig_dec = Signature::<P>::decode(&sig_enc).unwrap();
    +
    +            assert_eq!(sig_dec, sig);
    +            assert!(vk.verify_internal(&[M], &sig_dec));
    +        }
    +    }
    +
    +    #[test]
    +    fn many_round_trip() {
    +        many_round_trip_test::<MlDsa44>();
    +        many_round_trip_test::<MlDsa65>();
    +        many_round_trip_test::<MlDsa87>();
    +    }
     }
    

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

12

News mentions

0

No linked articles in our index yet.