From dcf1eba586ab1ae9efd547bd0af6e3843c5977f1 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:32:12 +0000 Subject: [PATCH 1/3] fix(security): make bits() produce all bits from input slice bits() was using .zip((0..8).rev()), which stops at the shorter iterator and capped the output at 8 bits regardless of input length. This left the KV1 and Waters identity hashes with only 8 bits of entropy instead of 512 / 256, breaking IND-ID-CPA / IND-CCA2 security for those schemes. Switched to flat_map so every byte contributes its 8 bits, added unit tests for bit count and bit order. PostGuard production uses CGWKV (which does not call bits()) and is not affected, but this is a breaking change for any deployment using KV1 or Waters: existing USKs under the buggy hash will not work against ciphertexts produced under the fixed hash. Closes #12 --- src/util.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/util.rs b/src/util.rs index 0aae16f..2655be0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -44,8 +44,36 @@ pub fn bits<'a>(slice: &'a [u8]) -> impl Iterator + 'a { slice .iter() .rev() - .zip((0..8).rev()) - .map(|(x, i)| subtle::Choice::from((*x >> i) & 1)) + .flat_map(|byte| (0..8u8).rev().map(move |i| subtle::Choice::from((byte >> i) & 1))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bits_produces_eight_bits_per_byte() { + let data = [0xABu8; 64]; + assert_eq!(bits(&data).count(), 64 * 8); + + let data = [0xCDu8; 32]; + assert_eq!(bits(&data).count(), 32 * 8); + + let data = [0u8; 0]; + assert_eq!(bits(&data).count(), 0); + + let data = [0xFFu8; 1]; + let bits_vec: std::vec::Vec = bits(&data).map(|c| c.unwrap_u8()).collect(); + assert_eq!(bits_vec, [1, 1, 1, 1, 1, 1, 1, 1]); + } + + #[test] + fn bits_reflects_exact_bit_pattern() { + // 0b1010_0101 = 0xA5, high bit (bit 7) first. + let data = [0xA5u8]; + let bits_vec: std::vec::Vec = bits(&data).map(|c| c.unwrap_u8()).collect(); + assert_eq!(bits_vec, [1, 0, 1, 0, 0, 1, 0, 1]); + } } pub fn sha3_256(slice: &[u8]) -> [u8; 32] { From 2fbeac24b8be647265029283864c9194940176e9 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:32:18 +0000 Subject: [PATCH 2/3] style: cargo fmt --- src/util.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/util.rs b/src/util.rs index 2655be0..0f315f4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -41,10 +41,11 @@ pub fn rand_gt(rng: &mut R) -> Gt { } pub fn bits<'a>(slice: &'a [u8]) -> impl Iterator + 'a { - slice - .iter() - .rev() - .flat_map(|byte| (0..8u8).rev().map(move |i| subtle::Choice::from((byte >> i) & 1))) + slice.iter().rev().flat_map(|byte| { + (0..8u8) + .rev() + .map(move |i| subtle::Choice::from((byte >> i) & 1)) + }) } #[cfg(test)] From 12ee537328df411b60b994a0e1c3c2c9f8711434 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 12 May 2026 16:54:15 +0200 Subject: [PATCH 3/3] test: add realistic-identity collision tests for KV1 and Waters Demonstrates the security impact of the bits() bug at the public-API level: two real strings derived via Identity::derive_str whose SHA3 digests share the 8 bits the buggy code reads must still produce distinct curve points. Both tests fail under the pre-fix bits() and pass under the fix. --- src/ibe/waters.rs | 26 ++++++++++++++++++++++++++ src/kem/kiltz_vahlis_one.rs | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/ibe/waters.rs b/src/ibe/waters.rs index 83cf1c8..fe2294c 100644 --- a/src/ibe/waters.rs +++ b/src/ibe/waters.rs @@ -367,5 +367,31 @@ impl Compress for CipherText { #[cfg(test)] mod tests { + use super::*; + test_ibe!(Waters); + + // Two distinct strings whose SHA3-256 digests happen to agree on the 8 bits + // that the buggy `bits()` read (one bit each from the last 8 bytes of the + // digest). Under the old code, both produce the same curve point — i.e. a + // user secret key extracted for one would decrypt ciphertexts for the other. + #[test] + fn realistic_identities_do_not_collide_in_entangle() { + use crate::Derive; + + let mut rng = rand::thread_rng(); + let (pk, _sk) = Waters::setup(&mut rng); + + let id_a = Identity::derive_str("user17@example.com"); + let id_b = Identity::derive_str("user20@example.com"); + assert_ne!(id_a.0, id_b.0, "sanity: digests must differ"); + + let u_a = G1Affine::from(entangle(&pk, &id_a)); + let u_b = G1Affine::from(entangle(&pk, &id_b)); + + assert_ne!( + u_a, u_b, + "distinct identities must entangle to distinct curve points" + ); + } } diff --git a/src/kem/kiltz_vahlis_one.rs b/src/kem/kiltz_vahlis_one.rs index 5e1af1c..401fb1e 100644 --- a/src/kem/kiltz_vahlis_one.rs +++ b/src/kem/kiltz_vahlis_one.rs @@ -351,4 +351,26 @@ mod tests { #[cfg(feature = "mkem")] test_multi_kem!(KV1); + + // Two distinct strings whose SHA3-512 digests happen to agree on the 8 bits + // that the buggy `bits()` read (one bit each from the last 8 bytes of the + // digest). Under the old code, both produce the same curve point — i.e. a + // user secret key extracted for one would decrypt ciphertexts for the other. + #[test] + fn realistic_identities_do_not_collide_in_hash_to_curve() { + let mut rng = rand::thread_rng(); + let (pk, _sk) = KV1::setup(&mut rng); + + let id_a = Identity::derive_str("user12@example.com"); + let id_b = Identity::derive_str("user26@example.com"); + assert_ne!(id_a.0, id_b.0, "sanity: digests must differ"); + + let h_a = G1Affine::from(hash_to_curve(&pk, &id_a)); + let h_b = G1Affine::from(hash_to_curve(&pk, &id_b)); + + assert_ne!( + h_a, h_b, + "distinct identities must hash to distinct curve points" + ); + } }