Skip to content
Merged
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 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 All @@ -35,8 +33,6 @@ lambda-vm-syscalls = { path = "../../syscalls" }

[dev-dependencies]
math = { path = "../math", features = ["test-utils"] }
rand = "0.8.5"
rand_chacha = "0.3.1"
sha2 = { version = "0.10", default-features = false }
bincode = "1"

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: 9 additions & 1 deletion crypto/crypto/src/fiat_shamir/is_transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ pub trait IsTranscript<F: IsField> {
fn append_field_element(&mut self, element: &FieldElement<F>);
/// Appends a bytes to the transcript.
fn append_bytes(&mut self, new_bytes: &[u8]);
/// Returns the inner state of the transcript that fully determines its outputs.
/// Returns a digest of everything absorbed so far (the sponge state).
///
/// This binds the absorbed input stream, but it does NOT capture any
/// buffered squeeze output an implementation may hold (see
/// `DefaultTranscript`'s duplex output buffer): two transcripts with equal
/// `state()` produce identical future samples only if they also share the
/// same absorb/sample history. Prover and verifier stay synchronized
/// because they perform the same sequence of calls, not because `state()`
/// alone determines outputs.
fn state(&self) -> [u8; 32];
/// Returns a random field element.
fn sample_field_element(&mut self) -> FieldElement<F>;
Expand Down
109 changes: 109 additions & 0 deletions crypto/crypto/src/tests/default_transcript_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,112 @@ fn fork_isolation() {

assert_eq!(fork_a.sample(), fork_a_fresh.sample());
}

// =========================================================================
// Duplex output-buffer contract (the soundness-critical invalidation lines).
//
// The roundtrip suites structurally cannot catch a missing invalidation:
// prover and verifier would consume identical stale bytes in lockstep. Each
// test below fails if its invalidation is removed, because the "next" sample
// would then come from bytes squeezed BEFORE the interleaved absorb — i.e.
// a challenge that does not depend on the absorbed commitment.
// =========================================================================

#[test]
fn absorb_bytes_invalidates_buffered_squeeze_output() {
let mut t1 = DefaultTranscript::<GoldilocksField>::new(b"seed");
let mut t2 = DefaultTranscript::<GoldilocksField>::new(b"seed");
// Fill the buffer and consume one candidate on both.
assert_eq!(t1.sample_field_element(), t2.sample_field_element());
// Diverge the absorbed input; the next challenge must depend on it.
t1.append_bytes(b"root-A");
t2.append_bytes(b"root-B");
assert_ne!(
t1.sample_field_element(),
t2.sample_field_element(),
"a challenge sampled after an absorb must depend on the absorbed bytes"
);
}

#[test]
fn absorb_field_element_invalidates_buffered_squeeze_output() {
let mut t1 = DefaultTranscript::<GoldilocksField>::new(b"seed");
let mut t2 = DefaultTranscript::<GoldilocksField>::new(b"seed");
assert_eq!(t1.sample_field_element(), t2.sample_field_element());
t1.append_field_element(&FieldElement::from(1u64));
t2.append_field_element(&FieldElement::from(2u64));
assert_ne!(
t1.sample_field_element(),
t2.sample_field_element(),
"a challenge sampled after absorbing a field element must depend on it"
);
}

#[test]
fn raw_sample_invalidates_buffered_squeeze_output() {
let mut t1 = DefaultTranscript::<GoldilocksField>::new(b"seed");
let mut t2 = DefaultTranscript::<GoldilocksField>::new(b"seed");
assert_eq!(t1.sample_field_element(), t2.sample_field_element());
// Interleave a raw squeeze on t1 only (the grinding path does this).
let _ = t1.sample();
assert_ne!(
t1.sample_field_element(),
t2.sample_field_element(),
"a raw sample() must invalidate buffered bytes, not hand them out again"
);
}

/// The GPU-FRI fallback clones the transcript mid-buffer; a clone that loses
/// `out_buf`/`out_pos` would replay a different challenge sequence there.
#[test]
fn clone_replays_identically_mid_buffer() {
let mut t = DefaultTranscript::<GoldilocksField>::new(b"snapshot");
let _ = t.sample_field_element(); // leave the buffer partially consumed
let mut snap = t.clone();
let original: (Vec<FieldElement<GoldilocksField>>, u64) = (
(0..6).map(|_| t.sample_field_element()).collect(),
t.sample_u64(1 << 20),
);
let replay: (Vec<FieldElement<GoldilocksField>>, u64) = (
(0..6).map(|_| snap.sample_field_element()).collect(),
snap.sample_u64(1 << 20),
);
assert_eq!(
original, replay,
"a mid-buffer clone must replay identically"
);
}

/// Known-answer pin of the duplex byte semantics: BE u64 candidates, 8 bytes
/// per candidate, refill after 4, absorb invalidation between phases. Any
/// accidental change to byte order, chunking or refill granularity is a
/// transcript hard-fork and must show up here, not in a red proof.
#[test]
fn pinned_duplex_sample_semantics_across_refill() {
let mut t = DefaultTranscript::<GoldilocksField>::new(b"lambda-vm-kat-v1");
// Five base samples: the fifth forces a refill (4 candidates per squeeze).
let base: Vec<u64> = (0..5).map(|_| *t.sample_field_element().value()).collect();
assert_eq!(base, KAT_BASE);
// A bounded index draw from the same buffered stream.
assert_eq!(t.sample_u64(1 << 20), KAT_U64);
// An ext3 sample after an absorb (invalidation + coordinate order).
let mut te = DefaultTranscript::<Degree3GoldilocksExtensionField>::new(b"lambda-vm-kat-v1");
te.append_bytes(b"phase-2");
let ext = te.sample_field_element();
let coords: Vec<u64> = ext.value().iter().map(|c| *c.value()).collect();
assert_eq!(coords, KAT_EXT3);
}

const KAT_BASE: [u64; 5] = [
14480544354348864378,
16386050731901120766,
7548241632395108276,
4782457473227177333,
12741265158531607555,
];
const KAT_U64: u64 = 661275;
const KAT_EXT3: [u64; 3] = [
1422269417846962659,
13550644288133318291,
8414859559479507538,
];
1 change: 0 additions & 1 deletion crypto/math/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ serde_json = { version = "1.0", default-features = false, features = [
"alloc",
], optional = true }
proptest = { version = "1.1.0", optional = true }
rand = { version = "0.8.5", default-features = false }

# rayon
rayon = { version = "1.7", optional = true }
Expand Down
24 changes: 7 additions & 17 deletions crypto/math/src/field/extensions_goldilocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use crate::field::{
element::FieldElement,
errors::FieldError,
goldilocks::{GOLDILOCKS_PRIME, GoldilocksField, dot_product_2, dot_product_3, mul_by_7_raw},
goldilocks::{GoldilocksField, dot_product_2, dot_product_3, mul_by_7_raw},
traits::{HasDefaultTranscript, IsField, IsSubFieldOf},
};
use crate::traits::{AsBytes, ByteConversion};
Expand Down Expand Up @@ -572,22 +572,12 @@ 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];
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);
break;
}
}
}

FieldElement::<Self>::new(coeffs)
fn sample_field_element_from(mut next_u64: impl FnMut() -> u64) -> FieldElement<Self> {
// Three base coordinates, each via the base field's rejection sampler
// (coordinate order 0, 1, 2 — `from_fn` evaluates in index order).
FieldElement::<Self>::new(core::array::from_fn(|_| {
GoldilocksField::sample_field_element_from(&mut next_u64)
}))
}
}

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