Skip to content

Commit bf6d5cf

Browse files
fix: ExpandMsgDrand scalar validation always passed
The validity check in ExpandMsgDrand::expand_message used `serialized_size(Compress::Yes) > 0` which always returns 32 > 0 = true. This means values >= Fr::MODULUS were never rejected and instead silently reduced via from_le_bytes_mod_order, producing a different scalar than the Go (drand/kyber) and JS (tlock-js) reference implementations. Measured impact: ~9.3% of inputs (926/10,000 trials) produce a scalar that differs from Go/JS, causing cross-language decrypt to fail silently. Fix: replace with round-trip serialization check. Deserialize, re-serialize, and compare bytes. If they differ, modular reduction occurred and the value was out of range -- reject and advance to the next iteration, matching the Go reference which uses UnmarshalBinary as its validity test. Added two regression tests: - test_expand_msg_drand_rejects_out_of_range: confirms out-of-range values at i=1 are rejected (not mod-reduced) - test_expand_msg_drand_matches_reference_10k: reimplements the Go/JS reference algorithm and verifies byte-identical output over 10,000 inputs, including ~930 that exercise the rejection path
1 parent a498bb1 commit bf6d5cf

1 file changed

Lines changed: 153 additions & 6 deletions

File tree

tlock/src/ibe.rs

Lines changed: 153 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)