Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions crypto/crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
61 changes: 57 additions & 4 deletions crypto/crypto/src/fiat_shamir/default_transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,42 @@ 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<F: HasDefaultTranscript> {
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<F>,
}

impl<F: HasDefaultTranscript> Clone for DefaultTranscript<F> {
fn clone(&self) -> Self {
Self {
hasher: self.hasher.clone(),
out_buf: self.out_buf,
out_pos: self.out_pos,
phantom: PhantomData,
}
}
Expand All @@ -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<F> Default for DefaultTranscript<F>
Expand All @@ -64,10 +111,17 @@ where
FieldElement<F>: 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<F>) {
// 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));
}

Expand All @@ -76,15 +130,14 @@ where
}

fn sample_field_element(&mut self) -> FieldElement<F> {
let mut rng = <ChaCha20Rng as SeedableRng>::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;
}
Expand Down
10 changes: 4 additions & 6 deletions crypto/math/src/field/extensions_goldilocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,16 +568,14 @@ impl AsBytes for FieldElement<Degree3GoldilocksExtensionField> {
}

impl HasDefaultTranscript for Degree3GoldilocksExtensionField {
fn get_random_field_element_from_rng(rng: &mut impl rand::Rng) -> FieldElement<Self> {
let mut sample = [0u8; 8];
fn sample_field_element_from(mut next_u64: impl FnMut() -> u64) -> FieldElement<Self> {
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;
}
}
Expand Down
10 changes: 4 additions & 6 deletions crypto/math/src/field/goldilocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
let mut sample = [0u8; 8];
fn sample_field_element_from(mut next_u64: impl FnMut() -> u64) -> FieldElement<Self> {
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);
}
}
}
Expand Down
10 changes: 7 additions & 3 deletions crypto/math/src/field/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>;
/// 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<Self>;
}
Loading