From 503f3919cc95c9a854f8aed8c3a18a51d7266dfa Mon Sep 17 00:00:00 2001 From: Amar Akshat Date: Mon, 6 Jul 2026 22:53:42 +0100 Subject: [PATCH] ml-dsa: deterministic hint-decode canonicity regression tests Adds unit-level tests exercising Signature::decode directly against the HintBitUnpack canonicity clauses (FIPS 204 Algorithm 21), the GHSA-5x2r-hc65-25f9 / CVE-2026-24850 site. They complement the Wycheproof verification corpus (which covers end-to-end verification of the repeated-hint vector) with decoder-local checks: strict per-polynomial index ordering (the bug was <= where < is required), monotonic bounded cuts, zero padding after the last cut, the empty hint, the cut == omega upper boundary, and the legal boundary that identical index values may occur in different polynomials. Run for all three parameter sets. Verified: pass on current main, and the repeated-index cases fail if the strict < in hint.rs is reverted to <=. --- ml-dsa/src/lib.rs | 160 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/ml-dsa/src/lib.rs b/ml-dsa/src/lib.rs index 7e6a1976..97bd0776 100644 --- a/ml-dsa/src/lib.rs +++ b/ml-dsa/src/lib.rs @@ -558,3 +558,163 @@ mod test { test_debug::(); } } + +// Deterministic, unit-level regression tests for the HintBitUnpack canonicity +// clauses (FIPS 204 Algorithm 21), the GHSA-5x2r-hc65-25f9 / CVE-2026-24850 site. +// These complement the Wycheproof verification corpus (which covers end-to-end +// verification of the repeated-hint vector) by exercising `Signature::decode` +// directly and pinning each decoder-local rule: strict per-polynomial index +// ordering (the bug was `<=` where `<` is required), monotonic bounded cuts, zero +// padding after the last cut, and the legal boundary that identical index values +// may occur in different polynomials. Run for all three parameter sets. +// +// The encoded hint is the trailing omega + k bytes of the signature: omega index +// bytes then k cumulative cut bytes. +#[cfg(test)] +mod hint_decode_regression { + use super::*; + use hybrid_array::typenum::Unsigned; + + const MAX_K: usize = 8; // ML-DSA-87 k + const MAX_OMEGA: usize = 80; // ML-DSA-44 omega + + fn omega() -> usize { + ::USIZE + } + fn kk() -> usize { + ::USIZE + } + + fn base_sig() -> EncodedSignature

{ + let ssk = SigningKey::

::from_seed(&Array::default()); + let sig = ssk + .expanded_key() + .sign_deterministic(b"hint regression", &[]) + .expect("sign"); + sig.encode() + } + + // Overwrite the hint region: `indices` (zero-padded to omega) then the k cuts. + fn with_hint( + mut enc: EncodedSignature

, + indices: &[u8], + cuts: &[u8], + ) -> EncodedSignature

{ + let (om, k) = (omega::

(), kk::

()); + assert_eq!(cuts.len(), k); + assert!(indices.len() <= om); + let off = enc.len() - (om + k); + for x in &mut enc[off..] { + *x = 0; + } + enc[off..off + indices.len()].copy_from_slice(indices); + enc[off + om..off + om + k].copy_from_slice(cuts); + enc + } + + fn decodes(enc: &EncodedSignature

) -> bool { + Signature::

::decode(enc).is_some() + } + + // k cumulative cut bytes all equal to `v` (so polynomial 0 holds `v` hints). + fn cut_buf(v: u8) -> [u8; MAX_K] { + let mut c = [0u8; MAX_K]; + for x in c.iter_mut().take(kk::

()) { + *x = v; + } + c + } + + fn run_cases() { + let om = omega::

(); + let k = kk::

(); + let omb = u8::try_from(om).expect("omega fits in u8"); + let base = base_sig::

(); + + // control: strictly increasing indices [5, 9] in polynomial 0 + assert!(decodes::

(&with_hint::

( + base.clone(), + &[5, 9], + &cut_buf::

(2)[..k] + ))); + + // GHSA-5x2r-hc65-25f9: a repeated index within one polynomial (the `<=` bug) + assert!(!decodes::

(&with_hint::

( + base.clone(), + &[5, 5], + &cut_buf::

(2)[..k] + ))); + + // decreasing index within one polynomial + assert!(!decodes::

(&with_hint::

( + base.clone(), + &[9, 5], + &cut_buf::

(2)[..k] + ))); + + // non-monotonic cuts [1, 0, ...] + let mut dc = cut_buf::

(1); + dc[1] = 0; + assert!(!decodes::

(&with_hint::

(base.clone(), &[5], &dc[..k]))); + + // a cut strictly above omega + let mut co = cut_buf::

(0); + co[k - 1] = omb + 1; + assert!(!decodes::

(&with_hint::

(base.clone(), &[], &co[..k]))); + + // upper valid boundary: a cut EQUAL to omega, polynomial 0 using all omega + // hints as strictly increasing indices 0..omega + let mut full = [0u8; MAX_OMEGA]; + for (i, x) in full.iter_mut().enumerate().take(om) { + *x = u8::try_from(i).expect("index fits in u8"); + } + assert!(decodes::

(&with_hint::

( + base.clone(), + &full[..om], + &cut_buf::

(omb)[..k] + ))); + + // the empty hint (all cuts zero, all indices zero) + assert!(decodes::

(&with_hint::

( + base.clone(), + &[], + &cut_buf::

(0)[..k] + ))); + + // nonzero byte immediately after the last used index (start of padding) + let mut enc = with_hint::

(base.clone(), &[5], &cut_buf::

(1)[..k]); + let off = enc.len() - (om + k); + enc[off + 1] = 7; + assert!(!decodes::

(&enc)); + + // nonzero byte in the final padding slot + let mut enc = with_hint::

(base.clone(), &[5], &cut_buf::

(1)[..k]); + let last = enc.len() - k - 1; + enc[last] = 7; + assert!(!decodes::

(&enc)); + + // legal: the same index value in DIFFERENT polynomials (strictness is per-poly) + let mut cx = cut_buf::

(2); + cx[0] = 1; // poly 0 = [5], poly 1 = [5] + assert!(decodes::

(&with_hint::

( + base.clone(), + &[5, 5], + &cx[..k] + ))); + } + + #[test] + fn mldsa44() { + run_cases::(); + } + + #[test] + fn mldsa65() { + run_cases::(); + } + + #[test] + fn mldsa87() { + run_cases::(); + } +}