diff --git a/crypto/crypto/Cargo.toml b/crypto/crypto/Cargo.toml index 6b78f81e7..23e5e949b 100644 --- a/crypto/crypto/Cargo.toml +++ b/crypto/crypto/Cargo.toml @@ -17,8 +17,6 @@ serde = { version = "1.0", default-features = false, features = [ "alloc", ], optional = true } rayon = { version = "1.8.0", optional = true } -rand = { version = "0.8.5", default-features = false } -rand_chacha = { version = "0.3.1", default-features = false } memmap2 = { version = "0.9", optional = true } tempfile = { version = "3", optional = true } libc = { version = "0.2", optional = true } diff --git a/crypto/crypto/src/fiat_shamir/default_transcript.rs b/crypto/crypto/src/fiat_shamir/default_transcript.rs index 819b0f761..d64f805a2 100644 --- a/crypto/crypto/src/fiat_shamir/default_transcript.rs +++ b/crypto/crypto/src/fiat_shamir/default_transcript.rs @@ -10,10 +10,33 @@ use math::{ }, traits::AsBytes, }; -use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; +/// Bytes produced by one Keccak squeeze; the duplex output buffer holds this +/// many bytes and hands them out `8` at a time (`SQUEEZE_LEN / 8` u64 candidates +/// per squeeze). +const SQUEEZE_LEN: usize = 32; + +/// Keccak-sponge Fiat-Shamir transcript with a Plonky3-style duplex output +/// buffer. +/// +/// Challenges are derived by squeezing the sponge and rejection-sampling field +/// coordinates directly from those bytes — there is **no CSPRNG**. Earlier this +/// type seeded a `ChaCha20Rng` from every squeeze and pulled the field element +/// from the keystream; on the recursion guest that ChaCha block was pure +/// software (Keccak is a precompile, ChaCha is not), so it dominated the +/// challenge-sampling cost while producing bytes the sponge already gives for +/// free. The output buffer amortizes one squeeze across up to `SQUEEZE_LEN / 8` +/// 64-bit candidates, so a cubic-extension element (3 coordinates) usually costs +/// a single squeeze. pub struct DefaultTranscript { hasher: Keccak256, + /// Duplex output buffer: bytes squeezed from the sponge, consumed 8 at a + /// time by field/`u64` sampling. Positions `[out_pos, SQUEEZE_LEN)` are the + /// bytes not yet handed out; `out_pos == SQUEEZE_LEN` means "empty, squeeze + /// to refill". Absorbing new data invalidates it (see `append_bytes`) so a + /// squeeze can never reflect input appended after it was produced. + out_buf: [u8; SQUEEZE_LEN], + out_pos: usize, phantom: PhantomData, } @@ -21,6 +44,8 @@ impl Clone for DefaultTranscript { fn clone(&self) -> Self { Self { hasher: self.hasher.clone(), + out_buf: self.out_buf, + out_pos: self.out_pos, phantom: PhantomData, } } @@ -34,18 +59,40 @@ where pub fn new(data: &[u8]) -> Self { let mut res = Self { hasher: Keccak256::new(), + out_buf: [0u8; SQUEEZE_LEN], + // Empty: the first sample forces a squeeze. + out_pos: SQUEEZE_LEN, phantom: PhantomData, }; res.append_bytes(data); res } + /// Raw squeeze: finalize the current sponge state, advance the hash chain by + /// absorbing the (reversed) output, and return it. Also invalidates the + /// duplex output buffer, so interleaving raw `sample()` calls with buffered + /// field/`u64` sampling can never reuse stale squeeze bytes. pub fn sample(&mut self) -> [u8; 32] { let mut result_hash: [u8; 32] = self.hasher.finalize_reset().into(); result_hash.reverse(); self.hasher.update(result_hash); + self.out_pos = SQUEEZE_LEN; result_hash } + + /// Next 64-bit candidate from the duplex output buffer, refilling with one + /// squeeze when fewer than 8 bytes remain. Big-endian, matching the byte + /// order `sample_u64` used when it read directly from `sample()`. + fn next_sample_u64(&mut self) -> u64 { + if self.out_pos + 8 > SQUEEZE_LEN { + self.out_buf = self.sample(); + self.out_pos = 0; + } + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&self.out_buf[self.out_pos..self.out_pos + 8]); + self.out_pos += 8; + u64::from_be_bytes(bytes) + } } impl Default for DefaultTranscript @@ -64,10 +111,17 @@ where FieldElement: AsBytes, { fn append_bytes(&mut self, new_bytes: &[u8]) { + // Absorbing new input invalidates any buffered squeeze output: a + // subsequent challenge must depend on this input, so drop the bytes + // squeezed before it. + self.out_pos = SQUEEZE_LEN; self.hasher.update(new_bytes); } fn append_field_element(&mut self, element: &FieldElement) { + // Absorb, same invalidation as `append_bytes` (the field element's bytes + // are streamed straight into the sponge with no intermediate `Vec`). + self.out_pos = SQUEEZE_LEN; element.stream_bytes(&mut |b| self.hasher.update(b)); } @@ -76,15 +130,14 @@ where } fn sample_field_element(&mut self) -> FieldElement { - let mut rng = ::from_seed(self.sample()); - F::get_random_field_element_from_rng(&mut rng) + F::sample_field_element_from(|| self.next_sample_u64()) } fn sample_u64(&mut self, upper_bound: u64) -> u64 { assert!(upper_bound > 0, "upper_bound must be greater than 0"); let threshold = upper_bound.wrapping_neg() % upper_bound; loop { - let candidate = u64::from_be_bytes(self.sample()[..8].try_into().unwrap()); + let candidate = self.next_sample_u64(); if candidate >= threshold { return candidate % upper_bound; } diff --git a/crypto/math/src/field/extensions_goldilocks.rs b/crypto/math/src/field/extensions_goldilocks.rs index 246a3cb87..3bf19b70f 100644 --- a/crypto/math/src/field/extensions_goldilocks.rs +++ b/crypto/math/src/field/extensions_goldilocks.rs @@ -568,16 +568,14 @@ impl AsBytes for FieldElement { } impl HasDefaultTranscript for Degree3GoldilocksExtensionField { - fn get_random_field_element_from_rng(rng: &mut impl rand::Rng) -> FieldElement { - let mut sample = [0u8; 8]; + fn sample_field_element_from(mut next_u64: impl FnMut() -> u64) -> FieldElement { let mut coeffs = [FpE::zero(), FpE::zero(), FpE::zero()]; for coeff in &mut coeffs { loop { - rng.fill(&mut sample); - let int_sample = u64::from_be_bytes(sample); - if int_sample < GOLDILOCKS_PRIME { - *coeff = FpE::from(int_sample); + let candidate = next_u64(); + if candidate < GOLDILOCKS_PRIME { + *coeff = FpE::from(candidate); break; } } diff --git a/crypto/math/src/field/goldilocks.rs b/crypto/math/src/field/goldilocks.rs index 1d60ee5b2..39fd707b7 100644 --- a/crypto/math/src/field/goldilocks.rs +++ b/crypto/math/src/field/goldilocks.rs @@ -545,13 +545,11 @@ impl IsFFTField for GoldilocksField { } impl HasDefaultTranscript for GoldilocksField { - fn get_random_field_element_from_rng(rng: &mut impl rand::Rng) -> FieldElement { - let mut sample = [0u8; 8]; + fn sample_field_element_from(mut next_u64: impl FnMut() -> u64) -> FieldElement { loop { - rng.fill(&mut sample); - let int_sample = u64::from_be_bytes(sample); - if int_sample < GOLDILOCKS_PRIME { - return FieldElement::from(int_sample); + let candidate = next_u64(); + if candidate < GOLDILOCKS_PRIME { + return FieldElement::from(candidate); } } } diff --git a/crypto/math/src/field/traits.rs b/crypto/math/src/field/traits.rs index 04dcc410d..a0e0a7fbc 100644 --- a/crypto/math/src/field/traits.rs +++ b/crypto/math/src/field/traits.rs @@ -298,7 +298,11 @@ pub trait IsPrimeField: IsField { /// This trait is necessary for sampling a random field element with a uniform distribution. pub trait HasDefaultTranscript: IsField { - /// This function should truncates the sampled bits to the quantity required to represent the order of the base field - /// and returns a field element. - fn get_random_field_element_from_rng(rng: &mut impl rand::Rng) -> FieldElement; + /// Sample a uniform field element by pulling 64-bit candidates from `next_u64` + /// — a transcript squeeze stream — and rejection-sampling each field + /// coordinate into its canonical range. Rejection (rather than modular + /// reduction) keeps the distribution exactly uniform. The caller feeds bytes + /// straight from the Fiat-Shamir sponge, so no separate CSPRNG keystream is + /// generated (see `DefaultTranscript`). + fn sample_field_element_from(next_u64: impl FnMut() -> u64) -> FieldElement; }