@@ -349,13 +349,19 @@ where
349349 . finalize ( )
350350 . to_vec ( ) ;
351351 * h. first_mut ( ) . unwrap ( ) = h. first ( ) . unwrap ( ) >> BITS_TO_MASK_FOR_BLS12381 ;
352- // test if we can build a valid scalar out of n
353- // this is a hash method to be compatible with the existing implementation
352+ // Check if the masked hash is a valid scalar (< Fr modulus).
353+ // We reverse to little-endian, deserialize, re-serialize, and compare.
354+ // If the round-trip matches, no modular reduction occurred,
355+ // meaning the value was already in range. This mirrors the Go
356+ // reference (drand/kyber) which uses UnmarshalBinary as a
357+ // validity test.
354358 let rev: Vec < u8 > = h. iter ( ) . copied ( ) . rev ( ) . collect ( ) ;
355- if ScalarField :: from_le_bytes_mod_order ( & rev)
356- . serialized_size ( ark_serialize:: Compress :: Yes )
357- > 0
358- {
359+ let candidate = ScalarField :: from_le_bytes_mod_order ( & rev) ;
360+ let mut rt = vec ! [ ] ;
361+ candidate
362+ . serialize_compressed ( & mut rt)
363+ . expect ( "scalar serialization cannot fail" ) ;
364+ if rt == rev {
359365 buf. copy_from_slice ( & rev) ;
360366 return ;
361367 }
@@ -382,4 +388,145 @@ mod tests {
382388 let x = vec ! [ ] ;
383389 assert_eq ! ( xor( & a, & b) , x) ;
384390 }
391+
392+ /// Verify that ExpandMsgDrand rejects values >= Fr modulus and advances
393+ /// to the next iteration, matching the Go (drand/kyber) and JS (tlock-js)
394+ /// implementations.
395+ ///
396+ /// This is a regression test: the previous check
397+ /// `serialized_size(Compress::Yes) > 0` always returned 32 > 0 = true,
398+ /// so values >= Fr.ORDER were silently reduced via from_le_bytes_mod_order,
399+ /// producing a different scalar than Go/JS (which skip to i+1).
400+ #[ test]
401+ fn test_expand_msg_drand_rejects_out_of_range ( ) {
402+ // Fr.ORDER for BLS12-381 (big-endian hex):
403+ // 73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001
404+ let order_be: [ u8 ; 32 ] = [
405+ 0x73 , 0xed , 0xa7 , 0x53 , 0x29 , 0x9d , 0x7d , 0x48 , 0x33 , 0x39 , 0xd8 , 0x08 , 0x09 , 0xa1 ,
406+ 0xd8 , 0x05 , 0x53 , 0xbd , 0xa4 , 0x02 , 0xff , 0xfe , 0x5b , 0xfe , 0xff , 0xff , 0xff , 0xff ,
407+ 0x00 , 0x00 , 0x00 , 0x01 ,
408+ ] ;
409+
410+ // Scan for an input where i=1 hash (after masking) >= Fr.ORDER.
411+ // With ~9.3% probability per trial this is found quickly.
412+ let mut found = false ;
413+ for trial in 0u32 ..200 {
414+ let msg = Sha256 :: new ( )
415+ . chain ( b"IBE-H3" )
416+ . chain ( trial. to_le_bytes ( ) )
417+ . chain ( b"test" )
418+ . finalize ( ) ;
419+
420+ let mut h = Sha256 :: new ( )
421+ . chain ( 1u16 . to_le_bytes ( ) )
422+ . chain ( msg. as_slice ( ) )
423+ . finalize ( )
424+ . to_vec ( ) ;
425+ h[ 0 ] >>= 1 ; // BITS_TO_MASK_FOR_BLS12381 = 1
426+
427+ // Check if this hash >= ORDER (big-endian comparison after masking)
428+ if h. as_slice ( ) >= & order_be[ ..] {
429+ // This input triggers the bug in the old code.
430+ // With the fix, expand_message must skip i=1 and use i=2+.
431+ let mut buf_fixed = [ 0u8 ; 32 ] ;
432+ ExpandMsgDrand :: < Sha256 > :: expand_message ( msg. as_slice ( ) , & [ ] , & mut buf_fixed) ;
433+
434+ // The result must NOT be the reduced i=1 value.
435+ let reduced = ScalarField :: from_le_bytes_mod_order (
436+ & h. iter ( ) . copied ( ) . rev ( ) . collect :: < Vec < u8 > > ( ) ,
437+ ) ;
438+ let mut reduced_bytes = vec ! [ ] ;
439+ reduced. serialize_compressed ( & mut reduced_bytes) . unwrap ( ) ;
440+
441+ assert_ne ! (
442+ buf_fixed. as_slice( ) ,
443+ reduced_bytes. as_slice( ) ,
444+ "expand_message returned mod-reduced i=1 value for trial {trial}; \
445+ it should have rejected and advanced to the next iteration"
446+ ) ;
447+
448+ found = true ;
449+ break ;
450+ }
451+ }
452+ assert ! (
453+ found,
454+ "failed to find a test case where i=1 hash >= Fr.ORDER within 200 trials"
455+ ) ;
456+ }
457+
458+ /// Exhaustive cross-implementation check: reimplement the Go/JS reference
459+ /// h3 algorithm in pure Rust (using byte-level Fr.ORDER comparison) and
460+ /// verify ExpandMsgDrand produces identical output for 10,000 random inputs.
461+ ///
462+ /// The reference algorithm (drand/kyber ibe.go, tlock-js):
463+ /// buffer = SHA256("IBE-H3" || sigma || msg)
464+ /// for i = 1..65535:
465+ /// h = SHA256(i_LE16 || buffer)
466+ /// h[0] >>= 1 // mask top bit for BLS12-381
467+ /// rev = reverse(h) // big-endian -> little-endian
468+ /// if h < Fr.ORDER: // big-endian byte comparison
469+ /// return rev
470+ #[ test]
471+ fn test_expand_msg_drand_matches_reference_10k ( ) {
472+ let order_be: [ u8 ; 32 ] = [
473+ 0x73 , 0xed , 0xa7 , 0x53 , 0x29 , 0x9d , 0x7d , 0x48 , 0x33 , 0x39 , 0xd8 , 0x08 , 0x09 , 0xa1 ,
474+ 0xd8 , 0x05 , 0x53 , 0xbd , 0xa4 , 0x02 , 0xff , 0xfe , 0x5b , 0xfe , 0xff , 0xff , 0xff , 0xff ,
475+ 0x00 , 0x00 , 0x00 , 0x01 ,
476+ ] ;
477+
478+ let mut rejected_count = 0u32 ;
479+
480+ for trial in 0u32 ..10_000 {
481+ // Generate deterministic but varied input
482+ let sigma = Sha256 :: new ( )
483+ . chain ( b"sigma" )
484+ . chain ( trial. to_le_bytes ( ) )
485+ . finalize ( ) ;
486+ let msg_bytes = Sha256 :: new ( )
487+ . chain ( b"msg" )
488+ . chain ( trial. to_le_bytes ( ) )
489+ . finalize ( ) ;
490+
491+ // Compute buffer = SHA256("IBE-H3" || sigma || msg) -- same as encrypt() does
492+ let buffer = Sha256 :: new ( )
493+ . chain ( b"IBE-H3" )
494+ . chain ( sigma. as_slice ( ) )
495+ . chain ( msg_bytes. as_slice ( ) )
496+ . finalize ( ) ;
497+
498+ // Reference implementation: iterate until valid scalar found
499+ let mut reference_result = [ 0u8 ; 32 ] ;
500+ for i in 1u16 ..u16:: MAX {
501+ let mut h = Sha256 :: new ( )
502+ . chain ( i. to_le_bytes ( ) )
503+ . chain ( buffer. as_slice ( ) )
504+ . finalize ( )
505+ . to_vec ( ) ;
506+ h[ 0 ] >>= 1 ; // BITS_TO_MASK_FOR_BLS12381
507+
508+ // Go/JS check: is h (big-endian) < Fr.ORDER?
509+ if h. as_slice ( ) < & order_be[ ..] {
510+ let rev: Vec < u8 > = h. iter ( ) . copied ( ) . rev ( ) . collect ( ) ;
511+ reference_result. copy_from_slice ( & rev) ;
512+ if i > 1 {
513+ rejected_count += 1 ;
514+ }
515+ break ;
516+ }
517+ }
518+
519+ // ExpandMsgDrand result
520+ let mut expand_result = [ 0u8 ; 32 ] ;
521+ ExpandMsgDrand :: < Sha256 > :: expand_message ( buffer. as_slice ( ) , & [ ] , & mut expand_result) ;
522+
523+ assert_eq ! ( expand_result, reference_result, "mismatch at trial {trial}" ) ;
524+ }
525+
526+ // Sanity: we must have hit at least some rejections (expected ~930 out of 10k)
527+ assert ! (
528+ rejected_count > 0 ,
529+ "no rejections seen in 10k trials -- test is not exercising the rejection path"
530+ ) ;
531+ }
385532}
0 commit comments