From 58d653c860f88d7233f7cc0f65c2bd9f1c84cf0f Mon Sep 17 00:00:00 2001 From: ocdbytes Date: Thu, 23 Apr 2026 17:29:21 +0530 Subject: [PATCH 1/4] fix : evaluations binding with the transcript --- src/protocols/audit_soundness_tests.rs | 839 +++++++++++++++++++++++++ src/protocols/mod.rs | 3 + src/protocols/whir/prover.rs | 4 + src/protocols/whir/verifier.rs | 5 + src/protocols/whir_zk/committer.rs | 16 +- src/protocols/whir_zk/mod.rs | 64 +- src/protocols/whir_zk/prover.rs | 4 + src/protocols/whir_zk/utils.rs | 59 +- src/protocols/whir_zk/verifier.rs | 5 + 9 files changed, 897 insertions(+), 102 deletions(-) create mode 100644 src/protocols/audit_soundness_tests.rs diff --git a/src/protocols/audit_soundness_tests.rs b/src/protocols/audit_soundness_tests.rs new file mode 100644 index 00000000..f98c617f --- /dev/null +++ b/src/protocols/audit_soundness_tests.rs @@ -0,0 +1,839 @@ +/// Soundness regression tests for evaluation forgery attacks. +/// +/// All three issues share the same root cause: public evaluations were not +/// bound in the Fiat-Shamir transcript before the challenges that depend on +/// them (α, ρ, constraint_rlc). +/// +/// - **Issue #1 (α):** n > 1 polynomials batched via `Σ αⁱ·eᵢ`. +/// Forgery: `[+Δ, −Δ/α]` preserves the weighted sum. +/// - **Issue #2 (ρ):** zkWHIR combines `claim = ρ·e + G`. +/// Forgery: send `G' = G+Δ`, then `e' = e−Δ/ρ`. +/// - **Issue #3 (constraint_rlc):** f > 1 forms collapsed via `Σ cⱼ·claimⱼ`. +/// Forgery: `[+Δ, −Δ/c₁]` preserves the weighted sum. +/// +/// **Fix:** absorb all evaluations into the transcript before α, ρ, and +/// constraint_rlc are sampled. Verifier reads them back and checks +/// `verify!(read == expected)`. + +// ========================================================================= +// WHIR (non-ZK) +// ========================================================================= + +#[cfg(test)] +mod whir_tests { + use std::borrow::Cow; + + use ark_ff::Field; + + use crate::{ + algebra::{ + embedding::Basefield, + fields::{Field64, Field64_3}, + linear_form::{Evaluate, LinearForm, MultilinearExtension}, + MultilinearPoint, + }, + hash, + parameters::ProtocolParameters, + protocols::{geometric_challenge::geometric_challenge, whir}, + transcript::{codecs::Empty, DomainSeparator, Proof, ProverState, VerifierState}, + }; + + type F = Field64; + type EF = Field64_3; + + const NUM_VARS: usize = 4; + const NUM_COEFFS: usize = 1 << NUM_VARS; + + fn make_config(batch_size: usize) -> whir::Config> { + let params = ProtocolParameters { + security_level: 32, + pow_bits: 0, + initial_folding_factor: 2, + folding_factor: 2, + unique_decoding: false, + starting_log_inv_rate: 1, + batch_size, + hash_id: hash::SHA2, + }; + let mut config = whir::Config::>::new(NUM_COEFFS, ¶ms); + config.disable_pow(); + config + } + + fn make_forms(points: &[MultilinearPoint]) -> Vec>>> { + points + .iter() + .map(|p| Box::new(MultilinearExtension { point: p.0.clone() }) as _) + .collect() + } + + fn prove_forms(points: &[MultilinearPoint]) -> Vec>> { + points + .iter() + .map(|p| { + Box::new(MultilinearExtension { point: p.0.clone() }) as Box> + }) + .collect() + } + + /// Attempt verification with `claimed_evals`. + /// Returns true if accepted (soundness bug), false if correctly rejected. + fn forgery_accepted_separate( + config: &whir::Config>, + ds: &DomainSeparator<'_, Empty>, + proof: &Proof, + forms: &[Box>>], + claimed_evals: &[EF], + num_commits: usize, + ) -> bool { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut vs = VerifierState::new_std(ds, proof); + let commitments: Vec<_> = (0..num_commits) + .map(|_| config.receive_commitment(&mut vs).unwrap()) + .collect(); + let refs: Vec<_> = commitments.iter().collect(); + config + .verify(&mut vs, &refs, claimed_evals) + .and_then(|fc| fc.verify(forms.iter().map(|l| l.as_ref() as &dyn LinearForm))) + })); + matches!(result, Ok(Ok(()))) + } + + // ─── Issue #1: α-batching forgery ──────────────────────────────────── + + /// Issue #1, separate commitments (batch_size=1, n=2, f=1). + #[test] + fn test_whir_issue1_separate_commits() { + let config = make_config(1); + let mut rng = ark_std::test_rng(); + + let v0 = vec![F::ONE; NUM_COEFFS]; + let v1 = vec![F::from(2u64); NUM_COEFFS]; + let points = vec![MultilinearPoint::rand(&mut rng, NUM_VARS)]; + let forms = make_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| [&v0, &v1].map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w0 = config.commit(&mut ps, &[&v0]); + let w1 = config.commit(&mut ps, &[&v1]); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], + vec![Cow::Owned(w0), Cow::Owned(w1)], + prove_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + // Sanity. + assert!(forgery_accepted_separate( + &config, &ds, &proof, &forms, &evals, 2 + )); + + // Forge one entry. + let mut forged = evals.clone(); + forged[0] += EF::from(1u64); + assert!( + !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 2), + "REGRESSION issue #1: single-entry forgery (separate commits) must be rejected" + ); + } + + /// Issue #1, batched commitment (batch_size=2, n=2, f=1). + #[test] + fn test_whir_issue1_batched_commit() { + let config = make_config(2); + let mut rng = ark_std::test_rng(); + + let v0 = vec![F::ONE; NUM_COEFFS]; + let v1 = vec![F::from(3u64); NUM_COEFFS]; + let vec_refs: Vec<&[F]> = vec![&v0[..], &v1[..]]; + let points = vec![MultilinearPoint::rand(&mut rng, NUM_VARS)]; + let forms = make_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| vec_refs.iter().map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w = config.commit(&mut ps, &vec_refs); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], + vec![Cow::Owned(w)], + prove_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + assert!(forgery_accepted_separate( + &config, &ds, &proof, &forms, &evals, 1 + )); + + let mut forged = evals.clone(); + forged[0] += EF::from(1u64); + assert!( + !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 1), + "REGRESSION issue #1: single-entry forgery (batched commit) must be rejected" + ); + } + + /// Issue #1, exact α-cancelling forgery (n=2, f=1). + /// Extracts α from transcript, constructs `[+Δ, −Δ/α]`. + #[test] + fn test_whir_issue1_alpha_cancelling() { + let config = make_config(1); + let mut rng = ark_std::test_rng(); + + let v0 = vec![F::ONE; NUM_COEFFS]; + let v1 = vec![F::from(2u64); NUM_COEFFS]; + let points = vec![MultilinearPoint::rand(&mut rng, NUM_VARS)]; + let forms = make_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| [&v0, &v1].map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + assert_eq!(evals.len(), 2); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w0 = config.commit(&mut ps, &[&v0]); + let w1 = config.commit(&mut ps, &[&v1]); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], + vec![Cow::Owned(w0), Cow::Owned(w1)], + prove_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + // Extract α by replaying the verifier transcript prefix. + // After receive_commitment, verify() reads OOD cross-terms, + // evaluations, then squeezes vector_rlc_coeffs = [1, α]. + let alpha = { + let mut vs = VerifierState::new_std(&ds, &proof); + let c0 = config.receive_commitment(&mut vs).unwrap(); + let c1 = config.receive_commitment(&mut vs).unwrap(); + // OOD cross-terms: 1 per commitment per OOD row. + for _ in 0..(c0.out_of_domain().points.len() + c1.out_of_domain().points.len()) { + let _: EF = vs.prover_message().unwrap(); + } + for _ in 0..evals.len() { + let _: EF = vs.prover_message().unwrap(); + } + let coeffs: Vec = geometric_challenge(&mut vs, 2); + coeffs[1] + }; + + // Exact cancelling forgery: e'₀ + α·e'₁ = e₀ + α·e₁. + let delta = EF::from(42u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / alpha; + assert_eq!(evals[0] + alpha * evals[1], forged[0] + alpha * forged[1]); + + assert!( + !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 2), + "REGRESSION issue #1: α-cancelling forgery [+Δ, −Δ/α] must be rejected" + ); + } + + // ─── Issue #3: constraint-RLC forgery ──────────────────────────────── + + /// Issue #3, exact c₁-cancelling forgery (n=1, f=2). + /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]`. + #[test] + fn test_whir_issue3_constraint_rlc_cancelling() { + let config = make_config(1); + let mut rng = ark_std::test_rng(); + + let vector = vec![F::ONE; NUM_COEFFS]; + let points: Vec<_> = (0..2) + .map(|_| MultilinearPoint::rand(&mut rng, NUM_VARS)) + .collect(); + let forms = make_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| [&vector].map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + assert_eq!(evals.len(), 2); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w = config.commit(&mut ps, &[&vector]); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(vector.as_slice())], + vec![Cow::Owned(w)], + prove_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + // Extract c₁. After commitment + evaluations, the verifier squeezes + // vector_rlc (count=1, no squeeze) then constraint_rlc (count = ood + 2). + let c1 = { + let mut vs = VerifierState::new_std(&ds, &proof); + let c = config.receive_commitment(&mut vs).unwrap(); + for _ in 0..evals.len() { + let _: EF = vs.prover_message().unwrap(); + } + let _: Vec = geometric_challenge(&mut vs, 1); // vector_rlc [1] + let num_ood = c.out_of_domain().points.len(); + let rlc: Vec = geometric_challenge(&mut vs, num_ood + 2); + rlc[1] + }; + + // Exact cancelling forgery: e'₀ + c₁·e'₁ = e₀ + c₁·e₁. + let delta = EF::from(99u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / c1; + assert_eq!(evals[0] + c1 * evals[1], forged[0] + c1 * forged[1]); + + assert!( + !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 1), + "REGRESSION issue #3: constraint-RLC-cancelling forgery must be rejected" + ); + } +} + +// ========================================================================= +// zkWHIR +// ========================================================================= + +#[cfg(test)] +mod whir_zk_tests { + use std::borrow::Cow; + + use ark_ff::{AdditiveGroup, Field}; + + use crate::{ + algebra::{ + dot, + embedding::Identity, + fields::Field64, + geometric_sequence, + linear_form::{ + Covector, Evaluate, LinearForm, MultilinearExtension, UnivariateEvaluation, + }, + multilinear_extend, univariate_evaluate, MultilinearPoint, + }, + hash, + parameters::ProtocolParameters, + protocols::{ + geometric_challenge::geometric_challenge, + whir, + whir_zk::{ + self, + committer::Witness, + utils::{ + build_beq_tables, build_fold_args, build_weight_covectors, compute_eq_weights, + compute_rs_fold_blinding_coeffs, gamma_to_f_hat_indices, ProtocolDims, + RsFoldCoeffs, + }, + }, + }, + transcript::{ + codecs::Empty, DomainSeparator, Proof, ProverState, VerifierMessage, VerifierState, + }, + }; + + type F = Field64; + + const NUM_VARS: usize = 12; + const NUM_COEFFS: usize = 1 << NUM_VARS; + + fn make_config(batch_size: usize) -> whir_zk::Config { + let params = ProtocolParameters { + unique_decoding: false, + security_level: 16, + pow_bits: 0, + initial_folding_factor: 2, + folding_factor: 2, + starting_log_inv_rate: 1, + batch_size, + hash_id: hash::SHA2, + }; + let mut config = whir_zk::Config::new(NUM_VARS, ¶ms); + config.disable_pow(); + config + } + + fn to_prove_forms( + forms: &[Box>], + size: usize, + ) -> Vec>> { + forms + .iter() + .map(|f| { + let mut cv = vec![F::ZERO; size]; + f.accumulate(&mut cv, F::ONE); + Box::new(Covector::new(cv)) as Box> + }) + .collect() + } + + /// Generate an honest proof and sanity-check it. + fn honest_proof( + config: &whir_zk::Config, + vectors: &[&[F]], + forms: &[Box>], + evals: &[F], + ) -> (DomainSeparator<'static, Empty>, Proof) { + let ds = DomainSeparator::protocol(config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let witness = config.commit(&mut ps, vectors); + config.prove( + &mut ps, + vectors.iter().map(|&v| Cow::Borrowed(v)).collect(), + witness, + to_prove_forms(forms, vectors[0].len()), + Cow::Borrowed(evals), + ); + let proof = ps.proof(); + + // Sanity: honest proof must pass. + let weights: Vec<&dyn LinearForm> = forms + .iter() + .map(|f| f.as_ref() as &dyn LinearForm) + .collect(); + let mut vs = VerifierState::new_std(&ds, &proof); + let commitments = config.receive_commitments(&mut vs).unwrap(); + config + .verify(&mut vs, &weights, evals, &commitments) + .unwrap() + .verify(weights.iter().copied()) + .unwrap(); + + (ds, proof) + } + + /// Attempt verification with `claimed_evals`. Returns true on accept. + fn forgery_accepted( + config: &whir_zk::Config, + ds: &DomainSeparator<'_, Empty>, + proof: &Proof, + forms: &[Box>], + claimed_evals: &[F], + ) -> bool { + let weights: Vec<&dyn LinearForm> = forms + .iter() + .map(|f| f.as_ref() as &dyn LinearForm) + .collect(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut vs = VerifierState::new_std(ds, proof); + let commitments = config.receive_commitments(&mut vs).unwrap(); + config + .verify(&mut vs, &weights, claimed_evals, &commitments) + .and_then(|fc| fc.verify(weights.iter().copied())) + })); + matches!(result, Ok(Ok(()))) + } + + // ─── Issue #1: α-batching forgery ──────────────────────────────────── + + /// Issue #1 (n=2, f=1): exact α-cancelling forgery. + /// Extracts α from transcript, constructs `[+Δ, −Δ/α]`. + #[test] + fn test_zkwhir_issue1_alpha_cancelling() { + let config = make_config(2); + let mut rng = ark_std::test_rng(); + + let v0: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 + 1)).collect(); + let v1: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 * 3 + 7)).collect(); + let point = MultilinearPoint::rand(&mut rng, NUM_VARS); + let form = MultilinearExtension { point: point.0 }; + let embedding = config.embedding(); + let evals = vec![form.evaluate(embedding, &v0), form.evaluate(embedding, &v1)]; + let forms: Vec>> = vec![Box::new(form)]; + + let (ds, proof) = honest_proof(&config, &[&v0, &v1], &forms, &evals); + + // Extract α. After receive_commitments: squeeze β, 1 g_claim, 2 evals, squeeze α. + let alpha = { + let mut vs = VerifierState::new_std(&ds, &proof); + let _ = config.receive_commitments(&mut vs).unwrap(); + let _beta: F = vs.verifier_message(); + let _g: F = vs.prover_message().unwrap(); + let _e0: F = vs.prover_message().unwrap(); + let _e1: F = vs.prover_message().unwrap(); + geometric_challenge::<_, F>(&mut vs, 2)[1] + }; + + let delta = F::from(42u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / alpha; + assert_eq!(evals[0] + alpha * evals[1], forged[0] + alpha * forged[1]); + + assert!( + !forgery_accepted(&config, &ds, &proof, &forms, &forged), + "REGRESSION issue #1: α-cancelling forgery must be rejected" + ); + } + + // ─── Issue #2: G-claim forgery via ρ ───────────────────────────────── + + /// Issue #2 (n=1, f=1): full manual transcript replay with forged g_claim. + /// + /// 1. Commit honestly. + /// 2. Send G' = G + Δ. + /// 3. Absorb honest eval (must commit before ρ). + /// 4. ρ is squeezed → construct e' = e − Δ/ρ. + /// 5. Complete proof honestly. + /// 6. Verifier reads honest e from transcript, compares to e' → rejected. + #[test] + #[allow(clippy::too_many_lines)] + fn test_zkwhir_issue2_g_claim_forgery() { + let mut rng = ark_std::test_rng(); + let config = make_config(1); + + let vector = vec![F::ONE; NUM_COEFFS]; + let point = MultilinearPoint::rand(&mut rng, NUM_VARS); + let form = MultilinearExtension { point: point.0 }; + let honest_eval = form.evaluate(config.embedding(), &vector); + + let forms: Vec>> = vec![Box::new(form)]; + let weight_refs: Vec<&dyn LinearForm> = forms + .iter() + .map(|f| f.as_ref() as &dyn LinearForm) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut prover_state = ProverState::new_std(&ds); + let witness = config.commit(&mut prover_state, &[&vector]); + + let Witness { + f_hat_witness, + blinding_poly_witness, + f_hat_polys, + secrets, + } = witness; + + let dims = ProtocolDims::new(&config, 1); + let size = dims.size; + + // ── MALICIOUS Step 2: send g_claim + Δ ── + + let beta: F = prover_state.verifier_message(); + let beta_powers = geometric_sequence(beta, dims.num_g_polys()); + let g_poly: Vec = (0..size) + .map(|idx| { + beta_powers + .iter() + .enumerate() + .map(|(i, &bp)| bp * secrets.g_polys[i][dims.phi_i_bits(idx, i)]) + .sum() + }) + .collect(); + let honest_g_claim: F = { + let mut buf = vec![F::ZERO; size]; + forms[0].accumulate(&mut buf, F::ONE); + dot(&buf, &g_poly) + }; + + let delta = F::from(77u64); + prover_state.prover_message(&(honest_g_claim + delta)); // forged G' + prover_state.prover_message(&honest_eval); // must absorb before ρ + + let alpha_coeffs: Vec = geometric_challenge(&mut prover_state, 1); + let rho: F = prover_state.verifier_message(); + assert_ne!(rho, F::ZERO); + + // f_zk = ρ·f + g (honest) + let mut f_zk: Vec = vector.iter().map(|&v| rho * v).collect(); + for (f, &g) in f_zk.iter_mut().zip(g_poly.iter()) { + *f += g; + } + drop(g_poly); + + let combined_claim = rho * honest_eval + honest_g_claim; + + // Step 4: sumcheck + let constraint_rlc: Vec = geometric_challenge(&mut prover_state, 1); + let mut covector = vec![F::ZERO; size]; + for (coeff, lf) in constraint_rlc.iter().zip(forms.iter()) { + lf.accumulate(&mut covector, *coeff); + } + let mut the_sum: F = constraint_rlc[0] * combined_claim; + let folding_randomness = config.blinded_polynomial.initial_sumcheck.prove( + &mut prover_state, + &mut f_zk, + &mut covector, + &mut the_sum, + ); + + // Steps 5-6: honest + let r_bar = &folding_randomness.0; + let eq_weights_vec = compute_eq_weights(r_bar); + let RsFoldCoeffs { + masking_coeffs_all, + g_i_coeffs, + } = compute_rs_fold_blinding_coeffs( + &eq_weights_vec, + &secrets.g_polys, + &secrets.masking_polys, + &alpha_coeffs, + rho, + dims, + ); + + let round_config = &config.blinded_polynomial.round_configs[0]; + let folded_commit = round_config + .irs_committer + .commit(&mut prover_state, &[&f_zk]); + round_config.pow.prove(&mut prover_state); + let in_domain = config + .blinded_polynomial + .initial_committer + .open(&mut prover_state, &[&f_hat_witness]); + + let mut lambda_z_points: Vec = Vec::new(); + let send_blinding = |ps: &mut ProverState<_, _>, z: F| { + for m in &masking_coeffs_all { + ps.prover_message(&univariate_evaluate(m, z)); + } + for g in &g_i_coeffs { + ps.prover_message(&univariate_evaluate(g, z)); + } + }; + + let f_hat_combined = &f_hat_polys[0]; + let mu = dims.mu; + for &z in &folded_commit.out_of_domain().points { + prover_state.prover_message(&multilinear_extend( + f_hat_combined, + &build_fold_args(r_bar, z, mu), + )); + send_blinding(&mut prover_state, z); + lambda_z_points.push(z); + } + drop(f_hat_polys); + for &z in &in_domain.points { + send_blinding(&mut prover_state, z); + lambda_z_points.push(z); + } + { + let stir_challenges: Vec> = folded_commit + .out_of_domain() + .evaluators(round_config.initial_size()) + .chain(in_domain.evaluators(round_config.initial_size())) + .collect(); + let ood_evals = folded_commit.out_of_domain().values(&[F::ONE]); + let num_ood = folded_commit.out_of_domain().points.len(); + let embedding = Identity::new(); + let stir_evals: Vec = ood_evals + .chain( + stir_challenges[num_ood..] + .iter() + .map(|ch| ch.evaluate(&embedding, &f_zk)), + ) + .collect(); + let stir_rlc: Vec = geometric_challenge(&mut prover_state, stir_challenges.len()); + UnivariateEvaluation::accumulate_many(&stir_challenges, &mut covector, &stir_rlc); + the_sum += dot(&stir_rlc, &stir_evals); + } + let round0_folding = + round_config + .sumcheck + .prove(&mut prover_state, &mut f_zk, &mut covector, &mut the_sum); + let remaining = whir::rounds::prove_remaining_rounds( + &config.blinded_polynomial.round_configs, + &whir::rounds::FinalRoundConfig { + sumcheck: &config.blinded_polynomial.final_sumcheck, + pow: &config.blinded_polynomial.final_pow, + }, + &mut prover_state, + &mut whir::rounds::SumcheckState { + vector: &mut f_zk, + covector: &mut covector, + the_sum: &mut the_sum, + }, + folded_commit, + &round0_folding, + ); + let gamma_points = remaining.first_in_domain_points; + let _ = config.blinded_polynomial.initial_committer.open_at_indices( + &mut prover_state, + &[&f_hat_witness], + &gamma_to_f_hat_indices(&gamma_points, &config), + ); + for &gamma in &gamma_points { + send_blinding(&mut prover_state, gamma); + lambda_z_points.push(gamma); + } + drop(f_zk); + drop(covector); + + // Step 7: blinding proof (honest) + let tau: F = prover_state.verifier_message(); + let beq_tables = build_beq_tables(&lambda_z_points, &eq_weights_vec, tau, dims); + let weight_covectors = build_weight_covectors(&beq_tables, rho, &alpha_coeffs, dims); + let mut eval_matrix = Vec::with_capacity(dims.num_blinding_vecs * dims.num_blinding_vecs); + for w in &weight_covectors { + for v in &secrets.blinding_vectors { + eval_matrix.push(dot(w, v)); + } + } + for e in &eval_matrix { + prover_state.prover_message(e); + } + let blinding_forms: Vec>> = weight_covectors + .into_iter() + .map(|cv| Box::new(Covector::new(cv)) as _) + .collect(); + let blinding_cows: Vec> = secrets + .blinding_vectors + .iter() + .map(|v| Cow::Borrowed(v.as_slice())) + .collect(); + let _ = config.blinding_polynomial.prove( + &mut prover_state, + blinding_cows, + vec![Cow::Borrowed(&blinding_poly_witness)], + blinding_forms, + Cow::Owned(eval_matrix), + ); + + // Verify with forged e' = e − Δ/ρ. + let proof = prover_state.proof(); + let forged_eval = honest_eval - delta / rho; + assert_ne!(forged_eval, honest_eval); + + let attack = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut vs = VerifierState::new_std(&ds, &proof); + let commitments = config.receive_commitments(&mut vs).unwrap(); + config + .verify(&mut vs, &weight_refs, &[forged_eval], &commitments) + .and_then(|fc| fc.verify(weight_refs.iter().copied())) + })); + assert!( + !matches!(attack, Ok(Ok(()))), + "REGRESSION issue #2: g_claim forgery (G'=G+Δ, e'=e−Δ/ρ) must be rejected" + ); + } + + // ─── Issue #3: constraint-RLC forgery ──────────────────────────────── + + /// Issue #3 (n=1, f=2): exact c₁-cancelling forgery. + /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]`. + #[test] + fn test_zkwhir_issue3_constraint_rlc_cancelling() { + let config = make_config(1); + let mut rng = ark_std::test_rng(); + + let vector: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 + 1)).collect(); + let p0 = MultilinearPoint::rand(&mut rng, NUM_VARS); + let p1 = MultilinearPoint::rand(&mut rng, NUM_VARS); + let f0 = MultilinearExtension { point: p0.0 }; + let f1 = MultilinearExtension { point: p1.0 }; + let embedding = config.embedding(); + let evals = vec![ + f0.evaluate(embedding, &vector), + f1.evaluate(embedding, &vector), + ]; + let forms: Vec>> = vec![Box::new(f0), Box::new(f1)]; + + let (ds, proof) = honest_proof(&config, &[&vector], &forms, &evals); + + // Extract c₁. For n=1, f=2: + // β, 2 g_claims, 2 evals, α(1→no squeeze), ρ, constraint_rlc=[1, c₁]. + let c1 = { + let mut vs = VerifierState::new_std(&ds, &proof); + let _ = config.receive_commitments(&mut vs).unwrap(); + let _beta: F = vs.verifier_message(); + for _ in 0..2 { + let _: F = vs.prover_message().unwrap(); + } // g_claims + for _ in 0..2 { + let _: F = vs.prover_message().unwrap(); + } // evals + let _: Vec = geometric_challenge(&mut vs, 1); // α = [1] + let _rho: F = vs.verifier_message(); + geometric_challenge::<_, F>(&mut vs, 2)[1] + }; + + let delta = F::from(99u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / c1; + assert_eq!(evals[0] + c1 * evals[1], forged[0] + c1 * forged[1]); + + assert!( + !forgery_accepted(&config, &ds, &proof, &forms, &forged), + "REGRESSION issue #3: constraint-RLC-cancelling forgery must be rejected" + ); + } + + // ─── Combined: all three surfaces ──────────────────────────────────── + + /// Issues #1+#2+#3 combined (n=2, f=2): 4 evaluations. + /// Tests single-entry, cross-vector, and cross-form forgeries. + #[test] + fn test_zkwhir_combined_n2_f2() { + let config = make_config(2); + let mut rng = ark_std::test_rng(); + + let v0: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 + 1)).collect(); + let v1: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 * 3 + 7)).collect(); + let p0 = MultilinearPoint::rand(&mut rng, NUM_VARS); + let p1 = MultilinearPoint::rand(&mut rng, NUM_VARS); + let f0 = MultilinearExtension { point: p0.0 }; + let f1 = MultilinearExtension { point: p1.0 }; + let embedding = config.embedding(); + let evals = vec![ + f0.evaluate(embedding, &v0), + f0.evaluate(embedding, &v1), + f1.evaluate(embedding, &v0), + f1.evaluate(embedding, &v1), + ]; + let forms: Vec>> = vec![Box::new(f0), Box::new(f1)]; + + let (ds, proof) = honest_proof(&config, &[&v0, &v1], &forms, &evals); + + // (a) Single entry. + let mut fa = evals.clone(); + fa[0] += F::from(1u64); + assert!( + !forgery_accepted(&config, &ds, &proof, &forms, &fa), + "single-entry forgery must be rejected" + ); + + // (b) Cross-vector (α dimension, row 0). + let mut fb = evals.clone(); + fb[0] += F::from(99u64); + fb[1] -= F::from(99u64); + assert!( + !forgery_accepted(&config, &ds, &proof, &forms, &fb), + "cross-vector forgery must be rejected" + ); + + // (c) Cross-form (constraint-RLC dimension). + let mut fc = evals.clone(); + fc[0] += F::from(55u64); + fc[2] -= F::from(55u64); + assert!( + !forgery_accepted(&config, &ds, &proof, &forms, &fc), + "cross-form forgery must be rejected" + ); + } +} diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 4f782391..608988b7 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -17,3 +17,6 @@ pub mod proof_of_work; pub mod sumcheck; pub mod whir; pub mod whir_zk; + +#[cfg(test)] +mod audit_soundness_tests; diff --git a/src/protocols/whir/prover.rs b/src/protocols/whir/prover.rs index 58ca2e51..f64b0d78 100644 --- a/src/protocols/whir/prover.rs +++ b/src/protocols/whir/prover.rs @@ -129,6 +129,10 @@ where (oods_evals, oods_matrix) }; + for eval in evaluations.iter() { + prover_state.prover_message(eval); + } + // Random linear combination of the vectors. let vector_rlc_coeffs: Vec = geometric_challenge(prover_state, num_vectors); assert_eq!(vector_rlc_coeffs[0], M::Target::ONE); diff --git a/src/protocols/whir/verifier.rs b/src/protocols/whir/verifier.rs index c2dcccd4..1a4842ae 100644 --- a/src/protocols/whir/verifier.rs +++ b/src/protocols/whir/verifier.rs @@ -82,6 +82,11 @@ where (oods_evals, oods_matrix) }; + for &expected in evaluations { + let read: M::Target = verifier_state.prover_message()?; + verify!(read == expected); + } + // Random linear combination of the vectors. let vector_rlc_coeffs = geometric_challenge(verifier_state, num_vectors); diff --git a/src/protocols/whir_zk/committer.rs b/src/protocols/whir_zk/committer.rs index 7b431565..1035d48e 100644 --- a/src/protocols/whir_zk/committer.rs +++ b/src/protocols/whir_zk/committer.rs @@ -15,13 +15,13 @@ use crate::{ /// destructurable (no custom `Drop`) while guaranteeing zeroization /// of the secret data regardless of how the witness is consumed. #[derive(Zeroize, ZeroizeOnDrop)] -pub(super) struct BlindingSecrets { +pub struct BlindingSecrets { /// Per-witness masking polynomials mskᵢ (ℓ-variate, 2^ℓ coefficients). - pub(super) masking_polys: Vec>, + pub(crate) masking_polys: Vec>, /// Blinding polynomials ĝ₀..ĝ_ν (ℓ-variate, 2^ℓ coefficients each). - pub(super) g_polys: Vec>, + pub(crate) g_polys: Vec>, /// Interleaved blinding vectors [M₀, ..., M_{n-1}, ĝ₁, ..., ĝ_ν] as committed. - pub(super) blinding_vectors: Vec>, + pub(crate) blinding_vectors: Vec>, } /// Prover-side witness produced by Step 1 (Commitment). @@ -36,13 +36,13 @@ pub(super) struct BlindingSecrets { #[allow(clippy::struct_field_names)] pub struct Witness { /// IRS-commit witness for [[f̂]] (first WHIR instance). - pub(super) f_hat_witness: irs_commit::Witness, + pub(crate) f_hat_witness: irs_commit::Witness, /// IRS-commit witness for [[M]], [[ĝ₁]]..[[ĝ_ν]] (second WHIR instance). - pub(super) blinding_poly_witness: irs_commit::Witness, + pub(crate) blinding_poly_witness: irs_commit::Witness, /// f̂ᵢ = fᵢ + mskᵢ(Φ₀) for each of the n witness polynomials. - pub(super) f_hat_polys: Vec>, + pub(crate) f_hat_polys: Vec>, /// Secret blinding randomness (zeroized on drop). - pub(super) secrets: BlindingSecrets, + pub(crate) secrets: BlindingSecrets, } impl Config { diff --git a/src/protocols/whir_zk/mod.rs b/src/protocols/whir_zk/mod.rs index 72c2e679..cf4a08fb 100644 --- a/src/protocols/whir_zk/mod.rs +++ b/src/protocols/whir_zk/mod.rs @@ -23,9 +23,9 @@ use serde::{Deserialize, Serialize}; use crate::algebra::embedding::Embedding; -mod committer; +pub(crate) mod committer; mod prover; -mod utils; +pub(crate) mod utils; mod verifier; pub use self::{committer::Witness, verifier::Commitments}; @@ -683,66 +683,6 @@ mod tests { // A panic is also a valid rejection (debug transcript checks). } - /// Soundness: a malicious prover who generates a proof for a wrong evaluation - /// must be rejected. If verify() accepts, it means the prover can forge - /// arbitrary evaluation claims. - #[test] - fn test_zk_malicious_prover_wrong_evaluation() { - let mut rng = ark_std::test_rng(); - let config = make_test_config(); - - let vector = vec![F::ONE; TEST_NUM_COEFFS]; - let point = MultilinearPoint::rand(&mut rng, TEST_NUM_VARIABLES); - let form = MultilinearExtension { point: point.0 }; - let correct_evaluation = form.evaluate(config.embedding(), &vector); - let wrong_evaluation = correct_evaluation + F::from(42u64); - - let forms: Vec>> = vec![Box::new(form)]; - let prove_forms = to_prove_forms(&forms, vector.len()); - let weight_refs: Vec<&dyn LinearForm> = forms - .iter() - .map(|f| f.as_ref() as &dyn LinearForm) - .collect(); - - let ds = DomainSeparator::protocol(&config) - .session(&format!("zk2-malicious {}:{}", file!(), line!())) - .instance(&Empty); - - let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let mut prover_state = ProverState::new_std(&ds); - let witness = config.commit(&mut prover_state, &[&vector]); - config.prove( - &mut prover_state, - vec![Cow::Borrowed(vector.as_slice())], - witness, - prove_forms, - Cow::Owned(vec![wrong_evaluation]), - ); - - let proof = prover_state.proof(); - let mut verifier_state = VerifierState::new_std(&ds, &proof); - let commitments = config - .receive_commitments(&mut verifier_state) - .expect("receive_commitments"); - config - .verify( - &mut verifier_state, - &weight_refs, - &[wrong_evaluation], - &commitments, - )? - .verify(weight_refs.iter().copied()) - })); - - if let Ok(result) = outcome { - assert!( - result.is_err(), - "SOUNDNESS BUG: verifier accepted wrong evaluation from malicious prover \ - (correct={correct_evaluation:?}, claimed={wrong_evaluation:?})" - ); - } - } - /// Verify that `unique_decoding: true` is rejected at config construction. /// /// zkWHIR 2.0's "Alternative Randomness Sampling" requires OOD queries diff --git a/src/protocols/whir_zk/prover.rs b/src/protocols/whir_zk/prover.rs index 418916b7..032db8cd 100644 --- a/src/protocols/whir_zk/prover.rs +++ b/src/protocols/whir_zk/prover.rs @@ -171,6 +171,10 @@ where self.prover_state.prover_message(g_claim); } + for eval in evaluations { + self.prover_state.prover_message(eval); + } + // ===================================================================== // Step 2.5: Multi-polynomial batching // diff --git a/src/protocols/whir_zk/utils.rs b/src/protocols/whir_zk/utils.rs index 90ed6493..d5da0201 100644 --- a/src/protocols/whir_zk/utils.rs +++ b/src/protocols/whir_zk/utils.rs @@ -8,19 +8,19 @@ use crate::algebra::{geometric_accumulate, geometric_sequence}; /// Computed once from the config and reused across prover/verifier steps /// to avoid recomputing (and passing individually) the same derived values. #[derive(Clone, Copy, Debug)] -pub(super) struct ProtocolDims { - pub(super) mu: usize, - pub(super) ell: usize, - pub(super) rem: usize, +pub struct ProtocolDims { + pub(crate) mu: usize, + pub(crate) ell: usize, + pub(crate) rem: usize, /// ν blinding polynomials excluding g₀; total g-polynomials = ν + 1 = `num_g_polys()`. - pub(super) nu: usize, - pub(super) size: usize, - pub(super) num_vectors: usize, - pub(super) num_blinding_vecs: usize, + pub(crate) nu: usize, + pub(crate) size: usize, + pub(crate) num_vectors: usize, + pub(crate) num_blinding_vecs: usize, } impl ProtocolDims { - pub(super) fn new(config: &Config, num_vectors: usize) -> Self { + pub(crate) fn new(config: &Config, num_vectors: usize) -> Self { let mu = config.blinded_polynomial.initial_num_variables(); let ell = config .blinding_polynomial @@ -46,12 +46,12 @@ impl ProtocolDims { } /// Number of blinding g-polynomials: ν + 1. - pub(super) const fn num_g_polys(&self) -> usize { + pub(crate) const fn num_g_polys(&self) -> usize { self.nu + 1 } /// Convenience wrapper for [`phi_i_bits`] using this instance's dimensions. - pub(super) const fn phi_i_bits(&self, hypercube_idx: usize, phi_index: usize) -> usize { + pub(crate) const fn phi_i_bits(&self, hypercube_idx: usize, phi_index: usize) -> usize { phi_i_bits(hypercube_idx, phi_index, self.mu, self.ell, self.rem) } } @@ -97,12 +97,7 @@ const fn phi_i_bits( /// Panics if `target` is not in `⟨gen⟩`. /// /// Complexity: O(log_order²) field multiplications — vs O(2^log_order) for linear scan. -pub(super) fn discrete_log_pow2( - target: F, - gen: F, - gen_inv: F, - log_order: u32, -) -> usize { +pub fn discrete_log_pow2(target: F, gen: F, gen_inv: F, log_order: u32) -> usize { debug_assert_eq!(gen * gen_inv, F::ONE, "gen_inv must be the inverse of gen"); let mut result = 0usize; let mut current = target; @@ -140,7 +135,7 @@ pub(super) fn discrete_log_pow2( /// /// The z-derived coordinates use descending powers (big-endian convention) /// to match the codebase's `UnivariateEvaluation::mle_evaluate` squaring ladder. -pub(super) fn build_fold_args(r_bar: &[F], z: F, mu: usize) -> Vec { +pub fn build_fold_args(r_bar: &[F], z: F, mu: usize) -> Vec { let num_folded_vars = r_bar.len(); let num_z_vars = mu - num_folded_vars; let mut point = Vec::with_capacity(mu); @@ -165,7 +160,7 @@ pub(super) fn build_fold_args(r_bar: &[F], z: F, mu: usize) -> Vec< /// /// `beq_i[k] = Σ_j τ^{j+1} · Σ_{c,m} eq(r̄, c) · z_j^m · δ(Φ_i(c·M+m), k)` #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, fields(num_points = lambda_z_points.len(), mu = dims.mu, ell = dims.ell, num_g_polys = dims.num_g_polys())))] -pub(super) fn build_beq_tables( +pub fn build_beq_tables( lambda_z_points: &[F], eq_weights: &[F], tau: F, @@ -283,9 +278,9 @@ pub(super) fn build_beq_tables( /// Produced by [`compute_rs_fold_blinding_coeffs`]; consumed when evaluating /// m̃(r̄, z, ρ) and g̃ᵢ(r̄, z) at OOD/STIR/Γ points. #[derive(Debug)] -pub(super) struct RsFoldCoeffs { - pub(super) masking_coeffs_all: Vec>, - pub(super) g_i_coeffs: Vec>, +pub struct RsFoldCoeffs { + pub(crate) masking_coeffs_all: Vec>, + pub(crate) g_i_coeffs: Vec>, } /// Precompute RS-fold coefficient vectors for the blinding polynomials (Steps 5-6). @@ -310,7 +305,7 @@ pub(super) struct RsFoldCoeffs { /// /// Returns [`RsFoldCoeffs`] where each inner vector has length M. #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, fields(mu = dims.mu, ell = dims.ell, num_g_polys = g_polys.len())))] -pub(super) fn compute_rs_fold_blinding_coeffs( +pub fn compute_rs_fold_blinding_coeffs( eq_weights: &[F], g_polys: &[Vec], masking_polys: &[Vec], @@ -393,7 +388,7 @@ pub(super) fn compute_rs_fold_blinding_coeffs( /// - `w_0`: `beq_0[k]` for g₀, `(-ρ)·beq_0[k]` for msk₀ /// - `w_i`: `(-ρ·αⁱ)·beq_0[k]` for mskᵢ (1 ≤ i < num_vectors) /// - `w_{n+j}`: `beq_{j+1}[k]` for ĝ_{j+1} (0 ≤ j < ν) -pub(super) fn build_weight_covectors( +pub fn build_weight_covectors( beq_tables: &[Vec], rho: F, alpha_coeffs: &[F], @@ -446,7 +441,7 @@ pub(super) fn build_weight_covectors( /// recovers the position in the initial codeword. /// /// Used identically by both prover (to open [[f̂]]) and verifier (to verify openings). -pub(super) fn gamma_to_f_hat_indices( +pub fn gamma_to_f_hat_indices( gamma_points: &[F], config: &super::Config, ) -> Vec { @@ -472,7 +467,7 @@ pub(super) fn gamma_to_f_hat_indices( } /// Compute eq_weights from r_bar. Shared helper to avoid redundant computation. -pub(super) fn compute_eq_weights(r_bar: &[F]) -> Vec { +pub fn compute_eq_weights(r_bar: &[F]) -> Vec { let len = 1usize << r_bar.len(); let mut buf = vec![F::ONE; len]; for (i, &r) in r_bar.iter().enumerate() { @@ -489,14 +484,14 @@ pub(super) fn compute_eq_weights(r_bar: &[F]) -> Vec { /// /// Collects (z, m_evals, g_evals) tuples during Steps 5-6 for use in Step 7. #[derive(Debug)] -pub(super) struct LambdaAccumulator { +pub struct LambdaAccumulator { z_points: Vec, m_evals: Vec>, g_evals: Vec>, } impl LambdaAccumulator { - pub(super) const fn new() -> Self { + pub(crate) const fn new() -> Self { Self { z_points: Vec::new(), m_evals: Vec::new(), @@ -504,11 +499,11 @@ impl LambdaAccumulator { } } - pub(super) fn z_points(&self) -> &[F] { + pub(crate) fn z_points(&self) -> &[F] { &self.z_points } - pub(super) fn push(&mut self, z: F, m: Vec, g: Vec) { + pub(crate) fn push(&mut self, z: F, m: Vec, g: Vec) { assert!( self.m_evals.is_empty() || m.len() == self.m_evals[0].len(), "m_evals length mismatch: expected {}, got {}", @@ -527,7 +522,7 @@ impl LambdaAccumulator { } #[must_use] - pub(super) const fn len(&self) -> usize { + pub(crate) const fn len(&self) -> usize { self.z_points.len() } @@ -535,7 +530,7 @@ impl LambdaAccumulator { /// /// Vectors `0..num_vectors` index into `m_evals`; vectors `num_vectors..` index /// into `g_evals` (shifted by `num_vectors`). - pub(super) fn claim(&self, lambda_idx: usize, vec_idx: usize, num_vectors: usize) -> F + pub(crate) fn claim(&self, lambda_idx: usize, vec_idx: usize, num_vectors: usize) -> F where F: Copy, { diff --git a/src/protocols/whir_zk/verifier.rs b/src/protocols/whir_zk/verifier.rs index 4ca42eae..4dbbd6d7 100644 --- a/src/protocols/whir_zk/verifier.rs +++ b/src/protocols/whir_zk/verifier.rs @@ -130,6 +130,11 @@ where let beta_powers = geometric_sequence(beta, num_g_polys); let g_claims: Vec = self.verifier_state.prover_messages_vec(num_forms)?; + for &expected in evaluations { + let read: F = self.verifier_state.prover_message()?; + verify!(read == expected); + } + // ===================================================================== // Step 2.5: Multi-polynomial batching // From 80069fb9c872a71bf79c0610210b1422344a00d0 Mon Sep 17 00:00:00 2001 From: ocdbytes Date: Thu, 23 Apr 2026 18:19:52 +0530 Subject: [PATCH 2/4] moved tests to respective file --- src/protocols/audit_soundness_tests.rs | 839 ------------------------- src/protocols/mod.rs | 3 - src/protocols/whir/mod.rs | 319 ++++++++++ src/protocols/whir_zk/committer.rs | 16 +- src/protocols/whir_zk/mod.rs | 546 ++++++++++++++-- src/protocols/whir_zk/utils.rs | 59 +- 6 files changed, 863 insertions(+), 919 deletions(-) delete mode 100644 src/protocols/audit_soundness_tests.rs diff --git a/src/protocols/audit_soundness_tests.rs b/src/protocols/audit_soundness_tests.rs deleted file mode 100644 index f98c617f..00000000 --- a/src/protocols/audit_soundness_tests.rs +++ /dev/null @@ -1,839 +0,0 @@ -/// Soundness regression tests for evaluation forgery attacks. -/// -/// All three issues share the same root cause: public evaluations were not -/// bound in the Fiat-Shamir transcript before the challenges that depend on -/// them (α, ρ, constraint_rlc). -/// -/// - **Issue #1 (α):** n > 1 polynomials batched via `Σ αⁱ·eᵢ`. -/// Forgery: `[+Δ, −Δ/α]` preserves the weighted sum. -/// - **Issue #2 (ρ):** zkWHIR combines `claim = ρ·e + G`. -/// Forgery: send `G' = G+Δ`, then `e' = e−Δ/ρ`. -/// - **Issue #3 (constraint_rlc):** f > 1 forms collapsed via `Σ cⱼ·claimⱼ`. -/// Forgery: `[+Δ, −Δ/c₁]` preserves the weighted sum. -/// -/// **Fix:** absorb all evaluations into the transcript before α, ρ, and -/// constraint_rlc are sampled. Verifier reads them back and checks -/// `verify!(read == expected)`. - -// ========================================================================= -// WHIR (non-ZK) -// ========================================================================= - -#[cfg(test)] -mod whir_tests { - use std::borrow::Cow; - - use ark_ff::Field; - - use crate::{ - algebra::{ - embedding::Basefield, - fields::{Field64, Field64_3}, - linear_form::{Evaluate, LinearForm, MultilinearExtension}, - MultilinearPoint, - }, - hash, - parameters::ProtocolParameters, - protocols::{geometric_challenge::geometric_challenge, whir}, - transcript::{codecs::Empty, DomainSeparator, Proof, ProverState, VerifierState}, - }; - - type F = Field64; - type EF = Field64_3; - - const NUM_VARS: usize = 4; - const NUM_COEFFS: usize = 1 << NUM_VARS; - - fn make_config(batch_size: usize) -> whir::Config> { - let params = ProtocolParameters { - security_level: 32, - pow_bits: 0, - initial_folding_factor: 2, - folding_factor: 2, - unique_decoding: false, - starting_log_inv_rate: 1, - batch_size, - hash_id: hash::SHA2, - }; - let mut config = whir::Config::>::new(NUM_COEFFS, ¶ms); - config.disable_pow(); - config - } - - fn make_forms(points: &[MultilinearPoint]) -> Vec>>> { - points - .iter() - .map(|p| Box::new(MultilinearExtension { point: p.0.clone() }) as _) - .collect() - } - - fn prove_forms(points: &[MultilinearPoint]) -> Vec>> { - points - .iter() - .map(|p| { - Box::new(MultilinearExtension { point: p.0.clone() }) as Box> - }) - .collect() - } - - /// Attempt verification with `claimed_evals`. - /// Returns true if accepted (soundness bug), false if correctly rejected. - fn forgery_accepted_separate( - config: &whir::Config>, - ds: &DomainSeparator<'_, Empty>, - proof: &Proof, - forms: &[Box>>], - claimed_evals: &[EF], - num_commits: usize, - ) -> bool { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let mut vs = VerifierState::new_std(ds, proof); - let commitments: Vec<_> = (0..num_commits) - .map(|_| config.receive_commitment(&mut vs).unwrap()) - .collect(); - let refs: Vec<_> = commitments.iter().collect(); - config - .verify(&mut vs, &refs, claimed_evals) - .and_then(|fc| fc.verify(forms.iter().map(|l| l.as_ref() as &dyn LinearForm))) - })); - matches!(result, Ok(Ok(()))) - } - - // ─── Issue #1: α-batching forgery ──────────────────────────────────── - - /// Issue #1, separate commitments (batch_size=1, n=2, f=1). - #[test] - fn test_whir_issue1_separate_commits() { - let config = make_config(1); - let mut rng = ark_std::test_rng(); - - let v0 = vec![F::ONE; NUM_COEFFS]; - let v1 = vec![F::from(2u64); NUM_COEFFS]; - let points = vec![MultilinearPoint::rand(&mut rng, NUM_VARS)]; - let forms = make_forms(&points); - let evals: Vec = forms - .iter() - .flat_map(|lf| [&v0, &v1].map(|v| lf.evaluate(config.embedding(), v))) - .collect(); - - let ds = DomainSeparator::protocol(&config) - .session(&format!("audit {}:{}", file!(), line!())) - .instance(&Empty); - let mut ps = ProverState::new_std(&ds); - let w0 = config.commit(&mut ps, &[&v0]); - let w1 = config.commit(&mut ps, &[&v1]); - let _ = config.prove( - &mut ps, - vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], - vec![Cow::Owned(w0), Cow::Owned(w1)], - prove_forms(&points), - Cow::Borrowed(&evals), - ); - let proof = ps.proof(); - - // Sanity. - assert!(forgery_accepted_separate( - &config, &ds, &proof, &forms, &evals, 2 - )); - - // Forge one entry. - let mut forged = evals.clone(); - forged[0] += EF::from(1u64); - assert!( - !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 2), - "REGRESSION issue #1: single-entry forgery (separate commits) must be rejected" - ); - } - - /// Issue #1, batched commitment (batch_size=2, n=2, f=1). - #[test] - fn test_whir_issue1_batched_commit() { - let config = make_config(2); - let mut rng = ark_std::test_rng(); - - let v0 = vec![F::ONE; NUM_COEFFS]; - let v1 = vec![F::from(3u64); NUM_COEFFS]; - let vec_refs: Vec<&[F]> = vec![&v0[..], &v1[..]]; - let points = vec![MultilinearPoint::rand(&mut rng, NUM_VARS)]; - let forms = make_forms(&points); - let evals: Vec = forms - .iter() - .flat_map(|lf| vec_refs.iter().map(|v| lf.evaluate(config.embedding(), v))) - .collect(); - - let ds = DomainSeparator::protocol(&config) - .session(&format!("audit {}:{}", file!(), line!())) - .instance(&Empty); - let mut ps = ProverState::new_std(&ds); - let w = config.commit(&mut ps, &vec_refs); - let _ = config.prove( - &mut ps, - vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], - vec![Cow::Owned(w)], - prove_forms(&points), - Cow::Borrowed(&evals), - ); - let proof = ps.proof(); - - assert!(forgery_accepted_separate( - &config, &ds, &proof, &forms, &evals, 1 - )); - - let mut forged = evals.clone(); - forged[0] += EF::from(1u64); - assert!( - !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 1), - "REGRESSION issue #1: single-entry forgery (batched commit) must be rejected" - ); - } - - /// Issue #1, exact α-cancelling forgery (n=2, f=1). - /// Extracts α from transcript, constructs `[+Δ, −Δ/α]`. - #[test] - fn test_whir_issue1_alpha_cancelling() { - let config = make_config(1); - let mut rng = ark_std::test_rng(); - - let v0 = vec![F::ONE; NUM_COEFFS]; - let v1 = vec![F::from(2u64); NUM_COEFFS]; - let points = vec![MultilinearPoint::rand(&mut rng, NUM_VARS)]; - let forms = make_forms(&points); - let evals: Vec = forms - .iter() - .flat_map(|lf| [&v0, &v1].map(|v| lf.evaluate(config.embedding(), v))) - .collect(); - assert_eq!(evals.len(), 2); - - let ds = DomainSeparator::protocol(&config) - .session(&format!("audit {}:{}", file!(), line!())) - .instance(&Empty); - let mut ps = ProverState::new_std(&ds); - let w0 = config.commit(&mut ps, &[&v0]); - let w1 = config.commit(&mut ps, &[&v1]); - let _ = config.prove( - &mut ps, - vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], - vec![Cow::Owned(w0), Cow::Owned(w1)], - prove_forms(&points), - Cow::Borrowed(&evals), - ); - let proof = ps.proof(); - - // Extract α by replaying the verifier transcript prefix. - // After receive_commitment, verify() reads OOD cross-terms, - // evaluations, then squeezes vector_rlc_coeffs = [1, α]. - let alpha = { - let mut vs = VerifierState::new_std(&ds, &proof); - let c0 = config.receive_commitment(&mut vs).unwrap(); - let c1 = config.receive_commitment(&mut vs).unwrap(); - // OOD cross-terms: 1 per commitment per OOD row. - for _ in 0..(c0.out_of_domain().points.len() + c1.out_of_domain().points.len()) { - let _: EF = vs.prover_message().unwrap(); - } - for _ in 0..evals.len() { - let _: EF = vs.prover_message().unwrap(); - } - let coeffs: Vec = geometric_challenge(&mut vs, 2); - coeffs[1] - }; - - // Exact cancelling forgery: e'₀ + α·e'₁ = e₀ + α·e₁. - let delta = EF::from(42u64); - let mut forged = evals.clone(); - forged[0] += delta; - forged[1] -= delta / alpha; - assert_eq!(evals[0] + alpha * evals[1], forged[0] + alpha * forged[1]); - - assert!( - !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 2), - "REGRESSION issue #1: α-cancelling forgery [+Δ, −Δ/α] must be rejected" - ); - } - - // ─── Issue #3: constraint-RLC forgery ──────────────────────────────── - - /// Issue #3, exact c₁-cancelling forgery (n=1, f=2). - /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]`. - #[test] - fn test_whir_issue3_constraint_rlc_cancelling() { - let config = make_config(1); - let mut rng = ark_std::test_rng(); - - let vector = vec![F::ONE; NUM_COEFFS]; - let points: Vec<_> = (0..2) - .map(|_| MultilinearPoint::rand(&mut rng, NUM_VARS)) - .collect(); - let forms = make_forms(&points); - let evals: Vec = forms - .iter() - .flat_map(|lf| [&vector].map(|v| lf.evaluate(config.embedding(), v))) - .collect(); - assert_eq!(evals.len(), 2); - - let ds = DomainSeparator::protocol(&config) - .session(&format!("audit {}:{}", file!(), line!())) - .instance(&Empty); - let mut ps = ProverState::new_std(&ds); - let w = config.commit(&mut ps, &[&vector]); - let _ = config.prove( - &mut ps, - vec![Cow::Borrowed(vector.as_slice())], - vec![Cow::Owned(w)], - prove_forms(&points), - Cow::Borrowed(&evals), - ); - let proof = ps.proof(); - - // Extract c₁. After commitment + evaluations, the verifier squeezes - // vector_rlc (count=1, no squeeze) then constraint_rlc (count = ood + 2). - let c1 = { - let mut vs = VerifierState::new_std(&ds, &proof); - let c = config.receive_commitment(&mut vs).unwrap(); - for _ in 0..evals.len() { - let _: EF = vs.prover_message().unwrap(); - } - let _: Vec = geometric_challenge(&mut vs, 1); // vector_rlc [1] - let num_ood = c.out_of_domain().points.len(); - let rlc: Vec = geometric_challenge(&mut vs, num_ood + 2); - rlc[1] - }; - - // Exact cancelling forgery: e'₀ + c₁·e'₁ = e₀ + c₁·e₁. - let delta = EF::from(99u64); - let mut forged = evals.clone(); - forged[0] += delta; - forged[1] -= delta / c1; - assert_eq!(evals[0] + c1 * evals[1], forged[0] + c1 * forged[1]); - - assert!( - !forgery_accepted_separate(&config, &ds, &proof, &forms, &forged, 1), - "REGRESSION issue #3: constraint-RLC-cancelling forgery must be rejected" - ); - } -} - -// ========================================================================= -// zkWHIR -// ========================================================================= - -#[cfg(test)] -mod whir_zk_tests { - use std::borrow::Cow; - - use ark_ff::{AdditiveGroup, Field}; - - use crate::{ - algebra::{ - dot, - embedding::Identity, - fields::Field64, - geometric_sequence, - linear_form::{ - Covector, Evaluate, LinearForm, MultilinearExtension, UnivariateEvaluation, - }, - multilinear_extend, univariate_evaluate, MultilinearPoint, - }, - hash, - parameters::ProtocolParameters, - protocols::{ - geometric_challenge::geometric_challenge, - whir, - whir_zk::{ - self, - committer::Witness, - utils::{ - build_beq_tables, build_fold_args, build_weight_covectors, compute_eq_weights, - compute_rs_fold_blinding_coeffs, gamma_to_f_hat_indices, ProtocolDims, - RsFoldCoeffs, - }, - }, - }, - transcript::{ - codecs::Empty, DomainSeparator, Proof, ProverState, VerifierMessage, VerifierState, - }, - }; - - type F = Field64; - - const NUM_VARS: usize = 12; - const NUM_COEFFS: usize = 1 << NUM_VARS; - - fn make_config(batch_size: usize) -> whir_zk::Config { - let params = ProtocolParameters { - unique_decoding: false, - security_level: 16, - pow_bits: 0, - initial_folding_factor: 2, - folding_factor: 2, - starting_log_inv_rate: 1, - batch_size, - hash_id: hash::SHA2, - }; - let mut config = whir_zk::Config::new(NUM_VARS, ¶ms); - config.disable_pow(); - config - } - - fn to_prove_forms( - forms: &[Box>], - size: usize, - ) -> Vec>> { - forms - .iter() - .map(|f| { - let mut cv = vec![F::ZERO; size]; - f.accumulate(&mut cv, F::ONE); - Box::new(Covector::new(cv)) as Box> - }) - .collect() - } - - /// Generate an honest proof and sanity-check it. - fn honest_proof( - config: &whir_zk::Config, - vectors: &[&[F]], - forms: &[Box>], - evals: &[F], - ) -> (DomainSeparator<'static, Empty>, Proof) { - let ds = DomainSeparator::protocol(config) - .session(&format!("audit {}:{}", file!(), line!())) - .instance(&Empty); - let mut ps = ProverState::new_std(&ds); - let witness = config.commit(&mut ps, vectors); - config.prove( - &mut ps, - vectors.iter().map(|&v| Cow::Borrowed(v)).collect(), - witness, - to_prove_forms(forms, vectors[0].len()), - Cow::Borrowed(evals), - ); - let proof = ps.proof(); - - // Sanity: honest proof must pass. - let weights: Vec<&dyn LinearForm> = forms - .iter() - .map(|f| f.as_ref() as &dyn LinearForm) - .collect(); - let mut vs = VerifierState::new_std(&ds, &proof); - let commitments = config.receive_commitments(&mut vs).unwrap(); - config - .verify(&mut vs, &weights, evals, &commitments) - .unwrap() - .verify(weights.iter().copied()) - .unwrap(); - - (ds, proof) - } - - /// Attempt verification with `claimed_evals`. Returns true on accept. - fn forgery_accepted( - config: &whir_zk::Config, - ds: &DomainSeparator<'_, Empty>, - proof: &Proof, - forms: &[Box>], - claimed_evals: &[F], - ) -> bool { - let weights: Vec<&dyn LinearForm> = forms - .iter() - .map(|f| f.as_ref() as &dyn LinearForm) - .collect(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let mut vs = VerifierState::new_std(ds, proof); - let commitments = config.receive_commitments(&mut vs).unwrap(); - config - .verify(&mut vs, &weights, claimed_evals, &commitments) - .and_then(|fc| fc.verify(weights.iter().copied())) - })); - matches!(result, Ok(Ok(()))) - } - - // ─── Issue #1: α-batching forgery ──────────────────────────────────── - - /// Issue #1 (n=2, f=1): exact α-cancelling forgery. - /// Extracts α from transcript, constructs `[+Δ, −Δ/α]`. - #[test] - fn test_zkwhir_issue1_alpha_cancelling() { - let config = make_config(2); - let mut rng = ark_std::test_rng(); - - let v0: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 + 1)).collect(); - let v1: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 * 3 + 7)).collect(); - let point = MultilinearPoint::rand(&mut rng, NUM_VARS); - let form = MultilinearExtension { point: point.0 }; - let embedding = config.embedding(); - let evals = vec![form.evaluate(embedding, &v0), form.evaluate(embedding, &v1)]; - let forms: Vec>> = vec![Box::new(form)]; - - let (ds, proof) = honest_proof(&config, &[&v0, &v1], &forms, &evals); - - // Extract α. After receive_commitments: squeeze β, 1 g_claim, 2 evals, squeeze α. - let alpha = { - let mut vs = VerifierState::new_std(&ds, &proof); - let _ = config.receive_commitments(&mut vs).unwrap(); - let _beta: F = vs.verifier_message(); - let _g: F = vs.prover_message().unwrap(); - let _e0: F = vs.prover_message().unwrap(); - let _e1: F = vs.prover_message().unwrap(); - geometric_challenge::<_, F>(&mut vs, 2)[1] - }; - - let delta = F::from(42u64); - let mut forged = evals.clone(); - forged[0] += delta; - forged[1] -= delta / alpha; - assert_eq!(evals[0] + alpha * evals[1], forged[0] + alpha * forged[1]); - - assert!( - !forgery_accepted(&config, &ds, &proof, &forms, &forged), - "REGRESSION issue #1: α-cancelling forgery must be rejected" - ); - } - - // ─── Issue #2: G-claim forgery via ρ ───────────────────────────────── - - /// Issue #2 (n=1, f=1): full manual transcript replay with forged g_claim. - /// - /// 1. Commit honestly. - /// 2. Send G' = G + Δ. - /// 3. Absorb honest eval (must commit before ρ). - /// 4. ρ is squeezed → construct e' = e − Δ/ρ. - /// 5. Complete proof honestly. - /// 6. Verifier reads honest e from transcript, compares to e' → rejected. - #[test] - #[allow(clippy::too_many_lines)] - fn test_zkwhir_issue2_g_claim_forgery() { - let mut rng = ark_std::test_rng(); - let config = make_config(1); - - let vector = vec![F::ONE; NUM_COEFFS]; - let point = MultilinearPoint::rand(&mut rng, NUM_VARS); - let form = MultilinearExtension { point: point.0 }; - let honest_eval = form.evaluate(config.embedding(), &vector); - - let forms: Vec>> = vec![Box::new(form)]; - let weight_refs: Vec<&dyn LinearForm> = forms - .iter() - .map(|f| f.as_ref() as &dyn LinearForm) - .collect(); - - let ds = DomainSeparator::protocol(&config) - .session(&format!("audit {}:{}", file!(), line!())) - .instance(&Empty); - let mut prover_state = ProverState::new_std(&ds); - let witness = config.commit(&mut prover_state, &[&vector]); - - let Witness { - f_hat_witness, - blinding_poly_witness, - f_hat_polys, - secrets, - } = witness; - - let dims = ProtocolDims::new(&config, 1); - let size = dims.size; - - // ── MALICIOUS Step 2: send g_claim + Δ ── - - let beta: F = prover_state.verifier_message(); - let beta_powers = geometric_sequence(beta, dims.num_g_polys()); - let g_poly: Vec = (0..size) - .map(|idx| { - beta_powers - .iter() - .enumerate() - .map(|(i, &bp)| bp * secrets.g_polys[i][dims.phi_i_bits(idx, i)]) - .sum() - }) - .collect(); - let honest_g_claim: F = { - let mut buf = vec![F::ZERO; size]; - forms[0].accumulate(&mut buf, F::ONE); - dot(&buf, &g_poly) - }; - - let delta = F::from(77u64); - prover_state.prover_message(&(honest_g_claim + delta)); // forged G' - prover_state.prover_message(&honest_eval); // must absorb before ρ - - let alpha_coeffs: Vec = geometric_challenge(&mut prover_state, 1); - let rho: F = prover_state.verifier_message(); - assert_ne!(rho, F::ZERO); - - // f_zk = ρ·f + g (honest) - let mut f_zk: Vec = vector.iter().map(|&v| rho * v).collect(); - for (f, &g) in f_zk.iter_mut().zip(g_poly.iter()) { - *f += g; - } - drop(g_poly); - - let combined_claim = rho * honest_eval + honest_g_claim; - - // Step 4: sumcheck - let constraint_rlc: Vec = geometric_challenge(&mut prover_state, 1); - let mut covector = vec![F::ZERO; size]; - for (coeff, lf) in constraint_rlc.iter().zip(forms.iter()) { - lf.accumulate(&mut covector, *coeff); - } - let mut the_sum: F = constraint_rlc[0] * combined_claim; - let folding_randomness = config.blinded_polynomial.initial_sumcheck.prove( - &mut prover_state, - &mut f_zk, - &mut covector, - &mut the_sum, - ); - - // Steps 5-6: honest - let r_bar = &folding_randomness.0; - let eq_weights_vec = compute_eq_weights(r_bar); - let RsFoldCoeffs { - masking_coeffs_all, - g_i_coeffs, - } = compute_rs_fold_blinding_coeffs( - &eq_weights_vec, - &secrets.g_polys, - &secrets.masking_polys, - &alpha_coeffs, - rho, - dims, - ); - - let round_config = &config.blinded_polynomial.round_configs[0]; - let folded_commit = round_config - .irs_committer - .commit(&mut prover_state, &[&f_zk]); - round_config.pow.prove(&mut prover_state); - let in_domain = config - .blinded_polynomial - .initial_committer - .open(&mut prover_state, &[&f_hat_witness]); - - let mut lambda_z_points: Vec = Vec::new(); - let send_blinding = |ps: &mut ProverState<_, _>, z: F| { - for m in &masking_coeffs_all { - ps.prover_message(&univariate_evaluate(m, z)); - } - for g in &g_i_coeffs { - ps.prover_message(&univariate_evaluate(g, z)); - } - }; - - let f_hat_combined = &f_hat_polys[0]; - let mu = dims.mu; - for &z in &folded_commit.out_of_domain().points { - prover_state.prover_message(&multilinear_extend( - f_hat_combined, - &build_fold_args(r_bar, z, mu), - )); - send_blinding(&mut prover_state, z); - lambda_z_points.push(z); - } - drop(f_hat_polys); - for &z in &in_domain.points { - send_blinding(&mut prover_state, z); - lambda_z_points.push(z); - } - { - let stir_challenges: Vec> = folded_commit - .out_of_domain() - .evaluators(round_config.initial_size()) - .chain(in_domain.evaluators(round_config.initial_size())) - .collect(); - let ood_evals = folded_commit.out_of_domain().values(&[F::ONE]); - let num_ood = folded_commit.out_of_domain().points.len(); - let embedding = Identity::new(); - let stir_evals: Vec = ood_evals - .chain( - stir_challenges[num_ood..] - .iter() - .map(|ch| ch.evaluate(&embedding, &f_zk)), - ) - .collect(); - let stir_rlc: Vec = geometric_challenge(&mut prover_state, stir_challenges.len()); - UnivariateEvaluation::accumulate_many(&stir_challenges, &mut covector, &stir_rlc); - the_sum += dot(&stir_rlc, &stir_evals); - } - let round0_folding = - round_config - .sumcheck - .prove(&mut prover_state, &mut f_zk, &mut covector, &mut the_sum); - let remaining = whir::rounds::prove_remaining_rounds( - &config.blinded_polynomial.round_configs, - &whir::rounds::FinalRoundConfig { - sumcheck: &config.blinded_polynomial.final_sumcheck, - pow: &config.blinded_polynomial.final_pow, - }, - &mut prover_state, - &mut whir::rounds::SumcheckState { - vector: &mut f_zk, - covector: &mut covector, - the_sum: &mut the_sum, - }, - folded_commit, - &round0_folding, - ); - let gamma_points = remaining.first_in_domain_points; - let _ = config.blinded_polynomial.initial_committer.open_at_indices( - &mut prover_state, - &[&f_hat_witness], - &gamma_to_f_hat_indices(&gamma_points, &config), - ); - for &gamma in &gamma_points { - send_blinding(&mut prover_state, gamma); - lambda_z_points.push(gamma); - } - drop(f_zk); - drop(covector); - - // Step 7: blinding proof (honest) - let tau: F = prover_state.verifier_message(); - let beq_tables = build_beq_tables(&lambda_z_points, &eq_weights_vec, tau, dims); - let weight_covectors = build_weight_covectors(&beq_tables, rho, &alpha_coeffs, dims); - let mut eval_matrix = Vec::with_capacity(dims.num_blinding_vecs * dims.num_blinding_vecs); - for w in &weight_covectors { - for v in &secrets.blinding_vectors { - eval_matrix.push(dot(w, v)); - } - } - for e in &eval_matrix { - prover_state.prover_message(e); - } - let blinding_forms: Vec>> = weight_covectors - .into_iter() - .map(|cv| Box::new(Covector::new(cv)) as _) - .collect(); - let blinding_cows: Vec> = secrets - .blinding_vectors - .iter() - .map(|v| Cow::Borrowed(v.as_slice())) - .collect(); - let _ = config.blinding_polynomial.prove( - &mut prover_state, - blinding_cows, - vec![Cow::Borrowed(&blinding_poly_witness)], - blinding_forms, - Cow::Owned(eval_matrix), - ); - - // Verify with forged e' = e − Δ/ρ. - let proof = prover_state.proof(); - let forged_eval = honest_eval - delta / rho; - assert_ne!(forged_eval, honest_eval); - - let attack = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let mut vs = VerifierState::new_std(&ds, &proof); - let commitments = config.receive_commitments(&mut vs).unwrap(); - config - .verify(&mut vs, &weight_refs, &[forged_eval], &commitments) - .and_then(|fc| fc.verify(weight_refs.iter().copied())) - })); - assert!( - !matches!(attack, Ok(Ok(()))), - "REGRESSION issue #2: g_claim forgery (G'=G+Δ, e'=e−Δ/ρ) must be rejected" - ); - } - - // ─── Issue #3: constraint-RLC forgery ──────────────────────────────── - - /// Issue #3 (n=1, f=2): exact c₁-cancelling forgery. - /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]`. - #[test] - fn test_zkwhir_issue3_constraint_rlc_cancelling() { - let config = make_config(1); - let mut rng = ark_std::test_rng(); - - let vector: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 + 1)).collect(); - let p0 = MultilinearPoint::rand(&mut rng, NUM_VARS); - let p1 = MultilinearPoint::rand(&mut rng, NUM_VARS); - let f0 = MultilinearExtension { point: p0.0 }; - let f1 = MultilinearExtension { point: p1.0 }; - let embedding = config.embedding(); - let evals = vec![ - f0.evaluate(embedding, &vector), - f1.evaluate(embedding, &vector), - ]; - let forms: Vec>> = vec![Box::new(f0), Box::new(f1)]; - - let (ds, proof) = honest_proof(&config, &[&vector], &forms, &evals); - - // Extract c₁. For n=1, f=2: - // β, 2 g_claims, 2 evals, α(1→no squeeze), ρ, constraint_rlc=[1, c₁]. - let c1 = { - let mut vs = VerifierState::new_std(&ds, &proof); - let _ = config.receive_commitments(&mut vs).unwrap(); - let _beta: F = vs.verifier_message(); - for _ in 0..2 { - let _: F = vs.prover_message().unwrap(); - } // g_claims - for _ in 0..2 { - let _: F = vs.prover_message().unwrap(); - } // evals - let _: Vec = geometric_challenge(&mut vs, 1); // α = [1] - let _rho: F = vs.verifier_message(); - geometric_challenge::<_, F>(&mut vs, 2)[1] - }; - - let delta = F::from(99u64); - let mut forged = evals.clone(); - forged[0] += delta; - forged[1] -= delta / c1; - assert_eq!(evals[0] + c1 * evals[1], forged[0] + c1 * forged[1]); - - assert!( - !forgery_accepted(&config, &ds, &proof, &forms, &forged), - "REGRESSION issue #3: constraint-RLC-cancelling forgery must be rejected" - ); - } - - // ─── Combined: all three surfaces ──────────────────────────────────── - - /// Issues #1+#2+#3 combined (n=2, f=2): 4 evaluations. - /// Tests single-entry, cross-vector, and cross-form forgeries. - #[test] - fn test_zkwhir_combined_n2_f2() { - let config = make_config(2); - let mut rng = ark_std::test_rng(); - - let v0: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 + 1)).collect(); - let v1: Vec = (0..NUM_COEFFS).map(|i| F::from(i as u64 * 3 + 7)).collect(); - let p0 = MultilinearPoint::rand(&mut rng, NUM_VARS); - let p1 = MultilinearPoint::rand(&mut rng, NUM_VARS); - let f0 = MultilinearExtension { point: p0.0 }; - let f1 = MultilinearExtension { point: p1.0 }; - let embedding = config.embedding(); - let evals = vec![ - f0.evaluate(embedding, &v0), - f0.evaluate(embedding, &v1), - f1.evaluate(embedding, &v0), - f1.evaluate(embedding, &v1), - ]; - let forms: Vec>> = vec![Box::new(f0), Box::new(f1)]; - - let (ds, proof) = honest_proof(&config, &[&v0, &v1], &forms, &evals); - - // (a) Single entry. - let mut fa = evals.clone(); - fa[0] += F::from(1u64); - assert!( - !forgery_accepted(&config, &ds, &proof, &forms, &fa), - "single-entry forgery must be rejected" - ); - - // (b) Cross-vector (α dimension, row 0). - let mut fb = evals.clone(); - fb[0] += F::from(99u64); - fb[1] -= F::from(99u64); - assert!( - !forgery_accepted(&config, &ds, &proof, &forms, &fb), - "cross-vector forgery must be rejected" - ); - - // (c) Cross-form (constraint-RLC dimension). - let mut fc = evals.clone(); - fc[0] += F::from(55u64); - fc[2] -= F::from(55u64); - assert!( - !forgery_accepted(&config, &ds, &proof, &forms, &fc), - "cross-form forgery must be rejected" - ); - } -} diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 608988b7..4f782391 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -17,6 +17,3 @@ pub mod proof_of_work; pub mod sumcheck; pub mod whir; pub mod whir_zk; - -#[cfg(test)] -mod audit_soundness_tests; diff --git a/src/protocols/whir/mod.rs b/src/protocols/whir/mod.rs index a0c6c403..bfd856c1 100644 --- a/src/protocols/whir/mod.rs +++ b/src/protocols/whir/mod.rs @@ -918,4 +918,323 @@ mod tests { } } } + + // ===================================================================== + // Soundness regression — evaluation forgery (issues #1, #3) + // + // Root cause: evaluations were not bound in the Fiat-Shamir transcript + // before α / constraint_rlc were sampled. The fix absorbs all evals + // as prover messages; the verifier reads them back and checks + // `verify!(read == expected)`. + // ===================================================================== + + use crate::protocols::geometric_challenge::geometric_challenge; + + /// Number of variables for the soundness regression tests. + /// Kept small (4) so the tests run fast while still exercising + /// all transcript-level challenge extraction paths. + const SOUNDNESS_NUM_VARIABLES: usize = 4; + const SOUNDNESS_NUM_COEFFS: usize = 1 << SOUNDNESS_NUM_VARIABLES; + + /// Build a WHIR config for soundness tests with PoW disabled. + fn soundness_config(batch_size: usize) -> Config> { + let params = ProtocolParameters { + security_level: 32, + pow_bits: 0, + initial_folding_factor: 2, + folding_factor: 2, + unique_decoding: false, + starting_log_inv_rate: 1, + batch_size, + hash_id: hash::SHA2, + }; + let mut config = Config::>::new(SOUNDNESS_NUM_COEFFS, ¶ms); + config.disable_pow(); + config + } + + /// Build `Evaluate`-trait linear forms from multilinear evaluation points. + /// Used to compute honest evaluations (the `Evaluate` trait provides + /// `evaluate(embedding, vector)` which `LinearForm` alone does not). + fn evaluation_forms(points: &[MultilinearPoint]) -> Vec>>> { + points + .iter() + .map(|p| Box::new(MultilinearExtension { point: p.0.clone() }) as _) + .collect() + } + + /// Build owned `LinearForm` objects consumed by `prove()`. + fn owned_linear_forms(points: &[MultilinearPoint]) -> Vec>> { + points + .iter() + .map(|p| Box::new(MultilinearExtension { point: p.0.clone() }) as _) + .collect() + } + + /// Run the WHIR verifier with the given evaluations. + /// Returns `true` if accepted (soundness bug), `false` if rejected. + /// + /// Uses `catch_unwind` because the `verify!` macro can either return + /// `Err` or panic depending on build configuration — both count as + /// correct rejection. + fn verifier_accepts( + config: &Config>, + ds: &DomainSeparator<'_, Empty>, + proof: &crate::transcript::Proof, + forms: &[Box>>], + claimed_evals: &[EF], + num_commits: usize, + ) -> bool { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut vs = VerifierState::new_std(ds, proof); + let cs: Vec<_> = (0..num_commits) + .map(|_| config.receive_commitment(&mut vs).unwrap()) + .collect(); + let refs: Vec<_> = cs.iter().collect(); + config + .verify(&mut vs, &refs, claimed_evals) + .and_then(|fc| fc.verify(forms.iter().map(|l| l.as_ref() as &dyn LinearForm))) + })); + matches!(result, Ok(Ok(()))) + } + + /// Replay the WHIR verifier transcript up to the vector_rlc challenge + /// to extract the batching coefficient α. + /// + /// Transcript structure after `receive_commitment`: + /// 1. OOD cross-terms (prover messages) — one per commitment per OOD + /// row for each out-of-range vector index + /// 2. Public evaluations (prover messages) — the fix + /// 3. vector_rlc_coeffs = geometric_challenge(num_vectors) → [1, α] + fn extract_alpha( + config: &Config>, + ds: &DomainSeparator<'_, Empty>, + proof: &crate::transcript::Proof, + num_evals: usize, + ) -> EF { + let mut vs = VerifierState::new_std(ds, proof); + let c0 = config.receive_commitment(&mut vs).unwrap(); + let c1 = config.receive_commitment(&mut vs).unwrap(); + + // Skip OOD cross-terms: with 2 separate commits of 1 vector each, + // each commitment produces 1 cross-term per OOD row (the other vector). + let num_ood_cross_terms = c0.out_of_domain().points.len() + c1.out_of_domain().points.len(); + for _ in 0..num_ood_cross_terms { + let _: EF = vs.prover_message().unwrap(); + } + + // Skip evaluation messages (the transcript-binding fix). + for _ in 0..num_evals { + let _: EF = vs.prover_message().unwrap(); + } + + // vector_rlc_coeffs = [1, α] for 2 vectors. + geometric_challenge::<_, EF>(&mut vs, 2)[1] + } + + /// Replay the WHIR verifier transcript up to the constraint_rlc challenge + /// to extract the per-form coefficient c₁. + /// + /// Transcript structure after `receive_commitment` (single commit, single vector): + /// 1. No OOD cross-terms (1 commit × 1 vector = no cross terms) + /// 2. Public evaluations (prover messages) + /// 3. vector_rlc = geometric_challenge(1) → [1] (no transcript squeeze) + /// 4. constraint_rlc = geometric_challenge(num_ood + num_forms) → [1, c₁, ...] + fn extract_constraint_rlc_coeff( + config: &Config>, + ds: &DomainSeparator<'_, Empty>, + proof: &crate::transcript::Proof, + num_evals: usize, + num_forms: usize, + ) -> EF { + let mut vs = VerifierState::new_std(ds, proof); + let c = config.receive_commitment(&mut vs).unwrap(); + + // No OOD cross-terms for a single commit with a single vector. + // Skip evaluation messages. + for _ in 0..num_evals { + let _: EF = vs.prover_message().unwrap(); + } + + // vector_rlc for 1 vector: geometric_challenge(1) returns [ONE] + // without squeezing from the transcript (see geometric_challenge.rs), + // but we must still call it to keep the replay in sync. + let _vector_rlc: Vec = geometric_challenge(&mut vs, 1); + + // constraint_rlc for (num_ood + num_forms) constraints. + let num_ood = c.out_of_domain().points.len(); + geometric_challenge::<_, EF>(&mut vs, num_ood + num_forms)[1] + } + + /// Issue #1, separate commitments (batch_size=1, n=2, f=1). + #[test] + fn test_whir_issue1_separate_commits() { + let config = soundness_config(1); + let mut rng = ark_std::test_rng(); + + let v0 = vec![F::ONE; SOUNDNESS_NUM_COEFFS]; + let v1 = vec![F::from(2u64); SOUNDNESS_NUM_COEFFS]; + let points = vec![MultilinearPoint::rand(&mut rng, SOUNDNESS_NUM_VARIABLES)]; + let forms = evaluation_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| [&v0, &v1].map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w0 = config.commit(&mut ps, &[&v0]); + let w1 = config.commit(&mut ps, &[&v1]); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], + vec![Cow::Owned(w0), Cow::Owned(w1)], + owned_linear_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + assert!(verifier_accepts(&config, &ds, &proof, &forms, &evals, 2)); + + let mut forged = evals.clone(); + forged[0] += EF::from(1u64); + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &forged, 2), + "REGRESSION issue #1: single-entry forgery (separate commits) must be rejected" + ); + } + + /// Issue #1, batched commitment (batch_size=2, n=2, f=1). + #[test] + fn test_whir_issue1_batched_commit() { + let config = soundness_config(2); + let mut rng = ark_std::test_rng(); + + let v0: Vec = std::iter::repeat_n(F::ONE, SOUNDNESS_NUM_COEFFS).collect(); + let v1: Vec = std::iter::repeat_n(F::from(3u64), SOUNDNESS_NUM_COEFFS).collect(); + let vec_refs: Vec<&[F]> = vec![&v0[..], &v1[..]]; + let points = vec![MultilinearPoint::rand(&mut rng, SOUNDNESS_NUM_VARIABLES)]; + let forms = evaluation_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| vec_refs.iter().map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w = config.commit(&mut ps, &vec_refs); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], + vec![Cow::Owned(w)], + owned_linear_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + assert!(verifier_accepts(&config, &ds, &proof, &forms, &evals, 1)); + + let mut forged = evals.clone(); + forged[0] += EF::from(1u64); + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &forged, 1), + "REGRESSION issue #1: single-entry forgery (batched commit) must be rejected" + ); + } + + /// Issue #1, exact α-cancelling forgery (n=2, f=1). + /// Replays verifier transcript to extract α, constructs `[+Δ, −Δ/α]`. + #[test] + fn test_whir_issue1_alpha_cancelling() { + let config = soundness_config(1); + let mut rng = ark_std::test_rng(); + + let v0 = vec![F::ONE; SOUNDNESS_NUM_COEFFS]; + let v1 = vec![F::from(2u64); SOUNDNESS_NUM_COEFFS]; + let points = vec![MultilinearPoint::rand(&mut rng, SOUNDNESS_NUM_VARIABLES)]; + let forms = evaluation_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| [&v0, &v1].map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w0 = config.commit(&mut ps, &[&v0]); + let w1 = config.commit(&mut ps, &[&v1]); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(&v0[..]), Cow::Borrowed(&v1[..])], + vec![Cow::Owned(w0), Cow::Owned(w1)], + owned_linear_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + let alpha = extract_alpha(&config, &ds, &proof, evals.len()); + + // Exact cancelling forgery: e'₀ + α·e'₁ = e₀ + α·e₁. + let delta = EF::from(42u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / alpha; + assert_eq!(evals[0] + alpha * evals[1], forged[0] + alpha * forged[1]); + + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &forged, 2), + "REGRESSION issue #1: α-cancelling forgery [+Δ, −Δ/α] must be rejected" + ); + } + + /// Issue #3, exact c₁-cancelling forgery (n=1, f=2). + /// Replays verifier transcript to extract c₁, constructs `[+Δ, −Δ/c₁]`. + #[test] + fn test_whir_issue3_constraint_rlc_cancelling() { + let config = soundness_config(1); + let mut rng = ark_std::test_rng(); + + let vector = vec![F::ONE; SOUNDNESS_NUM_COEFFS]; + let points: Vec<_> = (0..2) + .map(|_| MultilinearPoint::rand(&mut rng, SOUNDNESS_NUM_VARIABLES)) + .collect(); + let forms = evaluation_forms(&points); + let evals: Vec = forms + .iter() + .flat_map(|lf| [&vector].map(|v| lf.evaluate(config.embedding(), v))) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let w = config.commit(&mut ps, &[&vector]); + let _ = config.prove( + &mut ps, + vec![Cow::Borrowed(vector.as_slice())], + vec![Cow::Owned(w)], + owned_linear_forms(&points), + Cow::Borrowed(&evals), + ); + let proof = ps.proof(); + + let c1 = extract_constraint_rlc_coeff(&config, &ds, &proof, evals.len(), 2); + + // Exact cancelling forgery: e'₀ + c₁·e'₁ = e₀ + c₁·e₁. + let delta = EF::from(99u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / c1; + assert_eq!(evals[0] + c1 * evals[1], forged[0] + c1 * forged[1]); + + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &forged, 1), + "REGRESSION issue #3: constraint-RLC-cancelling forgery must be rejected" + ); + } } diff --git a/src/protocols/whir_zk/committer.rs b/src/protocols/whir_zk/committer.rs index 1035d48e..7b431565 100644 --- a/src/protocols/whir_zk/committer.rs +++ b/src/protocols/whir_zk/committer.rs @@ -15,13 +15,13 @@ use crate::{ /// destructurable (no custom `Drop`) while guaranteeing zeroization /// of the secret data regardless of how the witness is consumed. #[derive(Zeroize, ZeroizeOnDrop)] -pub struct BlindingSecrets { +pub(super) struct BlindingSecrets { /// Per-witness masking polynomials mskᵢ (ℓ-variate, 2^ℓ coefficients). - pub(crate) masking_polys: Vec>, + pub(super) masking_polys: Vec>, /// Blinding polynomials ĝ₀..ĝ_ν (ℓ-variate, 2^ℓ coefficients each). - pub(crate) g_polys: Vec>, + pub(super) g_polys: Vec>, /// Interleaved blinding vectors [M₀, ..., M_{n-1}, ĝ₁, ..., ĝ_ν] as committed. - pub(crate) blinding_vectors: Vec>, + pub(super) blinding_vectors: Vec>, } /// Prover-side witness produced by Step 1 (Commitment). @@ -36,13 +36,13 @@ pub struct BlindingSecrets { #[allow(clippy::struct_field_names)] pub struct Witness { /// IRS-commit witness for [[f̂]] (first WHIR instance). - pub(crate) f_hat_witness: irs_commit::Witness, + pub(super) f_hat_witness: irs_commit::Witness, /// IRS-commit witness for [[M]], [[ĝ₁]]..[[ĝ_ν]] (second WHIR instance). - pub(crate) blinding_poly_witness: irs_commit::Witness, + pub(super) blinding_poly_witness: irs_commit::Witness, /// f̂ᵢ = fᵢ + mskᵢ(Φ₀) for each of the n witness polynomials. - pub(crate) f_hat_polys: Vec>, + pub(super) f_hat_polys: Vec>, /// Secret blinding randomness (zeroized on drop). - pub(crate) secrets: BlindingSecrets, + pub(super) secrets: BlindingSecrets, } impl Config { diff --git a/src/protocols/whir_zk/mod.rs b/src/protocols/whir_zk/mod.rs index cf4a08fb..7acc4c5c 100644 --- a/src/protocols/whir_zk/mod.rs +++ b/src/protocols/whir_zk/mod.rs @@ -23,9 +23,9 @@ use serde::{Deserialize, Serialize}; use crate::algebra::embedding::Embedding; -pub(crate) mod committer; +mod committer; mod prover; -pub(crate) mod utils; +mod utils; mod verifier; pub use self::{committer::Witness, verifier::Commitments}; @@ -291,7 +291,10 @@ mod tests { }, hash, parameters::ProtocolParameters, - transcript::{codecs::Empty, DomainSeparator, ProverState, VerifierState}, + protocols::geometric_challenge::geometric_challenge, + transcript::{ + codecs::Empty, DomainSeparator, Proof, ProverState, VerifierMessage, VerifierState, + }, }; type F = Field64; @@ -318,9 +321,10 @@ mod tests { .collect() } - /// Helper: run a full prove → verify cycle for zkWHIR 2.0. - /// `vectors` is a list of witness polynomial evaluation tables. - /// `evaluations` is row-major: `evaluations[j * n + i]` = ⟨wⱼ, fᵢ⟩. + /// Run a full prove → verify cycle for zkWHIR 2.0. + /// Convenience wrapper around `honest_proof_and_verify` that discards + /// the returned `(ds, proof)` — used by functional tests that don't + /// need to attempt forgery afterwards. #[allow(clippy::needless_pass_by_value)] fn prove_and_verify( config: &Config, @@ -328,42 +332,8 @@ mod tests { forms: Vec>>, evaluations: &[F], ) { - let prove_forms = to_prove_forms(forms.as_slice(), vectors[0].len()); - - let ds = DomainSeparator::protocol(config) - .session(&format!("zk2-pv {}:{}", file!(), line!())) - .instance(&Empty); - let mut prover_state = ProverState::new_std(&ds); - - let poly_refs: Vec<&[F]> = vectors.iter().map(|v| v.as_slice()).collect(); - let witness = config.commit(&mut prover_state, &poly_refs); - config.prove( - &mut prover_state, - vectors.into_iter().map(Cow::Owned).collect(), - witness, - prove_forms, - Cow::Borrowed(evaluations), - ); - - let proof = prover_state.proof(); - let mut verifier_state = VerifierState::new_std(&ds, &proof); - - let commitments = config - .receive_commitments(&mut verifier_state) - .expect("receive_commitments failed"); - - let weight_refs: Vec<&dyn LinearForm> = forms - .iter() - .map(|f| f.as_ref() as &dyn LinearForm) - .collect(); - - // Blinded polynomial FinalClaim: verify the linear form RLC. - // (Blinding polynomial FinalClaim is verified internally by verify().) - config - .verify(&mut verifier_state, &weight_refs, evaluations, &commitments) - .expect("verification failed") - .verify(weight_refs) - .expect("blinded polynomial final claim check failed"); + let vec_refs: Vec<&[F]> = vectors.iter().map(|v| v.as_slice()).collect(); + let _ = honest_proof_and_verify(config, &vec_refs, &forms, evaluations); } #[test] @@ -753,4 +723,496 @@ mod tests { prove_and_verify(&config, vec![vector], vec![Box::new(form)], &[evaluation]); } + + // ===================================================================== + // Soundness regression — evaluation forgery (issues #1, #2, #3) + // + // Root cause: evaluations were not bound in the Fiat-Shamir transcript + // before α, ρ, or constraint_rlc were sampled. The fix absorbs all + // evals as prover messages; the verifier reads them back and checks + // `verify!(read == expected)`. + // ===================================================================== + + use super::{ + committer::Witness, + utils::{ + build_beq_tables, build_fold_args, build_weight_covectors, compute_eq_weights, + compute_rs_fold_blinding_coeffs, gamma_to_f_hat_indices, ProtocolDims, RsFoldCoeffs, + }, + }; + use crate::{ + algebra::{ + dot, embedding::Identity, geometric_sequence, linear_form::UnivariateEvaluation, + multilinear_extend, univariate_evaluate, + }, + protocols::whir, + }; + + /// Generate an honest zkWHIR proof and sanity-check that it verifies. + /// Returns the domain separator and proof for use in forgery tests. + fn honest_proof_and_verify( + config: &Config, + vectors: &[&[F]], + forms: &[Box>], + evals: &[F], + ) -> (DomainSeparator<'static, Empty>, Proof) { + let ds = DomainSeparator::protocol(config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut ps = ProverState::new_std(&ds); + let witness = config.commit(&mut ps, vectors); + config.prove( + &mut ps, + vectors.iter().map(|&v| Cow::Borrowed(v)).collect(), + witness, + to_prove_forms(forms, vectors[0].len()), + Cow::Borrowed(evals), + ); + let proof = ps.proof(); + + let weights: Vec<&dyn LinearForm> = forms + .iter() + .map(|f| f.as_ref() as &dyn LinearForm) + .collect(); + let mut vs = VerifierState::new_std(&ds, &proof); + let commitments = config.receive_commitments(&mut vs).unwrap(); + config + .verify(&mut vs, &weights, evals, &commitments) + .unwrap() + .verify(weights.iter().copied()) + .unwrap(); + + (ds, proof) + } + + /// Run the zkWHIR verifier with the given evaluations. + /// Returns `true` if accepted (soundness bug), `false` if rejected. + /// + /// Uses `catch_unwind` because the `verify!` macro can either return + /// `Err` or panic depending on build configuration — both count as + /// correct rejection. + fn verifier_accepts( + config: &Config, + ds: &DomainSeparator<'_, Empty>, + proof: &Proof, + forms: &[Box>], + claimed_evals: &[F], + ) -> bool { + let weights: Vec<&dyn LinearForm> = forms + .iter() + .map(|f| f.as_ref() as &dyn LinearForm) + .collect(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut vs = VerifierState::new_std(ds, proof); + let commitments = config.receive_commitments(&mut vs).unwrap(); + config + .verify(&mut vs, &weights, claimed_evals, &commitments) + .and_then(|fc| fc.verify(weights.iter().copied())) + })); + matches!(result, Ok(Ok(()))) + } + + /// Issue #1 (n=2, f=1): exact α-cancelling forgery. + /// Extracts α from transcript, constructs `[+Δ, −Δ/α]`. + #[test] + fn test_zkwhir_issue1_alpha_cancelling() { + let config = make_test_config_batch(2); + let mut rng = ark_std::test_rng(); + + let v0: Vec = (0..TEST_NUM_COEFFS) + .map(|i| F::from(i as u64 + 1)) + .collect(); + let v1: Vec = (0..TEST_NUM_COEFFS) + .map(|i| F::from(i as u64 * 3 + 7)) + .collect(); + let point = MultilinearPoint::rand(&mut rng, TEST_NUM_VARIABLES); + let form = MultilinearExtension { point: point.0 }; + let embedding = config.embedding(); + let evals = vec![form.evaluate(embedding, &v0), form.evaluate(embedding, &v1)]; + let forms: Vec>> = vec![Box::new(form)]; + + let (ds, proof) = honest_proof_and_verify(&config, &[&v0, &v1], &forms, &evals); + + // Replay the verifier transcript to extract the batching coefficient α. + // + // zkWHIR transcript after receive_commitments (n=2, f=1): + // 1. V → P: β (verifier challenge) + // 2. P → V: G = ⟨w, g⟩ (1 g_claim for 1 form) + // 3. P → V: eval₀, eval₁ (2 evals — the fix) + // 4. V → P: α via geometric_challenge(2) → [1, α] + let alpha = { + let mut vs = VerifierState::new_std(&ds, &proof); + let _ = config.receive_commitments(&mut vs).unwrap(); + let _beta: F = vs.verifier_message(); // step 1 + let _g_claim: F = vs.prover_message().unwrap(); // step 2 + let _eval_0: F = vs.prover_message().unwrap(); // step 3 + let _eval_1: F = vs.prover_message().unwrap(); // step 3 + geometric_challenge::<_, F>(&mut vs, 2)[1] // step 4 + }; + + let delta = F::from(42u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / alpha; + assert_eq!(evals[0] + alpha * evals[1], forged[0] + alpha * forged[1]); + + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &forged), + "REGRESSION issue #1: α-cancelling forgery must be rejected" + ); + } + + /// Issue #2 (n=1, f=1): full manual transcript replay with forged g_claim. + /// + /// 1. Commit honestly. + /// 2. Send G' = G + Δ. + /// 3. Absorb honest eval (must commit before ρ). + /// 4. ρ is squeezed → construct e' = e − Δ/ρ. + /// 5. Complete proof honestly. + /// 6. Verifier reads honest e from transcript, compares to e' → rejected. + #[test] + #[allow(clippy::too_many_lines)] + fn test_zkwhir_issue2_g_claim_forgery() { + let mut rng = ark_std::test_rng(); + let config = make_test_config(); + + let vector = vec![F::ONE; TEST_NUM_COEFFS]; + let point = MultilinearPoint::rand(&mut rng, TEST_NUM_VARIABLES); + let form = MultilinearExtension { point: point.0 }; + let honest_eval = form.evaluate(config.embedding(), &vector); + + let forms: Vec>> = vec![Box::new(form)]; + let weight_refs: Vec<&dyn LinearForm> = forms + .iter() + .map(|f| f.as_ref() as &dyn LinearForm) + .collect(); + + let ds = DomainSeparator::protocol(&config) + .session(&format!("audit {}:{}", file!(), line!())) + .instance(&Empty); + let mut prover_state = ProverState::new_std(&ds); + let witness = config.commit(&mut prover_state, &[&vector]); + + let Witness { + f_hat_witness, + blinding_poly_witness, + f_hat_polys, + secrets, + } = witness; + + let dims = ProtocolDims::new(&config, 1); + let size = dims.size; + + // ── MALICIOUS Step 2: send g_claim + Δ ── + + let beta: F = prover_state.verifier_message(); + let beta_powers = geometric_sequence(beta, dims.num_g_polys()); + let g_poly: Vec = (0..size) + .map(|idx| { + beta_powers + .iter() + .enumerate() + .map(|(i, &bp)| bp * secrets.g_polys[i][dims.phi_i_bits(idx, i)]) + .sum() + }) + .collect(); + let honest_g_claim: F = { + let mut buf = vec![F::ZERO; size]; + forms[0].accumulate(&mut buf, F::ONE); + dot(&buf, &g_poly) + }; + + let delta = F::from(77u64); + prover_state.prover_message(&(honest_g_claim + delta)); // forged G' + prover_state.prover_message(&honest_eval); // must absorb before ρ + + let alpha_coeffs: Vec = geometric_challenge(&mut prover_state, 1); + let rho: F = prover_state.verifier_message(); + assert_ne!(rho, F::ZERO); + + // f_zk = ρ·f + g (honest) + let mut f_zk: Vec = vector.iter().map(|&v| rho * v).collect(); + for (f, &g) in f_zk.iter_mut().zip(g_poly.iter()) { + *f += g; + } + drop(g_poly); + + let combined_claim = rho * honest_eval + honest_g_claim; + + // Step 4: sumcheck + let constraint_rlc: Vec = geometric_challenge(&mut prover_state, 1); + let mut covector = vec![F::ZERO; size]; + for (coeff, lf) in constraint_rlc.iter().zip(forms.iter()) { + lf.accumulate(&mut covector, *coeff); + } + let mut the_sum: F = constraint_rlc[0] * combined_claim; + let folding_randomness = config.blinded_polynomial.initial_sumcheck.prove( + &mut prover_state, + &mut f_zk, + &mut covector, + &mut the_sum, + ); + + // Steps 5-6: honest + let r_bar = &folding_randomness.0; + let eq_weights_vec = compute_eq_weights(r_bar); + let RsFoldCoeffs { + masking_coeffs_all, + g_i_coeffs, + } = compute_rs_fold_blinding_coeffs( + &eq_weights_vec, + &secrets.g_polys, + &secrets.masking_polys, + &alpha_coeffs, + rho, + dims, + ); + + let round_config = &config.blinded_polynomial.round_configs[0]; + let folded_commit = round_config + .irs_committer + .commit(&mut prover_state, &[&f_zk]); + round_config.pow.prove(&mut prover_state); + let in_domain = config + .blinded_polynomial + .initial_committer + .open(&mut prover_state, &[&f_hat_witness]); + + let mut lambda_z_points: Vec = Vec::new(); + let send_blinding = |ps: &mut ProverState<_, _>, z: F| { + for m in &masking_coeffs_all { + ps.prover_message(&univariate_evaluate(m, z)); + } + for g in &g_i_coeffs { + ps.prover_message(&univariate_evaluate(g, z)); + } + }; + + let f_hat_combined = &f_hat_polys[0]; + let mu = dims.mu; + for &z in &folded_commit.out_of_domain().points { + prover_state.prover_message(&multilinear_extend( + f_hat_combined, + &build_fold_args(r_bar, z, mu), + )); + send_blinding(&mut prover_state, z); + lambda_z_points.push(z); + } + drop(f_hat_polys); + for &z in &in_domain.points { + send_blinding(&mut prover_state, z); + lambda_z_points.push(z); + } + { + let stir_challenges: Vec> = folded_commit + .out_of_domain() + .evaluators(round_config.initial_size()) + .chain(in_domain.evaluators(round_config.initial_size())) + .collect(); + let ood_evals = folded_commit.out_of_domain().values(&[F::ONE]); + let num_ood = folded_commit.out_of_domain().points.len(); + let embedding = Identity::new(); + let stir_evals: Vec = ood_evals + .chain( + stir_challenges[num_ood..] + .iter() + .map(|ch| ch.evaluate(&embedding, &f_zk)), + ) + .collect(); + let stir_rlc: Vec = geometric_challenge(&mut prover_state, stir_challenges.len()); + UnivariateEvaluation::accumulate_many(&stir_challenges, &mut covector, &stir_rlc); + the_sum += dot(&stir_rlc, &stir_evals); + } + let round0_folding = + round_config + .sumcheck + .prove(&mut prover_state, &mut f_zk, &mut covector, &mut the_sum); + let remaining = whir::rounds::prove_remaining_rounds( + &config.blinded_polynomial.round_configs, + &whir::rounds::FinalRoundConfig { + sumcheck: &config.blinded_polynomial.final_sumcheck, + pow: &config.blinded_polynomial.final_pow, + }, + &mut prover_state, + &mut whir::rounds::SumcheckState { + vector: &mut f_zk, + covector: &mut covector, + the_sum: &mut the_sum, + }, + folded_commit, + &round0_folding, + ); + let gamma_points = remaining.first_in_domain_points; + let _ = config.blinded_polynomial.initial_committer.open_at_indices( + &mut prover_state, + &[&f_hat_witness], + &gamma_to_f_hat_indices(&gamma_points, &config), + ); + for &gamma in &gamma_points { + send_blinding(&mut prover_state, gamma); + lambda_z_points.push(gamma); + } + drop(f_zk); + drop(covector); + + // Step 7: blinding proof (honest) + let tau: F = prover_state.verifier_message(); + let beq_tables = build_beq_tables(&lambda_z_points, &eq_weights_vec, tau, dims); + let weight_covectors = build_weight_covectors(&beq_tables, rho, &alpha_coeffs, dims); + let mut eval_matrix = Vec::with_capacity(dims.num_blinding_vecs * dims.num_blinding_vecs); + for w in &weight_covectors { + for v in &secrets.blinding_vectors { + eval_matrix.push(dot(w, v)); + } + } + for e in &eval_matrix { + prover_state.prover_message(e); + } + let blinding_forms: Vec>> = weight_covectors + .into_iter() + .map(|cv| Box::new(Covector::new(cv)) as _) + .collect(); + let blinding_cows: Vec> = secrets + .blinding_vectors + .iter() + .map(|v| Cow::Borrowed(v.as_slice())) + .collect(); + let _ = config.blinding_polynomial.prove( + &mut prover_state, + blinding_cows, + vec![Cow::Borrowed(&blinding_poly_witness)], + blinding_forms, + Cow::Owned(eval_matrix), + ); + + // Verify with forged e' = e − Δ/ρ. + let proof = prover_state.proof(); + let forged_eval = honest_eval - delta / rho; + assert_ne!(forged_eval, honest_eval); + + let attack = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut vs = VerifierState::new_std(&ds, &proof); + let commitments = config.receive_commitments(&mut vs).unwrap(); + config + .verify(&mut vs, &weight_refs, &[forged_eval], &commitments) + .and_then(|fc| fc.verify(weight_refs.iter().copied())) + })); + assert!( + !matches!(attack, Ok(Ok(()))), + "REGRESSION issue #2: g_claim forgery (G'=G+Δ, e'=e−Δ/ρ) must be rejected" + ); + } + + /// Issue #3 (n=1, f=2): exact c₁-cancelling forgery. + /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]`. + #[test] + fn test_zkwhir_issue3_constraint_rlc_cancelling() { + let config = make_test_config(); + let mut rng = ark_std::test_rng(); + + let vector: Vec = (0..TEST_NUM_COEFFS) + .map(|i| F::from(i as u64 + 1)) + .collect(); + let p0 = MultilinearPoint::rand(&mut rng, TEST_NUM_VARIABLES); + let p1 = MultilinearPoint::rand(&mut rng, TEST_NUM_VARIABLES); + let f0 = MultilinearExtension { point: p0.0 }; + let f1 = MultilinearExtension { point: p1.0 }; + let embedding = config.embedding(); + let evals = vec![ + f0.evaluate(embedding, &vector), + f1.evaluate(embedding, &vector), + ]; + let forms: Vec>> = vec![Box::new(f0), Box::new(f1)]; + + let (ds, proof) = honest_proof_and_verify(&config, &[&vector], &forms, &evals); + + // Replay the verifier transcript to extract constraint RLC coefficient c₁. + // + // zkWHIR transcript after receive_commitments (n=1, f=2): + // 1. V → P: β (verifier challenge) + // 2. P → V: G₀, G₁ (2 g_claims for 2 forms) + // 3. P → V: eval₀, eval₁ (2 evals — the fix) + // 4. V → P: α via geometric_challenge(1) → [1] (no transcript squeeze for n=1) + // 5. V → P: ρ (verifier challenge) + // 6. V → P: c via geometric_challenge(2) → [1, c₁] + let c1 = { + let mut vs = VerifierState::new_std(&ds, &proof); + let _ = config.receive_commitments(&mut vs).unwrap(); + let _beta: F = vs.verifier_message(); // step 1 + for _ in 0..2 { + let _: F = vs.prover_message().unwrap(); + } // step 2: g_claims + for _ in 0..2 { + let _: F = vs.prover_message().unwrap(); + } // step 3: evals + // step 4: geometric_challenge(1) returns [ONE] without squeezing, + // but must be called to keep the transcript replay in sync. + let _alpha: Vec = geometric_challenge(&mut vs, 1); + let _rho: F = vs.verifier_message(); // step 5 + geometric_challenge::<_, F>(&mut vs, 2)[1] // step 6: c₁ + }; + + let delta = F::from(99u64); + let mut forged = evals.clone(); + forged[0] += delta; + forged[1] -= delta / c1; + assert_eq!(evals[0] + c1 * evals[1], forged[0] + c1 * forged[1]); + + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &forged), + "REGRESSION issue #3: constraint-RLC-cancelling forgery must be rejected" + ); + } + + /// Issues #1+#2+#3 combined (n=2, f=2): 4 evaluations. + /// Tests single-entry, cross-vector, and cross-form forgeries. + #[test] + fn test_zkwhir_combined_n2_f2() { + let config = make_test_config_batch(2); + let mut rng = ark_std::test_rng(); + + let v0: Vec = (0..TEST_NUM_COEFFS) + .map(|i| F::from(i as u64 + 1)) + .collect(); + let v1: Vec = (0..TEST_NUM_COEFFS) + .map(|i| F::from(i as u64 * 3 + 7)) + .collect(); + let p0 = MultilinearPoint::rand(&mut rng, TEST_NUM_VARIABLES); + let p1 = MultilinearPoint::rand(&mut rng, TEST_NUM_VARIABLES); + let f0 = MultilinearExtension { point: p0.0 }; + let f1 = MultilinearExtension { point: p1.0 }; + let embedding = config.embedding(); + let evals = vec![ + f0.evaluate(embedding, &v0), + f0.evaluate(embedding, &v1), + f1.evaluate(embedding, &v0), + f1.evaluate(embedding, &v1), + ]; + let forms: Vec>> = vec![Box::new(f0), Box::new(f1)]; + + let (ds, proof) = honest_proof_and_verify(&config, &[&v0, &v1], &forms, &evals); + + let mut fa = evals.clone(); + fa[0] += F::from(1u64); + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &fa), + "single-entry forgery must be rejected" + ); + + let mut fb = evals.clone(); + fb[0] += F::from(99u64); + fb[1] -= F::from(99u64); + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &fb), + "cross-vector forgery must be rejected" + ); + + let mut fc = evals.clone(); + fc[0] += F::from(55u64); + fc[2] -= F::from(55u64); + assert!( + !verifier_accepts(&config, &ds, &proof, &forms, &fc), + "cross-form forgery must be rejected" + ); + } } diff --git a/src/protocols/whir_zk/utils.rs b/src/protocols/whir_zk/utils.rs index d5da0201..90ed6493 100644 --- a/src/protocols/whir_zk/utils.rs +++ b/src/protocols/whir_zk/utils.rs @@ -8,19 +8,19 @@ use crate::algebra::{geometric_accumulate, geometric_sequence}; /// Computed once from the config and reused across prover/verifier steps /// to avoid recomputing (and passing individually) the same derived values. #[derive(Clone, Copy, Debug)] -pub struct ProtocolDims { - pub(crate) mu: usize, - pub(crate) ell: usize, - pub(crate) rem: usize, +pub(super) struct ProtocolDims { + pub(super) mu: usize, + pub(super) ell: usize, + pub(super) rem: usize, /// ν blinding polynomials excluding g₀; total g-polynomials = ν + 1 = `num_g_polys()`. - pub(crate) nu: usize, - pub(crate) size: usize, - pub(crate) num_vectors: usize, - pub(crate) num_blinding_vecs: usize, + pub(super) nu: usize, + pub(super) size: usize, + pub(super) num_vectors: usize, + pub(super) num_blinding_vecs: usize, } impl ProtocolDims { - pub(crate) fn new(config: &Config, num_vectors: usize) -> Self { + pub(super) fn new(config: &Config, num_vectors: usize) -> Self { let mu = config.blinded_polynomial.initial_num_variables(); let ell = config .blinding_polynomial @@ -46,12 +46,12 @@ impl ProtocolDims { } /// Number of blinding g-polynomials: ν + 1. - pub(crate) const fn num_g_polys(&self) -> usize { + pub(super) const fn num_g_polys(&self) -> usize { self.nu + 1 } /// Convenience wrapper for [`phi_i_bits`] using this instance's dimensions. - pub(crate) const fn phi_i_bits(&self, hypercube_idx: usize, phi_index: usize) -> usize { + pub(super) const fn phi_i_bits(&self, hypercube_idx: usize, phi_index: usize) -> usize { phi_i_bits(hypercube_idx, phi_index, self.mu, self.ell, self.rem) } } @@ -97,7 +97,12 @@ const fn phi_i_bits( /// Panics if `target` is not in `⟨gen⟩`. /// /// Complexity: O(log_order²) field multiplications — vs O(2^log_order) for linear scan. -pub fn discrete_log_pow2(target: F, gen: F, gen_inv: F, log_order: u32) -> usize { +pub(super) fn discrete_log_pow2( + target: F, + gen: F, + gen_inv: F, + log_order: u32, +) -> usize { debug_assert_eq!(gen * gen_inv, F::ONE, "gen_inv must be the inverse of gen"); let mut result = 0usize; let mut current = target; @@ -135,7 +140,7 @@ pub fn discrete_log_pow2(target: F, gen: F, gen_inv: F, log_order: /// /// The z-derived coordinates use descending powers (big-endian convention) /// to match the codebase's `UnivariateEvaluation::mle_evaluate` squaring ladder. -pub fn build_fold_args(r_bar: &[F], z: F, mu: usize) -> Vec { +pub(super) fn build_fold_args(r_bar: &[F], z: F, mu: usize) -> Vec { let num_folded_vars = r_bar.len(); let num_z_vars = mu - num_folded_vars; let mut point = Vec::with_capacity(mu); @@ -160,7 +165,7 @@ pub fn build_fold_args(r_bar: &[F], z: F, mu: usize) -> Vec { /// /// `beq_i[k] = Σ_j τ^{j+1} · Σ_{c,m} eq(r̄, c) · z_j^m · δ(Φ_i(c·M+m), k)` #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, fields(num_points = lambda_z_points.len(), mu = dims.mu, ell = dims.ell, num_g_polys = dims.num_g_polys())))] -pub fn build_beq_tables( +pub(super) fn build_beq_tables( lambda_z_points: &[F], eq_weights: &[F], tau: F, @@ -278,9 +283,9 @@ pub fn build_beq_tables( /// Produced by [`compute_rs_fold_blinding_coeffs`]; consumed when evaluating /// m̃(r̄, z, ρ) and g̃ᵢ(r̄, z) at OOD/STIR/Γ points. #[derive(Debug)] -pub struct RsFoldCoeffs { - pub(crate) masking_coeffs_all: Vec>, - pub(crate) g_i_coeffs: Vec>, +pub(super) struct RsFoldCoeffs { + pub(super) masking_coeffs_all: Vec>, + pub(super) g_i_coeffs: Vec>, } /// Precompute RS-fold coefficient vectors for the blinding polynomials (Steps 5-6). @@ -305,7 +310,7 @@ pub struct RsFoldCoeffs { /// /// Returns [`RsFoldCoeffs`] where each inner vector has length M. #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, fields(mu = dims.mu, ell = dims.ell, num_g_polys = g_polys.len())))] -pub fn compute_rs_fold_blinding_coeffs( +pub(super) fn compute_rs_fold_blinding_coeffs( eq_weights: &[F], g_polys: &[Vec], masking_polys: &[Vec], @@ -388,7 +393,7 @@ pub fn compute_rs_fold_blinding_coeffs( /// - `w_0`: `beq_0[k]` for g₀, `(-ρ)·beq_0[k]` for msk₀ /// - `w_i`: `(-ρ·αⁱ)·beq_0[k]` for mskᵢ (1 ≤ i < num_vectors) /// - `w_{n+j}`: `beq_{j+1}[k]` for ĝ_{j+1} (0 ≤ j < ν) -pub fn build_weight_covectors( +pub(super) fn build_weight_covectors( beq_tables: &[Vec], rho: F, alpha_coeffs: &[F], @@ -441,7 +446,7 @@ pub fn build_weight_covectors( /// recovers the position in the initial codeword. /// /// Used identically by both prover (to open [[f̂]]) and verifier (to verify openings). -pub fn gamma_to_f_hat_indices( +pub(super) fn gamma_to_f_hat_indices( gamma_points: &[F], config: &super::Config, ) -> Vec { @@ -467,7 +472,7 @@ pub fn gamma_to_f_hat_indices( } /// Compute eq_weights from r_bar. Shared helper to avoid redundant computation. -pub fn compute_eq_weights(r_bar: &[F]) -> Vec { +pub(super) fn compute_eq_weights(r_bar: &[F]) -> Vec { let len = 1usize << r_bar.len(); let mut buf = vec![F::ONE; len]; for (i, &r) in r_bar.iter().enumerate() { @@ -484,14 +489,14 @@ pub fn compute_eq_weights(r_bar: &[F]) -> Vec { /// /// Collects (z, m_evals, g_evals) tuples during Steps 5-6 for use in Step 7. #[derive(Debug)] -pub struct LambdaAccumulator { +pub(super) struct LambdaAccumulator { z_points: Vec, m_evals: Vec>, g_evals: Vec>, } impl LambdaAccumulator { - pub(crate) const fn new() -> Self { + pub(super) const fn new() -> Self { Self { z_points: Vec::new(), m_evals: Vec::new(), @@ -499,11 +504,11 @@ impl LambdaAccumulator { } } - pub(crate) fn z_points(&self) -> &[F] { + pub(super) fn z_points(&self) -> &[F] { &self.z_points } - pub(crate) fn push(&mut self, z: F, m: Vec, g: Vec) { + pub(super) fn push(&mut self, z: F, m: Vec, g: Vec) { assert!( self.m_evals.is_empty() || m.len() == self.m_evals[0].len(), "m_evals length mismatch: expected {}, got {}", @@ -522,7 +527,7 @@ impl LambdaAccumulator { } #[must_use] - pub(crate) const fn len(&self) -> usize { + pub(super) const fn len(&self) -> usize { self.z_points.len() } @@ -530,7 +535,7 @@ impl LambdaAccumulator { /// /// Vectors `0..num_vectors` index into `m_evals`; vectors `num_vectors..` index /// into `g_evals` (shifted by `num_vectors`). - pub(crate) fn claim(&self, lambda_idx: usize, vec_idx: usize, num_vectors: usize) -> F + pub(super) fn claim(&self, lambda_idx: usize, vec_idx: usize, num_vectors: usize) -> F where F: Copy, { From 9b38051eeb817bb387cb4c07a2a08d9a5377b381 Mon Sep 17 00:00:00 2001 From: ocdbytes Date: Thu, 23 Apr 2026 18:37:40 +0530 Subject: [PATCH 3/4] refactor : tests --- src/protocols/whir/mod.rs | 29 +++++++------- src/protocols/whir_zk/mod.rs | 74 ++++++++++++++++++------------------ 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/src/protocols/whir/mod.rs b/src/protocols/whir/mod.rs index bfd856c1..4ca910bf 100644 --- a/src/protocols/whir/mod.rs +++ b/src/protocols/whir/mod.rs @@ -147,6 +147,7 @@ mod tests { }, hash, parameters::ProtocolParameters, + protocols::geometric_challenge::geometric_challenge, transcript::{codecs::Empty, DomainSeparator, ProverState, VerifierState}, utils::test_serde, }; @@ -920,7 +921,7 @@ mod tests { } // ===================================================================== - // Soundness regression — evaluation forgery (issues #1, #3) + // Soundness regression — evaluation forgery // // Root cause: evaluations were not bound in the Fiat-Shamir transcript // before α / constraint_rlc were sampled. The fix absorbs all evals @@ -928,12 +929,10 @@ mod tests { // `verify!(read == expected)`. // ===================================================================== - use crate::protocols::geometric_challenge::geometric_challenge; - /// Number of variables for the soundness regression tests. /// Kept small (4) so the tests run fast while still exercising /// all transcript-level challenge extraction paths. - const SOUNDNESS_NUM_VARIABLES: usize = 4; + const SOUNDNESS_NUM_VARIABLES: usize = 8; const SOUNDNESS_NUM_COEFFS: usize = 1 << SOUNDNESS_NUM_VARIABLES; /// Build a WHIR config for soundness tests with PoW disabled. @@ -1066,9 +1065,9 @@ mod tests { geometric_challenge::<_, EF>(&mut vs, num_ood + num_forms)[1] } - /// Issue #1, separate commitments (batch_size=1, n=2, f=1). + /// Forging a single evaluation with separate commitments (n=2, f=1) is rejected. #[test] - fn test_whir_issue1_separate_commits() { + fn test_rejects_forged_eval_separate_commits() { let config = soundness_config(1); let mut rng = ark_std::test_rng(); @@ -1106,9 +1105,9 @@ mod tests { ); } - /// Issue #1, batched commitment (batch_size=2, n=2, f=1). + /// Forging a single evaluation with batched commitment (batch_size=2, n=2, f=1) is rejected. #[test] - fn test_whir_issue1_batched_commit() { + fn test_rejects_forged_eval_batched_commit() { let config = soundness_config(2); let mut rng = ark_std::test_rng(); @@ -1146,10 +1145,11 @@ mod tests { ); } - /// Issue #1, exact α-cancelling forgery (n=2, f=1). - /// Replays verifier transcript to extract α, constructs `[+Δ, −Δ/α]`. + /// α-cancelling forgery across batched vectors (n=2, f=1) is rejected. + /// Extracts α from transcript, constructs `[+Δ, −Δ/α]` preserving the + /// batched sum — verifier rejects because evals are individually bound. #[test] - fn test_whir_issue1_alpha_cancelling() { + fn test_rejects_alpha_cancelling_forgery() { let config = soundness_config(1); let mut rng = ark_std::test_rng(); @@ -1192,10 +1192,11 @@ mod tests { ); } - /// Issue #3, exact c₁-cancelling forgery (n=1, f=2). - /// Replays verifier transcript to extract c₁, constructs `[+Δ, −Δ/c₁]`. + /// Constraint-RLC-cancelling forgery across forms (n=1, f=2) is rejected. + /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]` preserving the + /// weighted sum — verifier rejects because evals are individually bound. #[test] - fn test_whir_issue3_constraint_rlc_cancelling() { + fn test_rejects_constraint_rlc_cancelling_forgery() { let config = soundness_config(1); let mut rng = ark_std::test_rng(); diff --git a/src/protocols/whir_zk/mod.rs b/src/protocols/whir_zk/mod.rs index 7acc4c5c..f99a2512 100644 --- a/src/protocols/whir_zk/mod.rs +++ b/src/protocols/whir_zk/mod.rs @@ -282,16 +282,28 @@ mod tests { use ark_ff::{AdditiveGroup, Field}; - use super::Config; + use super::{ + committer::Witness, + utils::{ + build_beq_tables, build_fold_args, build_weight_covectors, compute_eq_weights, + compute_rs_fold_blinding_coeffs, gamma_to_f_hat_indices, ProtocolDims, RsFoldCoeffs, + }, + Config, + }; use crate::{ algebra::{ + dot, + embedding::Identity, fields::Field64, - linear_form::{Covector, Evaluate, LinearForm, MultilinearExtension}, - MultilinearPoint, + geometric_sequence, + linear_form::{ + Covector, Evaluate, LinearForm, MultilinearExtension, UnivariateEvaluation, + }, + multilinear_extend, univariate_evaluate, MultilinearPoint, }, hash, parameters::ProtocolParameters, - protocols::geometric_challenge::geometric_challenge, + protocols::{geometric_challenge::geometric_challenge, whir}, transcript::{ codecs::Empty, DomainSeparator, Proof, ProverState, VerifierMessage, VerifierState, }, @@ -733,21 +745,6 @@ mod tests { // `verify!(read == expected)`. // ===================================================================== - use super::{ - committer::Witness, - utils::{ - build_beq_tables, build_fold_args, build_weight_covectors, compute_eq_weights, - compute_rs_fold_blinding_coeffs, gamma_to_f_hat_indices, ProtocolDims, RsFoldCoeffs, - }, - }; - use crate::{ - algebra::{ - dot, embedding::Identity, geometric_sequence, linear_form::UnivariateEvaluation, - multilinear_extend, univariate_evaluate, - }, - protocols::whir, - }; - /// Generate an honest zkWHIR proof and sanity-check that it verifies. /// Returns the domain separator and proof for use in forgery tests. fn honest_proof_and_verify( @@ -812,10 +809,11 @@ mod tests { matches!(result, Ok(Ok(()))) } - /// Issue #1 (n=2, f=1): exact α-cancelling forgery. - /// Extracts α from transcript, constructs `[+Δ, −Δ/α]`. + /// α-cancelling forgery across batched vectors (n=2, f=1) is rejected. + /// Extracts α from transcript, constructs `[+Δ, −Δ/α]` preserving the + /// batched sum — verifier rejects because evals are individually bound. #[test] - fn test_zkwhir_issue1_alpha_cancelling() { + fn test_rejects_alpha_cancelling_forgery() { let config = make_test_config_batch(2); let mut rng = ark_std::test_rng(); @@ -862,17 +860,19 @@ mod tests { ); } - /// Issue #2 (n=1, f=1): full manual transcript replay with forged g_claim. + /// G-claim forgery compensated via ρ (n=1, f=1) is rejected. + /// + /// Full manual transcript replay with a malicious prover that: + /// 1. Commits honestly. + /// 2. Sends forged G' = G + Δ. + /// 3. Absorbs honest eval (must commit before ρ is sampled). + /// 4. After ρ is sampled, constructs e' = e − Δ/ρ to preserve ρ·e + G. + /// 5. Completes the rest of the proof honestly. /// - /// 1. Commit honestly. - /// 2. Send G' = G + Δ. - /// 3. Absorb honest eval (must commit before ρ). - /// 4. ρ is squeezed → construct e' = e − Δ/ρ. - /// 5. Complete proof honestly. - /// 6. Verifier reads honest e from transcript, compares to e' → rejected. + /// Verifier reads the honest eval from the transcript and rejects e'. #[test] #[allow(clippy::too_many_lines)] - fn test_zkwhir_issue2_g_claim_forgery() { + fn test_rejects_g_claim_forgery_via_rho() { let mut rng = ark_std::test_rng(); let config = make_test_config(); @@ -1103,10 +1103,11 @@ mod tests { ); } - /// Issue #3 (n=1, f=2): exact c₁-cancelling forgery. - /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]`. + /// Constraint-RLC-cancelling forgery across forms (n=1, f=2) is rejected. + /// Extracts c₁ from transcript, constructs `[+Δ, −Δ/c₁]` preserving the + /// weighted sum — verifier rejects because evals are individually bound. #[test] - fn test_zkwhir_issue3_constraint_rlc_cancelling() { + fn test_rejects_constraint_rlc_cancelling_forgery() { let config = make_test_config(); let mut rng = ark_std::test_rng(); @@ -1164,10 +1165,11 @@ mod tests { ); } - /// Issues #1+#2+#3 combined (n=2, f=2): 4 evaluations. - /// Tests single-entry, cross-vector, and cross-form forgeries. + /// All forgery surfaces combined (n=2, f=2, 4 evaluations). + /// Tests single-entry, cross-vector, and cross-form forgeries + /// in one proof to exercise α, ρ, and constraint_rlc binding together. #[test] - fn test_zkwhir_combined_n2_f2() { + fn test_rejects_all_forgery_patterns_n2_f2() { let config = make_test_config_batch(2); let mut rng = ark_std::test_rng(); From af73bc05a481490d92fcf8dba1806f6186e4910f Mon Sep 17 00:00:00 2001 From: ocdbytes Date: Thu, 23 Apr 2026 18:56:39 +0530 Subject: [PATCH 4/4] refactor : tests --- src/protocols/whir/mod.rs | 9 --------- src/protocols/whir_zk/mod.rs | 9 --------- 2 files changed, 18 deletions(-) diff --git a/src/protocols/whir/mod.rs b/src/protocols/whir/mod.rs index 4ca910bf..6341ff03 100644 --- a/src/protocols/whir/mod.rs +++ b/src/protocols/whir/mod.rs @@ -920,15 +920,6 @@ mod tests { } } - // ===================================================================== - // Soundness regression — evaluation forgery - // - // Root cause: evaluations were not bound in the Fiat-Shamir transcript - // before α / constraint_rlc were sampled. The fix absorbs all evals - // as prover messages; the verifier reads them back and checks - // `verify!(read == expected)`. - // ===================================================================== - /// Number of variables for the soundness regression tests. /// Kept small (4) so the tests run fast while still exercising /// all transcript-level challenge extraction paths. diff --git a/src/protocols/whir_zk/mod.rs b/src/protocols/whir_zk/mod.rs index f99a2512..e1d18c4e 100644 --- a/src/protocols/whir_zk/mod.rs +++ b/src/protocols/whir_zk/mod.rs @@ -736,15 +736,6 @@ mod tests { prove_and_verify(&config, vec![vector], vec![Box::new(form)], &[evaluation]); } - // ===================================================================== - // Soundness regression — evaluation forgery (issues #1, #2, #3) - // - // Root cause: evaluations were not bound in the Fiat-Shamir transcript - // before α, ρ, or constraint_rlc were sampled. The fix absorbs all - // evals as prover messages; the verifier reads them back and checks - // `verify!(read == expected)`. - // ===================================================================== - /// Generate an honest zkWHIR proof and sanity-check that it verifies. /// Returns the domain separator and proof for use in forgery tests. fn honest_proof_and_verify(