From dc02b5515610fc6170822ff63047d6271631fa98 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 23 Sep 2026 11:07:35 +0530 Subject: [PATCH 01/16] refactor: share helpers for OOD commitments --- crates/pcs/src/challenger.rs | 39 +----------------------------- crates/pcs/src/lib.rs | 1 + crates/pcs/src/pow.rs | 47 ++++++++++++++++++++++++++++++++++++ crates/poly/src/mle.rs | 8 +++--- 4 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 crates/pcs/src/pow.rs diff --git a/crates/pcs/src/challenger.rs b/crates/pcs/src/challenger.rs index 24ff2eac..0a341c49 100644 --- a/crates/pcs/src/challenger.rs +++ b/crates/pcs/src/challenger.rs @@ -1,6 +1,7 @@ //! Flock challenger adapters over the project transcript. use crate::bridge::{as_flock_f128, from_flock_f128}; +use crate::pow::{find as find_pow, valid as pow_valid}; use field::F128 as LocalF128; use flock_core::challenger::Challenger; use flock_core::field::F128 as FlockF128; @@ -232,44 +233,6 @@ impl Challenger for VerifierChallenger<'_, '_> { } } -/// todo: parallel pow? use potentially spongefish? -fn find_pow(seed: &[u8; 16], bits: u32) -> u64 { - if bits == 0 { - return 0; - } - let mut nonce = 0u64; - loop { - if pow_valid(seed, nonce, bits) { - return nonce; - } - nonce = nonce.checked_add(1).expect("proof-of-work nonce exhausted"); - } -} - -fn pow_valid(seed: &[u8; 16], nonce: u64, bits: u32) -> bool { - if bits == 0 { - return nonce == 0; - } - let mut hasher = blake3::Hasher::new(); - hasher.update(b"bitz-pcs-pow-v1"); - hasher.update(seed); - hasher.update(&nonce.to_le_bytes()); - let digest = hasher.finalize(); - leading_zero_bits(digest.as_bytes()) >= bits -} - -fn leading_zero_bits(bytes: &[u8]) -> u32 { - let mut total = 0; - for byte in bytes { - let zeros = byte.leading_zeros(); - total += zeros; - if zeros != 8 { - break; - } - } - total -} - #[cfg(test)] mod tests { use proptest::prelude::*; diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index 2c636d7c..7f28b8fb 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -94,6 +94,7 @@ mod commitment; mod ligerito; mod mle; mod opening; +mod pow; mod profiles; mod sumcheck; mod transpose; diff --git a/crates/pcs/src/pow.rs b/crates/pcs/src/pow.rs new file mode 100644 index 00000000..c0f2f890 --- /dev/null +++ b/crates/pcs/src/pow.rs @@ -0,0 +1,47 @@ +//! Nonce search and validation shared by PCS grinding rounds. +//! +//! A positive difficulty requires that many leading zero bits in +//! `BLAKE3(POW_HASH_TAG || seed || nonce.to_le_bytes())`. At zero difficulty, +//! only nonce zero is accepted. Transcript framing belongs to each calling round. + +const POW_HASH_TAG: &[u8] = b"bitz-pcs-pow-v1"; + +/// Returns the first valid nonce in ascending order. +// todo: parallel pow? use potentially spongefish? +pub(crate) fn find(seed: &[u8; 16], bits: u32) -> u64 { + if bits == 0 { + return 0; + } + let mut nonce = 0u64; + loop { + if valid(seed, nonce, bits) { + return nonce; + } + nonce = nonce.checked_add(1).expect("proof-of-work nonce exhausted"); + } +} + +/// Checks the hash difficulty, or the canonical zero nonce at zero difficulty. +pub(crate) fn valid(seed: &[u8; 16], nonce: u64, bits: u32) -> bool { + if bits == 0 { + return nonce == 0; + } + let mut hasher = blake3::Hasher::new(); + hasher.update(POW_HASH_TAG); + hasher.update(seed); + hasher.update(&nonce.to_le_bytes()); + let digest = hasher.finalize(); + leading_zero_bits(digest.as_bytes()) >= bits +} + +fn leading_zero_bits(bytes: &[u8]) -> u32 { + let mut total = 0; + for byte in bytes { + let zeros = byte.leading_zeros(); + total += zeros; + if zeros != 8 { + break; + } + } + total +} diff --git a/crates/poly/src/mle.rs b/crates/poly/src/mle.rs index 43d1e817..ba1d331e 100644 --- a/crates/poly/src/mle.rs +++ b/crates/poly/src/mle.rs @@ -166,11 +166,13 @@ impl DenseMultilinearExtension { Ok(Self::evaluate_exact(&self.evaluations, r)) } - #[inline] + /// Evaluates an MLE table whose length is exactly `2^r.len()`. + /// /// Unrolled base cases adapted from WHIR's `eval_exact` (Apache-2.0): /// - fn evaluate_exact(evaluations: &[F], r: &[F]) -> F { - debug_assert_eq!(evaluations.len(), 1 << r.len()); + #[inline] + pub fn evaluate_exact(evaluations: &[F], r: &[F]) -> F { + assert_eq!(evaluations.len(), 1 << r.len(), "MLE table length"); let interpolate = |zero: F, one: F, challenge: F| zero + challenge * (one - zero); From 6d102c7f4acf66bc3a9199b15fad803a49f37b71 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 23 Sep 2026 12:11:49 +0530 Subject: [PATCH 02/16] feat: authenticate initial OOD claims in PCS openings --- Cargo.lock | 1 + crates/pcs/Cargo.toml | 1 + crates/pcs/src/commitment.rs | 68 +++++++++- crates/pcs/src/lib.rs | 76 ++++++++++- crates/pcs/src/ood.rs | 217 ++++++++++++++++++++++++++++++++ crates/pcs/src/opening.rs | 72 +++++++++-- crates/pcs/src/opening/tests.rs | 2 + crates/pcs/src/profiles.rs | 37 ++++++ crates/pcs/tests/round_trip.rs | 64 ++++++++++ 9 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 crates/pcs/src/ood.rs diff --git a/Cargo.lock b/Cargo.lock index 082feb8d..2842e2c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1119,6 +1119,7 @@ dependencies = [ "field", "flock-core", "num-traits", + "poly", "proptest", "tracing", "transcript", diff --git a/crates/pcs/Cargo.toml b/crates/pcs/Cargo.toml index ad2b2f8f..18218780 100644 --- a/crates/pcs/Cargo.toml +++ b/crates/pcs/Cargo.toml @@ -14,6 +14,7 @@ flock-core = { workspace = true } transcript = { workspace = true } tracing = { workspace = true } num-traits = { workspace = true } +poly = { workspace = true } [dev-dependencies] divan = { workspace = true } diff --git a/crates/pcs/src/commitment.rs b/crates/pcs/src/commitment.rs index 0b17b5ce..2a2ca593 100644 --- a/crates/pcs/src/commitment.rs +++ b/crates/pcs/src/commitment.rs @@ -8,15 +8,18 @@ use core::mem::size_of; +use crate::VerifyError; use crate::bridge::as_flock_f128s; use crate::ligerito::CheckedLigerito; +use crate::ood::{OodClaim, prove, verify}; +use crate::profiles::{ood_grinding_bits, security_config}; use common::{Root, Shape}; use field::F128; pub use flock_core::hash::HashKind; use flock_core::pcs::Commitment as FlockCommitment; use flock_core::pcs::ligerito::LigeritoProfile; use flock_core::pcs::{PcsParams, ProverData as FlockProverData}; -use transcript::Encoding; +use transcript::{Encoding, ProverState, VerifierState}; /// Errors from PCS configuration. #[derive(Clone, Debug, PartialEq, Eq)] @@ -38,14 +41,32 @@ pub enum CommitError { pub struct Pcs { params: PcsParams, checked_ligerito: CheckedLigerito, + ood_grinding_bits: Option, bit_len: usize, packed_len: usize, } /// Flock state retained between commitment and openings. +/// OOD-aware commitment creation also retains the initial evaluation for each opening. pub struct ProverData { commitment: FlockCommitment, flock_prover_data: FlockProverData, + pub(crate) ood: Option, +} + +/// Commitment and out-of-domain claim read from the verifier transcript. +/// The claim is authenticated only after [`CommitScheme::verify_lin_with_ood`](crate::CommitScheme::verify_lin_with_ood) succeeds. +#[derive(Debug)] +pub struct VerifierData { + pub(crate) root: Root, + pub(crate) ood: Option, +} + +impl VerifierData { + /// Returns the public commitment root. + pub fn root(&self) -> Root { + self.root + } } impl Pcs { @@ -61,7 +82,7 @@ impl Pcs { // The ladder fixes the L0 interleaving: the commit must use the same // `log_batch_size` as the opening's `initial_k`, or the L0 tree is not // reusable as Ligerito's first oracle. - let security = crate::profiles::security_config(m, security_profile, merkle_hash)?; + let security = security_config(m, security_profile, merkle_hash)?; let params = PcsParams { m, log_inv_rate: security_profile.log_inv_rate(), @@ -70,6 +91,7 @@ impl Pcs { merkle_hash, }; let checked_ligerito = CheckedLigerito::new(¶ms, &security)?; + let ood_grinding_bits = ood_grinding_bits(&security, checked_ligerito.log_n_u32() as usize); let packed_len = 1usize .checked_shl(checked_ligerito.log_n_u32()) .ok_or(ConfigError::Invalid("packed length overflow"))?; @@ -77,12 +99,14 @@ impl Pcs { Ok(Self { params, checked_ligerito, + ood_grinding_bits, bit_len, packed_len, }) } - /// Commits to the exact configured number of packed field elements. + /// Commits to the packed codeword without sampling an OOD claim. + /// Use [`Self::commit_with_ood`] for protocols requiring initial OOD sampling. #[tracing::instrument(name = "Commit witness", skip_all)] pub fn commit(&self, packed_witness: &[F128]) -> Result<(Root, ProverData), CommitError> { // 1. Input Validation @@ -103,10 +127,44 @@ impl Pcs { ProverData { commitment: flock_commitment, flock_prover_data, + ood: None, }, )) } + /// Commits and retains the initial OOD claim for subsequent batched openings. + /// + /// Call before witness-dependent challenges and continue with the same transcript. + /// [`CommitScheme::prove_lin`](crate::CommitScheme::prove_lin) batches the retained claim into each opening. + /// Profiles using unique decoding omit the OOD round. + /// + /// Returns [`CommitError::PackedWitnessLengthMismatch`] before transcript mutation + /// if `packed_witness` does not have the configured length. + #[tracing::instrument(name = "Commit witness with OOD", skip_all)] + pub fn commit_with_ood( + &self, + packed_witness: &[F128], + transcript: &mut ProverState, + ) -> Result<(Root, ProverData), CommitError> { + let (root, mut data) = self.commit(packed_witness)?; + data.ood = prove(self, &root.0, packed_witness, transcript); + Ok((root, data)) + } + + /// Receives the OOD claim for the public root before subsequent protocol challenges. + /// + /// Mirrors [`Self::commit_with_ood`]. Invalid grinding or a truncated evaluation + /// returns [`VerifyError::MalformedProof`]; authentication of the evaluation is + /// deferred to [`CommitScheme::verify_lin_with_ood`](crate::CommitScheme::verify_lin_with_ood). + pub fn receive_commitment( + &self, + root: Root, + transcript: &mut VerifierState<'_>, + ) -> Result { + let ood = verify(self, &root.0, transcript)?; + Ok(VerifierData { root, ood }) + } + pub fn bit_len(&self) -> usize { self.bit_len } @@ -120,6 +178,10 @@ impl Pcs { &self.params } + pub(crate) fn ood_grinding_bits(&self) -> Option { + self.ood_grinding_bits + } + pub(crate) fn prover_config(&self) -> &flock_core::pcs::ligerito::ProverConfig { self.checked_ligerito.prover_config() } diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index 7f28b8fb..61deb3b0 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -29,6 +29,7 @@ //! - [`Pcs`] stores trusted Flock parameters and the expected bit length. //! - [`Root`] is the public Merkle root. //! - [`ProverData`] retains the codeword and Merkle tree after commitment. +//! - [`VerifierData`] retains the root and OOD claim received before opening. //! - [`OpeningQuery`] contains an MLE point and target, or a `common::LinearClaim`. //! - [`CommitScheme`] connects commitment, proving, and verification to project transcripts. //! - [`ConfigError`] reports configuration failures. @@ -39,6 +40,9 @@ //! It consumes the packed witness and borrows [`ProverData`]. //! The caller must use matching transcript session and instance labels. //! The caller must also call `VerifierState::check_eof` after successful verification. +//! Use [`Pcs::commit_with_ood`] and [`Pcs::receive_commitment`] before any +//! witness-dependent challenges to include the initial OOD claim. Proving batches +//! that retained claim automatically; verification uses [`Pcs::verify_lin_with_ood`]. //! //! # Example //! @@ -65,8 +69,8 @@ //! target: F128::from(0u64), //! }; //! -//! let (commitment, prover_data) = pcs.commit(&packed_witness).unwrap(); //! let mut prover = build_prover(b"pcs-example", b"zero-polynomial"); +//! let (commitment, prover_data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); //! pcs.prove_lin( //! &prover_data, //! packed_witness, @@ -78,7 +82,8 @@ //! let proof = prover.finish(); //! //! let mut verifier = build_verifier(b"pcs-example", b"zero-polynomial", &proof); -//! pcs.verify_lin( +//! let commitment = pcs.receive_commitment(commitment, &mut verifier).unwrap(); +//! pcs.verify_lin_with_ood( //! &commitment, //! &query, //! StatementBinding::Bind, @@ -93,6 +98,7 @@ mod challenger; mod commitment; mod ligerito; mod mle; +mod ood; mod opening; mod pow; mod profiles; @@ -106,7 +112,7 @@ mod transpose_tests; use field::F128; use transcript::{ProverState, VerifierState}; -pub use commitment::{CommitError, ConfigError, HashKind, Pcs, ProverData}; +pub use commitment::{CommitError, ConfigError, HashKind, Pcs, ProverData, VerifierData}; pub use common::{OpeningQuery, Root}; pub use flock_core::pcs::ligerito::LigeritoProfile; pub use opening::{ProveError, VerifyError}; @@ -137,6 +143,8 @@ pub trait CommitScheme { type Commitment; /// Private data retained by the prover after commitment. type ProverData; + /// Commitment and OOD claim retained by the verifier before opening. + type VerifierData; /// Commits the caller-owned packed witness to `Enc_C(q_pkd)`, where /// `q_pkd(y) = Σ_{v ∈ {0,1}^7} q(y, v) · basis[v]`. @@ -146,9 +154,30 @@ pub trait CommitScheme { packed_witness: &[F128], ) -> Result<(Self::Commitment, Self::ProverData), CommitError>; + /// Commits and retains the initial OOD claim for subsequent openings. + /// + /// Call before witness-dependent challenges and continue with the same transcript. + /// Profiles without initial OOD sampling omit that round. + fn commit_with_ood( + &self, + packed_witness: &[F128], + transcript: &mut ProverState, + ) -> Result<(Self::Commitment, Self::ProverData), CommitError>; + + /// Receives the OOD claim for the public commitment before protocol challenges. + /// + /// Mirrors [`Self::commit_with_ood`]. The returned claim must be authenticated + /// by [`Self::verify_lin_with_ood`] on the same transcript. + fn receive_commitment( + &self, + commitment: Self::Commitment, + transcript: &mut VerifierState<'_>, + ) -> Result; + /// Consumes the exact packed witness and proves either opening query. /// /// Inner-product claims first pass through quadratic sumcheck and then the MLE opening protocol. + /// An OOD claim retained by [`Self::commit_with_ood`] is batched into the opening. fn prove_lin( &self, data: &Self::ProverData, @@ -168,11 +197,24 @@ pub trait CommitScheme { statement_binding: StatementBinding, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError>; + + /// Verifies the linear query batched with the retained OOD claim. + /// + /// Continue the transcript used by [`Self::receive_commitment`]. Borrowing + /// the retained state permits multiple openings against the same commitment. + fn verify_lin_with_ood( + &self, + commitment: &Self::VerifierData, + query: &OpeningQuery, + statement_binding: StatementBinding, + transcript: &mut VerifierState<'_>, + ) -> Result<(), VerifyError>; } impl CommitScheme for Pcs { type Commitment = Root; type ProverData = ProverData; + type VerifierData = VerifierData; fn commit( &self, @@ -181,6 +223,22 @@ impl CommitScheme for Pcs { Pcs::commit(self, packed_witness) } + fn commit_with_ood( + &self, + packed_witness: &[F128], + transcript: &mut ProverState, + ) -> Result<(Self::Commitment, Self::ProverData), CommitError> { + Pcs::commit_with_ood(self, packed_witness, transcript) + } + + fn receive_commitment( + &self, + commitment: Self::Commitment, + transcript: &mut VerifierState<'_>, + ) -> Result { + Pcs::receive_commitment(self, commitment, transcript) + } + fn prove_lin( &self, data: &Self::ProverData, @@ -206,6 +264,16 @@ impl CommitScheme for Pcs { statement_binding: StatementBinding, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { - opening::verify(self, commitment, query, statement_binding, transcript) + opening::verify(self, commitment, query, statement_binding, None, transcript) + } + + fn verify_lin_with_ood( + &self, + commitment: &Self::VerifierData, + query: &OpeningQuery, + statement_binding: StatementBinding, + transcript: &mut VerifierState<'_>, + ) -> Result<(), VerifyError> { + opening::verify_lin_with_ood(self, commitment, query, statement_binding, transcript) } } diff --git a/crates/pcs/src/ood.rs b/crates/pcs/src/ood.rs new file mode 100644 index 00000000..bb5d718a --- /dev/null +++ b/crates/pcs/src/ood.rs @@ -0,0 +1,217 @@ +//! Initial out-of-domain claim on the packed commitment polynomial. +//! +//! After binding the root and PCS parameters, the prover performs any configured +//! grinding, samples `zeta`, and sends `value = p(point)`, where `p` is the packed +//! witness MLE and `point[i] = zeta^(2^i)` in low-bit-first order. +//! The verifier derives the same point and reads the claimed value. +//! +//! After ring switching, a fresh `coefficient` batches this claim into Ligerito: +//! `basis += coefficient * eq(point, ·)` and `target += coefficient * value`. +//! The claim remains borrowed from commitment state so it can be used by multiple +//! openings. Unique-decoding profiles omit this initial round. + +use field::F128; +use flock_core::field::F128 as FlockF128; +use num_traits::ConstOne; +use poly::{DenseMultilinearExtension, eq_table}; +use transcript::{ProverState, PublicTranscript, VerifierState}; + +use crate::bridge::{as_flock_f128, from_flock_f128}; +use crate::pow::{find, valid}; +use crate::{Pcs, VerifyError}; + +const OOD_ROUND_TAG: &[u8] = b"bitz/pcs/ood/v1"; +const OOD_BATCHING_TAG: &[u8] = b"bitz/pcs/ood-batching/v1"; +const OOD_POW_TAG: &[u8] = b"bitz/pcs/ood-pow/v1"; +const BLOCK_LOG: usize = 12; + +/// An evaluation of the packed witness MLE, authenticated by the batched opening. +#[derive(Debug)] +pub(crate) struct OodClaim { + /// Successive squares of the sampled challenge, in low-bit-first order. + pub(crate) point: Vec, + /// Claimed MLE evaluation at `point`. + pub(crate) value: F128, +} + +/// Sends the initial evaluation after binding the commitment and configuration. +/// Returns `None` without transcript events when the profile omits OOD sampling. +pub(crate) fn prove( + pcs: &Pcs, + root: &[u8; 32], + packed: &[F128], + transcript: &mut ProverState, +) -> Option { + let grinding_bits = pcs.ood_grinding_bits()?; + absorb_header(pcs, root, grinding_bits, transcript); + if grinding_bits != 0 { + prove_pow(transcript, grinding_bits); + } + let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); + let value = DenseMultilinearExtension::evaluate_exact(packed, &point); + transcript.prover_message(&value); + Some(OodClaim { point, value }) +} + +/// Reads the initial evaluation, checking grinding before sampling its point. +/// Reading the value does not authenticate it; the caller must verify its opening. +pub(crate) fn verify( + pcs: &Pcs, + root: &[u8; 32], + transcript: &mut VerifierState<'_>, +) -> Result, VerifyError> { + let Some(grinding_bits) = pcs.ood_grinding_bits() else { + return Ok(None); + }; + absorb_header(pcs, root, grinding_bits, transcript); + if grinding_bits != 0 { + verify_pow(transcript, grinding_bits).map_err(|_| VerifyError::MalformedProof)?; + } + let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); + let value = transcript + .prover_message::() + .map_err(|_| VerifyError::MalformedProof)?; + Ok(Some(OodClaim { point, value })) +} + +/// Samples the OOD batching coefficient after the ring-switch claims are bound. +pub(crate) fn batching_challenge(transcript: &mut impl PublicTranscript) -> F128 { + transcript.public_message(OOD_BATCHING_TAG); + transcript.verifier_message_f128() +} + +/// Adds `coefficient * eq(claim.point, ·)` to the prover's Boolean evaluation table. +pub(crate) fn add_dense_basis(basis: &mut [FlockF128], claim: &OodClaim, coefficient: F128) { + let low = claim.point.len().min(BLOCK_LOG); + let block = 1usize << low; + let tail = eq_table(&claim.point[..low]); + let head = eq_table(&claim.point[low..]); + for (chunk, &scale) in basis.chunks_exact_mut(block).zip(&head) { + for (basis, &weight) in chunk.iter_mut().zip(&tail) { + *basis += as_flock_f128(coefficient * scale * weight); + } + } +} + +/// Adds the same equality polynomial after its low coordinates are fixed to `ris`. +pub(crate) fn add_succinct_basis( + basis: &mut [FlockF128], + claim: &OodClaim, + coefficient: F128, + ris: &[FlockF128], +) { + let suffix_vars = basis.len().ilog2() as usize; + if claim.point.len() != ris.len() + suffix_vars { + basis.fill(FlockF128::ZERO); + return; + } + let prefix = claim.point[..ris.len()] + .iter() + .zip(ris) + .fold(F128::ONE, |weight, (&point, &query)| { + weight * (F128::ONE + point + from_flock_f128(query)) + }); + let scale = coefficient * prefix; + for (basis, weight) in basis.iter_mut().zip(eq_table(&claim.point[ris.len()..])) { + *basis += as_flock_f128(scale * weight); + } +} + +fn absorb_header( + pcs: &Pcs, + root: &[u8; 32], + grinding_bits: u32, + transcript: &mut impl PublicTranscript, +) { + transcript.public_message(OOD_ROUND_TAG); + transcript.public_message(root); + transcript.public_message(pcs); + transcript.public_message(&(pcs.packed_len() as u64)); + transcript.public_message(&grinding_bits); +} + +fn ood_point(zeta: F128, packed_len: usize) -> Vec { + let mut point = Vec::with_capacity(packed_len.ilog2() as usize); + let mut coordinate = zeta; + for _ in 0..packed_len.ilog2() { + point.push(coordinate); + coordinate *= coordinate; + } + point +} + +fn prove_pow(transcript: &mut ProverState, bits: u32) { + transcript.public_message(OOD_POW_TAG); + transcript.public_message(&bits); + let seed = transcript.verifier_message::().to_bytes(); + let nonce = find(&seed, bits); + transcript.prover_message(&nonce.to_le_bytes()); +} + +fn verify_pow(transcript: &mut VerifierState<'_>, bits: u32) -> Result<(), ()> { + transcript.public_message(OOD_POW_TAG); + transcript.public_message(&bits); + let seed = transcript.verifier_message::().to_bytes(); + let nonce = transcript + .prover_message::<[u8; 8]>() + .map(u64::from_le_bytes) + .map_err(|_| ())?; + valid(&seed, nonce, bits).then_some(()).ok_or(()) +} + +#[cfg(test)] +mod tests { + use num_traits::ConstZero; + use transcript::{build_prover, build_verifier}; + + use super::*; + + #[test] + fn dense_and_succinct_ood_bases_agree_after_folding() { + let point = ood_point(F128::new(7, 11), 1 << 14); + let coefficient = F128::new(13, 17); + let claim = OodClaim { + point, + value: F128::ZERO, + }; + let mut dense = vec![FlockF128::ZERO; 1 << 14]; + add_dense_basis(&mut dense, &claim, coefficient); + let queries: Vec<_> = (0..9) + .map(|i| as_flock_f128(F128::new(i + 2, i + 19))) + .collect(); + for &query in &queries { + for i in 0..dense.len() / 2 { + dense[i] = dense[2 * i] + query * (dense[2 * i] + dense[2 * i + 1]); + } + dense.truncate(dense.len() / 2); + } + let mut succinct = vec![FlockF128::ZERO; dense.len()]; + add_succinct_basis(&mut succinct, &claim, coefficient, &queries); + assert_eq!(dense, succinct); + } + + #[test] + fn grinding_binds_the_following_challenge_and_rejects_invalid_nonces() { + const BITS: u32 = 8; + let mut prover = build_prover(b"ood-test", b"grinding"); + prove_pow(&mut prover, BITS); + let challenge = prover.verifier_message::(); + let mut proof = prover.finish(); + let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); + verify_pow(&mut verifier, BITS).unwrap(); + assert_eq!(verifier.verifier_message::(), challenge); + verifier.check_eof().unwrap(); + + let mut seed_transcript = build_prover(b"ood-test", b"grinding"); + seed_transcript.public_message(OOD_POW_TAG); + seed_transcript.public_message(&BITS); + let seed = seed_transcript.verifier_message::().to_bytes(); + let invalid = (0..).find(|&nonce| !valid(&seed, nonce, BITS)).unwrap(); + proof.narg_string.copy_from_slice(&invalid.to_le_bytes()); + let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); + assert!(verify_pow(&mut verifier, BITS).is_err()); + proof.narg_string.truncate(7); + let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); + assert!(verify_pow(&mut verifier, BITS).is_err()); + } +} diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index f2683320..87f0236b 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -8,7 +8,8 @@ use transcript::{ProverState, PublicTranscript, VerifierState}; use crate::bridge::{as_flock_f128, as_flock_f128s, from_flock_f128}; use crate::ligerito::{self, ReducedProver}; -use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, mle, sumcheck}; +use crate::ood::{OodClaim, add_dense_basis, add_succinct_basis, batching_challenge}; +use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, VerifierData, mle, sumcheck}; const MLE_STATEMENT_LABEL: &[u8] = b"bitz/pcs/mle-opening/v1"; const INNER_PRODUCT_STATEMENT_LABEL: &[u8] = b"bitz/pcs/bit-inner-product/v2"; @@ -82,6 +83,28 @@ impl From for VerifyError { } } +/// Verifies an opening batched with the OOD claim retained at commitment ingestion. +/// +/// Use the state returned by [`Pcs::receive_commitment`] and continue its transcript. +/// The state is borrowed so multiple openings can authenticate the same OOD claim. +/// Profiles without OOD sampling verify the ordinary linear claim. +pub(crate) fn verify_lin_with_ood( + pcs: &Pcs, + commitment: &VerifierData, + query: &OpeningQuery, + statement_binding: StatementBinding, + transcript: &mut VerifierState<'_>, +) -> Result<(), VerifyError> { + verify( + pcs, + &commitment.root, + query, + statement_binding, + commitment.ood.as_ref(), + transcript, + ) +} + #[tracing::instrument(name = "Prove PCS opening", skip_all)] pub(crate) fn prove( pcs: &Pcs, @@ -98,7 +121,7 @@ pub(crate) fn prove( if statement_binding == StatementBinding::Bind { bind_mle_statement(pcs, &data.commitment().root, point, *target, transcript); } - prove_mle(prover, ring_switch, *target, transcript) + prove_mle(prover, ring_switch, *target, data.ood.as_ref(), transcript) } OpeningQuery::InnerProduct { claim } => { validate_inner_product_claim(pcs, claim)?; @@ -117,7 +140,13 @@ pub(crate) fn prove( reduced.target, transcript, ); - prove_mle(prover, ring_switch, reduced.target, transcript) + prove_mle( + prover, + ring_switch, + reduced.target, + data.ood.as_ref(), + transcript, + ) } } } @@ -128,6 +157,7 @@ pub(crate) fn verify( commitment: &Root, query: &OpeningQuery, statement_binding: StatementBinding, + ood_claim: Option<&OodClaim>, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { match query { @@ -136,7 +166,7 @@ pub(crate) fn verify( if statement_binding == StatementBinding::Bind { bind_mle_statement(pcs, &commitment.0, point, *target, transcript); } - verify_mle(pcs, commitment, ring_switch, *target, transcript) + verify_mle(pcs, commitment, ring_switch, *target, ood_claim, transcript) } OpeningQuery::InnerProduct { claim } => { validate_inner_product_claim(pcs, claim)?; @@ -153,7 +183,14 @@ pub(crate) fn verify( reduced.target, transcript, ); - verify_mle(pcs, commitment, ring_switch, reduced.target, transcript) + verify_mle( + pcs, + commitment, + ring_switch, + reduced.target, + ood_claim, + transcript, + ) } } } @@ -176,6 +213,7 @@ fn prove_mle( prover: ReducedProver<'_>, ring_switch: mle::RingSwitch<'_>, target: F128, + ood_claim: Option<&OodClaim>, transcript: &mut ProverState, ) -> Result<(), ProveError> { let dense_reduction = { @@ -184,7 +222,13 @@ fn prove_mle( ring_switch.prepare_claims(as_flock_f128s(prover.witness()), target)?; write_claims(transcript, &prepared_claims.claims); let batching_point = sample_challenges(transcript); - prepared_claims.reduce_dense(&batching_point) + let mut reduced = prepared_claims.reduce_dense(&batching_point); + if let Some(claim) = ood_claim { + let coefficient = batching_challenge(transcript); + add_dense_basis(&mut reduced.packed_basis, claim, coefficient); + reduced.packed_target += as_flock_f128(coefficient * claim.value); + } + reduced }; prover.prove(dense_reduction, transcript) } @@ -195,6 +239,7 @@ fn verify_mle( commitment: &Root, ring_switch: mle::RingSwitch<'_>, target: F128, + ood_claim: Option<&OodClaim>, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { let proof = ligerito::read_proof(pcs, commitment, transcript)?; @@ -207,13 +252,24 @@ fn verify_mle( let batching_point = sample_challenges(transcript); ring_switch.reduce_succinct(&claims, &batching_point) }; + let ood = ood_claim.map(|claim| (claim, batching_challenge(transcript))); + let mut packed_target = reduction.packed_target; + if let Some((claim, coefficient)) = ood { + packed_target += as_flock_f128(coefficient * claim.value); + } ligerito::verify_succinct( pcs, commitment, &proof, ring_switch.suffix_dimension(), - reduction.packed_target, - |ris, yr_log_n| reduction.evaluate_basis(ris, yr_log_n), + packed_target, + |ris, yr_log_n| { + let mut basis = reduction.evaluate_basis(ris, yr_log_n); + if let Some((claim, coefficient)) = ood { + add_succinct_basis(&mut basis, claim, coefficient, ris); + } + basis + }, transcript, ) } diff --git a/crates/pcs/src/opening/tests.rs b/crates/pcs/src/opening/tests.rs index a704f6f8..1e0d30c9 100644 --- a/crates/pcs/src/opening/tests.rs +++ b/crates/pcs/src/opening/tests.rs @@ -78,6 +78,7 @@ fn inner_product_proof_composes_sumcheck_with_a_bound_mle_opening() { target: reduced.target, }, StatementBinding::Bind, + None, &mut verifier, ) .unwrap(); @@ -155,6 +156,7 @@ fn opening_leaves_matching_transcripts_for_following_protocols() { &fixture.root, &query, StatementBinding::Bind, + None, &mut verifier, ) .unwrap(); diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index 572d5b12..1050dbf3 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -42,6 +42,32 @@ pub(crate) fn security_config( Ok(security) } +/// Derives initial OOD grinding from the level-zero collision bound. +/// +/// With `rho = 2^-log_inv_rate`, let `list_size = 1 / (2 * eta * sqrt(rho))`, +/// `pairs = max(list_size * (list_size - 1) / 2, 1)`, and +/// `degree = max(2^packed_vars - 1, 1)`. The unground bound is +/// `128 - log2(pairs) - log2(degree)` bits; grinding covers its rounded-up +/// deficit against the target. Unique-decoding profiles return `None`. +pub(crate) fn ood_grinding_bits( + security: &LigeritoSecurityConfig, + packed_vars: usize, +) -> Option { + let level = security.levels.first()?; + let eta = match level.regime { + SoundnessRegime::JohnsonOod => level.eta?, + SoundnessRegime::Udr => return None, + }; + let rho = (-(level.log_inv_rate as f64)).exp2(); + let list_size = 1.0 / (2.0 * eta * rho.sqrt()); + let pairs = (list_size * (list_size - 1.0) / 2.0).max(1.0); + let degree = ((packed_vars as f64).exp2() - 1.0).max(1.0); + let collision_bits = 128.0 - pairs.log2() - degree.log2(); + let deficit = security.target_security_bits as f64 - collision_bits; + let grinding_bits = deficit.ceil().max(0.0) as u32; + Some(grinding_bits) +} + /// Reproduces the reference prover's k = 4 profile with 16-bit query grinding. /// Flock's `derive_profile` fixes k = 6 and zero query grinding for Fast. fn fast_security_config(m: usize) -> Result { @@ -181,4 +207,15 @@ mod tests { assert!(security_config(m, LigeritoProfile::Fast, HashKind::Blake3).is_err()); } } + + #[test] + fn ood_round_parameters_match_the_level_zero_bound() { + let security = security_config(22, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + assert_eq!(ood_grinding_bits(&security, 15), Some(0)); + + let mut unique = security; + unique.levels[0].regime = SoundnessRegime::Udr; + unique.levels[0].eta = None; + assert_eq!(ood_grinding_bits(&unique, 15), None); + } } diff --git a/crates/pcs/tests/round_trip.rs b/crates/pcs/tests/round_trip.rs index d738a41e..561964eb 100644 --- a/crates/pcs/tests/round_trip.rs +++ b/crates/pcs/tests/round_trip.rs @@ -263,6 +263,70 @@ fn real_pcs_opening_round_trip_succeeds() { verifier.check_eof().unwrap(); } +#[test] +fn real_pcs_ood_round_batches_into_opening() { + let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let mut packed_witness = vec![F128::ZERO; pcs.packed_len()]; + packed_witness[SINGLETON / 128] = F128::new(0, 1 << (SINGLETON % 128 - 64)); + let point = vec![F128::from(2u64); M]; + let query = OpeningQuery::Mle { + target: singleton_target(&point, SINGLETON), + point, + }; + ood_round_trip(&pcs, packed_witness, query); +} + +fn ood_round_trip(pcs: &impl CommitScheme, packed_witness: Vec, query: OpeningQuery) { + let mut prover = build_prover(SESSION, b"ood-round-trip"); + let (commitment, data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); + pcs.prove_lin( + &data, + packed_witness, + &query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + let next_challenge = prover.verifier_message::(); + let proof = prover.finish(); + + let mut verifier = build_verifier(SESSION, b"ood-round-trip", &proof); + let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); + pcs.verify_lin_with_ood(&received, &query, StatementBinding::Bind, &mut verifier) + .unwrap(); + assert_eq!(verifier.verifier_message::(), next_challenge); + verifier.check_eof().unwrap(); +} + +#[test] +fn real_pcs_ood_round_rejects_a_changed_evaluation() { + let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let packed_witness = vec![F128::ZERO; pcs.packed_len()]; + let query = OpeningQuery::Mle { + point: vec![F128::from(2u64); M], + target: F128::ZERO, + }; + let mut prover = build_prover(SESSION, b"ood-tampering"); + let (commitment, data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); + pcs.prove_lin( + &data, + packed_witness, + &query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + let mut proof = prover.finish(); + proof.narg_string[0] ^= 1; + + let mut verifier = build_verifier(SESSION, b"ood-tampering", &proof); + let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); + assert!( + pcs.verify_lin_with_ood(&received, &query, StatementBinding::Bind, &mut verifier,) + .is_err() + ); +} + #[test] fn factored_inner_product_round_trip_succeeds_for_all_profiles_and_bindings() { for profile in [ From 39f550959acad596e747eb49cfb81ca9574ee437 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 23 Sep 2026 12:18:01 +0530 Subject: [PATCH 03/16] feat: bind OOD claims before circuit proof challenges --- crates/prover/src/prove.rs | 8 +-- crates/tests/examples/dump_bitz.rs | 6 ++- crates/tests/tests/host.rs | 6 ++- crates/tests/tests/prove.rs | 23 +++++--- crates/tests/tests/virtual_prove.rs | 16 ++++-- crates/verifier/src/verify.rs | 60 ++++++++++++++++----- tooling/cli/benches/circuits.rs | 12 +++-- tooling/cli/src/benchmark.rs | 2 +- tooling/cli/src/end_to_end.rs | 81 +++++++++++++++++++++++------ tooling/cli/tests/circuits.rs | 4 +- tooling/cli/tests/end_to_end.rs | 31 +++++++++-- 11 files changed, 191 insertions(+), 58 deletions(-) diff --git a/crates/prover/src/prove.rs b/crates/prover/src/prove.rs index 87c62705..055ca7a9 100644 --- a/crates/prover/src/prove.rs +++ b/crates/prover/src/prove.rs @@ -42,8 +42,8 @@ pub struct VirtualWitness<'a> { impl BitZProver { /// Proves the caller's linear claim about the committed bits. /// - /// The caller commits first and passes what that produced: the `data` the - /// opening reads and the packed witness itself. The root is read back off + /// Call `Pcs::commit_with_ood` on this transcript, then pass its retained + /// `data` and the packed witness. The root is read back off /// `data` rather than passed alongside it, so the two cannot disagree. /// `pcs` must be the scheme that committed, or the opening will not verify. /// @@ -82,8 +82,8 @@ impl BitZProver { /// Proves a claim on `h = M (1 || f)` against the commitment to `f`. /// - /// Build the setup from `statement.params().claim()`. Commit `witness.committed_bits` - /// with `pcs` under the committed shape and pass its returned `data`. GKR reduces + /// Build the setup from `statement.params().claim()`. Use `Pcs::commit_with_ood` + /// on `witness.committed_bits` and this transcript, then pass its `data`. GKR reduces /// the input claim to an inner product on padded virtual bits. This method /// transposes its coefficients before PCS opens the committed bits. /// diff --git a/crates/tests/examples/dump_bitz.rs b/crates/tests/examples/dump_bitz.rs index 92f98f77..85f6d316 100644 --- a/crates/tests/examples/dump_bitz.rs +++ b/crates/tests/examples/dump_bitz.rs @@ -29,12 +29,16 @@ fn main() -> Result<(), Box> { let started = std::time::Instant::now(); let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.packed, &mut transcript) + .unwrap(); instance .prover .prove( &instance.claim, &instance.pcs, - &instance.data, + &data, instance.packed.clone(), &mut transcript, ) diff --git a/crates/tests/tests/host.rs b/crates/tests/tests/host.rs index 60d101be..6dfb080e 100644 --- a/crates/tests/tests/host.rs +++ b/crates/tests/tests/host.rs @@ -11,12 +11,16 @@ use tests::{Instance, narrow_shape, prover_transcript, verifier_transcript, wide /// Runs an honest prover and hands back what a caller would ship. fn shipped(instance: &Instance) -> Vec { let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.packed, &mut transcript) + .unwrap(); instance .prover .prove( &instance.claim, &instance.pcs, - &instance.data, + &data, instance.packed.clone(), &mut transcript, ) diff --git a/crates/tests/tests/prove.rs b/crates/tests/tests/prove.rs index d464853c..08311c69 100644 --- a/crates/tests/tests/prove.rs +++ b/crates/tests/tests/prove.rs @@ -13,12 +13,16 @@ use verifier::{ReceiveError, VerifyError}; fn prove(instance: &Instance) -> Proof { let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.packed, &mut transcript) + .unwrap(); instance .prover .prove( &instance.claim, &instance.pcs, - &instance.data, + &data, instance.packed.clone(), &mut transcript, ) @@ -108,12 +112,16 @@ fn an_opening_against_another_commitment_is_refused() { let committed = Instance::honest(narrow_shape(), 36); let mut transcript = prover_transcript(); + let (_, data) = committed + .pcs + .commit_with_ood(&committed.packed, &mut transcript) + .unwrap(); proved .prover .prove( &proved.claim, &proved.pcs, - &committed.data, + &data, proved.packed.clone(), &mut transcript, ) @@ -153,9 +161,8 @@ fn a_tampered_opening_proof_is_refused() { #[test] fn a_proof_verified_under_a_different_profile_is_refused() { - // The profile is not in the frame step 1 absorbs, so what rejects this is - // the opening binding its own parameters: a different profile encodes - // differently, the two sponges part, and the ring-switch check fails. + // OOD binds PCS parameters before the first fold challenge, so a different + // profile changes the fold transcript and GKR rejects. let instance = Instance::honest(narrow_shape(), 38); let slim = Pcs::new( instance.params.shape(), @@ -165,15 +172,15 @@ fn a_proof_verified_under_a_different_profile_is_refused() { .unwrap(); let proof = prove(&instance); - assert_eq!( + assert!(matches!( instance.verifier.verify( &instance.claim, &slim, instance.com, verifier_transcript(&proof) ), - Err(VerifyError::Opening(PcsVerifyError::VerificationFailed)) - ); + Err(VerifyError::Reduction(_)) + )); } #[test] diff --git a/crates/tests/tests/virtual_prove.rs b/crates/tests/tests/virtual_prove.rs index 424de53c..7d4a9048 100644 --- a/crates/tests/tests/virtual_prove.rs +++ b/crates/tests/tests/virtual_prove.rs @@ -94,11 +94,15 @@ impl Instance { fn prove(&self) -> Proof { let mut transcript = prover_transcript(); + let (_, data) = self + .pcs + .commit_with_ood(&self.committed_bits, &mut transcript) + .unwrap(); BitZProver::new(self.params, WINDOW) .prove_virtual( &self.statement(), &self.pcs, - &self.data, + &data, VirtualWitness { committed_bits: self.committed_bits.clone(), virtual_bits: &self.virtual_bits, @@ -297,11 +301,15 @@ fn virtual_bits_inconsistent_with_the_map_cannot_be_opened() { // but the virtual witness no longer equals M (1 || f). virtual_bits[0] = F128::from(6u64); let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.committed_bits, &mut transcript) + .unwrap(); assert_eq!( BitZProver::new(instance.params, WINDOW).prove_virtual( &instance.statement(), &instance.pcs, - &instance.data, + &data, VirtualWitness { committed_bits: instance.committed_bits.clone(), virtual_bits: &virtual_bits, @@ -363,8 +371,10 @@ fn sha256_virtual_inner_product_opens_the_committed_bits() { let claim = LinearClaim::new(¶ms, rows, columns, target).unwrap(); let statement = VirtualStatement::new(params, committed_shape, &map, &claim).unwrap(); let pcs = Pcs::new(&committed_shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - let (root, data) = pcs.commit(&committed_bits).unwrap(); let mut transcript = prover_transcript(); + let (root, data) = pcs + .commit_with_ood(&committed_bits, &mut transcript) + .unwrap(); BitZProver::new(params, WINDOW) .prove_virtual( &statement, diff --git a/crates/verifier/src/verify.rs b/crates/verifier/src/verify.rs index e90f2770..e90da561 100644 --- a/crates/verifier/src/verify.rs +++ b/crates/verifier/src/verify.rs @@ -2,7 +2,7 @@ use common::{LinearClaim, OpeningQuery, Root, VirtualMap, VirtualMapError, VirtualStatement}; use field::Fq; -use pcs::{CommitScheme, Pcs, StatementBinding, VerifyError as OpeningVerifyError}; +use pcs::{CommitScheme, Pcs, StatementBinding, VerifierData, VerifyError as OpeningVerifyError}; use transcript::VerifierState; use crate::{BitZVerifier, ReceiveError, ReduceError, reduce::gkr_reduce}; @@ -25,6 +25,39 @@ pub enum VerifyError { } impl BitZVerifier { + /// Receives the commitment's OOD claim and verifies the virtual BitZ proof. + pub fn verify_virtual( + &self, + statement: &VirtualStatement<'_, Q, impl VirtualMap>, + pcs: &Pcs, + root: Root, + mut transcript: VerifierState<'_>, + ) -> Result<(), VerifyError> { + if self.params() != statement.params().claim() + || pcs.bit_len() != 1 << statement.params().committed_shape().log_bits() + { + return Err(VerifyError::ParameterMismatch); + } + let commitment = pcs + .receive_commitment(root, &mut transcript) + .map_err(VerifyError::Opening)?; + self.verify_virtual_with_commitment(statement, pcs, &commitment, transcript) + } + + /// Receives the commitment's OOD claim and verifies the BitZ proof. + pub fn verify( + &self, + claim: &LinearClaim>, + pcs: &Pcs, + root: Root, + mut transcript: VerifierState<'_>, + ) -> Result<(), VerifyError> { + let commitment = pcs + .receive_commitment(root, &mut transcript) + .map_err(VerifyError::Opening)?; + self.verify_with_commitment(claim, pcs, &commitment, transcript) + } + /// Verifies a claim on `h = M (1 || f)` against the commitment to `f`. /// /// Build the setup from `statement.params().claim()` and match the commitment's @@ -32,15 +65,16 @@ impl BitZVerifier { /// padded virtual bits. Supply the public circuit's map; its digest must cover /// its shape and entries. /// - /// Start the transcript with the prover's session, instance, and public-input events. + /// Continue the transcript that produced `commitment` through + /// [`Pcs::receive_commitment`], using the prover's public-input events. /// This method binds the inputs in [`VirtualStatement`], transposes the reduced /// claim, verifies the PCS opening, and rejects trailing proof or hint bytes. #[tracing::instrument(name = "Verify virtual BitZ", skip_all)] - pub fn verify_virtual( + pub fn verify_virtual_with_commitment( &self, statement: &VirtualStatement<'_, Q, impl VirtualMap>, pcs: &Pcs, - com: Root, + commitment: &VerifierData, mut transcript: VerifierState<'_>, ) -> Result<(), VerifyError> { let params = statement.params(); @@ -51,7 +85,7 @@ impl BitZVerifier { return Err(VerifyError::ParameterMismatch); } transcript.public_message(b"bitz/virtual-statement/v1"); - transcript.public_message(&com.0); + transcript.public_message(&commitment.root().0); transcript.public_message(params); transcript.public_message(&statement.map().digest()); transcript.public_message(claim); @@ -59,7 +93,7 @@ impl BitZVerifier { let query = statement .transpose_query(query) .map_err(VerifyError::VirtualMap)?; - pcs.verify_lin(&com, &query, StatementBinding::Bind, &mut transcript) + pcs.verify_lin_with_ood(commitment, &query, StatementBinding::Bind, &mut transcript) .map_err(VerifyError::Opening)?; transcript .check_eof() @@ -68,29 +102,29 @@ impl BitZVerifier { /// Replays the proof of the caller's linear claim about the committed bits. /// - /// `pcs` must be the scheme the commitment was made under. The transcript - /// arrives carrying the caller's events; this appends and consumes it. + /// `pcs` must be the scheme the commitment was made under. Continue the + /// transcript used by [`Pcs::receive_commitment`]; this consumes it and checks EOF. #[tracing::instrument(name = "Verify BitZ", skip_all)] - pub fn verify( + pub fn verify_with_commitment( &self, claim: &LinearClaim>, pcs: &Pcs, - com: Root, + commitment: &VerifierData, mut transcript: VerifierState<'_>, ) -> Result<(), VerifyError> { // Step 1: the admissibility and precondition checks have already run -- // the shape gates in Shape::new, the modulus in Fq's own const assertions, // the generator's order in BitZParams::new and the weight counts in // LinearClaim::new. What is left is binding, before any challenge. - transcript.public_message(&com.0); + transcript.public_message(&commitment.root().0); transcript.public_message(self.params()); // Steps 3 and 4: check integer folds and replay GKR to obtain a bit claim. let query = self.fold_and_reduce(claim, &mut transcript)?; // Step 6: verify the inner-product sumcheck, ring switch, and opening. - // Acceptance requires authenticating GKR's terminal claim against com. - pcs.verify_lin(&com, &query, StatementBinding::Bind, &mut transcript) + // Acceptance requires authenticating GKR's terminal claim against the commitment. + pcs.verify_lin_with_ood(commitment, &query, StatementBinding::Bind, &mut transcript) .map_err(VerifyError::Opening)?; // Both streams must be spent. Taking the transcript by value is what diff --git a/tooling/cli/benches/circuits.rs b/tooling/cli/benches/circuits.rs index 987f2952..13fec1c1 100644 --- a/tooling/cli/benches/circuits.rs +++ b/tooling/cli/benches/circuits.rs @@ -52,11 +52,13 @@ fn commit(bencher: Bencher, circuit: BuiltinCircuit) { #[divan::bench(args = BuiltinCircuit::ALL)] fn prove(bencher: Bencher, circuit: BuiltinCircuit) { let (system, inputs) = setup(circuit); - let witness = system.witness(&inputs).unwrap(); - let data = system.commit(&witness).unwrap(); bencher - .with_inputs(|| system.witness(&inputs).unwrap()) - .bench_local_values(|witness| system.prove(witness, &data).unwrap()); + .with_inputs(|| { + let witness = system.witness(&inputs).unwrap(); + let data = system.commit(&witness).unwrap(); + (witness, data) + }) + .bench_local_values(|(witness, data)| system.prove(witness, data).unwrap()); } #[divan::bench(args = BuiltinCircuit::ALL)] @@ -64,6 +66,6 @@ fn verify(bencher: Bencher, circuit: BuiltinCircuit) { let (system, inputs) = setup(circuit); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); bencher.bench_local(|| system.verify(&proof).unwrap()); } diff --git a/tooling/cli/src/benchmark.rs b/tooling/cli/src/benchmark.rs index e5bbc09f..cbd8f6a9 100644 --- a/tooling/cli/src/benchmark.rs +++ b/tooling/cli/src/benchmark.rs @@ -38,7 +38,7 @@ pub fn run(statement: S, inputs: &[bool]) -> Result, } +/// Commitment data and the transcript that sampled its OOD claim. +pub struct CommittedWitness { + data: ProverData, + transcript: ProverState, +} + #[derive(Clone, Debug)] pub struct Proof { pub root: Root, @@ -201,23 +209,31 @@ impl CircuitProofSystem { }) } + /// Commits and sends the initial OOD evaluation before any PIOP challenge. + /// The returned state retains both PCS data and the transcript for proving. #[tracing::instrument(name = "commit", skip_all)] - pub fn commit(&self, witness: &Witness) -> Result { - self.pcs - .commit(&witness.committed) - .map(|(_, data)| data) - .map_err(Error::Commit) + pub fn commit(&self, witness: &Witness) -> Result { + let mut transcript = build_prover(SESSION, self.statement.domain()); + let (_, data) = self + .pcs + .commit_with_ood(&witness.committed, &mut transcript) + .map_err(Error::Commit)?; + Ok(CommittedWitness { data, transcript }) } + /// Continues the commitment transcript through Spartan and the BitZ opening. #[tracing::instrument(name = "prove", skip_all, fields(opening_path = ?self.opening_path))] - pub fn prove(&self, witness: Witness, data: &ProverData) -> Result { + pub fn prove(&self, witness: Witness, commitment: CommittedWitness) -> Result { + let CommittedWitness { + data, + mut transcript, + } = commitment; let root = data.root(); - let mut transcript = build_prover(SESSION, self.statement.domain()); self.bind(&mut transcript, root); if self.opening_path == OpeningPath::Direct { self.pcs .prove_lin( - data, + &data, witness.committed.clone(), &self.constant_query(), StatementBinding::Bind, @@ -236,7 +252,7 @@ impl CircuitProofSystem { let prover = BitZProver::new(self.params, WINDOW); match self.opening_path { OpeningPath::Direct => { - prover.prove(&claim, &self.pcs, data, witness.committed, &mut transcript) + prover.prove(&claim, &self.pcs, &data, witness.committed, &mut transcript) } OpeningPath::Virtual => { let statement = @@ -245,7 +261,7 @@ impl CircuitProofSystem { prover.prove_virtual( &statement, &self.pcs, - data, + &data, VirtualWitness { committed_bits: witness.committed, virtual_bits: &witness.assignment_bits, @@ -265,11 +281,15 @@ impl CircuitProofSystem { #[tracing::instrument(name = "verify", skip_all)] pub fn verify(&self, proof: &Proof) -> Result<(), Error> { let mut transcript = build_verifier(SESSION, self.statement.domain(), &proof.opening); + let commitment = self + .pcs + .receive_commitment(proof.root, &mut transcript) + .map_err(Error::OodVerify)?; self.bind(&mut transcript, proof.root); if self.opening_path == OpeningPath::Direct { self.pcs - .verify_lin( - &proof.root, + .verify_lin_with_ood( + &commitment, &self.constant_query(), StatementBinding::Bind, &mut transcript, @@ -281,12 +301,19 @@ impl CircuitProofSystem { let claim = opening_claim(&self.params, &terminal)?; let verifier = BitZVerifier::new(self.params, WINDOW); match self.opening_path { - OpeningPath::Direct => verifier.verify(&claim, &self.pcs, proof.root, transcript), + OpeningPath::Direct => { + verifier.verify_with_commitment(&claim, &self.pcs, &commitment, transcript) + } OpeningPath::Virtual => { let statement = VirtualStatement::new(self.params, self.committed_shape, &self.map, &claim) .map_err(|_| Error::Configuration("invalid virtual statement"))?; - verifier.verify_virtual(&statement, &self.pcs, proof.root, transcript) + verifier.verify_virtual_with_commitment( + &statement, + &self.pcs, + &commitment, + transcript, + ) } } .map_err(Error::Verify) @@ -405,12 +432,28 @@ mod tests { } } + #[test] + fn commitment_sends_ood_before_proving() { + let system = CircuitProofSystem::new(IdentityBit).unwrap(); + let witness = system.witness(&[true]).unwrap(); + let committed = system.commit(&witness).unwrap(); + let proof = committed.transcript.finish(); + assert_eq!(proof.narg_string.len(), 16); + assert!(proof.hints.is_empty()); + let mut verifier = build_verifier(SESSION, system.statement.domain(), &proof); + system + .pcs + .receive_commitment(committed.data.root(), &mut verifier) + .unwrap(); + verifier.check_eof().unwrap(); + } + #[test] fn direct_opening_requires_constant_one_on_both_sides() { let mut system = CircuitProofSystem::new(IdentityBit).unwrap(); let witness = system.witness(&[true]).unwrap(); let data = system.commit(&witness).unwrap(); - let mut proof = system.prove(witness, &data).unwrap(); + let mut proof = system.prove(witness, data).unwrap(); system.verify(&proof).unwrap(); system.opening_path = OpeningPath::Virtual; assert!(system.verify(&proof).is_err()); @@ -420,13 +463,17 @@ mod tests { bad_witness.committed.fill(F128::ZERO); let bad_data = system.commit(&bad_witness).unwrap(); assert!(matches!( - system.prove(bad_witness, &bad_data), + system.prove(bad_witness, bad_data), Err(Error::ConstantProve(_)) )); // A valid opening to zero must not substitute for the required one. let packed = vec![F128::ZERO; 1 << system.committed_shape.log_packed_len()]; let mut transcript = build_prover(SESSION, system.statement.domain()); + let (_, bad_data) = system + .pcs + .commit_with_ood(&packed, &mut transcript) + .unwrap(); system.bind(&mut transcript, bad_data.root()); let query = OpeningQuery::Mle { point: vec![F128::ZERO; system.committed_shape.log_bits()], diff --git a/tooling/cli/tests/circuits.rs b/tooling/cli/tests/circuits.rs index e5581ca5..420e238d 100644 --- a/tooling/cli/tests/circuits.rs +++ b/tooling/cli/tests/circuits.rs @@ -53,7 +53,7 @@ fn supported_sha_circuits_prove_and_verify() { let system = CircuitProofSystem::new(statement).unwrap(); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); system.verify(&proof).unwrap(); } } @@ -76,7 +76,7 @@ fn sha_compression_matches_abc_and_binds_public_values() { let system = CircuitProofSystem::new(statement.clone()).unwrap(); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); CircuitProofSystem::new(statement.clone()) .unwrap() .verify(&proof) diff --git a/tooling/cli/tests/end_to_end.rs b/tooling/cli/tests/end_to_end.rs index 7484ce80..857f7b80 100644 --- a/tooling/cli/tests/end_to_end.rs +++ b/tooling/cli/tests/end_to_end.rs @@ -1,5 +1,28 @@ -use bitz_cli::end_to_end::{CircuitProofSystem, CircuitStatement, Error, OpeningPath}; +use bitz_cli::end_to_end::{CircuitProofSystem, CircuitStatement, Error, OpeningPath, Proof}; use circuit::Circuit; +use pcs::VerifyError; + +fn rejects_changed_or_missing_ood( + system: &CircuitProofSystem, + proof: &Proof, +) { + // These Fast-profile fixtures have zero initial grinding bits, so the first + // 16 transcript bytes encode the OOD evaluation. + let mut changed = proof.clone(); + changed.opening.narg_string[0] ^= 1; + assert!(system.verify(&changed).is_err()); + + let mut missing = proof.clone(); + missing.opening.narg_string.drain(..16); + assert!(system.verify(&missing).is_err()); + + let mut truncated = proof.clone(); + truncated.opening.narg_string.truncate(15); + assert!(matches!( + system.verify(&truncated), + Err(Error::OodVerify(VerifyError::MalformedProof)) + )); +} struct PublicBit; @@ -28,11 +51,12 @@ fn generic_driver_accepts_a_non_sha_circuit() { assert_eq!(prepared.stats().committed_bits, 2); let witness = prepared.witness(&[true]).unwrap(); let data = prepared.commit(&witness).unwrap(); - let proof = prepared.prove(witness, &data).unwrap(); + let proof = prepared.prove(witness, data).unwrap(); CircuitProofSystem::new(PublicBit) .unwrap() .verify(&proof) .unwrap(); + rejects_changed_or_missing_ood(&prepared, &proof); let mut changed = proof.clone(); changed.root.0[0] ^= 1; @@ -100,11 +124,12 @@ fn nonidentity_map_uses_virtual_opening_and_checks_xor_relation() { assert_eq!(system.stats().committed_bits, 2); let witness = system.witness(&[true, false]).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); CircuitProofSystem::new(PublicXor) .unwrap() .verify(&proof) .unwrap(); + rejects_changed_or_missing_ood(&system, &proof); assert!(matches!( system.witness(&[true, true]), Err(Error::Unsatisfied) From de704377bf50455893968500a5ca1d6be1cd26cb Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 29 Sep 2026 09:56:51 +0200 Subject: [PATCH 04/16] wip: e2e security --- Cargo.lock | 1 + crates/common/src/shape.rs | 17 +++--- crates/gkr/src/lib.rs | 114 +++++++++++++++++++++++++++++++++++-- 3 files changed, 117 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2842e2c6..3603d4a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1805,6 +1805,7 @@ dependencies = [ name = "transcript" version = "0.1.0" dependencies = [ + "blake3", "field", "spongefish", ] diff --git a/crates/common/src/shape.rs b/crates/common/src/shape.rs index 22333b41..054cf40e 100644 --- a/crates/common/src/shape.rs +++ b/crates/common/src/shape.rs @@ -4,12 +4,9 @@ /// packed field element carries. pub const PACK_BITS: u32 = 7; -/// The commitment size window the opening parameters are fixed for. -/// -/// `22..=35` is not derived from a security bound here; it is the range -/// `flock-core`'s Ligerito configs are precomputed for (one shipped TOML per -/// `m` in that range, per profile). Sizes outside it have no config to load. -pub const MIN_LOG_BITS: usize = 22; +/// The size window for dynamically derived opening parameters. +/// Legacy profiles require at least `2^22` bits. +pub const MIN_LOG_BITS: usize = 20; /// The upper end of that window. pub const MAX_LOG_BITS: usize = 35; @@ -19,7 +16,7 @@ pub enum ShapeError { /// Fewer than seven row-index bits: a packed row would not fill one /// codeword position. The paper writes this count `t`. RowIndexTooNarrow, - /// The total bit count falls outside `2^22..=2^35`. + /// The total bit count falls outside `2^20..=2^35`. CommitmentSizeOutOfRange, } @@ -118,8 +115,8 @@ mod tests { #[test] fn rejects_a_commitment_size_outside_the_window() { - // m = 21, then m = 36. - assert_eq!(Shape::new(7, 14), Err(ShapeError::CommitmentSizeOutOfRange)); + // m = 19, then m = 36. + assert_eq!(Shape::new(7, 12), Err(ShapeError::CommitmentSizeOutOfRange)); assert_eq!( Shape::new(13, 23), Err(ShapeError::CommitmentSizeOutOfRange) @@ -157,7 +154,7 @@ mod tests { #[test] fn accepts_the_window_boundaries() { - assert_eq!(Shape::new(7, 15).unwrap().log_bits(), MIN_LOG_BITS); + assert_eq!(Shape::new(7, 13).unwrap().log_bits(), MIN_LOG_BITS); assert_eq!(Shape::new(14, 21).unwrap().log_bits(), MAX_LOG_BITS); } } diff --git a/crates/gkr/src/lib.rs b/crates/gkr/src/lib.rs index 4a7f9617..fe6b4c32 100644 --- a/crates/gkr/src/lib.rs +++ b/crates/gkr/src/lib.rs @@ -3,12 +3,15 @@ use std::collections::VecDeque; use field::{F128, Wide256}; use num_traits::{ConstOne, ConstZero}; use rayon::prelude::*; -use transcript::{ProverState, VerifierState}; +use transcript::{ProverState, SecurityLevel, VerifierState}; pub type Field = F128; type Point = VecDeque; +const CUBIC_GRINDING_LABEL: &[u8] = b"gkr/cubic/v1"; +const AFFINE_GRINDING_LABEL: &[u8] = b"gkr/affine/v1"; + /// Proves the layer-by-layer sumcheck reduction from a claim at `point` /// (an evaluation point on the output layer) down to a claim on the leaves. // TODO #[must_use], requires changing the test suite @@ -18,6 +21,18 @@ pub fn gpgkr_prove( point: &[F128], // All the intermediate witnesses + the input layer. Doesn't contain the output layer witnesses: LayerWitnesses, +) -> (Vec, Field) { + gpgkr_prove_with_security(ps, point, witnesses, None) +} + +/// Proves GKR with optional grinding for the selected classical security target. +/// +/// `None` preserves the legacy transcript. +pub fn gpgkr_prove_with_security( + ps: &mut ProverState, + point: &[F128], + witnesses: LayerWitnesses, + security: Option, ) -> (Vec, Field) { // Edge cases // - empty witnesses -> single constant circuit -> one verifier message that permutes the proof state, but a single constant can't have an MLE @@ -28,7 +43,7 @@ pub fn gpgkr_prove( let mut claim = Field::ZERO; for wnext in witnesses.into_iter() { - (point, claim) = prove_layer(ps, point, wnext); + (point, claim) = prove_layer(ps, point, wnext, security); } let mut point = Vec::from(point); @@ -36,7 +51,12 @@ pub fn gpgkr_prove( (point, claim) } -fn prove_layer(ps: &mut ProverState, point: Point, mut wnext: Vec) -> (Point, Field) { +fn prove_layer( + ps: &mut ProverState, + point: Point, + mut wnext: Vec, + security: Option, +) -> (Point, Field) { let mut suffix_table = SuffixTable::new(&point); let mut factor = Field::ONE; @@ -95,6 +115,9 @@ fn prove_layer(ps: &mut ProverState, point: Point, mut wnext: Vec) -> (Po ps.prover_message(&[factor * sum_endpoint.reduce(), factor * sum_inf.reduce()]); + if let Some(security) = security { + ps.grind(CUBIC_GRINDING_LABEL, security.grinding_bits(3)); + } let r = ps.verifier_message(); next_point.push_back(r); @@ -119,6 +142,9 @@ fn prove_layer(ps: &mut ProverState, point: Point, mut wnext: Vec) -> (Po } ps.prover_message(&[mle_l[0], mle_r[0]]); + if let Some(security) = security { + ps.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)); + } let r = ps.verifier_message(); next_point.push_front(r); let claim = mle_l[0] + r * (mle_r[0] - mle_l[0]); @@ -191,10 +217,24 @@ const PARALLEL_MIN_LANES: usize = 1 << 12; #[must_use] #[tracing::instrument(name = "Verify GKR", skip_all)] pub fn gpgkr_verify( + vs: &mut VerifierState, + claim: Field, + point: &[F128], + rounds: u32, +) -> Option<(Vec, Field)> { + gpgkr_verify_with_security(vs, claim, point, rounds, None) +} + +/// Verifies GKR with optional grinding for the selected classical security target. +/// +/// `None` preserves the legacy transcript. +#[must_use] +pub fn gpgkr_verify_with_security( vs: &mut VerifierState, mut claim: Field, point: &[F128], rounds: u32, + security: Option, ) -> Option<(Vec, Field)> { // Edge cases around input lenghts, 0 meaning empty // | circuit | last value | @@ -211,7 +251,7 @@ pub fn gpgkr_verify( let mut point = VecDeque::from(point); for _i in 0..rounds { - (point, claim) = verify_layer(vs, claim, point)? + (point, claim) = verify_layer(vs, claim, point, security)? } let mut point = Vec::from(point); @@ -221,7 +261,12 @@ pub fn gpgkr_verify( } /// Point's orientation is the reverse of gpgkr_verify -fn verify_layer(vs: &mut VerifierState, mut claim: Field, point: Point) -> Option<(Point, Field)> { +fn verify_layer( + vs: &mut VerifierState, + mut claim: Field, + point: Point, + security: Option, +) -> Option<(Point, Field)> { let mut prefix = Field::ONE; let mut next_point: Point = VecDeque::new(); @@ -237,6 +282,10 @@ fn verify_layer(vs: &mut VerifierState, mut claim: Field, point: Point) -> Optio (sum_endpoint, (claim - eqjsum0) / z) }; + if let Some(security) = security { + vs.grind(CUBIC_GRINDING_LABEL, security.grinding_bits(3)) + .ok()?; + } let r = vs.verifier_message(); next_point.push_back(r); let factor = eq_factor(r, z); @@ -253,6 +302,10 @@ fn verify_layer(vs: &mut VerifierState, mut claim: Field, point: Point) -> Optio if (prefix * elem_lr[0] * elem_lr[1]) != claim { None } else { + if let Some(security) = security { + vs.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)) + .ok()?; + } let r = vs.verifier_message(); next_point.push_front(r); @@ -434,6 +487,57 @@ mod tests { } } + #[test] + fn gpgkr_security_targets_replay_and_reject_changed_nonces() { + let leaves: Vec = (1u128..=16).map(Field::from).collect(); + let point = [Field::from(5u128), Field::from(7u128)]; + let circuit = GrandProductCircuit::new(leaves.clone()); + let instance = (leaves.clone(), point.to_vec()); + let mut legacy_bytes = None; + + for security in [ + None, + Some(SecurityLevel::Bits100), + Some(SecurityLevel::Bits128), + ] { + let (output, witnesses) = circuit.batched_eval(4); + let claim = mle(output, &point); + let mut prover = transcript::build_prover("gkr-security", &instance); + let terminal = gpgkr_prove_with_security(&mut prover, &point, witnesses, security); + assert_eq!(terminal.1, mle(leaves.clone(), &terminal.0)); + let mut proof = prover.finish(); + + let mut verifier = transcript::build_verifier("gkr-security", &instance, &proof); + assert_eq!( + gpgkr_verify_with_security(&mut verifier, claim, &point, 2, security), + Some(terminal) + ); + verifier.check_eof().unwrap(); + + match security { + None => legacy_bytes = Some(proof.narg_string), + Some(SecurityLevel::Bits100) => { + assert_eq!(Some(proof.narg_string), legacy_bytes); + } + Some(SecurityLevel::Bits128) => { + // Two layers have five cubic challenges and five eight-byte nonces. + assert_eq!( + proof.narg_string.len(), + legacy_bytes.as_ref().unwrap().len() + 40 + ); + // The first cubic message occupies two canonical field elements. + proof.narg_string[32] ^= 1; + let mut verifier = + transcript::build_verifier("gkr-security", &instance, &proof); + assert!( + gpgkr_verify_with_security(&mut verifier, claim, &point, 2, security) + .is_none() + ); + } + } + } + } + #[test] fn gpgkr_round_trip_with_zero_coordinates() { let leaves: Vec = (1u128..=16).map(Field::from).collect(); From 5f928ab2b786246586397bcea2bf40af1bfd9461 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 29 Sep 2026 12:57:47 +0200 Subject: [PATCH 05/16] feat: 128 bit pcs config --- Cargo.lock | 1 - README.md | 6 + crates/common/src/shape.rs | 1 - crates/gkr/src/lib.rs | 101 +++----- crates/pcs/Cargo.toml | 1 - crates/pcs/src/challenger.rs | 107 +++++++- crates/pcs/src/commitment.rs | 282 +++++++++++--------- crates/pcs/src/lib.rs | 102 ++------ crates/pcs/src/ligerito.rs | 61 +++-- crates/pcs/src/ood.rs | 56 +--- crates/pcs/src/opening.rs | 101 ++++---- crates/pcs/src/opening/tests.rs | 170 +++++++++--- crates/pcs/src/pow.rs | 47 ---- crates/pcs/src/sumcheck.rs | 10 +- crates/pcs/src/sumcheck/tests.rs | 54 +++- crates/pcs/tests/round_trip.rs | 375 ++++++++++++++------------- crates/prover/benches/prover.rs | 15 +- crates/prover/examples/profile.rs | 9 +- crates/prover/src/fold.rs | 7 +- crates/prover/src/prove.rs | 27 +- crates/prover/src/reduce.rs | 11 +- crates/tests/examples/dump_bitz.rs | 3 +- crates/tests/examples/dump_commit.rs | 8 +- crates/tests/examples/verify_bitz.rs | 2 +- crates/tests/src/lib.rs | 20 +- crates/tests/tests/fold.rs | 50 ++-- crates/tests/tests/host.rs | 4 +- crates/tests/tests/prove.rs | 172 +++++++++--- crates/tests/tests/virtual_prove.rs | 94 +++++-- crates/transcript/Cargo.toml | 1 + crates/transcript/src/lib.rs | 2 + crates/transcript/src/pow.rs | 182 +++++++++++++ crates/transcript/src/prover.rs | 17 ++ crates/transcript/src/verifier.rs | 18 ++ crates/transcript/tests/hints.rs | 10 +- crates/verifier/src/fold.rs | 9 +- crates/verifier/src/reduce.rs | 9 +- crates/verifier/src/verify.rs | 76 ++---- tooling/cli/benches/circuits.rs | 14 +- tooling/cli/src/benchmark.rs | 17 +- tooling/cli/src/cmd/circuit_e2e.rs | 53 +++- tooling/cli/src/end_to_end.rs | 72 +++-- tooling/cli/tests/circuits.rs | 15 +- tooling/cli/tests/end_to_end.rs | 85 +++--- 44 files changed, 1527 insertions(+), 950 deletions(-) delete mode 100644 crates/pcs/src/pow.rs create mode 100644 crates/transcript/src/pow.rs diff --git a/Cargo.lock b/Cargo.lock index 3603d4a7..b7e2221b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1113,7 +1113,6 @@ name = "pcs" version = "0.1.0" dependencies = [ "bincode", - "blake3", "common", "divan", "field", diff --git a/README.md b/README.md index df5954ad..e93b4f66 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,12 @@ Select the workload with `--circuit`: `--num-blocks` to set the chain or block-aligned message length in 64-byte blocks. Use `--threads` to set the number of worker threads. +Use `--pcs-security-bits 100` or `128` to select the PCS round budget; the default is `100`. +The implementation derives internal parameters from the padded witness size. +The `100` target uses list decoding and an initial out-of-domain (OOD) check. +The `128` target uses unique decoding and omits that check. +Spartan still uses `Q100`; this option does include spartan yet. + ## Benchmarks Run all SHA-256 circuit benchmarks with one Rayon worker: diff --git a/crates/common/src/shape.rs b/crates/common/src/shape.rs index 054cf40e..8270f04f 100644 --- a/crates/common/src/shape.rs +++ b/crates/common/src/shape.rs @@ -5,7 +5,6 @@ pub const PACK_BITS: u32 = 7; /// The size window for dynamically derived opening parameters. -/// Legacy profiles require at least `2^22` bits. pub const MIN_LOG_BITS: usize = 20; /// The upper end of that window. pub const MAX_LOG_BITS: usize = 35; diff --git a/crates/gkr/src/lib.rs b/crates/gkr/src/lib.rs index fe6b4c32..e459e8e4 100644 --- a/crates/gkr/src/lib.rs +++ b/crates/gkr/src/lib.rs @@ -19,20 +19,8 @@ const AFFINE_GRINDING_LABEL: &[u8] = b"gkr/affine/v1"; pub fn gpgkr_prove( ps: &mut ProverState, point: &[F128], - // All the intermediate witnesses + the input layer. Doesn't contain the output layer witnesses: LayerWitnesses, -) -> (Vec, Field) { - gpgkr_prove_with_security(ps, point, witnesses, None) -} - -/// Proves GKR with optional grinding for the selected classical security target. -/// -/// `None` preserves the legacy transcript. -pub fn gpgkr_prove_with_security( - ps: &mut ProverState, - point: &[F128], - witnesses: LayerWitnesses, - security: Option, + security: SecurityLevel, ) -> (Vec, Field) { // Edge cases // - empty witnesses -> single constant circuit -> one verifier message that permutes the proof state, but a single constant can't have an MLE @@ -55,7 +43,7 @@ fn prove_layer( ps: &mut ProverState, point: Point, mut wnext: Vec, - security: Option, + security: SecurityLevel, ) -> (Point, Field) { let mut suffix_table = SuffixTable::new(&point); let mut factor = Field::ONE; @@ -115,9 +103,7 @@ fn prove_layer( ps.prover_message(&[factor * sum_endpoint.reduce(), factor * sum_inf.reduce()]); - if let Some(security) = security { - ps.grind(CUBIC_GRINDING_LABEL, security.grinding_bits(3)); - } + ps.grind(CUBIC_GRINDING_LABEL, security.grinding_bits(3)); let r = ps.verifier_message(); next_point.push_back(r); @@ -142,9 +128,7 @@ fn prove_layer( } ps.prover_message(&[mle_l[0], mle_r[0]]); - if let Some(security) = security { - ps.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)); - } + ps.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)); let r = ps.verifier_message(); next_point.push_front(r); let claim = mle_l[0] + r * (mle_r[0] - mle_l[0]); @@ -217,24 +201,11 @@ const PARALLEL_MIN_LANES: usize = 1 << 12; #[must_use] #[tracing::instrument(name = "Verify GKR", skip_all)] pub fn gpgkr_verify( - vs: &mut VerifierState, - claim: Field, - point: &[F128], - rounds: u32, -) -> Option<(Vec, Field)> { - gpgkr_verify_with_security(vs, claim, point, rounds, None) -} - -/// Verifies GKR with optional grinding for the selected classical security target. -/// -/// `None` preserves the legacy transcript. -#[must_use] -pub fn gpgkr_verify_with_security( vs: &mut VerifierState, mut claim: Field, point: &[F128], rounds: u32, - security: Option, + security: SecurityLevel, ) -> Option<(Vec, Field)> { // Edge cases around input lenghts, 0 meaning empty // | circuit | last value | @@ -265,7 +236,7 @@ fn verify_layer( vs: &mut VerifierState, mut claim: Field, point: Point, - security: Option, + security: SecurityLevel, ) -> Option<(Point, Field)> { let mut prefix = Field::ONE; @@ -282,10 +253,8 @@ fn verify_layer( (sum_endpoint, (claim - eqjsum0) / z) }; - if let Some(security) = security { - vs.grind(CUBIC_GRINDING_LABEL, security.grinding_bits(3)) - .ok()?; - } + vs.grind(CUBIC_GRINDING_LABEL, security.grinding_bits(3)) + .ok()?; let r = vs.verifier_message(); next_point.push_back(r); let factor = eq_factor(r, z); @@ -302,10 +271,8 @@ fn verify_layer( if (prefix * elem_lr[0] * elem_lr[1]) != claim { None } else { - if let Some(security) = security { - vs.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)) - .ok()?; - } + vs.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)) + .ok()?; let r = vs.verifier_message(); next_point.push_front(r); @@ -493,46 +460,33 @@ mod tests { let point = [Field::from(5u128), Field::from(7u128)]; let circuit = GrandProductCircuit::new(leaves.clone()); let instance = (leaves.clone(), point.to_vec()); - let mut legacy_bytes = None; + let mut unground_len = 0; - for security in [ - None, - Some(SecurityLevel::Bits100), - Some(SecurityLevel::Bits128), - ] { + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { let (output, witnesses) = circuit.batched_eval(4); let claim = mle(output, &point); let mut prover = transcript::build_prover("gkr-security", &instance); - let terminal = gpgkr_prove_with_security(&mut prover, &point, witnesses, security); + let terminal = gpgkr_prove(&mut prover, &point, witnesses, security); assert_eq!(terminal.1, mle(leaves.clone(), &terminal.0)); let mut proof = prover.finish(); let mut verifier = transcript::build_verifier("gkr-security", &instance, &proof); assert_eq!( - gpgkr_verify_with_security(&mut verifier, claim, &point, 2, security), + gpgkr_verify(&mut verifier, claim, &point, 2, security), Some(terminal) ); verifier.check_eof().unwrap(); match security { - None => legacy_bytes = Some(proof.narg_string), - Some(SecurityLevel::Bits100) => { - assert_eq!(Some(proof.narg_string), legacy_bytes); - } - Some(SecurityLevel::Bits128) => { + SecurityLevel::Bits100 => unground_len = proof.narg_string.len(), + SecurityLevel::Bits128 => { // Two layers have five cubic challenges and five eight-byte nonces. - assert_eq!( - proof.narg_string.len(), - legacy_bytes.as_ref().unwrap().len() + 40 - ); + assert_eq!(proof.narg_string.len(), unground_len + 40); // The first cubic message occupies two canonical field elements. proof.narg_string[32] ^= 1; let mut verifier = transcript::build_verifier("gkr-security", &instance, &proof); - assert!( - gpgkr_verify_with_security(&mut verifier, claim, &point, 2, security) - .is_none() - ); + assert!(gpgkr_verify(&mut verifier, claim, &point, 2, security).is_none()); } } } @@ -554,19 +508,28 @@ mod tests { let claim = mle(output, &point); let instance = (leaves.clone(), point.to_vec()); let mut prover = transcript::build_prover("gkr-zero", &instance); - let terminal = gpgkr_prove(&mut prover, &point, witnesses); + let terminal = gpgkr_prove(&mut prover, &point, witnesses, SecurityLevel::Bits100); assert_eq!(terminal.1, mle(leaves.clone(), &terminal.0)); let proof = prover.finish(); let mut verifier = transcript::build_verifier("gkr-zero", &instance, &proof); assert_eq!( - gpgkr_verify(&mut verifier, claim, &point, 2), + gpgkr_verify(&mut verifier, claim, &point, 2, SecurityLevel::Bits100), Some(terminal) ); verifier.check_eof().unwrap(); let mut verifier = transcript::build_verifier("gkr-zero", &instance, &proof); - assert!(gpgkr_verify(&mut verifier, claim + Field::ONE, &point, 2).is_none()); + assert!( + gpgkr_verify( + &mut verifier, + claim + Field::ONE, + &point, + 2, + SecurityLevel::Bits100 + ) + .is_none() + ); } } @@ -605,7 +568,7 @@ mod tests { let log_groups = last_value.len().max(1).ilog2(); let point: Vec = (0..log_groups).map(|_| prover.verifier_message()).collect(); - gpgkr_prove(&mut prover, &point, witnesses); + gpgkr_prove(&mut prover, &point, witnesses, SecurityLevel::Bits100); (last_value, prover.finish()) } @@ -623,7 +586,7 @@ mod tests { let log_leafs = circuit.leafs.len().max(1).ilog2(); let rounds = log_leafs.saturating_sub(log_groups); - match gpgkr_verify(&mut verifier, claim, &point, rounds) { + match gpgkr_verify(&mut verifier, claim, &point, rounds, SecurityLevel::Bits100) { Some((point, claim)) => { let leaf_check = mle(circuit.leafs, &point); diff --git a/crates/pcs/Cargo.toml b/crates/pcs/Cargo.toml index 18218780..cff5e894 100644 --- a/crates/pcs/Cargo.toml +++ b/crates/pcs/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true [dependencies] bincode = { workspace = true } -blake3 = { workspace = true } common = { workspace = true } field = { workspace = true, features = ["spongefish"] } flock-core = { workspace = true } diff --git a/crates/pcs/src/challenger.rs b/crates/pcs/src/challenger.rs index 0a341c49..5bcc1137 100644 --- a/crates/pcs/src/challenger.rs +++ b/crates/pcs/src/challenger.rs @@ -1,10 +1,10 @@ //! Flock challenger adapters over the project transcript. use crate::bridge::{as_flock_f128, from_flock_f128}; -use crate::pow::{find as find_pow, valid as pow_valid}; use field::F128 as LocalF128; use flock_core::challenger::Challenger; use flock_core::field::F128 as FlockF128; +use transcript::pow::{find as find_pow, valid as pow_valid}; use transcript::{ProverState, VerifierState}; const VECTOR_SQUEEZE_TAG: &[u8] = b"pcs/flock/sample-vector/v1"; @@ -12,6 +12,28 @@ const POW_TAG: &[u8] = b"pcs/flock/pow/v1"; const LIGERITO_BASIS_LABEL: &[u8] = b"flock-ligerito-basis-v0"; const MAX_OBSERVED_BYTES: usize = 32; +/// Checks the backend call order and enforces the selected fold difficulties. +struct PowSchedule { + calls: std::vec::IntoIter<(u32, u32)>, + failed: bool, +} + +impl PowSchedule { + fn difficulty(&mut self, native: u32) -> u32 { + match self.calls.next() { + Some((expected, effective)) if native == expected => effective, + _ => { + self.failed = true; + 0 + } + } + } + + fn failed(&self) -> bool { + self.failed || self.calls.len() != 0 + } +} + #[derive(Clone, Copy)] struct OpeningTargetPrefix { expected_target: FlockF128, @@ -22,14 +44,19 @@ pub(crate) struct ProverChallenger<'a> { transcript: &'a mut ProverState, failed: bool, opening_target: Option, + pow_schedule: PowSchedule, } impl<'a> ProverChallenger<'a> { #[cfg(test)] - pub(crate) fn new(transcript: &'a mut ProverState) -> Self { + pub(crate) fn new(transcript: &'a mut ProverState, calls: Vec<(u32, u32)>) -> Self { Self { transcript, failed: false, + pow_schedule: PowSchedule { + calls: calls.into_iter(), + failed: false, + }, opening_target: None, } } @@ -37,10 +64,15 @@ impl<'a> ProverChallenger<'a> { pub(crate) fn new_ligerito( transcript: &'a mut ProverState, expected_target: FlockF128, + calls: Vec<(u32, u32)>, ) -> Self { Self { transcript, failed: false, + pow_schedule: PowSchedule { + calls: calls.into_iter(), + failed: false, + }, opening_target: Some(OpeningTargetPrefix { expected_target, label_seen: false, @@ -49,7 +81,7 @@ impl<'a> ProverChallenger<'a> { } pub(crate) fn failed(&self) -> bool { - self.failed || self.opening_target.is_some() + self.failed || self.opening_target.is_some() || self.pow_schedule.failed() } } @@ -57,14 +89,19 @@ pub(crate) struct VerifierChallenger<'a, 'proof> { transcript: &'a mut VerifierState<'proof>, failed: bool, opening_target: Option, + pow_schedule: PowSchedule, } impl<'a, 'proof> VerifierChallenger<'a, 'proof> { #[cfg(test)] - pub(crate) fn new(transcript: &'a mut VerifierState<'proof>) -> Self { + pub(crate) fn new(transcript: &'a mut VerifierState<'proof>, calls: Vec<(u32, u32)>) -> Self { Self { transcript, failed: false, + pow_schedule: PowSchedule { + calls: calls.into_iter(), + failed: false, + }, opening_target: None, } } @@ -72,10 +109,15 @@ impl<'a, 'proof> VerifierChallenger<'a, 'proof> { pub(crate) fn new_ligerito( transcript: &'a mut VerifierState<'proof>, expected_target: FlockF128, + calls: Vec<(u32, u32)>, ) -> Self { Self { transcript, failed: false, + pow_schedule: PowSchedule { + calls: calls.into_iter(), + failed: false, + }, opening_target: Some(OpeningTargetPrefix { expected_target, label_seen: false, @@ -84,7 +126,7 @@ impl<'a, 'proof> VerifierChallenger<'a, 'proof> { } pub(crate) fn failed(&self) -> bool { - self.failed || self.opening_target.is_some() + self.failed || self.opening_target.is_some() || self.pow_schedule.failed() } fn read(&mut self) -> Option @@ -149,6 +191,7 @@ impl Challenger for ProverChallenger<'_> { } fn grind_pow(&mut self, bits: u32) -> u64 { + let bits = self.pow_schedule.difficulty(bits); self.transcript.public_message(POW_TAG); self.transcript.public_message(&bits); let seed = self.transcript.verifier_message::().to_bytes(); @@ -220,6 +263,7 @@ impl Challenger for VerifierChallenger<'_, '_> { } fn verify_pow(&mut self, nonce: u64, bits: u32) -> bool { + let bits = self.pow_schedule.difficulty(bits); self.transcript.public_message(POW_TAG); self.transcript.public_message(&bits); let seed = self.transcript.verifier_message::().to_bytes(); @@ -240,6 +284,43 @@ mod tests { use super::*; + #[test] + fn constant_schedule_replays_and_rejects_changed_call_order() { + let calls = vec![(5, 5), (4, 5), (3, 5), (0, 0)]; + let mut transcript = build_prover(b"schedule", b"instance"); + let mut prover = ProverChallenger::new(&mut transcript, calls.clone()); + assert!(prover.failed()); // An incomplete schedule cannot succeed. + let nonces: Vec<_> = calls + .iter() + .map(|&(native, _)| prover.grind_pow(native)) + .collect(); + assert!(!prover.failed()); + let proof = transcript.finish(); + + let mut transcript = build_verifier(b"schedule", b"instance", &proof); + let mut verifier = VerifierChallenger::new(&mut transcript, calls.clone()); + for (&nonce, &(native, _)) in nonces.iter().zip(&calls) { + assert!(verifier.verify_pow(nonce, native)); + } + assert!(!verifier.failed()); + transcript.check_eof().unwrap(); + + let mut transcript = build_verifier(b"schedule", b"instance", &proof); + let mut verifier = VerifierChallenger::new(&mut transcript, calls); + verifier.verify_pow(nonces[0], 4); + assert!(verifier.failed()); + } + + #[test] + fn extra_pow_calls_fail_after_schedule_completion() { + let mut transcript = build_prover(b"schedule", b"instance"); + let mut prover = ProverChallenger::new(&mut transcript, vec![(0, 0)]); + prover.grind_pow(0); + assert!(!prover.failed()); + prover.grind_pow(0); + assert!(prover.failed()); + } + #[test] fn zero_bit_pow_has_one_canonical_nonce() { assert!(pow_valid(&[0; 16], 0, 0)); @@ -262,7 +343,7 @@ mod tests { fn observed_root_changes_the_following_challenge() { fn sample_after_root(root: &[u8; 32]) -> FlockF128 { let mut transcript = build_prover(b"pcs-challenger-test", b"root-binding"); - let mut challenger = ProverChallenger::new(&mut transcript); + let mut challenger = ProverChallenger::new(&mut transcript, vec![]); challenger.observe_bytes(root); challenger.sample_f128() } @@ -289,7 +370,7 @@ mod tests { let mut prover = build_prover(SESSION, INSTANCE); let nonce = { - let mut challenger = ProverChallenger::new(&mut prover); + let mut challenger = ProverChallenger::new(&mut prover, vec![(BITS, BITS)]); challenger.grind_pow(BITS) }; let mut proof = prover.finish(); @@ -304,7 +385,7 @@ mod tests { let mut verifier = build_verifier(SESSION, INSTANCE, &proof); { - let mut challenger = VerifierChallenger::new(&mut verifier); + let mut challenger = VerifierChallenger::new(&mut verifier, vec![(BITS, BITS)]); assert!(!challenger.verify_pow(changed_nonce, BITS)); assert!(challenger.failed()); } @@ -317,7 +398,7 @@ mod tests { let next_message = FlockF128::new(3, 4); let mut prover = build_prover(b"pcs-challenger-test", b"public-opening-target"); { - let mut challenger = ProverChallenger::new_ligerito(&mut prover, target); + let mut challenger = ProverChallenger::new_ligerito(&mut prover, target, vec![]); challenger.observe_label(LIGERITO_BASIS_LABEL); challenger.observe_f128(target); challenger.observe_f128(next_message); @@ -328,7 +409,7 @@ mod tests { let mut verifier = build_verifier(b"pcs-challenger-test", b"public-opening-target", &proof); { - let mut challenger = VerifierChallenger::new_ligerito(&mut verifier, target); + let mut challenger = VerifierChallenger::new_ligerito(&mut verifier, target, vec![]); challenger.observe_label(LIGERITO_BASIS_LABEL); challenger.observe_f128(target); challenger.observe_f128(next_message); @@ -341,7 +422,7 @@ mod tests { fn ligerito_public_target_changes_the_challenge() { fn sample(target: FlockF128) -> FlockF128 { let mut prover = build_prover(b"pcs-challenger-test", b"public-opening-target"); - let mut challenger = ProverChallenger::new_ligerito(&mut prover, target); + let mut challenger = ProverChallenger::new_ligerito(&mut prover, target, vec![]); challenger.observe_label(LIGERITO_BASIS_LABEL); challenger.observe_f128(target); challenger.sample_f128() @@ -369,7 +450,7 @@ mod tests { let mut prover = build_prover(b"pcs-challenger-test", b"method-round-trip"); let (sampled_scalar, sampled_vector, nonce) = { - let mut challenger = ProverChallenger::new(&mut prover); + let mut challenger = ProverChallenger::new(&mut prover, vec![(pow_bits, pow_bits)]); challenger.observe_label(b"test-label"); challenger.observe_f128(scalar); challenger.observe_f128_slice(&slice); @@ -388,7 +469,7 @@ mod tests { &proof, ); { - let mut challenger = VerifierChallenger::new(&mut verifier); + let mut challenger = VerifierChallenger::new(&mut verifier, vec![(pow_bits, pow_bits)]); challenger.observe_label(b"test-label"); challenger.observe_f128(scalar); challenger.observe_f128_slice(&slice); diff --git a/crates/pcs/src/commitment.rs b/crates/pcs/src/commitment.rs index 2a2ca593..00e3e050 100644 --- a/crates/pcs/src/commitment.rs +++ b/crates/pcs/src/commitment.rs @@ -6,20 +6,21 @@ //! 3. Expose the Merkle root as the public commitment. //! 4. Retain Flock prover data for later openings. -use core::mem::size_of; - use crate::VerifyError; use crate::bridge::as_flock_f128s; use crate::ligerito::CheckedLigerito; -use crate::ood::{OodClaim, prove, verify}; -use crate::profiles::{ood_grinding_bits, security_config}; +use crate::ood::{self, OodClaim}; +use crate::profiles::security_config; use common::{Root, Shape}; use field::F128; -pub use flock_core::hash::HashKind; -use flock_core::pcs::Commitment as FlockCommitment; +use flock_core::hash::HashKind; use flock_core::pcs::ligerito::LigeritoProfile; use flock_core::pcs::{PcsParams, ProverData as FlockProverData}; -use transcript::{Encoding, ProverState, VerifierState}; +use transcript::{Encoding, ProverState, PublicTranscript, SecurityLevel, VerifierState}; + +// Increment this version when parameter derivation or transcript rules change. +// This includes protocol changes in Flock or the selected hash. +const PROTOCOL_VERSION: &[u8] = b"bitz/pcs/security/v1"; /// Errors from PCS configuration. #[derive(Clone, Debug, PartialEq, Eq)] @@ -44,71 +45,82 @@ pub struct Pcs { ood_grinding_bits: Option, bit_len: usize, packed_len: usize, + security_level: SecurityLevel, } -/// Flock state retained between commitment and openings. -/// OOD-aware commitment creation also retains the initial evaluation for each opening. +/// The commitment and private Flock data retained for proving openings. pub struct ProverData { - commitment: FlockCommitment, + pub(crate) commitment: Commitment, flock_prover_data: FlockProverData, - pub(crate) ood: Option, } -/// Commitment and out-of-domain claim read from the verifier transcript. -/// The claim is authenticated only after [`CommitScheme::verify_lin_with_ood`](crate::CommitScheme::verify_lin_with_ood) succeeds. +/// The PCS commitment: its root, parameters, and optional initial OOD claim. +/// Both prover and verifier use this type after the commitment phase. +/// Verifiers construct it with [`Pcs::receive_commitment`] before subsequent challenges. +/// An opening must authenticate its OOD claim before the verifier accepts the proof. #[derive(Debug)] -pub struct VerifierData { +pub struct Commitment { pub(crate) root: Root, + pub(crate) bit_len: usize, + pub(crate) security_level: SecurityLevel, pub(crate) ood: Option, } -impl VerifierData { +impl Commitment { /// Returns the public commitment root. pub fn root(&self) -> Root { self.root } + + pub(crate) fn matches(&self, pcs: &Pcs) -> bool { + self.bit_len == pcs.bit_len() + && self.security_level == pcs.security_level() + && self.ood.is_some() == pcs.ood_grinding_bits().is_some() + } } impl Pcs { - pub fn new( - shape: &Shape, - security_profile: LigeritoProfile, - merkle_hash: HashKind, - ) -> Result { + /// Derives the selected round budget from the padded witness size. + /// The 100-bit profile uses Johnson decoding; the 128-bit profile uses unique decoding. + pub fn new(shape: &Shape, security_level: SecurityLevel) -> Result { let m = shape.log_bits(); + let (security, ood_grinding_bits) = security_config(m, security_level)?; let bit_len = 1usize .checked_shl(m as u32) .ok_or(ConfigError::Invalid("bit length overflow"))?; // The ladder fixes the L0 interleaving: the commit must use the same // `log_batch_size` as the opening's `initial_k`, or the L0 tree is not // reusable as Ligerito's first oracle. - let security = security_config(m, security_profile, merkle_hash)?; let params = PcsParams { m, - log_inv_rate: security_profile.log_inv_rate(), + log_inv_rate: security.levels[0].log_inv_rate, log_batch_size: security.initial_k, - profile: security_profile, - merkle_hash, + // Flock retains this tag; explicit geometry controls our encoding. + profile: LigeritoProfile::Fast, + merkle_hash: HashKind::Blake3, }; let checked_ligerito = CheckedLigerito::new(¶ms, &security)?; - let ood_grinding_bits = ood_grinding_bits(&security, checked_ligerito.log_n_u32() as usize); let packed_len = 1usize .checked_shl(checked_ligerito.log_n_u32()) .ok_or(ConfigError::Invalid("packed length overflow"))?; - Ok(Self { params, checked_ligerito, ood_grinding_bits, bit_len, packed_len, + security_level, }) } - /// Commits to the packed codeword without sampling an OOD claim. - /// Use [`Self::commit_with_ood`] for protocols requiring initial OOD sampling. + /// Commits and samples any initial OOD claim before subsequent protocol challenges. + /// Continue with this transcript for every opening of the retained data. #[tracing::instrument(name = "Commit witness", skip_all)] - pub fn commit(&self, packed_witness: &[F128]) -> Result<(Root, ProverData), CommitError> { + pub fn commit( + &self, + packed_witness: &[F128], + transcript: &mut ProverState, + ) -> Result<(Root, ProverData), CommitError> { // 1. Input Validation if packed_witness.len() != self.packed_len() { return Err(CommitError::PackedWitnessLengthMismatch); @@ -119,50 +131,51 @@ impl Pcs { flock_core::pcs::commit(as_flock_f128s(packed_witness), &self.params); // 3. Build Public Commitment - let commitment = Root(flock_commitment.root); + let root = Root(flock_commitment.root); + self.bind_commitment(root, transcript); + let ood = ood::prove(self, &root.0, packed_witness, transcript); + let commitment = Commitment { + root, + bit_len: self.bit_len, + security_level: self.security_level, + ood, + }; // 4. Retain Opening Data Ok(( - commitment, + root, ProverData { - commitment: flock_commitment, + commitment, flock_prover_data, - ood: None, }, )) } - /// Commits and retains the initial OOD claim for subsequent batched openings. - /// - /// Call before witness-dependent challenges and continue with the same transcript. - /// [`CommitScheme::prove_lin`](crate::CommitScheme::prove_lin) batches the retained claim into each opening. - /// Profiles using unique decoding omit the OOD round. - /// - /// Returns [`CommitError::PackedWitnessLengthMismatch`] before transcript mutation - /// if `packed_witness` does not have the configured length. - #[tracing::instrument(name = "Commit witness with OOD", skip_all)] - pub fn commit_with_ood( - &self, - packed_witness: &[F128], - transcript: &mut ProverState, - ) -> Result<(Root, ProverData), CommitError> { - let (root, mut data) = self.commit(packed_witness)?; - data.ood = prove(self, &root.0, packed_witness, transcript); - Ok((root, data)) - } - - /// Receives the OOD claim for the public root before subsequent protocol challenges. - /// - /// Mirrors [`Self::commit_with_ood`]. Invalid grinding or a truncated evaluation - /// returns [`VerifyError::MalformedProof`]; authentication of the evaluation is - /// deferred to [`CommitScheme::verify_lin_with_ood`](crate::CommitScheme::verify_lin_with_ood). + /// Receives commitment state before subsequent protocol challenges. + /// Verification of an opening authenticates the retained initial claim. pub fn receive_commitment( &self, root: Root, transcript: &mut VerifierState<'_>, - ) -> Result { - let ood = verify(self, &root.0, transcript)?; - Ok(VerifierData { root, ood }) + ) -> Result { + self.bind_commitment(root, transcript); + let ood = ood::verify(self, &root.0, transcript)?; + Ok(Commitment { + root, + bit_len: self.bit_len, + security_level: self.security_level, + ood, + }) + } + + fn bind_commitment(&self, root: Root, transcript: &mut impl PublicTranscript) { + transcript.public_message(b"bitz/pcs/commit/v1" as &[u8]); + transcript.public_message(&root.0); + transcript.public_message(self); + } + + pub(crate) fn ood_grinding_bits(&self) -> Option { + self.ood_grinding_bits } pub fn bit_len(&self) -> usize { @@ -174,12 +187,18 @@ impl Pcs { self.packed_len } - pub(crate) fn params(&self) -> &PcsParams { - &self.params + /// Returns the selected classical PCS round budget. + pub fn security_level(&self) -> SecurityLevel { + self.security_level } - pub(crate) fn ood_grinding_bits(&self) -> Option { - self.ood_grinding_bits + /// Maps each native PoW call to its checked effective difficulty. + pub(crate) fn pow_schedule(&self) -> Vec<(u32, u32)> { + self.checked_ligerito.pow_schedule().to_vec() + } + + pub(crate) fn params(&self) -> &PcsParams { + &self.params } pub(crate) fn prover_config(&self) -> &flock_core::pcs::ligerito::ProverConfig { @@ -197,27 +216,9 @@ impl Pcs { impl Encoding<[u8]> for Pcs { fn encode(&self) -> impl AsRef<[u8]> { - let profile_tag = match self.params.profile { - LigeritoProfile::Fast => 0, - LigeritoProfile::Slim => 1, - LigeritoProfile::Secure => 2, - }; - let hash_tag = match self.params.merkle_hash { - HashKind::Sha256 => 0, - HashKind::Blake3 => 1, - }; - - let tags = [ - self.params.m as u64, - self.params.log_inv_rate as u64, - self.params.log_batch_size as u64, - profile_tag, - hash_tag, - ]; - let mut encoded = [0u8; 5 * size_of::()]; - for (chunk, tag) in encoded.chunks_exact_mut(size_of::()).zip(tags) { - chunk.copy_from_slice(&tag.to_le_bytes()); - } + let mut encoded = PROTOCOL_VERSION.to_vec(); + encoded.extend_from_slice(&(self.bit_len as u64).to_le_bytes()); + encoded.extend_from_slice(&self.security_level.bits().to_le_bytes()); encoded } } @@ -229,14 +230,15 @@ impl ProverData { /// The commitment this data opens against. pub fn root(&self) -> Root { - Root(self.commitment.root) + self.commitment.root() } pub(crate) fn flock_data(&self) -> &FlockProverData { &self.flock_prover_data } - pub(crate) fn commitment(&self) -> &FlockCommitment { + /// Returns the shared commitment without the private proving data. + pub fn commitment(&self) -> &Commitment { &self.commitment } } @@ -253,19 +255,79 @@ mod tests { Shape::new(7, 15).unwrap() } + #[test] + fn explicit_profiles_bind_the_target_and_witness_size() { + let shape = Shape::new(7, 13).unwrap(); + let low = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); + let high = Pcs::new(&shape, SecurityLevel::Bits128).unwrap(); + // Fixed encoding: version tag, padded bit count (u64 LE), target (u32 LE). + assert_eq!( + low.encode().as_ref(), + b"bitz/pcs/security/v1\x00\x00\x10\x00\x00\x00\x00\x00\x64\x00\x00\x00" + ); + assert_ne!(low.encode().as_ref(), high.encode().as_ref()); + let (_, data) = low + .commit( + &vec![F128::ZERO; low.packed_len()], + &mut transcript::build_prover(b"commit-test", b"instance"), + ) + .unwrap(); + assert_eq!(crate::ligerito::validate_prover_data(&low, &data), Ok(())); + assert_eq!( + crate::ligerito::validate_prover_data(&high, &data), + Err(crate::ProveError::ProverDataMismatch) + ); + let larger_shape = Shape::new(7, 14).unwrap(); + let changed = Pcs::new(&larger_shape, SecurityLevel::Bits100).unwrap(); + assert_ne!(low.encode().as_ref(), changed.encode().as_ref()); + assert!(crate::ligerito::validate_prover_data(&changed, &data).is_err()); + } + + #[test] + fn every_dynamic_size_builds_a_complete_native_pow_schedule() { + for m in 20..=35 { + let shape = Shape::new(7, m - 7).unwrap(); + for level in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + let pcs = Pcs::new(&shape, level).unwrap(); + let (config, _) = security_config(m, level).unwrap(); + let expected: usize = config + .levels + .iter() + .map(|level| 1 + level.fold_grinding_bits.min(level.k_recursive)) + .sum(); + assert_eq!(pcs.pow_schedule().len(), expected); + } + } + } + #[test] fn commitment_is_deterministic_for_packed_boundary_bits() { - let scheme = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let scheme = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let mut packed_witness = vec![F128::ZERO; scheme.packed_len()]; packed_witness[0] = F128::new(1 | (1 << 1) | (1 << 63), 1 | (1 << 63)); packed_witness[1] = F128::new(1, 0); packed_witness.last_mut().unwrap().hi = 1 << 63; - let (commitment, data) = scheme.commit(&packed_witness).unwrap(); - let (second_commitment, _) = scheme.commit(&packed_witness).unwrap(); + let (commitment, data) = scheme + .commit( + &packed_witness, + &mut transcript::build_prover(b"commit-test", b"instance"), + ) + .unwrap(); + let (second_commitment, _) = scheme + .commit( + &packed_witness, + &mut transcript::build_prover(b"commit-test", b"instance"), + ) + .unwrap(); let mut changed_witness = packed_witness.clone(); changed_witness[0].lo |= 1 << 2; - let (changed_commitment, _) = scheme.commit(&changed_witness).unwrap(); + let (changed_commitment, _) = scheme + .commit( + &changed_witness, + &mut transcript::build_prover(b"commit-test", b"instance"), + ) + .unwrap(); assert_eq!(commitment, second_commitment); assert_ne!(commitment, changed_commitment); @@ -274,7 +336,7 @@ mod tests { #[test] fn caller_packing_layout_matches_flock() { - let scheme = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let scheme = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let mut bits = vec![false; scheme.bit_len()]; for index in [0, 63, 64, 127, 128, bits.len() - 1] { bits[index] = true; @@ -290,48 +352,16 @@ mod tests { ); } - #[test] - fn encoding_covers_every_profile_and_hash() { - for (profile, profile_tag) in [ - (LigeritoProfile::Fast, 0), - (LigeritoProfile::Slim, 1), - (LigeritoProfile::Secure, 2), - ] { - for (hash, hash_tag) in [(HashKind::Sha256, 0), (HashKind::Blake3, 1)] { - let pcs = Pcs::new(&shape(), profile, hash).unwrap(); - // `Fast` runs the k = 4 ladder; the other profiles keep flock's - // embedded k = 6 generation. - let initial_k = match profile { - LigeritoProfile::Fast => 4, - LigeritoProfile::Slim | LigeritoProfile::Secure => 6, - }; - let expected_tags = [ - 22, - profile.log_inv_rate() as u64, - initial_k, - profile_tag, - hash_tag, - ]; - let encoded = pcs.encode(); - let encoded = encoded.as_ref(); - assert_eq!(encoded.len(), 5 * size_of::()); - for (chunk, tag) in encoded.chunks_exact(size_of::()).zip(expected_tags) { - assert_eq!(chunk, tag.to_le_bytes()); - } - } - } - } - proptest! { #![proptest_config(ProptestConfig::with_cases(64))] #[test] fn rejects_arbitrary_short_packed_witnesses(len in 0usize..4096) { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; len]; prop_assert!(matches!( - pcs.commit(&packed_witness), + pcs.commit(&packed_witness, &mut transcript::build_prover(b"commit-test", b"instance")), Err(CommitError::PackedWitnessLengthMismatch) )); } diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index 61deb3b0..11f55406 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -28,21 +28,22 @@ //! //! - [`Pcs`] stores trusted Flock parameters and the expected bit length. //! - [`Root`] is the public Merkle root. -//! - [`ProverData`] retains the codeword and Merkle tree after commitment. -//! - [`VerifierData`] retains the root and OOD claim received before opening. +//! - [`Commitment`] holds the root, parameters, and optional initial OOD claim for both sides. +//! - [`ProverData`] retains that commitment, the codeword, and the Merkle tree. //! - [`OpeningQuery`] contains an MLE point and target, or a `common::LinearClaim`. //! - [`CommitScheme`] connects commitment, proving, and verification to project transcripts. //! - [`ConfigError`] reports configuration failures. //! - [`CommitError`], [`ProveError`], and [`VerifyError`] report operation-specific failures. //! +//! Commitment starts the transcript before witness-dependent challenges. +//! The 100-bit profile includes initial and recursive OOD checks. +//! The 128-bit profile uses unique decoding without OOD checks. +//! The verifier calls [`Pcs::receive_commitment`] before subsequent protocol challenges. //! The caller packs and retains the witness after [`CommitScheme::commit`]. //! [`CommitScheme::prove_lin`] dispatches both query variants. //! It consumes the packed witness and borrows [`ProverData`]. //! The caller must use matching transcript session and instance labels. //! The caller must also call `VerifierState::check_eof` after successful verification. -//! Use [`Pcs::commit_with_ood`] and [`Pcs::receive_commitment`] before any -//! witness-dependent challenges to include the initial OOD claim. Proving batches -//! that retained claim automatically; verification uses [`Pcs::verify_lin_with_ood`]. //! //! # Example //! @@ -53,13 +54,13 @@ //! use field::F128; //! use num_traits::ConstZero; //! use pcs::{ -//! CommitScheme, HashKind, LigeritoProfile, OpeningQuery, Pcs, StatementBinding, +//! CommitScheme, SecurityLevel, OpeningQuery, Pcs, StatementBinding, //! }; //! use transcript::{build_prover, build_verifier}; //! //! const M: usize = 22; //! let shape = Shape::new(7, 15).unwrap(); -//! let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); +//! let pcs = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); //! let packed_witness = vec![F128::ZERO; pcs.packed_len()]; //! let point = (0..M) //! .map(|coordinate| F128::from(coordinate as u64 + 2)) @@ -70,7 +71,7 @@ //! }; //! //! let mut prover = build_prover(b"pcs-example", b"zero-polynomial"); -//! let (commitment, prover_data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); +//! let (root, prover_data) = pcs.commit(&packed_witness, &mut prover).unwrap(); //! pcs.prove_lin( //! &prover_data, //! packed_witness, @@ -82,8 +83,8 @@ //! let proof = prover.finish(); //! //! let mut verifier = build_verifier(b"pcs-example", b"zero-polynomial", &proof); -//! let commitment = pcs.receive_commitment(commitment, &mut verifier).unwrap(); -//! pcs.verify_lin_with_ood( +//! let commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); +//! pcs.verify_lin( //! &commitment, //! &query, //! StatementBinding::Bind, @@ -100,11 +101,12 @@ mod ligerito; mod mle; mod ood; mod opening; -mod pow; mod profiles; mod sumcheck; mod transpose; +pub use transcript::SecurityLevel; + #[cfg(test)] #[path = "transpose/tests.rs"] mod transpose_tests; @@ -112,9 +114,8 @@ mod transpose_tests; use field::F128; use transcript::{ProverState, VerifierState}; -pub use commitment::{CommitError, ConfigError, HashKind, Pcs, ProverData, VerifierData}; +pub use commitment::{CommitError, Commitment, ConfigError, Pcs, ProverData}; pub use common::{OpeningQuery, Root}; -pub use flock_core::pcs::ligerito::LigeritoProfile; pub use opening::{ProveError, VerifyError}; /// Controls statement binding for one opening. @@ -139,45 +140,24 @@ pub enum StatementBinding { /// [`OpeningQuery::InnerProduct`] accepts row weights, column weights, and a target over `F128`. /// Quadratic sumcheck reduces this claim to an MLE claim before the opening protocol. pub trait CommitScheme { - /// The public commitment. + /// Commitment state shared by the prover and verifier. type Commitment; /// Private data retained by the prover after commitment. type ProverData; - /// Commitment and OOD claim retained by the verifier before opening. - type VerifierData; /// Commits the caller-owned packed witness to `Enc_C(q_pkd)`, where /// `q_pkd(y) = Σ_{v ∈ {0,1}^7} q(y, v) · basis[v]`. /// Bit `r` of element `i` must equal logical bit `128 * i + r`. + /// Returns the transmitted root and private data containing the complete commitment. fn commit( &self, packed_witness: &[F128], - ) -> Result<(Self::Commitment, Self::ProverData), CommitError>; - - /// Commits and retains the initial OOD claim for subsequent openings. - /// - /// Call before witness-dependent challenges and continue with the same transcript. - /// Profiles without initial OOD sampling omit that round. - fn commit_with_ood( - &self, - packed_witness: &[F128], transcript: &mut ProverState, - ) -> Result<(Self::Commitment, Self::ProverData), CommitError>; - - /// Receives the OOD claim for the public commitment before protocol challenges. - /// - /// Mirrors [`Self::commit_with_ood`]. The returned claim must be authenticated - /// by [`Self::verify_lin_with_ood`] on the same transcript. - fn receive_commitment( - &self, - commitment: Self::Commitment, - transcript: &mut VerifierState<'_>, - ) -> Result; + ) -> Result<(Root, Self::ProverData), CommitError>; /// Consumes the exact packed witness and proves either opening query. /// /// Inner-product claims first pass through quadratic sumcheck and then the MLE opening protocol. - /// An OOD claim retained by [`Self::commit_with_ood`] is batched into the opening. fn prove_lin( &self, data: &Self::ProverData, @@ -187,7 +167,7 @@ pub trait CommitScheme { transcript: &mut ProverState, ) -> Result<(), ProveError>; - /// Verifies either opening query against `commitment`. + /// Verifies either opening query against the received commitment state. /// /// Inner-product claims first pass through quadratic sumcheck and then the MLE opening protocol. fn verify_lin( @@ -197,46 +177,18 @@ pub trait CommitScheme { statement_binding: StatementBinding, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError>; - - /// Verifies the linear query batched with the retained OOD claim. - /// - /// Continue the transcript used by [`Self::receive_commitment`]. Borrowing - /// the retained state permits multiple openings against the same commitment. - fn verify_lin_with_ood( - &self, - commitment: &Self::VerifierData, - query: &OpeningQuery, - statement_binding: StatementBinding, - transcript: &mut VerifierState<'_>, - ) -> Result<(), VerifyError>; } impl CommitScheme for Pcs { - type Commitment = Root; + type Commitment = Commitment; type ProverData = ProverData; - type VerifierData = VerifierData; fn commit( &self, packed_witness: &[F128], - ) -> Result<(Self::Commitment, Self::ProverData), CommitError> { - Pcs::commit(self, packed_witness) - } - - fn commit_with_ood( - &self, - packed_witness: &[F128], transcript: &mut ProverState, - ) -> Result<(Self::Commitment, Self::ProverData), CommitError> { - Pcs::commit_with_ood(self, packed_witness, transcript) - } - - fn receive_commitment( - &self, - commitment: Self::Commitment, - transcript: &mut VerifierState<'_>, - ) -> Result { - Pcs::receive_commitment(self, commitment, transcript) + ) -> Result<(Root, Self::ProverData), CommitError> { + Pcs::commit(self, packed_witness, transcript) } fn prove_lin( @@ -264,16 +216,6 @@ impl CommitScheme for Pcs { statement_binding: StatementBinding, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { - opening::verify(self, commitment, query, statement_binding, None, transcript) - } - - fn verify_lin_with_ood( - &self, - commitment: &Self::VerifierData, - query: &OpeningQuery, - statement_binding: StatementBinding, - transcript: &mut VerifierState<'_>, - ) -> Result<(), VerifyError> { - opening::verify_lin_with_ood(self, commitment, query, statement_binding, transcript) + opening::verify(self, commitment, query, statement_binding, transcript) } } diff --git a/crates/pcs/src/ligerito.rs b/crates/pcs/src/ligerito.rs index e2abc5fd..2e8f5a5b 100644 --- a/crates/pcs/src/ligerito.rs +++ b/crates/pcs/src/ligerito.rs @@ -10,7 +10,7 @@ use flock_core::field::F128 as FlockF128; use flock_core::pcs::LOG_PACKING; use flock_core::pcs::PcsParams; use flock_core::pcs::ligerito::{ - LigeritoProof, LigeritoSecurityConfig, ProverConfig, VerifierConfig, + LigeritoProof, LigeritoSecurityConfig, ProverConfig, SoundnessRegime, VerifierConfig, recursive_prover_with_basis, recursive_verifier_with_basis_succinct, }; use transcript::{ProverState, VerifierState}; @@ -27,6 +27,7 @@ pub(crate) struct CheckedLigerito { verifier_config: VerifierConfig, log_n_u32: u32, final_log_n: usize, + pow_schedule: Vec<(u32, u32)>, } impl CheckedLigerito { @@ -45,12 +46,34 @@ impl CheckedLigerito { .map_err(|_| ConfigError::Invalid("prover config"))?; validate_pcs_verifier_prover(params, &prover_config, &verifier_config)?; let final_log_n = validate_verifier_config(&verifier_config, log_n, params.log_batch_size)?; + let mut pow_schedule = Vec::new(); + for level in &security.levels { + if matches!(level.regime, SoundnessRegime::Udr) + && level.fold_grinding_bits != 0 + && level.fold_grinding_bits < level.k_recursive + { + return Err(ConfigError::Invalid("missing native fold grinding hook")); + } + let bits = level.fold_grinding_bits as u32; + for round in 0..level.k_recursive { + let native = bits.saturating_sub(round as u32); + if native > 0 { + let effective = match level.regime { + SoundnessRegime::Udr => bits, + SoundnessRegime::JohnsonOod => native, + }; + pow_schedule.push((native, effective)); + } + } + pow_schedule.push((level.grinding_bits as u32, level.grinding_bits as u32)); + } Ok(Self { prover_config, verifier_config, log_n_u32, final_log_n, + pow_schedule, }) } @@ -69,6 +92,10 @@ impl CheckedLigerito { pub(crate) fn final_log_n(&self) -> usize { self.final_log_n } + + pub(crate) fn pow_schedule(&self) -> &[(u32, u32)] { + &self.pow_schedule + } } pub(crate) struct ReducedClaim { @@ -116,7 +143,8 @@ impl<'a> ReducedProver<'a> { packed_basis, packed_target, } = claim; - let mut challenger = ProverChallenger::new_ligerito(transcript, packed_target); + let mut challenger = + ProverChallenger::new_ligerito(transcript, packed_target, self.pcs.pow_schedule()); let flock_data = self.data.flock_data(); let ligerito = recursive_prover_with_basis( self.pcs.prover_config(), @@ -183,7 +211,7 @@ pub(crate) fn validate_verifier_config( return Err(ConfigError::Invalid("initial_log_num_interleaved mismatch")); } if config.ood_samples[0] != 0 { - return Err(ConfigError::Invalid("ood_samples[0] is nonzero")); + return Err(ConfigError::Invalid("initial OOD belongs to commitment")); } if config.log_inv_rates.contains(&0) { return Err(ConfigError::Invalid("log_inv_rates contains zero")); @@ -249,14 +277,7 @@ pub(crate) fn validate_verifier_config( } pub(crate) fn validate_prover_data(pcs: &Pcs, data: &ProverData) -> Result<(), ProveError> { - let expected = pcs.params(); - let actual = &data.commitment().params; - if expected.m != actual.m - || expected.log_inv_rate != actual.log_inv_rate - || expected.log_batch_size != actual.log_batch_size - || expected.profile != actual.profile - || expected.merkle_hash != actual.merkle_hash - { + if !data.commitment().matches(pcs) { return Err(ProveError::ProverDataMismatch); } Ok(()) @@ -290,7 +311,8 @@ pub(crate) fn verify_succinct( where F: Fn(&[FlockF128], usize) -> Vec, { - let mut challenger = VerifierChallenger::new_ligerito(transcript, packed_target); + let mut challenger = + VerifierChallenger::new_ligerito(transcript, packed_target, pcs.pow_schedule()); let valid = recursive_verifier_with_basis_succinct( pcs.verifier_config(), proof, @@ -472,7 +494,7 @@ fn proof_options() -> impl Options { #[cfg(test)] mod tests { use super::*; - use crate::{CommitScheme, HashKind, LigeritoProfile, OpeningQuery, StatementBinding}; + use crate::{CommitScheme, OpeningQuery, SecurityLevel, StatementBinding}; use common::Shape; use flock_core::pcs::LOG_PACKING; use num_traits::ConstZero; @@ -524,7 +546,7 @@ mod tests { fn registered_config() -> (VerifierConfig, usize, usize) { let shape = Shape::new(7, 15).unwrap(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); let config = pcs.verifier_config().clone(); ( config, @@ -596,14 +618,14 @@ mod tests { const SESSION: &[u8] = b"pcs-proof-shape-test"; const INSTANCE: &[u8] = b"zero-polynomial"; let shape = Shape::new(7, 15).unwrap(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let (commitment, data) = pcs.commit(&packed_witness).unwrap(); + let mut prover = build_prover(SESSION, INSTANCE); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); let query = OpeningQuery::Mle { point: vec![F128::from(2u64); 22], target: F128::ZERO, }; - let mut prover = build_prover(SESSION, INSTANCE); pcs.prove_lin( &data, packed_witness, @@ -626,13 +648,16 @@ mod tests { ); type ProofMutation = fn(&mut LigeritoProof); - let mutations: [(&str, ProofMutation); 2] = [ + let mutations: [(&str, ProofMutation); 3] = [ ("recursive-root count", |proof| { proof.recursive_roots.pop(); }), ("opened-row width", |proof| { proof.initial_proof.opened_rows[0].pop(); }), + ("unexpected OOD value", |proof| { + proof.ood_values.push(FlockF128::ZERO); + }), ]; for (case, mutate) in mutations { diff --git a/crates/pcs/src/ood.rs b/crates/pcs/src/ood.rs index bb5d718a..c002a326 100644 --- a/crates/pcs/src/ood.rs +++ b/crates/pcs/src/ood.rs @@ -17,7 +17,6 @@ use poly::{DenseMultilinearExtension, eq_table}; use transcript::{ProverState, PublicTranscript, VerifierState}; use crate::bridge::{as_flock_f128, from_flock_f128}; -use crate::pow::{find, valid}; use crate::{Pcs, VerifyError}; const OOD_ROUND_TAG: &[u8] = b"bitz/pcs/ood/v1"; @@ -44,9 +43,7 @@ pub(crate) fn prove( ) -> Option { let grinding_bits = pcs.ood_grinding_bits()?; absorb_header(pcs, root, grinding_bits, transcript); - if grinding_bits != 0 { - prove_pow(transcript, grinding_bits); - } + transcript.grind(OOD_POW_TAG, grinding_bits); let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); let value = DenseMultilinearExtension::evaluate_exact(packed, &point); transcript.prover_message(&value); @@ -64,9 +61,9 @@ pub(crate) fn verify( return Ok(None); }; absorb_header(pcs, root, grinding_bits, transcript); - if grinding_bits != 0 { - verify_pow(transcript, grinding_bits).map_err(|_| VerifyError::MalformedProof)?; - } + transcript + .grind(OOD_POW_TAG, grinding_bits) + .map_err(|_| VerifyError::MalformedProof)?; let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); let value = transcript .prover_message::() @@ -140,29 +137,9 @@ fn ood_point(zeta: F128, packed_len: usize) -> Vec { point } -fn prove_pow(transcript: &mut ProverState, bits: u32) { - transcript.public_message(OOD_POW_TAG); - transcript.public_message(&bits); - let seed = transcript.verifier_message::().to_bytes(); - let nonce = find(&seed, bits); - transcript.prover_message(&nonce.to_le_bytes()); -} - -fn verify_pow(transcript: &mut VerifierState<'_>, bits: u32) -> Result<(), ()> { - transcript.public_message(OOD_POW_TAG); - transcript.public_message(&bits); - let seed = transcript.verifier_message::().to_bytes(); - let nonce = transcript - .prover_message::<[u8; 8]>() - .map(u64::from_le_bytes) - .map_err(|_| ())?; - valid(&seed, nonce, bits).then_some(()).ok_or(()) -} - #[cfg(test)] mod tests { use num_traits::ConstZero; - use transcript::{build_prover, build_verifier}; use super::*; @@ -189,29 +166,4 @@ mod tests { add_succinct_basis(&mut succinct, &claim, coefficient, &queries); assert_eq!(dense, succinct); } - - #[test] - fn grinding_binds_the_following_challenge_and_rejects_invalid_nonces() { - const BITS: u32 = 8; - let mut prover = build_prover(b"ood-test", b"grinding"); - prove_pow(&mut prover, BITS); - let challenge = prover.verifier_message::(); - let mut proof = prover.finish(); - let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); - verify_pow(&mut verifier, BITS).unwrap(); - assert_eq!(verifier.verifier_message::(), challenge); - verifier.check_eof().unwrap(); - - let mut seed_transcript = build_prover(b"ood-test", b"grinding"); - seed_transcript.public_message(OOD_POW_TAG); - seed_transcript.public_message(&BITS); - let seed = seed_transcript.verifier_message::().to_bytes(); - let invalid = (0..).find(|&nonce| !valid(&seed, nonce, BITS)).unwrap(); - proof.narg_string.copy_from_slice(&invalid.to_le_bytes()); - let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); - assert!(verify_pow(&mut verifier, BITS).is_err()); - proof.narg_string.truncate(7); - let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); - assert!(verify_pow(&mut verifier, BITS).is_err()); - } } diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index 87f0236b..4853b40b 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -4,18 +4,19 @@ use common::LinearClaim; use field::F128; use flock_core::field::F128 as FlockF128; use flock_core::pcs::pack::PACKING_WIDTH as CLAIM_COUNT; -use transcript::{ProverState, PublicTranscript, VerifierState}; +use transcript::{ProverState, PublicTranscript, SecurityLevel, VerifierState}; use crate::bridge::{as_flock_f128, as_flock_f128s, from_flock_f128}; use crate::ligerito::{self, ReducedProver}; use crate::ood::{OodClaim, add_dense_basis, add_succinct_basis, batching_challenge}; -use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, VerifierData, mle, sumcheck}; +use crate::{Commitment, OpeningQuery, Pcs, ProverData, Root, StatementBinding, mle, sumcheck}; const MLE_STATEMENT_LABEL: &[u8] = b"bitz/pcs/mle-opening/v1"; -const INNER_PRODUCT_STATEMENT_LABEL: &[u8] = b"bitz/pcs/bit-inner-product/v2"; +const INNER_PRODUCT_STATEMENT_LABEL: &[u8] = b"bitz/pcs/bit-inner-product/v1"; const SUMCHECK_LABEL: &[u8] = b"bitz/pcs/inner-product-sumcheck/v1"; const MLE_CLAIMS_LABEL: &[u8] = b"bitz/pcs/mle-claims/v1"; const CHALLENGES_LABEL: &[u8] = b"bitz/pcs/ring-switch-challenges/v1"; +const RING_GRINDING_LABEL: &[u8] = b"bitz/pcs/ring/v1"; /// Errors from opening proof creation. #[derive(Clone, Debug, PartialEq, Eq)] @@ -83,28 +84,6 @@ impl From for VerifyError { } } -/// Verifies an opening batched with the OOD claim retained at commitment ingestion. -/// -/// Use the state returned by [`Pcs::receive_commitment`] and continue its transcript. -/// The state is borrowed so multiple openings can authenticate the same OOD claim. -/// Profiles without OOD sampling verify the ordinary linear claim. -pub(crate) fn verify_lin_with_ood( - pcs: &Pcs, - commitment: &VerifierData, - query: &OpeningQuery, - statement_binding: StatementBinding, - transcript: &mut VerifierState<'_>, -) -> Result<(), VerifyError> { - verify( - pcs, - &commitment.root, - query, - statement_binding, - commitment.ood.as_ref(), - transcript, - ) -} - #[tracing::instrument(name = "Prove PCS opening", skip_all)] pub(crate) fn prove( pcs: &Pcs, @@ -114,28 +93,40 @@ pub(crate) fn prove( statement_binding: StatementBinding, transcript: &mut ProverState, ) -> Result<(), ProveError> { + let commitment = data.commitment(); + if !commitment.matches(pcs) { + return Err(ProveError::ProverDataMismatch); + } match query { OpeningQuery::Mle { point, target } => { let ring_switch = mle::RingSwitch::new(point, pcs.params().m)?; let prover = ReducedProver::new(pcs, data, packed_witness)?; if statement_binding == StatementBinding::Bind { - bind_mle_statement(pcs, &data.commitment().root, point, *target, transcript); + bind_mle_statement(pcs, &commitment.root().0, point, *target, transcript); } - prove_mle(prover, ring_switch, *target, data.ood.as_ref(), transcript) + prove_mle( + prover, + ring_switch, + *target, + commitment.ood.as_ref(), + pcs.security_level(), + transcript, + ) } OpeningQuery::InnerProduct { claim } => { validate_inner_product_claim(pcs, claim)?; let prover = ReducedProver::new(pcs, data, packed_witness)?; if statement_binding == StatementBinding::Bind { - bind_inner_product_statement(pcs, &data.commitment().root, claim, transcript); + bind_inner_product_statement(pcs, &commitment.root().0, claim, transcript); } transcript.public_message(SUMCHECK_LABEL); - let reduced = sumcheck::prove(claim, prover.witness(), transcript)?; + let reduced = + sumcheck::prove(claim, prover.witness(), pcs.security_level(), transcript)?; let ring_switch = mle::RingSwitch::new(&reduced.point, pcs.params().m)?; // AlreadyBound covers the original claim, before the reduction produces this MLE claim. bind_mle_statement( pcs, - &data.commitment().root, + &commitment.root().0, &reduced.point, reduced.target, transcript, @@ -144,7 +135,8 @@ pub(crate) fn prove( prover, ring_switch, reduced.target, - data.ood.as_ref(), + commitment.ood.as_ref(), + pcs.security_level(), transcript, ) } @@ -154,41 +146,45 @@ pub(crate) fn prove( #[tracing::instrument(name = "Verify PCS opening", skip_all)] pub(crate) fn verify( pcs: &Pcs, - commitment: &Root, + commitment: &Commitment, query: &OpeningQuery, statement_binding: StatementBinding, - ood_claim: Option<&OodClaim>, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { + if !commitment.matches(pcs) { + return Err(VerifyError::VerificationFailed); + } + let root = &commitment.root; match query { OpeningQuery::Mle { point, target } => { let ring_switch = mle::RingSwitch::new(point, pcs.params().m)?; if statement_binding == StatementBinding::Bind { - bind_mle_statement(pcs, &commitment.0, point, *target, transcript); + bind_mle_statement(pcs, &root.0, point, *target, transcript); } - verify_mle(pcs, commitment, ring_switch, *target, ood_claim, transcript) + verify_mle( + pcs, + root, + ring_switch, + *target, + commitment.ood.as_ref(), + transcript, + ) } OpeningQuery::InnerProduct { claim } => { validate_inner_product_claim(pcs, claim)?; if statement_binding == StatementBinding::Bind { - bind_inner_product_statement(pcs, &commitment.0, claim, transcript); + bind_inner_product_statement(pcs, &root.0, claim, transcript); } transcript.public_message(SUMCHECK_LABEL); - let reduced = sumcheck::verify(claim, transcript)?; + let reduced = sumcheck::verify(claim, pcs.security_level(), transcript)?; let ring_switch = mle::RingSwitch::new(&reduced.point, pcs.params().m)?; - bind_mle_statement( - pcs, - &commitment.0, - &reduced.point, - reduced.target, - transcript, - ); + bind_mle_statement(pcs, &root.0, &reduced.point, reduced.target, transcript); verify_mle( pcs, - commitment, + root, ring_switch, reduced.target, - ood_claim, + commitment.ood.as_ref(), transcript, ) } @@ -214,6 +210,7 @@ fn prove_mle( ring_switch: mle::RingSwitch<'_>, target: F128, ood_claim: Option<&OodClaim>, + security: SecurityLevel, transcript: &mut ProverState, ) -> Result<(), ProveError> { let dense_reduction = { @@ -221,6 +218,11 @@ fn prove_mle( let prepared_claims = ring_switch.prepare_claims(as_flock_f128s(prover.witness()), target)?; write_claims(transcript, &prepared_claims.claims); + // The seven coordinates and optional OOD coefficient share one challenge block. + transcript.grind( + RING_GRINDING_LABEL, + security.grinding_bits(7 + usize::from(ood_claim.is_some())), + ); let batching_point = sample_challenges(transcript); let mut reduced = prepared_claims.reduce_dense(&batching_point); if let Some(claim) = ood_claim { @@ -249,6 +251,13 @@ fn verify_mle( if !ring_switch.target_matches(&claims, target) { return Err(VerifyError::VerificationFailed); } + transcript + .grind( + RING_GRINDING_LABEL, + pcs.security_level() + .grinding_bits(7 + usize::from(ood_claim.is_some())), + ) + .map_err(|_| VerifyError::MalformedProof)?; let batching_point = sample_challenges(transcript); ring_switch.reduce_succinct(&claims, &batching_point) }; diff --git a/crates/pcs/src/opening/tests.rs b/crates/pcs/src/opening/tests.rs index 1e0d30c9..de109dce 100644 --- a/crates/pcs/src/opening/tests.rs +++ b/crates/pcs/src/opening/tests.rs @@ -6,7 +6,7 @@ use proptest::prelude::*; use transcript::{build_prover, build_verifier}; use super::*; -use crate::{HashKind, LigeritoProfile}; +use crate::SecurityLevel; const M: usize = 22; const SINGLETON: usize = (1 << 21) | (1 << 7) | 0b101_0101; @@ -15,8 +15,6 @@ const INSTANCE: &[u8] = b"singleton"; struct Fixture { pcs: Pcs, - root: Root, - data: ProverData, witness: Vec, claim: LinearClaim, } @@ -25,10 +23,9 @@ fn fixture() -> &'static Fixture { static FIXTURE: OnceLock = OnceLock::new(); FIXTURE.get_or_init(|| { let shape = Shape::new(7, M - 7).unwrap(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); let mut witness = vec![F128::ZERO; pcs.packed_len()]; witness[SINGLETON / 128].hi = 1 << (SINGLETON % 128 - 64); - let (root, data) = pcs.commit(&witness).unwrap(); let rows = (0..shape.rows()) .map(|row| F128::from(row as u64 + 2)) .collect::>(); @@ -39,8 +36,6 @@ fn fixture() -> &'static Fixture { let claim = LinearClaim::from_shape(&shape, rows, columns, target).unwrap(); Fixture { pcs, - root, - data, witness, claim, } @@ -51,10 +46,11 @@ fn fixture() -> &'static Fixture { fn inner_product_proof_composes_sumcheck_with_a_bound_mle_opening() { let fixture = fixture(); let mut prover = build_prover(SESSION, INSTANCE); - bind_inner_product_statement(&fixture.pcs, &fixture.root.0, &fixture.claim, &mut prover); + let (root, data) = fixture.pcs.commit(&fixture.witness, &mut prover).unwrap(); + bind_inner_product_statement(&fixture.pcs, &root.0, &fixture.claim, &mut prover); prove( &fixture.pcs, - &fixture.data, + &data, fixture.witness.clone(), &OpeningQuery::InnerProduct { claim: fixture.claim.clone(), @@ -67,18 +63,18 @@ fn inner_product_proof_composes_sumcheck_with_a_bound_mle_opening() { // Independent composition checks stage order and binding of the derived MLE claim. let mut verifier = build_verifier(SESSION, INSTANCE, &proof); - bind_inner_product_statement(&fixture.pcs, &fixture.root.0, &fixture.claim, &mut verifier); + let commitment = fixture.pcs.receive_commitment(root, &mut verifier).unwrap(); + bind_inner_product_statement(&fixture.pcs, &root.0, &fixture.claim, &mut verifier); verifier.public_message(SUMCHECK_LABEL); - let reduced = sumcheck::verify(&fixture.claim, &mut verifier).unwrap(); + let reduced = sumcheck::verify(&fixture.claim, SecurityLevel::Bits100, &mut verifier).unwrap(); verify( &fixture.pcs, - &fixture.root, + &commitment, &OpeningQuery::Mle { point: reduced.point, target: reduced.target, }, StatementBinding::Bind, - None, &mut verifier, ) .unwrap(); @@ -127,9 +123,9 @@ proptest! { } #[test] -fn opening_leaves_matching_transcripts_for_following_protocols() { +fn opening_reuses_commitment_state_and_leaves_matching_transcripts() { let fixture = fixture(); - for query in [ + let queries = [ OpeningQuery::Mle { point: vec![F128::ZERO; M], target: F128::ZERO, @@ -137,30 +133,138 @@ fn opening_leaves_matching_transcripts_for_following_protocols() { OpeningQuery::InnerProduct { claim: fixture.claim.clone(), }, - ] { + ]; + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + let pcs = Pcs::new(&Shape::new(7, M - 7).unwrap(), security).unwrap(); let mut prover = build_prover(SESSION, INSTANCE); - prove( - &fixture.pcs, - &fixture.data, - fixture.witness.clone(), - &query, - StatementBinding::Bind, - &mut prover, - ) - .unwrap(); + let (root, data) = pcs.commit(&fixture.witness, &mut prover).unwrap(); + assert_eq!( + data.commitment().ood.is_some(), + security == SecurityLevel::Bits100 + ); + for query in &queries { + prove( + &pcs, + &data, + fixture.witness.clone(), + query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + } let expected = prover.verifier_message::(); let proof = prover.finish(); let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + let commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); + assert_eq!(commitment.ood.is_some(), security == SecurityLevel::Bits100); + for query in &queries { + verify( + &pcs, + &commitment, + query, + StatementBinding::Bind, + &mut verifier, + ) + .unwrap(); + } + assert_eq!(verifier.verifier_message::(), expected); + verifier.check_eof().unwrap(); + } +} + +#[test] +fn initial_ood_rejects_changed_values_and_missing_or_mismatched_state() { + let fixture = fixture(); + let pcs = &fixture.pcs; + let query = OpeningQuery::Mle { + point: vec![F128::ZERO; M], + target: F128::ZERO, + }; + let mut prover = build_prover(SESSION, INSTANCE); + let (root, mut data) = pcs.commit(&fixture.witness, &mut prover).unwrap(); + prove( + pcs, + &data, + fixture.witness.clone(), + &query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + let proof = prover.finish(); + let value_offset = usize::from(pcs.ood_grinding_bits().unwrap() > 0) * 8; + + let mut changed = proof.clone(); + changed.narg_string[value_offset] ^= 1; + let mut verifier = build_verifier(SESSION, INSTANCE, &changed); + let commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); + assert!( verify( - &fixture.pcs, - &fixture.root, + pcs, + &commitment, &query, StatementBinding::Bind, - None, - &mut verifier, + &mut verifier ) - .unwrap(); - assert_eq!(verifier.verifier_message::(), expected); - verifier.check_eof().unwrap(); - } + .is_err() + ); + + let mut missing = proof.clone(); + missing.narg_string.truncate(value_offset + 15); + let mut verifier = build_verifier(SESSION, INSTANCE, &missing); + assert!(pcs.receive_commitment(root, &mut verifier).is_err()); + + let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + let mut commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); + let smaller = Pcs::new(&Shape::new(7, M - 8).unwrap(), SecurityLevel::Bits100).unwrap(); + let smaller_query = OpeningQuery::Mle { + point: vec![F128::ZERO; M - 1], + target: F128::ZERO, + }; + assert_eq!( + verify( + &smaller, + &commitment, + &smaller_query, + StatementBinding::Bind, + &mut verifier, + ), + Err(VerifyError::VerificationFailed), + ); + let other = Pcs::new(&Shape::new(7, M - 7).unwrap(), SecurityLevel::Bits128).unwrap(); + assert_eq!( + verify( + &other, + &commitment, + &query, + StatementBinding::Bind, + &mut verifier + ), + Err(VerifyError::VerificationFailed), + ); + commitment.ood = None; + assert_eq!( + verify( + pcs, + &commitment, + &query, + StatementBinding::Bind, + &mut verifier + ), + Err(VerifyError::VerificationFailed), + ); + data.commitment.ood = None; + let mut prover = build_prover(SESSION, INSTANCE); + assert_eq!( + prove( + pcs, + &data, + fixture.witness.clone(), + &query, + StatementBinding::Bind, + &mut prover + ), + Err(ProveError::ProverDataMismatch), + ); } diff --git a/crates/pcs/src/pow.rs b/crates/pcs/src/pow.rs deleted file mode 100644 index c0f2f890..00000000 --- a/crates/pcs/src/pow.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Nonce search and validation shared by PCS grinding rounds. -//! -//! A positive difficulty requires that many leading zero bits in -//! `BLAKE3(POW_HASH_TAG || seed || nonce.to_le_bytes())`. At zero difficulty, -//! only nonce zero is accepted. Transcript framing belongs to each calling round. - -const POW_HASH_TAG: &[u8] = b"bitz-pcs-pow-v1"; - -/// Returns the first valid nonce in ascending order. -// todo: parallel pow? use potentially spongefish? -pub(crate) fn find(seed: &[u8; 16], bits: u32) -> u64 { - if bits == 0 { - return 0; - } - let mut nonce = 0u64; - loop { - if valid(seed, nonce, bits) { - return nonce; - } - nonce = nonce.checked_add(1).expect("proof-of-work nonce exhausted"); - } -} - -/// Checks the hash difficulty, or the canonical zero nonce at zero difficulty. -pub(crate) fn valid(seed: &[u8; 16], nonce: u64, bits: u32) -> bool { - if bits == 0 { - return nonce == 0; - } - let mut hasher = blake3::Hasher::new(); - hasher.update(POW_HASH_TAG); - hasher.update(seed); - hasher.update(&nonce.to_le_bytes()); - let digest = hasher.finalize(); - leading_zero_bits(digest.as_bytes()) >= bits -} - -fn leading_zero_bits(bytes: &[u8]) -> u32 { - let mut total = 0; - for byte in bytes { - let zeros = byte.leading_zeros(); - total += zeros; - if zeros != 8 { - break; - } - } - total -} diff --git a/crates/pcs/src/sumcheck.rs b/crates/pcs/src/sumcheck.rs index c8f6c6d6..74a408b6 100644 --- a/crates/pcs/src/sumcheck.rs +++ b/crates/pcs/src/sumcheck.rs @@ -16,10 +16,12 @@ use common::LinearClaim; use field::F128; use num_traits::ConstZero; -use transcript::{ProverState, VerifierState}; +use transcript::{ProverState, SecurityLevel, VerifierState}; use crate::{ProveError, VerifyError}; +const GRINDING_LABEL: &[u8] = b"bitz/pcs/sumcheck/v1"; + /// A pending evaluation claim over the original committed bit polynomial. pub(super) struct MleClaim { /// The evaluation point, with row coordinates before column coordinates, in low-bit-first order. @@ -34,6 +36,7 @@ pub(super) struct MleClaim { pub(super) fn prove( claim: &LinearClaim, packed_witness: &[F128], + security: SecurityLevel, transcript: &mut ProverState, ) -> Result { let mut rows = claim.row_weights().to_vec(); @@ -59,6 +62,7 @@ pub(super) fn prove( return Err(ProveError::InvalidClaim); } transcript.prover_message(&coefficients); + transcript.grind(GRINDING_LABEL, security.grinding_bits(2)); let challenge = transcript.verifier_message::(); target = evaluate_round(coefficients, challenge); point.push(challenge); @@ -85,6 +89,7 @@ pub(super) fn prove( #[tracing::instrument(name = "Verify inner-product sumcheck", skip_all)] pub(super) fn verify( claim: &LinearClaim, + security: SecurityLevel, transcript: &mut VerifierState<'_>, ) -> Result { let mut rows = claim.row_weights().to_vec(); @@ -99,6 +104,9 @@ pub(super) fn verify( if coefficients[1] + coefficients[2] != target { return Err(VerifyError::VerificationFailed); } + transcript + .grind(GRINDING_LABEL, security.grinding_bits(2)) + .map_err(|_| VerifyError::MalformedProof)?; let challenge = transcript.verifier_message::(); target = evaluate_round(coefficients, challenge); point.push(challenge); diff --git a/crates/pcs/src/sumcheck/tests.rs b/crates/pcs/src/sumcheck/tests.rs index 198888a1..ace47a7e 100644 --- a/crates/pcs/src/sumcheck/tests.rs +++ b/crates/pcs/src/sumcheck/tests.rs @@ -3,12 +3,12 @@ use std::sync::OnceLock; use common::{LinearClaim, Shape}; use field::F128; use num_traits::ConstZero; -use transcript::{Proof, PublicTranscript, VerifierState, build_prover, build_verifier}; +use transcript::{ + Proof, PublicTranscript, SecurityLevel, VerifierState, build_prover, build_verifier, +}; use super::{prove, verify}; -use crate::{ - CommitScheme, HashKind, LigeritoProfile, OpeningQuery, Pcs, StatementBinding, VerifyError, -}; +use crate::{CommitScheme, OpeningQuery, Pcs, StatementBinding, VerifyError}; const M: usize = 22; const SESSION: &[u8] = b"pcs-sumcheck-format-test"; @@ -55,7 +55,7 @@ struct Fixture { impl Fixture { fn build() -> Self { - // Shape requires at least 2^22 bits, so this is the smallest valid commitment size. + // Use a fixed commitment size for round-message mutation tests. let shape = shape(); let target = SET_BITS .into_iter() @@ -76,7 +76,7 @@ impl Fixture { let witness = sparse_witness(1 << shape.log_packed_len()); let mut prover = build_prover(SESSION, INSTANCE); bind_claim(&mut prover, &claim); - let reduced = prove(&claim, &witness, &mut prover).unwrap(); + let reduced = prove(&claim, &witness, SecurityLevel::Bits100, &mut prover).unwrap(); Self { claim, proof: prover.finish(), @@ -101,7 +101,7 @@ fn fixture() -> &'static Fixture { fn standalone_proof_returns_the_witness_mle_evaluation() { let fixture = fixture(); let mut verifier = fixture.verifier(&fixture.proof); - let reduced = verify(&fixture.claim, &mut verifier).unwrap(); + let reduced = verify(&fixture.claim, SecurityLevel::Bits100, &mut verifier).unwrap(); assert_eq!(reduced.point, fixture.point); assert_eq!(reduced.target, fixture.evaluation); let expected = SET_BITS @@ -138,7 +138,7 @@ fn rejects_truncated_rounds_and_witness_evaluation() { proof.narg_string.truncate(length); let mut verifier = fixture.verifier(&proof); assert_eq!( - verify(&fixture.claim, &mut verifier).err(), + verify(&fixture.claim, SecurityLevel::Bits100, &mut verifier).err(), Some(VerifyError::MalformedProof), ); } @@ -159,18 +159,42 @@ fn rejects_changed_round_coefficients_and_witness_evaluation() { proof.narg_string[offset] ^= 1; let mut verifier = fixture.verifier(&proof); assert_eq!( - verify(&fixture.claim, &mut verifier).err(), + verify(&fixture.claim, SecurityLevel::Bits100, &mut verifier).err(), Some(VerifyError::VerificationFailed), ); } } +#[test] +fn round_grinding_replays_and_rejects_a_missing_nonce_or_wrong_budget() { + let fixture = fixture(); + let witness = sparse_witness(1 << shape().log_packed_len()); + let security = SecurityLevel::Bits128; + let mut prover = build_prover(SESSION, INSTANCE); + bind_claim(&mut prover, &fixture.claim); + let expected = prove(&fixture.claim, &witness, security, &mut prover).unwrap(); + let proof = prover.finish(); + assert_eq!(proof.narg_string.len(), M * (ROUND_BYTES + 8) + 16); + + let mut verifier = fixture.verifier(&proof); + let actual = verify(&fixture.claim, security, &mut verifier).unwrap(); + assert_eq!(actual.point, expected.point); + assert_eq!(actual.target, expected.target); + verifier.check_eof().unwrap(); + + let mut verifier = fixture.verifier(&proof); + assert!(verify(&fixture.claim, SecurityLevel::Bits100, &mut verifier).is_err()); + let mut missing = proof; + missing.narg_string.drain(ROUND_BYTES..ROUND_BYTES + 8); + let mut verifier = fixture.verifier(&missing); + assert!(verify(&fixture.claim, security, &mut verifier).is_err()); +} + #[test] fn zero_weight_factor_still_requires_the_correct_pcs_witness_evaluation() { let shape = shape(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); let witness = sparse_witness(pcs.packed_len()); - let (commitment, data) = pcs.commit(&witness).unwrap(); for zero_rows in [true, false] { let mut row_weights = (0..shape.rows()).map(factor_weight).collect::>(); @@ -187,6 +211,7 @@ fn zero_weight_factor_still_requires_the_correct_pcs_witness_evaluation() { .unwrap(), }; let mut prover = build_prover(SESSION, b"zero-inner-product-factor"); + let (root, data) = pcs.commit(&witness, &mut prover).unwrap(); pcs.prove_lin( &data, witness.clone(), @@ -197,14 +222,19 @@ fn zero_weight_factor_still_requires_the_correct_pcs_witness_evaluation() { .unwrap(); let proof = prover.finish(); let mut verifier = build_verifier(SESSION, b"zero-inner-product-factor", &proof); + let commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); pcs.verify_lin(&commitment, &query, StatementBinding::Bind, &mut verifier) .unwrap(); verifier.check_eof().unwrap(); // A zero factor leaves this value unconstrained until the full PCS checks the MLE opening. let mut changed_proof = proof; - changed_proof.narg_string[EVALUATION_OFFSET] ^= 1; + let initial_bytes = pcs + .ood_grinding_bits() + .map_or(0, |bits| 16 + usize::from(bits > 0) * 8); + changed_proof.narg_string[initial_bytes + EVALUATION_OFFSET] ^= 1; let mut verifier = build_verifier(SESSION, b"zero-inner-product-factor", &changed_proof); + let commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); assert_eq!( pcs.verify_lin(&commitment, &query, StatementBinding::Bind, &mut verifier), Err(VerifyError::VerificationFailed), diff --git a/crates/pcs/tests/round_trip.rs b/crates/pcs/tests/round_trip.rs index 561964eb..f6ba126e 100644 --- a/crates/pcs/tests/round_trip.rs +++ b/crates/pcs/tests/round_trip.rs @@ -4,7 +4,7 @@ use common::{LinearClaim, Shape}; use field::F128; use num_traits::ConstZero; use pcs::{ - CommitScheme, HashKind, LigeritoProfile, OpeningQuery, Pcs, ProveError, Root, StatementBinding, + CommitScheme, Commitment, OpeningQuery, Pcs, ProveError, Root, SecurityLevel, StatementBinding, VerifyError, }; use transcript::{Proof, PublicTranscript, VerifierState, build_prover, build_verifier}; @@ -32,8 +32,8 @@ struct RealFixture { } impl RealFixture { - fn build(profile: LigeritoProfile) -> Self { - let pcs = Pcs::new(&shape(), profile, HashKind::Blake3).unwrap(); + fn build(security: SecurityLevel) -> Self { + let pcs = Pcs::new(&shape(), security).unwrap(); // One nonzero bit gives the expected MLE value a simple independent formula. let mut packed_witness = vec![F128::ZERO; pcs.packed_len()]; let packed_index = SINGLETON / 128; @@ -52,8 +52,9 @@ impl RealFixture { point, }; - let (commitment, data) = pcs.commit(&packed_witness).unwrap(); let mut prover = build_prover(SESSION, INSTANCE); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); + pcs.prove_lin( &data, packed_witness, @@ -72,9 +73,20 @@ impl RealFixture { } } +fn verify_opening( + pcs: &Pcs, + root: &Root, + query: &OpeningQuery, + binding: StatementBinding, + transcript: &mut VerifierState<'_>, +) -> Result<(), VerifyError> { + let data = pcs.receive_commitment(*root, transcript)?; + pcs.verify_lin(&data, query, binding, transcript) +} + fn fixture() -> &'static RealFixture { static FIXTURE: OnceLock = OnceLock::new(); - FIXTURE.get_or_init(|| RealFixture::build(LigeritoProfile::Fast)) + FIXTURE.get_or_init(|| RealFixture::build(SecurityLevel::Bits100)) } fn singleton_target(point: &[F128], index: usize) -> F128 { @@ -93,6 +105,45 @@ fn singleton_target(point: &[F128], index: usize) -> F128 { }) } +#[test] +fn dynamic_shapes_open_nonzero_witnesses_across_ladder_shapes() { + use pcs::SecurityLevel::{Bits100, Bits128}; + // These sizes exercise final folds of one, two, and three variables. + // Size 23 also adds a recursive level. + for m in 20..=23 { + let shape = Shape::new(7, m - 7).unwrap(); + for level in [Bits100, Bits128] { + let pcs = Pcs::new(&shape, level).unwrap(); + let index = (1usize << m) - 1; + let mut witness = vec![F128::ZERO; pcs.packed_len()]; + witness[index / 128].hi = 1 << 63; + let point: Vec<_> = (0..m).map(|i| F128::from(i as u64 + 2)).collect(); + let query = OpeningQuery::Mle { + target: singleton_target(&point, index), + point, + }; + let mut prover = build_prover(SESSION, b"dynamic"); + let (root, data) = pcs.commit(&witness, &mut prover).unwrap(); + + pcs.prove_lin(&data, witness, &query, StatementBinding::Bind, &mut prover) + .unwrap(); + let continuation = prover.verifier_message::(); + let proof = prover.finish(); + let mut verifier = build_verifier(SESSION, b"dynamic", &proof); + verify_opening(&pcs, &root, &query, StatementBinding::Bind, &mut verifier).unwrap(); + assert_eq!(verifier.verifier_message::(), continuation); + verifier.check_eof().unwrap(); + + let mut truncated = proof.clone(); + truncated.narg_string.pop(); + let mut verifier = build_verifier(SESSION, b"dynamic", &truncated); + assert!( + verify_opening(&pcs, &root, &query, StatementBinding::Bind, &mut verifier).is_err() + ); + } + } +} + fn factor_weight(index: usize) -> F128 { let index = index as u64; F128::new( @@ -144,16 +195,18 @@ struct InnerProductFixture { } impl InnerProductFixture { - fn build(profile: LigeritoProfile) -> Self { + fn build(security: SecurityLevel) -> Self { let shape = inner_product_shape(); - let pcs = Pcs::new(&shape, profile, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape, security).unwrap(); let witness = inner_product_witness(pcs.packed_len()); let target = inner_product_target(&shape); assert_ne!(target, F128::ZERO); let query = factored_query(&shape, target); - let (commitment, data) = pcs.commit(&witness).unwrap(); + let mut commitment = Root([0; 32]); let proofs = [StatementBinding::Bind, StatementBinding::AlreadyBound].map(|binding| { let mut prover = build_prover(SESSION, INNER_PRODUCT_INSTANCE); + let (root, data) = pcs.commit(&witness, &mut prover).unwrap(); + commitment = root; if binding == StatementBinding::AlreadyBound { bind_outer_inner_product_statement(&mut prover, &pcs, &commitment, &query); } @@ -179,12 +232,13 @@ impl InnerProductFixture { query: &OpeningQuery, proof: &'proof Proof, binding: StatementBinding, - ) -> VerifierState<'proof> { + ) -> Result<(Commitment, VerifierState<'proof>), VerifyError> { let mut verifier = build_verifier(SESSION, INNER_PRODUCT_INSTANCE, proof); + let data = self.pcs.receive_commitment(*commitment, &mut verifier)?; if binding == StatementBinding::AlreadyBound { bind_outer_inner_product_statement(&mut verifier, &self.pcs, commitment, query); } - verifier + Ok((data, verifier)) } fn verify( @@ -194,22 +248,19 @@ impl InnerProductFixture { proof: &Proof, binding: StatementBinding, ) -> Result<(), VerifyError> { - let mut verifier = self.verifier(commitment, query, proof, binding); - self.pcs - .verify_lin(commitment, query, binding, &mut verifier) + let (data, mut verifier) = self.verifier(commitment, query, proof, binding)?; + self.pcs.verify_lin(&data, query, binding, &mut verifier) } } -fn inner_product_fixture(profile: LigeritoProfile) -> &'static InnerProductFixture { - static FAST: OnceLock = OnceLock::new(); - static SLIM: OnceLock = OnceLock::new(); - static SECURE: OnceLock = OnceLock::new(); - let fixture = match profile { - LigeritoProfile::Fast => &FAST, - LigeritoProfile::Slim => &SLIM, - LigeritoProfile::Secure => &SECURE, +fn inner_product_fixture(security: SecurityLevel) -> &'static InnerProductFixture { + static BITS100: OnceLock = OnceLock::new(); + static BITS128: OnceLock = OnceLock::new(); + let fixture = match security { + SecurityLevel::Bits100 => &BITS100, + SecurityLevel::Bits128 => &BITS128, }; - fixture.get_or_init(|| InnerProductFixture::build(profile)) + fixture.get_or_init(|| InnerProductFixture::build(security)) } fn bind_outer_inner_product_statement( @@ -251,100 +302,33 @@ fn real_pcs_opening_round_trip_succeeds() { let fixture = fixture(); let mut verifier = build_verifier(SESSION, INSTANCE, &fixture.proof); - fixture - .pcs - .verify_lin( - &fixture.commitment, - &fixture.query, - StatementBinding::Bind, - &mut verifier, - ) - .unwrap(); - verifier.check_eof().unwrap(); -} - -#[test] -fn real_pcs_ood_round_batches_into_opening() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - let mut packed_witness = vec![F128::ZERO; pcs.packed_len()]; - packed_witness[SINGLETON / 128] = F128::new(0, 1 << (SINGLETON % 128 - 64)); - let point = vec![F128::from(2u64); M]; - let query = OpeningQuery::Mle { - target: singleton_target(&point, SINGLETON), - point, - }; - ood_round_trip(&pcs, packed_witness, query); -} - -fn ood_round_trip(pcs: &impl CommitScheme, packed_witness: Vec, query: OpeningQuery) { - let mut prover = build_prover(SESSION, b"ood-round-trip"); - let (commitment, data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); - pcs.prove_lin( - &data, - packed_witness, - &query, + verify_opening( + &fixture.pcs, + &fixture.commitment, + &fixture.query, StatementBinding::Bind, - &mut prover, + &mut verifier, ) .unwrap(); - let next_challenge = prover.verifier_message::(); - let proof = prover.finish(); - - let mut verifier = build_verifier(SESSION, b"ood-round-trip", &proof); - let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); - pcs.verify_lin_with_ood(&received, &query, StatementBinding::Bind, &mut verifier) - .unwrap(); - assert_eq!(verifier.verifier_message::(), next_challenge); verifier.check_eof().unwrap(); } #[test] -fn real_pcs_ood_round_rejects_a_changed_evaluation() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let query = OpeningQuery::Mle { - point: vec![F128::from(2u64); M], - target: F128::ZERO, - }; - let mut prover = build_prover(SESSION, b"ood-tampering"); - let (commitment, data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); - pcs.prove_lin( - &data, - packed_witness, - &query, - StatementBinding::Bind, - &mut prover, - ) - .unwrap(); - let mut proof = prover.finish(); - proof.narg_string[0] ^= 1; - - let mut verifier = build_verifier(SESSION, b"ood-tampering", &proof); - let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); - assert!( - pcs.verify_lin_with_ood(&received, &query, StatementBinding::Bind, &mut verifier,) - .is_err() - ); -} - -#[test] -fn factored_inner_product_round_trip_succeeds_for_all_profiles_and_bindings() { - for profile in [ - LigeritoProfile::Fast, - LigeritoProfile::Slim, - LigeritoProfile::Secure, - ] { - let fixture = inner_product_fixture(profile); +fn factored_inner_product_round_trip_succeeds_for_both_security_levels_and_bindings() { + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + let fixture = inner_product_fixture(security); for binding in [StatementBinding::Bind, StatementBinding::AlreadyBound] { - let mut verifier = fixture.verifier( - &fixture.commitment, - &fixture.query, - fixture.proof(binding), - binding, - ); + let (data, mut verifier) = fixture + .verifier( + &fixture.commitment, + &fixture.query, + fixture.proof(binding), + binding, + ) + .unwrap(); fixture .pcs - .verify_lin(&fixture.commitment, &fixture.query, binding, &mut verifier) + .verify_lin(&data, &fixture.query, binding, &mut verifier) .unwrap(); verifier.check_eof().unwrap(); } @@ -354,13 +338,13 @@ fn factored_inner_product_round_trip_succeeds_for_all_profiles_and_bindings() { #[test] fn factored_inner_product_prover_rejects_a_false_target() { let shape = inner_product_shape(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); let witness = inner_product_witness(pcs.packed_len()); - let (commitment, data) = pcs.commit(&witness).unwrap(); let query = factored_query(&shape, inner_product_target(&shape) + F128::from(1u64)); for binding in [StatementBinding::Bind, StatementBinding::AlreadyBound] { let mut prover = build_prover(SESSION, INNER_PRODUCT_INSTANCE); + let (commitment, data) = pcs.commit(&witness, &mut prover).unwrap(); if binding == StatementBinding::AlreadyBound { bind_outer_inner_product_statement(&mut prover, &pcs, &commitment, &query); } @@ -373,7 +357,7 @@ fn factored_inner_product_prover_rejects_a_false_target() { #[test] fn factored_inner_product_rejects_statement_mutations() { - let fixture = inner_product_fixture(LigeritoProfile::Fast); + let fixture = inner_product_fixture(SecurityLevel::Bits100); let OpeningQuery::InnerProduct { claim } = &fixture.query else { unreachable!(); }; @@ -415,7 +399,7 @@ fn factored_inner_product_rejects_statement_mutations() { #[test] fn factored_inner_product_requires_complete_transcript_consumption() { - let fixture = inner_product_fixture(LigeritoProfile::Fast); + let fixture = inner_product_fixture(SecurityLevel::Bits100); for binding in [StatementBinding::Bind, StatementBinding::AlreadyBound] { for append_hint in [false, true] { let mut proof = fixture.proof(binding).clone(); @@ -424,11 +408,12 @@ fn factored_inner_product_requires_complete_transcript_consumption() { } else { proof.narg_string.push(0); } - let mut verifier = - fixture.verifier(&fixture.commitment, &fixture.query, &proof, binding); + let (data, mut verifier) = fixture + .verifier(&fixture.commitment, &fixture.query, &proof, binding) + .unwrap(); fixture .pcs - .verify_lin(&fixture.commitment, &fixture.query, binding, &mut verifier) + .verify_lin(&data, &fixture.query, binding, &mut verifier) .unwrap(); assert!(verifier.check_eof().is_err()); } @@ -437,13 +422,13 @@ fn factored_inner_product_requires_complete_transcript_consumption() { #[test] fn factored_inner_product_rejects_wrong_weight_lengths() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Secure, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape(), SecurityLevel::Bits128).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let (commitment, data) = pcs.commit(&packed_witness).unwrap(); + let mut prover = build_prover(SESSION, b"wrong-inner-product-weight-count"); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); let larger_shape = Shape::new(8, M - 7).unwrap(); let query = factored_query(&larger_shape, F128::ZERO); - let mut prover = build_prover(SESSION, b"wrong-inner-product-weight-count"); assert_eq!( pcs.prove_lin( &data, @@ -458,7 +443,13 @@ fn factored_inner_product_rejects_wrong_weight_lengths() { let proof = Proof::default(); let mut verifier = build_verifier(SESSION, b"wrong-inner-product-weight-count", &proof); assert_eq!( - pcs.verify_lin(&commitment, &query, StatementBinding::Bind, &mut verifier), + verify_opening( + &pcs, + &commitment, + &query, + StatementBinding::Bind, + &mut verifier + ), Err(VerifyError::WeightLengthMismatch), ); } @@ -466,14 +457,15 @@ fn factored_inner_product_rejects_wrong_weight_lengths() { #[test] fn factored_inner_product_rejects_invalid_prover_inputs_before_sumcheck() { let shape = inner_product_shape(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let (_, data) = pcs.commit(&packed_witness).unwrap(); + let mut prover = build_prover(SESSION, b"inner-product-wrong-packed-length"); + let (_, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); let query = factored_query(&shape, F128::ZERO); let mut short_witness = packed_witness.clone(); short_witness.pop(); - let mut prover = build_prover(SESSION, b"inner-product-wrong-packed-length"); + assert_eq!( pcs.prove_lin( &data, @@ -485,7 +477,7 @@ fn factored_inner_product_rejects_invalid_prover_inputs_before_sumcheck() { Err(ProveError::PackedWitnessLengthMismatch), ); - let other = Pcs::new(&shape, LigeritoProfile::Slim, HashKind::Blake3).unwrap(); + let other = Pcs::new(&shape, SecurityLevel::Bits128).unwrap(); let mut prover = build_prover(SESSION, b"inner-product-mismatched-parameters"); assert_eq!( other.prove_lin( @@ -500,33 +492,32 @@ fn factored_inner_product_rejects_invalid_prover_inputs_before_sumcheck() { } #[test] -fn slim_profile_opening_round_trip_exercises_pow() { - let fixture = RealFixture::build(LigeritoProfile::Slim); +fn bits128_opening_round_trip_exercises_pow() { + let fixture = RealFixture::build(SecurityLevel::Bits128); let mut verifier = build_verifier(SESSION, INSTANCE, &fixture.proof); - fixture - .pcs - .verify_lin( - &fixture.commitment, - &fixture.query, - StatementBinding::Bind, - &mut verifier, - ) - .unwrap(); + verify_opening( + &fixture.pcs, + &fixture.commitment, + &fixture.query, + StatementBinding::Bind, + &mut verifier, + ) + .unwrap(); verifier.check_eof().unwrap(); } #[test] fn real_pcs_accepts_an_already_bound_statement() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; let query = OpeningQuery::Mle { point: vec![F128::from(2u64); M], target: F128::from(0u64), }; - let (commitment, data) = pcs.commit(&packed_witness).unwrap(); - let mut prover = build_prover(SESSION, b"already-bound"); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); + bind_outer_statement(&mut prover, &pcs, &commitment, &query); pcs.prove_lin( &data, @@ -539,21 +530,20 @@ fn real_pcs_accepts_an_already_bound_statement() { let proof = prover.finish(); let mut verifier = build_verifier(SESSION, b"already-bound", &proof); + let data = pcs.receive_commitment(commitment, &mut verifier).unwrap(); bind_outer_statement(&mut verifier, &pcs, &commitment, &query); - pcs.verify_lin( - &commitment, - &query, - StatementBinding::AlreadyBound, - &mut verifier, - ) - .unwrap(); + pcs.verify_lin(&data, &query, StatementBinding::AlreadyBound, &mut verifier) + .unwrap(); verifier.check_eof().unwrap(); let mut mismatched_verifier = build_verifier(SESSION, b"already-bound", &proof); + let data = pcs + .receive_commitment(commitment, &mut mismatched_verifier) + .unwrap(); bind_outer_statement(&mut mismatched_verifier, &pcs, &commitment, &query); assert!( pcs.verify_lin( - &commitment, + &data, &query, StatementBinding::Bind, &mut mismatched_verifier, @@ -564,15 +554,15 @@ fn real_pcs_accepts_an_already_bound_statement() { #[test] fn real_pcs_rejects_point_length_mismatches() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let (commitment, data) = pcs.commit(&packed_witness).unwrap(); + let mut prover = build_prover(SESSION, b"wrong-prover-point"); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); let short_query = OpeningQuery::Mle { point: vec![F128::from(2u64); M - 1], target: F128::from(0u64), }; - let mut prover = build_prover(SESSION, b"wrong-prover-point"); assert_eq!( pcs.prove_lin( &data, @@ -588,10 +578,14 @@ fn real_pcs_rejects_point_length_mismatches() { point: vec![F128::from(2u64); M + 1], target: F128::from(0u64), }; - let proof = Proof::default(); + let mut initial = build_prover(SESSION, b"wrong-verifier-point"); + let packed = vec![F128::ZERO; pcs.packed_len()]; + pcs.commit(&packed, &mut initial).unwrap(); + let proof = initial.finish(); let mut verifier = build_verifier(SESSION, b"wrong-verifier-point", &proof); assert_eq!( - pcs.verify_lin( + verify_opening( + &pcs, &commitment, &long_query, StatementBinding::Bind, @@ -603,15 +597,15 @@ fn real_pcs_rejects_point_length_mismatches() { #[test] fn real_pcs_rejects_packed_witness_length_mismatches_during_opening() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let mut packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let (_, data) = pcs.commit(&packed_witness).unwrap(); + let mut prover = build_prover(SESSION, b"wrong-packed-length"); + let (_, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); packed_witness.pop(); let query = OpeningQuery::Mle { point: vec![F128::from(2u64); M], target: F128::from(0u64), }; - let mut prover = build_prover(SESSION, b"wrong-packed-length"); assert_eq!( pcs.prove_lin( @@ -627,15 +621,15 @@ fn real_pcs_rejects_packed_witness_length_mismatches_during_opening() { #[test] fn real_pcs_rejects_mismatched_prover_parameters() { - let source = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let source = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; source.packed_len()]; - let (_, data) = source.commit(&packed_witness).unwrap(); - let other = Pcs::new(&shape(), LigeritoProfile::Slim, HashKind::Blake3).unwrap(); + let mut prover = build_prover(SESSION, b"mismatched-parameters"); + let (_, data) = source.commit(&packed_witness, &mut prover).unwrap(); + let other = Pcs::new(&shape(), SecurityLevel::Bits128).unwrap(); let query = OpeningQuery::Mle { point: vec![F128::from(2u64); M], target: F128::from(0u64), }; - let mut prover = build_prover(SESSION, b"mismatched-parameters"); assert_eq!( other.prove_lin( @@ -651,14 +645,15 @@ fn real_pcs_rejects_mismatched_prover_parameters() { #[test] fn real_pcs_prover_rejects_a_false_evaluation_without_consuming_prover_data() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let (_, data) = pcs.commit(&packed_witness).unwrap(); + let mut prover = build_prover(SESSION, b"false-evaluation"); + let (_, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); let query = OpeningQuery::Mle { point: vec![F128::from(2u64); M], target: F128::from(1u64), }; - let mut prover = build_prover(SESSION, b"false-evaluation"); + let codeword_len = data.codeword_len(); assert_eq!( @@ -676,9 +671,10 @@ fn real_pcs_prover_rejects_a_false_evaluation_without_consuming_prover_data() { #[test] fn real_pcs_rejects_an_opening_for_a_different_packed_witness() { - let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let (commitment, data) = pcs.commit(&packed_witness).unwrap(); + let mut prover = build_prover(SESSION, b"different-packed-witness"); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); let mut different_witness = packed_witness; different_witness[0].lo = 1; let point = (0..M) @@ -688,7 +684,7 @@ fn real_pcs_rejects_an_opening_for_a_different_packed_witness() { target: singleton_target(&point, 0), point, }; - let mut prover = build_prover(SESSION, b"different-packed-witness"); + pcs.prove_lin( &data, different_witness, @@ -701,7 +697,13 @@ fn real_pcs_rejects_an_opening_for_a_different_packed_witness() { let mut verifier = build_verifier(SESSION, b"different-packed-witness", &proof); assert_eq!( - pcs.verify_lin(&commitment, &query, StatementBinding::Bind, &mut verifier,), + verify_opening( + &pcs, + &commitment, + &query, + StatementBinding::Bind, + &mut verifier, + ), Err(VerifyError::VerificationFailed) ); } @@ -717,7 +719,8 @@ fn real_pcs_rejects_statement_mutations() { *target += F128::from(1u64); let mut verifier = build_verifier(SESSION, INSTANCE, &fixture.proof); assert_eq!( - fixture.pcs.verify_lin( + verify_opening( + &fixture.pcs, &fixture.commitment, &changed_query, StatementBinding::Bind, @@ -731,7 +734,8 @@ fn real_pcs_rejects_statement_mutations() { let changed_commitment = Root(changed_root); let mut verifier = build_verifier(SESSION, INSTANCE, &fixture.proof); assert_eq!( - fixture.pcs.verify_lin( + verify_opening( + &fixture.pcs, &changed_commitment, &fixture.query, StatementBinding::Bind, @@ -749,7 +753,8 @@ fn real_pcs_rejects_malformed_transcript_streams() { truncated_stream.narg_string.truncate(1); let mut verifier = build_verifier(SESSION, INSTANCE, &truncated_stream); assert_eq!( - fixture.pcs.verify_lin( + verify_opening( + &fixture.pcs, &fixture.commitment, &fixture.query, StatementBinding::Bind, @@ -762,7 +767,8 @@ fn real_pcs_rejects_malformed_transcript_streams() { truncated_hint.hints.pop(); let mut verifier = build_verifier(SESSION, INSTANCE, &truncated_hint); assert_eq!( - fixture.pcs.verify_lin( + verify_opening( + &fixture.pcs, &fixture.commitment, &fixture.query, StatementBinding::Bind, @@ -775,7 +781,8 @@ fn real_pcs_rejects_malformed_transcript_streams() { *changed_hint.hints.last_mut().unwrap() ^= 1; let mut verifier = build_verifier(SESSION, INSTANCE, &changed_hint); assert_eq!( - fixture.pcs.verify_lin( + verify_opening( + &fixture.pcs, &fixture.commitment, &fixture.query, StatementBinding::Bind, @@ -792,29 +799,27 @@ fn real_pcs_requires_complete_transcript_consumption() { let mut trailing_narg = fixture.proof.clone(); trailing_narg.narg_string.push(0); let mut verifier = build_verifier(SESSION, INSTANCE, &trailing_narg); - fixture - .pcs - .verify_lin( - &fixture.commitment, - &fixture.query, - StatementBinding::Bind, - &mut verifier, - ) - .unwrap(); + verify_opening( + &fixture.pcs, + &fixture.commitment, + &fixture.query, + StatementBinding::Bind, + &mut verifier, + ) + .unwrap(); assert!(verifier.check_eof().is_err()); let mut trailing_hint = fixture.proof.clone(); trailing_hint.hints.push(0); let mut verifier = build_verifier(SESSION, INSTANCE, &trailing_hint); - fixture - .pcs - .verify_lin( - &fixture.commitment, - &fixture.query, - StatementBinding::Bind, - &mut verifier, - ) - .unwrap(); + verify_opening( + &fixture.pcs, + &fixture.commitment, + &fixture.query, + StatementBinding::Bind, + &mut verifier, + ) + .unwrap(); assert!(verifier.check_eof().is_err()); } diff --git a/crates/prover/benches/prover.rs b/crates/prover/benches/prover.rs index 6c70a9f4..1d82bfe0 100644 --- a/crates/prover/benches/prover.rs +++ b/crates/prover/benches/prover.rs @@ -28,11 +28,7 @@ fn random_table(shape: Shape) -> BitTable<'static> { #[divan::bench] fn gkr(bencher: Bencher) { - // `Shape::new(log_rows, log_columns)` rejects fewer than `2^22` committed - // bits total (`MIN_LOG_BITS`) and a row width under 7 (`PACK_BITS`), so - // the two args must sum to at least 22. That floor isn't a security bound - // computed here -- it's the range flock-core ships precomputed Ligerito - // configs for (see the comment on `MIN_LOG_BITS`). + // Use a table within the supported commitment range. let shape = Shape::new(10, 15).unwrap(); let table = random_table(shape); @@ -55,8 +51,13 @@ fn gkr(bencher: Bencher) { .with_inputs(|| transcript::build_prover("gkr", instance)) .bench_values(|mut transcript| { black_box( - prover::gkr_reduce(&mut transcript, black_box(&fold), black_box(&table)) - .expect("benchmark fold matches the table shape"), + prover::gkr_reduce( + &mut transcript, + black_box(&fold), + black_box(&table), + transcript::SecurityLevel::Bits100, + ) + .expect("benchmark fold matches the table shape"), ) }); } diff --git a/crates/prover/examples/profile.rs b/crates/prover/examples/profile.rs index b2e97402..706698be 100644 --- a/crates/prover/examples/profile.rs +++ b/crates/prover/examples/profile.rs @@ -51,7 +51,12 @@ fn main() { #[inline(never)] fn gkr_wrapper(mut transcript: ProverState, fold: &Fold, table: BitTable<'_>) { black_box( - prover::gkr_reduce(&mut transcript, black_box(fold), black_box(&table)) - .expect("profiling fold matches the table shape"), + prover::gkr_reduce( + &mut transcript, + black_box(fold), + black_box(&table), + transcript::SecurityLevel::Bits100, + ) + .expect("profiling fold matches the table shape"), ); } diff --git a/crates/prover/src/fold.rs b/crates/prover/src/fold.rs index 8c272fe5..1da71f50 100644 --- a/crates/prover/src/fold.rs +++ b/crates/prover/src/fold.rs @@ -3,7 +3,7 @@ use common::{BitTable, Fold, FoldError, LinearClaim, column_images, fold_columns, row_images}; use crate::BitZProver; -use transcript::ProverState; +use transcript::{ProverState, SecurityLevel}; /// A fold the prover cannot produce. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,6 +37,7 @@ impl BitZProver { claim: &LinearClaim>, table: &BitTable<'_>, transcript: &mut ProverState, + security: SecurityLevel, ) -> Result { // The weights are sized by the configured row count while the bits are // read at the table's. Disagreement is a panic, a silently wrong fold, or @@ -56,6 +57,10 @@ impl BitZProver { let images = column_images(self.comb(), &folds); let row_images = row_images(self.comb(), &exponents); + transcript.grind( + b"bitz/columns/v1", + security.grinding_bits(shape.log_columns()), + ); let zeta = (0..shape.log_columns()) .map(|_| transcript.verifier_message()) .collect(); diff --git a/crates/prover/src/prove.rs b/crates/prover/src/prove.rs index 055ca7a9..2a5335c6 100644 --- a/crates/prover/src/prove.rs +++ b/crates/prover/src/prove.rs @@ -6,14 +6,14 @@ use common::{ }; use field::{F128, Fq}; use pcs::{CommitScheme, Pcs, ProveError as OpeningProveError, ProverData, StatementBinding}; -use transcript::ProverState; +use transcript::{ProverState, SecurityLevel}; use crate::{BitZProver, SendError, reduce::gkr_reduce}; /// A proof the prover cannot produce. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProveError { - /// The setup or PCS bit count differs from the virtual parameters. + /// The setup or PCS bit count differs from the statement parameters. ParameterMismatch, /// The reduced claim cannot be transposed onto the committed bits. VirtualMap(VirtualMapError), @@ -42,7 +42,7 @@ pub struct VirtualWitness<'a> { impl BitZProver { /// Proves the caller's linear claim about the committed bits. /// - /// Call `Pcs::commit_with_ood` on this transcript, then pass its retained + /// Call `Pcs::commit` on this transcript, then pass its retained /// `data` and the packed witness. The root is read back off /// `data` rather than passed alongside it, so the two cannot disagree. /// `pcs` must be the scheme that committed, or the opening will not verify. @@ -58,6 +58,9 @@ impl BitZProver { packed: Vec, transcript: &mut ProverState, ) -> Result<(), ProveError> { + if pcs.bit_len() != 1 << self.params().shape().log_bits() { + return Err(ProveError::ParameterMismatch); + } let com = data.root(); let table = self.params().table(&packed).map_err(ProveError::Witness)?; @@ -65,14 +68,14 @@ impl BitZProver { // scheme, whose batched opening binds it only in its own statement mode -- // and that fires at step 6, long after the fold has squeezed. // - // The claim itself is not bound: neither `v^(1)`, `v^(2)` nor `mu` reaches - // the sponge here, only the parameters. They enter through the caller's - // own events. + // Bind the PCS policy and original claim before the fold challenges. transcript.public_message(&com.0); transcript.public_message(self.params()); + transcript.public_message(pcs); + transcript.public_message(claim); // Steps 3 and 4: integer column folds, then GKR to a factored bit claim. - let query = self.fold_and_reduce(claim, &table, transcript)?; + let query = self.fold_and_reduce(claim, &table, transcript, pcs.security_level())?; // Step 6: inner-product sumcheck, ring switching, and commitment opening. // Bind the derived query and PCS parameters before the opening challenges. @@ -82,7 +85,7 @@ impl BitZProver { /// Proves a claim on `h = M (1 || f)` against the commitment to `f`. /// - /// Build the setup from `statement.params().claim()`. Use `Pcs::commit_with_ood` + /// Build the setup from `statement.params().claim()`. Use `Pcs::commit` /// on `witness.committed_bits` and this transcript, then pass its `data`. GKR reduces /// the input claim to an inner product on padded virtual bits. This method /// transposes its coefficients before PCS opens the committed bits. @@ -122,10 +125,11 @@ impl BitZProver { transcript.public_message(params); transcript.public_message(&statement.map().digest()); transcript.public_message(claim); + transcript.public_message(pcs); // Steps 3 and 4: fold the virtual columns and reduce them through GKR. // Modulus reduction is absent; the parameters must already be admissible. - let query = self.fold_and_reduce(claim, &table, transcript)?; + let query = self.fold_and_reduce(claim, &table, transcript, pcs.security_level())?; // Transpose the reduced claim from h to f, since the PCS commits to f. let query = statement @@ -151,16 +155,17 @@ impl BitZProver { claim: &LinearClaim>, table: &BitTable<'_>, transcript: &mut ProverState, + security: SecurityLevel, ) -> Result { // Step 2 is absent: Q is fixed, and BitZParams::new checks its fold bound. // Step 3: fold each column into an integer exponent. let fold = self - .send_fold(claim, table, transcript) + .send_fold(claim, table, transcript, security) .map_err(ProveError::Fold)?; // Step 4: GKR reduces the batched column products to a factored bit claim. // Step 5 needs no separate batching: fold.zeta already batches the columns. - gkr_reduce(transcript, &fold, table).map_err(ProveError::Reduction) + gkr_reduce(transcript, &fold, table, security).map_err(ProveError::Reduction) } } diff --git a/crates/prover/src/reduce.rs b/crates/prover/src/reduce.rs index 985c9f22..09828b13 100644 --- a/crates/prover/src/reduce.rs +++ b/crates/prover/src/reduce.rs @@ -10,7 +10,7 @@ use field::F128; use gkr::{GrandProductCircuit, gpgkr_prove}; use num_traits::ConstOne; use poly::eq_table; -use transcript::ProverState; +use transcript::{ProverState, SecurityLevel}; #[inline(never)] #[tracing::instrument(name = "Build grand-product circuit", level = "debug", skip_all)] @@ -56,11 +56,12 @@ pub fn gkr_reduce( transcript: &mut ProverState, fold: &Fold, table: &BitTable, + security: SecurityLevel, ) -> Result { let circuit = init_circuit(table, fold); let (_last_value, witnesses) = circuit.batched_eval(table.shape().columns()); - let (mut point, claim) = gpgkr_prove(transcript, &fold.zeta, witnesses); + let (mut point, claim) = gpgkr_prove(transcript, &fold.zeta, witnesses, security); // The multilinear extension of the constant-one table is one at every point. let inner_product_claim = claim - F128::ONE; @@ -143,7 +144,7 @@ mod order_check_ai_test { let fold = Fold::new(&shape, folds, images, row_images, zeta).unwrap(); let mut prover = transcript::build_prover("order-check", &F128::ZERO); - let query = gkr_reduce(&mut prover, &fold, &table).unwrap(); + let query = gkr_reduce(&mut prover, &fold, &table, SecurityLevel::Bits100).unwrap(); check_query(&query, &table); } @@ -177,7 +178,7 @@ mod order_check_ai_test { .unwrap(); let mut prover = transcript::build_prover("narrow-table", &F128::ZERO); - let query = gkr_reduce(&mut prover, &fold, &table).unwrap(); + let query = gkr_reduce(&mut prover, &fold, &table, SecurityLevel::Bits100).unwrap(); check_query(&query, &table); } } @@ -202,7 +203,7 @@ mod order_check_ai_test { let fold = Fold::new(&shape, folds, images, row_images, zeta).unwrap(); let mut prover = transcript::build_prover("order-check-b", &F128::ZERO); - let query = gkr_reduce(&mut prover, &fold, &table).unwrap(); + let query = gkr_reduce(&mut prover, &fold, &table, SecurityLevel::Bits100).unwrap(); check_query(&query, &table); } diff --git a/crates/tests/examples/dump_bitz.rs b/crates/tests/examples/dump_bitz.rs index 85f6d316..647c5a3d 100644 --- a/crates/tests/examples/dump_bitz.rs +++ b/crates/tests/examples/dump_bitz.rs @@ -31,7 +31,7 @@ fn main() -> Result<(), Box> { let mut transcript = prover_transcript(); let (_, data) = instance .pcs - .commit_with_ood(&instance.packed, &mut transcript) + .commit(&instance.packed, &mut transcript) .unwrap(); instance .prover @@ -47,7 +47,6 @@ fn main() -> Result<(), Box> { println!("prove: {:.1?}", started.elapsed()); let started = std::time::Instant::now(); instance - .verifier .verify( &instance.claim, &instance.pcs, diff --git a/crates/tests/examples/dump_commit.rs b/crates/tests/examples/dump_commit.rs index 4605c01c..e114a93f 100644 --- a/crates/tests/examples/dump_commit.rs +++ b/crates/tests/examples/dump_commit.rs @@ -5,7 +5,7 @@ mod support; use common::Shape; -use pcs::{HashKind, LigeritoProfile, Pcs}; +use pcs::Pcs; use rand_chacha::ChaCha8Rng; use rand_core::SeedableRng; use support::{hex, write_binary, write_witness}; @@ -39,15 +39,15 @@ fn main() -> Result<(), Box> { let shape = Shape::new(t, s).map_err(|error| format!("invalid shape: {error:?}"))?; let mut rng = ChaCha8Rng::seed_from_u64(seed); let packed = packed_witness(shape, &mut rng); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3) + let pcs = Pcs::new(&shape, transcript::SecurityLevel::Bits100) .map_err(|error| format!("PCS configuration failed: {error:?}"))?; let (root, _data) = pcs - .commit(&packed) + .commit(&packed, &mut tests::prover_transcript()) .map_err(|error| format!("commitment failed: {error:?}"))?; write_binary(out, |output| write_witness(output, &packed))?; let root = hex(&root.0); println!( - "their side: t={t} s={s} m={} packed_len={} profile=Fast hash=blake3 root={root}", + "their side: t={t} s={s} m={} packed_len={} security=100 hash=blake3 root={root}", shape.log_bits(), packed.len() ); diff --git a/crates/tests/examples/verify_bitz.rs b/crates/tests/examples/verify_bitz.rs index de6f74f1..2aea93f5 100644 --- a/crates/tests/examples/verify_bitz.rs +++ b/crates/tests/examples/verify_bitz.rs @@ -22,7 +22,7 @@ fn main() -> Result<(), Box> { }; let instance = Instance::honest(shape, seed); let started = std::time::Instant::now(); - let result = instance.verifier.verify( + let result = instance.verify( &instance.claim, &instance.pcs, instance.com, diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 81f51aeb..d397dcd7 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -12,7 +12,7 @@ use common::{BitTable, BitZParams, LinearClaim, Root, Shape}; use crypto_primitives::LiftElement; use field::{F128, Fq, gf128::smallest_generator}; -use pcs::{HashKind, LigeritoProfile, Pcs, ProverData}; +use pcs::{Pcs, ProverData}; use rand_chacha::ChaCha8Rng; use rand_core::{Rng, SeedableRng}; use transcript::{Proof, ProverState, VerifierState, build_prover, build_verifier}; @@ -79,8 +79,8 @@ impl Instance { let claim = LinearClaim::new(¶ms, row_weights, column_weights, target).unwrap(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - let (com, data) = pcs.commit(&packed).unwrap(); + let pcs = Pcs::new(&shape, transcript::SecurityLevel::Bits100).unwrap(); + let (com, data) = pcs.commit(&packed, &mut prover_transcript()).unwrap(); Self { params, @@ -94,6 +94,20 @@ impl Instance { } } + /// Receives the commitment before the BitZ proof draws witness-dependent challenges. + pub fn verify( + &self, + claim: &LinearClaim>, + pcs: &Pcs, + root: Root, + mut transcript: VerifierState<'_>, + ) -> Result<(), verifier::VerifyError> { + let commitment = pcs + .receive_commitment(root, &mut transcript) + .map_err(verifier::VerifyError::Opening)?; + self.verifier.verify(claim, pcs, &commitment, transcript) + } + pub fn table(&self) -> BitTable<'_> { self.params.table(&self.packed).unwrap() } diff --git a/crates/tests/tests/fold.rs b/crates/tests/tests/fold.rs index 2d2e9c15..2e28a1dd 100644 --- a/crates/tests/tests/fold.rs +++ b/crates/tests/tests/fold.rs @@ -7,7 +7,7 @@ use prover::{BitZProver, SendError}; use tests::{ Instance, Q, WINDOW, narrow_shape, prover_transcript, verifier_transcript, wide_shape, }; -use transcript::Proof; +use transcript::{Proof, SecurityLevel}; use verifier::{BitZVerifier, ReceiveError}; /// Runs an honest prover and returns the round it produced with its proof. @@ -15,7 +15,12 @@ fn prove(instance: &Instance) -> (common::Fold, Proof) { let mut transcript = prover_transcript(); let round = instance .prover - .send_fold(&instance.claim, &instance.table(), &mut transcript) + .send_fold( + &instance.claim, + &instance.table(), + &mut transcript, + SecurityLevel::Bits100, + ) .unwrap(); (round, transcript.finish()) } @@ -31,7 +36,7 @@ fn forge(folds: &[u128]) -> Proof { } #[test] -fn the_two_sides_agree_on_every_shape_the_profile_admits() { +fn the_two_sides_agree_on_the_test_shapes() { for shape in [narrow_shape(), wide_shape()] { let instance = Instance::honest(shape, 7); let (sent, proof) = prove(&instance); @@ -39,7 +44,7 @@ fn the_two_sides_agree_on_every_shape_the_profile_admits() { let mut transcript = verifier_transcript(&proof); let received = instance .verifier - .receive_fold(&instance.claim, &mut transcript) + .receive_fold(&instance.claim, &mut transcript, SecurityLevel::Bits100) .expect("honest proof"); assert_eq!(sent, received, "t = {}", shape.log_rows()); @@ -110,17 +115,23 @@ fn a_fold_at_the_bound_is_accepted_and_one_past_it_is_not() { .unwrap(); let mut transcript = prover_transcript(); - let round = prover.send_fold(&claim, &table, &mut transcript).unwrap(); + let round = prover + .send_fold(&claim, &table, &mut transcript, SecurityLevel::Bits100) + .unwrap(); assert!(round.folds.iter().all(|&value| value == fold)); let proof = transcript.finish(); let mut transcript = verifier_transcript(&proof); - assert!(verifier.receive_fold(&claim, &mut transcript).is_ok()); + assert!( + verifier + .receive_fold(&claim, &mut transcript, SecurityLevel::Bits100) + .is_ok() + ); let over = forge(&vec![fold + 1; shape.columns()]); let mut transcript = verifier_transcript(&over); assert_eq!( - verifier.receive_fold(&claim, &mut transcript), + verifier.receive_fold(&claim, &mut transcript, SecurityLevel::Bits100), Err(ReceiveError::FoldOutOfRange) ); } @@ -138,7 +149,7 @@ fn the_range_check_fires_before_the_reconstruction() { assert_eq!( instance .verifier - .receive_fold(&instance.claim, &mut transcript), + .receive_fold(&instance.claim, &mut transcript, SecurityLevel::Bits100), Err(ReceiveError::FoldOutOfRange) ); // The same folds also fail the reconstruction, so the assertion above is @@ -158,7 +169,9 @@ fn folds_that_do_not_reconstruct_the_target_are_rejected() { let retargeted = instance.with_target(instance.claim.target() + Fq::ONE); let mut transcript = verifier_transcript(&proof); assert_eq!( - instance.verifier.receive_fold(&retargeted, &mut transcript), + instance + .verifier + .receive_fold(&retargeted, &mut transcript, SecurityLevel::Bits100), Err(ReceiveError::TargetMismatch) ); } @@ -170,9 +183,12 @@ fn a_witness_of_a_different_shape_is_refused_before_anything_is_written() { let mut transcript = prover_transcript(); assert_eq!( - instance - .prover - .send_fold(&instance.claim, &other.table(), &mut transcript), + instance.prover.send_fold( + &instance.claim, + &other.table(), + &mut transcript, + SecurityLevel::Bits100 + ), Err(SendError::ShapeMismatch) ); assert!(transcript.finish().narg_string.is_empty()); @@ -189,7 +205,7 @@ fn a_truncated_proof_is_refused_rather_than_read_past() { assert_eq!( instance .verifier - .receive_fold(&instance.claim, &mut transcript), + .receive_fold(&instance.claim, &mut transcript, SecurityLevel::Bits100), Err(ReceiveError::MalformedProof) ); } @@ -211,14 +227,18 @@ fn an_all_zero_witness_folds_to_zero_and_still_round_trips() { let table = params.table(&packed).unwrap(); let mut transcript = prover_transcript(); - let round = prover.send_fold(&claim, &table, &mut transcript).unwrap(); + let round = prover + .send_fold(&claim, &table, &mut transcript, SecurityLevel::Bits100) + .unwrap(); assert!(round.folds.iter().all(|&fold| fold == 0)); assert!(round.images.iter().all(|&image| image == F128::ONE)); let proof = transcript.finish(); let mut transcript = verifier_transcript(&proof); assert_eq!( - verifier.receive_fold(&claim, &mut transcript).unwrap(), + verifier + .receive_fold(&claim, &mut transcript, SecurityLevel::Bits100) + .unwrap(), round ); } diff --git a/crates/tests/tests/host.rs b/crates/tests/tests/host.rs index 6dfb080e..2ef90ff5 100644 --- a/crates/tests/tests/host.rs +++ b/crates/tests/tests/host.rs @@ -13,7 +13,7 @@ fn shipped(instance: &Instance) -> Vec { let mut transcript = prover_transcript(); let (_, data) = instance .pcs - .commit_with_ood(&instance.packed, &mut transcript) + .commit(&instance.packed, &mut transcript) .unwrap(); instance .prover @@ -46,7 +46,6 @@ fn a_proof_survives_the_round_trip_through_bytes() { // Verification against the decoded proof, with the claim supplied // the way a caller supplies it on both sides. instance - .verifier .verify( &instance.claim, &instance.pcs, @@ -75,7 +74,6 @@ fn a_tampered_fold_is_left_for_the_verifier_to_catch() { let transcript = verifier_transcript(&proof); assert!( instance - .verifier .verify(&instance.claim, &instance.pcs, instance.com, transcript) .is_err(), "a tampered fold must not verify" diff --git a/crates/tests/tests/prove.rs b/crates/tests/tests/prove.rs index 08311c69..15437416 100644 --- a/crates/tests/tests/prove.rs +++ b/crates/tests/tests/prove.rs @@ -1,21 +1,21 @@ //! The top-level prove and verify, through the real opening. -use common::{Root, TableError}; +use common::{OpeningQuery, Root, Shape, TableError}; use field::{F128, Fq}; use num_traits::{ConstOne, ConstZero}; -use pcs::{HashKind, LigeritoProfile, Pcs, VerifyError as PcsVerifyError}; +use pcs::{CommitScheme, Pcs, StatementBinding, VerifyError as PcsVerifyError}; use prover::ProveError; use tests::{ Instance, large_shape, narrow_shape, prover_transcript, verifier_transcript, wide_shape, }; -use transcript::Proof; +use transcript::{Proof, SecurityLevel}; use verifier::{ReceiveError, VerifyError}; fn prove(instance: &Instance) -> Proof { let mut transcript = prover_transcript(); let (_, data) = instance .pcs - .commit_with_ood(&instance.packed, &mut transcript) + .commit(&instance.packed, &mut transcript) .unwrap(); instance .prover @@ -31,13 +31,12 @@ fn prove(instance: &Instance) -> Proof { } #[test] -fn an_honest_proof_verifies_on_every_shape_the_profile_admits() { +fn an_honest_proof_verifies_on_the_test_shapes() { for shape in [narrow_shape(), wide_shape(), large_shape()] { let instance = Instance::honest(shape, 31); let proof = prove(&instance); instance - .verifier .verify( &instance.claim, &instance.pcs, @@ -55,7 +54,7 @@ fn a_proof_replayed_under_a_different_commitment_is_refused() { // Binding a different root changes the fold batching point, so GKR rejects. assert_eq!( - instance.verifier.verify( + instance.verify( &instance.claim, &instance.pcs, Root([0xffu8; 32]), @@ -75,7 +74,7 @@ fn the_statement_is_bound_before_the_first_challenge() { // binding backs up rather than replaces. let retargeted = instance.with_target(instance.claim.target() + Fq::ONE); assert_eq!( - instance.verifier.verify( + instance.verify( &retargeted, &instance.pcs, instance.com, @@ -92,7 +91,7 @@ fn a_proof_with_trailing_bytes_is_refused() { proof.hints.push(0); assert_eq!( - instance.verifier.verify( + instance.verify( &instance.claim, &instance.pcs, instance.com, @@ -114,7 +113,7 @@ fn an_opening_against_another_commitment_is_refused() { let mut transcript = prover_transcript(); let (_, data) = committed .pcs - .commit_with_ood(&committed.packed, &mut transcript) + .commit(&committed.packed, &mut transcript) .unwrap(); proved .prover @@ -129,7 +128,7 @@ fn an_opening_against_another_commitment_is_refused() { let proof = transcript.finish(); assert_eq!( - proved.verifier.verify( + proved.verify( &proved.claim, &proved.pcs, committed.com, @@ -148,39 +147,36 @@ fn a_tampered_opening_proof_is_refused() { let middle = proof.hints.len() / 2; proof.hints[middle] ^= 0xff; - assert_eq!( - instance.verifier.verify( + assert!(matches!( + instance.verify( &instance.claim, &instance.pcs, instance.com, verifier_transcript(&proof) ), - Err(VerifyError::Opening(PcsVerifyError::VerificationFailed)) - ); + Err(VerifyError::Opening( + PcsVerifyError::MalformedProof | PcsVerifyError::VerificationFailed + )) + )); } #[test] -fn a_proof_verified_under_a_different_profile_is_refused() { - // OOD binds PCS parameters before the first fold challenge, so a different - // profile changes the fold transcript and GKR rejects. +fn a_proof_verified_under_a_different_security_target_is_refused() { + // PCS parameters enter the transcript before the first fold challenge. let instance = Instance::honest(narrow_shape(), 38); - let slim = Pcs::new( - instance.params.shape(), - LigeritoProfile::Slim, - HashKind::Blake3, - ) - .unwrap(); + let other_pcs = Pcs::new(instance.params.shape(), SecurityLevel::Bits128).unwrap(); let proof = prove(&instance); - assert!(matches!( - instance.verifier.verify( - &instance.claim, - &slim, - instance.com, - verifier_transcript(&proof) - ), - Err(VerifyError::Reduction(_)) - )); + assert!( + instance + .verify( + &instance.claim, + &other_pcs, + instance.com, + verifier_transcript(&proof), + ) + .is_err() + ); } #[test] @@ -204,3 +200,113 @@ fn a_witness_of_the_wrong_length_is_refused_before_anything_is_written() { let proof = transcript.finish(); assert!(proof.narg_string.is_empty() && proof.hints.is_empty()); } + +#[test] +fn explicit_security_targets_verify_and_reject_replay_or_tampering() { + let mut instance = Instance::honest(narrow_shape(), 40); + for level in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + instance.pcs = Pcs::new(instance.params.shape(), level).unwrap(); + (instance.com, instance.data) = instance + .pcs + .commit(&instance.packed, &mut prover_transcript()) + .unwrap(); + let proof = prove(&instance); + instance + .verify( + &instance.claim, + &instance.pcs, + instance.com, + verifier_transcript(&proof), + ) + .unwrap(); + + let other_level = match level { + SecurityLevel::Bits100 => SecurityLevel::Bits128, + SecurityLevel::Bits128 => SecurityLevel::Bits100, + }; + let other_pcs = Pcs::new(instance.params.shape(), other_level).unwrap(); + assert!( + instance + .verify( + &instance.claim, + &other_pcs, + instance.com, + verifier_transcript(&proof), + ) + .is_err() + ); + + let wrong_target = instance.with_target(instance.claim.target() + Fq::ONE); + assert_eq!( + instance.verify( + &wrong_target, + &instance.pcs, + instance.com, + verifier_transcript(&proof), + ), + Err(VerifyError::Fold(ReceiveError::TargetMismatch)) + ); + + if level == SecurityLevel::Bits128 { + // The first nonce follows the column folds. + let mut changed = proof.clone(); + changed.narg_string[16 * instance.params.shape().columns()] ^= 0xff; + assert!( + instance + .verify( + &instance.claim, + &instance.pcs, + instance.com, + verifier_transcript(&changed), + ) + .is_err() + ); + } + + // Both policies use the same commitment geometry, but different opening configurations. + // Retained data must match the security target as well as the commitment shape. + let query = OpeningQuery::Mle { + point: vec![F128::ZERO; instance.params.shape().log_bits()], + target: F128::from(instance.packed[0].lo & 1), + }; + let mut transcript = prover_transcript(); + assert_eq!( + other_pcs.prove_lin( + &instance.data, + instance.packed.clone(), + &query, + StatementBinding::Bind, + &mut transcript, + ), + Err(pcs::ProveError::ProverDataMismatch) + ); + assert_eq!(transcript.finish(), Proof::default()); + } +} + +#[test] +fn a_mismatched_direct_commitment_size_is_rejected_before_transcript_mutation() { + let instance = Instance::honest(narrow_shape(), 41); + let pcs = Pcs::new(&Shape::new(7, 16).unwrap(), SecurityLevel::Bits128).unwrap(); + let mut transcript = prover_transcript(); + assert_eq!( + instance.prover.prove( + &instance.claim, + &pcs, + &instance.data, + instance.packed.clone(), + &mut transcript, + ), + Err(ProveError::ParameterMismatch) + ); + assert_eq!(transcript.finish(), Proof::default()); + assert_eq!( + instance.verify( + &instance.claim, + &pcs, + instance.com, + verifier_transcript(&Proof::default()), + ), + Err(VerifyError::ParameterMismatch) + ); +} diff --git a/crates/tests/tests/virtual_prove.rs b/crates/tests/tests/virtual_prove.rs index 7d4a9048..75cc65eb 100644 --- a/crates/tests/tests/virtual_prove.rs +++ b/crates/tests/tests/virtual_prove.rs @@ -11,10 +11,10 @@ use common::{ }; use field::{F128, Fq, gf128::smallest_generator}; use num_traits::{ConstOne, ConstZero}; -use pcs::{HashKind, LigeritoProfile, Pcs, ProverData}; +use pcs::{Pcs, ProverData}; use prover::{BitZProver, ProveError, VirtualWitness}; use tests::{Q, WINDOW, prover_transcript, verifier_transcript}; -use transcript::Proof; +use transcript::{Proof, SecurityLevel}; use verifier::{BitZVerifier, VerifyError}; /// `h[0] = 1`, `h[1] = f[0]`, `h[128] = f[1]`, `h[129] = f[0] XOR f[1]`. @@ -58,8 +58,14 @@ struct Instance { impl Instance { fn new() -> Self { - let claim_shape = Shape::new(7, 15).unwrap(); - let committed_shape = Shape::new(8, 14).unwrap(); + Self::with_shapes( + Shape::new(7, 15).unwrap(), + Shape::new(8, 14).unwrap(), + SecurityLevel::Bits100, + ) + } + + fn with_shapes(claim_shape: Shape, committed_shape: Shape, security: SecurityLevel) -> Self { let params = BitZParams::::new(claim_shape, smallest_generator()).unwrap(); let claim = LinearClaim::new( ¶ms, @@ -74,8 +80,10 @@ impl Instance { virtual_bits[0] = F128::from(3u64); virtual_bits[1] = F128::from(2u64); - let pcs = Pcs::new(&committed_shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - let (root, data) = pcs.commit(&committed_bits).unwrap(); + let pcs = Pcs::new(&committed_shape, security).unwrap(); + let (root, data) = pcs + .commit(&committed_bits, &mut prover_transcript()) + .unwrap(); Self { params, committed_shape, @@ -96,7 +104,7 @@ impl Instance { let mut transcript = prover_transcript(); let (_, data) = self .pcs - .commit_with_ood(&self.committed_bits, &mut transcript) + .commit(&self.committed_bits, &mut transcript) .unwrap(); BitZProver::new(self.params, WINDOW) .prove_virtual( @@ -119,11 +127,16 @@ impl Instance { root: Root, proof: &Proof, ) -> Result<(), VerifyError> { + let mut transcript = verifier_transcript(proof); + let commitment = self + .pcs + .receive_commitment(root, &mut transcript) + .map_err(VerifyError::Opening)?; BitZVerifier::new(self.params, WINDOW).verify_virtual( statement, &self.pcs, - root, - verifier_transcript(proof), + &commitment, + transcript, ) } } @@ -137,6 +150,36 @@ fn virtual_inner_product_opens_the_committed_bits() { .unwrap(); } +#[test] +fn explicit_security_targets_support_different_virtual_and_committed_sizes() { + for level in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + let instance = Instance::with_shapes( + Shape::new(7, 14).unwrap(), + Shape::new(8, 12).unwrap(), + level, + ); + assert_eq!(instance.params.shape().log_bits(), 21); + assert_eq!(instance.committed_shape.log_bits(), 20); + let proof = instance.prove(); + instance + .verify(&instance.statement(), instance.root, &proof) + .unwrap(); + + let changed_map = VirtualStatement::new( + instance.params, + instance.committed_shape, + &Map(8), + &instance.claim, + ) + .unwrap(); + assert!( + instance + .verify(&changed_map, instance.root, &proof) + .is_err() + ); + } +} + #[test] fn changed_virtual_statements_are_rejected() { let instance = Instance::new(); @@ -281,12 +324,23 @@ fn virtual_witness_lengths_and_setup_must_match_the_statement() { Err(ProveError::ParameterMismatch) ); assert_eq!(transcript.finish(), Proof::default()); + let mut prefix = prover_transcript(); + let (root, _) = instance + .pcs + .commit(&instance.committed_bits, &mut prefix) + .unwrap(); + let prefix = prefix.finish(); + let mut verifier = verifier_transcript(&prefix); + let commitment = instance + .pcs + .receive_commitment(root, &mut verifier) + .unwrap(); assert_eq!( BitZVerifier::new(params, WINDOW).verify_virtual( &statement, &instance.pcs, - instance.root, - verifier_transcript(&Proof::default()), + &commitment, + verifier, ), Err(VerifyError::ParameterMismatch) ); @@ -303,7 +357,7 @@ fn virtual_bits_inconsistent_with_the_map_cannot_be_opened() { let mut transcript = prover_transcript(); let (_, data) = instance .pcs - .commit_with_ood(&instance.committed_bits, &mut transcript) + .commit(&instance.committed_bits, &mut transcript) .unwrap(); assert_eq!( BitZProver::new(instance.params, WINDOW).prove_virtual( @@ -370,11 +424,9 @@ fn sha256_virtual_inner_product_opens_the_committed_bits() { .sum(); let claim = LinearClaim::new(¶ms, rows, columns, target).unwrap(); let statement = VirtualStatement::new(params, committed_shape, &map, &claim).unwrap(); - let pcs = Pcs::new(&committed_shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let pcs = Pcs::new(&committed_shape, transcript::SecurityLevel::Bits100).unwrap(); let mut transcript = prover_transcript(); - let (root, data) = pcs - .commit_with_ood(&committed_bits, &mut transcript) - .unwrap(); + let (root, data) = pcs.commit(&committed_bits, &mut transcript).unwrap(); BitZProver::new(params, WINDOW) .prove_virtual( &statement, @@ -387,12 +439,10 @@ fn sha256_virtual_inner_product_opens_the_committed_bits() { &mut transcript, ) .unwrap(); + let proof = transcript.finish(); + let mut transcript = verifier_transcript(&proof); + let commitment = pcs.receive_commitment(root, &mut transcript).unwrap(); BitZVerifier::new(params, WINDOW) - .verify_virtual( - &statement, - &pcs, - root, - verifier_transcript(&transcript.finish()), - ) + .verify_virtual(&statement, &pcs, &commitment, transcript) .unwrap(); } diff --git a/crates/transcript/Cargo.toml b/crates/transcript/Cargo.toml index ab3f2373..a3ad0bed 100644 --- a/crates/transcript/Cargo.toml +++ b/crates/transcript/Cargo.toml @@ -6,5 +6,6 @@ rust-version.workspace = true license.workspace = true [dependencies] +blake3 = { workspace = true } field = { workspace = true, features = ["spongefish"] } spongefish = { workspace = true } diff --git a/crates/transcript/src/lib.rs b/crates/transcript/src/lib.rs index eb9b981d..88ed1b1b 100644 --- a/crates/transcript/src/lib.rs +++ b/crates/transcript/src/lib.rs @@ -29,12 +29,14 @@ mod bytes; mod challenge; mod domain; +pub mod pow; mod proof; mod prover; mod verifier; pub use challenge::TranscriptChallenge; pub use domain::{PROTOCOL_LABEL, build_prover, build_verifier}; +pub use pow::SecurityLevel; pub use proof::Proof; pub use prover::ProverState; pub use spongefish::{ diff --git a/crates/transcript/src/pow.rs b/crates/transcript/src/pow.rs new file mode 100644 index 00000000..5e935c63 --- /dev/null +++ b/crates/transcript/src/pow.rs @@ -0,0 +1,182 @@ +//! Shared nonce search and explicit transcript grinding boundaries. + +use crate::PublicTranscript; + +const POW_HASH_TAG: &[u8] = b"bitz-pcs-pow-v1"; +const POW_TRANSCRIPT_TAG: &[u8] = b"bitz-transcript-pow-v1"; + +/// The largest supported grinding difficulty. +pub const MAX_GRINDING_BITS: u32 = 32; + +/// The target for each classical PCS challenge block over `F128`. +/// +/// This target does not certify the complete protocol or quantum security. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SecurityLevel { + Bits100, + Bits128, +} + +impl SecurityLevel { + /// Returns the classical security target in bits. + pub const fn bits(self) -> u32 { + match self { + Self::Bits100 => 100, + Self::Bits128 => 128, + } + } + + /// Returns `ceil(log2(coefficient)) + target - 128`, bounded below by zero. + /// + /// This covers a challenge block with error at most `coefficient / 2^128`. + /// A zero coefficient needs no grinding. + pub const fn grinding_bits(self, coefficient: usize) -> u32 { + if coefficient == 0 { + return 0; + } + let log_coefficient = usize::BITS - (coefficient - 1).leading_zeros(); + (self.bits() + log_coefficient).saturating_sub(128) + } +} + +pub(crate) fn absorb_header(transcript: &mut impl PublicTranscript, label: &[u8], bits: u32) { + transcript.public_message(POW_TRANSCRIPT_TAG); + transcript.public_message(&(label.len() as u64)); + transcript.public_message(label); + transcript.public_message(&bits); +} + +/// Returns the first valid nonce in ascending order. +/// +/// The caller supplies transcript framing. Difficulties above 32 panic. +pub fn find(seed: &[u8; 16], bits: u32) -> u64 { + assert!( + bits <= MAX_GRINDING_BITS, + "grinding difficulty exceeds 32 bits" + ); + let mut nonce = 0u64; + loop { + if valid(seed, nonce, bits) { + return nonce; + } + nonce = nonce.checked_add(1).expect("proof-of-work nonce exhausted"); + } +} + +/// Checks the hash difficulty. Zero difficulty accepts only nonce zero. +/// +/// Difficulties above 32 return false. +pub fn valid(seed: &[u8; 16], nonce: u64, bits: u32) -> bool { + if bits > MAX_GRINDING_BITS { + return false; + } + if bits == 0 { + return nonce == 0; + } + let mut hasher = blake3::Hasher::new(); + hasher.update(POW_HASH_TAG); + hasher.update(seed); + hasher.update(&nonce.to_le_bytes()); + let digest = hasher.finalize(); + let mut zeros = 0; + for byte in digest.as_bytes() { + let current = byte.leading_zeros(); + zeros += current; + if current != 8 { + break; + } + } + zeros >= bits +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{build_prover, build_verifier}; + use field::F128; + + const SESSION: &[u8] = b"grinding-test"; + const INSTANCE: &[u8] = b"instance"; + const LABEL: &[u8] = b"test/cubic/v1"; + + #[test] + fn grinding_covers_the_integer_error_coefficient() { + for (coefficient, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (7, 3), (8, 3), (15, 4)] { + assert_eq!(SecurityLevel::Bits128.grinding_bits(coefficient), expected); + assert_eq!(SecurityLevel::Bits100.grinding_bits(coefficient), 0); + } + assert_eq!(SecurityLevel::Bits100.grinding_bits(1 << 28), 0); + assert_eq!(SecurityLevel::Bits100.grinding_bits((1 << 28) + 1), 1); + } + + #[test] + fn zero_grinding_leaves_the_transcript_unchanged() { + let mut unmodified = build_prover(SESSION, INSTANCE); + let expected = unmodified.verifier_message::(); + let unmodified_proof = unmodified.finish(); + + let mut prover = build_prover(SESSION, INSTANCE); + prover.grind(LABEL, 0); + assert_eq!(prover.verifier_message::(), expected); + let proof = prover.finish(); + assert_eq!(proof.narg_string, unmodified_proof.narg_string); + + let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + verifier.grind(LABEL, 0).unwrap(); + assert_eq!(verifier.verifier_message::(), expected); + verifier.check_eof().unwrap(); + assert_eq!(find(&[0; 16], 0), 0); + assert!(valid(&[0; 16], 0, 0)); + assert!(!valid(&[0; 16], 1, 0)); + } + + #[test] + fn grinding_replays_and_rejects_an_invalid_nonce() { + const BITS: u32 = 8; + let mut prover = build_prover(SESSION, INSTANCE); + prover.grind(LABEL, BITS); + let expected = prover.verifier_message::(); + let mut proof = prover.finish(); + assert_eq!(proof.narg_string.len(), 8); + + let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + verifier.grind(LABEL, BITS).unwrap(); + assert_eq!(verifier.verifier_message::(), expected); + verifier.check_eof().unwrap(); + + let mut seed_transcript = build_prover(SESSION, INSTANCE); + absorb_header(&mut seed_transcript, LABEL, BITS); + let seed = seed_transcript.verifier_message::().to_bytes(); + let invalid = (0..).find(|&nonce| !valid(&seed, nonce, BITS)).unwrap(); + proof.narg_string.copy_from_slice(&invalid.to_le_bytes()); + let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + assert!(verifier.grind(LABEL, BITS).is_err()); + } + + #[test] + fn grinding_binds_label_difficulty_and_history() { + fn seed(label: &[u8], bits: u32, message: &[u8]) -> F128 { + let mut prover = build_prover(SESSION, INSTANCE); + prover.public_message(message); + absorb_header(&mut prover, label, bits); + prover.verifier_message() + } + let expected = seed(LABEL, 8, b"first"); + assert_ne!(seed(b"test/affine/v1", 8, b"first"), expected); + assert_ne!(seed(LABEL, 9, b"first"), expected); + assert_ne!(seed(LABEL, 8, b"second"), expected); + } + + #[test] + fn verifier_rejects_excessive_difficulty_and_truncated_nonces() { + let mut prover = build_prover(SESSION, INSTANCE); + prover.grind(LABEL, 2); + let mut proof = prover.finish(); + let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + assert!(verifier.grind(LABEL, MAX_GRINDING_BITS + 1).is_err()); + assert!(!valid(&[0; 16], 0, MAX_GRINDING_BITS + 1)); + proof.narg_string.pop(); + let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + assert!(verifier.grind(LABEL, 2).is_err()); + } +} diff --git a/crates/transcript/src/prover.rs b/crates/transcript/src/prover.rs index 9b1035fe..70a38c70 100644 --- a/crates/transcript/src/prover.rs +++ b/crates/transcript/src/prover.rs @@ -23,6 +23,23 @@ impl PublicTranscript for ProverState { } impl ProverState { + /// Adds a labeled grinding boundary before the next challenge block. + /// + /// Zero difficulty leaves the transcript unchanged. Difficulties above 32 panic. + pub fn grind(&mut self, label: &[u8], bits: u32) { + assert!( + bits <= crate::pow::MAX_GRINDING_BITS, + "grinding difficulty exceeds 32 bits" + ); + if bits == 0 { + return; + } + crate::pow::absorb_header(self, label, bits); + let seed = self.verifier_message::().to_bytes(); + let nonce = crate::pow::find(&seed, bits); + self.prover_message(&nonce.to_le_bytes()); + } + /// Absorbs a message both parties already know; nothing is written. pub fn public_message + ?Sized>(&mut self, message: &T) { self.inner.public_message(message); diff --git a/crates/transcript/src/verifier.rs b/crates/transcript/src/verifier.rs index 0ba3e23f..be160551 100644 --- a/crates/transcript/src/verifier.rs +++ b/crates/transcript/src/verifier.rs @@ -23,6 +23,24 @@ impl PublicTranscript for VerifierState<'_> { } impl VerifierState<'_> { + /// Checks a labeled grinding boundary before the next challenge block. + /// + /// Zero difficulty leaves the transcript unchanged. Difficulties above 32 fail. + pub fn grind(&mut self, label: &[u8], bits: u32) -> VerificationResult<()> { + if bits > crate::pow::MAX_GRINDING_BITS { + return Err(VerificationError); + } + if bits == 0 { + return Ok(()); + } + crate::pow::absorb_header(self, label, bits); + let seed = self.verifier_message::().to_bytes(); + let nonce = u64::from_le_bytes(self.prover_message::<[u8; 8]>()?); + crate::pow::valid(&seed, nonce, bits) + .then_some(()) + .ok_or(VerificationError) + } + /// Absorbs a message both parties already know; nothing is read. pub fn public_message + ?Sized>(&mut self, message: &T) { self.inner.public_message(message); diff --git a/crates/transcript/tests/hints.rs b/crates/transcript/tests/hints.rs index 081ff1cd..af5db4fc 100644 --- a/crates/transcript/tests/hints.rs +++ b/crates/transcript/tests/hints.rs @@ -111,13 +111,13 @@ fn bounded_prover_message_bytes_preserve_the_transcript() { let challenge = prover.verifier_message::(); let proof = prover.finish(); - let mut legacy = build_prover(SESSION, INSTANCE); - legacy.prover_message(&(bytes.len() as u32)); + let mut reference = build_prover(SESSION, INSTANCE); + reference.prover_message(&(bytes.len() as u32)); for &byte in bytes { - legacy.prover_message(&[byte]); + reference.prover_message(&[byte]); } - assert_eq!(legacy.verifier_message::(), challenge); - assert_eq!(legacy.finish().narg_string, proof.narg_string); + assert_eq!(reference.verifier_message::(), challenge); + assert_eq!(reference.finish().narg_string, proof.narg_string); let mut verifier = build_verifier(SESSION, INSTANCE, &proof); assert_eq!(verifier.prover_message_bytes::<11>().unwrap(), bytes); diff --git a/crates/verifier/src/fold.rs b/crates/verifier/src/fold.rs index 4c23e0b9..19b6563a 100644 --- a/crates/verifier/src/fold.rs +++ b/crates/verifier/src/fold.rs @@ -4,7 +4,7 @@ use common::{Fold, FoldError, LinearClaim, column_images, reconstruct, row_images}; use crate::BitZVerifier; -use transcript::VerifierState; +use transcript::{SecurityLevel, VerifierState}; /// A fold the verifier rejects. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,6 +38,7 @@ impl BitZVerifier { &self, claim: &LinearClaim>, transcript: &mut VerifierState<'_>, + security: SecurityLevel, ) -> Result { let shape = self.params().shape(); @@ -59,6 +60,12 @@ impl BitZVerifier { let images = column_images(self.comb(), &folds); let row_images = row_images(self.comb(), &claim.row_exponents()); + transcript + .grind( + b"bitz/columns/v1", + security.grinding_bits(shape.log_columns()), + ) + .map_err(|_| ReceiveError::MalformedProof)?; let zeta = (0..shape.log_columns()) .map(|_| transcript.verifier_message()) .collect(); diff --git a/crates/verifier/src/reduce.rs b/crates/verifier/src/reduce.rs index d0e75776..c5fdfaee 100644 --- a/crates/verifier/src/reduce.rs +++ b/crates/verifier/src/reduce.rs @@ -10,7 +10,7 @@ use common::{ClaimError, Fold, LinearClaim, OpeningQuery, Shape}; use field::F128; use num_traits::ConstOne; -use transcript::VerifierState; +use transcript::{SecurityLevel, VerifierState}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReduceError { @@ -25,12 +25,13 @@ pub(crate) fn gkr_reduce( transcript: &mut VerifierState, fold: &Fold, shape: &Shape, + security: SecurityLevel, ) -> Result { // Each layer halves the row count, leaving one product per column. let r1 = fold.row_images.len().max(1).ilog2(); let (point, mle_leaf_claim) = - gkr::gpgkr_verify(transcript, fold.e0, &fold.zeta, r1).ok_or(ReduceError::GKR)?; + gkr::gpgkr_verify(transcript, fold.e0, &fold.zeta, r1, security).ok_or(ReduceError::GKR)?; let r2 = point.len() - r1 as usize; // Columns occupy the low index bits of the GKR leaf table. @@ -119,7 +120,7 @@ mod round_trip_ai_test { let fold = Fold::new(&shape, folds, top_layer, row_images.clone(), zeta.clone()).unwrap(); let mut prover = transcript::build_prover("verifier-round-trip", &F128::ZERO); - let (mut point, claim) = gpgkr_prove(&mut prover, &zeta, witnesses); + let (mut point, claim) = gpgkr_prove(&mut prover, &zeta, witnesses, SecurityLevel::Bits100); let proof = prover.finish(); // Derive the expected factors from the prover's terminal point. @@ -134,7 +135,7 @@ mod round_trip_ai_test { let expected_inner_product_claim = claim - F128::ONE; let mut verifier = transcript::build_verifier("verifier-round-trip", &F128::ZERO, &proof); - let query = gkr_reduce(&mut verifier, &fold, &shape).unwrap(); + let query = gkr_reduce(&mut verifier, &fold, &shape, SecurityLevel::Bits100).unwrap(); verifier.check_eof().unwrap(); let expected = LinearClaim::from_shape( &shape, diff --git a/crates/verifier/src/verify.rs b/crates/verifier/src/verify.rs index e90da561..a53b7166 100644 --- a/crates/verifier/src/verify.rs +++ b/crates/verifier/src/verify.rs @@ -1,16 +1,16 @@ //! `VerifyBitZ`. -use common::{LinearClaim, OpeningQuery, Root, VirtualMap, VirtualMapError, VirtualStatement}; +use common::{LinearClaim, OpeningQuery, VirtualMap, VirtualMapError, VirtualStatement}; use field::Fq; -use pcs::{CommitScheme, Pcs, StatementBinding, VerifierData, VerifyError as OpeningVerifyError}; -use transcript::VerifierState; +use pcs::{CommitScheme, Commitment, Pcs, StatementBinding, VerifyError as OpeningVerifyError}; +use transcript::{SecurityLevel, VerifierState}; use crate::{BitZVerifier, ReceiveError, ReduceError, reduce::gkr_reduce}; /// A proof the verifier rejects. #[derive(Debug, Clone, PartialEq, Eq)] pub enum VerifyError { - /// The setup or PCS bit count differs from the virtual parameters. + /// The setup or PCS bit count differs from the statement parameters. ParameterMismatch, /// The reduced claim cannot be transposed onto the committed bits. VirtualMap(VirtualMapError), @@ -25,39 +25,6 @@ pub enum VerifyError { } impl BitZVerifier { - /// Receives the commitment's OOD claim and verifies the virtual BitZ proof. - pub fn verify_virtual( - &self, - statement: &VirtualStatement<'_, Q, impl VirtualMap>, - pcs: &Pcs, - root: Root, - mut transcript: VerifierState<'_>, - ) -> Result<(), VerifyError> { - if self.params() != statement.params().claim() - || pcs.bit_len() != 1 << statement.params().committed_shape().log_bits() - { - return Err(VerifyError::ParameterMismatch); - } - let commitment = pcs - .receive_commitment(root, &mut transcript) - .map_err(VerifyError::Opening)?; - self.verify_virtual_with_commitment(statement, pcs, &commitment, transcript) - } - - /// Receives the commitment's OOD claim and verifies the BitZ proof. - pub fn verify( - &self, - claim: &LinearClaim>, - pcs: &Pcs, - root: Root, - mut transcript: VerifierState<'_>, - ) -> Result<(), VerifyError> { - let commitment = pcs - .receive_commitment(root, &mut transcript) - .map_err(VerifyError::Opening)?; - self.verify_with_commitment(claim, pcs, &commitment, transcript) - } - /// Verifies a claim on `h = M (1 || f)` against the commitment to `f`. /// /// Build the setup from `statement.params().claim()` and match the commitment's @@ -65,16 +32,15 @@ impl BitZVerifier { /// padded virtual bits. Supply the public circuit's map; its digest must cover /// its shape and entries. /// - /// Continue the transcript that produced `commitment` through - /// [`Pcs::receive_commitment`], using the prover's public-input events. + /// Call `Pcs::receive_commitment` before witness-dependent challenges and continue its transcript. /// This method binds the inputs in [`VirtualStatement`], transposes the reduced /// claim, verifies the PCS opening, and rejects trailing proof or hint bytes. #[tracing::instrument(name = "Verify virtual BitZ", skip_all)] - pub fn verify_virtual_with_commitment( + pub fn verify_virtual( &self, statement: &VirtualStatement<'_, Q, impl VirtualMap>, pcs: &Pcs, - commitment: &VerifierData, + commitment: &Commitment, mut transcript: VerifierState<'_>, ) -> Result<(), VerifyError> { let params = statement.params(); @@ -89,11 +55,12 @@ impl BitZVerifier { transcript.public_message(params); transcript.public_message(&statement.map().digest()); transcript.public_message(claim); - let query = self.fold_and_reduce(claim, &mut transcript)?; + transcript.public_message(pcs); + let query = self.fold_and_reduce(claim, &mut transcript, pcs.security_level())?; let query = statement .transpose_query(query) .map_err(VerifyError::VirtualMap)?; - pcs.verify_lin_with_ood(commitment, &query, StatementBinding::Bind, &mut transcript) + pcs.verify_lin(commitment, &query, StatementBinding::Bind, &mut transcript) .map_err(VerifyError::Opening)?; transcript .check_eof() @@ -102,29 +69,34 @@ impl BitZVerifier { /// Replays the proof of the caller's linear claim about the committed bits. /// - /// `pcs` must be the scheme the commitment was made under. Continue the - /// transcript used by [`Pcs::receive_commitment`]; this consumes it and checks EOF. + /// Call `Pcs::receive_commitment` before witness-dependent challenges and continue its transcript. + /// `pcs` must match the commitment parameters. This method consumes the transcript and checks EOF. #[tracing::instrument(name = "Verify BitZ", skip_all)] - pub fn verify_with_commitment( + pub fn verify( &self, claim: &LinearClaim>, pcs: &Pcs, - commitment: &VerifierData, + commitment: &Commitment, mut transcript: VerifierState<'_>, ) -> Result<(), VerifyError> { + if pcs.bit_len() != 1 << self.params().shape().log_bits() { + return Err(VerifyError::ParameterMismatch); + } // Step 1: the admissibility and precondition checks have already run -- // the shape gates in Shape::new, the modulus in Fq's own const assertions, // the generator's order in BitZParams::new and the weight counts in // LinearClaim::new. What is left is binding, before any challenge. transcript.public_message(&commitment.root().0); transcript.public_message(self.params()); + transcript.public_message(pcs); + transcript.public_message(claim); // Steps 3 and 4: check integer folds and replay GKR to obtain a bit claim. - let query = self.fold_and_reduce(claim, &mut transcript)?; + let query = self.fold_and_reduce(claim, &mut transcript, pcs.security_level())?; // Step 6: verify the inner-product sumcheck, ring switch, and opening. // Acceptance requires authenticating GKR's terminal claim against the commitment. - pcs.verify_lin_with_ood(commitment, &query, StatementBinding::Bind, &mut transcript) + pcs.verify_lin(commitment, &query, StatementBinding::Bind, &mut transcript) .map_err(VerifyError::Opening)?; // Both streams must be spent. Taking the transcript by value is what @@ -142,16 +114,18 @@ impl BitZVerifier { &self, claim: &LinearClaim>, transcript: &mut VerifierState<'_>, + security: SecurityLevel, ) -> Result { // Step 2 is absent: Q is fixed, and BitZParams::new checks its fold bound. // Step 3: read the folds, range-check them, reconstruct against mu. let fold = self - .receive_fold(claim, transcript) + .receive_fold(claim, transcript, security) .map_err(VerifyError::Fold)?; // Step 4: replay GKR from fold.e0 at fold.zeta to obtain the bit claim. // Step 5 needs no separate batching: fold.zeta already batches the columns. - gkr_reduce(transcript, &fold, self.params().shape()).map_err(VerifyError::Reduction) + gkr_reduce(transcript, &fold, self.params().shape(), security) + .map_err(VerifyError::Reduction) } } diff --git a/tooling/cli/benches/circuits.rs b/tooling/cli/benches/circuits.rs index 13fec1c1..514d080e 100644 --- a/tooling/cli/benches/circuits.rs +++ b/tooling/cli/benches/circuits.rs @@ -6,6 +6,7 @@ use bitz_cli::{ end_to_end::CircuitProofSystem, }; use divan::Bencher; +use pcs::SecurityLevel; fn main() { divan::main(); @@ -19,21 +20,28 @@ fn instance(circuit: BuiltinCircuit) -> (CircuitInstance, Vec) { fn setup(circuit: BuiltinCircuit) -> (CircuitProofSystem, Vec) { let (statement, inputs) = instance(circuit); - (CircuitProofSystem::new(statement).unwrap(), inputs) + ( + CircuitProofSystem::new(statement, SecurityLevel::Bits100).unwrap(), + inputs, + ) } #[divan::bench(args = BuiltinCircuit::ALL)] fn end_to_end(bencher: Bencher, circuit: BuiltinCircuit) { bencher .with_inputs(|| instance(circuit)) - .bench_local_values(|(statement, inputs)| benchmark::run(statement, &inputs).unwrap()); + .bench_local_values(|(statement, inputs)| { + benchmark::run(statement, &inputs, SecurityLevel::Bits100).unwrap() + }); } #[divan::bench(args = BuiltinCircuit::ALL)] fn circuit_setup(bencher: Bencher, circuit: BuiltinCircuit) { bencher .with_inputs(|| CircuitInstance::random(circuit, None, None).unwrap()) - .bench_local_values(|statement| CircuitProofSystem::new(statement).unwrap()); + .bench_local_values(|statement| { + CircuitProofSystem::new(statement, SecurityLevel::Bits100).unwrap() + }); } #[divan::bench(args = BuiltinCircuit::ALL)] diff --git a/tooling/cli/src/benchmark.rs b/tooling/cli/src/benchmark.rs index cbd8f6a9..adf764fe 100644 --- a/tooling/cli/src/benchmark.rs +++ b/tooling/cli/src/benchmark.rs @@ -1,6 +1,7 @@ //! Shared execution and timing for circuit proof benchmarks. use crate::end_to_end::{CircuitProofSystem, CircuitStatement, CircuitStats, Error}; +use pcs::SecurityLevel; use std::{ fmt, time::{Duration, Instant}, @@ -18,13 +19,22 @@ pub struct Timings { /// Runs setup, witness generation, commitment, proving, and verification. /// Callers generate inputs and initialize worker threads before calling this. -pub fn run(statement: S, inputs: &[bool]) -> Result { +pub fn run( + statement: S, + inputs: &[bool], + pcs_security: SecurityLevel, +) -> Result { let started = Instant::now(); - let prepared = CircuitProofSystem::new(statement)?; + let prepared = CircuitProofSystem::new(statement, pcs_security)?; let setup = started.elapsed(); let circuit = prepared.stats(); tracing::info!( opening_path = ?circuit.opening_path, + pcs_round_target_bits = circuit.pcs_security.bits(), + pcs_decoding = match circuit.pcs_security { + SecurityLevel::Bits100 => "list", + SecurityLevel::Bits128 => "unique", + }, constraints = circuit.constraints, assignment_bits = circuit.assignment_bits, committed_bits = circuit.committed_bits, @@ -57,8 +67,9 @@ impl fmt::Display for Timings { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "opening_path={:?} constraints={} assignment_bits={} committed_bits={} padded_committed_bits={} setup_ms={:.3} witness_ms={:.3} commit_ms={:.3} prove_ms={:.3} total_prove_ms={:.3} verify_ms={:.3}", + "opening_path={:?} pcs_round_target_bits={} constraints={} assignment_bits={} committed_bits={} padded_committed_bits={} setup_ms={:.3} witness_ms={:.3} commit_ms={:.3} prove_ms={:.3} total_prove_ms={:.3} verify_ms={:.3}", self.circuit.opening_path, + self.circuit.pcs_security.bits(), self.circuit.constraints, self.circuit.assignment_bits, self.circuit.committed_bits, diff --git a/tooling/cli/src/cmd/circuit_e2e.rs b/tooling/cli/src/cmd/circuit_e2e.rs index c56ad04b..82ff3457 100644 --- a/tooling/cli/src/cmd/circuit_e2e.rs +++ b/tooling/cli/src/cmd/circuit_e2e.rs @@ -6,6 +6,7 @@ use { benchmark, circuits::{BuiltinCircuit, CircuitInstance}, }, + pcs::SecurityLevel, }; /// Prove generated circuit constraints over Q100 and verify the proof. @@ -28,6 +29,14 @@ pub struct Args { /// positive Rayon worker count (default available parallelism) #[argh(option)] threads: Option, + + /// classical PCS round budget: 100 (default) or 128; Spartan remains over Q100 + #[argh( + option, + default = "SecurityLevel::Bits100", + from_str_fn(parse_pcs_security) + )] + pcs_security_bits: SecurityLevel, } impl Command for Args { @@ -46,22 +55,30 @@ impl Command for Args { circuit = %self.circuit, threads = rayon::current_num_threads(), field = "Q100", - pcs = "Fast", + pcs_round_target_bits = self.pcs_security_bits.bits(), hash = "Blake3", ).entered(); let statement = tracing::info_span!("generate_inputs").in_scope(|| { CircuitInstance::random(self.circuit, self.num_blocks, self.initial_state) })?; let inputs = statement.inputs.clone(); - let timings = benchmark::run(statement, &inputs)?; + let timings = benchmark::run(statement, &inputs, self.pcs_security_bits)?; tracing::info!("Proof verified successfully"); - println!("circuit={} threads={} field=Q100 pcs=Fast hash=Blake3 relation=Q100-r1cs constraints_verified=true", self.circuit, rayon::current_num_threads()); + println!("circuit={} threads={} field=Q100 pcs_round_target_bits={} security_model=classical-pcs-round-budget relation=Q100-r1cs constraints_verified=true", self.circuit, rayon::current_num_threads(), self.pcs_security_bits.bits()); println!("{timings}"); Ok(()) }) } } +fn parse_pcs_security(value: &str) -> Result { + match value { + "100" => Ok(SecurityLevel::Bits100), + "128" => Ok(SecurityLevel::Bits128), + _ => Err("expected a PCS round budget of 100 or 128 bits".into()), + } +} + fn parse_state(hex: &str) -> Result<[u32; 8], String> { if hex.len() != 64 || !hex.bytes().all(|c| c.is_ascii_hexdigit()) { return Err("expected 64 hexadecimal digits".into()); @@ -72,3 +89,33 @@ fn parse_state(hex: &str) -> Result<[u32; 8], String> { } Ok(words) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pcs_budget_defaults_to_100_and_accepts_only_supported_values() { + let args = Args::from_args(&["circuit-e2e"], &["--circuit", "sha256-compression"]).unwrap(); + assert_eq!(args.pcs_security_bits, SecurityLevel::Bits100); + for (value, expected) in [ + ("100", SecurityLevel::Bits100), + ("128", SecurityLevel::Bits128), + ] { + let args = Args::from_args( + &["circuit-e2e"], + &[ + "--circuit", + "sha256-compression", + "--pcs-security-bits", + value, + ], + ) + .unwrap(); + assert_eq!(args.pcs_security_bits, expected); + } + for value in ["0", "99", "120", "129", "invalid"] { + assert!(parse_pcs_security(value).is_err()); + } + } +} diff --git a/tooling/cli/src/end_to_end.rs b/tooling/cli/src/end_to_end.rs index d1be2c78..841f0272 100644 --- a/tooling/cli/src/end_to_end.rs +++ b/tooling/cli/src/end_to_end.rs @@ -12,7 +12,7 @@ use common::{ }; use field::{F128, FqDefault, Q100, gf128::smallest_generator}; use num_traits::{ConstOne, ConstZero}; -use pcs::{CommitScheme, HashKind, LigeritoProfile, Pcs, ProverData, StatementBinding}; +use pcs::{CommitScheme, Pcs, ProverData, SecurityLevel, StatementBinding}; use poly::{DenseMultilinearExtension, ScaledMleEvaluationClaim}; use prover::{BitZProver, VirtualWitness}; use transcript::{ProverState, PublicTranscript, build_prover, build_verifier}; @@ -50,8 +50,8 @@ pub enum Error { Spartan(spartan::SpartanError), #[error("commitment failed: {0:?}")] Commit(pcs::CommitError), - #[error("OOD commitment binding verification failed: {0:?}")] - OodVerify(pcs::VerifyError), + #[error("commitment verification failed: {0:?}")] + CommitmentVerify(pcs::VerifyError), #[error("constant-one opening failed: {0:?}")] ConstantProve(pcs::ProveError), #[error("constant-one verification failed: {0:?}")] @@ -72,6 +72,8 @@ pub enum OpeningPath { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CircuitStats { pub opening_path: OpeningPath, + /// Classical PCS round budget; this does not describe Spartan security. + pub pcs_security: SecurityLevel, pub constraints: usize, pub assignment_bits: usize, /// Meaningful committed witness bits before PCS zero padding. @@ -98,7 +100,8 @@ pub struct Witness { products: R1csProductMles, } -/// Commitment data and the transcript that sampled its OOD claim. +/// Retains the commitment and transcript for the remaining proof stages. +/// The transcript includes the initial OOD check when the selected policy requires it. pub struct CommittedWitness { data: ProverData, transcript: ProverState, @@ -112,8 +115,9 @@ pub struct Proof { } impl CircuitProofSystem { + /// Selects the classical PCS round budget. Spartan still uses Q100. #[tracing::instrument(name = "setup", skip_all)] - pub fn new(statement: S) -> Result { + pub fn new(statement: S, pcs_security: SecurityLevel) -> Result { let mut constraints = ConstraintGenerator::new(statement.input_bits()); let inputs: Vec<_> = (0..statement.input_bits()) .map(|i| constraints.input(i)) @@ -147,7 +151,7 @@ impl CircuitProofSystem { }; let params = BitZParams::new(claim_shape, smallest_generator()) .map_err(|_| Error::Configuration("inadmissible BitZ parameters"))?; - let pcs = Pcs::new(&committed_shape, LigeritoProfile::Fast, HashKind::Blake3) + let pcs = Pcs::new(&committed_shape, pcs_security) .map_err(|_| Error::Configuration("unsupported PCS shape"))?; Ok(Self { statement, @@ -163,6 +167,7 @@ impl CircuitProofSystem { pub fn stats(&self) -> CircuitStats { CircuitStats { opening_path: self.opening_path, + pcs_security: self.pcs.security_level(), constraints: self.matrices.matrices().a.row_count(), assignment_bits: self.map.h_len(), committed_bits: match self.opening_path { @@ -209,25 +214,25 @@ impl CircuitProofSystem { }) } - /// Commits and sends the initial OOD evaluation before any PIOP challenge. - /// The returned state retains both PCS data and the transcript for proving. + /// Commits and runs the selected policy's initial checks before any PIOP challenge. + /// The returned state retains the transcript, codeword, and Merkle tree for proving. #[tracing::instrument(name = "commit", skip_all)] pub fn commit(&self, witness: &Witness) -> Result { let mut transcript = build_prover(SESSION, self.statement.domain()); let (_, data) = self .pcs - .commit_with_ood(&witness.committed, &mut transcript) + .commit(&witness.committed, &mut transcript) .map_err(Error::Commit)?; Ok(CommittedWitness { data, transcript }) } /// Continues the commitment transcript through Spartan and the BitZ opening. #[tracing::instrument(name = "prove", skip_all, fields(opening_path = ?self.opening_path))] - pub fn prove(&self, witness: Witness, commitment: CommittedWitness) -> Result { + pub fn prove(&self, witness: Witness, committed: CommittedWitness) -> Result { let CommittedWitness { data, mut transcript, - } = commitment; + } = committed; let root = data.root(); self.bind(&mut transcript, root); if self.opening_path == OpeningPath::Direct { @@ -284,11 +289,11 @@ impl CircuitProofSystem { let commitment = self .pcs .receive_commitment(proof.root, &mut transcript) - .map_err(Error::OodVerify)?; + .map_err(Error::CommitmentVerify)?; self.bind(&mut transcript, proof.root); if self.opening_path == OpeningPath::Direct { self.pcs - .verify_lin_with_ood( + .verify_lin( &commitment, &self.constant_query(), StatementBinding::Bind, @@ -301,19 +306,12 @@ impl CircuitProofSystem { let claim = opening_claim(&self.params, &terminal)?; let verifier = BitZVerifier::new(self.params, WINDOW); match self.opening_path { - OpeningPath::Direct => { - verifier.verify_with_commitment(&claim, &self.pcs, &commitment, transcript) - } + OpeningPath::Direct => verifier.verify(&claim, &self.pcs, &commitment, transcript), OpeningPath::Virtual => { let statement = VirtualStatement::new(self.params, self.committed_shape, &self.map, &claim) .map_err(|_| Error::Configuration("invalid virtual statement"))?; - verifier.verify_virtual_with_commitment( - &statement, - &self.pcs, - &commitment, - transcript, - ) + verifier.verify_virtual(&statement, &self.pcs, &commitment, transcript) } } .map_err(Error::Verify) @@ -432,29 +430,20 @@ mod tests { } } - #[test] - fn commitment_sends_ood_before_proving() { - let system = CircuitProofSystem::new(IdentityBit).unwrap(); - let witness = system.witness(&[true]).unwrap(); - let committed = system.commit(&witness).unwrap(); - let proof = committed.transcript.finish(); - assert_eq!(proof.narg_string.len(), 16); - assert!(proof.hints.is_empty()); - let mut verifier = build_verifier(SESSION, system.statement.domain(), &proof); - system - .pcs - .receive_commitment(committed.data.root(), &mut verifier) - .unwrap(); - verifier.check_eof().unwrap(); - } - #[test] fn direct_opening_requires_constant_one_on_both_sides() { - let mut system = CircuitProofSystem::new(IdentityBit).unwrap(); + let mut system = CircuitProofSystem::new(IdentityBit, SecurityLevel::Bits100).unwrap(); let witness = system.witness(&[true]).unwrap(); let data = system.commit(&witness).unwrap(); let mut proof = system.prove(witness, data).unwrap(); system.verify(&proof).unwrap(); + // The initial OOD value must arrive before constant checks or Spartan challenges. + let mut truncated_commitment = proof.clone(); + truncated_commitment.opening.narg_string.truncate(15); + assert!(matches!( + system.verify(&truncated_commitment), + Err(Error::CommitmentVerify(_)) + )); system.opening_path = OpeningPath::Virtual; assert!(system.verify(&proof).is_err()); system.opening_path = OpeningPath::Direct; @@ -470,10 +459,7 @@ mod tests { // A valid opening to zero must not substitute for the required one. let packed = vec![F128::ZERO; 1 << system.committed_shape.log_packed_len()]; let mut transcript = build_prover(SESSION, system.statement.domain()); - let (_, bad_data) = system - .pcs - .commit_with_ood(&packed, &mut transcript) - .unwrap(); + let (_, bad_data) = system.pcs.commit(&packed, &mut transcript).unwrap(); system.bind(&mut transcript, bad_data.root()); let query = OpeningQuery::Mle { point: vec![F128::ZERO; system.committed_shape.log_bits()], diff --git a/tooling/cli/tests/circuits.rs b/tooling/cli/tests/circuits.rs index 420e238d..7757f8fa 100644 --- a/tooling/cli/tests/circuits.rs +++ b/tooling/cli/tests/circuits.rs @@ -7,6 +7,7 @@ use circuit::{ sha256::{ABC_BLOCK, ABC_DIGEST, INITIAL_STATE}, }; use num_traits::{Signed, ToPrimitive}; +use pcs::SecurityLevel; #[test] fn sha_constraint_residuals_cannot_wrap_modulo_q100() { @@ -50,7 +51,7 @@ fn supported_sha_circuits_prove_and_verify() { for circuit in BuiltinCircuit::ALL { let statement = CircuitInstance::random(circuit, None, None).unwrap(); let inputs = statement.inputs.clone(); - let system = CircuitProofSystem::new(statement).unwrap(); + let system = CircuitProofSystem::new(statement, SecurityLevel::Bits100).unwrap(); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); let proof = system.prove(witness, data).unwrap(); @@ -73,17 +74,17 @@ fn sha_compression_matches_abc_and_binds_public_values() { inputs: inputs.clone(), output: bits(&ABC_DIGEST), }; - let system = CircuitProofSystem::new(statement.clone()).unwrap(); + let system = CircuitProofSystem::new(statement.clone(), SecurityLevel::Bits100).unwrap(); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); let proof = system.prove(witness, data).unwrap(); - CircuitProofSystem::new(statement.clone()) + CircuitProofSystem::new(statement.clone(), SecurityLevel::Bits100) .unwrap() .verify(&proof) .unwrap(); let mut changed = statement.clone(); changed.output[0] ^= true; - let wrong_output = CircuitProofSystem::new(changed).unwrap(); + let wrong_output = CircuitProofSystem::new(changed, SecurityLevel::Bits100).unwrap(); assert!(wrong_output.verify(&proof).is_err()); assert!(matches!( wrong_output.witness(&inputs), @@ -92,7 +93,7 @@ fn sha_compression_matches_abc_and_binds_public_values() { let mut changed = statement; changed.inputs[0] ^= true; assert!( - CircuitProofSystem::new(changed) + CircuitProofSystem::new(changed, SecurityLevel::Bits100) .unwrap() .verify(&proof) .is_err() @@ -109,7 +110,7 @@ fn variable_lengths_custom_state_and_invalid_dimensions() { ] { let statement = CircuitInstance::random(circuit, count, state).unwrap(); let inputs = statement.inputs.clone(); - let system = CircuitProofSystem::new(statement).unwrap(); + let system = CircuitProofSystem::new(statement, SecurityLevel::Bits100).unwrap(); system.witness(&inputs).unwrap(); } for (circuit, count) in [ @@ -126,5 +127,5 @@ fn variable_lengths_custom_state_and_invalid_dimensions() { let mut malformed = CircuitInstance::random(BuiltinCircuit::Sha256Compression, None, None).unwrap(); malformed.inputs.pop(); - assert!(CircuitProofSystem::new(malformed).is_err()); + assert!(CircuitProofSystem::new(malformed, SecurityLevel::Bits100).is_err()); } diff --git a/tooling/cli/tests/end_to_end.rs b/tooling/cli/tests/end_to_end.rs index 857f7b80..ba94a4e0 100644 --- a/tooling/cli/tests/end_to_end.rs +++ b/tooling/cli/tests/end_to_end.rs @@ -1,37 +1,15 @@ -use bitz_cli::end_to_end::{CircuitProofSystem, CircuitStatement, Error, OpeningPath, Proof}; +use bitz_cli::end_to_end::{CircuitProofSystem, CircuitStatement, Error, OpeningPath}; use circuit::Circuit; -use pcs::VerifyError; +use pcs::SecurityLevel; -fn rejects_changed_or_missing_ood( - system: &CircuitProofSystem, - proof: &Proof, -) { - // These Fast-profile fixtures have zero initial grinding bits, so the first - // 16 transcript bytes encode the OOD evaluation. - let mut changed = proof.clone(); - changed.opening.narg_string[0] ^= 1; - assert!(system.verify(&changed).is_err()); - - let mut missing = proof.clone(); - missing.opening.narg_string.drain(..16); - assert!(system.verify(&missing).is_err()); - - let mut truncated = proof.clone(); - truncated.opening.narg_string.truncate(15); - assert!(matches!( - system.verify(&truncated), - Err(Error::OodVerify(VerifyError::MalformedProof)) - )); -} - -struct PublicBit; +struct PublicBit(bool); impl CircuitStatement for PublicBit { fn domain(&self) -> &'static [u8] { b"test/public-bit/v1" } fn public_bytes(&self) -> Vec { - vec![1] + vec![u8::from(self.0)] } fn input_bits(&self) -> usize { 1 @@ -39,24 +17,25 @@ impl CircuitStatement for PublicBit { fn synthesize(&self, cs: &mut CS, inputs: &[CS::Bool]) -> Result<(), Error> { let bit = cs.bitz::<1>(inputs[0].clone()); let one = CS::Z::<1>::from(CS::Coefficient::<1>::from(1u64)); - cs.assert_r1c::<1>(one.clone(), bit, one); + let expected = CS::Z::<1>::from(CS::Coefficient::<1>::from(u64::from(self.0))); + cs.assert_r1c::<1>(one, bit, expected); Ok(()) } } #[test] fn generic_driver_accepts_a_non_sha_circuit() { - let prepared = CircuitProofSystem::new(PublicBit).unwrap(); + let prepared = CircuitProofSystem::new(PublicBit(true), SecurityLevel::Bits100).unwrap(); assert_eq!(prepared.stats().opening_path, OpeningPath::Direct); + assert_eq!(prepared.stats().pcs_security, SecurityLevel::Bits100); assert_eq!(prepared.stats().committed_bits, 2); let witness = prepared.witness(&[true]).unwrap(); let data = prepared.commit(&witness).unwrap(); let proof = prepared.prove(witness, data).unwrap(); - CircuitProofSystem::new(PublicBit) + CircuitProofSystem::new(PublicBit(true), SecurityLevel::Bits100) .unwrap() .verify(&proof) .unwrap(); - rejects_changed_or_missing_ood(&prepared, &proof); let mut changed = proof.clone(); changed.root.0[0] ^= 1; @@ -83,12 +62,14 @@ fn generic_driver_accepts_a_non_sha_circuit() { #[test] fn benchmark_runs_a_generic_circuit_and_propagates_failure() { - let timings = bitz_cli::benchmark::run(PublicBit, &[true]).unwrap(); + let timings = + bitz_cli::benchmark::run(PublicBit(true), &[true], SecurityLevel::Bits100).unwrap(); let output = timings.to_string(); assert!(output.contains("total_prove_ms=")); assert!(output.contains("verify_ms=")); + assert!(output.contains("pcs_round_target_bits=100")); assert!(matches!( - bitz_cli::benchmark::run(PublicBit, &[false]), + bitz_cli::benchmark::run(PublicBit(true), &[false], SecurityLevel::Bits100), Err(Error::Unsatisfied) )); } @@ -118,18 +99,17 @@ impl CircuitStatement for PublicXor { #[test] fn nonidentity_map_uses_virtual_opening_and_checks_xor_relation() { - let system = CircuitProofSystem::new(PublicXor).unwrap(); + let system = CircuitProofSystem::new(PublicXor, SecurityLevel::Bits100).unwrap(); assert_eq!(system.stats().opening_path, OpeningPath::Virtual); assert_eq!(system.stats().assignment_bits, 3); assert_eq!(system.stats().committed_bits, 2); let witness = system.witness(&[true, false]).unwrap(); let data = system.commit(&witness).unwrap(); let proof = system.prove(witness, data).unwrap(); - CircuitProofSystem::new(PublicXor) + CircuitProofSystem::new(PublicXor, SecurityLevel::Bits100) .unwrap() .verify(&proof) .unwrap(); - rejects_changed_or_missing_ood(&system, &proof); assert!(matches!( system.witness(&[true, true]), Err(Error::Unsatisfied) @@ -140,6 +120,39 @@ fn nonidentity_map_uses_virtual_opening_and_checks_xor_relation() { let mut changed = proof; changed.opening.narg_string.push(0); assert!(system.verify(&changed).is_err()); - let timings = bitz_cli::benchmark::run(PublicXor, &[true, false]).unwrap(); + let timings = + bitz_cli::benchmark::run(PublicXor, &[true, false], SecurityLevel::Bits100).unwrap(); + assert_eq!(timings.circuit.opening_path, OpeningPath::Virtual); +} + +#[test] +fn explicit_128_budget_binds_the_policy_and_public_statement() { + let system = CircuitProofSystem::new(PublicBit(true), SecurityLevel::Bits128).unwrap(); + assert_eq!(system.stats().pcs_security, SecurityLevel::Bits128); + let witness = system.witness(&[true]).unwrap(); + let data = system.commit(&witness).unwrap(); + let proof = system.prove(witness, data).unwrap(); + system.verify(&proof).unwrap(); + + assert!( + CircuitProofSystem::new(PublicBit(true), SecurityLevel::Bits100) + .unwrap() + .verify(&proof) + .is_err() + ); + assert!( + CircuitProofSystem::new(PublicBit(false), SecurityLevel::Bits128) + .unwrap() + .verify(&proof) + .is_err() + ); +} + +#[test] +fn explicit_128_budget_supports_virtual_openings() { + let timings = + bitz_cli::benchmark::run(PublicXor, &[true, false], SecurityLevel::Bits128).unwrap(); assert_eq!(timings.circuit.opening_path, OpeningPath::Virtual); + assert_eq!(timings.circuit.pcs_security, SecurityLevel::Bits128); + assert!(timings.to_string().contains("pcs_round_target_bits=128")); } From a3e76177ef4f3600c5f033a4fad3b5d83f71f4f7 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 29 Sep 2026 13:01:59 +0200 Subject: [PATCH 06/16] feat: add johnson and unique decoding bounds calculation --- crates/pcs/src/profiles.rs | 237 ++++++-------------------- crates/pcs/src/profiles/bounds.rs | 273 ++++++++++++++++++++++++++++++ crates/pcs/src/profiles/tests.rs | 171 +++++++++++++++++++ tooling/cli/README.md | 11 ++ 4 files changed, 508 insertions(+), 184 deletions(-) create mode 100644 crates/pcs/src/profiles/bounds.rs create mode 100644 crates/pcs/src/profiles/tests.rs diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index 1050dbf3..5161631e 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -1,221 +1,90 @@ -//! Security profiles for the commitment and its recursive opening. +//! Derives both PCS security profiles from the padded witness size. -use flock_core::hash::HashKind; use flock_core::pcs::LOG_PACKING; use flock_core::pcs::ligerito::{ - FinalBlockConfig, GrindingStep, LigeritoLevelConfig, LigeritoProfile, LigeritoSecurityConfig, - SoundnessRegime, embedded_security_config, + FinalBlockConfig, GrindingStep, LigeritoLevelConfig, LigeritoSecurityConfig, SoundnessRegime, }; -use crate::ConfigError; +use crate::{ConfigError, SecurityLevel}; -const FAST_INITIAL_K: usize = 4; -const FAST_RECURSIVE_K: usize = 3; -const FAST_MAX_FINAL_LOG_N: usize = 5; -const FAST_SECURITY_BITS: usize = 100; -const FAST_QUERY_GRINDING_BITS: usize = 16; +mod bounds; + +const INITIAL_K: usize = 4; +const RECURSIVE_K: usize = 3; +const FINAL_LOG_N: usize = 5; const JOHNSON_ETA: f64 = 0.02; -/// Uses 16-lane rows for Fast and keeps Flock's embedded Slim and Secure profiles. -/// Every level uses the commitment's Merkle hash. -/// `CheckedLigerito` validates the result when it constructs the prover and verifier configurations. +/// Builds Johnson100 with OOD, or UDR128 without OOD. +/// Returns the Flock parameters and any initial OOD grinding requirement. pub(crate) fn security_config( m: usize, - profile: LigeritoProfile, - merkle_hash: HashKind, -) -> Result { - let mut security = match profile { - LigeritoProfile::Fast => fast_security_config(m)?, - LigeritoProfile::Slim | LigeritoProfile::Secure => { - let toml = embedded_security_config(m, profile).ok_or(ConfigError::Invalid( - "no security config for this size and profile", - ))?; - LigeritoSecurityConfig::from_toml_str(toml) - .map_err(|_| ConfigError::Invalid("security config"))? - } - }; - security.hash = match merkle_hash { - HashKind::Sha256 => "sha256", - HashKind::Blake3 => "blake3", - } - .into(); - Ok(security) -} - -/// Derives initial OOD grinding from the level-zero collision bound. -/// -/// With `rho = 2^-log_inv_rate`, let `list_size = 1 / (2 * eta * sqrt(rho))`, -/// `pairs = max(list_size * (list_size - 1) / 2, 1)`, and -/// `degree = max(2^packed_vars - 1, 1)`. The unground bound is -/// `128 - log2(pairs) - log2(degree)` bits; grinding covers its rounded-up -/// deficit against the target. Unique-decoding profiles return `None`. -pub(crate) fn ood_grinding_bits( - security: &LigeritoSecurityConfig, - packed_vars: usize, -) -> Option { - let level = security.levels.first()?; - let eta = match level.regime { - SoundnessRegime::JohnsonOod => level.eta?, - SoundnessRegime::Udr => return None, - }; - let rho = (-(level.log_inv_rate as f64)).exp2(); - let list_size = 1.0 / (2.0 * eta * rho.sqrt()); - let pairs = (list_size * (list_size - 1.0) / 2.0).max(1.0); - let degree = ((packed_vars as f64).exp2() - 1.0).max(1.0); - let collision_bits = 128.0 - pairs.log2() - degree.log2(); - let deficit = security.target_security_bits as f64 - collision_bits; - let grinding_bits = deficit.ceil().max(0.0) as u32; - Some(grinding_bits) -} - -/// Reproduces the reference prover's k = 4 profile with 16-bit query grinding. -/// Flock's `derive_profile` fixes k = 6 and zero query grinding for Fast. -fn fast_security_config(m: usize) -> Result { - if !(22..=35).contains(&m) { - return Err(ConfigError::Invalid( - "no security config for this size and profile", - )); + security_level: SecurityLevel, +) -> Result<(LigeritoSecurityConfig, Option), ConfigError> { + if !(20..=35).contains(&m) { + return Err(ConfigError::Invalid("unsupported PCS size")); } - + let target = security_level.bits() as usize; + let johnson = security_level == SecurityLevel::Bits100; let log_n = m - LOG_PACKING; - let mut log_msg_cols = log_n - FAST_INITIAL_K; + let mut remaining = log_n; let mut levels = Vec::new(); - loop { + let mut initial_ood = None; + while remaining > FINAL_LOG_N { let first = levels.is_empty(); let k = if first { - FAST_INITIAL_K + INITIAL_K } else { - FAST_RECURSIVE_K + RECURSIVE_K.min(remaining - FINAL_LOG_N) }; + remaining -= k; let mut level = LigeritoLevelConfig { - log_inv_rate: LigeritoProfile::Fast.log_inv_rate() + levels.len(), - log_msg_cols, + log_inv_rate: levels.len() + 1, + log_msg_cols: remaining, log_num_interleaved: k, k_recursive: k, - regime: SoundnessRegime::JohnsonOod, - eta: Some(JOHNSON_ETA), - proximity_loss: None, - // One query lets Flock calculate the security contribution per query. - queries: 1, - grinding_bits: FAST_QUERY_GRINDING_BITS, + regime: if johnson { + SoundnessRegime::JohnsonOod + } else { + SoundnessRegime::Udr + }, + eta: johnson.then_some(JOHNSON_ETA), + proximity_loss: (!johnson).then_some(0.0), + queries: 0, + grinding_bits: 0, fold_grinding_bits: 0, - ood_samples: usize::from(!first), - target_security_bits: FAST_SECURITY_BITS, + ood_samples: usize::from(johnson && !first), + target_security_bits: target, expected_eps_pg_bits: 0.0, expected_eps_query_bits: 0.0, expected_eps_ood_bits: None, }; - let (proximity_bits, per_query_bits) = level.paper_predicted_bits(); - level.queries = ((FAST_SECURITY_BITS - FAST_QUERY_GRINDING_BITS) as f64 / per_query_bits) - .ceil() as usize; - level.fold_grinding_bits = - (FAST_SECURITY_BITS as f64 - proximity_bits).ceil().max(0.0) as usize; - level.expected_eps_pg_bits = round_bits(proximity_bits); - level.expected_eps_query_bits = round_bits(level.queries as f64 * per_query_bits); - level.expected_eps_ood_bits = level.paper_predicted_ood_bits().map(round_bits); - levels.push(level); - - if log_msg_cols <= FAST_MAX_FINAL_LOG_N { - break; + let ood = bounds::configure_level(&mut level, first, remaining == FINAL_LOG_N)?; + if first { + initial_ood = ood; } - log_msg_cols -= FAST_RECURSIVE_K; + levels.push(level); } - - Ok(LigeritoSecurityConfig { + let security = LigeritoSecurityConfig { m, log_n, - initial_k: FAST_INITIAL_K, - target_security_bits: FAST_SECURITY_BITS, - analysis_version: "johnson_ood_row_union_over_bchks25_thm_4_6".into(), + initial_k: INITIAL_K, + target_security_bits: target, + analysis_version: if johnson { + "bitz_johnson_combined_blocks_v1" + } else { + "bitz_udr_combined_blocks_v1" + } + .into(), field: "f128".into(), - hash: "sha256".into(), + hash: "blake3".into(), grinding_step: GrindingStep::PostCommitPreQueries, levels, final_block: FinalBlockConfig { - yr_log_n: log_msg_cols, + yr_log_n: remaining, }, - }) -} - -fn round_bits(bits: f64) -> f64 { - (10.0 * bits).round() / 10.0 + }; + Ok((security, initial_ood)) } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fast_profiles_match_original_configs() { - // BLAKE3 fingerprints of Flock's canonical serialization of PR #66's original m22..m35 TOMLs. - let fingerprints = [ - "ea2a8a8e069a402f46cac3b621a0d5ec4fa422e410a59cd402ce0875e6335ae6", - "b7f47c3046888625127fb03f7995693915b40c6381718aa42049bea727311170", - "f452a419d857364970ce991c5abcc5eedb34f826a52ca8cd79f6209133f39a70", - "63a6a55488bae28b0af93fc904f655c96f131ccb08b9798ed261fc08654864fe", - "36537633d668dfe7617eea1d27ad9cbf412b52d7dc0f3ef826371c683bba2b38", - "aa7f5f62d683154cbde220298b541edd6938471eed5fed5b0fd9eb5d901674bc", - "94d33bc4dd03edea9dcb3543921d32ef9c5ac613299de6672e95b23cb3d231be", - "640972ec7c9c7a8243f98cae8812a297229adcd431386300f29bf99391fbc1d5", - "53cf053efb6bb3f4c17623991c26a2b313244ee953ad61a81760814c3361b72b", - "916129a4c90b1732d581783279059eb549bf272ecd05494072d0240af7cea75e", - "001a4d0bef2bcad7be395b39e55e60d4ec25bdf5268a7c3bf5db2267d86060a8", - "97c7ff6015cc19d373d6417ac8107ac20c83ce62858cab16939330d79249fadf", - "8ab7b908ad3d93fc34abd63ba5700ef47565f9720087a16b54354253529b6be9", - "40a19c7d4d41f3f47c92dda8c2961f8ebdbd1b555a13e8a04a6e481162354697", - ]; - for (m, expected) in (22..=35).zip(fingerprints) { - for (hash, hash_name) in [(HashKind::Sha256, "sha256"), (HashKind::Blake3, "blake3")] { - let mut security = security_config(m, LigeritoProfile::Fast, hash).unwrap(); - assert_eq!(security.hash, hash_name); - security.to_prover_verifier_configs().unwrap(); - security.hash = "sha256".into(); - let canonical = security.to_toml_string().unwrap(); - assert_eq!( - blake3::hash(canonical.as_bytes()).to_hex().as_str(), - expected, - "m={m}" - ); - } - } - } - - #[test] - fn other_profiles_keep_embedded_configs() { - for m in 22..=35 { - for profile in [LigeritoProfile::Slim, LigeritoProfile::Secure] { - let toml = embedded_security_config(m, profile).unwrap(); - let mut expected = LigeritoSecurityConfig::from_toml_str(toml).unwrap(); - for (hash, hash_name) in - [(HashKind::Sha256, "sha256"), (HashKind::Blake3, "blake3")] - { - let security = security_config(m, profile, hash).unwrap(); - security.to_prover_verifier_configs().unwrap(); - expected.hash = hash_name.into(); - assert_eq!( - security.to_toml_string().unwrap(), - expected.to_toml_string().unwrap() - ); - } - } - } - } - - #[test] - fn fast_profile_rejects_unsupported_sizes() { - for m in [0, 21, 36, usize::MAX] { - assert!(security_config(m, LigeritoProfile::Fast, HashKind::Blake3).is_err()); - } - } - - #[test] - fn ood_round_parameters_match_the_level_zero_bound() { - let security = security_config(22, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - assert_eq!(ood_grinding_bits(&security, 15), Some(0)); - - let mut unique = security; - unique.levels[0].regime = SoundnessRegime::Udr; - unique.levels[0].eta = None; - assert_eq!(ood_grinding_bits(&unique, 15), None); - } -} +mod tests; diff --git a/crates/pcs/src/profiles/bounds.rs b/crates/pcs/src/profiles/bounds.rs new file mode 100644 index 00000000..7272b56a --- /dev/null +++ b/crates/pcs/src/profiles/bounds.rs @@ -0,0 +1,273 @@ +//! Conservative probability bounds and per-level parameter selection. +//! +//! BitZ Remark A.2 and Lemma B.2 give the list and initial OOD bounds. +//! Flock Appendix C.3 gives folding, sumcheck, batching, and query bounds. + +use flock_core::pcs::ligerito::{LigeritoLevelConfig, SoundnessRegime}; + +use super::JOHNSON_ETA; +use crate::ConfigError; + +const MAX_GRINDING_BITS: usize = 32; + +/// Selects query and grinding parameters for the supplied canonical level. +/// Only the first Johnson level returns an initial OOD grinding requirement. +pub(super) fn configure_level( + level: &mut LigeritoLevelConfig, + first: bool, + final_level: bool, +) -> Result, ConfigError> { + let target = level.target_security_bits; + let johnson = matches!(level.regime, SoundnessRegime::JohnsonOod); + let mut initial_ood = None; + let (positions, miss_upper, query_list) = match level.regime { + SoundnessRegime::JohnsonOod => { + let probability = JohnsonProbability::new(level); + level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| probability.folds_meet_target(level.k_recursive, bits, first)) + .ok_or(ConfigError::Invalid("Johnson fold grinding exceeds cap"))?; + let variables = level.log_msg_cols + level.log_num_interleaved; + probability.check_ood(variables, first)?; + if first { + initial_ood = Some(probability.initial_ood_grinding(variables)?); + } + // Nonfinal batching concerns the next commitment, whose rate halves rho. + // Its list bound grows by sqrt(2). The final residual has one candidate. + let query_list = if final_level { + 1.0 + } else { + mul_up(probability.list_upper, sqrt_bounds(2.0).1) + }; + (probability.positions, probability.miss_upper, query_list) + } + SoundnessRegime::Udr => { + let probability = UdrProbability::new(level)?; + level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| probability.fold_meets_target(target, bits, first)) + .ok_or(ConfigError::Invalid("UDR fold grinding exceeds cap"))?; + (probability.positions, probability.miss_upper, 1.0) + } + }; + let target_error = 2f64.powi(-(target as i32)); + let mut query_miss = 1.0; + // Queries cover the target. Johnson also covers alpha and OOD beta without grinding. + loop { + let error = if johnson { + combined_query_error(query_miss, level.queries, true, query_list) + } else { + query_miss + }; + if error <= target_error { + break; + } + if level.queries == positions { + return Err(ConfigError::Invalid("queries exceed codeword length")); + } + query_miss = mul_up(query_miss, miss_upper); + level.queries += 1; + } + if !johnson { + // UDR grinding covers the combined query, alpha, and final beta errors. + let error = combined_query_error(query_miss, level.queries, final_level, query_list); + level.grinding_bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| error <= 2f64.powi(bits as i32 - target as i32)) + .ok_or(ConfigError::Invalid("UDR query grinding exceeds cap"))?; + } + let (pg, query) = level.paper_predicted_bits(); + level.expected_eps_pg_bits = pg; + level.expected_eps_query_bits = query; + level.expected_eps_ood_bits = level.paper_predicted_ood_bits(); + Ok(initial_ood) +} + +struct JohnsonProbability { + positions: usize, + fold_coefficient_upper: f64, + miss_upper: f64, + list_upper: f64, +} + +impl JohnsonProbability { + fn new(level: &LigeritoLevelConfig) -> Self { + // All inputs have the checked canonical geometry and eta=0.02. + let positions = 1usize << (level.log_msg_cols + level.log_inv_rate); + let rho = 2f64.powi(-(level.log_inv_rate as i32)); + let (sqrt_lower, sqrt_upper) = sqrt_bounds(rho); + let eta_lower = JOHNSON_ETA.next_down(); + let eta_upper = JOHNSON_ETA.next_up(); + let gamma_upper = ((1.0 - sqrt_lower).next_up() - eta_lower).next_up(); + let half = div_up(sqrt_upper, 2.0 * eta_lower).ceil().max(3.0) + 0.5; + let half5 = (0..5).fold(1.0, |value, _| mul_up(value, half)); + let numerator = add_up(2.0 * half5, mul_up(mul_up(3.0 * half, gamma_upper), rho)); + let denominator = (3.0 * (rho * sqrt_lower).next_down()).next_down(); + let base = add_up( + mul_up(div_up(numerator, denominator), positions as f64), + div_up(half, sqrt_lower), + ); + // Retain the pinned backend's row multiplier as a conservative bound. + let row_union = 2f64.powi(level.log_num_interleaved as i32 - 1); + Self { + positions, + fold_coefficient_upper: mul_up(base, row_union), + miss_upper: add_up(sqrt_upper, eta_upper), + list_upper: div_up(1.0, (2.0 * eta_lower * sqrt_lower).next_down()), + } + } + + fn check_ood(&self, variables: usize, first: bool) -> Result<(), ConfigError> { + // One sample covers each recursive selection. Its response separates selection from beta. + // The next query block covers beta. Level zero uses its implicit OOD bound. + let mu = variables as f64; + let coefficient = if first { + mul_up(self.list_upper, mu) + } else { + mul_up(mul_up(self.list_upper, self.list_upper), mu * 0.5) + }; + if coefficient > 2f64.powi(28) { + return Err(ConfigError::Invalid("Johnson recursive OOD bound")); + } + // Initial ring switching and OOD mixing cost at most 8L/F. + // Later introduction beta costs L/F before any fold grinding starts. + let batching = if first { 8.0 } else { 1.0 }; + if mul_up(batching, self.list_upper) > 2f64.powi(28) { + return Err(ConfigError::Invalid("Johnson unground batching bound")); + } + Ok(()) + } + + fn initial_ood_grinding(&self, log_n: usize) -> Result { + let pairs = mul_up(self.list_upper, (self.list_upper - 1.0).next_up()) * 0.5; + let degree = ((1u64 << log_n) - 1) as f64; + let coefficient = mul_up(pairs, degree); + (0..=MAX_GRINDING_BITS) + .find(|&bits| coefficient <= 2f64.powi(28 + bits as i32)) + .map(|bits| bits as u32) + .ok_or(ConfigError::Invalid( + "Johnson initial OOD grinding exceeds cap", + )) + } + + fn folds_meet_target(&self, folds: usize, grinding: usize, first: bool) -> bool { + (0..folds).all(|round| { + let effective = grinding.saturating_sub(round); + // Flock C.3 takes a list union for sumcheck and claim batching. + let extra = mul_up( + 2.0 + f64::from(!first && round == 0 && effective == 0), + self.list_upper, + ); + let coefficient = add_up( + self.fold_coefficient_upper * 2f64.powi(-(round as i32)), + extra, + ); + coefficient <= 2f64.powi(28 + effective as i32) + }) + } +} + +fn add_up(left: f64, right: f64) -> f64 { + (left + right).next_up() +} + +fn mul_up(left: f64, right: f64) -> f64 { + (left * right).next_up() +} + +fn div_up(numerator: f64, denominator: f64) -> f64 { + (numerator / denominator).next_up() +} + +fn sqrt_bounds(value: f64) -> (f64, f64) { + // The product checks certify the bracket independently of sqrt rounding. + let root = value.sqrt(); + let mut lower = root.next_down(); + while mul_up(lower, lower) > value { + lower = lower.next_down(); + } + let mut upper = root.next_up(); + while (upper * upper).next_down() < value { + upper = upper.next_up(); + } + (lower, upper) +} + +struct UdrProbability { + positions: usize, + coefficient_numerator: u128, + coefficient_denominator: u128, + miss_upper: f64, +} + +impl UdrProbability { + fn new(level: &LigeritoLevelConfig) -> Result { + // Callers first check the canonical ladder. Its largest exponent is 25. + let positions = 1usize << (level.log_msg_cols + level.log_inv_rate); + let n = positions as u128; + let d = 1u128 << level.log_inv_rate; + let distance_square = (d - 1).pow(2) * n; + // BCHKS25 Corollary 1.4: delta >= 3*sqrt(2/n). + // This also ensures gamma >= delta/3 at the selected upper endpoint. + if distance_square < 18 * d * d { + return Err(ConfigError::Invalid("UDR theorem range")); + } + let denominator = 2 * d * (d - 1); + let gamma_n_numerator = distance_square - 6 * d * d; + let miss_numerator = denominator * n - gamma_n_numerator; + // Outward conversion and division keep this probability an upper bound. + let miss_upper = + ((miss_numerator as f64).next_up() / ((denominator * n) as f64).next_down()).next_up(); + Ok(Self { + positions, + coefficient_numerator: gamma_n_numerator + denominator, + coefficient_denominator: denominator, + miss_upper, + }) + } + + fn fold_meets_target(&self, target: usize, grinding: usize, first: bool) -> bool { + // A quadratic shares each fold. An unground first recursive fold also + // shares the preceding introduction beta, adding one more field error. + let extras = 2 + u128::from(!first && grinding == 0); + self.coefficient_numerator + extras * self.coefficient_denominator + <= self.coefficient_denominator * (1u128 << (128 - target + grinding)) + } +} + +fn combined_query_error( + miss_upper: f64, + queries: usize, + include_beta: bool, + list_upper: f64, +) -> f64 { + let alpha = queries.next_power_of_two().ilog2(); + let field_terms = alpha + u32::from(include_beta); + add_up( + miss_upper, + mul_up(f64::from(field_terms), list_upper) * 2f64.powi(-128), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_union_can_exhaust_the_remaining_fold_budget() { + let probability = JohnsonProbability { + positions: 512, + fold_coefficient_upper: 2f64.powi(28) - 100.0, + miss_upper: 0.5, + list_upper: 40.0, + }; + // Sumcheck costs 80 field errors. Introduction beta raises that cost to 120. + assert!(probability.folds_meet_target(1, 0, true)); + assert!(!probability.folds_meet_target(1, 0, false)); + } + + #[test] + fn query_batching_covers_every_candidate() { + // Eight alpha coordinates and one beta each range over 64 candidates. + let error = combined_query_error(0.0, 256, true, 64.0); + assert!(error >= 576.0 * 2f64.powi(-128)); + assert!(error < 577.0 * 2f64.powi(-128)); + } +} diff --git a/crates/pcs/src/profiles/tests.rs b/crates/pcs/src/profiles/tests.rs new file mode 100644 index 00000000..d79facfa --- /dev/null +++ b/crates/pcs/src/profiles/tests.rs @@ -0,0 +1,171 @@ +use super::*; + +#[test] +fn udr_m22_matches_audited_parameters() { + let (config, initial_ood) = security_config(22, SecurityLevel::Bits128).unwrap(); + assert_eq!(config.initial_k, 4); + assert_eq!(config.final_block.yr_log_n, 5); + assert_eq!(config.hash, "blake3"); + assert_eq!(config.target_security_bits, 128); + for (index, level) in config.levels.iter().enumerate() { + assert_eq!(level.queries, [311, 192, 161][index]); + assert_eq!(level.fold_grinding_bits, [10, 9, 7][index]); + assert_eq!(level.grinding_bits, 4); + assert_eq!(level.ood_samples, 0); + } + assert_eq!(initial_ood, None); +} + +#[test] +fn udr_all_supported_sizes_cover_combined_errors() { + for m in 20..=35 { + let (config, initial_ood) = security_config(m, SecurityLevel::Bits128).unwrap(); + assert_eq!(initial_ood, None); + assert_eq!(config.hash, "blake3"); + config.to_prover_verifier_configs().unwrap(); + for (index, level) in config.levels.iter().enumerate() { + // Reconstruct the published formulas independently of the bound helpers. + let n = 2f64.powi((level.log_msg_cols + level.log_inv_rate) as i32); + let rho = 2f64.powi(-(level.log_inv_rate as i32)); + let delta = 1.0 - rho; + let gamma = delta / 2.0 - 3.0 / (delta * n); + assert!(delta >= 3.0 * (2.0 / n).sqrt()); + assert!(gamma >= delta / 3.0 && gamma < delta / 2.0); + let fold_error = |grinding| { + let beta = f64::from(index > 0 && grinding == 0); + (gamma * n + 3.0 + beta) * 2f64.powi(-128 - grinding as i32) + }; + assert!(fold_error(level.fold_grinding_bits) <= 2f64.powi(-128)); + if level.fold_grinding_bits > 0 { + assert!(fold_error(level.fold_grinding_bits - 1) > 2f64.powi(-128)); + } + let final_beta = usize::from(index + 1 == config.levels.len()); + let alpha = level.queries.next_power_of_two().ilog2() as usize; + let miss = (1.0 - gamma).powi(level.queries as i32); + assert!(miss <= 2f64.powi(-128)); + assert!((1.0 - gamma).powi(level.queries as i32 - 1) > 2f64.powi(-128)); + let query = (miss + (alpha + final_beta) as f64 * 2f64.powi(-128)) + * 2f64.powi(-(level.grinding_bits as i32)); + assert!(query <= 2f64.powi(-128), "m={m}, level={index}"); + if level.grinding_bits > 0 { + assert!(query * 2.0 > 2f64.powi(-128)); + } + assert!(level.queries <= n as usize); + assert!(level.fold_grinding_bits <= 32 && level.grinding_bits <= 32); + assert!( + level.fold_grinding_bits == 0 || level.fold_grinding_bits >= level.k_recursive, + "every positive fold schedule must retain all native grinding hooks" + ); + } + } +} + +#[test] +fn johnson_queries_replace_query_grinding() { + for (m, folds) in [(20, [7, 4, 1]), (22, [9, 6, 3])] { + let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); + assert_eq!(config.target_security_bits, 100); + assert_eq!(initial_ood, Some(0)); + for (index, level) in config.levels.iter().enumerate() { + assert_eq!(level.queries, [218, 106, 71][index]); + assert_eq!(level.fold_grinding_bits, folds[index]); + assert_eq!(level.grinding_bits, 0); + assert_eq!(level.ood_samples, usize::from(index > 0)); + } + } +} + +#[test] +fn johnson_all_sizes_cover_combined_blocks() { + for m in 20..=35 { + let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); + config.to_prover_verifier_configs().unwrap(); + let initial_grinding = initial_ood.unwrap(); + let list = 1.0 / (0.04 * 0.5f64.sqrt()); + let initial = list * (list - 1.0) * 0.5 * (2f64.powi(m as i32 - 7) - 1.0); + assert!(initial <= 2f64.powi(28 + initial_grinding as i32)); + assert!(initial_grinding <= 32); + if initial_grinding > 0 { + assert!(initial > 2f64.powi(27 + initial_grinding as i32)); + } + for (index, level) in config.levels.iter().enumerate() { + // Reconstruct the published formulas independently of the bound helpers. + let rho = 2f64.powi(-(level.log_inv_rate as i32)); + let sqrt_rho = rho.sqrt(); + let list = 1.0 / (0.04 * sqrt_rho); + let gamma = 1.0 - sqrt_rho - 0.02; + let half = (sqrt_rho / 0.04).ceil().max(3.0) + 0.5; + let n = 2f64.powi((level.log_msg_cols + level.log_inv_rate) as i32); + let base = (2.0 * half.powi(5) + 3.0 * half * gamma * rho) / (3.0 * rho.powf(1.5)) * n + + half / sqrt_rho; + let k = level.k_recursive; + let folds_fit = |grinding: usize| { + (0..k).all(|round| { + let row_union = 2f64.powi((k - 1 - round) as i32); + let effective = grinding.saturating_sub(round); + let beta = f64::from(index > 0 && round == 0 && effective == 0); + base * row_union + (2.0 + beta) * list <= 2f64.powi(28 + effective as i32) + }) + }; + assert!(folds_fit(level.fold_grinding_bits)); + if level.fold_grinding_bits > 0 { + assert!(!folds_fit(level.fold_grinding_bits - 1)); + } + let next_list = config.levels.get(index + 1).map_or(1.0, |next| { + 1.0 / (0.04 * 2f64.powi(-(next.log_inv_rate as i32)).sqrt()) + }); + let alpha = level.queries.next_power_of_two().ilog2(); + let query = (sqrt_rho + 0.02).powi(level.queries as i32) + + f64::from(alpha + 1) * next_list * 2f64.powi(-128); + assert!(query <= 2f64.powi(-100)); + let previous_query = (sqrt_rho + 0.02).powi(level.queries as i32 - 1) + + f64::from((level.queries - 1).next_power_of_two().ilog2() + 1) + * next_list + * 2f64.powi(-128); + assert!(previous_query > 2f64.powi(-100)); + let mu = (level.log_msg_cols + level.log_num_interleaved) as f64; + let ood = if index == 0 { + list * mu + } else { + list * list * mu * 0.5 + }; + assert!(ood <= 2f64.powi(28)); + assert!((if index == 0 { 8.0 } else { 1.0 }) * list <= 2f64.powi(28)); + assert!(level.queries <= n as usize); + assert_eq!(level.grinding_bits, 0); + assert_eq!(level.ood_samples, usize::from(index > 0)); + assert!(level.fold_grinding_bits <= 32); + assert!(level.fold_grinding_bits >= k); + } + } +} + +#[test] +fn both_profiles_keep_a_five_variable_residual() { + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + for m in 20..=35 { + let (config, _) = security_config(m, security).unwrap(); + assert_eq!(config.levels[0].k_recursive, 4); + assert_eq!(config.final_block.yr_log_n, 5); + let total_folds: usize = config.levels.iter().map(|level| level.k_recursive).sum(); + assert_eq!(total_folds + 5, m - 7); + for (index, level) in config.levels.iter().enumerate() { + assert_eq!(level.log_inv_rate, index + 1); + assert_eq!(level.log_num_interleaved, level.k_recursive); + } + if m <= 21 { + assert_eq!(config.levels[1].k_recursive, 3); + assert_eq!(config.levels[2].k_recursive, m - 19); + } + } + } +} + +#[test] +fn both_profiles_reject_unsupported_sizes() { + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + for m in [0, 19, 36, usize::MAX] { + assert!(security_config(m, security).is_err()); + } + } +} diff --git a/tooling/cli/README.md b/tooling/cli/README.md index bfbe3a44..cbcebd39 100644 --- a/tooling/cli/README.md +++ b/tooling/cli/README.md @@ -6,6 +6,12 @@ Run an end-to-end proof with fresh random inputs: cargo run -p bitz-cli --release -- circuit-e2e --circuit sha256-chain --num-blocks 8 --threads 1 ``` +Select the PCS round budget with `--pcs-security-bits 100` or `128`; the default is `100`. +The implementation derives all internal parameters from the padded witness size. +The `100` target uses list decoding and an initial out-of-domain (OOD) check. +The `128` target uses unique decoding and omits that check. +Spartan still uses `Q100`; selecting `128` does not include spartan yet + `--circuit` selects one of these compiled-in adapters: - `sha256-compression`: one raw block; optional `--initial-state` accepts eight words as 64 hexadecimal digits. @@ -19,6 +25,11 @@ Every proof checks R1CS constraints modulo Q100. SHA-256 constraint residuals ar The output reports the selected opening path, constraint and witness sizes, and setup/witness/commit/prove/verify timings. `total_prove_ms` includes commitment and proving only. Input generation and thread-pool initialization are excluded. +Commitment timing includes the initial OOD check when the selected target requires it. +`CircuitProofSystem::commit` retains the transcript and commitment data together. +`CircuitProofSystem::prove` consumes that state and continues the same transcript. +Verification receives the commitment before public-input binding, constant checks, or Spartan challenges. + Run the four circuits through end-to-end and individual setup, witness, commitment, proving, and verification benchmarks: ```sh From ec6c8cf2efcf6168c1cdf56b7ec0e792ce28e10f Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 09:48:05 +0200 Subject: [PATCH 07/16] Simplify dynamic security profiles and PCS integration --- README.md | 2 +- crates/gkr/src/lib.rs | 37 ++--- crates/pcs/src/challenger.rs | 95 +++++------- crates/pcs/src/commitment.rs | 114 +++++++------- crates/pcs/src/ligerito.rs | 8 - crates/pcs/src/opening.rs | 3 - crates/pcs/src/opening/tests.rs | 68 +-------- crates/pcs/src/profiles/bounds.rs | 97 +++++------- crates/pcs/tests/round_trip.rs | 228 +++++++++++++++------------- crates/post_gkr/src/lib.rs | 39 ++--- crates/post_gkr/src/sumcheck.rs | 14 +- crates/tests/src/lib.rs | 9 +- crates/tests/tests/fold.rs | 36 ++--- crates/tests/tests/large.rs | 23 +-- crates/tests/tests/prove.rs | 101 +++--------- crates/tests/tests/virtual_prove.rs | 15 +- crates/transcript/src/pow.rs | 11 +- tooling/cli/README.md | 7 +- tooling/cli/src/end_to_end.rs | 4 +- tooling/cli/tests/end_to_end.rs | 22 ++- 20 files changed, 355 insertions(+), 578 deletions(-) diff --git a/README.md b/README.md index e93b4f66..d57a1ae1 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Use `--pcs-security-bits 100` or `128` to select the PCS round budget; the defau The implementation derives internal parameters from the padded witness size. The `100` target uses list decoding and an initial out-of-domain (OOD) check. The `128` target uses unique decoding and omits that check. -Spartan still uses `Q100`; this option does include spartan yet. +Spartan still uses `Q100`; this option does not set Spartan security. ## Benchmarks diff --git a/crates/gkr/src/lib.rs b/crates/gkr/src/lib.rs index 6099cfa1..fa860e4a 100644 --- a/crates/gkr/src/lib.rs +++ b/crates/gkr/src/lib.rs @@ -10,7 +10,6 @@ pub type Field = F128; type Point = VecDeque; const CUBIC_GRINDING_LABEL: &[u8] = b"gkr/cubic/v1"; -const AFFINE_GRINDING_LABEL: &[u8] = b"gkr/affine/v1"; /// Proves the layer-by-layer sumcheck reduction from a claim at `point` /// (an evaluation point on the output layer) down to a claim on the leaves. @@ -20,6 +19,7 @@ pub fn gpgkr_prove( ps: &mut ProverState, log_bits: usize, point: &[F128], + // All the intermediate witnesses + the input layer. Doesn't contain the output layer witnesses: LayerWitnesses, security: SecurityLevel, ) -> (Vec, Field) { @@ -145,7 +145,6 @@ fn prove_layer( } ps.prover_message(&[mle_l[0], mle_r[0]]); - ps.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)); let r = ps.verifier_message(); next_point.push_front(r); let claim = mle_l[0] + r * (mle_r[0] - mle_l[0]); @@ -341,8 +340,6 @@ fn verify_layer( if (prefix * elem_lr[0] * elem_lr[1]) != claim { None } else { - vs.grind(AFFINE_GRINDING_LABEL, security.grinding_bits(1)) - .ok()?; let r = vs.verifier_message(); next_point.push_front(r); @@ -411,6 +408,7 @@ impl IntoIterator for LayerWitnesses { mod tests { use super::*; use proptest::prelude::*; + use transcript::SecurityLevel::{Bits100, Bits128}; fn field() -> impl Strategy { any::().prop_map(Field::from) @@ -524,7 +522,7 @@ mod tests { let instance = (leaves.clone(), point.to_vec()); let mut unground_len = 0; - for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + for security in [Bits100, Bits128] { let circuit = GrandProductCircuit::new(leaves.clone()); let (output, witnesses) = circuit.batched_eval(4); let claim = mle(output, &point); @@ -547,8 +545,8 @@ mod tests { verifier.check_eof().unwrap(); match security { - SecurityLevel::Bits100 => unground_len = proof.narg_string.len(), - SecurityLevel::Bits128 => { + Bits100 => unground_len = proof.narg_string.len(), + Bits128 => { // Two layers have five cubic challenges and five eight-byte nonces. assert_eq!(proof.narg_string.len(), unground_len + 40); // The first cubic message occupies two canonical field elements. @@ -582,29 +580,20 @@ mod tests { leaves.len().ilog2() as usize, &point, witnesses, - SecurityLevel::Bits100, + Bits100, ); assert_eq!(terminal.1, mle(leaves.clone(), &terminal.0)); let proof = prover.finish(); let mut verifier = transcript::build_verifier("gkr-zero", &instance, &proof); assert_eq!( - gpgkr_verify(&mut verifier, claim, &point, 2, SecurityLevel::Bits100), + gpgkr_verify(&mut verifier, claim, &point, 2, Bits100), Some(terminal) ); verifier.check_eof().unwrap(); let mut verifier = transcript::build_verifier("gkr-zero", &instance, &proof); - assert!( - gpgkr_verify( - &mut verifier, - claim + Field::ONE, - &point, - 2, - SecurityLevel::Bits100 - ) - .is_none() - ); + assert!(gpgkr_verify(&mut verifier, claim + Field::ONE, &point, 2, Bits100).is_none()); } } @@ -647,13 +636,7 @@ mod tests { let log_groups = last_value.len().max(1).ilog2(); let point: Vec = (0..log_groups).map(|_| prover.verifier_message()).collect(); - gpgkr_prove( - &mut prover, - log_bits, - &point, - witnesses, - SecurityLevel::Bits100, - ); + gpgkr_prove(&mut prover, log_bits, &point, witnesses, Bits100); (last_value, prover.finish()) } @@ -672,7 +655,7 @@ mod tests { let log_leafs = circuit.leafs.len().max(1).ilog2(); let rounds = log_leafs.saturating_sub(log_groups); - match gpgkr_verify(&mut verifier, claim, &point, rounds, SecurityLevel::Bits100) { + match gpgkr_verify(&mut verifier, claim, &point, rounds, Bits100) { Some((point, claim)) => { let leaf_check = mle(circuit.leafs, &point); diff --git a/crates/pcs/src/challenger.rs b/crates/pcs/src/challenger.rs index 5bcc1137..62b40bba 100644 --- a/crates/pcs/src/challenger.rs +++ b/crates/pcs/src/challenger.rs @@ -12,26 +12,10 @@ const POW_TAG: &[u8] = b"pcs/flock/pow/v1"; const LIGERITO_BASIS_LABEL: &[u8] = b"flock-ligerito-basis-v0"; const MAX_OBSERVED_BYTES: usize = 32; -/// Checks the backend call order and enforces the selected fold difficulties. -struct PowSchedule { - calls: std::vec::IntoIter<(u32, u32)>, - failed: bool, -} - -impl PowSchedule { - fn difficulty(&mut self, native: u32) -> u32 { - match self.calls.next() { - Some((expected, effective)) if native == expected => effective, - _ => { - self.failed = true; - 0 - } - } - } - - fn failed(&self) -> bool { - self.failed || self.calls.len() != 0 - } +/// Checks the backend call order before applying the selected difficulty. +fn pow_difficulty(calls: &mut std::slice::Iter<'_, (u32, u32)>, native: u32) -> Option { + let &(expected, effective) = calls.next()?; + (native == expected).then_some(effective) } #[derive(Clone, Copy)] @@ -44,19 +28,16 @@ pub(crate) struct ProverChallenger<'a> { transcript: &'a mut ProverState, failed: bool, opening_target: Option, - pow_schedule: PowSchedule, + pow_schedule: std::slice::Iter<'a, (u32, u32)>, } impl<'a> ProverChallenger<'a> { #[cfg(test)] - pub(crate) fn new(transcript: &'a mut ProverState, calls: Vec<(u32, u32)>) -> Self { + pub(crate) fn new(transcript: &'a mut ProverState, calls: &'a [(u32, u32)]) -> Self { Self { transcript, failed: false, - pow_schedule: PowSchedule { - calls: calls.into_iter(), - failed: false, - }, + pow_schedule: calls.iter(), opening_target: None, } } @@ -64,15 +45,12 @@ impl<'a> ProverChallenger<'a> { pub(crate) fn new_ligerito( transcript: &'a mut ProverState, expected_target: FlockF128, - calls: Vec<(u32, u32)>, + calls: &'a [(u32, u32)], ) -> Self { Self { transcript, failed: false, - pow_schedule: PowSchedule { - calls: calls.into_iter(), - failed: false, - }, + pow_schedule: calls.iter(), opening_target: Some(OpeningTargetPrefix { expected_target, label_seen: false, @@ -81,7 +59,7 @@ impl<'a> ProverChallenger<'a> { } pub(crate) fn failed(&self) -> bool { - self.failed || self.opening_target.is_some() || self.pow_schedule.failed() + self.failed || self.opening_target.is_some() || self.pow_schedule.len() != 0 } } @@ -89,19 +67,16 @@ pub(crate) struct VerifierChallenger<'a, 'proof> { transcript: &'a mut VerifierState<'proof>, failed: bool, opening_target: Option, - pow_schedule: PowSchedule, + pow_schedule: std::slice::Iter<'a, (u32, u32)>, } impl<'a, 'proof> VerifierChallenger<'a, 'proof> { #[cfg(test)] - pub(crate) fn new(transcript: &'a mut VerifierState<'proof>, calls: Vec<(u32, u32)>) -> Self { + pub(crate) fn new(transcript: &'a mut VerifierState<'proof>, calls: &'a [(u32, u32)]) -> Self { Self { transcript, failed: false, - pow_schedule: PowSchedule { - calls: calls.into_iter(), - failed: false, - }, + pow_schedule: calls.iter(), opening_target: None, } } @@ -109,15 +84,12 @@ impl<'a, 'proof> VerifierChallenger<'a, 'proof> { pub(crate) fn new_ligerito( transcript: &'a mut VerifierState<'proof>, expected_target: FlockF128, - calls: Vec<(u32, u32)>, + calls: &'a [(u32, u32)], ) -> Self { Self { transcript, failed: false, - pow_schedule: PowSchedule { - calls: calls.into_iter(), - failed: false, - }, + pow_schedule: calls.iter(), opening_target: Some(OpeningTargetPrefix { expected_target, label_seen: false, @@ -126,7 +98,7 @@ impl<'a, 'proof> VerifierChallenger<'a, 'proof> { } pub(crate) fn failed(&self) -> bool { - self.failed || self.opening_target.is_some() || self.pow_schedule.failed() + self.failed || self.opening_target.is_some() || self.pow_schedule.len() != 0 } fn read(&mut self) -> Option @@ -191,7 +163,10 @@ impl Challenger for ProverChallenger<'_> { } fn grind_pow(&mut self, bits: u32) -> u64 { - let bits = self.pow_schedule.difficulty(bits); + let Some(bits) = pow_difficulty(&mut self.pow_schedule, bits) else { + self.failed = true; + return 0; + }; self.transcript.public_message(POW_TAG); self.transcript.public_message(&bits); let seed = self.transcript.verifier_message::().to_bytes(); @@ -263,7 +238,10 @@ impl Challenger for VerifierChallenger<'_, '_> { } fn verify_pow(&mut self, nonce: u64, bits: u32) -> bool { - let bits = self.pow_schedule.difficulty(bits); + let Some(bits) = pow_difficulty(&mut self.pow_schedule, bits) else { + self.failed = true; + return false; + }; self.transcript.public_message(POW_TAG); self.transcript.public_message(&bits); let seed = self.transcript.verifier_message::().to_bytes(); @@ -288,7 +266,7 @@ mod tests { fn constant_schedule_replays_and_rejects_changed_call_order() { let calls = vec![(5, 5), (4, 5), (3, 5), (0, 0)]; let mut transcript = build_prover(b"schedule", b"instance"); - let mut prover = ProverChallenger::new(&mut transcript, calls.clone()); + let mut prover = ProverChallenger::new(&mut transcript, &calls); assert!(prover.failed()); // An incomplete schedule cannot succeed. let nonces: Vec<_> = calls .iter() @@ -298,7 +276,7 @@ mod tests { let proof = transcript.finish(); let mut transcript = build_verifier(b"schedule", b"instance", &proof); - let mut verifier = VerifierChallenger::new(&mut transcript, calls.clone()); + let mut verifier = VerifierChallenger::new(&mut transcript, &calls); for (&nonce, &(native, _)) in nonces.iter().zip(&calls) { assert!(verifier.verify_pow(nonce, native)); } @@ -306,7 +284,7 @@ mod tests { transcript.check_eof().unwrap(); let mut transcript = build_verifier(b"schedule", b"instance", &proof); - let mut verifier = VerifierChallenger::new(&mut transcript, calls); + let mut verifier = VerifierChallenger::new(&mut transcript, &calls); verifier.verify_pow(nonces[0], 4); assert!(verifier.failed()); } @@ -314,7 +292,7 @@ mod tests { #[test] fn extra_pow_calls_fail_after_schedule_completion() { let mut transcript = build_prover(b"schedule", b"instance"); - let mut prover = ProverChallenger::new(&mut transcript, vec![(0, 0)]); + let mut prover = ProverChallenger::new(&mut transcript, &[(0, 0)]); prover.grind_pow(0); assert!(!prover.failed()); prover.grind_pow(0); @@ -343,7 +321,7 @@ mod tests { fn observed_root_changes_the_following_challenge() { fn sample_after_root(root: &[u8; 32]) -> FlockF128 { let mut transcript = build_prover(b"pcs-challenger-test", b"root-binding"); - let mut challenger = ProverChallenger::new(&mut transcript, vec![]); + let mut challenger = ProverChallenger::new(&mut transcript, &[]); challenger.observe_bytes(root); challenger.sample_f128() } @@ -370,7 +348,7 @@ mod tests { let mut prover = build_prover(SESSION, INSTANCE); let nonce = { - let mut challenger = ProverChallenger::new(&mut prover, vec![(BITS, BITS)]); + let mut challenger = ProverChallenger::new(&mut prover, &[(BITS, BITS)]); challenger.grind_pow(BITS) }; let mut proof = prover.finish(); @@ -385,7 +363,7 @@ mod tests { let mut verifier = build_verifier(SESSION, INSTANCE, &proof); { - let mut challenger = VerifierChallenger::new(&mut verifier, vec![(BITS, BITS)]); + let mut challenger = VerifierChallenger::new(&mut verifier, &[(BITS, BITS)]); assert!(!challenger.verify_pow(changed_nonce, BITS)); assert!(challenger.failed()); } @@ -398,7 +376,7 @@ mod tests { let next_message = FlockF128::new(3, 4); let mut prover = build_prover(b"pcs-challenger-test", b"public-opening-target"); { - let mut challenger = ProverChallenger::new_ligerito(&mut prover, target, vec![]); + let mut challenger = ProverChallenger::new_ligerito(&mut prover, target, &[]); challenger.observe_label(LIGERITO_BASIS_LABEL); challenger.observe_f128(target); challenger.observe_f128(next_message); @@ -409,7 +387,7 @@ mod tests { let mut verifier = build_verifier(b"pcs-challenger-test", b"public-opening-target", &proof); { - let mut challenger = VerifierChallenger::new_ligerito(&mut verifier, target, vec![]); + let mut challenger = VerifierChallenger::new_ligerito(&mut verifier, target, &[]); challenger.observe_label(LIGERITO_BASIS_LABEL); challenger.observe_f128(target); challenger.observe_f128(next_message); @@ -422,7 +400,7 @@ mod tests { fn ligerito_public_target_changes_the_challenge() { fn sample(target: FlockF128) -> FlockF128 { let mut prover = build_prover(b"pcs-challenger-test", b"public-opening-target"); - let mut challenger = ProverChallenger::new_ligerito(&mut prover, target, vec![]); + let mut challenger = ProverChallenger::new_ligerito(&mut prover, target, &[]); challenger.observe_label(LIGERITO_BASIS_LABEL); challenger.observe_f128(target); challenger.sample_f128() @@ -448,9 +426,10 @@ mod tests { .map(|&(lo, hi)| FlockF128::new(lo, hi)) .collect::>(); + let calls = [(pow_bits, pow_bits)]; let mut prover = build_prover(b"pcs-challenger-test", b"method-round-trip"); let (sampled_scalar, sampled_vector, nonce) = { - let mut challenger = ProverChallenger::new(&mut prover, vec![(pow_bits, pow_bits)]); + let mut challenger = ProverChallenger::new(&mut prover, &calls); challenger.observe_label(b"test-label"); challenger.observe_f128(scalar); challenger.observe_f128_slice(&slice); @@ -469,7 +448,7 @@ mod tests { &proof, ); { - let mut challenger = VerifierChallenger::new(&mut verifier, vec![(pow_bits, pow_bits)]); + let mut challenger = VerifierChallenger::new(&mut verifier, &calls); challenger.observe_label(b"test-label"); challenger.observe_f128(scalar); challenger.observe_f128_slice(&slice); diff --git a/crates/pcs/src/commitment.rs b/crates/pcs/src/commitment.rs index 7b57d367..994fe61b 100644 --- a/crates/pcs/src/commitment.rs +++ b/crates/pcs/src/commitment.rs @@ -15,7 +15,7 @@ use common::{Root, Shape}; use field::F128; use flock_core::hash::HashKind; use flock_core::pcs::ligerito::LigeritoProfile; -use flock_core::pcs::{PcsParams, ProverData as FlockProverData}; +use flock_core::pcs::{LOG_PACKING, PcsParams, ProverData as FlockProverData, commit}; use transcript::{Encoding, ProverState, PublicTranscript, SecurityLevel, VerifierState}; // Increment this version when parameter derivation or transcript rules change. @@ -44,11 +44,10 @@ pub struct Pcs { checked_ligerito: CheckedLigerito, ood_grinding_bits: Option, bit_len: usize, - packed_len: usize, security_level: SecurityLevel, } -/// The commitment and private Flock data retained for proving openings. +/// Commitment and private Flock data retained for proving openings. pub struct ProverData { pub(crate) commitment: Commitment, flock_prover_data: FlockProverData, @@ -60,9 +59,9 @@ pub struct ProverData { /// An opening must authenticate its OOD claim before the verifier accepts the proof. #[derive(Debug)] pub struct Commitment { - pub(crate) root: Root, - pub(crate) bit_len: usize, - pub(crate) security_level: SecurityLevel, + root: Root, + bit_len: usize, + security_level: SecurityLevel, pub(crate) ood: Option, } @@ -100,15 +99,11 @@ impl Pcs { merkle_hash: HashKind::Blake3, }; let checked_ligerito = CheckedLigerito::new(¶ms, &security)?; - let packed_len = 1usize - .checked_shl(checked_ligerito.log_n_u32()) - .ok_or(ConfigError::Invalid("packed length overflow"))?; Ok(Self { params, checked_ligerito, ood_grinding_bits, bit_len, - packed_len, security_level, }) } @@ -128,24 +123,23 @@ impl Pcs { // 2. Commit Packed Witness let (flock_commitment, flock_prover_data) = - flock_core::pcs::commit(as_flock_f128s(packed_witness), &self.params); + commit(as_flock_f128s(packed_witness), &self.params); // 3. Build Public Commitment let root = Root(flock_commitment.root); self.bind_commitment(root, transcript); let ood = ood::prove(self, &root.0, packed_witness, transcript); - let commitment = Commitment { - root, - bit_len: self.bit_len, - security_level: self.security_level, - ood, - }; // 4. Retain Opening Data Ok(( root, ProverData { - commitment, + commitment: Commitment { + root, + bit_len: self.bit_len, + security_level: self.security_level, + ood, + }, flock_prover_data, }, )) @@ -174,17 +168,21 @@ impl Pcs { transcript.public_message(self); } - pub(crate) fn ood_grinding_bits(&self) -> Option { - self.ood_grinding_bits - } - pub fn bit_len(&self) -> usize { self.bit_len } /// Returns the required number of packed `F128` elements. pub fn packed_len(&self) -> usize { - self.packed_len + self.bit_len >> LOG_PACKING + } + + pub(crate) fn params(&self) -> &PcsParams { + &self.params + } + + pub(crate) fn ood_grinding_bits(&self) -> Option { + self.ood_grinding_bits } /// Returns the selected classical PCS round budget. @@ -193,12 +191,8 @@ impl Pcs { } /// Maps each native PoW call to its checked effective difficulty. - pub(crate) fn pow_schedule(&self) -> Vec<(u32, u32)> { - self.checked_ligerito.pow_schedule().to_vec() - } - - pub(crate) fn params(&self) -> &PcsParams { - &self.params + pub(crate) fn pow_schedule(&self) -> &[(u32, u32)] { + self.checked_ligerito.pow_schedule() } pub(crate) fn prover_config(&self) -> &flock_core::pcs::ligerito::ProverConfig { @@ -256,34 +250,6 @@ mod tests { Shape::new(7, 15).unwrap() } - #[test] - fn explicit_profiles_bind_the_target_and_witness_size() { - let shape = Shape::new(7, 13).unwrap(); - let low = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); - let high = Pcs::new(&shape, SecurityLevel::Bits128).unwrap(); - // Fixed encoding: version tag, padded bit count (u64 LE), target (u32 LE). - assert_eq!( - low.encode().as_ref(), - b"bitz/pcs/security/v2\x00\x00\x10\x00\x00\x00\x00\x00\x64\x00\x00\x00" - ); - assert_ne!(low.encode().as_ref(), high.encode().as_ref()); - let (_, data) = low - .commit( - &vec![F128::ZERO; low.packed_len()], - &mut transcript::build_prover(b"commit-test", b"instance"), - ) - .unwrap(); - assert_eq!(crate::ligerito::validate_prover_data(&low, &data), Ok(())); - assert_eq!( - crate::ligerito::validate_prover_data(&high, &data), - Err(crate::ProveError::ProverDataMismatch) - ); - let larger_shape = Shape::new(7, 14).unwrap(); - let changed = Pcs::new(&larger_shape, SecurityLevel::Bits100).unwrap(); - assert_ne!(low.encode().as_ref(), changed.encode().as_ref()); - assert!(crate::ligerito::validate_prover_data(&changed, &data).is_err()); - } - #[test] fn every_dynamic_size_builds_a_complete_native_pow_schedule() { for m in 20..=35 { @@ -334,13 +300,13 @@ mod tests { let (commitment, data) = scheme .commit( &packed_witness, - &mut transcript::build_prover(b"commit-test", b"instance"), + &mut build_prover(b"commit-test", b"witness"), ) .unwrap(); let (second_commitment, _) = scheme .commit( &packed_witness, - &mut transcript::build_prover(b"commit-test", b"instance"), + &mut build_prover(b"commit-test", b"witness"), ) .unwrap(); let mut changed_witness = packed_witness.clone(); @@ -348,7 +314,7 @@ mod tests { let (changed_commitment, _) = scheme .commit( &changed_witness, - &mut transcript::build_prover(b"commit-test", b"instance"), + &mut build_prover(b"commit-test", b"witness"), ) .unwrap(); @@ -375,6 +341,34 @@ mod tests { ); } + #[test] + fn explicit_profiles_bind_the_target_and_witness_size() { + let shape = Shape::new(7, 13).unwrap(); + let low = Pcs::new(&shape, SecurityLevel::Bits100).unwrap(); + let high = Pcs::new(&shape, SecurityLevel::Bits128).unwrap(); + // Fixed encoding: version tag, padded bit count (u64 LE), target (u32 LE). + assert_eq!( + low.encode().as_ref(), + b"bitz/pcs/security/v2\x00\x00\x10\x00\x00\x00\x00\x00\x64\x00\x00\x00" + ); + assert_ne!(low.encode().as_ref(), high.encode().as_ref()); + let (_, data) = low + .commit( + &vec![F128::ZERO; low.packed_len()], + &mut build_prover(b"commit-test", b"witness"), + ) + .unwrap(); + assert_eq!(crate::ligerito::validate_prover_data(&low, &data), Ok(())); + assert_eq!( + crate::ligerito::validate_prover_data(&high, &data), + Err(crate::ProveError::ProverDataMismatch) + ); + let larger_shape = Shape::new(7, 14).unwrap(); + let changed = Pcs::new(&larger_shape, SecurityLevel::Bits100).unwrap(); + assert_ne!(low.encode().as_ref(), changed.encode().as_ref()); + assert!(crate::ligerito::validate_prover_data(&changed, &data).is_err()); + } + proptest! { #![proptest_config(ProptestConfig::with_cases(64))] diff --git a/crates/pcs/src/ligerito.rs b/crates/pcs/src/ligerito.rs index 2e8f5a5b..c6366393 100644 --- a/crates/pcs/src/ligerito.rs +++ b/crates/pcs/src/ligerito.rs @@ -25,7 +25,6 @@ const PROOF_HINT_LIMIT: usize = 64 * 1024 * 1024; pub(crate) struct CheckedLigerito { prover_config: ProverConfig, verifier_config: VerifierConfig, - log_n_u32: u32, final_log_n: usize, pow_schedule: Vec<(u32, u32)>, } @@ -39,8 +38,6 @@ impl CheckedLigerito { .m .checked_sub(LOG_PACKING) .ok_or(ConfigError::Invalid("m below packing width"))?; - let log_n_u32 = - u32::try_from(log_n).map_err(|_| ConfigError::Invalid("log_n exceeds u32"))?; let (prover_config, verifier_config) = security .to_prover_verifier_configs() .map_err(|_| ConfigError::Invalid("prover config"))?; @@ -71,7 +68,6 @@ impl CheckedLigerito { Ok(Self { prover_config, verifier_config, - log_n_u32, final_log_n, pow_schedule, }) @@ -85,10 +81,6 @@ impl CheckedLigerito { &self.verifier_config } - pub(crate) fn log_n_u32(&self) -> u32 { - self.log_n_u32 - } - pub(crate) fn final_log_n(&self) -> usize { self.final_log_n } diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index 1f757377..a21e8060 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -117,9 +117,6 @@ pub(crate) fn prove( transcript: &mut ProverState, ) -> Result<(), ProveError> { let commitment = data.commitment(); - if !commitment.matches(pcs) { - return Err(ProveError::ProverDataMismatch); - } let root = commitment.root(); match query { OpeningQuery::Mle { point, target } => { diff --git a/crates/pcs/src/opening/tests.rs b/crates/pcs/src/opening/tests.rs index e61a1d33..e67ec4bb 100644 --- a/crates/pcs/src/opening/tests.rs +++ b/crates/pcs/src/opening/tests.rs @@ -172,75 +172,19 @@ fn opening_reuses_commitment_state_and_leaves_matching_transcripts() { } #[test] -fn initial_ood_rejects_changed_values_and_missing_or_mismatched_state() { +fn opening_rejects_missing_ood_commitment_state() { let fixture = fixture(); let pcs = &fixture.pcs; - let query = OpeningQuery::Mle { - point: vec![F128::ZERO; M], - target: F128::ZERO, - }; let mut prover = build_prover(SESSION, INSTANCE); let (root, mut data) = pcs.commit(&fixture.witness, &mut prover).unwrap(); - prove( - pcs, - &data, - fixture.witness.clone(), - &query, - StatementBinding::Bind, - &mut prover, - ) - .unwrap(); let proof = prover.finish(); - let value_offset = usize::from(pcs.ood_grinding_bits().unwrap() > 0) * 8; - let mut changed = proof.clone(); - changed.narg_string[value_offset] ^= 1; - let mut verifier = build_verifier(SESSION, INSTANCE, &changed); - let commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); - assert!( - verify( - pcs, - &commitment, - &query, - StatementBinding::Bind, - &mut verifier - ) - .is_err() - ); - - let mut missing = proof.clone(); - missing.narg_string.truncate(value_offset + 15); - let mut verifier = build_verifier(SESSION, INSTANCE, &missing); - assert!(pcs.receive_commitment(root, &mut verifier).is_err()); - - let mut verifier = build_verifier(SESSION, INSTANCE, &proof); - let mut commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); - let smaller = Pcs::new(&Shape::new(7, M - 8).unwrap(), SecurityLevel::Bits100).unwrap(); - let smaller_query = OpeningQuery::Mle { - point: vec![F128::ZERO; M - 1], + let query = OpeningQuery::Mle { + point: vec![F128::ZERO; M], target: F128::ZERO, }; - assert_eq!( - verify( - &smaller, - &commitment, - &smaller_query, - StatementBinding::Bind, - &mut verifier, - ), - Err(VerifyError::VerificationFailed), - ); - let other = Pcs::new(&Shape::new(7, M - 7).unwrap(), SecurityLevel::Bits128).unwrap(); - assert_eq!( - verify( - &other, - &commitment, - &query, - StatementBinding::Bind, - &mut verifier - ), - Err(VerifyError::VerificationFailed), - ); + let mut verifier = build_verifier(SESSION, INSTANCE, &proof); + let mut commitment = pcs.receive_commitment(root, &mut verifier).unwrap(); commitment.ood = None; assert_eq!( verify( @@ -261,7 +205,7 @@ fn initial_ood_rejects_changed_values_and_missing_or_mismatched_state() { fixture.witness.clone(), &query, StatementBinding::Bind, - &mut prover + &mut prover, ), Err(ProveError::ProverDataMismatch), ); diff --git a/crates/pcs/src/profiles/bounds.rs b/crates/pcs/src/profiles/bounds.rs index 7272b56a..3ef20201 100644 --- a/crates/pcs/src/profiles/bounds.rs +++ b/crates/pcs/src/profiles/bounds.rs @@ -8,7 +8,8 @@ use flock_core::pcs::ligerito::{LigeritoLevelConfig, SoundnessRegime}; use super::JOHNSON_ETA; use crate::ConfigError; -const MAX_GRINDING_BITS: usize = 32; +const MAX_GRINDING_BITS: usize = transcript::pow::MAX_GRINDING_BITS as usize; +const JOHNSON_SLACK_BITS: i32 = 128 - 100; /// Selects query and grinding parameters for the supplied canonical level. /// Only the first Johnson level returns an initial OOD grinding requirement. @@ -19,10 +20,11 @@ pub(super) fn configure_level( ) -> Result, ConfigError> { let target = level.target_security_bits; let johnson = matches!(level.regime, SoundnessRegime::JohnsonOod); + let positions = 1usize << (level.log_msg_cols + level.log_inv_rate); let mut initial_ood = None; - let (positions, miss_upper, query_list) = match level.regime { + let (miss_upper, query_list) = match level.regime { SoundnessRegime::JohnsonOod => { - let probability = JohnsonProbability::new(level); + let probability = JohnsonProbability::new(level, positions); level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) .find(|&bits| probability.folds_meet_target(level.k_recursive, bits, first)) .ok_or(ConfigError::Invalid("Johnson fold grinding exceeds cap"))?; @@ -38,15 +40,9 @@ pub(super) fn configure_level( } else { mul_up(probability.list_upper, sqrt_bounds(2.0).1) }; - (probability.positions, probability.miss_upper, query_list) - } - SoundnessRegime::Udr => { - let probability = UdrProbability::new(level)?; - level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| probability.fold_meets_target(target, bits, first)) - .ok_or(ConfigError::Invalid("UDR fold grinding exceeds cap"))?; - (probability.positions, probability.miss_upper, 1.0) + (probability.miss_upper, query_list) } + SoundnessRegime::Udr => (configure_udr(level, positions, first)?, 1.0), }; let target_error = 2f64.powi(-(target as i32)); let mut query_miss = 1.0; @@ -81,16 +77,14 @@ pub(super) fn configure_level( } struct JohnsonProbability { - positions: usize, fold_coefficient_upper: f64, miss_upper: f64, list_upper: f64, } impl JohnsonProbability { - fn new(level: &LigeritoLevelConfig) -> Self { + fn new(level: &LigeritoLevelConfig, positions: usize) -> Self { // All inputs have the checked canonical geometry and eta=0.02. - let positions = 1usize << (level.log_msg_cols + level.log_inv_rate); let rho = 2f64.powi(-(level.log_inv_rate as i32)); let (sqrt_lower, sqrt_upper) = sqrt_bounds(rho); let eta_lower = JOHNSON_ETA.next_down(); @@ -107,7 +101,6 @@ impl JohnsonProbability { // Retain the pinned backend's row multiplier as a conservative bound. let row_union = 2f64.powi(level.log_num_interleaved as i32 - 1); Self { - positions, fold_coefficient_upper: mul_up(base, row_union), miss_upper: add_up(sqrt_upper, eta_upper), list_upper: div_up(1.0, (2.0 * eta_lower * sqrt_lower).next_down()), @@ -123,13 +116,13 @@ impl JohnsonProbability { } else { mul_up(mul_up(self.list_upper, self.list_upper), mu * 0.5) }; - if coefficient > 2f64.powi(28) { + if coefficient > 2f64.powi(JOHNSON_SLACK_BITS) { return Err(ConfigError::Invalid("Johnson recursive OOD bound")); } // Initial ring switching and OOD mixing cost at most 8L/F. // Later introduction beta costs L/F before any fold grinding starts. let batching = if first { 8.0 } else { 1.0 }; - if mul_up(batching, self.list_upper) > 2f64.powi(28) { + if mul_up(batching, self.list_upper) > 2f64.powi(JOHNSON_SLACK_BITS) { return Err(ConfigError::Invalid("Johnson unground batching bound")); } Ok(()) @@ -140,7 +133,7 @@ impl JohnsonProbability { let degree = ((1u64 << log_n) - 1) as f64; let coefficient = mul_up(pairs, degree); (0..=MAX_GRINDING_BITS) - .find(|&bits| coefficient <= 2f64.powi(28 + bits as i32)) + .find(|&bits| coefficient <= 2f64.powi(JOHNSON_SLACK_BITS + bits as i32)) .map(|bits| bits as u32) .ok_or(ConfigError::Invalid( "Johnson initial OOD grinding exceeds cap", @@ -159,7 +152,7 @@ impl JohnsonProbability { self.fold_coefficient_upper * 2f64.powi(-(round as i32)), extra, ); - coefficient <= 2f64.powi(28 + effective as i32) + coefficient <= 2f64.powi(JOHNSON_SLACK_BITS + effective as i32) }) } } @@ -190,46 +183,35 @@ fn sqrt_bounds(value: f64) -> (f64, f64) { (lower, upper) } -struct UdrProbability { +/// Selects constant UDR fold grinding and returns the per-query miss bound. +fn configure_udr( + level: &mut LigeritoLevelConfig, positions: usize, - coefficient_numerator: u128, - coefficient_denominator: u128, - miss_upper: f64, -} - -impl UdrProbability { - fn new(level: &LigeritoLevelConfig) -> Result { - // Callers first check the canonical ladder. Its largest exponent is 25. - let positions = 1usize << (level.log_msg_cols + level.log_inv_rate); - let n = positions as u128; - let d = 1u128 << level.log_inv_rate; - let distance_square = (d - 1).pow(2) * n; - // BCHKS25 Corollary 1.4: delta >= 3*sqrt(2/n). - // This also ensures gamma >= delta/3 at the selected upper endpoint. - if distance_square < 18 * d * d { - return Err(ConfigError::Invalid("UDR theorem range")); - } - let denominator = 2 * d * (d - 1); - let gamma_n_numerator = distance_square - 6 * d * d; - let miss_numerator = denominator * n - gamma_n_numerator; - // Outward conversion and division keep this probability an upper bound. - let miss_upper = - ((miss_numerator as f64).next_up() / ((denominator * n) as f64).next_down()).next_up(); - Ok(Self { - positions, - coefficient_numerator: gamma_n_numerator + denominator, - coefficient_denominator: denominator, - miss_upper, - }) - } - - fn fold_meets_target(&self, target: usize, grinding: usize, first: bool) -> bool { - // A quadratic shares each fold. An unground first recursive fold also - // shares the preceding introduction beta, adding one more field error. - let extras = 2 + u128::from(!first && grinding == 0); - self.coefficient_numerator + extras * self.coefficient_denominator - <= self.coefficient_denominator * (1u128 << (128 - target + grinding)) + first: bool, +) -> Result { + // Callers first check the canonical ladder. Its largest exponent is 25. + let n = positions as u128; + let d = 1u128 << level.log_inv_rate; + let distance_square = (d - 1).pow(2) * n; + // BCHKS25 Corollary 1.4: delta >= 3*sqrt(2/n). + // This also ensures gamma >= delta/3 at the selected upper endpoint. + if distance_square < 18 * d * d { + return Err(ConfigError::Invalid("UDR theorem range")); } + let denominator = 2 * d * (d - 1); + let gamma_n_numerator = distance_square - 6 * d * d; + level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| { + // The fold and its quadratic cost gamma*n + 3 field errors. + // An unground first recursive fold also shares the preceding introduction beta. + let beta = u128::from(!first && bits == 0); + gamma_n_numerator + (3 + beta) * denominator + <= denominator * (1u128 << (128 - level.target_security_bits + bits)) + }) + .ok_or(ConfigError::Invalid("UDR fold grinding exceeds cap"))?; + let miss_numerator = denominator * n - gamma_n_numerator; + // Outward conversion and division keep this probability an upper bound. + Ok(((miss_numerator as f64).next_up() / ((denominator * n) as f64).next_down()).next_up()) } fn combined_query_error( @@ -253,7 +235,6 @@ mod tests { #[test] fn list_union_can_exhaust_the_remaining_fold_budget() { let probability = JohnsonProbability { - positions: 512, fold_coefficient_upper: 2f64.powi(28) - 100.0, miss_upper: 0.5, list_upper: 40.0, diff --git a/crates/pcs/tests/round_trip.rs b/crates/pcs/tests/round_trip.rs index 8f88f9be..acfe8c43 100644 --- a/crates/pcs/tests/round_trip.rs +++ b/crates/pcs/tests/round_trip.rs @@ -16,6 +16,17 @@ const INSTANCE: &[u8] = b"m22-singleton-opening"; const INNER_PRODUCT_INSTANCE: &[u8] = b"m22-factored-inner-product"; const INNER_PRODUCT_SET_BITS: [usize; 8] = [0, 63, 64, 127, 128, 255, 256, (1 << M) - 1]; +fn verify_opening( + pcs: &Pcs, + root: &Root, + query: &OpeningQuery, + binding: StatementBinding, + transcript: &mut VerifierState<'_>, +) -> Result<(), VerifyError> { + let commitment = pcs.receive_commitment(*root, transcript)?; + pcs.verify_lin(&commitment, query, binding, transcript) +} + fn shape() -> Shape { Shape::new(7, M - 7).unwrap() } @@ -54,7 +65,6 @@ impl RealFixture { let mut prover = build_prover(SESSION, INSTANCE); let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); - pcs.prove_lin( &data, packed_witness, @@ -73,17 +83,6 @@ impl RealFixture { } } -fn verify_opening( - pcs: &Pcs, - root: &Root, - query: &OpeningQuery, - binding: StatementBinding, - transcript: &mut VerifierState<'_>, -) -> Result<(), VerifyError> { - let data = pcs.receive_commitment(*root, transcript)?; - pcs.verify_lin(&data, query, binding, transcript) -} - fn fixture() -> &'static RealFixture { static FIXTURE: OnceLock = OnceLock::new(); FIXTURE.get_or_init(|| RealFixture::build(SecurityLevel::Bits100)) @@ -202,18 +201,19 @@ impl InnerProductFixture { let target = inner_product_target(&shape); assert_ne!(target, F128::ZERO); let query = factored_query(&shape, target); - let mut commitment = Root([0; 32]); let proofs = [StatementBinding::Bind, StatementBinding::AlreadyBound].map(|binding| { let mut prover = build_prover(SESSION, INNER_PRODUCT_INSTANCE); - let (root, data) = pcs.commit(&witness, &mut prover).unwrap(); - commitment = root; + let (commitment, data) = pcs.commit(&witness, &mut prover).unwrap(); if binding == StatementBinding::AlreadyBound { bind_outer_inner_product_statement(&mut prover, &pcs, &commitment, &query); } pcs.prove_lin(&data, witness.clone(), &query, binding, &mut prover) .unwrap(); - prover.finish() + (commitment, prover.finish()) }); + let [(commitment, bound), (other_root, already_bound)] = proofs; + assert_eq!(commitment, other_root); + let proofs = [bound, already_bound]; Self { pcs, commitment, @@ -234,11 +234,11 @@ impl InnerProductFixture { binding: StatementBinding, ) -> Result<(Commitment, VerifierState<'proof>), VerifyError> { let mut verifier = build_verifier(SESSION, INNER_PRODUCT_INSTANCE, proof); - let data = self.pcs.receive_commitment(*commitment, &mut verifier)?; + let received = self.pcs.receive_commitment(*commitment, &mut verifier)?; if binding == StatementBinding::AlreadyBound { bind_outer_inner_product_statement(&mut verifier, &self.pcs, commitment, query); } - Ok((data, verifier)) + Ok((received, verifier)) } fn verify( @@ -248,8 +248,9 @@ impl InnerProductFixture { proof: &Proof, binding: StatementBinding, ) -> Result<(), VerifyError> { - let (data, mut verifier) = self.verifier(commitment, query, proof, binding)?; - self.pcs.verify_lin(&data, query, binding, &mut verifier) + let (received, mut verifier) = self.verifier(commitment, query, proof, binding)?; + self.pcs + .verify_lin(&received, query, binding, &mut verifier) } } @@ -313,12 +314,76 @@ fn real_pcs_opening_round_trip_succeeds() { verifier.check_eof().unwrap(); } +#[test] +fn real_pcs_ood_round_batches_into_opening() { + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); + let mut packed_witness = vec![F128::ZERO; pcs.packed_len()]; + packed_witness[SINGLETON / 128] = F128::new(0, 1 << (SINGLETON % 128 - 64)); + let point = vec![F128::from(2u64); M]; + let query = OpeningQuery::Mle { + target: singleton_target(&point, SINGLETON), + point, + }; + ood_round_trip(&pcs, packed_witness, query); +} + +fn ood_round_trip(pcs: &impl CommitScheme, packed_witness: Vec, query: OpeningQuery) { + let mut prover = build_prover(SESSION, b"ood-round-trip"); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); + pcs.prove_lin( + &data, + packed_witness, + &query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + let next_challenge = prover.verifier_message::(); + let proof = prover.finish(); + + let mut verifier = build_verifier(SESSION, b"ood-round-trip", &proof); + let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); + pcs.verify_lin(&received, &query, StatementBinding::Bind, &mut verifier) + .unwrap(); + assert_eq!(verifier.verifier_message::(), next_challenge); + verifier.check_eof().unwrap(); +} + +#[test] +fn real_pcs_ood_round_rejects_a_changed_evaluation() { + let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); + let packed_witness = vec![F128::ZERO; pcs.packed_len()]; + let query = OpeningQuery::Mle { + point: vec![F128::from(2u64); M], + target: F128::ZERO, + }; + let mut prover = build_prover(SESSION, b"ood-tampering"); + let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); + pcs.prove_lin( + &data, + packed_witness, + &query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + let mut proof = prover.finish(); + proof.narg_string[0] ^= 1; + + let mut verifier = build_verifier(SESSION, b"ood-tampering", &proof); + let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); + assert!( + pcs.verify_lin(&received, &query, StatementBinding::Bind, &mut verifier,) + .is_err() + ); +} + #[test] fn factored_inner_product_round_trip_succeeds_for_both_security_levels_and_bindings() { for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { let fixture = inner_product_fixture(security); for binding in [StatementBinding::Bind, StatementBinding::AlreadyBound] { - let (data, mut verifier) = fixture + let (received, mut verifier) = fixture .verifier( &fixture.commitment, &fixture.query, @@ -328,7 +393,7 @@ fn factored_inner_product_round_trip_succeeds_for_both_security_levels_and_bindi .unwrap(); fixture .pcs - .verify_lin(&data, &fixture.query, binding, &mut verifier) + .verify_lin(&received, &fixture.query, binding, &mut verifier) .unwrap(); verifier.check_eof().unwrap(); } @@ -408,12 +473,12 @@ fn factored_inner_product_requires_complete_transcript_consumption() { } else { proof.narg_string.push(0); } - let (data, mut verifier) = fixture + let (received, mut verifier) = fixture .verifier(&fixture.commitment, &fixture.query, &proof, binding) .unwrap(); fixture .pcs - .verify_lin(&data, &fixture.query, binding, &mut verifier) + .verify_lin(&received, &fixture.query, binding, &mut verifier) .unwrap(); assert!(verifier.check_eof().is_err()); } @@ -465,7 +530,6 @@ fn factored_inner_product_rejects_invalid_prover_inputs_before_sumcheck() { let mut short_witness = packed_witness.clone(); short_witness.pop(); - assert_eq!( pcs.prove_lin( &data, @@ -530,20 +594,25 @@ fn real_pcs_accepts_an_already_bound_statement() { let proof = prover.finish(); let mut verifier = build_verifier(SESSION, b"already-bound", &proof); - let data = pcs.receive_commitment(commitment, &mut verifier).unwrap(); + let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); bind_outer_statement(&mut verifier, &pcs, &commitment, &query); - pcs.verify_lin(&data, &query, StatementBinding::AlreadyBound, &mut verifier) - .unwrap(); + pcs.verify_lin( + &received, + &query, + StatementBinding::AlreadyBound, + &mut verifier, + ) + .unwrap(); verifier.check_eof().unwrap(); let mut mismatched_verifier = build_verifier(SESSION, b"already-bound", &proof); - let data = pcs + let received = pcs .receive_commitment(commitment, &mut mismatched_verifier) .unwrap(); bind_outer_statement(&mut mismatched_verifier, &pcs, &commitment, &query); assert!( pcs.verify_lin( - &data, + &received, &query, StatementBinding::Bind, &mut mismatched_verifier, @@ -557,7 +626,7 @@ fn real_pcs_rejects_point_length_mismatches() { let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); let packed_witness = vec![F128::ZERO; pcs.packed_len()]; let mut prover = build_prover(SESSION, b"wrong-prover-point"); - let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); + let (_, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); let short_query = OpeningQuery::Mle { point: vec![F128::from(2u64); M - 1], target: F128::from(0u64), @@ -578,15 +647,11 @@ fn real_pcs_rejects_point_length_mismatches() { point: vec![F128::from(2u64); M + 1], target: F128::from(0u64), }; - let mut initial = build_prover(SESSION, b"wrong-verifier-point"); - let packed = vec![F128::ZERO; pcs.packed_len()]; - pcs.commit(&packed, &mut initial).unwrap(); - let proof = initial.finish(); + let proof = Proof::default(); let mut verifier = build_verifier(SESSION, b"wrong-verifier-point", &proof); assert_eq!( - verify_opening( - &pcs, - &commitment, + pcs.verify_lin( + data.commitment(), &long_query, StatementBinding::Bind, &mut verifier, @@ -643,6 +708,27 @@ fn real_pcs_rejects_mismatched_prover_parameters() { ); } +#[test] +fn real_pcs_rejects_commitment_state_from_different_parameters() { + let fixture = fixture(); + for (m, security) in [(M - 1, SecurityLevel::Bits100), (M, SecurityLevel::Bits128)] { + let other = Pcs::new(&Shape::new(7, m - 7).unwrap(), security).unwrap(); + let query = OpeningQuery::Mle { + point: vec![F128::ZERO; m], + target: F128::ZERO, + }; + let mut verifier = build_verifier(SESSION, INSTANCE, &fixture.proof); + let commitment = fixture + .pcs + .receive_commitment(fixture.commitment, &mut verifier) + .unwrap(); + assert_eq!( + other.verify_lin(&commitment, &query, StatementBinding::Bind, &mut verifier,), + Err(VerifyError::VerificationFailed), + ); + } +} + #[test] fn real_pcs_prover_rejects_a_false_evaluation_without_consuming_prover_data() { let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); @@ -653,7 +739,6 @@ fn real_pcs_prover_rejects_a_false_evaluation_without_consuming_prover_data() { point: vec![F128::from(2u64); M], target: F128::from(1u64), }; - let codeword_len = data.codeword_len(); assert_eq!( @@ -684,7 +769,6 @@ fn real_pcs_rejects_an_opening_for_a_different_packed_witness() { target: singleton_target(&point, 0), point, }; - pcs.prove_lin( &data, different_witness, @@ -823,67 +907,3 @@ fn real_pcs_requires_complete_transcript_consumption() { .unwrap(); assert!(verifier.check_eof().is_err()); } - -#[test] -fn real_pcs_ood_round_batches_into_opening() { - let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); - let mut packed_witness = vec![F128::ZERO; pcs.packed_len()]; - packed_witness[SINGLETON / 128] = F128::new(0, 1 << (SINGLETON % 128 - 64)); - let point = vec![F128::from(2u64); M]; - let query = OpeningQuery::Mle { - target: singleton_target(&point, SINGLETON), - point, - }; - ood_round_trip(&pcs, packed_witness, query); -} - -fn ood_round_trip(pcs: &impl CommitScheme, packed_witness: Vec, query: OpeningQuery) { - let mut prover = build_prover(SESSION, b"ood-round-trip"); - let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); - pcs.prove_lin( - &data, - packed_witness, - &query, - StatementBinding::Bind, - &mut prover, - ) - .unwrap(); - let next_challenge = prover.verifier_message::(); - let proof = prover.finish(); - - let mut verifier = build_verifier(SESSION, b"ood-round-trip", &proof); - let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); - pcs.verify_lin(&received, &query, StatementBinding::Bind, &mut verifier) - .unwrap(); - assert_eq!(verifier.verifier_message::(), next_challenge); - verifier.check_eof().unwrap(); -} - -#[test] -fn real_pcs_ood_round_rejects_a_changed_evaluation() { - let pcs = Pcs::new(&shape(), SecurityLevel::Bits100).unwrap(); - let packed_witness = vec![F128::ZERO; pcs.packed_len()]; - let query = OpeningQuery::Mle { - point: vec![F128::from(2u64); M], - target: F128::ZERO, - }; - let mut prover = build_prover(SESSION, b"ood-tampering"); - let (commitment, data) = pcs.commit(&packed_witness, &mut prover).unwrap(); - pcs.prove_lin( - &data, - packed_witness, - &query, - StatementBinding::Bind, - &mut prover, - ) - .unwrap(); - let mut proof = prover.finish(); - proof.narg_string[0] ^= 1; - - let mut verifier = build_verifier(SESSION, b"ood-tampering", &proof); - let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); - assert!( - pcs.verify_lin(&received, &query, StatementBinding::Bind, &mut verifier,) - .is_err() - ); -} diff --git a/crates/post_gkr/src/lib.rs b/crates/post_gkr/src/lib.rs index 92827b74..5b540f97 100644 --- a/crates/post_gkr/src/lib.rs +++ b/crates/post_gkr/src/lib.rs @@ -295,6 +295,7 @@ fn advance(claim: F128, [a0, a2]: RoundMessage, challenge: F128) -> F128 { mod tests { use common::Shape; use poly::DenseMultilinearExtension; + use transcript::SecurityLevel::{Bits100, Bits128}; use transcript::{Proof, build_prover, build_verifier}; use super::*; @@ -357,11 +358,11 @@ mod tests { fn the_two_sides_agree_on_a_true_claim() { for (log_rows, log_columns, seed) in [(8, 2, 63), (10, 0, 64)] { let leaf = Leaf::random(log_rows, log_columns, seed); - let (sent, proof) = reduced(&leaf, SecurityLevel::Bits100); + let (sent, proof) = reduced(&leaf, Bits100); assert_eq!(proof.narg_string.len(), (2 * 10 + 1) * 16); assert!(proof.hints.is_empty()); - let received = verified(&leaf, &proof, SecurityLevel::Bits100).unwrap(); + let received = verified(&leaf, &proof, Bits100).unwrap(); assert_eq!(sent, received); let (point, target) = mle(&received); assert_eq!(point.len(), 10); @@ -385,13 +386,10 @@ mod tests { ) .unwrap(); let mut prover = build_prover("post_gkr-tests", "reduce"); - let sent = prove(&claim, &leaf.packed, SecurityLevel::Bits100, &mut prover).unwrap(); + let sent = prove(&claim, &leaf.packed, Bits100, &mut prover).unwrap(); let proof = prover.finish(); let mut verifier = build_verifier("post_gkr-tests", "reduce", &proof); - assert_eq!( - verify(&claim, SecurityLevel::Bits100, &mut verifier), - Ok(sent.clone()) - ); + assert_eq!(verify(&claim, Bits100, &mut verifier), Ok(sent.clone())); let (point, target) = mle(&sent); assert_eq!(leaf.evaluate(point), target); } @@ -414,12 +412,7 @@ mod tests { let mut generic = Pair::new(weights.clone(), written_out.clone()); let mut prover = build_prover("post_gkr-tests", "reduce"); - let (point, _) = sumcheck::prove( - &mut generic, - leaf.target, - SecurityLevel::Bits100, - &mut prover, - ); + let (point, _) = sumcheck::prove(&mut generic, leaf.target, Bits100, &mut prover); let proof = prover.finish(); let message = factors.first_message(&leaf.packed); assert_eq!( @@ -441,14 +434,14 @@ mod tests { #[test] fn a_reduction_altered_in_transit_is_caught() { let mut leaf = Leaf::random(7, 1, 66); - let (_, proof) = reduced(&leaf, SecurityLevel::Bits100); + let (_, proof) = reduced(&leaf, Bits100); let records = 2 * 8 + 1; assert_eq!(proof.narg_string.len(), records * 16); for record in 0..records { let mut altered = proof.clone(); altered.narg_string[record * 16] ^= 1; assert_eq!( - verified(&leaf, &altered, SecurityLevel::Bits100), + verified(&leaf, &altered, Bits100), Err(VerifyError::EvaluationMismatch), "record {record}" ); @@ -456,13 +449,13 @@ mod tests { let mut short = proof.clone(); short.narg_string.truncate(proof.narg_string.len() - 16); assert_eq!( - verified(&leaf, &short, SecurityLevel::Bits100), + verified(&leaf, &short, Bits100), Err(VerifyError::MalformedProof) ); leaf.target += F128::ONE; assert_eq!( - verified(&leaf, &proof, SecurityLevel::Bits100), + verified(&leaf, &proof, Bits100), Err(VerifyError::EvaluationMismatch) ); } @@ -471,17 +464,17 @@ mod tests { fn security_targets_cover_every_quadratic_round() { let leaf = Leaf::random(7, 1, 68); let rounds = 8; - for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + for security in [Bits100, Bits128] { let (sent, proof) = reduced(&leaf, security); - let nonce_bytes = usize::from(security == SecurityLevel::Bits128) * 8; + let nonce_bytes = usize::from(security == Bits128) * 8; assert_eq!(proof.narg_string.len(), rounds * (32 + nonce_bytes) + 16); assert_eq!(verified(&leaf, &proof, security), Ok(sent.clone())); let (point, target) = mle(&sent); assert_eq!(leaf.evaluate(point), target); let other = match security { - SecurityLevel::Bits100 => SecurityLevel::Bits128, - SecurityLevel::Bits128 => SecurityLevel::Bits100, + Bits100 => Bits128, + Bits128 => Bits100, }; assert!(verified(&leaf, &proof, other).is_err()); @@ -523,7 +516,7 @@ mod tests { &leaf.columns, leaf.target + random(&mut rng(57)), &leaf.packed, - SecurityLevel::Bits100, + Bits100, &mut transcript ), Err(ProveError::ClaimDoesNotHold) @@ -534,7 +527,7 @@ mod tests { &leaf.columns, leaf.target, &leaf.packed[1..], - SecurityLevel::Bits100, + Bits100, &mut transcript ), Err(ProveError::WitnessLengthMismatch) diff --git a/crates/post_gkr/src/sumcheck.rs b/crates/post_gkr/src/sumcheck.rs index 45c0e500..be53160c 100644 --- a/crates/post_gkr/src/sumcheck.rs +++ b/crates/post_gkr/src/sumcheck.rs @@ -219,6 +219,7 @@ mod tests { use poly::DenseMultilinearExtension; use poly::eq_table; use poly::f128::{evaluate, inner_product}; + use transcript::SecurityLevel::Bits100; use transcript::{build_prover, build_verifier}; use super::*; @@ -264,8 +265,7 @@ mod tests { let claim = inner_product(&weights, &values); let mut prover = build_prover("post_gkr-tests", "sumcheck"); - let (point, running) = - prove_rounds(&mut pair, 6, claim, SecurityLevel::Bits100, &mut prover); + let (point, running) = prove_rounds(&mut pair, 6, claim, Bits100, &mut prover); let evaluation = prove_evaluation(&pair, running, &mut prover); let proof = prover.finish(); assert_eq!(proof.narg_string.len(), (2 * 6 + 1) * 16); @@ -281,8 +281,7 @@ mod tests { assert_eq!(evaluate(&weights, &point), extension(&weights)); let mut verifier = build_verifier("post_gkr-tests", "sumcheck", &proof); - let (same_point, same_running) = - verify_rounds(6, claim, SecurityLevel::Bits100, &mut verifier).unwrap(); + let (same_point, same_running) = verify_rounds(6, claim, Bits100, &mut verifier).unwrap(); assert_eq!(same_point, point); assert_eq!(same_running, running); assert_eq!( @@ -294,8 +293,7 @@ mod tests { // The same records against a claim one off: the gap survives every // round and the closing check catches it. let mut verifier = build_verifier("post_gkr-tests", "sumcheck", &proof); - let (_, running) = - verify_rounds(6, claim + F128::ONE, SecurityLevel::Bits100, &mut verifier).unwrap(); + let (_, running) = verify_rounds(6, claim + F128::ONE, Bits100, &mut verifier).unwrap(); assert_eq!( verify_evaluation(evaluate(&weights, &point), running, &mut verifier), Err(VerifyError::EvaluationMismatch) @@ -324,12 +322,12 @@ mod tests { let mut pair = pair(3, &mut rng(10)); let claim = inner_product(&pair.weights, &pair.values); let mut prover = build_prover("post_gkr-tests", "sumcheck"); - prove_rounds(&mut pair, 3, claim, SecurityLevel::Bits100, &mut prover); + prove_rounds(&mut pair, 3, claim, Bits100, &mut prover); let mut proof = prover.finish(); proof.narg_string.truncate(proof.narg_string.len() - 16); let mut verifier = build_verifier("post_gkr-tests", "sumcheck", &proof); assert_eq!( - verify_rounds(3, claim, SecurityLevel::Bits100, &mut verifier).err(), + verify_rounds(3, claim, Bits100, &mut verifier).err(), Some(VerifyError::MalformedProof) ); } diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 138865b2..28de9c1d 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -15,7 +15,7 @@ use field::{F128, Fq, gf128::smallest_generator}; use pcs::{Pcs, ProverData}; use rand_chacha::ChaCha8Rng; use rand_core::{Rng, SeedableRng}; -use transcript::{Proof, ProverState, VerifierState, build_prover, build_verifier}; +use transcript::{Proof, ProverState, SecurityLevel, VerifierState, build_prover, build_verifier}; /// The specification's fixed modulus, `2^100 − 15`. Under it the fold bound /// admits every row width up to `t = 27`, so the reference split @@ -103,6 +103,11 @@ pub struct Instance { impl Instance { /// Commits [`HonestClaim::new`] under the 100-bit policy, with a setup per role. pub fn honest(shape: Shape, seed: u64) -> Self { + Self::with_security(shape, seed, SecurityLevel::Bits100) + } + + /// Builds and commits an honest instance at the requested security level. + pub fn with_security(shape: Shape, seed: u64, security: SecurityLevel) -> Self { let mut rng = ChaCha8Rng::seed_from_u64(seed); let HonestClaim { params, @@ -110,7 +115,7 @@ impl Instance { packed, } = HonestClaim::new(shape, &mut rng); - let pcs = Pcs::new(&shape, transcript::SecurityLevel::Bits100).unwrap(); + let pcs = Pcs::new(&shape, security).unwrap(); let mut transcript = prover_transcript(); let (com, data) = pcs.commit(&packed, &mut transcript).unwrap(); diff --git a/crates/tests/tests/fold.rs b/crates/tests/tests/fold.rs index 2e28a1dd..674307db 100644 --- a/crates/tests/tests/fold.rs +++ b/crates/tests/tests/fold.rs @@ -7,7 +7,7 @@ use prover::{BitZProver, SendError}; use tests::{ Instance, Q, WINDOW, narrow_shape, prover_transcript, verifier_transcript, wide_shape, }; -use transcript::{Proof, SecurityLevel}; +use transcript::{Proof, SecurityLevel::Bits100}; use verifier::{BitZVerifier, ReceiveError}; /// Runs an honest prover and returns the round it produced with its proof. @@ -15,12 +15,7 @@ fn prove(instance: &Instance) -> (common::Fold, Proof) { let mut transcript = prover_transcript(); let round = instance .prover - .send_fold( - &instance.claim, - &instance.table(), - &mut transcript, - SecurityLevel::Bits100, - ) + .send_fold(&instance.claim, &instance.table(), &mut transcript, Bits100) .unwrap(); (round, transcript.finish()) } @@ -44,7 +39,7 @@ fn the_two_sides_agree_on_the_test_shapes() { let mut transcript = verifier_transcript(&proof); let received = instance .verifier - .receive_fold(&instance.claim, &mut transcript, SecurityLevel::Bits100) + .receive_fold(&instance.claim, &mut transcript, Bits100) .expect("honest proof"); assert_eq!(sent, received, "t = {}", shape.log_rows()); @@ -116,7 +111,7 @@ fn a_fold_at_the_bound_is_accepted_and_one_past_it_is_not() { let mut transcript = prover_transcript(); let round = prover - .send_fold(&claim, &table, &mut transcript, SecurityLevel::Bits100) + .send_fold(&claim, &table, &mut transcript, Bits100) .unwrap(); assert!(round.folds.iter().all(|&value| value == fold)); @@ -124,14 +119,14 @@ fn a_fold_at_the_bound_is_accepted_and_one_past_it_is_not() { let mut transcript = verifier_transcript(&proof); assert!( verifier - .receive_fold(&claim, &mut transcript, SecurityLevel::Bits100) + .receive_fold(&claim, &mut transcript, Bits100) .is_ok() ); let over = forge(&vec![fold + 1; shape.columns()]); let mut transcript = verifier_transcript(&over); assert_eq!( - verifier.receive_fold(&claim, &mut transcript, SecurityLevel::Bits100), + verifier.receive_fold(&claim, &mut transcript, Bits100), Err(ReceiveError::FoldOutOfRange) ); } @@ -149,7 +144,7 @@ fn the_range_check_fires_before_the_reconstruction() { assert_eq!( instance .verifier - .receive_fold(&instance.claim, &mut transcript, SecurityLevel::Bits100), + .receive_fold(&instance.claim, &mut transcript, Bits100), Err(ReceiveError::FoldOutOfRange) ); // The same folds also fail the reconstruction, so the assertion above is @@ -171,7 +166,7 @@ fn folds_that_do_not_reconstruct_the_target_are_rejected() { assert_eq!( instance .verifier - .receive_fold(&retargeted, &mut transcript, SecurityLevel::Bits100), + .receive_fold(&retargeted, &mut transcript, Bits100), Err(ReceiveError::TargetMismatch) ); } @@ -183,12 +178,9 @@ fn a_witness_of_a_different_shape_is_refused_before_anything_is_written() { let mut transcript = prover_transcript(); assert_eq!( - instance.prover.send_fold( - &instance.claim, - &other.table(), - &mut transcript, - SecurityLevel::Bits100 - ), + instance + .prover + .send_fold(&instance.claim, &other.table(), &mut transcript, Bits100), Err(SendError::ShapeMismatch) ); assert!(transcript.finish().narg_string.is_empty()); @@ -205,7 +197,7 @@ fn a_truncated_proof_is_refused_rather_than_read_past() { assert_eq!( instance .verifier - .receive_fold(&instance.claim, &mut transcript, SecurityLevel::Bits100), + .receive_fold(&instance.claim, &mut transcript, Bits100), Err(ReceiveError::MalformedProof) ); } @@ -228,7 +220,7 @@ fn an_all_zero_witness_folds_to_zero_and_still_round_trips() { let mut transcript = prover_transcript(); let round = prover - .send_fold(&claim, &table, &mut transcript, SecurityLevel::Bits100) + .send_fold(&claim, &table, &mut transcript, Bits100) .unwrap(); assert!(round.folds.iter().all(|&fold| fold == 0)); assert!(round.images.iter().all(|&image| image == F128::ONE)); @@ -237,7 +229,7 @@ fn an_all_zero_witness_folds_to_zero_and_still_round_trips() { let mut transcript = verifier_transcript(&proof); assert_eq!( verifier - .receive_fold(&claim, &mut transcript, SecurityLevel::Bits100) + .receive_fold(&claim, &mut transcript, Bits100) .unwrap(), round ); diff --git a/crates/tests/tests/large.rs b/crates/tests/tests/large.rs index 12cc5ead..0e7e5335 100644 --- a/crates/tests/tests/large.rs +++ b/crates/tests/tests/large.rs @@ -14,7 +14,7 @@ use prover::BitZProver; use rand_chacha::ChaCha8Rng; use rand_core::SeedableRng; use tests::{HonestClaim, WINDOW, large_shape, prover_transcript, verifier_transcript}; -use transcript::SecurityLevel; +use transcript::SecurityLevel::Bits100; use verifier::{BitZVerifier, ReceiveError}; #[test] @@ -26,12 +26,7 @@ fn the_fold_round_trips_on_the_large_shape() { let mut transcript = prover_transcript(); let sent = prover - .send_fold( - &honest.claim, - &honest.table(), - &mut transcript, - SecurityLevel::Bits100, - ) + .send_fold(&honest.claim, &honest.table(), &mut transcript, Bits100) .unwrap(); let proof = transcript.finish(); assert_eq!(proof.narg_string.len(), 16 * shape.columns()); @@ -39,7 +34,7 @@ fn the_fold_round_trips_on_the_large_shape() { let mut transcript = verifier_transcript(&proof); let received = verifier - .receive_fold(&honest.claim, &mut transcript, SecurityLevel::Bits100) + .receive_fold(&honest.claim, &mut transcript, Bits100) .expect("honest proof"); assert_eq!(sent, received); assert_eq!(received.row_images.len(), shape.rows()); @@ -53,11 +48,7 @@ fn the_fold_round_trips_on_the_large_shape() { } let over = transcript.finish(); assert_eq!( - verifier.receive_fold( - &honest.claim, - &mut verifier_transcript(&over), - SecurityLevel::Bits100, - ), + verifier.receive_fold(&honest.claim, &mut verifier_transcript(&over), Bits100), Err(ReceiveError::FoldOutOfRange) ); @@ -70,11 +61,7 @@ fn the_fold_round_trips_on_the_large_shape() { ) .unwrap(); assert_eq!( - verifier.receive_fold( - &retargeted, - &mut verifier_transcript(&proof), - SecurityLevel::Bits100, - ), + verifier.receive_fold(&retargeted, &mut verifier_transcript(&proof), Bits100), Err(ReceiveError::TargetMismatch) ); } diff --git a/crates/tests/tests/prove.rs b/crates/tests/tests/prove.rs index 78a7e373..8d21a8ea 100644 --- a/crates/tests/tests/prove.rs +++ b/crates/tests/tests/prove.rs @@ -1,9 +1,9 @@ //! The top-level prove and verify, through the real opening. -use common::{OpeningQuery, Root, Shape, TableError}; +use common::{Root, Shape, TableError}; use field::{F128, Fq}; use num_traits::{ConstOne, ConstZero}; -use pcs::{CommitScheme, Pcs, StatementBinding, VerifyError as PcsVerifyError}; +use pcs::{Pcs, VerifyError as PcsVerifyError}; use prover::ProveError; use tests::{Instance, narrow_shape, prover_transcript, verifier_transcript, wide_shape}; use transcript::{Proof, SecurityLevel}; @@ -197,88 +197,25 @@ fn a_witness_of_the_wrong_length_is_refused_without_changing_the_commitment_tran } #[test] -fn explicit_security_targets_verify_and_reject_replay_or_tampering() { - let mut instance = Instance::honest(narrow_shape(), 40); - for level in [SecurityLevel::Bits100, SecurityLevel::Bits128] { - instance.pcs = Pcs::new(instance.params.shape(), level).unwrap(); - let mut transcript = prover_transcript(); - (instance.com, instance.data) = instance - .pcs - .commit(&instance.packed, &mut transcript) - .unwrap(); - instance.transcript = Some(transcript); - let proof = prove(&mut instance); - instance - .verify( - &instance.claim, - &instance.pcs, - instance.com, - verifier_transcript(&proof), - ) - .unwrap(); - - let other_level = match level { - SecurityLevel::Bits100 => SecurityLevel::Bits128, - SecurityLevel::Bits128 => SecurityLevel::Bits100, - }; - let other_pcs = Pcs::new(instance.params.shape(), other_level).unwrap(); - assert!( - instance - .verify( - &instance.claim, - &other_pcs, - instance.com, - verifier_transcript(&proof), - ) - .is_err() - ); - - let wrong_target = instance.with_target(instance.claim.target() + Fq::ONE); - assert_eq!( - instance.verify( - &wrong_target, - &instance.pcs, - instance.com, - verifier_transcript(&proof), - ), - Err(VerifyError::Fold(ReceiveError::TargetMismatch)) - ); +fn the_128_bit_profile_verifies_and_rejects_replay_or_tampering() { + let mut instance = Instance::with_security(narrow_shape(), 40, SecurityLevel::Bits128); + let mut proof = prove(&mut instance); + let verify = |claim, pcs, proof: &Proof| { + instance.verify(claim, pcs, instance.com, verifier_transcript(proof)) + }; + verify(&instance.claim, &instance.pcs, &proof).unwrap(); - if level == SecurityLevel::Bits128 { - // The first nonce follows the column folds. - let mut changed = proof.clone(); - changed.narg_string[16 * instance.params.shape().columns()] ^= 0xff; - assert!( - instance - .verify( - &instance.claim, - &instance.pcs, - instance.com, - verifier_transcript(&changed), - ) - .is_err() - ); - } + let other = Pcs::new(instance.params.shape(), SecurityLevel::Bits100).unwrap(); + assert!(verify(&instance.claim, &other, &proof).is_err()); + let retargeted = instance.with_target(instance.claim.target() + Fq::ONE); + assert_eq!( + verify(&retargeted, &instance.pcs, &proof), + Err(VerifyError::Fold(ReceiveError::TargetMismatch)) + ); - // Both policies use the same commitment geometry, but different opening configurations. - // Retained data must match the security target as well as the commitment shape. - let query = OpeningQuery::Mle { - point: vec![F128::ZERO; instance.params.shape().log_bits()], - target: F128::from(instance.packed[0].lo & 1), - }; - let mut transcript = prover_transcript(); - assert_eq!( - other_pcs.prove_lin( - &instance.data, - instance.packed.clone(), - &query, - StatementBinding::Bind, - &mut transcript, - ), - Err(pcs::ProveError::ProverDataMismatch) - ); - assert_eq!(transcript.finish(), Proof::default()); - } + // The first nonce follows the column folds. + proof.narg_string[16 * instance.params.shape().columns()] ^= 0xff; + assert!(verify(&instance.claim, &instance.pcs, &proof).is_err()); } #[test] diff --git a/crates/tests/tests/virtual_prove.rs b/crates/tests/tests/virtual_prove.rs index ccdf919c..b59f8a1d 100644 --- a/crates/tests/tests/virtual_prove.rs +++ b/crates/tests/tests/virtual_prove.rs @@ -321,23 +321,12 @@ fn virtual_witness_lengths_and_setup_must_match_the_statement() { Err(ProveError::ParameterMismatch) ); assert_eq!(transcript.finish(), Proof::default()); - let mut prefix = prover_transcript(); - let (root, _) = instance - .pcs - .commit(&instance.committed_bits, &mut prefix) - .unwrap(); - let prefix = prefix.finish(); - let mut verifier = verifier_transcript(&prefix); - let commitment = instance - .pcs - .receive_commitment(root, &mut verifier) - .unwrap(); assert_eq!( BitZVerifier::new(params, WINDOW).verify_virtual( &statement, &instance.pcs, - &commitment, - verifier, + instance.data.commitment(), + verifier_transcript(&Proof::default()), ), Err(VerifyError::ParameterMismatch) ); diff --git a/crates/transcript/src/pow.rs b/crates/transcript/src/pow.rs index 5e935c63..39ca231f 100644 --- a/crates/transcript/src/pow.rs +++ b/crates/transcript/src/pow.rs @@ -78,15 +78,8 @@ pub fn valid(seed: &[u8; 16], nonce: u64, bits: u32) -> bool { hasher.update(seed); hasher.update(&nonce.to_le_bytes()); let digest = hasher.finalize(); - let mut zeros = 0; - for byte in digest.as_bytes() { - let current = byte.leading_zeros(); - zeros += current; - if current != 8 { - break; - } - } - zeros >= bits + // The 32-bit cap makes the first four hash bytes sufficient. + u32::from_be_bytes(digest.as_bytes()[..4].try_into().unwrap()).leading_zeros() >= bits } #[cfg(test)] diff --git a/tooling/cli/README.md b/tooling/cli/README.md index cbcebd39..1c285468 100644 --- a/tooling/cli/README.md +++ b/tooling/cli/README.md @@ -10,7 +10,7 @@ Select the PCS round budget with `--pcs-security-bits 100` or `128`; the default The implementation derives all internal parameters from the padded witness size. The `100` target uses list decoding and an initial out-of-domain (OOD) check. The `128` target uses unique decoding and omits that check. -Spartan still uses `Q100`; selecting `128` does not include spartan yet +Spartan still uses `Q100`; this option does not set Spartan security. `--circuit` selects one of these compiled-in adapters: @@ -25,11 +25,6 @@ Every proof checks R1CS constraints modulo Q100. SHA-256 constraint residuals ar The output reports the selected opening path, constraint and witness sizes, and setup/witness/commit/prove/verify timings. `total_prove_ms` includes commitment and proving only. Input generation and thread-pool initialization are excluded. -Commitment timing includes the initial OOD check when the selected target requires it. -`CircuitProofSystem::commit` retains the transcript and commitment data together. -`CircuitProofSystem::prove` consumes that state and continues the same transcript. -Verification receives the commitment before public-input binding, constant checks, or Spartan challenges. - Run the four circuits through end-to-end and individual setup, witness, commitment, proving, and verification benchmarks: ```sh diff --git a/tooling/cli/src/end_to_end.rs b/tooling/cli/src/end_to_end.rs index 12d0afc3..d326e49d 100644 --- a/tooling/cli/src/end_to_end.rs +++ b/tooling/cli/src/end_to_end.rs @@ -228,11 +228,11 @@ impl CircuitProofSystem { /// Continues the commitment transcript through Spartan and the BitZ opening. #[tracing::instrument(name = "prove", skip_all, fields(opening_path = ?self.opening_path))] - pub fn prove(&self, witness: Witness, committed: CommittedWitness) -> Result { + pub fn prove(&self, witness: Witness, commitment: CommittedWitness) -> Result { let CommittedWitness { data, mut transcript, - } = committed; + } = commitment; let root = data.root(); self.bind(&mut transcript, root); if self.opening_path == OpeningPath::Direct { diff --git a/tooling/cli/tests/end_to_end.rs b/tooling/cli/tests/end_to_end.rs index c6c3b0c6..68d68705 100644 --- a/tooling/cli/tests/end_to_end.rs +++ b/tooling/cli/tests/end_to_end.rs @@ -144,9 +144,16 @@ fn nonidentity_map_uses_virtual_opening_and_checks_xor_relation() { let mut changed = proof; changed.opening.narg_string.push(0); assert!(system.verify(&changed).is_err()); - let timings = - bitz_cli::benchmark::run(PublicXor, &[true, false], SecurityLevel::Bits100).unwrap(); - assert_eq!(timings.circuit.opening_path, OpeningPath::Virtual); + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + let timings = bitz_cli::benchmark::run(PublicXor, &[true, false], security).unwrap(); + assert_eq!(timings.circuit.opening_path, OpeningPath::Virtual); + assert_eq!(timings.circuit.pcs_security, security); + assert!( + timings + .to_string() + .contains(&format!("pcs_round_target_bits={}", security.bits())) + ); + } } #[test] @@ -171,12 +178,3 @@ fn explicit_128_budget_binds_the_policy_and_public_statement() { .is_err() ); } - -#[test] -fn explicit_128_budget_supports_virtual_openings() { - let timings = - bitz_cli::benchmark::run(PublicXor, &[true, false], SecurityLevel::Bits128).unwrap(); - assert_eq!(timings.circuit.opening_path, OpeningPath::Virtual); - assert_eq!(timings.circuit.pcs_security, SecurityLevel::Bits128); - assert!(timings.to_string().contains("pcs_round_target_bits=128")); -} From 1ac32870c3a49e2b4a32173a9df5c52e87ca2f94 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 09:56:18 +0200 Subject: [PATCH 08/16] Move security policy from transcript to common --- Cargo.lock | 1 + crates/common/src/lib.rs | 2 ++ crates/common/src/security.rs | 47 ++++++++++++++++++++++++++++ crates/gkr/Cargo.toml | 1 + crates/gkr/src/lib.rs | 5 +-- crates/pcs/src/commitment.rs | 4 +-- crates/pcs/src/lib.rs | 3 +- crates/pcs/src/opening.rs | 4 +-- crates/post_gkr/src/lib.rs | 6 ++-- crates/post_gkr/src/sumcheck.rs | 5 +-- crates/prover/benches/prover.rs | 2 +- crates/prover/examples/profile.rs | 2 +- crates/prover/src/fold.rs | 6 ++-- crates/prover/src/prove.rs | 6 ++-- crates/prover/src/reduce.rs | 6 ++-- crates/tests/examples/dump_commit.rs | 2 +- crates/tests/src/lib.rs | 4 +-- crates/tests/tests/fold.rs | 4 +-- crates/tests/tests/large.rs | 3 +- crates/tests/tests/prove.rs | 4 +-- crates/tests/tests/virtual_prove.rs | 6 ++-- crates/transcript/src/lib.rs | 1 - crates/transcript/src/pow.rs | 41 ------------------------ crates/verifier/src/fold.rs | 4 +-- crates/verifier/src/reduce.rs | 4 +-- crates/verifier/src/verify.rs | 6 ++-- 26 files changed, 97 insertions(+), 82 deletions(-) create mode 100644 crates/common/src/security.rs diff --git a/Cargo.lock b/Cargo.lock index 24810fe2..4c8a39b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,6 +754,7 @@ dependencies = [ name = "gkr" version = "0.1.0" dependencies = [ + "common", "field", "num-traits", "poly", diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 1897a00b..e6105294 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -8,6 +8,7 @@ pub mod claim; pub mod fold; pub mod opening; pub mod params; +pub mod security; pub mod shape; pub mod table; pub mod virtual_map; @@ -18,6 +19,7 @@ pub use fold::{ }; pub use opening::OpeningQuery; pub use params::{BitZParams, ParamsError, VirtualParams, VirtualParamsError}; +pub use security::SecurityLevel; pub use shape::{Shape, ShapeError}; pub use table::{BitTable, TableError, TransposeError, TransposedBitTable}; pub use virtual_map::{ diff --git a/crates/common/src/security.rs b/crates/common/src/security.rs new file mode 100644 index 00000000..eb0217b5 --- /dev/null +++ b/crates/common/src/security.rs @@ -0,0 +1,47 @@ +//! Shared protocol security targets and grinding requirements. + +/// The target for each classical PCS challenge block over `F128`. +/// +/// This target does not certify the complete protocol or quantum security. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SecurityLevel { + Bits100, + Bits128, +} + +impl SecurityLevel { + /// Returns the classical security target in bits. + pub const fn bits(self) -> u32 { + match self { + Self::Bits100 => 100, + Self::Bits128 => 128, + } + } + + /// Returns `ceil(log2(coefficient)) + target - 128`, bounded below by zero. + /// + /// This covers a challenge block with error at most `coefficient / 2^128`. + /// A zero coefficient needs no grinding. + pub const fn grinding_bits(self, coefficient: usize) -> u32 { + if coefficient == 0 { + return 0; + } + let log_coefficient = usize::BITS - (coefficient - 1).leading_zeros(); + (self.bits() + log_coefficient).saturating_sub(128) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grinding_covers_the_integer_error_coefficient() { + for (coefficient, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (7, 3), (8, 3), (15, 4)] { + assert_eq!(SecurityLevel::Bits128.grinding_bits(coefficient), expected); + assert_eq!(SecurityLevel::Bits100.grinding_bits(coefficient), 0); + } + assert_eq!(SecurityLevel::Bits100.grinding_bits(1 << 28), 0); + assert_eq!(SecurityLevel::Bits100.grinding_bits((1 << 28) + 1), 1); + } +} diff --git a/crates/gkr/Cargo.toml b/crates/gkr/Cargo.toml index 7781751f..bc0be426 100644 --- a/crates/gkr/Cargo.toml +++ b/crates/gkr/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true [dependencies] +common = { workspace = true } field = { path = "../field", features = ["spongefish"] } transcript = {path ="../transcript"} tracing = { workspace = true } diff --git a/crates/gkr/src/lib.rs b/crates/gkr/src/lib.rs index fa860e4a..c486919a 100644 --- a/crates/gkr/src/lib.rs +++ b/crates/gkr/src/lib.rs @@ -1,9 +1,10 @@ use std::collections::VecDeque; +use common::SecurityLevel; use field::{F128, Wide256}; use num_traits::{ConstOne, ConstZero}; use rayon::prelude::*; -use transcript::{ProverState, SecurityLevel, VerifierState}; +use transcript::{ProverState, VerifierState}; pub type Field = F128; @@ -407,8 +408,8 @@ impl IntoIterator for LayerWitnesses { #[cfg(test)] mod tests { use super::*; + use common::SecurityLevel::{Bits100, Bits128}; use proptest::prelude::*; - use transcript::SecurityLevel::{Bits100, Bits128}; fn field() -> impl Strategy { any::().prop_map(Field::from) diff --git a/crates/pcs/src/commitment.rs b/crates/pcs/src/commitment.rs index 994fe61b..7ea24e9d 100644 --- a/crates/pcs/src/commitment.rs +++ b/crates/pcs/src/commitment.rs @@ -11,12 +11,12 @@ use crate::bridge::as_flock_f128s; use crate::ligerito::CheckedLigerito; use crate::ood::{self, OodClaim}; use crate::profiles::security_config; -use common::{Root, Shape}; +use common::{Root, SecurityLevel, Shape}; use field::F128; use flock_core::hash::HashKind; use flock_core::pcs::ligerito::LigeritoProfile; use flock_core::pcs::{LOG_PACKING, PcsParams, ProverData as FlockProverData, commit}; -use transcript::{Encoding, ProverState, PublicTranscript, SecurityLevel, VerifierState}; +use transcript::{Encoding, ProverState, PublicTranscript, VerifierState}; // Increment this version when parameter derivation or transcript rules change. // This includes protocol changes in Flock or the selected hash. diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index f6b9b474..0c2a533b 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -114,9 +114,8 @@ use opening::{prove, verify}; use transcript::{ProverState, VerifierState}; pub use commitment::{CommitError, Commitment, ConfigError, Pcs, ProverData}; -pub use common::{OpeningQuery, Root}; +pub use common::{OpeningQuery, Root, SecurityLevel}; pub use opening::{ProveError, VerifyError}; -pub use transcript::SecurityLevel; /// Controls statement binding for one opening. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index a21e8060..90929faf 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -1,6 +1,6 @@ //! Transcript orchestration for MLE openings and inner-product sumcheck reduction. -use common::LinearClaim; +use common::{LinearClaim, SecurityLevel}; use field::F128; use flock_core::field::F128 as FlockF128; use flock_core::pcs::pack::PACKING_WIDTH as CLAIM_COUNT; @@ -8,7 +8,7 @@ use post_gkr::{ ProveError as PostGkrProveError, VerifyError as PostGkrVerifyError, prove as prove_post_gkr, verify as verify_post_gkr, }; -use transcript::{ProverState, PublicTranscript, SecurityLevel, VerifierState}; +use transcript::{ProverState, PublicTranscript, VerifierState}; use crate::bridge::{as_flock_f128, as_flock_f128s, from_flock_f128}; use crate::ligerito::{self, ReducedProver, validate_prover_data}; diff --git a/crates/post_gkr/src/lib.rs b/crates/post_gkr/src/lib.rs index 5b540f97..81a81a17 100644 --- a/crates/post_gkr/src/lib.rs +++ b/crates/post_gkr/src/lib.rs @@ -37,14 +37,14 @@ mod test_util; use crate::sumcheck::{Pair, RoundMessage}; use common::shape::PACK_BITS; -use common::{LinearClaim, OpeningQuery}; +use common::{LinearClaim, OpeningQuery, SecurityLevel}; use field::F128; use num_traits::{ConstOne, ConstZero}; #[cfg(feature = "parallel")] use poly::parallel::workload_size; #[cfg(feature = "parallel")] use rayon::prelude::*; -use transcript::{ProverState, SecurityLevel, VerifierState}; +use transcript::{ProverState, VerifierState}; /// A reduction the prover cannot run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -293,9 +293,9 @@ fn advance(claim: F128, [a0, a2]: RoundMessage, challenge: F128) -> F128 { #[cfg(test)] mod tests { + use common::SecurityLevel::{Bits100, Bits128}; use common::Shape; use poly::DenseMultilinearExtension; - use transcript::SecurityLevel::{Bits100, Bits128}; use transcript::{Proof, build_prover, build_verifier}; use super::*; diff --git a/crates/post_gkr/src/sumcheck.rs b/crates/post_gkr/src/sumcheck.rs index be53160c..9b60b807 100644 --- a/crates/post_gkr/src/sumcheck.rs +++ b/crates/post_gkr/src/sumcheck.rs @@ -18,12 +18,13 @@ //! The opening authenticates the closing evaluation, including when its weight is zero. use crate::{GRINDING_LABEL, VerifyError}; +use common::SecurityLevel; use field::{F128, Wide256}; #[cfg(feature = "parallel")] use poly::parallel::workload_size; #[cfg(feature = "parallel")] use rayon::prelude::*; -use transcript::{ProverState, SecurityLevel, VerifierState}; +use transcript::{ProverState, VerifierState}; /// `(a_0, a_2)` of `p(X) = a_0 + a_1 X + a_2 X^2`; `a_1` the running claim /// implies. @@ -215,11 +216,11 @@ fn coefficients_serial(weights: &[F128], values: &[F128]) -> (Wide256, Wide256) #[cfg(test)] mod tests { + use common::SecurityLevel::Bits100; use num_traits::{ConstOne, ConstZero}; use poly::DenseMultilinearExtension; use poly::eq_table; use poly::f128::{evaluate, inner_product}; - use transcript::SecurityLevel::Bits100; use transcript::{build_prover, build_verifier}; use super::*; diff --git a/crates/prover/benches/prover.rs b/crates/prover/benches/prover.rs index 1d82bfe0..26c8745a 100644 --- a/crates/prover/benches/prover.rs +++ b/crates/prover/benches/prover.rs @@ -55,7 +55,7 @@ fn gkr(bencher: Bencher) { &mut transcript, black_box(&fold), black_box(&table), - transcript::SecurityLevel::Bits100, + common::SecurityLevel::Bits100, ) .expect("benchmark fold matches the table shape"), ) diff --git a/crates/prover/examples/profile.rs b/crates/prover/examples/profile.rs index 706698be..5b02dd23 100644 --- a/crates/prover/examples/profile.rs +++ b/crates/prover/examples/profile.rs @@ -55,7 +55,7 @@ fn gkr_wrapper(mut transcript: ProverState, fold: &Fold, table: BitTable<'_>) { &mut transcript, black_box(fold), black_box(&table), - transcript::SecurityLevel::Bits100, + common::SecurityLevel::Bits100, ) .expect("profiling fold matches the table shape"), ); diff --git a/crates/prover/src/fold.rs b/crates/prover/src/fold.rs index 1da71f50..3934e690 100644 --- a/crates/prover/src/fold.rs +++ b/crates/prover/src/fold.rs @@ -1,9 +1,11 @@ //! The fold round: send the column folds, then take the challenge. -use common::{BitTable, Fold, FoldError, LinearClaim, column_images, fold_columns, row_images}; +use common::{ + BitTable, Fold, FoldError, LinearClaim, SecurityLevel, column_images, fold_columns, row_images, +}; use crate::BitZProver; -use transcript::{ProverState, SecurityLevel}; +use transcript::ProverState; /// A fold the prover cannot produce. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/prover/src/prove.rs b/crates/prover/src/prove.rs index ca40ddd2..e59e760b 100644 --- a/crates/prover/src/prove.rs +++ b/crates/prover/src/prove.rs @@ -1,12 +1,12 @@ //! `ProveBitZ`. use common::{ - BitTable, ClaimError, LinearClaim, OpeningQuery, TableError, VirtualMap, VirtualMapError, - VirtualStatement, + BitTable, ClaimError, LinearClaim, OpeningQuery, SecurityLevel, TableError, VirtualMap, + VirtualMapError, VirtualStatement, }; use field::{F128, Fq}; use pcs::{CommitScheme, Pcs, ProveError as OpeningProveError, ProverData, StatementBinding}; -use transcript::{ProverState, SecurityLevel}; +use transcript::ProverState; use crate::{BitZProver, SendError, reduce::gkr_reduce}; diff --git a/crates/prover/src/reduce.rs b/crates/prover/src/reduce.rs index c8ed7382..e2d9d13f 100644 --- a/crates/prover/src/reduce.rs +++ b/crates/prover/src/reduce.rs @@ -5,12 +5,14 @@ //! `sum(row, column) u1[row] * u2[column] * table.bit(column, row)`. //! The caller must discharge this claim through the commitment opening. -use common::{BitTable, ClaimError, Fold, LinearClaim, OpeningQuery, TransposeError}; +use common::{ + BitTable, ClaimError, Fold, LinearClaim, OpeningQuery, SecurityLevel, TransposeError, +}; use field::F128; use gkr::{GrandProductCircuit, gpgkr_prove}; use num_traits::ConstOne; use poly::eq_table; -use transcript::{ProverState, SecurityLevel}; +use transcript::ProverState; #[inline(never)] #[tracing::instrument(name = "Build grand-product circuit", level = "debug", skip_all)] diff --git a/crates/tests/examples/dump_commit.rs b/crates/tests/examples/dump_commit.rs index 3ee69d31..76b3e3cb 100644 --- a/crates/tests/examples/dump_commit.rs +++ b/crates/tests/examples/dump_commit.rs @@ -39,7 +39,7 @@ fn main() -> Result<(), Box> { let shape = Shape::new(t, s).map_err(|error| format!("invalid shape: {error:?}"))?; let mut rng = ChaCha8Rng::seed_from_u64(seed); let packed = packed_witness(shape, &mut rng); - let pcs = Pcs::new(&shape, transcript::SecurityLevel::Bits100) + let pcs = Pcs::new(&shape, common::SecurityLevel::Bits100) .map_err(|error| format!("PCS configuration failed: {error:?}"))?; let (root, _data) = pcs .commit(&packed, &mut prover_transcript()) diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 28de9c1d..9ea559c0 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -9,13 +9,13 @@ //! The fixtures live here rather than under `tests/` so they compile once //! rather than once per test binary. -use common::{BitTable, BitZParams, LinearClaim, Root, Shape}; +use common::{BitTable, BitZParams, LinearClaim, Root, SecurityLevel, Shape}; use crypto_primitives::LiftElement; use field::{F128, Fq, gf128::smallest_generator}; use pcs::{Pcs, ProverData}; use rand_chacha::ChaCha8Rng; use rand_core::{Rng, SeedableRng}; -use transcript::{Proof, ProverState, SecurityLevel, VerifierState, build_prover, build_verifier}; +use transcript::{Proof, ProverState, VerifierState, build_prover, build_verifier}; /// The specification's fixed modulus, `2^100 − 15`. Under it the fold bound /// admits every row width up to `t = 27`, so the reference split diff --git a/crates/tests/tests/fold.rs b/crates/tests/tests/fold.rs index 674307db..5d786c05 100644 --- a/crates/tests/tests/fold.rs +++ b/crates/tests/tests/fold.rs @@ -1,13 +1,13 @@ //! The fold round, prover against verifier. -use common::{BitZParams, FoldError, LinearClaim}; +use common::{BitZParams, FoldError, LinearClaim, SecurityLevel::Bits100}; use field::{F128, Fq, gf128::smallest_generator}; use num_traits::{ConstOne, ConstZero}; use prover::{BitZProver, SendError}; use tests::{ Instance, Q, WINDOW, narrow_shape, prover_transcript, verifier_transcript, wide_shape, }; -use transcript::{Proof, SecurityLevel::Bits100}; +use transcript::Proof; use verifier::{BitZVerifier, ReceiveError}; /// Runs an honest prover and returns the round it produced with its proof. diff --git a/crates/tests/tests/large.rs b/crates/tests/tests/large.rs index 0e7e5335..8f814dd6 100644 --- a/crates/tests/tests/large.rs +++ b/crates/tests/tests/large.rs @@ -7,14 +7,13 @@ //! //! This test is designed to test what's possible in under a minute. -use common::LinearClaim; +use common::{LinearClaim, SecurityLevel::Bits100}; use field::Fq; use num_traits::ConstOne; use prover::BitZProver; use rand_chacha::ChaCha8Rng; use rand_core::SeedableRng; use tests::{HonestClaim, WINDOW, large_shape, prover_transcript, verifier_transcript}; -use transcript::SecurityLevel::Bits100; use verifier::{BitZVerifier, ReceiveError}; #[test] diff --git a/crates/tests/tests/prove.rs b/crates/tests/tests/prove.rs index 8d21a8ea..36e0a831 100644 --- a/crates/tests/tests/prove.rs +++ b/crates/tests/tests/prove.rs @@ -1,12 +1,12 @@ //! The top-level prove and verify, through the real opening. -use common::{Root, Shape, TableError}; +use common::{Root, SecurityLevel, Shape, TableError}; use field::{F128, Fq}; use num_traits::{ConstOne, ConstZero}; use pcs::{Pcs, VerifyError as PcsVerifyError}; use prover::ProveError; use tests::{Instance, narrow_shape, prover_transcript, verifier_transcript, wide_shape}; -use transcript::{Proof, SecurityLevel}; +use transcript::Proof; use verifier::{ReceiveError, VerifyError}; fn prove(instance: &mut Instance) -> Proof { diff --git a/crates/tests/tests/virtual_prove.rs b/crates/tests/tests/virtual_prove.rs index b59f8a1d..f6afbdcb 100644 --- a/crates/tests/tests/virtual_prove.rs +++ b/crates/tests/tests/virtual_prove.rs @@ -6,7 +6,7 @@ use circuit::{ witgen::{PackedWitness, Witgen}, }; use common::{ - BitZParams, LinearClaim, Root, Shape, TableError, TransposedWeights, VirtualMap, + BitZParams, LinearClaim, Root, SecurityLevel, Shape, TableError, TransposedWeights, VirtualMap, VirtualMapError, VirtualStatement, }; use field::{F128, Fq, gf128::smallest_generator}; @@ -14,7 +14,7 @@ use num_traits::{ConstOne, ConstZero}; use pcs::{Pcs, ProverData}; use prover::{BitZProver, ProveError, VirtualWitness}; use tests::{Q, WINDOW, prover_transcript, verifier_transcript}; -use transcript::{Proof, ProverState, SecurityLevel}; +use transcript::{Proof, ProverState}; use verifier::{BitZVerifier, VerifyError}; /// `h[0] = 1`, `h[1] = f[0]`, `h[128] = f[1]`, `h[129] = f[0] XOR f[1]`. @@ -406,7 +406,7 @@ fn sha256_virtual_inner_product_opens_the_committed_bits() { .sum(); let claim = LinearClaim::new(¶ms, rows, columns, target).unwrap(); let statement = VirtualStatement::new(params, committed_shape, &map, &claim).unwrap(); - let pcs = Pcs::new(&committed_shape, transcript::SecurityLevel::Bits100).unwrap(); + let pcs = Pcs::new(&committed_shape, common::SecurityLevel::Bits100).unwrap(); let mut transcript = prover_transcript(); let (root, data) = pcs.commit(&committed_bits, &mut transcript).unwrap(); BitZProver::new(params, WINDOW) diff --git a/crates/transcript/src/lib.rs b/crates/transcript/src/lib.rs index 88ed1b1b..72c639f1 100644 --- a/crates/transcript/src/lib.rs +++ b/crates/transcript/src/lib.rs @@ -36,7 +36,6 @@ mod verifier; pub use challenge::TranscriptChallenge; pub use domain::{PROTOCOL_LABEL, build_prover, build_verifier}; -pub use pow::SecurityLevel; pub use proof::Proof; pub use prover::ProverState; pub use spongefish::{ diff --git a/crates/transcript/src/pow.rs b/crates/transcript/src/pow.rs index 39ca231f..5142db76 100644 --- a/crates/transcript/src/pow.rs +++ b/crates/transcript/src/pow.rs @@ -8,37 +8,6 @@ const POW_TRANSCRIPT_TAG: &[u8] = b"bitz-transcript-pow-v1"; /// The largest supported grinding difficulty. pub const MAX_GRINDING_BITS: u32 = 32; -/// The target for each classical PCS challenge block over `F128`. -/// -/// This target does not certify the complete protocol or quantum security. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SecurityLevel { - Bits100, - Bits128, -} - -impl SecurityLevel { - /// Returns the classical security target in bits. - pub const fn bits(self) -> u32 { - match self { - Self::Bits100 => 100, - Self::Bits128 => 128, - } - } - - /// Returns `ceil(log2(coefficient)) + target - 128`, bounded below by zero. - /// - /// This covers a challenge block with error at most `coefficient / 2^128`. - /// A zero coefficient needs no grinding. - pub const fn grinding_bits(self, coefficient: usize) -> u32 { - if coefficient == 0 { - return 0; - } - let log_coefficient = usize::BITS - (coefficient - 1).leading_zeros(); - (self.bits() + log_coefficient).saturating_sub(128) - } -} - pub(crate) fn absorb_header(transcript: &mut impl PublicTranscript, label: &[u8], bits: u32) { transcript.public_message(POW_TRANSCRIPT_TAG); transcript.public_message(&(label.len() as u64)); @@ -92,16 +61,6 @@ mod tests { const INSTANCE: &[u8] = b"instance"; const LABEL: &[u8] = b"test/cubic/v1"; - #[test] - fn grinding_covers_the_integer_error_coefficient() { - for (coefficient, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (7, 3), (8, 3), (15, 4)] { - assert_eq!(SecurityLevel::Bits128.grinding_bits(coefficient), expected); - assert_eq!(SecurityLevel::Bits100.grinding_bits(coefficient), 0); - } - assert_eq!(SecurityLevel::Bits100.grinding_bits(1 << 28), 0); - assert_eq!(SecurityLevel::Bits100.grinding_bits((1 << 28) + 1), 1); - } - #[test] fn zero_grinding_leaves_the_transcript_unchanged() { let mut unmodified = build_prover(SESSION, INSTANCE); diff --git a/crates/verifier/src/fold.rs b/crates/verifier/src/fold.rs index 19b6563a..a3e2ae48 100644 --- a/crates/verifier/src/fold.rs +++ b/crates/verifier/src/fold.rs @@ -1,10 +1,10 @@ //! The fold round: read the column folds, check them, then take the //! challenge. -use common::{Fold, FoldError, LinearClaim, column_images, reconstruct, row_images}; +use common::{Fold, FoldError, LinearClaim, SecurityLevel, column_images, reconstruct, row_images}; use crate::BitZVerifier; -use transcript::{SecurityLevel, VerifierState}; +use transcript::VerifierState; /// A fold the verifier rejects. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/verifier/src/reduce.rs b/crates/verifier/src/reduce.rs index fa62d8dd..d7081ef7 100644 --- a/crates/verifier/src/reduce.rs +++ b/crates/verifier/src/reduce.rs @@ -7,10 +7,10 @@ //! column coordinates first; `alfa_b` contains the remaining row coordinates. //! The caller must verify the returned claim against the commitment. -use common::{ClaimError, Fold, LinearClaim, OpeningQuery, Shape}; +use common::{ClaimError, Fold, LinearClaim, OpeningQuery, SecurityLevel, Shape}; use field::F128; use num_traits::ConstOne; -use transcript::{SecurityLevel, VerifierState}; +use transcript::VerifierState; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReduceError { diff --git a/crates/verifier/src/verify.rs b/crates/verifier/src/verify.rs index a53b7166..0e944f15 100644 --- a/crates/verifier/src/verify.rs +++ b/crates/verifier/src/verify.rs @@ -1,9 +1,11 @@ //! `VerifyBitZ`. -use common::{LinearClaim, OpeningQuery, VirtualMap, VirtualMapError, VirtualStatement}; +use common::{ + LinearClaim, OpeningQuery, SecurityLevel, VirtualMap, VirtualMapError, VirtualStatement, +}; use field::Fq; use pcs::{CommitScheme, Commitment, Pcs, StatementBinding, VerifyError as OpeningVerifyError}; -use transcript::{SecurityLevel, VerifierState}; +use transcript::VerifierState; use crate::{BitZVerifier, ReceiveError, ReduceError, reduce::gkr_reduce}; From fcbe1daff70e1c06f587aaf2e19b32027812571b Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 11:11:52 +0200 Subject: [PATCH 09/16] Simplify PCS security profile calculation flow --- crates/pcs/src/profiles.rs | 512 +++++++++++++++++++++++++++--- crates/pcs/src/profiles/bounds.rs | 254 --------------- crates/pcs/src/profiles/tests.rs | 171 ---------- 3 files changed, 474 insertions(+), 463 deletions(-) delete mode 100644 crates/pcs/src/profiles/bounds.rs delete mode 100644 crates/pcs/src/profiles/tests.rs diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index 5161631e..1b4e0c41 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -1,4 +1,18 @@ -//! Derives both PCS security profiles from the padded witness size. +//! Calculates PCS parameters from the padded witness size and security target. +//! +//! Input `m` is log2(padded witness bits). Packing gives `2^(m - 7)` field elements. +//! We use GF(2^128), 128 witness bits per element, and BLAKE3 for Merkle commitments and PoW. +//! +//! Each level groups binary folds. The first level uses four folds; later levels use up to three. +//! Each fold halves the message size. We stop at five variables: 32 field elements. +//! Successive levels use code rates 1/2, 1/4, 1/8, and so on. +//! +//! The 100-bit profile uses Johnson list decoding, OOD checks, and no query grinding. +//! The 128-bit profile uses unique decoding, no OOD checks, and query grinding. +//! Both profiles calculate fold grinding from the level size. +//! +//! These targets concern classical challenge blocks. They assume uniform transcript challenges and classical PoW costs. +//! Sources: BitZ Remark A.2 and Lemma B.2; Flock Appendix C.3; BCHKS25 Corollary 1.4. use flock_core::pcs::LOG_PACKING; use flock_core::pcs::ligerito::{ @@ -7,15 +21,17 @@ use flock_core::pcs::ligerito::{ use crate::{ConfigError, SecurityLevel}; -mod bounds; - const INITIAL_K: usize = 4; const RECURSIVE_K: usize = 3; const FINAL_LOG_N: usize = 5; const JOHNSON_ETA: f64 = 0.02; +const MAX_GRINDING_BITS: usize = transcript::pow::MAX_GRINDING_BITS as usize; -/// Builds Johnson100 with OOD, or UDR128 without OOD. -/// Returns the Flock parameters and any initial OOD grinding requirement. +/// Derives every level from `m` and the selected target. +/// +/// Per level: choose folds, set the shape, calculate queries and grinding, then build the backend configuration. +/// Only diagnostic fields start as placeholders. Backend methods fill them before we store the level. +/// The separate initial OOD result uses `None` for no check and `Some(0)` for a check without grinding. pub(crate) fn security_config( m: usize, security_level: SecurityLevel, @@ -23,68 +39,488 @@ pub(crate) fn security_config( if !(20..=35).contains(&m) { return Err(ConfigError::Invalid("unsupported PCS size")); } - let target = security_level.bits() as usize; let johnson = security_level == SecurityLevel::Bits100; let log_n = m - LOG_PACKING; let mut remaining = log_n; let mut levels = Vec::new(); let mut initial_ood = None; + while remaining > FINAL_LOG_N { + // 1. Choose folds. Leave five variables for the explicit final message. let first = levels.is_empty(); - let k = if first { + let folds = if first { INITIAL_K } else { RECURSIVE_K.min(remaining - FINAL_LOG_N) }; - remaining -= k; + remaining -= folds; + + // 2. Set the shape: 2^folds rows, 2^remaining message columns, and rate 2^-log_inv_rate. + let log_inv_rate = levels.len() + 1; + + // 3. Calculate all query and grinding parameters before creating the backend configuration. + let parameters = match security_level { + SecurityLevel::Bits100 => parameters_100(remaining, log_inv_rate, folds)?, + SecurityLevel::Bits128 => parameters_128(remaining, log_inv_rate)?, + }; + if first { + initial_ood = parameters.initial_ood_grinding; + } + + // 4. Combine the shape, fixed decoding policy, and calculated parameters. let mut level = LigeritoLevelConfig { - log_inv_rate: levels.len() + 1, + log_inv_rate, log_msg_cols: remaining, - log_num_interleaved: k, - k_recursive: k, + log_num_interleaved: folds, + k_recursive: folds, regime: if johnson { SoundnessRegime::JohnsonOod } else { SoundnessRegime::Udr }, eta: johnson.then_some(JOHNSON_ETA), + // Zero is the fixed backend policy for unique decoding. proximity_loss: (!johnson).then_some(0.0), - queries: 0, - grinding_bits: 0, - fold_grinding_bits: 0, + queries: parameters.queries, + grinding_bits: parameters.query_grinding_bits, + fold_grinding_bits: parameters.fold_grinding_bits, + // Later Johnson levels use one OOD sample. The initial OOD check has separate configuration. ood_samples: usize::from(johnson && !first), - target_security_bits: target, + target_security_bits: security_level.bits() as usize, expected_eps_pg_bits: 0.0, expected_eps_query_bits: 0.0, expected_eps_ood_bits: None, }; - let ood = bounds::configure_level(&mut level, first, remaining == FINAL_LOG_N)?; - if first { - initial_ood = ood; - } + + // 5. Fill backend diagnostics. These estimates do not select the parameters. + let (fold_bits, query_bits) = level.paper_predicted_bits(); + level.expected_eps_pg_bits = fold_bits; + level.expected_eps_query_bits = query_bits; + level.expected_eps_ood_bits = level.paper_predicted_ood_bits(); levels.push(level); } - let security = LigeritoSecurityConfig { - m, - log_n, - initial_k: INITIAL_K, - target_security_bits: target, - analysis_version: if johnson { - "bitz_johnson_combined_blocks_v1" - } else { - "bitz_udr_combined_blocks_v1" - } - .into(), - field: "f128".into(), - hash: "blake3".into(), - grinding_step: GrindingStep::PostCommitPreQueries, - levels, - final_block: FinalBlockConfig { - yr_log_n: remaining, + + Ok(( + LigeritoSecurityConfig { + m, + log_n, + initial_k: INITIAL_K, + target_security_bits: security_level.bits() as usize, + analysis_version: if johnson { + "bitz_johnson_combined_blocks_v1" + } else { + "bitz_udr_combined_blocks_v1" + } + .into(), + field: "f128".into(), + hash: "blake3".into(), + grinding_step: GrindingStep::PostCommitPreQueries, + levels, + final_block: FinalBlockConfig { + yr_log_n: remaining, + }, }, + initial_ood, + )) +} + +/// Calculated values for one level. The caller supplies geometry and fixed policy separately. +struct LevelParameters { + queries: usize, + fold_grinding_bits: usize, + query_grinding_bits: usize, + initial_ood_grinding: Option, +} + +/// Selects the 100-bit parameters with Johnson list decoding. +fn parameters_100( + log_msg_cols: usize, + log_inv_rate: usize, + folds: usize, +) -> Result { + let first = log_inv_rate == 1; + let final_level = log_msg_cols == FINAL_LOG_N; + let codeword_length = 1usize << (log_msg_cols + log_inv_rate); + let variables = log_msg_cols + folds; + // Error coefficient C represents probability C / 2^128. The 100-bit target permits C <= 2^28. + let allowed_coefficient = 2f64.powi(28); + + // 1. Calculate the Johnson list size, query miss probability, and folding bound. + let rate = 2f64.powi(-(log_inv_rate as i32)); + let (sqrt_rate_lower, sqrt_rate_upper) = sqrt_bounds(rate); + let eta_lower = JOHNSON_ETA.next_down(); + let eta_upper = JOHNSON_ETA.next_up(); + let radius_upper = ((1.0 - sqrt_rate_lower).next_up() - eta_lower).next_up(); + let list_size = div_up(1.0, (2.0 * eta_lower * sqrt_rate_lower).next_down()); + let query_miss = add_up(sqrt_rate_upper, eta_upper); + + // Flock C.3: h = max(ceil(sqrt(rate)/(2*eta)), 3) + 1/2. + // The fold coefficient is n*(2*h^5 + 3*h*radius*rate)/(3*rate^(3/2)) + h/sqrt(rate). + let h = div_up(sqrt_rate_upper, 2.0 * eta_lower).ceil().max(3.0) + 0.5; + let h_fifth = (0..5).fold(1.0, |power, _| mul_up(power, h)); + let numerator = add_up(2.0 * h_fifth, mul_up(mul_up(3.0 * h, radius_upper), rate)); + let denominator = (3.0 * (rate * sqrt_rate_lower).next_down()).next_down(); + let per_position = div_up(numerator, denominator); + let fold_coefficient = add_up( + mul_up(per_position, codeword_length as f64), + div_up(h, sqrt_rate_lower), + ); + // Retain the pinned backend's conservative multiplier for interleaved rows. + let fold_coefficient = mul_up(fold_coefficient, 2f64.powi(folds as i32 - 1)); + + // 2. Choose the smallest fold grinding that covers every round and its sumcheck. + // Johnson halves the fold coefficient and decreases grinding by one bit after each round. + let fold_grinding_bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| { + (0..folds).all(|round| { + let grinding = bits.saturating_sub(round); + let claim_batching = f64::from(!first && round == 0 && grinding == 0); + let total_coefficient = add_up( + fold_coefficient * 2f64.powi(-(round as i32)), + mul_up(2.0 + claim_batching, list_size), + ); + total_coefficient <= 2f64.powi(28 + grinding as i32) + }) + }) + .ok_or(ConfigError::Invalid("Johnson fold grinding exceeds cap"))?; + + // 3. Check OOD selection and batching. Their challenges precede fold grinding. + let selection_coefficient = if first { + mul_up(list_size, variables as f64) + } else { + mul_up(mul_up(list_size, list_size), variables as f64 * 0.5) + }; + if selection_coefficient > allowed_coefficient { + return Err(ConfigError::Invalid("Johnson recursive OOD bound")); + } + let batching_challenges = if first { 8.0 } else { 1.0 }; + if mul_up(batching_challenges, list_size) > allowed_coefficient { + return Err(ConfigError::Invalid("Johnson unground batching bound")); + } + let initial_ood_grinding = if first { + // BitZ Lemma B.2: each candidate pair can collide at at most degree points. + let candidate_pairs = mul_up(list_size, (list_size - 1.0).next_up()) * 0.5; + let degree = ((1u64 << variables) - 1) as f64; + let collision_coefficient = mul_up(candidate_pairs, degree); + let bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| collision_coefficient <= 2f64.powi(28 + bits as i32)) + .ok_or(ConfigError::Invalid( + "Johnson initial OOD grinding exceeds cap", + ))?; + Some(bits as u32) + } else { + None + }; + + // 4. Add queries until query misses and batching together meet 2^-100, without query grinding. + // The next commitment halves the rate, increasing its list bound by sqrt(2). + // The explicit final message has only one candidate. + let next_list_size = if final_level { + 1.0 + } else { + mul_up(list_size, sqrt_bounds(2.0).1) }; - Ok((security, initial_ood)) + let mut queries = 0; + let mut all_queries_miss = 1.0; + while query_and_batching_error(all_queries_miss, queries, next_list_size, true) + > 2f64.powi(-100) + { + if queries == codeword_length { + return Err(ConfigError::Invalid("queries exceed codeword length")); + } + all_queries_miss = mul_up(all_queries_miss, query_miss); + queries += 1; + } + Ok(LevelParameters { + queries, + fold_grinding_bits, + query_grinding_bits: 0, + initial_ood_grinding, + }) +} + +/// Selects the 128-bit parameters with unique decoding and no OOD checks. +fn parameters_128( + log_msg_cols: usize, + log_inv_rate: usize, +) -> Result { + let first = log_inv_rate == 1; + let final_level = log_msg_cols == FINAL_LOG_N; + let codeword_length = 1usize << (log_msg_cols + log_inv_rate); + + // 1. Calculate radius = distance/2 - 3/(distance*n), where distance = 1 - 1/inverse_rate. + // Keep radius*n as an exact fraction for the fold check. + let length = codeword_length as u128; + let inverse_rate = 1u128 << log_inv_rate; + let scaled_distance_squared = (inverse_rate - 1).pow(2) * length; + // BCHKS25 Corollary 1.4 requires distance^2 * n >= 18. + if scaled_distance_squared < 18 * inverse_rate.pow(2) { + return Err(ConfigError::Invalid("UDR theorem range")); + } + let denominator = 2 * inverse_rate * (inverse_rate - 1); + let radius_times_length_numerator = scaled_distance_squared - 6 * inverse_rate.pow(2); + + // 2. Choose constant fold grinding. Each fold and its sumcheck cost (radius*n + 3) / 2^128. + // An unground first recursive fold also shares the preceding claim-batching challenge. + let fold_grinding_bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| { + let claim_batching = u128::from(!first && bits == 0); + let error_numerator = + radius_times_length_numerator + (3 + claim_batching) * denominator; + error_numerator <= denominator * (1u128 << bits) + }) + .ok_or(ConfigError::Invalid("UDR fold grinding exceeds cap"))?; + + // 3. Add queries until their miss probability alone meets 2^-128. + let miss_numerator = denominator * length - radius_times_length_numerator; + let query_miss = div_up( + (miss_numerator as f64).next_up(), + ((denominator * length) as f64).next_down(), + ); + let mut queries = 0; + let mut all_queries_miss = 1.0; + while all_queries_miss > 2f64.powi(-128) { + if queries == codeword_length { + return Err(ConfigError::Invalid("queries exceed codeword length")); + } + all_queries_miss = mul_up(all_queries_miss, query_miss); + queries += 1; + } + + // 4. Choose query grinding to cover query misses and batching together. + // Unique decoding has one candidate. Only the final level includes the extra claim-batching challenge. + let combined_error = query_and_batching_error(all_queries_miss, queries, 1.0, final_level); + let query_grinding_bits = (0..=MAX_GRINDING_BITS) + .find(|&bits| combined_error <= 2f64.powi(bits as i32 - 128)) + .ok_or(ConfigError::Invalid("UDR query grinding exceeds cap"))?; + Ok(LevelParameters { + queries, + fold_grinding_bits, + query_grinding_bits, + initial_ood_grinding: None, + }) +} + +/// Adds query misses and batching error. Each batching challenge costs list_size / 2^128. +fn query_and_batching_error( + all_queries_miss: f64, + queries: usize, + list_size: f64, + include_claim_batching: bool, +) -> f64 { + let batching_challenges = + queries.next_power_of_two().ilog2() + u32::from(include_claim_batching); + let batching_error = mul_up(f64::from(batching_challenges), list_size) * 2f64.powi(-128); + add_up(all_queries_miss, batching_error) +} + +// Round error bounds upward and divisors downward. Rounding must never weaken a bound. +fn add_up(left: f64, right: f64) -> f64 { + (left + right).next_up() +} + +fn mul_up(left: f64, right: f64) -> f64 { + (left * right).next_up() +} + +fn div_up(numerator: f64, denominator: f64) -> f64 { + (numerator / denominator).next_up() +} + +fn sqrt_bounds(value: f64) -> (f64, f64) { + // Check both endpoints independently of the platform's sqrt rounding. + let root = value.sqrt(); + let mut lower = root.next_down(); + while mul_up(lower, lower) > value { + lower = lower.next_down(); + } + let mut upper = root.next_up(); + while (upper * upper).next_down() < value { + upper = upper.next_up(); + } + (lower, upper) } #[cfg(test)] -mod tests; +mod tests { + use super::*; + + #[test] + fn query_batching_covers_every_candidate() { + // Eight query-batching challenges and one claim-batching challenge each cover 64 candidates. + let error = query_and_batching_error(0.0, 256, 64.0, true); + assert!(error >= 576.0 * 2f64.powi(-128)); + assert!(error < 577.0 * 2f64.powi(-128)); + } + + #[test] + fn udr_m22_matches_audited_parameters() { + let (config, initial_ood) = security_config(22, SecurityLevel::Bits128).unwrap(); + assert_eq!(config.initial_k, 4); + assert_eq!(config.final_block.yr_log_n, 5); + assert_eq!(config.hash, "blake3"); + assert_eq!(config.target_security_bits, 128); + for (index, level) in config.levels.iter().enumerate() { + assert_eq!(level.queries, [311, 192, 161][index]); + assert_eq!(level.fold_grinding_bits, [10, 9, 7][index]); + assert_eq!(level.grinding_bits, 4); + assert_eq!(level.ood_samples, 0); + } + assert_eq!(initial_ood, None); + } + + #[test] + fn udr_all_supported_sizes_cover_combined_errors() { + for m in 20..=35 { + let (config, initial_ood) = security_config(m, SecurityLevel::Bits128).unwrap(); + assert_eq!(initial_ood, None); + assert_eq!(config.hash, "blake3"); + config.to_prover_verifier_configs().unwrap(); + for (index, level) in config.levels.iter().enumerate() { + // Reconstruct the published formulas independently of the bound helpers. + let n = 2f64.powi((level.log_msg_cols + level.log_inv_rate) as i32); + let rho = 2f64.powi(-(level.log_inv_rate as i32)); + let delta = 1.0 - rho; + let gamma = delta / 2.0 - 3.0 / (delta * n); + assert!(delta >= 3.0 * (2.0 / n).sqrt()); + assert!(gamma >= delta / 3.0 && gamma < delta / 2.0); + let fold_error = |grinding| { + let beta = f64::from(index > 0 && grinding == 0); + (gamma * n + 3.0 + beta) * 2f64.powi(-128 - grinding as i32) + }; + assert!(fold_error(level.fold_grinding_bits) <= 2f64.powi(-128)); + if level.fold_grinding_bits > 0 { + assert!(fold_error(level.fold_grinding_bits - 1) > 2f64.powi(-128)); + } + let final_beta = usize::from(index + 1 == config.levels.len()); + let alpha = level.queries.next_power_of_two().ilog2() as usize; + let miss = (1.0 - gamma).powi(level.queries as i32); + assert!(miss <= 2f64.powi(-128)); + assert!((1.0 - gamma).powi(level.queries as i32 - 1) > 2f64.powi(-128)); + let query = (miss + (alpha + final_beta) as f64 * 2f64.powi(-128)) + * 2f64.powi(-(level.grinding_bits as i32)); + assert!(query <= 2f64.powi(-128), "m={m}, level={index}"); + if level.grinding_bits > 0 { + assert!(query * 2.0 > 2f64.powi(-128)); + } + assert!(level.queries <= n as usize); + assert!(level.fold_grinding_bits <= 32 && level.grinding_bits <= 32); + assert!( + level.fold_grinding_bits == 0 || level.fold_grinding_bits >= level.k_recursive, + "every positive fold schedule must retain all native grinding hooks" + ); + } + } + } + + #[test] + fn johnson_queries_replace_query_grinding() { + for (m, folds) in [(20, [7, 4, 1]), (22, [9, 6, 3])] { + let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); + assert_eq!(config.target_security_bits, 100); + assert_eq!(initial_ood, Some(0)); + for (index, level) in config.levels.iter().enumerate() { + assert_eq!(level.queries, [218, 106, 71][index]); + assert_eq!(level.fold_grinding_bits, folds[index]); + assert_eq!(level.grinding_bits, 0); + assert_eq!(level.ood_samples, usize::from(index > 0)); + } + } + } + + #[test] + fn johnson_all_sizes_cover_combined_blocks() { + for m in 20..=35 { + let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); + config.to_prover_verifier_configs().unwrap(); + let initial_grinding = initial_ood.unwrap(); + let list = 1.0 / (0.04 * 0.5f64.sqrt()); + let initial = list * (list - 1.0) * 0.5 * (2f64.powi(m as i32 - 7) - 1.0); + assert!(initial <= 2f64.powi(28 + initial_grinding as i32)); + assert!(initial_grinding <= 32); + if initial_grinding > 0 { + assert!(initial > 2f64.powi(27 + initial_grinding as i32)); + } + for (index, level) in config.levels.iter().enumerate() { + // Reconstruct the published formulas independently of the bound helpers. + let rho = 2f64.powi(-(level.log_inv_rate as i32)); + let sqrt_rho = rho.sqrt(); + let list = 1.0 / (0.04 * sqrt_rho); + let gamma = 1.0 - sqrt_rho - 0.02; + let half = (sqrt_rho / 0.04).ceil().max(3.0) + 0.5; + let n = 2f64.powi((level.log_msg_cols + level.log_inv_rate) as i32); + let base = (2.0 * half.powi(5) + 3.0 * half * gamma * rho) / (3.0 * rho.powf(1.5)) + * n + + half / sqrt_rho; + let k = level.k_recursive; + let folds_fit = |grinding: usize| { + (0..k).all(|round| { + let row_union = 2f64.powi((k - 1 - round) as i32); + let effective = grinding.saturating_sub(round); + let beta = f64::from(index > 0 && round == 0 && effective == 0); + base * row_union + (2.0 + beta) * list <= 2f64.powi(28 + effective as i32) + }) + }; + assert!(folds_fit(level.fold_grinding_bits)); + if level.fold_grinding_bits > 0 { + assert!(!folds_fit(level.fold_grinding_bits - 1)); + } + let next_list = config.levels.get(index + 1).map_or(1.0, |next| { + 1.0 / (0.04 * 2f64.powi(-(next.log_inv_rate as i32)).sqrt()) + }); + let alpha = level.queries.next_power_of_two().ilog2(); + let query = (sqrt_rho + 0.02).powi(level.queries as i32) + + f64::from(alpha + 1) * next_list * 2f64.powi(-128); + assert!(query <= 2f64.powi(-100)); + let previous_query = (sqrt_rho + 0.02).powi(level.queries as i32 - 1) + + f64::from((level.queries - 1).next_power_of_two().ilog2() + 1) + * next_list + * 2f64.powi(-128); + assert!(previous_query > 2f64.powi(-100)); + let mu = (level.log_msg_cols + level.log_num_interleaved) as f64; + let ood = if index == 0 { + list * mu + } else { + list * list * mu * 0.5 + }; + assert!(ood <= 2f64.powi(28)); + assert!((if index == 0 { 8.0 } else { 1.0 }) * list <= 2f64.powi(28)); + assert!(level.queries <= n as usize); + assert_eq!(level.grinding_bits, 0); + assert_eq!(level.ood_samples, usize::from(index > 0)); + assert!(level.fold_grinding_bits <= 32); + assert!(level.fold_grinding_bits >= k); + } + } + } + + #[test] + fn both_profiles_keep_a_five_variable_residual() { + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + for m in 20..=35 { + let (config, _) = security_config(m, security).unwrap(); + assert_eq!(config.levels[0].k_recursive, 4); + assert_eq!(config.final_block.yr_log_n, 5); + let total_folds: usize = config.levels.iter().map(|level| level.k_recursive).sum(); + assert_eq!(total_folds + 5, m - 7); + for (index, level) in config.levels.iter().enumerate() { + assert_eq!(level.log_inv_rate, index + 1); + assert_eq!(level.log_num_interleaved, level.k_recursive); + } + if m <= 21 { + assert_eq!(config.levels[1].k_recursive, 3); + assert_eq!(config.levels[2].k_recursive, m - 19); + } + } + } + } + + #[test] + fn both_profiles_reject_unsupported_sizes() { + for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { + for m in [0, 19, 36, usize::MAX] { + assert!(security_config(m, security).is_err()); + } + } + } +} diff --git a/crates/pcs/src/profiles/bounds.rs b/crates/pcs/src/profiles/bounds.rs deleted file mode 100644 index 3ef20201..00000000 --- a/crates/pcs/src/profiles/bounds.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Conservative probability bounds and per-level parameter selection. -//! -//! BitZ Remark A.2 and Lemma B.2 give the list and initial OOD bounds. -//! Flock Appendix C.3 gives folding, sumcheck, batching, and query bounds. - -use flock_core::pcs::ligerito::{LigeritoLevelConfig, SoundnessRegime}; - -use super::JOHNSON_ETA; -use crate::ConfigError; - -const MAX_GRINDING_BITS: usize = transcript::pow::MAX_GRINDING_BITS as usize; -const JOHNSON_SLACK_BITS: i32 = 128 - 100; - -/// Selects query and grinding parameters for the supplied canonical level. -/// Only the first Johnson level returns an initial OOD grinding requirement. -pub(super) fn configure_level( - level: &mut LigeritoLevelConfig, - first: bool, - final_level: bool, -) -> Result, ConfigError> { - let target = level.target_security_bits; - let johnson = matches!(level.regime, SoundnessRegime::JohnsonOod); - let positions = 1usize << (level.log_msg_cols + level.log_inv_rate); - let mut initial_ood = None; - let (miss_upper, query_list) = match level.regime { - SoundnessRegime::JohnsonOod => { - let probability = JohnsonProbability::new(level, positions); - level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| probability.folds_meet_target(level.k_recursive, bits, first)) - .ok_or(ConfigError::Invalid("Johnson fold grinding exceeds cap"))?; - let variables = level.log_msg_cols + level.log_num_interleaved; - probability.check_ood(variables, first)?; - if first { - initial_ood = Some(probability.initial_ood_grinding(variables)?); - } - // Nonfinal batching concerns the next commitment, whose rate halves rho. - // Its list bound grows by sqrt(2). The final residual has one candidate. - let query_list = if final_level { - 1.0 - } else { - mul_up(probability.list_upper, sqrt_bounds(2.0).1) - }; - (probability.miss_upper, query_list) - } - SoundnessRegime::Udr => (configure_udr(level, positions, first)?, 1.0), - }; - let target_error = 2f64.powi(-(target as i32)); - let mut query_miss = 1.0; - // Queries cover the target. Johnson also covers alpha and OOD beta without grinding. - loop { - let error = if johnson { - combined_query_error(query_miss, level.queries, true, query_list) - } else { - query_miss - }; - if error <= target_error { - break; - } - if level.queries == positions { - return Err(ConfigError::Invalid("queries exceed codeword length")); - } - query_miss = mul_up(query_miss, miss_upper); - level.queries += 1; - } - if !johnson { - // UDR grinding covers the combined query, alpha, and final beta errors. - let error = combined_query_error(query_miss, level.queries, final_level, query_list); - level.grinding_bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| error <= 2f64.powi(bits as i32 - target as i32)) - .ok_or(ConfigError::Invalid("UDR query grinding exceeds cap"))?; - } - let (pg, query) = level.paper_predicted_bits(); - level.expected_eps_pg_bits = pg; - level.expected_eps_query_bits = query; - level.expected_eps_ood_bits = level.paper_predicted_ood_bits(); - Ok(initial_ood) -} - -struct JohnsonProbability { - fold_coefficient_upper: f64, - miss_upper: f64, - list_upper: f64, -} - -impl JohnsonProbability { - fn new(level: &LigeritoLevelConfig, positions: usize) -> Self { - // All inputs have the checked canonical geometry and eta=0.02. - let rho = 2f64.powi(-(level.log_inv_rate as i32)); - let (sqrt_lower, sqrt_upper) = sqrt_bounds(rho); - let eta_lower = JOHNSON_ETA.next_down(); - let eta_upper = JOHNSON_ETA.next_up(); - let gamma_upper = ((1.0 - sqrt_lower).next_up() - eta_lower).next_up(); - let half = div_up(sqrt_upper, 2.0 * eta_lower).ceil().max(3.0) + 0.5; - let half5 = (0..5).fold(1.0, |value, _| mul_up(value, half)); - let numerator = add_up(2.0 * half5, mul_up(mul_up(3.0 * half, gamma_upper), rho)); - let denominator = (3.0 * (rho * sqrt_lower).next_down()).next_down(); - let base = add_up( - mul_up(div_up(numerator, denominator), positions as f64), - div_up(half, sqrt_lower), - ); - // Retain the pinned backend's row multiplier as a conservative bound. - let row_union = 2f64.powi(level.log_num_interleaved as i32 - 1); - Self { - fold_coefficient_upper: mul_up(base, row_union), - miss_upper: add_up(sqrt_upper, eta_upper), - list_upper: div_up(1.0, (2.0 * eta_lower * sqrt_lower).next_down()), - } - } - - fn check_ood(&self, variables: usize, first: bool) -> Result<(), ConfigError> { - // One sample covers each recursive selection. Its response separates selection from beta. - // The next query block covers beta. Level zero uses its implicit OOD bound. - let mu = variables as f64; - let coefficient = if first { - mul_up(self.list_upper, mu) - } else { - mul_up(mul_up(self.list_upper, self.list_upper), mu * 0.5) - }; - if coefficient > 2f64.powi(JOHNSON_SLACK_BITS) { - return Err(ConfigError::Invalid("Johnson recursive OOD bound")); - } - // Initial ring switching and OOD mixing cost at most 8L/F. - // Later introduction beta costs L/F before any fold grinding starts. - let batching = if first { 8.0 } else { 1.0 }; - if mul_up(batching, self.list_upper) > 2f64.powi(JOHNSON_SLACK_BITS) { - return Err(ConfigError::Invalid("Johnson unground batching bound")); - } - Ok(()) - } - - fn initial_ood_grinding(&self, log_n: usize) -> Result { - let pairs = mul_up(self.list_upper, (self.list_upper - 1.0).next_up()) * 0.5; - let degree = ((1u64 << log_n) - 1) as f64; - let coefficient = mul_up(pairs, degree); - (0..=MAX_GRINDING_BITS) - .find(|&bits| coefficient <= 2f64.powi(JOHNSON_SLACK_BITS + bits as i32)) - .map(|bits| bits as u32) - .ok_or(ConfigError::Invalid( - "Johnson initial OOD grinding exceeds cap", - )) - } - - fn folds_meet_target(&self, folds: usize, grinding: usize, first: bool) -> bool { - (0..folds).all(|round| { - let effective = grinding.saturating_sub(round); - // Flock C.3 takes a list union for sumcheck and claim batching. - let extra = mul_up( - 2.0 + f64::from(!first && round == 0 && effective == 0), - self.list_upper, - ); - let coefficient = add_up( - self.fold_coefficient_upper * 2f64.powi(-(round as i32)), - extra, - ); - coefficient <= 2f64.powi(JOHNSON_SLACK_BITS + effective as i32) - }) - } -} - -fn add_up(left: f64, right: f64) -> f64 { - (left + right).next_up() -} - -fn mul_up(left: f64, right: f64) -> f64 { - (left * right).next_up() -} - -fn div_up(numerator: f64, denominator: f64) -> f64 { - (numerator / denominator).next_up() -} - -fn sqrt_bounds(value: f64) -> (f64, f64) { - // The product checks certify the bracket independently of sqrt rounding. - let root = value.sqrt(); - let mut lower = root.next_down(); - while mul_up(lower, lower) > value { - lower = lower.next_down(); - } - let mut upper = root.next_up(); - while (upper * upper).next_down() < value { - upper = upper.next_up(); - } - (lower, upper) -} - -/// Selects constant UDR fold grinding and returns the per-query miss bound. -fn configure_udr( - level: &mut LigeritoLevelConfig, - positions: usize, - first: bool, -) -> Result { - // Callers first check the canonical ladder. Its largest exponent is 25. - let n = positions as u128; - let d = 1u128 << level.log_inv_rate; - let distance_square = (d - 1).pow(2) * n; - // BCHKS25 Corollary 1.4: delta >= 3*sqrt(2/n). - // This also ensures gamma >= delta/3 at the selected upper endpoint. - if distance_square < 18 * d * d { - return Err(ConfigError::Invalid("UDR theorem range")); - } - let denominator = 2 * d * (d - 1); - let gamma_n_numerator = distance_square - 6 * d * d; - level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| { - // The fold and its quadratic cost gamma*n + 3 field errors. - // An unground first recursive fold also shares the preceding introduction beta. - let beta = u128::from(!first && bits == 0); - gamma_n_numerator + (3 + beta) * denominator - <= denominator * (1u128 << (128 - level.target_security_bits + bits)) - }) - .ok_or(ConfigError::Invalid("UDR fold grinding exceeds cap"))?; - let miss_numerator = denominator * n - gamma_n_numerator; - // Outward conversion and division keep this probability an upper bound. - Ok(((miss_numerator as f64).next_up() / ((denominator * n) as f64).next_down()).next_up()) -} - -fn combined_query_error( - miss_upper: f64, - queries: usize, - include_beta: bool, - list_upper: f64, -) -> f64 { - let alpha = queries.next_power_of_two().ilog2(); - let field_terms = alpha + u32::from(include_beta); - add_up( - miss_upper, - mul_up(f64::from(field_terms), list_upper) * 2f64.powi(-128), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn list_union_can_exhaust_the_remaining_fold_budget() { - let probability = JohnsonProbability { - fold_coefficient_upper: 2f64.powi(28) - 100.0, - miss_upper: 0.5, - list_upper: 40.0, - }; - // Sumcheck costs 80 field errors. Introduction beta raises that cost to 120. - assert!(probability.folds_meet_target(1, 0, true)); - assert!(!probability.folds_meet_target(1, 0, false)); - } - - #[test] - fn query_batching_covers_every_candidate() { - // Eight alpha coordinates and one beta each range over 64 candidates. - let error = combined_query_error(0.0, 256, true, 64.0); - assert!(error >= 576.0 * 2f64.powi(-128)); - assert!(error < 577.0 * 2f64.powi(-128)); - } -} diff --git a/crates/pcs/src/profiles/tests.rs b/crates/pcs/src/profiles/tests.rs deleted file mode 100644 index d79facfa..00000000 --- a/crates/pcs/src/profiles/tests.rs +++ /dev/null @@ -1,171 +0,0 @@ -use super::*; - -#[test] -fn udr_m22_matches_audited_parameters() { - let (config, initial_ood) = security_config(22, SecurityLevel::Bits128).unwrap(); - assert_eq!(config.initial_k, 4); - assert_eq!(config.final_block.yr_log_n, 5); - assert_eq!(config.hash, "blake3"); - assert_eq!(config.target_security_bits, 128); - for (index, level) in config.levels.iter().enumerate() { - assert_eq!(level.queries, [311, 192, 161][index]); - assert_eq!(level.fold_grinding_bits, [10, 9, 7][index]); - assert_eq!(level.grinding_bits, 4); - assert_eq!(level.ood_samples, 0); - } - assert_eq!(initial_ood, None); -} - -#[test] -fn udr_all_supported_sizes_cover_combined_errors() { - for m in 20..=35 { - let (config, initial_ood) = security_config(m, SecurityLevel::Bits128).unwrap(); - assert_eq!(initial_ood, None); - assert_eq!(config.hash, "blake3"); - config.to_prover_verifier_configs().unwrap(); - for (index, level) in config.levels.iter().enumerate() { - // Reconstruct the published formulas independently of the bound helpers. - let n = 2f64.powi((level.log_msg_cols + level.log_inv_rate) as i32); - let rho = 2f64.powi(-(level.log_inv_rate as i32)); - let delta = 1.0 - rho; - let gamma = delta / 2.0 - 3.0 / (delta * n); - assert!(delta >= 3.0 * (2.0 / n).sqrt()); - assert!(gamma >= delta / 3.0 && gamma < delta / 2.0); - let fold_error = |grinding| { - let beta = f64::from(index > 0 && grinding == 0); - (gamma * n + 3.0 + beta) * 2f64.powi(-128 - grinding as i32) - }; - assert!(fold_error(level.fold_grinding_bits) <= 2f64.powi(-128)); - if level.fold_grinding_bits > 0 { - assert!(fold_error(level.fold_grinding_bits - 1) > 2f64.powi(-128)); - } - let final_beta = usize::from(index + 1 == config.levels.len()); - let alpha = level.queries.next_power_of_two().ilog2() as usize; - let miss = (1.0 - gamma).powi(level.queries as i32); - assert!(miss <= 2f64.powi(-128)); - assert!((1.0 - gamma).powi(level.queries as i32 - 1) > 2f64.powi(-128)); - let query = (miss + (alpha + final_beta) as f64 * 2f64.powi(-128)) - * 2f64.powi(-(level.grinding_bits as i32)); - assert!(query <= 2f64.powi(-128), "m={m}, level={index}"); - if level.grinding_bits > 0 { - assert!(query * 2.0 > 2f64.powi(-128)); - } - assert!(level.queries <= n as usize); - assert!(level.fold_grinding_bits <= 32 && level.grinding_bits <= 32); - assert!( - level.fold_grinding_bits == 0 || level.fold_grinding_bits >= level.k_recursive, - "every positive fold schedule must retain all native grinding hooks" - ); - } - } -} - -#[test] -fn johnson_queries_replace_query_grinding() { - for (m, folds) in [(20, [7, 4, 1]), (22, [9, 6, 3])] { - let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); - assert_eq!(config.target_security_bits, 100); - assert_eq!(initial_ood, Some(0)); - for (index, level) in config.levels.iter().enumerate() { - assert_eq!(level.queries, [218, 106, 71][index]); - assert_eq!(level.fold_grinding_bits, folds[index]); - assert_eq!(level.grinding_bits, 0); - assert_eq!(level.ood_samples, usize::from(index > 0)); - } - } -} - -#[test] -fn johnson_all_sizes_cover_combined_blocks() { - for m in 20..=35 { - let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); - config.to_prover_verifier_configs().unwrap(); - let initial_grinding = initial_ood.unwrap(); - let list = 1.0 / (0.04 * 0.5f64.sqrt()); - let initial = list * (list - 1.0) * 0.5 * (2f64.powi(m as i32 - 7) - 1.0); - assert!(initial <= 2f64.powi(28 + initial_grinding as i32)); - assert!(initial_grinding <= 32); - if initial_grinding > 0 { - assert!(initial > 2f64.powi(27 + initial_grinding as i32)); - } - for (index, level) in config.levels.iter().enumerate() { - // Reconstruct the published formulas independently of the bound helpers. - let rho = 2f64.powi(-(level.log_inv_rate as i32)); - let sqrt_rho = rho.sqrt(); - let list = 1.0 / (0.04 * sqrt_rho); - let gamma = 1.0 - sqrt_rho - 0.02; - let half = (sqrt_rho / 0.04).ceil().max(3.0) + 0.5; - let n = 2f64.powi((level.log_msg_cols + level.log_inv_rate) as i32); - let base = (2.0 * half.powi(5) + 3.0 * half * gamma * rho) / (3.0 * rho.powf(1.5)) * n - + half / sqrt_rho; - let k = level.k_recursive; - let folds_fit = |grinding: usize| { - (0..k).all(|round| { - let row_union = 2f64.powi((k - 1 - round) as i32); - let effective = grinding.saturating_sub(round); - let beta = f64::from(index > 0 && round == 0 && effective == 0); - base * row_union + (2.0 + beta) * list <= 2f64.powi(28 + effective as i32) - }) - }; - assert!(folds_fit(level.fold_grinding_bits)); - if level.fold_grinding_bits > 0 { - assert!(!folds_fit(level.fold_grinding_bits - 1)); - } - let next_list = config.levels.get(index + 1).map_or(1.0, |next| { - 1.0 / (0.04 * 2f64.powi(-(next.log_inv_rate as i32)).sqrt()) - }); - let alpha = level.queries.next_power_of_two().ilog2(); - let query = (sqrt_rho + 0.02).powi(level.queries as i32) - + f64::from(alpha + 1) * next_list * 2f64.powi(-128); - assert!(query <= 2f64.powi(-100)); - let previous_query = (sqrt_rho + 0.02).powi(level.queries as i32 - 1) - + f64::from((level.queries - 1).next_power_of_two().ilog2() + 1) - * next_list - * 2f64.powi(-128); - assert!(previous_query > 2f64.powi(-100)); - let mu = (level.log_msg_cols + level.log_num_interleaved) as f64; - let ood = if index == 0 { - list * mu - } else { - list * list * mu * 0.5 - }; - assert!(ood <= 2f64.powi(28)); - assert!((if index == 0 { 8.0 } else { 1.0 }) * list <= 2f64.powi(28)); - assert!(level.queries <= n as usize); - assert_eq!(level.grinding_bits, 0); - assert_eq!(level.ood_samples, usize::from(index > 0)); - assert!(level.fold_grinding_bits <= 32); - assert!(level.fold_grinding_bits >= k); - } - } -} - -#[test] -fn both_profiles_keep_a_five_variable_residual() { - for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { - for m in 20..=35 { - let (config, _) = security_config(m, security).unwrap(); - assert_eq!(config.levels[0].k_recursive, 4); - assert_eq!(config.final_block.yr_log_n, 5); - let total_folds: usize = config.levels.iter().map(|level| level.k_recursive).sum(); - assert_eq!(total_folds + 5, m - 7); - for (index, level) in config.levels.iter().enumerate() { - assert_eq!(level.log_inv_rate, index + 1); - assert_eq!(level.log_num_interleaved, level.k_recursive); - } - if m <= 21 { - assert_eq!(config.levels[1].k_recursive, 3); - assert_eq!(config.levels[2].k_recursive, m - 19); - } - } - } -} - -#[test] -fn both_profiles_reject_unsupported_sizes() { - for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { - for m in [0, 19, 36, usize::MAX] { - assert!(security_config(m, security).is_err()); - } - } -} From ded2c1b0cab6ae88d64149386fda941724b75041 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 12:02:53 +0200 Subject: [PATCH 10/16] refactor: reuse flock calculations --- crates/pcs/src/profiles.rs | 242 +++++++++++++------------------------ 1 file changed, 82 insertions(+), 160 deletions(-) diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index 1b4e0c41..846a7c61 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -9,7 +9,7 @@ //! //! The 100-bit profile uses Johnson list decoding, OOD checks, and no query grinding. //! The 128-bit profile uses unique decoding, no OOD checks, and query grinding. -//! Both profiles calculate fold grinding from the level size. +//! Flock supplies folding, query, and recursive OOD estimates. We add our combined error costs. //! //! These targets concern classical challenge blocks. They assume uniform transcript challenges and classical PoW costs. //! Sources: BitZ Remark A.2 and Lemma B.2; Flock Appendix C.3; BCHKS25 Corollary 1.4. @@ -29,8 +29,8 @@ const MAX_GRINDING_BITS: usize = transcript::pow::MAX_GRINDING_BITS as usize; /// Derives every level from `m` and the selected target. /// -/// Per level: choose folds, set the shape, calculate queries and grinding, then build the backend configuration. -/// Only diagnostic fields start as placeholders. Backend methods fill them before we store the level. +/// Per level: choose the shape, ask Flock for base estimates, then select queries and grinding. +/// We use one query initially to measure its contribution. We store only the completed configuration. /// The separate initial OOD result uses `None` for no check and `Some(0)` for a check without grinding. pub(crate) fn security_config( m: usize, @@ -55,19 +55,15 @@ pub(crate) fn security_config( }; remaining -= folds; - // 2. Set the shape: 2^folds rows, 2^remaining message columns, and rate 2^-log_inv_rate. + // 2. Choose the code rate: message columns / encoded columns. + // log_inv_rate = log2(encoded columns / message columns). + // levels.len() counts completed levels: 0, 1, 2, ... + // Adding 1 selects our fixed expansion schedule: 2x, 4x, 8x, ... + // The corresponding code rates are 1/2, 1/4, 1/8, ... + // More redundancy improves query detection bounds, so later levels need fewer queries. let log_inv_rate = levels.len() + 1; - // 3. Calculate all query and grinding parameters before creating the backend configuration. - let parameters = match security_level { - SecurityLevel::Bits100 => parameters_100(remaining, log_inv_rate, folds)?, - SecurityLevel::Bits128 => parameters_128(remaining, log_inv_rate)?, - }; - if first { - initial_ood = parameters.initial_ood_grinding; - } - - // 4. Combine the shape, fixed decoding policy, and calculated parameters. + // 3. Give Flock the shape and decoding policy. One query measures the contribution per query. let mut level = LigeritoLevelConfig { log_inv_rate, log_msg_cols: remaining, @@ -81,9 +77,10 @@ pub(crate) fn security_config( eta: johnson.then_some(JOHNSON_ETA), // Zero is the fixed backend policy for unique decoding. proximity_loss: (!johnson).then_some(0.0), - queries: parameters.queries, - grinding_bits: parameters.query_grinding_bits, - fold_grinding_bits: parameters.fold_grinding_bits, + queries: 1, + // Starting values. The selected profile sets the final query count and grinding below. + grinding_bits: 0, + fold_grinding_bits: 0, // Later Johnson levels use one OOD sample. The initial OOD check has separate configuration. ood_samples: usize::from(johnson && !first), target_security_bits: security_level.bits() as usize, @@ -92,7 +89,22 @@ pub(crate) fn security_config( expected_eps_ood_bits: None, }; - // 5. Fill backend diagnostics. These estimates do not select the parameters. + // 4. Select parameters using Flock's estimates and our combined error costs. + let ood = match security_level { + SecurityLevel::Bits100 => configure_100(&mut level, first, remaining == FINAL_LOG_N)?, + SecurityLevel::Bits128 => { + configure_128(&mut level, first, remaining == FINAL_LOG_N)?; + None + } + }; + if first { + initial_ood = ood; + } + if level.queries > 1usize << (remaining + log_inv_rate) { + return Err(ConfigError::Invalid("queries exceed codeword length")); + } + + // 5. Refresh backend diagnostics with the final query count. let (fold_bits, query_bits) = level.paper_predicted_bits(); level.expected_eps_pg_bits = fold_bits; level.expected_eps_query_bits = query_bits; @@ -124,84 +136,49 @@ pub(crate) fn security_config( )) } -/// Calculated values for one level. The caller supplies geometry and fixed policy separately. -struct LevelParameters { - queries: usize, - fold_grinding_bits: usize, - query_grinding_bits: usize, - initial_ood_grinding: Option, -} - /// Selects the 100-bit parameters with Johnson list decoding. -fn parameters_100( - log_msg_cols: usize, - log_inv_rate: usize, - folds: usize, -) -> Result { - let first = log_inv_rate == 1; - let final_level = log_msg_cols == FINAL_LOG_N; - let codeword_length = 1usize << (log_msg_cols + log_inv_rate); - let variables = log_msg_cols + folds; +fn configure_100( + level: &mut LigeritoLevelConfig, + first: bool, + final_level: bool, +) -> Result, ConfigError> { + let variables = level.log_msg_cols + level.log_num_interleaved; // Error coefficient C represents probability C / 2^128. The 100-bit target permits C <= 2^28. let allowed_coefficient = 2f64.powi(28); - // 1. Calculate the Johnson list size, query miss probability, and folding bound. - let rate = 2f64.powi(-(log_inv_rate as i32)); - let (sqrt_rate_lower, sqrt_rate_upper) = sqrt_bounds(rate); - let eta_lower = JOHNSON_ETA.next_down(); - let eta_upper = JOHNSON_ETA.next_up(); - let radius_upper = ((1.0 - sqrt_rate_lower).next_up() - eta_lower).next_up(); - let list_size = div_up(1.0, (2.0 * eta_lower * sqrt_rate_lower).next_down()); - let query_miss = add_up(sqrt_rate_upper, eta_upper); - - // Flock C.3: h = max(ceil(sqrt(rate)/(2*eta)), 3) + 1/2. - // The fold coefficient is n*(2*h^5 + 3*h*radius*rate)/(3*rate^(3/2)) + h/sqrt(rate). - let h = div_up(sqrt_rate_upper, 2.0 * eta_lower).ceil().max(3.0) + 0.5; - let h_fifth = (0..5).fold(1.0, |power, _| mul_up(power, h)); - let numerator = add_up(2.0 * h_fifth, mul_up(mul_up(3.0 * h, radius_upper), rate)); - let denominator = (3.0 * (rate * sqrt_rate_lower).next_down()).next_down(); - let per_position = div_up(numerator, denominator); - let fold_coefficient = add_up( - mul_up(per_position, codeword_length as f64), - div_up(h, sqrt_rate_lower), - ); - // Retain the pinned backend's conservative multiplier for interleaved rows. - let fold_coefficient = mul_up(fold_coefficient, 2f64.powi(folds as i32 - 1)); + // 1. Reuse Flock's folding and per-query estimates. The fold estimate already includes the row multiplier. + let (fold_bits, per_query_bits) = level.paper_predicted_bits(); + let fold_coefficient = (128.0 - fold_bits).exp2(); + let rate = 2f64.powi(-(level.log_inv_rate as i32)); + let list_size = 1.0 / (2.0 * JOHNSON_ETA * rate.sqrt()); // 2. Choose the smallest fold grinding that covers every round and its sumcheck. // Johnson halves the fold coefficient and decreases grinding by one bit after each round. - let fold_grinding_bits = (0..=MAX_GRINDING_BITS) + level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) .find(|&bits| { - (0..folds).all(|round| { + (0..level.k_recursive).all(|round| { let grinding = bits.saturating_sub(round); let claim_batching = f64::from(!first && round == 0 && grinding == 0); - let total_coefficient = add_up( - fold_coefficient * 2f64.powi(-(round as i32)), - mul_up(2.0 + claim_batching, list_size), - ); + let total_coefficient = fold_coefficient * 2f64.powi(-(round as i32)) + + (2.0 + claim_batching) * list_size; total_coefficient <= 2f64.powi(28 + grinding as i32) }) }) .ok_or(ConfigError::Invalid("Johnson fold grinding exceeds cap"))?; // 3. Check OOD selection and batching. Their challenges precede fold grinding. - let selection_coefficient = if first { - mul_up(list_size, variables as f64) - } else { - mul_up(mul_up(list_size, list_size), variables as f64 * 0.5) - }; - if selection_coefficient > allowed_coefficient { + if level.paper_predicted_ood_bits().unwrap() < 100.0 { return Err(ConfigError::Invalid("Johnson recursive OOD bound")); } let batching_challenges = if first { 8.0 } else { 1.0 }; - if mul_up(batching_challenges, list_size) > allowed_coefficient { + if batching_challenges * list_size > allowed_coefficient { return Err(ConfigError::Invalid("Johnson unground batching bound")); } let initial_ood_grinding = if first { // BitZ Lemma B.2: each candidate pair can collide at at most degree points. - let candidate_pairs = mul_up(list_size, (list_size - 1.0).next_up()) * 0.5; + let candidate_pairs = list_size * (list_size - 1.0) * 0.5; let degree = ((1u64 << variables) - 1) as f64; - let collision_coefficient = mul_up(candidate_pairs, degree); + let collision_coefficient = candidate_pairs * degree; let bits = (0..=MAX_GRINDING_BITS) .find(|&bits| collision_coefficient <= 2f64.powi(28 + bits as i32)) .ok_or(ConfigError::Invalid( @@ -218,87 +195,60 @@ fn parameters_100( let next_list_size = if final_level { 1.0 } else { - mul_up(list_size, sqrt_bounds(2.0).1) + list_size * 2f64.sqrt() }; - let mut queries = 0; - let mut all_queries_miss = 1.0; - while query_and_batching_error(all_queries_miss, queries, next_list_size, true) - > 2f64.powi(-100) + level.queries = (100.0 / per_query_bits).ceil() as usize; + while query_and_batching_error( + (-per_query_bits * level.queries as f64).exp2(), + level.queries, + next_list_size, + true, + ) > 2f64.powi(-100) { - if queries == codeword_length { - return Err(ConfigError::Invalid("queries exceed codeword length")); - } - all_queries_miss = mul_up(all_queries_miss, query_miss); - queries += 1; + level.queries += 1; } - Ok(LevelParameters { - queries, - fold_grinding_bits, - query_grinding_bits: 0, - initial_ood_grinding, - }) + level.grinding_bits = 0; + Ok(initial_ood_grinding) } /// Selects the 128-bit parameters with unique decoding and no OOD checks. -fn parameters_128( - log_msg_cols: usize, - log_inv_rate: usize, -) -> Result { - let first = log_inv_rate == 1; - let final_level = log_msg_cols == FINAL_LOG_N; - let codeword_length = 1usize << (log_msg_cols + log_inv_rate); - - // 1. Calculate radius = distance/2 - 3/(distance*n), where distance = 1 - 1/inverse_rate. - // Keep radius*n as an exact fraction for the fold check. - let length = codeword_length as u128; - let inverse_rate = 1u128 << log_inv_rate; +fn configure_128( + level: &mut LigeritoLevelConfig, + first: bool, + final_level: bool, +) -> Result<(), ConfigError> { + // 1. Check the theorem range, then reuse Flock's folding and per-query estimates. + let length = 1u128 << (level.log_msg_cols + level.log_inv_rate); + let inverse_rate = 1u128 << level.log_inv_rate; let scaled_distance_squared = (inverse_rate - 1).pow(2) * length; // BCHKS25 Corollary 1.4 requires distance^2 * n >= 18. if scaled_distance_squared < 18 * inverse_rate.pow(2) { return Err(ConfigError::Invalid("UDR theorem range")); } - let denominator = 2 * inverse_rate * (inverse_rate - 1); - let radius_times_length_numerator = scaled_distance_squared - 6 * inverse_rate.pow(2); + let (fold_bits, per_query_bits) = level.paper_predicted_bits(); + let fold_coefficient = (128.0 - fold_bits).exp2(); - // 2. Choose constant fold grinding. Each fold and its sumcheck cost (radius*n + 3) / 2^128. + // 2. Choose constant fold grinding. Add the sumcheck's coefficient of two to Flock's fold coefficient. // An unground first recursive fold also shares the preceding claim-batching challenge. - let fold_grinding_bits = (0..=MAX_GRINDING_BITS) + level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) .find(|&bits| { - let claim_batching = u128::from(!first && bits == 0); - let error_numerator = - radius_times_length_numerator + (3 + claim_batching) * denominator; - error_numerator <= denominator * (1u128 << bits) + let claim_batching = f64::from(!first && bits == 0); + fold_coefficient + 2.0 + claim_batching <= 2f64.powi(bits as i32) }) .ok_or(ConfigError::Invalid("UDR fold grinding exceeds cap"))?; - // 3. Add queries until their miss probability alone meets 2^-128. - let miss_numerator = denominator * length - radius_times_length_numerator; - let query_miss = div_up( - (miss_numerator as f64).next_up(), - ((denominator * length) as f64).next_down(), - ); - let mut queries = 0; - let mut all_queries_miss = 1.0; - while all_queries_miss > 2f64.powi(-128) { - if queries == codeword_length { - return Err(ConfigError::Invalid("queries exceed codeword length")); - } - all_queries_miss = mul_up(all_queries_miss, query_miss); - queries += 1; - } + // 3. Reuse Flock's per-query estimate. Queries alone must meet 2^-128. + level.queries = (128.0 / per_query_bits).ceil() as usize; + let all_queries_miss = (-per_query_bits * level.queries as f64).exp2(); // 4. Choose query grinding to cover query misses and batching together. // Unique decoding has one candidate. Only the final level includes the extra claim-batching challenge. - let combined_error = query_and_batching_error(all_queries_miss, queries, 1.0, final_level); - let query_grinding_bits = (0..=MAX_GRINDING_BITS) + let combined_error = + query_and_batching_error(all_queries_miss, level.queries, 1.0, final_level); + level.grinding_bits = (0..=MAX_GRINDING_BITS) .find(|&bits| combined_error <= 2f64.powi(bits as i32 - 128)) .ok_or(ConfigError::Invalid("UDR query grinding exceeds cap"))?; - Ok(LevelParameters { - queries, - fold_grinding_bits, - query_grinding_bits, - initial_ood_grinding: None, - }) + Ok(()) } /// Adds query misses and batching error. Each batching challenge costs list_size / 2^128. @@ -310,35 +260,7 @@ fn query_and_batching_error( ) -> f64 { let batching_challenges = queries.next_power_of_two().ilog2() + u32::from(include_claim_batching); - let batching_error = mul_up(f64::from(batching_challenges), list_size) * 2f64.powi(-128); - add_up(all_queries_miss, batching_error) -} - -// Round error bounds upward and divisors downward. Rounding must never weaken a bound. -fn add_up(left: f64, right: f64) -> f64 { - (left + right).next_up() -} - -fn mul_up(left: f64, right: f64) -> f64 { - (left * right).next_up() -} - -fn div_up(numerator: f64, denominator: f64) -> f64 { - (numerator / denominator).next_up() -} - -fn sqrt_bounds(value: f64) -> (f64, f64) { - // Check both endpoints independently of the platform's sqrt rounding. - let root = value.sqrt(); - let mut lower = root.next_down(); - while mul_up(lower, lower) > value { - lower = lower.next_down(); - } - let mut upper = root.next_up(); - while (upper * upper).next_down() < value { - upper = upper.next_up(); - } - (lower, upper) + all_queries_miss + f64::from(batching_challenges) * list_size * 2f64.powi(-128) } #[cfg(test)] From 6781fd9502190a7c36ad92de3c813fe71a4ca779 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 12:14:37 +0200 Subject: [PATCH 11/16] Use multilinear initial OOD sampling without grinding --- crates/pcs/src/commitment.rs | 26 ++++--- crates/pcs/src/ood.rs | 92 +++++++++++++------------ crates/pcs/src/opening/tests.rs | 2 +- crates/pcs/src/profiles.rs | 116 +++++++++++--------------------- 4 files changed, 99 insertions(+), 137 deletions(-) diff --git a/crates/pcs/src/commitment.rs b/crates/pcs/src/commitment.rs index 7ea24e9d..3270b8cc 100644 --- a/crates/pcs/src/commitment.rs +++ b/crates/pcs/src/commitment.rs @@ -20,7 +20,7 @@ use transcript::{Encoding, ProverState, PublicTranscript, VerifierState}; // Increment this version when parameter derivation or transcript rules change. // This includes protocol changes in Flock or the selected hash. -const PROTOCOL_VERSION: &[u8] = b"bitz/pcs/security/v2"; +const PROTOCOL_VERSION: &[u8] = b"bitz/pcs/security/v1"; /// Errors from PCS configuration. #[derive(Clone, Debug, PartialEq, Eq)] @@ -42,7 +42,6 @@ pub enum CommitError { pub struct Pcs { params: PcsParams, checked_ligerito: CheckedLigerito, - ood_grinding_bits: Option, bit_len: usize, security_level: SecurityLevel, } @@ -74,7 +73,7 @@ impl Commitment { pub(crate) fn matches(&self, pcs: &Pcs) -> bool { self.bit_len == pcs.bit_len() && self.security_level == pcs.security_level() - && self.ood.is_some() == pcs.ood_grinding_bits().is_some() + && self.ood.is_some() == (pcs.security_level() == SecurityLevel::Bits100) } } @@ -83,7 +82,7 @@ impl Pcs { /// The 100-bit profile uses Johnson decoding; the 128-bit profile uses unique decoding. pub fn new(shape: &Shape, security_level: SecurityLevel) -> Result { let m = shape.log_bits(); - let (security, ood_grinding_bits) = security_config(m, security_level)?; + let security = security_config(m, security_level)?; let bit_len = 1usize .checked_shl(m as u32) .ok_or(ConfigError::Invalid("bit length overflow"))?; @@ -102,7 +101,6 @@ impl Pcs { Ok(Self { params, checked_ligerito, - ood_grinding_bits, bit_len, security_level, }) @@ -128,7 +126,7 @@ impl Pcs { // 3. Build Public Commitment let root = Root(flock_commitment.root); self.bind_commitment(root, transcript); - let ood = ood::prove(self, &root.0, packed_witness, transcript); + let ood = ood::prove(self, packed_witness, transcript); // 4. Retain Opening Data Ok(( @@ -153,7 +151,7 @@ impl Pcs { transcript: &mut VerifierState<'_>, ) -> Result { self.bind_commitment(root, transcript); - let ood = ood::verify(self, &root.0, transcript)?; + let ood = ood::verify(self, transcript)?; Ok(Commitment { root, bit_len: self.bit_len, @@ -181,10 +179,6 @@ impl Pcs { &self.params } - pub(crate) fn ood_grinding_bits(&self) -> Option { - self.ood_grinding_bits - } - /// Returns the selected classical PCS round budget. pub fn security_level(&self) -> SecurityLevel { self.security_level @@ -256,7 +250,7 @@ mod tests { let shape = Shape::new(7, m - 7).unwrap(); for level in [SecurityLevel::Bits100, SecurityLevel::Bits128] { let pcs = Pcs::new(&shape, level).unwrap(); - let (config, _) = security_config(m, level).unwrap(); + let config = security_config(m, level).unwrap(); let expected: usize = config .levels .iter() @@ -278,11 +272,15 @@ mod tests { assert_eq!(data.commitment().ood.is_some(), expected_ood); let next_challenge = prover.verifier_message::(); let proof = prover.finish(); - assert_eq!(proof.narg_string.is_empty(), !expected_ood); + assert_eq!(proof.narg_string.len(), if expected_ood { 16 } else { 0 }); let mut verifier = build_verifier(b"commit-test", b"profile", &proof); let received = pcs.receive_commitment(root, &mut verifier).unwrap(); assert_eq!(received.root(), data.commitment().root()); assert_eq!(received.ood.is_some(), expected_ood); + if let Some(claim) = received.ood.as_ref() { + assert_eq!(claim.point, data.commitment().ood.as_ref().unwrap().point); + assert_eq!(claim.point.len(), pcs.packed_len().ilog2() as usize); + } assert!(received.matches(&pcs)); assert_eq!(verifier.verifier_message::(), next_challenge); verifier.check_eof().unwrap(); @@ -349,7 +347,7 @@ mod tests { // Fixed encoding: version tag, padded bit count (u64 LE), target (u32 LE). assert_eq!( low.encode().as_ref(), - b"bitz/pcs/security/v2\x00\x00\x10\x00\x00\x00\x00\x00\x64\x00\x00\x00" + b"bitz/pcs/security/v1\x00\x00\x10\x00\x00\x00\x00\x00\x64\x00\x00\x00" ); assert_ne!(low.encode().as_ref(), high.encode().as_ref()); let (_, data) = low diff --git a/crates/pcs/src/ood.rs b/crates/pcs/src/ood.rs index acae13be..63ff2b5d 100644 --- a/crates/pcs/src/ood.rs +++ b/crates/pcs/src/ood.rs @@ -1,10 +1,15 @@ //! Initial out-of-domain claim on the packed commitment polynomial. //! -//! After binding the root and PCS parameters, the prover performs any configured -//! grinding, samples `zeta`, and sends `value = p(point)`, where `p` is the packed -//! witness MLE and `point[i] = zeta^(2^i)` in low-bit-first order. +//! The caller binds the root and PCS parameters before this round. +//! We sample one independent field coordinate per packed variable, as in Flock's recursive OOD checks. +//! The prover sends `value = p(point)`, where `p` is the packed witness MLE. //! The verifier derives the same point and reads the claimed value. //! +//! This adapts BitZ Lemma B.2 with independent coordinates and the multilinear Schwartz-Zippel bound. +//! Two distinct candidates differ by a nonzero multilinear polynomial of total degree at most `point.len()`. +//! For at most `L` candidates, the collision bound is `L^2 * point.len() / (2 * 2^128)`. +//! This meets the 100-bit target for every supported witness size without initial OOD grinding. +//! //! After ring switching, a fresh `coefficient` batches this claim into Ligerito: //! `basis += coefficient * eq(point, ·)` and `target += coefficient * value`. //! The claim remains borrowed from commitment state so it can be used by multiple @@ -18,18 +23,17 @@ use rayon::{current_num_threads, prelude::*}; use transcript::{ProverState, PublicTranscript, VerifierState}; use crate::bridge::{as_flock_f128, from_flock_f128}; -use crate::{Pcs, VerifyError}; +use crate::{Pcs, SecurityLevel, VerifyError}; const OOD_ROUND_TAG: &[u8] = b"bitz/pcs/ood/v1"; const OOD_BATCHING_TAG: &[u8] = b"bitz/pcs/ood-batching/v1"; -const OOD_POW_TAG: &[u8] = b"bitz/pcs/ood-pow/v1"; const BLOCK_LOG: usize = 12; const PARALLEL_MIN_LEN: usize = 1 << 18; /// An evaluation of the packed witness MLE, authenticated by the batched opening. #[derive(Debug)] pub(crate) struct OodClaim { - /// Successive squares of the sampled challenge, in low-bit-first order. + /// Independent field coordinates in low-bit-first variable order. pub(crate) point: Vec, /// Claimed MLE evaluation at `point`. pub(crate) value: F128, @@ -37,36 +41,26 @@ pub(crate) struct OodClaim { /// Sends the initial evaluation after binding the commitment and configuration. /// Returns `None` without transcript events when the profile omits OOD sampling. -pub(crate) fn prove( - pcs: &Pcs, - root: &[u8; 32], - packed: &[F128], - transcript: &mut ProverState, -) -> Option { - let grinding_bits = pcs.ood_grinding_bits()?; - absorb_header(pcs, root, grinding_bits, transcript); - transcript.grind(OOD_POW_TAG, grinding_bits); - let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); +pub(crate) fn prove(pcs: &Pcs, packed: &[F128], transcript: &mut ProverState) -> Option { + if pcs.security_level() != SecurityLevel::Bits100 { + return None; + } + let point = sample_point(pcs.packed_len(), transcript); let value = DenseMultilinearExtension::evaluate_exact(packed, &point); transcript.prover_message(&value); Some(OodClaim { point, value }) } -/// Reads the initial evaluation, checking grinding before sampling its point. +/// Reads the initial evaluation after sampling its point. /// Reading the value does not authenticate it; the caller must verify its opening. pub(crate) fn verify( pcs: &Pcs, - root: &[u8; 32], transcript: &mut VerifierState<'_>, ) -> Result, VerifyError> { - let Some(grinding_bits) = pcs.ood_grinding_bits() else { + if pcs.security_level() != SecurityLevel::Bits100 { return Ok(None); - }; - absorb_header(pcs, root, grinding_bits, transcript); - transcript - .grind(OOD_POW_TAG, grinding_bits) - .map_err(|_| VerifyError::MalformedProof)?; - let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); + } + let point = sample_point(pcs.packed_len(), transcript); let value = transcript .prover_message::() .map_err(|_| VerifyError::MalformedProof)?; @@ -124,36 +118,40 @@ pub(crate) fn add_succinct_basis( } } -fn absorb_header( - pcs: &Pcs, - root: &[u8; 32], - grinding_bits: u32, - transcript: &mut impl PublicTranscript, -) { +fn sample_point(packed_len: usize, transcript: &mut impl PublicTranscript) -> Vec { transcript.public_message(OOD_ROUND_TAG); - transcript.public_message(root); - transcript.public_message(pcs); - transcript.public_message(&(pcs.packed_len() as u64)); - transcript.public_message(&grinding_bits); -} - -fn ood_point(zeta: F128, packed_len: usize) -> Vec { - let mut point = Vec::with_capacity(packed_len.ilog2() as usize); - let mut coordinate = zeta; - for _ in 0..packed_len.ilog2() { - point.push(coordinate); - coordinate *= coordinate; - } - point + (0..packed_len.ilog2()) + .map(|_| transcript.verifier_message_f128()) + .collect() } #[cfg(test)] mod tests { use num_traits::ConstZero; use rayon::ThreadPoolBuilder; + use transcript::build_prover; use super::*; + #[test] + fn each_ood_coordinate_consumes_a_fresh_challenge() { + for variables in 13..=28 { + let mut prover = build_prover(b"ood-test", b"independent-coordinates"); + let mut expected = build_prover(b"ood-test", b"independent-coordinates"); + expected.public_message(OOD_ROUND_TAG); + let point = sample_point(1 << variables, &mut prover); + assert_eq!(point.len(), variables); + for coordinate in point { + assert_eq!(coordinate, expected.verifier_message::()); + } + assert_eq!( + prover.verifier_message::(), + expected.verifier_message::() + ); + assert!(prover.finish().narg_string.is_empty()); + } + } + #[test] fn dense_basis_updates_match_the_full_equality_table_across_thread_counts() { let pools: Vec<_> = [1, 2, 4, 8] @@ -168,7 +166,7 @@ mod tests { for log_len in [0, 8, 12, 17, 18, 19] { let len = 1usize << log_len; let claim = OodClaim { - point: ood_point(F128::new(7, 11), len), + point: sample_point(len, &mut build_prover(b"ood-test", b"dense-basis")), value: F128::ZERO, }; let weights = eq_table(&claim.point); @@ -197,7 +195,7 @@ mod tests { #[test] fn dense_and_succinct_ood_bases_agree_after_folding() { - let point = ood_point(F128::new(7, 11), 1 << 14); + let point = sample_point(1 << 14, &mut build_prover(b"ood-test", b"folded-basis")); let coefficient = F128::new(13, 17); let claim = OodClaim { point, diff --git a/crates/pcs/src/opening/tests.rs b/crates/pcs/src/opening/tests.rs index e67ec4bb..8fd63ca3 100644 --- a/crates/pcs/src/opening/tests.rs +++ b/crates/pcs/src/opening/tests.rs @@ -216,7 +216,7 @@ fn zero_weight_factor_still_requires_the_correct_pcs_witness_evaluation() { let fixture = fixture(); let pcs = &fixture.pcs; let shape = Shape::new(7, M - 7).unwrap(); - assert_eq!(pcs.ood_grinding_bits(), Some(0)); + assert_eq!(pcs.security_level(), SecurityLevel::Bits100); for zero_rows in [true, false] { let mut rows = fixture.claim.row_weights().to_vec(); diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index 846a7c61..d89bf445 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -9,10 +9,10 @@ //! //! The 100-bit profile uses Johnson list decoding, OOD checks, and no query grinding. //! The 128-bit profile uses unique decoding, no OOD checks, and query grinding. -//! Flock supplies folding, query, and recursive OOD estimates. We add our combined error costs. +//! Flock supplies folding, query, and multilinear OOD estimates. We add our combined error costs. //! //! These targets concern classical challenge blocks. They assume uniform transcript challenges and classical PoW costs. -//! Sources: BitZ Remark A.2 and Lemma B.2; Flock Appendix C.3; BCHKS25 Corollary 1.4. +//! Sources: BitZ Remark A.2; Flock Appendix C.3; BCHKS25 Corollary 1.4. use flock_core::pcs::LOG_PACKING; use flock_core::pcs::ligerito::{ @@ -31,11 +31,10 @@ const MAX_GRINDING_BITS: usize = transcript::pow::MAX_GRINDING_BITS as usize; /// /// Per level: choose the shape, ask Flock for base estimates, then select queries and grinding. /// We use one query initially to measure its contribution. We store only the completed configuration. -/// The separate initial OOD result uses `None` for no check and `Some(0)` for a check without grinding. pub(crate) fn security_config( m: usize, security_level: SecurityLevel, -) -> Result<(LigeritoSecurityConfig, Option), ConfigError> { +) -> Result { if !(20..=35).contains(&m) { return Err(ConfigError::Invalid("unsupported PCS size")); } @@ -43,7 +42,6 @@ pub(crate) fn security_config( let log_n = m - LOG_PACKING; let mut remaining = log_n; let mut levels = Vec::new(); - let mut initial_ood = None; while remaining > FINAL_LOG_N { // 1. Choose folds. Leave five variables for the explicit final message. @@ -81,7 +79,7 @@ pub(crate) fn security_config( // Starting values. The selected profile sets the final query count and grinding below. grinding_bits: 0, fold_grinding_bits: 0, - // Later Johnson levels use one OOD sample. The initial OOD check has separate configuration. + // Later Johnson levels use one OOD sample. The commitment performs the initial check. ood_samples: usize::from(johnson && !first), target_security_bits: security_level.bits() as usize, expected_eps_pg_bits: 0.0, @@ -90,15 +88,9 @@ pub(crate) fn security_config( }; // 4. Select parameters using Flock's estimates and our combined error costs. - let ood = match security_level { + match security_level { SecurityLevel::Bits100 => configure_100(&mut level, first, remaining == FINAL_LOG_N)?, - SecurityLevel::Bits128 => { - configure_128(&mut level, first, remaining == FINAL_LOG_N)?; - None - } - }; - if first { - initial_ood = ood; + SecurityLevel::Bits128 => configure_128(&mut level, first, remaining == FINAL_LOG_N)?, } if level.queries > 1usize << (remaining + log_inv_rate) { return Err(ConfigError::Invalid("queries exceed codeword length")); @@ -112,28 +104,25 @@ pub(crate) fn security_config( levels.push(level); } - Ok(( - LigeritoSecurityConfig { - m, - log_n, - initial_k: INITIAL_K, - target_security_bits: security_level.bits() as usize, - analysis_version: if johnson { - "bitz_johnson_combined_blocks_v1" - } else { - "bitz_udr_combined_blocks_v1" - } - .into(), - field: "f128".into(), - hash: "blake3".into(), - grinding_step: GrindingStep::PostCommitPreQueries, - levels, - final_block: FinalBlockConfig { - yr_log_n: remaining, - }, + Ok(LigeritoSecurityConfig { + m, + log_n, + initial_k: INITIAL_K, + target_security_bits: security_level.bits() as usize, + analysis_version: if johnson { + "bitz_johnson_combined_blocks_v2" + } else { + "bitz_udr_combined_blocks_v1" + } + .into(), + field: "f128".into(), + hash: "blake3".into(), + grinding_step: GrindingStep::PostCommitPreQueries, + levels, + final_block: FinalBlockConfig { + yr_log_n: remaining, }, - initial_ood, - )) + }) } /// Selects the 100-bit parameters with Johnson list decoding. @@ -141,8 +130,7 @@ fn configure_100( level: &mut LigeritoLevelConfig, first: bool, final_level: bool, -) -> Result, ConfigError> { - let variables = level.log_msg_cols + level.log_num_interleaved; +) -> Result<(), ConfigError> { // Error coefficient C represents probability C / 2^128. The 100-bit target permits C <= 2^28. let allowed_coefficient = 2f64.powi(28); @@ -167,27 +155,19 @@ fn configure_100( .ok_or(ConfigError::Invalid("Johnson fold grinding exceeds cap"))?; // 3. Check OOD selection and batching. Their challenges precede fold grinding. - if level.paper_predicted_ood_bits().unwrap() < 100.0 { - return Err(ConfigError::Invalid("Johnson recursive OOD bound")); + // Independent coordinates give degree at most mu. Pair collisions cost at most L^2 * mu / (2 * |F|). + // The commitment performs the initial check. Its backend sample count stays zero. + let explicit_ood = LigeritoLevelConfig { + ood_samples: 1, + ..level.clone() + }; + if explicit_ood.paper_predicted_ood_bits().unwrap() < 100.0 { + return Err(ConfigError::Invalid("Johnson OOD bound")); } let batching_challenges = if first { 8.0 } else { 1.0 }; if batching_challenges * list_size > allowed_coefficient { return Err(ConfigError::Invalid("Johnson unground batching bound")); } - let initial_ood_grinding = if first { - // BitZ Lemma B.2: each candidate pair can collide at at most degree points. - let candidate_pairs = list_size * (list_size - 1.0) * 0.5; - let degree = ((1u64 << variables) - 1) as f64; - let collision_coefficient = candidate_pairs * degree; - let bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| collision_coefficient <= 2f64.powi(28 + bits as i32)) - .ok_or(ConfigError::Invalid( - "Johnson initial OOD grinding exceeds cap", - ))?; - Some(bits as u32) - } else { - None - }; // 4. Add queries until query misses and batching together meet 2^-100, without query grinding. // The next commitment halves the rate, increasing its list bound by sqrt(2). @@ -208,7 +188,7 @@ fn configure_100( level.queries += 1; } level.grinding_bits = 0; - Ok(initial_ood_grinding) + Ok(()) } /// Selects the 128-bit parameters with unique decoding and no OOD checks. @@ -277,7 +257,7 @@ mod tests { #[test] fn udr_m22_matches_audited_parameters() { - let (config, initial_ood) = security_config(22, SecurityLevel::Bits128).unwrap(); + let config = security_config(22, SecurityLevel::Bits128).unwrap(); assert_eq!(config.initial_k, 4); assert_eq!(config.final_block.yr_log_n, 5); assert_eq!(config.hash, "blake3"); @@ -288,14 +268,12 @@ mod tests { assert_eq!(level.grinding_bits, 4); assert_eq!(level.ood_samples, 0); } - assert_eq!(initial_ood, None); } #[test] fn udr_all_supported_sizes_cover_combined_errors() { for m in 20..=35 { - let (config, initial_ood) = security_config(m, SecurityLevel::Bits128).unwrap(); - assert_eq!(initial_ood, None); + let config = security_config(m, SecurityLevel::Bits128).unwrap(); assert_eq!(config.hash, "blake3"); config.to_prover_verifier_configs().unwrap(); for (index, level) in config.levels.iter().enumerate() { @@ -338,9 +316,8 @@ mod tests { #[test] fn johnson_queries_replace_query_grinding() { for (m, folds) in [(20, [7, 4, 1]), (22, [9, 6, 3])] { - let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); + let config = security_config(m, SecurityLevel::Bits100).unwrap(); assert_eq!(config.target_security_bits, 100); - assert_eq!(initial_ood, Some(0)); for (index, level) in config.levels.iter().enumerate() { assert_eq!(level.queries, [218, 106, 71][index]); assert_eq!(level.fold_grinding_bits, folds[index]); @@ -353,16 +330,8 @@ mod tests { #[test] fn johnson_all_sizes_cover_combined_blocks() { for m in 20..=35 { - let (config, initial_ood) = security_config(m, SecurityLevel::Bits100).unwrap(); + let config = security_config(m, SecurityLevel::Bits100).unwrap(); config.to_prover_verifier_configs().unwrap(); - let initial_grinding = initial_ood.unwrap(); - let list = 1.0 / (0.04 * 0.5f64.sqrt()); - let initial = list * (list - 1.0) * 0.5 * (2f64.powi(m as i32 - 7) - 1.0); - assert!(initial <= 2f64.powi(28 + initial_grinding as i32)); - assert!(initial_grinding <= 32); - if initial_grinding > 0 { - assert!(initial > 2f64.powi(27 + initial_grinding as i32)); - } for (index, level) in config.levels.iter().enumerate() { // Reconstruct the published formulas independently of the bound helpers. let rho = 2f64.powi(-(level.log_inv_rate as i32)); @@ -400,11 +369,8 @@ mod tests { * 2f64.powi(-128); assert!(previous_query > 2f64.powi(-100)); let mu = (level.log_msg_cols + level.log_num_interleaved) as f64; - let ood = if index == 0 { - list * mu - } else { - list * list * mu * 0.5 - }; + // Initial and recursive OOD points use independent coordinates, with total degree at most mu. + let ood = list * list * mu * 0.5; assert!(ood <= 2f64.powi(28)); assert!((if index == 0 { 8.0 } else { 1.0 }) * list <= 2f64.powi(28)); assert!(level.queries <= n as usize); @@ -420,7 +386,7 @@ mod tests { fn both_profiles_keep_a_five_variable_residual() { for security in [SecurityLevel::Bits100, SecurityLevel::Bits128] { for m in 20..=35 { - let (config, _) = security_config(m, security).unwrap(); + let config = security_config(m, security).unwrap(); assert_eq!(config.levels[0].k_recursive, 4); assert_eq!(config.final_block.yr_log_n, 5); let total_folds: usize = config.levels.iter().map(|level| level.k_recursive).sum(); From ec8b190f879930b54f71619a1d8d26d322b86a2c Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 12:41:16 +0200 Subject: [PATCH 12/16] Simplify Johnson security parameters with closed formulas --- crates/pcs/src/profiles.rs | 77 ++++++++------------------------------ 1 file changed, 16 insertions(+), 61 deletions(-) diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index d89bf445..cca761f1 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -89,7 +89,7 @@ pub(crate) fn security_config( // 4. Select parameters using Flock's estimates and our combined error costs. match security_level { - SecurityLevel::Bits100 => configure_100(&mut level, first, remaining == FINAL_LOG_N)?, + SecurityLevel::Bits100 => configure_100(&mut level), SecurityLevel::Bits128 => configure_128(&mut level, first, remaining == FINAL_LOG_N)?, } if level.queries > 1usize << (remaining + log_inv_rate) { @@ -126,69 +126,23 @@ pub(crate) fn security_config( } /// Selects the 100-bit parameters with Johnson list decoding. -fn configure_100( - level: &mut LigeritoLevelConfig, - first: bool, - final_level: bool, -) -> Result<(), ConfigError> { - // Error coefficient C represents probability C / 2^128. The 100-bit target permits C <= 2^28. - let allowed_coefficient = 2f64.powi(28); - +/// Tests check the fixed OOD and batching bounds for every supported size. +fn configure_100(level: &mut LigeritoLevelConfig) { // 1. Reuse Flock's folding and per-query estimates. The fold estimate already includes the row multiplier. let (fold_bits, per_query_bits) = level.paper_predicted_bits(); let fold_coefficient = (128.0 - fold_bits).exp2(); let rate = 2f64.powi(-(level.log_inv_rate as i32)); let list_size = 1.0 / (2.0 * JOHNSON_ETA * rate.sqrt()); - // 2. Choose the smallest fold grinding that covers every round and its sumcheck. - // Johnson halves the fold coefficient and decreases grinding by one bit after each round. - level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| { - (0..level.k_recursive).all(|round| { - let grinding = bits.saturating_sub(round); - let claim_batching = f64::from(!first && round == 0 && grinding == 0); - let total_coefficient = fold_coefficient * 2f64.powi(-(round as i32)) - + (2.0 + claim_batching) * list_size; - total_coefficient <= 2f64.powi(28 + grinding as i32) - }) - }) - .ok_or(ConfigError::Invalid("Johnson fold grinding exceeds cap"))?; - - // 3. Check OOD selection and batching. Their challenges precede fold grinding. - // Independent coordinates give degree at most mu. Pair collisions cost at most L^2 * mu / (2 * |F|). - // The commitment performs the initial check. Its backend sample count stays zero. - let explicit_ood = LigeritoLevelConfig { - ood_samples: 1, - ..level.clone() - }; - if explicit_ood.paper_predicted_ood_bits().unwrap() < 100.0 { - return Err(ConfigError::Invalid("Johnson OOD bound")); - } - let batching_challenges = if first { 8.0 } else { 1.0 }; - if batching_challenges * list_size > allowed_coefficient { - return Err(ConfigError::Invalid("Johnson unground batching bound")); - } + // 2. b = max(0, ceil(log2(fold_coefficient + 2^k_recursive * list_size)) - 28), for b >= k_recursive. + // Derived from Flock Appendix C.3 (paper_predicted_bits), plus our quadratic sumcheck error. + let coefficient = fold_coefficient + 2f64.powi(level.k_recursive as i32) * list_size; + level.fold_grinding_bits = (coefficient.log2().ceil() - 28.0).max(0.0) as usize; - // 4. Add queries until query misses and batching together meet 2^-100, without query grinding. - // The next commitment halves the rate, increasing its list bound by sqrt(2). - // The explicit final message has only one candidate. - let next_list_size = if final_level { - 1.0 - } else { - list_size * 2f64.sqrt() - }; + // 3. q = ceil(100 / per_query_bits). Supported profiles leave enough error budget for batching. + // See Flock paper_predicted_bits and johnson_all_sizes_cover_combined_blocks. level.queries = (100.0 / per_query_bits).ceil() as usize; - while query_and_batching_error( - (-per_query_bits * level.queries as f64).exp2(), - level.queries, - next_list_size, - true, - ) > 2f64.powi(-100) - { - level.queries += 1; - } level.grinding_bits = 0; - Ok(()) } /// Selects the 128-bit parameters with unique decoding and no OOD checks. @@ -336,9 +290,10 @@ mod tests { // Reconstruct the published formulas independently of the bound helpers. let rho = 2f64.powi(-(level.log_inv_rate as i32)); let sqrt_rho = rho.sqrt(); - let list = 1.0 / (0.04 * sqrt_rho); - let gamma = 1.0 - sqrt_rho - 0.02; - let half = (sqrt_rho / 0.04).ceil().max(3.0) + 0.5; + let eta = level.eta.unwrap(); + let list = 1.0 / (2.0 * eta * sqrt_rho); + let gamma = 1.0 - sqrt_rho - eta; + let half = (sqrt_rho / (2.0 * eta)).ceil().max(3.0) + 0.5; let n = 2f64.powi((level.log_msg_cols + level.log_inv_rate) as i32); let base = (2.0 * half.powi(5) + 3.0 * half * gamma * rho) / (3.0 * rho.powf(1.5)) * n @@ -357,13 +312,13 @@ mod tests { assert!(!folds_fit(level.fold_grinding_bits - 1)); } let next_list = config.levels.get(index + 1).map_or(1.0, |next| { - 1.0 / (0.04 * 2f64.powi(-(next.log_inv_rate as i32)).sqrt()) + 1.0 / (2.0 * next.eta.unwrap() * 2f64.powi(-(next.log_inv_rate as i32)).sqrt()) }); let alpha = level.queries.next_power_of_two().ilog2(); - let query = (sqrt_rho + 0.02).powi(level.queries as i32) + let query = (sqrt_rho + eta).powi(level.queries as i32) + f64::from(alpha + 1) * next_list * 2f64.powi(-128); assert!(query <= 2f64.powi(-100)); - let previous_query = (sqrt_rho + 0.02).powi(level.queries as i32 - 1) + let previous_query = (sqrt_rho + eta).powi(level.queries as i32 - 1) + f64::from((level.queries - 1).next_power_of_two().ilog2() + 1) * next_list * 2f64.powi(-128); From ee9db5a7812cda9823fafe2493ae3296989c4db7 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 12:44:35 +0200 Subject: [PATCH 13/16] Simplify 128-bit security profile calculations --- crates/pcs/src/profiles.rs | 71 ++++++++------------------------------ 1 file changed, 15 insertions(+), 56 deletions(-) diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index cca761f1..f6389fbc 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -25,7 +25,6 @@ const INITIAL_K: usize = 4; const RECURSIVE_K: usize = 3; const FINAL_LOG_N: usize = 5; const JOHNSON_ETA: f64 = 0.02; -const MAX_GRINDING_BITS: usize = transcript::pow::MAX_GRINDING_BITS as usize; /// Derives every level from `m` and the selected target. /// @@ -90,7 +89,7 @@ pub(crate) fn security_config( // 4. Select parameters using Flock's estimates and our combined error costs. match security_level { SecurityLevel::Bits100 => configure_100(&mut level), - SecurityLevel::Bits128 => configure_128(&mut level, first, remaining == FINAL_LOG_N)?, + SecurityLevel::Bits128 => configure_128(&mut level), } if level.queries > 1usize << (remaining + log_inv_rate) { return Err(ConfigError::Invalid("queries exceed codeword length")); @@ -146,69 +145,28 @@ fn configure_100(level: &mut LigeritoLevelConfig) { } /// Selects the 128-bit parameters with unique decoding and no OOD checks. -fn configure_128( - level: &mut LigeritoLevelConfig, - first: bool, - final_level: bool, -) -> Result<(), ConfigError> { - // 1. Check the theorem range, then reuse Flock's folding and per-query estimates. - let length = 1u128 << (level.log_msg_cols + level.log_inv_rate); - let inverse_rate = 1u128 << level.log_inv_rate; - let scaled_distance_squared = (inverse_rate - 1).pow(2) * length; - // BCHKS25 Corollary 1.4 requires distance^2 * n >= 18. - if scaled_distance_squared < 18 * inverse_rate.pow(2) { - return Err(ConfigError::Invalid("UDR theorem range")); - } +/// Tests check the theorem range and combined bounds for every supported size. +fn configure_128(level: &mut LigeritoLevelConfig) { + // 1. Reuse Flock's folding and per-query estimates. let (fold_bits, per_query_bits) = level.paper_predicted_bits(); let fold_coefficient = (128.0 - fold_bits).exp2(); - // 2. Choose constant fold grinding. Add the sumcheck's coefficient of two to Flock's fold coefficient. - // An unground first recursive fold also shares the preceding claim-batching challenge. - level.fold_grinding_bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| { - let claim_batching = f64::from(!first && bits == 0); - fold_coefficient + 2.0 + claim_batching <= 2f64.powi(bits as i32) - }) - .ok_or(ConfigError::Invalid("UDR fold grinding exceeds cap"))?; + // 2. b = ceil(log2(fold_coefficient + 2)). Each fold uses the same grinding. + // Flock Appendix C.3 (BCHKS25 Corollary 1.4), plus our quadratic sumcheck error. + level.fold_grinding_bits = (fold_coefficient + 2.0).log2().ceil() as usize; - // 3. Reuse Flock's per-query estimate. Queries alone must meet 2^-128. + // 3. q = ceil(128 / per_query_bits). See Flock paper_predicted_bits. level.queries = (128.0 / per_query_bits).ceil() as usize; - let all_queries_miss = (-per_query_bits * level.queries as f64).exp2(); - - // 4. Choose query grinding to cover query misses and batching together. - // Unique decoding has one candidate. Only the final level includes the extra claim-batching challenge. - let combined_error = - query_and_batching_error(all_queries_miss, level.queries, 1.0, final_level); - level.grinding_bits = (0..=MAX_GRINDING_BITS) - .find(|&bits| combined_error <= 2f64.powi(bits as i32 - 128)) - .ok_or(ConfigError::Invalid("UDR query grinding exceeds cap"))?; - Ok(()) -} -/// Adds query misses and batching error. Each batching challenge costs list_size / 2^128. -fn query_and_batching_error( - all_queries_miss: f64, - queries: usize, - list_size: f64, - include_claim_batching: bool, -) -> f64 { - let batching_challenges = - queries.next_power_of_two().ilog2() + u32::from(include_claim_batching); - all_queries_miss + f64::from(batching_challenges) * list_size * 2f64.powi(-128) + // 4. 8 < 2^128 * query_miss + ceil(log2(q)) + final_claim <= 11 < 2^4. + // All supported sizes need four bits; see udr_all_supported_sizes_cover_combined_errors. + level.grinding_bits = 4; } #[cfg(test)] mod tests { use super::*; - #[test] - fn query_batching_covers_every_candidate() { - // Eight query-batching challenges and one claim-batching challenge each cover 64 candidates. - let error = query_and_batching_error(0.0, 256, 64.0, true); - assert!(error >= 576.0 * 2f64.powi(-128)); - assert!(error < 577.0 * 2f64.powi(-128)); - } - #[test] fn udr_m22_matches_audited_parameters() { let config = security_config(22, SecurityLevel::Bits128).unwrap(); @@ -236,7 +194,8 @@ mod tests { let rho = 2f64.powi(-(level.log_inv_rate as i32)); let delta = 1.0 - rho; let gamma = delta / 2.0 - 3.0 / (delta * n); - assert!(delta >= 3.0 * (2.0 / n).sqrt()); + // BCHKS25 Corollary 1.4 requires delta^2 * n >= 18. + assert!(delta * delta * n >= 18.0); assert!(gamma >= delta / 3.0 && gamma < delta / 2.0); let fold_error = |grinding| { let beta = f64::from(index > 0 && grinding == 0); @@ -260,8 +219,8 @@ mod tests { assert!(level.queries <= n as usize); assert!(level.fold_grinding_bits <= 32 && level.grinding_bits <= 32); assert!( - level.fold_grinding_bits == 0 || level.fold_grinding_bits >= level.k_recursive, - "every positive fold schedule must retain all native grinding hooks" + level.fold_grinding_bits >= level.k_recursive, + "every fold must retain its native grinding hook" ); } } From 95cbf41568600ee8ec08429dea00df39a91d7790 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 12:49:23 +0200 Subject: [PATCH 14/16] Clarify security configuration and level construction --- crates/pcs/src/profiles.rs | 120 +++++++++++++++++++------------------ 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index f6389fbc..79636393 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -26,10 +26,7 @@ const RECURSIVE_K: usize = 3; const FINAL_LOG_N: usize = 5; const JOHNSON_ETA: f64 = 0.02; -/// Derives every level from `m` and the selected target. -/// -/// Per level: choose the shape, ask Flock for base estimates, then select queries and grinding. -/// We use one query initially to measure its contribution. We store only the completed configuration. +/// Builds the fold schedule and configures each level for the selected target. pub(crate) fn security_config( m: usize, security_level: SecurityLevel, @@ -37,81 +34,39 @@ pub(crate) fn security_config( if !(20..=35).contains(&m) { return Err(ConfigError::Invalid("unsupported PCS size")); } - let johnson = security_level == SecurityLevel::Bits100; let log_n = m - LOG_PACKING; let mut remaining = log_n; let mut levels = Vec::new(); while remaining > FINAL_LOG_N { // 1. Choose folds. Leave five variables for the explicit final message. - let first = levels.is_empty(); - let folds = if first { + let folds = if levels.is_empty() { INITIAL_K } else { RECURSIVE_K.min(remaining - FINAL_LOG_N) }; remaining -= folds; - // 2. Choose the code rate: message columns / encoded columns. - // log_inv_rate = log2(encoded columns / message columns). - // levels.len() counts completed levels: 0, 1, 2, ... - // Adding 1 selects our fixed expansion schedule: 2x, 4x, 8x, ... - // The corresponding code rates are 1/2, 1/4, 1/8, ... - // More redundancy improves query detection bounds, so later levels need fewer queries. + // 2. log_inv_rate = log2(encoded columns / message columns). + // Completed levels start at zero; adding one gives rates 1/2, 1/4, 1/8, ... let log_inv_rate = levels.len() + 1; - - // 3. Give Flock the shape and decoding policy. One query measures the contribution per query. - let mut level = LigeritoLevelConfig { + levels.push(configure_level( + remaining, + folds, log_inv_rate, - log_msg_cols: remaining, - log_num_interleaved: folds, - k_recursive: folds, - regime: if johnson { - SoundnessRegime::JohnsonOod - } else { - SoundnessRegime::Udr - }, - eta: johnson.then_some(JOHNSON_ETA), - // Zero is the fixed backend policy for unique decoding. - proximity_loss: (!johnson).then_some(0.0), - queries: 1, - // Starting values. The selected profile sets the final query count and grinding below. - grinding_bits: 0, - fold_grinding_bits: 0, - // Later Johnson levels use one OOD sample. The commitment performs the initial check. - ood_samples: usize::from(johnson && !first), - target_security_bits: security_level.bits() as usize, - expected_eps_pg_bits: 0.0, - expected_eps_query_bits: 0.0, - expected_eps_ood_bits: None, - }; - - // 4. Select parameters using Flock's estimates and our combined error costs. - match security_level { - SecurityLevel::Bits100 => configure_100(&mut level), - SecurityLevel::Bits128 => configure_128(&mut level), - } - if level.queries > 1usize << (remaining + log_inv_rate) { - return Err(ConfigError::Invalid("queries exceed codeword length")); - } - - // 5. Refresh backend diagnostics with the final query count. - let (fold_bits, query_bits) = level.paper_predicted_bits(); - level.expected_eps_pg_bits = fold_bits; - level.expected_eps_query_bits = query_bits; - level.expected_eps_ood_bits = level.paper_predicted_ood_bits(); - levels.push(level); + security_level, + )?); } + // 3. Assemble the completed levels and the explicit final message. Ok(LigeritoSecurityConfig { m, log_n, initial_k: INITIAL_K, target_security_bits: security_level.bits() as usize, - analysis_version: if johnson { - "bitz_johnson_combined_blocks_v2" - } else { - "bitz_udr_combined_blocks_v1" + analysis_version: match security_level { + SecurityLevel::Bits100 => "bitz_johnson_combined_blocks_v2", + SecurityLevel::Bits128 => "bitz_udr_combined_blocks_v1", } .into(), field: "f128".into(), @@ -124,6 +79,55 @@ pub(crate) fn security_config( }) } +/// Builds one complete level, including the estimates that Flock requires for validation. +fn configure_level( + log_msg_cols: usize, + folds: usize, + log_inv_rate: usize, + security_level: SecurityLevel, +) -> Result { + // 1. Set the shape and decoding policy. One query measures the contribution per query. + let johnson = security_level == SecurityLevel::Bits100; + let mut level = LigeritoLevelConfig { + log_msg_cols, + log_inv_rate, + log_num_interleaved: folds, + k_recursive: folds, + regime: if johnson { + SoundnessRegime::JohnsonOod + } else { + SoundnessRegime::Udr + }, + eta: johnson.then_some(JOHNSON_ETA), + // Zero is the fixed backend policy for unique decoding. + proximity_loss: (!johnson).then_some(0.0), + // Later Johnson levels use one OOD sample. The commitment performs the initial check. + ood_samples: usize::from(johnson && log_inv_rate > 1), + target_security_bits: security_level.bits() as usize, + // Steps 2 and 3 replace these starting values. + queries: 1, + grinding_bits: 0, + fold_grinding_bits: 0, + expected_eps_pg_bits: 0.0, + expected_eps_query_bits: 0.0, + expected_eps_ood_bits: None, + }; + + // 2. Select queries and grinding for the requested target. + match security_level { + SecurityLevel::Bits100 => configure_100(&mut level), + SecurityLevel::Bits128 => configure_128(&mut level), + } + if level.queries > 1usize << (log_msg_cols + log_inv_rate) { + return Err(ConfigError::Invalid("queries exceed codeword length")); + } + + // 3. Store Flock's validation estimates using the final query count. + (level.expected_eps_pg_bits, level.expected_eps_query_bits) = level.paper_predicted_bits(); + level.expected_eps_ood_bits = level.paper_predicted_ood_bits(); + Ok(level) +} + /// Selects the 100-bit parameters with Johnson list decoding. /// Tests check the fixed OOD and batching bounds for every supported size. fn configure_100(level: &mut LigeritoLevelConfig) { From d4a014e2a877af154a753e28a5717ebb2534986f Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 12:59:40 +0200 Subject: [PATCH 15/16] Reuse Flock commitment state to reduce the diff --- crates/pcs/src/commitment.rs | 39 ++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/pcs/src/commitment.rs b/crates/pcs/src/commitment.rs index 3270b8cc..d4c2dccf 100644 --- a/crates/pcs/src/commitment.rs +++ b/crates/pcs/src/commitment.rs @@ -14,6 +14,7 @@ use crate::profiles::security_config; use common::{Root, SecurityLevel, Shape}; use field::F128; use flock_core::hash::HashKind; +use flock_core::pcs::Commitment as FlockCommitment; use flock_core::pcs::ligerito::LigeritoProfile; use flock_core::pcs::{LOG_PACKING, PcsParams, ProverData as FlockProverData, commit}; use transcript::{Encoding, ProverState, PublicTranscript, VerifierState}; @@ -43,6 +44,7 @@ pub struct Pcs { params: PcsParams, checked_ligerito: CheckedLigerito, bit_len: usize, + packed_len: usize, security_level: SecurityLevel, } @@ -52,14 +54,12 @@ pub struct ProverData { flock_prover_data: FlockProverData, } -/// The PCS commitment: its root, parameters, and optional initial OOD claim. -/// Both prover and verifier use this type after the commitment phase. -/// Verifiers construct it with [`Pcs::receive_commitment`] before subsequent challenges. -/// An opening must authenticate its OOD claim before the verifier accepts the proof. +/// Root, parameters, and optional out-of-domain claim retained after commitment. +/// Both prover and verifier use this state for openings on the commitment transcript. +/// The verifier authenticates the claim when [`CommitScheme::verify_lin`](crate::CommitScheme::verify_lin) succeeds. #[derive(Debug)] pub struct Commitment { - root: Root, - bit_len: usize, + flock: FlockCommitment, security_level: SecurityLevel, pub(crate) ood: Option, } @@ -67,11 +67,17 @@ pub struct Commitment { impl Commitment { /// Returns the public commitment root. pub fn root(&self) -> Root { - self.root + Root(self.flock.root) } pub(crate) fn matches(&self, pcs: &Pcs) -> bool { - self.bit_len == pcs.bit_len() + let expected = pcs.params(); + let actual = &self.flock.params; + expected.m == actual.m + && expected.log_inv_rate == actual.log_inv_rate + && expected.log_batch_size == actual.log_batch_size + && expected.profile == actual.profile + && expected.merkle_hash == actual.merkle_hash && self.security_level == pcs.security_level() && self.ood.is_some() == (pcs.security_level() == SecurityLevel::Bits100) } @@ -98,16 +104,18 @@ impl Pcs { merkle_hash: HashKind::Blake3, }; let checked_ligerito = CheckedLigerito::new(¶ms, &security)?; + let packed_len = bit_len >> LOG_PACKING; Ok(Self { params, checked_ligerito, bit_len, + packed_len, security_level, }) } - /// Commits and samples any initial OOD claim before subsequent protocol challenges. - /// Continue with this transcript for every opening of the retained data. + /// Commits and samples the initial OOD claim when the security profile requires it. + /// Call before witness-dependent challenges and continue with the same transcript. #[tracing::instrument(name = "Commit witness", skip_all)] pub fn commit( &self, @@ -133,8 +141,7 @@ impl Pcs { root, ProverData { commitment: Commitment { - root, - bit_len: self.bit_len, + flock: flock_commitment, security_level: self.security_level, ood, }, @@ -153,8 +160,10 @@ impl Pcs { self.bind_commitment(root, transcript); let ood = ood::verify(self, transcript)?; Ok(Commitment { - root, - bit_len: self.bit_len, + flock: FlockCommitment { + root: root.0, + params: self.params.clone(), + }, security_level: self.security_level, ood, }) @@ -172,7 +181,7 @@ impl Pcs { /// Returns the required number of packed `F128` elements. pub fn packed_len(&self) -> usize { - self.bit_len >> LOG_PACKING + self.packed_len } pub(crate) fn params(&self) -> &PcsParams { From d258b4412fc0c42db6dd139a27fe38223dbad646 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 6 Oct 2026 13:06:16 +0200 Subject: [PATCH 16/16] Simplify Ligerito grinding schedule construction --- crates/pcs/src/ligerito.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates/pcs/src/ligerito.rs b/crates/pcs/src/ligerito.rs index c6366393..9fe448db 100644 --- a/crates/pcs/src/ligerito.rs +++ b/crates/pcs/src/ligerito.rs @@ -45,22 +45,15 @@ impl CheckedLigerito { let final_log_n = validate_verifier_config(&verifier_config, log_n, params.log_batch_size)?; let mut pow_schedule = Vec::new(); for level in &security.levels { - if matches!(level.regime, SoundnessRegime::Udr) - && level.fold_grinding_bits != 0 - && level.fold_grinding_bits < level.k_recursive - { - return Err(ConfigError::Invalid("missing native fold grinding hook")); - } let bits = level.fold_grinding_bits as u32; + // Both profiles keep positive grinding through every fold; tests cover all supported sizes. for round in 0..level.k_recursive { - let native = bits.saturating_sub(round as u32); - if native > 0 { - let effective = match level.regime { - SoundnessRegime::Udr => bits, - SoundnessRegime::JohnsonOod => native, - }; - pow_schedule.push((native, effective)); - } + let native = bits - round as u32; + let effective = match level.regime { + SoundnessRegime::Udr => bits, + SoundnessRegime::JohnsonOod => native, + }; + pow_schedule.push((native, effective)); } pow_schedule.push((level.grinding_bits as u32, level.grinding_bits as u32)); }