Skip to content

Commit 231471c

Browse files
committed
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 <=.
1 parent a51becc commit 231471c

1 file changed

Lines changed: 167 additions & 0 deletions

File tree

ml-dsa/src/lib.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,3 +558,170 @@ mod test {
558558
test_debug::<MlDsa87>();
559559
}
560560
}
561+
562+
// Deterministic, unit-level regression tests for the HintBitUnpack canonicity
563+
// clauses (FIPS 204 Algorithm 21), the GHSA-5x2r-hc65-25f9 / CVE-2026-24850 site.
564+
// These complement the Wycheproof verification corpus (which covers end-to-end
565+
// verification of the repeated-hint vector) by exercising `Signature::decode`
566+
// directly and pinning each decoder-local rule: strict per-polynomial index
567+
// ordering (the bug was `<=` where `<` is required), monotonic bounded cuts, zero
568+
// padding after the last cut, and the legal boundary that identical index values
569+
// may occur in different polynomials. Run for all three parameter sets.
570+
//
571+
// The encoded hint is the trailing omega + k bytes of the signature: omega index
572+
// bytes then k cumulative cut bytes.
573+
#[cfg(test)]
574+
mod hint_decode_regression {
575+
use super::*;
576+
use hybrid_array::typenum::Unsigned;
577+
578+
const MAX_K: usize = 8; // ML-DSA-87 k
579+
const MAX_OMEGA: usize = 80; // ML-DSA-44 omega
580+
581+
fn omega<P: MlDsaParams>() -> usize {
582+
<P::Omega as Unsigned>::USIZE
583+
}
584+
fn kk<P: MlDsaParams>() -> usize {
585+
<P::K as Unsigned>::USIZE
586+
}
587+
588+
fn base_sig<P: MlDsaParams>() -> EncodedSignature<P> {
589+
let ssk = SigningKey::<P>::from_seed(&Array::default());
590+
let esk = ssk.expanded_key();
591+
let rnd = Array([0u8; 32]);
592+
let m = b"hint regression";
593+
#[allow(deprecated)]
594+
let sig = esk.sign_internal(&[m], &rnd);
595+
sig.encode()
596+
}
597+
598+
// Overwrite the hint region: `indices` (zero-padded to omega) then the k cuts.
599+
fn with_hint<P: MlDsaParams>(
600+
mut enc: EncodedSignature<P>,
601+
indices: &[u8],
602+
cuts: &[u8],
603+
) -> EncodedSignature<P> {
604+
let (om, k) = (omega::<P>(), kk::<P>());
605+
assert_eq!(cuts.len(), k);
606+
let off = enc.len() - (om + k);
607+
for x in &mut enc[off..] {
608+
*x = 0;
609+
}
610+
enc[off..off + indices.len()].copy_from_slice(indices);
611+
enc[off + om..off + om + k].copy_from_slice(cuts);
612+
enc
613+
}
614+
615+
fn decodes<P: MlDsaParams>(enc: &EncodedSignature<P>) -> bool {
616+
Signature::<P>::decode(enc).is_some()
617+
}
618+
619+
// k cumulative cut bytes all equal to `v` (so polynomial 0 holds `v` hints).
620+
fn cut_buf<P: MlDsaParams>(v: u8) -> [u8; MAX_K] {
621+
let mut c = [0u8; MAX_K];
622+
for x in c.iter_mut().take(kk::<P>()) {
623+
*x = v;
624+
}
625+
c
626+
}
627+
628+
fn run_cases<P: MlDsaParams>() {
629+
let om = omega::<P>();
630+
let k = kk::<P>();
631+
let omb = u8::try_from(om).expect("omega fits in u8");
632+
633+
// control: strictly increasing indices [5, 9] in polynomial 0
634+
assert!(decodes::<P>(&with_hint::<P>(
635+
base_sig::<P>(),
636+
&[5, 9],
637+
&cut_buf::<P>(2)[..k]
638+
)));
639+
640+
// GHSA-5x2r-hc65-25f9: a repeated index within one polynomial (the `<=` bug)
641+
assert!(!decodes::<P>(&with_hint::<P>(
642+
base_sig::<P>(),
643+
&[5, 5],
644+
&cut_buf::<P>(2)[..k]
645+
)));
646+
647+
// decreasing index within one polynomial
648+
assert!(!decodes::<P>(&with_hint::<P>(
649+
base_sig::<P>(),
650+
&[9, 5],
651+
&cut_buf::<P>(2)[..k]
652+
)));
653+
654+
// non-monotonic cuts [1, 0, ...]
655+
let mut dc = cut_buf::<P>(1);
656+
dc[1] = 0;
657+
assert!(!decodes::<P>(&with_hint::<P>(
658+
base_sig::<P>(),
659+
&[5],
660+
&dc[..k]
661+
)));
662+
663+
// a cut strictly above omega
664+
let mut co = cut_buf::<P>(0);
665+
co[k - 1] = omb + 1;
666+
assert!(!decodes::<P>(&with_hint::<P>(
667+
base_sig::<P>(),
668+
&[],
669+
&co[..k]
670+
)));
671+
672+
// upper valid boundary: a cut EQUAL to omega, polynomial 0 using all omega
673+
// hints as strictly increasing indices 0..omega
674+
let mut full = [0u8; MAX_OMEGA];
675+
for (i, x) in full.iter_mut().enumerate().take(om) {
676+
*x = u8::try_from(i).expect("index fits in u8");
677+
}
678+
assert!(decodes::<P>(&with_hint::<P>(
679+
base_sig::<P>(),
680+
&full[..om],
681+
&cut_buf::<P>(omb)[..k]
682+
)));
683+
684+
// the empty hint (all cuts zero, all indices zero)
685+
assert!(decodes::<P>(&with_hint::<P>(
686+
base_sig::<P>(),
687+
&[],
688+
&cut_buf::<P>(0)[..k]
689+
)));
690+
691+
// nonzero byte immediately after the last used index (start of padding)
692+
let mut enc = with_hint::<P>(base_sig::<P>(), &[5], &cut_buf::<P>(1)[..k]);
693+
let off = enc.len() - (om + k);
694+
enc[off + 1] = 7;
695+
assert!(!decodes::<P>(&enc));
696+
697+
// nonzero byte in the final padding slot
698+
let mut enc = with_hint::<P>(base_sig::<P>(), &[5], &cut_buf::<P>(1)[..k]);
699+
let last = enc.len() - k - 1;
700+
enc[last] = 7;
701+
assert!(!decodes::<P>(&enc));
702+
703+
// legal: the same index value in DIFFERENT polynomials (strictness is per-poly)
704+
let mut cx = cut_buf::<P>(2);
705+
cx[0] = 1; // poly 0 = [5], poly 1 = [5]
706+
assert!(decodes::<P>(&with_hint::<P>(
707+
base_sig::<P>(),
708+
&[5, 5],
709+
&cx[..k]
710+
)));
711+
}
712+
713+
#[test]
714+
fn mldsa44() {
715+
run_cases::<MlDsa44>();
716+
}
717+
718+
#[test]
719+
fn mldsa65() {
720+
run_cases::<MlDsa65>();
721+
}
722+
723+
#[test]
724+
fn mldsa87() {
725+
run_cases::<MlDsa87>();
726+
}
727+
}

0 commit comments

Comments
 (0)