From d0902653f9a8d6a55583b92a09c9b947f23a36cd Mon Sep 17 00:00:00 2001 From: diegokingston Date: Thu, 16 Jul 2026 15:17:02 -0300 Subject: [PATCH 1/2] perf(transcript): derive challenges by direct sponge squeeze, drop ChaCha20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fiat-Shamir challenge sampler seeded a fresh ChaCha20Rng from every 32-byte Keccak squeeze and pulled the field element from the keystream. On the recursion guest that ChaCha block is pure software (Keccak is a precompile, ChaCha is not), so it dominated the challenge-sampling cost while producing randomness the sponge already yields for free — ~2-3M guest instructions per proof across β/z/γ and the per-FRI-layer ζ's. Replace it with a Plonky3-style duplex challenger: `DefaultTranscript` now holds a 32-byte output buffer, and `sample_field_element`/`sample_u64` rejection-sample 64-bit candidates straight from the squeeze bytes (8 bytes at a time), refilling with one squeeze when drained. A cubic-extension element (3 coordinates) usually costs a single squeeze instead of a squeeze + a ChaCha block. Rejection sampling (`< GOLDILOCKS_PRIME`) is unchanged, so the distribution stays exactly uniform; squeezing field elements directly from the sponge is the standard FS instantiation (Plonky3/Winterfell), so soundness is preserved (arguably cleaner — ChaCha only expanded the same 32-byte seed). - `HasDefaultTranscript::get_random_field_element_from_rng(rng)` → `sample_field_element_from(next_u64)` (Goldilocks + cubic ext). - Output buffer is invalidated on every absorb (`append_bytes`/`append_field_element`) so a squeeze never reflects input appended after it; `Clone` copies the buffer, keeping the snapshot/restore contract byte-identical (the GPU-FRI fallback relies on it). - Drops `rand` + `rand_chacha` from crypto's non-dev dependencies (they were ChaCha-only). BREAKING: this changes the Fiat-Shamir hash-to-field, so all proofs and the pinned recursion ELFs must be regenerated — it is a transcript hard-fork, not a verifier-only change. Prover and verifier share `DefaultTranscript`, so they move in lockstep automatically. Validated: 190 stark prove→verify roundtrips pass (prover↔verifier lockstep with the new sampler), 47 crypto tests pass (snapshot/restore + sampling determinism), clippy clean. Guest-cycle benchmark = server (no local RISC-V toolchain). --- crypto/crypto/Cargo.toml | 2 - .../src/fiat_shamir/default_transcript.rs | 61 +++++++++++++++++-- .../math/src/field/extensions_goldilocks.rs | 10 ++- crypto/math/src/field/goldilocks.rs | 10 ++- crypto/math/src/field/traits.rs | 10 ++- 5 files changed, 72 insertions(+), 21 deletions(-) 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; } From 4f2d350ccb30a5cffbd844cd00ea1ec51742d44e Mon Sep 17 00:00:00 2001 From: Mauro Toscano Date: Wed, 29 Jul 2026 10:47:32 -0300 Subject: [PATCH 2/2] test(transcript): pin duplex-buffer invalidation, clone replay and byte semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Divergence tests for the buffer-invalidation lines (append_bytes, append_field_element, raw sample()): a removed invalidation hands out stale squeeze bytes to prover and verifier in lockstep, which roundtrip suites structurally cannot see; these tests fail on it directly. - Mid-buffer clone replay test (the GPU-FRI fallback clones the transcript between samples; state()-only comparisons never observe the buffer). - Known-answer test pinning the BE / 8-byte-chunk / refill-after-4 semantics across a squeeze boundary, plus ext3 coordinate order after an absorb — any accidental change is a transcript hard-fork and shows up here instead of as a red proof. - Docs: state() no longer claims to fully determine outputs (the duplex buffer position is deliberately not part of it). - Dedup: the ext3 sampler now delegates to the base-field rejection sampler, coordinate by coordinate (behavior-identical, covered by the known-answer test). - Drop dead rand deps: the mandatory rand in math and the rand/rand_chacha dev-deps in crypto were unused since the ChaCha removal (math's benches keep their own dev-deps). --- Cargo.lock | 2 - crypto/crypto/Cargo.toml | 2 - .../crypto/src/fiat_shamir/is_transcript.rs | 10 +- .../src/tests/default_transcript_tests.rs | 109 ++++++++++++++++++ crypto/math/Cargo.toml | 1 - .../math/src/field/extensions_goldilocks.rs | 20 +--- 6 files changed, 124 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74986dcc9..c009eca9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,8 +435,6 @@ dependencies = [ "libc", "math", "memmap2", - "rand 0.8.5", - "rand_chacha 0.3.1", "rayon", "rkyv", "serde", diff --git a/crypto/crypto/Cargo.toml b/crypto/crypto/Cargo.toml index 23e5e949b..cff4deaeb 100644 --- a/crypto/crypto/Cargo.toml +++ b/crypto/crypto/Cargo.toml @@ -31,8 +31,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" diff --git a/crypto/crypto/src/fiat_shamir/is_transcript.rs b/crypto/crypto/src/fiat_shamir/is_transcript.rs index eb011e4d4..316d9a742 100644 --- a/crypto/crypto/src/fiat_shamir/is_transcript.rs +++ b/crypto/crypto/src/fiat_shamir/is_transcript.rs @@ -9,7 +9,15 @@ pub trait IsTranscript { fn append_field_element(&mut self, element: &FieldElement); /// 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; diff --git a/crypto/crypto/src/tests/default_transcript_tests.rs b/crypto/crypto/src/tests/default_transcript_tests.rs index 065ab8751..cbfa2daf4 100644 --- a/crypto/crypto/src/tests/default_transcript_tests.rs +++ b/crypto/crypto/src/tests/default_transcript_tests.rs @@ -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::::new(b"seed"); + let mut t2 = DefaultTranscript::::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::::new(b"seed"); + let mut t2 = DefaultTranscript::::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::::new(b"seed"); + let mut t2 = DefaultTranscript::::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::::new(b"snapshot"); + let _ = t.sample_field_element(); // leave the buffer partially consumed + let mut snap = t.clone(); + let original: (Vec>, u64) = ( + (0..6).map(|_| t.sample_field_element()).collect(), + t.sample_u64(1 << 20), + ); + let replay: (Vec>, 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::::new(b"lambda-vm-kat-v1"); + // Five base samples: the fifth forces a refill (4 candidates per squeeze). + let base: Vec = (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::::new(b"lambda-vm-kat-v1"); + te.append_bytes(b"phase-2"); + let ext = te.sample_field_element(); + let coords: Vec = 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, +]; diff --git a/crypto/math/Cargo.toml b/crypto/math/Cargo.toml index df43ea975..43e5e3ac0 100644 --- a/crypto/math/Cargo.toml +++ b/crypto/math/Cargo.toml @@ -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 } diff --git a/crypto/math/src/field/extensions_goldilocks.rs b/crypto/math/src/field/extensions_goldilocks.rs index e18bd7b06..b4814a2c7 100644 --- a/crypto/math/src/field/extensions_goldilocks.rs +++ b/crypto/math/src/field/extensions_goldilocks.rs @@ -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}; @@ -573,19 +573,11 @@ impl AsBytes for FieldElement { impl HasDefaultTranscript for Degree3GoldilocksExtensionField { 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 { - let candidate = next_u64(); - if candidate < GOLDILOCKS_PRIME { - *coeff = FpE::from(candidate); - break; - } - } - } - - FieldElement::::new(coeffs) + // Three base coordinates, each via the base field's rejection sampler + // (coordinate order 0, 1, 2 — `from_fn` evaluates in index order). + FieldElement::::new(core::array::from_fn(|_| { + GoldilocksField::sample_field_element_from(&mut next_u64) + })) } }