diff --git a/crypto/stark/src/lookup.rs b/crypto/stark/src/lookup.rs index cdc68e7e0..745736d4d 100644 --- a/crypto/stark/src/lookup.rs +++ b/crypto/stark/src/lookup.rs @@ -998,7 +998,13 @@ where .map(|c| c.degree()) .max() .unwrap_or(1); - trace_length * max_degree + // The composition polynomial is the constraint QUOTIENT H = Σ βᵢ·Cᵢ/Zᵢ. Its degree is + // deg(Cᵢ) − deg(Zᵢ) = (max_degree−1)·N − max_degree + eᵢ, so with the end-exemptions + // eᵢ < max_degree (the max-degree LogUp constraints have eᵢ = 0) it fits in + // (max_degree−1) parts — the max_degree-th part is identically zero. The tight bound is + // therefore (max_degree−1)·N; the previous max_degree·N committed and opened a wasted + // all-zero part (e.g. 3 parts for a degree-3 AIR where 2 suffice). + trace_length * (max_degree - 1).max(1) } fn context(&self) -> &AirContext { diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 46261103e..4da57559c 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -918,7 +918,8 @@ pub trait IsStarkProver< // Compute entirely in base field — mixed F×E multiplication when used with extension values. let two_base = FieldElement::::from(2u64); let mut inv_2x: Vec> = (0..n) - .map(|i| &two_base * &domain.lde_roots_of_unity_coset[i]) + // 2·(g·ωⁱ) = (g·ωⁱ).double() — one add, vs a base mul+reduce per element. + .map(|i| domain.lde_roots_of_unity_coset[i].double()) .collect(); FieldElement::inplace_batch_inverse(&mut inv_2x).expect("Coset points are non-zero"); diff --git a/crypto/stark/src/tests/bus_tests/soundness_tests.rs b/crypto/stark/src/tests/bus_tests/soundness_tests.rs index fc718bf7c..eb26276b8 100644 --- a/crypto/stark/src/tests/bus_tests/soundness_tests.rs +++ b/crypto/stark/src/tests/bus_tests/soundness_tests.rs @@ -93,6 +93,61 @@ fn test_wrong_result_value() { )); } +/// The composition-poly part count is fixed by the AIR's max constraint degree, +/// not chosen by the prover. A proof advertising a different number of parts must +/// be rejected — otherwise a malicious prover could inflate the parts to widen the +/// composition polynomial's degree space and weaken the low-degree test. +#[test_log::test] +fn test_rejects_inflated_composition_part_count() { + // All-padding traces: a valid, bus-balanced (Σ = 0) proof — the simplest valid case. + let mut cpu_trace = TraceTable::from_columns_main(vec![vec![FE::zero(); 4]; 5], 1); + let mut add_trace = TraceTable::from_columns_main(vec![vec![FE::zero(); 4]; 4], 1); + let mut mul_trace = TraceTable::from_columns_main(vec![vec![FE::zero(); 4]; 4], 1); + + let proof_options = ProofOptions::default_test_options(); + let cpu_air = new_cpu_air_with_lookup(&proof_options); + let add_air = new_add_air_with_lookup(&proof_options); + let mul_air = new_mul_air_with_lookup(&proof_options); + + let air_trace_pairs: Vec<( + &dyn AIR, + _, + _, + )> = vec![ + (&cpu_air, &mut cpu_trace, &()), + (&add_air, &mut add_trace, &()), + (&mul_air, &mut mul_trace, &()), + ]; + let mut multi_proof = + multi_prove_ram(air_trace_pairs, &mut DefaultTranscript::::new(&[])).unwrap(); + + let airs: Vec<&dyn AIR> = + vec![&cpu_air, &add_air, &mul_air]; + + // The untampered proof verifies. + assert!(Verifier::multi_verify( + &airs, + &multi_proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + )); + + // Tamper: inflate the first table's composition-poly part count. + multi_proof.proofs[0] + .composition_poly_parts_ood_evaluation + .push(FieldElement::::zero()); + + assert!( + !Verifier::multi_verify( + &airs, + &multi_proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + ), + "verifier must reject a composition part count that disagrees with the AIR degree bound" + ); +} + /// Off-by-one error: CPU sends (5, 3, 8) but ADD claims (5, 3, 9). #[test_log::test] fn test_off_by_one() { diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 8091c8b32..68819c76b 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -742,6 +742,17 @@ pub trait IsStarkVerifier< // trust the prover). For normal tables, use the commitment from the proof. for (idx, (air, proof)) in airs.iter().zip(&multi_proof.proofs).enumerate() { + // Soundness: the number of composition-poly parts is fixed by the AIR's + // degree bound, NOT chosen by the prover. Deriving it from the proof would + // let a malicious prover inflate the part count, widening the composition + // polynomial's degree space and weakening the low-degree test. Reject any + // proof whose advertised part count disagrees with the AIR. + if proof.trace_length == 0 + || proof.composition_poly_parts_ood_evaluation.len() + != air.composition_poly_degree_bound(proof.trace_length) / proof.trace_length + { + return false; + } if air.is_preprocessed() { // Preprocessed table: VERIFY precomputed commitment matches hardcoded. // This is the critical soundness check - ensures prover used correct precomputed values.