@@ -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