diff --git a/crates/halo2_proofs/src/plonk/prover.rs b/crates/halo2_proofs/src/plonk/prover.rs index 6c495a10..0aec05a4 100644 --- a/crates/halo2_proofs/src/plonk/prover.rs +++ b/crates/halo2_proofs/src/plonk/prover.rs @@ -41,6 +41,217 @@ use crate::{ const NO_DENOMINATOR: u32 = u32::MAX; +// These routing thresholds were measured for the prepared no-orbits backend +// at the Ironwood Action circuit's k. For each later circuit, an exact count +// pass must show that no advice column gains nonzero coefficients, while the +// aggregate removes more than one eighth of its nonzero coefficients and at +// least 256 coefficients. Eight-row per-column prepared-work samples reject +// adverse recoding costs that they observe, and each direct sample must span +// every prepared main window. Those small samples do not bound unseen digit +// visits or residual-tail work, so this remains a performance heuristic after +// the exact density guard. Commitment blinds are excluded because both routes +// evaluate one independent blind term. +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +const ADVICE_DELTA_PREPARED_K: u32 = 11; +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +const ADVICE_DELTA_ROUTE_DENOMINATOR: usize = 8; +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +const ADVICE_DELTA_MIN_SAVINGS: usize = 256; +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +const ADVICE_DELTA_WORK_SAMPLES: usize = 8; +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +const ADVICE_DELTA_STRATIFIED_FRACTION: u32 = 0x9e37_79b9; + +#[cfg(all(test, feature = "multicore", not(feature = "orbits")))] +static ADVICE_DELTA_ROUTE_HITS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(all(test, feature = "multicore", not(feature = "orbits")))] +fn take_advice_delta_route_hits() -> usize { + ADVICE_DELTA_ROUTE_HITS.swap(0, std::sync::atomic::Ordering::Relaxed) +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +fn advice_delta_stratified_row( + polynomial_len: usize, + sample_rows: usize, + sample: usize, +) -> Option { + if polynomial_len == 0 || sample_rows == 0 || sample >= sample_rows { + return None; + } + + let polynomial_len = polynomial_len as u128; + let sample_rows = sample_rows as u128; + let sample = sample as u128; + let start = sample.checked_mul(polynomial_len)? / sample_rows; + let end = sample.checked_add(1)?.checked_mul(polynomial_len)? / sample_rows; + let width = end.checked_sub(start)?.max(1); + // A fractional Weyl sequence chooses a different deterministic offset in + // each stratum instead of repeatedly sampling the same relative row. + let sample = u32::try_from(sample).ok()?; + let fraction = sample + .wrapping_add(1) + .wrapping_mul(ADVICE_DELTA_STRATIFIED_FRACTION); + let offset = (fraction as u128).checked_mul(width)? >> u32::BITS; + let row = usize::try_from(start.checked_add(offset)?).ok()?; + (row < usize::try_from(polynomial_len).ok()?).then_some(row) +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +fn advice_delta_nonzero_counts(direct: &[F], reference: &[F]) -> Option<(usize, usize)> { + if direct.len() != reference.len() { + return None; + } + + direct.iter().zip(reference).try_fold( + (0_usize, 0_usize), + |(direct_count, delta_count), (direct, reference)| { + Some(( + direct_count.checked_add(usize::from(!direct.is_zero_vartime()))?, + delta_count.checked_add(usize::from(direct != reference))?, + )) + }, + ) +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +fn use_advice_delta_counts(counts: &[(usize, usize)]) -> Option { + let every_column_nonincreasing = counts.iter().all(|&(direct, delta)| delta <= direct); + if !every_column_nonincreasing { + return Some(false); + } + let direct = counts + .iter() + .try_fold(0_usize, |total, &(direct, _)| total.checked_add(direct))?; + let delta = counts + .iter() + .try_fold(0_usize, |total, &(_, delta)| total.checked_add(delta))?; + let saved = direct.checked_sub(delta)?; + Some( + every_column_nonincreasing + && saved >= ADVICE_DELTA_MIN_SAVINGS + && saved > direct / ADVICE_DELTA_ROUTE_DENOMINATOR, + ) +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +type AdvicePolynomialsAndBlinds = ( + Vec::ScalarExt, LagrangeCoeff>>, + Vec::ScalarExt>>, +); + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +type AdviceDeltaPlan = + Vec::ScalarExt, LagrangeCoeff>>>>; + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[inline(never)] +fn plan_advice_deltas( + params: &Params, + domain: &poly::EvaluationDomain, + advice_witnesses: &[AdvicePolynomialsAndBlinds], +) -> Option> { + if advice_witnesses.len() <= 1 || params.k() != ADVICE_DELTA_PREPARED_K { + return None; + } + + let (reference, reference_blinds) = advice_witnesses.first()?; + let polynomial_len = reference.first()?.len(); + if polynomial_len == 0 + || reference.len() != reference_blinds.len() + || reference.iter().any(|poly| poly.len() != polynomial_len) + || advice_witnesses[1..].iter().any(|(advice, blinds)| { + advice.len() != reference.len() + || advice.len() != blinds.len() + || advice.iter().any(|poly| poly.len() != polynomial_len) + }) + { + return None; + } + + // Avoid charging the count scan to unprepared params and worker pools + // above the prepared backend's measured cap. The prepared handle itself is + // only acquired after a count candidate survives. + if !params.prepared_lagrange_commitments_active(polynomial_len) { + return None; + } + + // Evaluate each later circuit independently. The exact count pass only + // reads coefficients and does not invoke the prepared evaluator. + let count_candidates = advice_witnesses[1..] + .par_iter() + .map(|(advice, _)| { + let counts = advice + .par_iter() + .zip(reference.par_iter()) + .map(|(direct, reference)| advice_delta_nonzero_counts(direct, reference)) + .collect::>>()?; + use_advice_delta_counts(&counts) + }) + .collect::>>()?; + if !count_candidates.iter().any(|&candidate| candidate) { + return None; + } + + // Only count-qualified circuits acquire the prepared handle and pay for + // the per-column work comparison. + let prepared = params.lagrange_table()?; + let work_rows = ADVICE_DELTA_WORK_SAMPLES.min(polynomial_len); + let decisions = advice_witnesses[1..] + .par_iter() + .zip(count_candidates.par_iter()) + .map(|((advice, _), &count_candidate)| { + if !count_candidate { + return Some(false); + } + + let mut direct_sample = Vec::with_capacity(work_rows); + let mut delta_sample = Vec::with_capacity(work_rows); + for (direct, reference) in advice.iter().zip(reference) { + direct_sample.clear(); + delta_sample.clear(); + for sample in 0..work_rows { + let row = advice_delta_stratified_row(polynomial_len, work_rows, sample)?; + direct_sample.push(direct[row]); + delta_sample.push(direct[row] - reference[row]); + } + if !prepared.scalar_work_is_at_most_vartime(&delta_sample, &direct_sample)? { + return Some(false); + } + } + Some(true) + }) + .collect::>>()?; + if !decisions.iter().any(|&route| route) { + return None; + } + + Some( + advice_witnesses[1..] + .par_iter() + .zip(decisions.par_iter()) + .map(|((advice, _), &route)| { + advice + .par_iter() + .zip(reference.par_iter()) + .map(|(direct, reference)| { + route.then(|| { + domain.lagrange_from_vec( + direct + .iter() + .zip(reference.iter()) + .map(|(&direct, &reference)| direct - reference) + .collect(), + ) + }) + }) + .collect() + }) + .collect(), + ) +} + #[cfg(all(test, feature = "batch"))] std::thread_local! { static PREPARED_INSTANCE_ROUTE_HITS: std::cell::Cell = @@ -689,6 +900,13 @@ impl<'a, F: Field> Assignment for WitnessCollection<'a, F> { /// The circuit type must be `Sync` and its configuration `Send` so that /// compatible floor planners can synthesize independent circuit witnesses in /// parallel. +/// +/// # Security +/// +/// Proof creation is variable-time in the private witnesses. In particular, +/// prepared batched commitments may expose sparsity and similarity between +/// circuit witnesses through timing. Do not expose proving latency across a +/// boundary where those relationships are sensitive. pub fn create_proof< C: CurveAffine, E: EncodedChallenge, @@ -885,50 +1103,189 @@ where let circuit_count = advice_witnesses.len(); crate::multicore::join( || { - advice_witnesses - .into_par_iter() - .map(|(advice, advice_blinds)| { - let (advice_commitments, (advice_polys, advice_cosets)) = - crate::multicore::join( - || { - #[cfg(feature = "multicore")] - let advice_commitments_projective: Vec<_> = advice - .par_iter() - .zip(advice_blinds.par_iter()) - .map(|(poly, blind)| params.commit_lagrange(poly, *blind)) - .collect(); - #[cfg(not(feature = "multicore"))] - let advice_commitments_projective: Vec<_> = advice - .iter() - .zip(advice_blinds.iter()) - .map(|(poly, blind)| params.commit_lagrange(poly, *blind)) - .collect(); - let mut advice_commitments = - vec![C::identity(); advice_commitments_projective.len()]; - C::Curve::batch_normalize( - &advice_commitments_projective, - &mut advice_commitments, - ); - advice_commitments + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + let delta_plan = plan_advice_deltas(params, domain, &advice_witnesses); + #[cfg(any(not(feature = "multicore"), feature = "orbits"))] + let delta_plan: Option< + Vec>>>, + > = None; + + #[cfg(all(test, feature = "multicore", not(feature = "orbits")))] + if let Some(plan) = &delta_plan { + ADVICE_DELTA_ROUTE_HITS.fetch_add( + plan.iter() + .flatten() + .filter(|delta| delta.is_some()) + .count(), + std::sync::atomic::Ordering::Relaxed, + ); + } + + // Preserve the original scheduling and normalization path unless + // the selected deltas collectively amortize its batch-wide work. + let Some(delta_plan) = delta_plan else { + return advice_witnesses + .into_par_iter() + .map(|(advice, advice_blinds)| { + let (advice_commitments, (advice_polys, advice_cosets)) = + crate::multicore::join( + || { + #[cfg(feature = "multicore")] + let advice_commitments_projective: Vec<_> = advice + .par_iter() + .zip(advice_blinds.par_iter()) + .map(|(poly, blind)| { + params.commit_lagrange(poly, *blind) + }) + .collect(); + #[cfg(not(feature = "multicore"))] + let advice_commitments_projective: Vec<_> = advice + .iter() + .zip(advice_blinds.iter()) + .map(|(poly, blind)| { + params.commit_lagrange(poly, *blind) + }) + .collect(); + let mut advice_commitments = vec![ + C::identity(); + advice_commitments_projective.len() + ]; + C::Curve::batch_normalize( + &advice_commitments_projective, + &mut advice_commitments, + ); + advice_commitments + }, + || { + domain.batch_lagrange_to_coeff_and_extended( + &advice, + &pk.fft_twiddles, + ) + }, + ); + + ( + advice_commitments, + AdviceSingle:: { + advice_values: advice, + advice_polys, + advice_cosets, + advice_blinds, }, - || { - domain.batch_lagrange_to_coeff_and_extended( - &advice, - &pk.fft_twiddles, - ) + ) + }) + .collect::>(); + }; + + let reference_blinds = &advice_witnesses[0].1; + // Keep every transform concurrent with the complete routed + // commitment path, including reconstruction and normalization. + let (advice_commitments, transforms) = crate::multicore::join( + || { + let candidates = (0..circuit_count) + .into_par_iter() + .map(|circuit| { + let (advice, advice_blinds) = &advice_witnesses[circuit]; + (0..advice.len()) + .into_par_iter() + .map(|column| { + let direct = &advice[column]; + if circuit == 0 { + return ( + params + .commit_lagrange(direct, advice_blinds[column]), + false, + ); + } + + let Some(delta) = delta_plan[circuit - 1][column].as_ref() + else { + return ( + params + .commit_lagrange(direct, advice_blinds[column]), + false, + ); + }; + + // Com(a, r) = Com(a_ref, r_ref) + // + Com(a - a_ref, r - r_ref). + ( + params.commit_lagrange( + delta, + Blind( + advice_blinds[column].0 + - reference_blinds[column].0, + ), + ), + true, + ) + }) + .collect::>() + }) + .collect::>(); + + let reference = candidates[0] + .iter() + .map(|(commitment, _)| *commitment) + .collect::>(); + candidates + .into_par_iter() + .map(|candidates| { + debug_assert_eq!(reference.len(), candidates.len()); + // Reconstruct in the original circuit and column + // order, so later transcript writes remain + // unchanged. + let projective = reference + .iter() + .zip(candidates) + .map(|(reference, (candidate, use_delta))| { + if use_delta { + *reference + candidate + } else { + candidate + } + }) + .collect::>(); + let mut commitments = vec![C::identity(); projective.len()]; + C::Curve::batch_normalize(&projective, &mut commitments); + commitments + }) + .collect::>() + }, + || { + #[cfg(feature = "multicore")] + let advice = advice_witnesses.par_iter(); + #[cfg(not(feature = "multicore"))] + let advice = advice_witnesses.iter(); + advice + .map(|(advice, _)| { + domain + .batch_lagrange_to_coeff_and_extended(advice, &pk.fft_twiddles) + }) + .collect::>() + }, + ); + + advice_witnesses + .into_iter() + .zip(advice_commitments) + .zip(transforms) + .map( + |( + ((advice, advice_blinds), advice_commitments), + (advice_polys, advice_cosets), + )| { + ( + advice_commitments, + AdviceSingle:: { + advice_values: advice, + advice_polys, + advice_cosets, + advice_blinds, }, - ); - - ( - advice_commitments, - AdviceSingle:: { - advice_values: advice, - advice_polys, - advice_cosets, - advice_blinds, - }, - ) - }) + ) + }, + ) .collect::>() }, || { @@ -2133,6 +2490,589 @@ fn test_create_proof() { .expect("same-shape proof verification should not fail"); } +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[test] +fn advice_delta_counts_require_global_savings() { + assert_eq!( + use_advice_delta_counts(&[(ADVICE_DELTA_MIN_SAVINGS - 1, 0)]), + Some(false), + ); + assert_eq!( + use_advice_delta_counts(&[(ADVICE_DELTA_MIN_SAVINGS, 0)]), + Some(true), + ); + + // The fractional threshold is strict. + let direct = ADVICE_DELTA_MIN_SAVINGS; + let delta = direct - direct / ADVICE_DELTA_ROUTE_DENOMINATOR; + let exactly_one_eighth = [(direct, delta); ADVICE_DELTA_ROUTE_DENOMINATOR]; + assert_eq!(use_advice_delta_counts(&exactly_one_eighth), Some(false),); + let mut more_than_one_eighth = exactly_one_eighth; + more_than_one_eighth[0].1 -= 1; + assert_eq!(use_advice_delta_counts(&more_than_one_eighth), Some(true),); + + // No individual column may gain nonzero coefficients, even when the + // aggregate would otherwise pass. + assert_eq!( + use_advice_delta_counts(&[ + (ADVICE_DELTA_MIN_SAVINGS, 0), + (ADVICE_DELTA_MIN_SAVINGS, 0), + (0, 1), + ]), + Some(false), + ); + + // Overflow declines the route instead of wrapping into a decision. + assert_eq!( + use_advice_delta_counts(&[(usize::MAX, usize::MAX), (usize::MAX, usize::MAX),]), + None, + ); +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[test] +fn advice_delta_work_samples_one_row_per_stratum() { + const POLYNOMIAL_LEN: usize = 1 << ADVICE_DELTA_PREPARED_K; + + for sample in 0..ADVICE_DELTA_WORK_SAMPLES { + let row = + advice_delta_stratified_row(POLYNOMIAL_LEN, ADVICE_DELTA_WORK_SAMPLES, sample).unwrap(); + let start = sample * POLYNOMIAL_LEN / ADVICE_DELTA_WORK_SAMPLES; + let end = (sample + 1) * POLYNOMIAL_LEN / ADVICE_DELTA_WORK_SAMPLES; + assert!((start..end).contains(&row)); + } + + assert_eq!(advice_delta_stratified_row(0, 1, 0), None); + assert_eq!(advice_delta_stratified_row(1, 0, 0), None); + assert_eq!(advice_delta_stratified_row(1, 1, 1), None); +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[test] +fn advice_delta_counts_zeroes_and_equalities() { + use pasta_curves::Fp; + + let reference = [Fp::ZERO, Fp::ONE, Fp::from(2), Fp::ZERO]; + let direct = [Fp::ZERO, Fp::ONE, Fp::ZERO, Fp::from(3)]; + assert_eq!( + advice_delta_nonzero_counts(&direct, &reference), + Some((2, 2)), + ); + assert_eq!(advice_delta_nonzero_counts(&direct, &reference[..3]), None); +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[test] +fn malformed_advice_delta_shapes_fall_back() { + use pasta_curves::{EqAffine, Fp}; + + let params = Params::::new(ADVICE_DELTA_PREPARED_K); + let domain = poly::EvaluationDomain::::new(3, ADVICE_DELTA_PREPARED_K); + let smaller_domain = poly::EvaluationDomain::::new(3, ADVICE_DELTA_PREPARED_K - 1); + + let empty_columns = vec![(Vec::new(), Vec::new()), (Vec::new(), Vec::new())]; + assert!(plan_advice_deltas(¶ms, &domain, &empty_columns).is_none()); + + let mismatched_columns = vec![ + (vec![domain.empty_lagrange()], vec![Blind::::default()]), + ( + vec![domain.empty_lagrange(), domain.empty_lagrange()], + vec![Blind::::default(), Blind::::default()], + ), + ]; + assert!(plan_advice_deltas(¶ms, &domain, &mismatched_columns).is_none()); + + let mismatched_blinds = vec![ + (vec![domain.empty_lagrange()], vec![Blind::::default()]), + (vec![domain.empty_lagrange()], Vec::new()), + ]; + assert!(plan_advice_deltas(¶ms, &domain, &mismatched_blinds).is_none()); + + let mismatched_lengths = vec![ + (vec![domain.empty_lagrange()], vec![Blind::::default()]), + ( + vec![smaller_domain.empty_lagrange()], + vec![Blind::::default()], + ), + ]; + assert!(plan_advice_deltas(¶ms, &domain, &mismatched_lengths).is_none()); +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[test] +fn advice_delta_commitments_preserve_proofs() { + use crate::{ + circuit::{Layouter, SimpleFloorPlanner}, + plonk::{SingleVerifier, keygen_pk, keygen_vk, verify_proof}, + transcript::{Blake2bRead, Blake2bWrite, Challenge255}, + }; + use ff::{FromUniformBytes, PrimeField}; + use pasta_curves::{EqAffine, Fp}; + use rand::{SeedableRng, rngs::StdRng}; + + const ASSIGNED_ROWS: usize = 1100; + const ADVICE_COLUMNS: usize = 10; + const MAGNITUDE_SHARED_ROWS: usize = 700; + const PROOF_SEED: u64 = 0x4144_5649_4345_444c; + + #[derive(Clone, Copy)] + enum AdviceDeltaProfile { + Similar, + MagnitudeInversion, + HighWindowSparse, + MissedHighWindow, + } + + #[derive(Clone, Copy)] + struct AdviceDeltaCircuit { + circuit_index: usize, + shared_columns: usize, + profile: AdviceDeltaProfile, + } + + impl AdviceDeltaCircuit { + fn is_work_sample(row: usize) -> bool { + static SAMPLE_ROWS: std::sync::OnceLock> = std::sync::OnceLock::new(); + + SAMPLE_ROWS.get_or_init(|| { + let polynomial_len = 1_usize << ADVICE_DELTA_PREPARED_K; + let mut rows = vec![false; polynomial_len]; + for sample in 0..ADVICE_DELTA_WORK_SAMPLES { + let row = advice_delta_stratified_row( + polynomial_len, + ADVICE_DELTA_WORK_SAMPLES, + sample, + ) + .unwrap(); + rows[row] = true; + } + rows + })[row] + } + + fn random_value(column: usize, row: usize) -> Fp { + let mut state = u64::try_from(column * ASSIGNED_ROWS + row) + .unwrap() + .wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut bytes = [0_u8; 64]; + for chunk in bytes.chunks_exact_mut(8) { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + chunk.copy_from_slice(&state.to_le_bytes()); + } + Fp::from_uniform_bytes(&bytes) + } + + fn value(&self, column: usize, row: usize) -> Fp { + match self.profile { + AdviceDeltaProfile::Similar => { + let reference = Self::random_value(column, row); + if self.circuit_index == 0 || column < self.shared_columns { + // This profile models same-wallet witness reuse. + reference + } else { + // Every value at the same position differs from the + // reference by a low-work scalar. + let delta = self.circuit_index * ASSIGNED_ROWS + row + 1; + reference + + Fp::from(u64::try_from(delta).expect("test value fits into u64")) + } + } + AdviceDeltaProfile::MagnitudeInversion => { + let direct = Fp::from(u64::try_from(row + 1).unwrap()); + if self.circuit_index == 0 && row >= MAGNITUDE_SHARED_ROWS { + direct - Self::random_value(column, row) + } else { + direct + } + } + AdviceDeltaProfile::HighWindowSparse => { + let direct = Fp::ONE; + if self.circuit_index == 0 && row % 4 != 0 { + direct - Fp::from_u128(1_u128 << 119) + } else { + direct + } + } + AdviceDeltaProfile::MissedHighWindow => { + let direct = Fp::ONE; + if self.circuit_index == 0 + && row < MAGNITUDE_SHARED_ROWS + && !Self::is_work_sample(row) + { + direct - Fp::from_u128(1_u128 << 119) + } else { + direct + } + } + } + } + } + + impl Circuit for AdviceDeltaCircuit { + type Config = [Column; ADVICE_COLUMNS]; + type FloorPlanner = SimpleFloorPlanner; + + fn without_witnesses(&self) -> Self { + Self { + circuit_index: 0, + shared_columns: self.shared_columns, + profile: self.profile, + } + } + + fn configure(meta: &mut ConstraintSystem) -> Self::Config { + std::array::from_fn(|_| meta.advice_column()) + } + + fn synthesize( + &self, + advice: Self::Config, + mut layouter: impl Layouter, + ) -> Result<(), Error> { + layouter.assign_region( + || "advice delta profiles", + |mut region| { + for (column_index, column) in advice.into_iter().enumerate() { + for row in 0..ASSIGNED_ROWS { + region.assign_advice( + || "value", + column, + row, + || Value::known(self.value(column_index, row)), + )?; + } + } + Ok(()) + }, + ) + } + } + + fn exact_counts_reject_known_sample_evasion(params: &Params) { + const LEGACY_COUNT_SAMPLES: usize = 256; + let polynomial_len = 1_usize << ADVICE_DELTA_PREPARED_K; + let mut count_rows = vec![false; polynomial_len]; + let mut work_rows = vec![false; polynomial_len]; + for sample in 0..LEGACY_COUNT_SAMPLES { + count_rows[advice_delta_stratified_row(polynomial_len, LEGACY_COUNT_SAMPLES, sample) + .unwrap()] = true; + } + for sample in 0..ADVICE_DELTA_WORK_SAMPLES { + work_rows[advice_delta_stratified_row( + polynomial_len, + ADVICE_DELTA_WORK_SAMPLES, + sample, + ) + .unwrap()] = true; + } + + // A deterministic count sample could see 256 equal nonzero pairs and + // miss a dense delta everywhere else. Give the work-sample rows + // full-width direct values too, so its zero delta passes the remaining + // sampled prepared-work comparison. + let mut direct = vec![Fp::ZERO; polynomial_len]; + let mut reference = vec![Fp::ONE; polynomial_len]; + for row in 0..polynomial_len { + if count_rows[row] || work_rows[row] { + direct[row] = if work_rows[row] { + AdviceDeltaCircuit::random_value(0, row) + } else { + Fp::ONE + }; + reference[row] = direct[row]; + } + } + + let legacy_counts = count_rows.iter().enumerate().fold( + (0_usize, 0_usize), + |(direct_count, delta_count), (row, &sampled)| { + if sampled { + ( + direct_count + usize::from(!direct[row].is_zero_vartime()), + delta_count + usize::from(direct[row] != reference[row]), + ) + } else { + (direct_count, delta_count) + } + }, + ); + assert_eq!(legacy_counts, (LEGACY_COUNT_SAMPLES, 0)); + assert_eq!( + use_advice_delta_counts(&[legacy_counts; ADVICE_COLUMNS]), + Some(true), + ); + + let direct_work = work_rows + .iter() + .enumerate() + .filter_map(|(row, &sampled)| sampled.then_some(direct[row])) + .collect::>(); + let delta_work = vec![Fp::ZERO; direct_work.len()]; + let prepared = params.lagrange_table().unwrap(); + assert_eq!( + prepared.scalar_work_is_at_most_vartime(&delta_work, &direct_work), + Some(true), + ); + + let equal_rows = count_rows + .iter() + .zip(&work_rows) + .filter(|&(&count_sample, &work_sample)| count_sample || work_sample) + .count(); + let exact_counts = advice_delta_nonzero_counts(&direct, &reference).unwrap(); + assert_eq!(exact_counts, (equal_rows, polynomial_len - equal_rows)); + assert_eq!( + use_advice_delta_counts(&[exact_counts; ADVICE_COLUMNS]), + Some(false), + ); + } + + fn proof( + params: &Params, + pk: &ProvingKey, + circuits: &[AdviceDeltaCircuit], + threads: usize, + prepared: bool, + ) -> Vec { + let no_columns: &[&[Fp]] = &[]; + let instances = vec![no_columns; circuits.len()]; + let mut transcript = Blake2bWrite::<_, _, Challenge255<_>>::init(vec![]); + maybe_rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .unwrap() + .install(|| { + assert_eq!( + params.prepared_lagrange_commitments_active(1 << ADVICE_DELTA_PREPARED_K), + prepared, + ); + create_proof( + params, + pk, + circuits, + &instances, + StdRng::seed_from_u64(PROOF_SEED), + &mut transcript, + ) + }) + .expect("proof generation should not fail"); + transcript.finalize() + } + + fn verify( + params: &Params, + pk: &ProvingKey, + circuit_count: usize, + proof: &[u8], + ) { + let no_columns: &[&[Fp]] = &[]; + let instances = vec![no_columns; circuit_count]; + let strategy = SingleVerifier::new(params); + let mut transcript = Blake2bRead::<_, _, Challenge255<_>>::init(proof); + verify_proof(params, pk.get_vk(), strategy, &instances, &mut transcript) + .expect("proof verification should not fail"); + } + + fn compare_profiles( + armed: &Params, + unarmed: &Params, + pk: &ProvingKey, + circuits: &[AdviceDeltaCircuit], + expected_route_hits: usize, + worker_counts: &[usize], + ) { + take_advice_delta_route_hits(); + let expected = proof(unarmed, pk, circuits, 1, false); + assert_eq!(take_advice_delta_route_hits(), 0); + verify(unarmed, pk, circuits.len(), &expected); + + for &threads in worker_counts { + take_advice_delta_route_hits(); + let actual = proof(armed, pk, circuits, threads, true); + assert_eq!(take_advice_delta_route_hits(), expected_route_hits); + assert_eq!(actual, expected); + } + } + + let unarmed = Params::::new(ADVICE_DELTA_PREPARED_K); + let armed = Params::::new(ADVICE_DELTA_PREPARED_K); + let keygen_circuit = AdviceDeltaCircuit { + circuit_index: 0, + shared_columns: 2, + profile: AdviceDeltaProfile::Similar, + }; + let vk = keygen_vk(&unarmed, &keygen_circuit).expect("keygen_vk should not fail"); + let pk = keygen_pk(&unarmed, vk, &keygen_circuit).expect("keygen_pk should not fail"); + assert!(armed.prepare_commitments()); + exact_counts_reject_known_sample_evasion(&armed); + + for (circuit_count, worker_counts) in [(1, &[1][..]), (2, &[1, 4][..]), (4, &[1][..])] { + let circuits = (0..circuit_count) + .map(|circuit_index| AdviceDeltaCircuit { + circuit_index, + shared_columns: 2, + profile: AdviceDeltaProfile::Similar, + }) + .collect::>(); + compare_profiles( + &armed, + &unarmed, + &pk, + &circuits, + ADVICE_COLUMNS * circuit_count.saturating_sub(1), + worker_counts, + ); + } + + // A dissimilar second circuit exercises the exact original-schedule + // fallback after the route scan finds no useful delta. + let direct_only = [ + keygen_circuit, + AdviceDeltaCircuit { + circuit_index: 1, + shared_columns: 0, + profile: AdviceDeltaProfile::Similar, + }, + ]; + compare_profiles(&armed, &unarmed, &pk, &direct_only, 0, &[1]); + + // Routing is independent per later circuit: a useful second circuit can + // reuse the reference while a dissimilar third circuit commits directly. + let mixed = [ + keygen_circuit, + AdviceDeltaCircuit { + circuit_index: 1, + shared_columns: 2, + profile: AdviceDeltaProfile::Similar, + }, + AdviceDeltaCircuit { + circuit_index: 2, + shared_columns: 0, + profile: AdviceDeltaProfile::Similar, + }, + ]; + compare_profiles(&armed, &unarmed, &pk, &mixed, ADVICE_COLUMNS, &[1, 4]); + + // One useful column cannot amortize the circuit-wide path, so the exact + // aggregate gate retains the fallback. + let globally_too_small = [ + keygen_circuit, + AdviceDeltaCircuit { + circuit_index: 1, + shared_columns: 1, + profile: AdviceDeltaProfile::Similar, + }, + ]; + compare_profiles(&armed, &unarmed, &pk, &globally_too_small, 0, &[1]); + + // Counts alone strongly prefer these deltas, but their few nonzero + // values are full-width while the direct scalars are small. The sampled + // prepared-work comparison must retain the exact direct fallback. + let magnitude_inversion = [ + AdviceDeltaCircuit { + circuit_index: 0, + shared_columns: 0, + profile: AdviceDeltaProfile::MagnitudeInversion, + }, + AdviceDeltaCircuit { + circuit_index: 1, + shared_columns: 0, + profile: AdviceDeltaProfile::MagnitudeInversion, + }, + ]; + compare_profiles(&armed, &unarmed, &pk, &magnitude_inversion, 0, &[1, 4]); + + // The count guard prefers a delta with one quarter zeroes over an all-one + // direct polynomial. Its nonzero terms are 2^119, however, so they activate + // almost every prepared main window. The per-evaluation work comparison + // must retain the direct route. + let direct = (0..(1_usize << ADVICE_DELTA_PREPARED_K)) + .map(|row| { + if row < ASSIGNED_ROWS { + Fp::ONE + } else { + Fp::ZERO + } + }) + .collect::>(); + let reference = direct + .iter() + .enumerate() + .map(|(row, &direct)| { + if row < ASSIGNED_ROWS && row % 4 != 0 { + direct - Fp::from_u128(1_u128 << 119) + } else { + direct + } + }) + .collect::>(); + let counts = (0..ADVICE_COLUMNS) + .map(|_| advice_delta_nonzero_counts(&direct, &reference)) + .collect::>>() + .unwrap(); + assert_eq!(use_advice_delta_counts(&counts), Some(true)); + + let high_window_sparse = [ + AdviceDeltaCircuit { + circuit_index: 0, + shared_columns: 0, + profile: AdviceDeltaProfile::HighWindowSparse, + }, + AdviceDeltaCircuit { + circuit_index: 1, + shared_columns: 0, + profile: AdviceDeltaProfile::HighWindowSparse, + }, + ]; + compare_profiles(&armed, &unarmed, &pk, &high_window_sparse, 0, &[1, 4]); + + // A fixed work sample could otherwise miss every high-window delta. The + // direct sample only contains unit scalars and therefore does not span the + // prepared evaluator's main windows, so the conservative comparison must + // retain the direct route. + let direct = (0..(1_usize << ADVICE_DELTA_PREPARED_K)) + .map(|row| { + if row < ASSIGNED_ROWS { + Fp::ONE + } else { + Fp::ZERO + } + }) + .collect::>(); + let reference = direct + .iter() + .enumerate() + .map(|(row, &direct)| { + if row < MAGNITUDE_SHARED_ROWS && !AdviceDeltaCircuit::is_work_sample(row) { + direct - Fp::from_u128(1_u128 << 119) + } else { + direct + } + }) + .collect::>(); + let counts = (0..ADVICE_COLUMNS) + .map(|_| advice_delta_nonzero_counts(&direct, &reference)) + .collect::>>() + .unwrap(); + assert_eq!(use_advice_delta_counts(&counts), Some(true)); + + let missed_high_window = [ + AdviceDeltaCircuit { + circuit_index: 0, + shared_columns: 0, + profile: AdviceDeltaProfile::MissedHighWindow, + }, + AdviceDeltaCircuit { + circuit_index: 1, + shared_columns: 0, + profile: AdviceDeltaProfile::MissedHighWindow, + }, + ]; + compare_profiles(&armed, &unarmed, &pk, &missed_high_window, 0, &[1, 4]); +} + #[test] fn advice_witness_evaluates_rationals_and_reassignments() { use pasta_curves::Fp; diff --git a/crates/halo2_proofs/src/poly/commitment.rs b/crates/halo2_proofs/src/poly/commitment.rs index c472984d..b5396a9c 100644 --- a/crates/halo2_proofs/src/poly/commitment.rs +++ b/crates/halo2_proofs/src/poly/commitment.rs @@ -1970,6 +1970,24 @@ impl Params { } } + /// Returns whether [`Self::commit_lagrange`] would use the prepared + /// no-orbits backend for a polynomial of `polynomial_len` coefficients. + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + pub(crate) fn prepared_lagrange_commitments_active(&self, polynomial_len: usize) -> bool { + // Keep these checks aligned with the prepared no-orbits route above + // without factoring the commitment hot path through another helper. + if crate::multicore::current_num_threads() > prepared_commitment_max_threads(self.k) { + return false; + } + + let (Some(prepared), Some(_fixed_bases)) = (self.lagrange_table(), self.fixed_base_table()) + else { + return false; + }; + let n = self.n as usize; + prepared.terms() == n && polynomial_len == n + } + /// Generates an empty multiscalar multiplication struct using the /// appropriate params. pub fn empty_msm(&self) -> MSM<'_, C> { @@ -2241,6 +2259,14 @@ impl Params { /// params. Concurrent callers outside that pool safely wait for and share /// the same attempt; fanning a cold call out across the worker pool can /// occupy its other workers and serialize the initializer's parallel work. + /// + /// # Security + /// + /// Commitments evaluated from these prepared tables are variable-time in + /// their secret inputs. When proving several circuits together, their + /// relative sparsity and similarity can affect runtime. Callers must not + /// expose that timing across an untrusted boundary when those relationships + /// are sensitive. pub fn prepare_commitments(&self) -> bool { #[cfg(feature = "orbits")] { @@ -3544,7 +3570,19 @@ fn prepared_commitments_match_unprepared() { .num_threads(num_threads) .build() .expect("test pool must build") - .install(|| exercise(&armed, &unarmed, 41 + num_threads as u64)); + .install(|| { + #[cfg(not(feature = "orbits"))] + { + let full_len = armed.n as usize; + assert_eq!( + armed.prepared_lagrange_commitments_active(full_len), + num_threads <= prepared_commitment_max_threads(armed.k), + ); + assert!(!armed.prepared_lagrange_commitments_active(full_len - 1)); + assert!(!unarmed.prepared_lagrange_commitments_active(full_len)); + } + exercise(&armed, &unarmed, 41 + num_threads as u64) + }); } } diff --git a/crates/orchard/src/builder.rs b/crates/orchard/src/builder.rs index 7c4931d0..e1c6d794 100644 --- a/crates/orchard/src/builder.rs +++ b/crates/orchard/src/builder.rs @@ -1591,6 +1591,12 @@ impl InProgress { /// /// Also returns an error if `pk` does not match the circuit version this /// bundle's actions were built for, or if proof creation fails. + /// + /// # Security + /// + /// With a [`ProvingKey`] armed by [`ProvingKey::prepare_proving`], relative + /// sparsity and similarity between Actions can affect proving latency; see + /// [`Proof::create`]. pub fn create_proof( &self, pk: &ProvingKey, @@ -1620,6 +1626,12 @@ impl Bundle, V> { /// /// Also returns an error if `pk` does not match this bundle's /// [`circuit_version`](Self::circuit_version), or if proof creation fails. + /// + /// # Security + /// + /// With a [`ProvingKey`] armed by [`ProvingKey::prepare_proving`], relative + /// sparsity and similarity between Actions can affect proving latency; see + /// [`Proof::create`]. pub fn create_proof( self, pk: &ProvingKey, diff --git a/crates/orchard/src/circuit.rs b/crates/orchard/src/circuit.rs index 72ee0adb..2a42b322 100644 --- a/crates/orchard/src/circuit.rs +++ b/crates/orchard/src/circuit.rs @@ -1405,6 +1405,14 @@ impl ProvingKey { /// means arming was a no-op (Orchard was built with neither `multicore` /// nor `orbits`, or its backend declined) and proving simply keeps its /// unprepared path. Callers may ignore the result. + /// + /// # Security + /// + /// Proofs created after preparation remain variable-time in private + /// witnesses. When proving several Actions together, their sparsity and + /// similarity may affect timing; see [`halo2_proofs::plonk::create_proof`]. + /// Do not expose proving latency across an untrusted boundary when those + /// relationships are sensitive. pub fn prepare_proving(&self) -> bool { self.params.prepare_commitments() } @@ -1597,6 +1605,14 @@ impl Proof { /// /// All instances of a bundle carry the same `disableCrossAddress` value; that uniformity /// is the bundle layer's invariant, and is not checked here. + /// + /// # Security + /// + /// Proof creation is variable-time in the private witnesses. With a + /// [`ProvingKey`] armed by [`ProvingKey::prepare_proving`], the relative + /// sparsity and similarity of several Actions can affect runtime. Do not + /// expose proving latency across an untrusted boundary when those + /// relationships are sensitive. pub fn create( pk: &ProvingKey, circuits: &[Circuit], diff --git a/crates/orchard/src/pczt/prover.rs b/crates/orchard/src/pczt/prover.rs index 746bda63..edf8b319 100644 --- a/crates/orchard/src/pczt/prover.rs +++ b/crates/orchard/src/pczt/prover.rs @@ -34,6 +34,12 @@ impl super::Bundle { /// Also returns an error if required Prover-role fields are missing or invalid, /// or if proof creation fails. /// + /// # Security + /// + /// With a [`ProvingKey`] armed by [`ProvingKey::prepare_proving`], relative + /// sparsity and similarity between Actions can affect proving latency; see + /// [`Proof::create`]. + /// /// [`OrchardCircuitVersion::PostNu6_3`]: crate::circuit::OrchardCircuitVersion::PostNu6_3 pub fn create_proof( &mut self, diff --git a/crates/pasta_curves/src/arithmetic/curves.rs b/crates/pasta_curves/src/arithmetic/curves.rs index 68ed2cdd..aac7b87d 100644 --- a/crates/pasta_curves/src/arithmetic/curves.rs +++ b/crates/pasta_curves/src/arithmetic/curves.rs @@ -187,10 +187,33 @@ pub trait CurveExt: #[cfg(any(feature = "multicore", feature = "orbits"))] #[cfg_attr(docsrs, doc(cfg(any(feature = "multicore", feature = "orbits"))))] pub trait PreparedZeroCheck: core::fmt::Debug + Send + Sync { - /// The number of fixed bases this preparation covers; `scalars` below - /// must have exactly this length. + /// The number of fixed bases this preparation covers; scalar slices + /// passed to the evaluation methods must have exactly this length. fn terms(&self) -> usize; + /// Compares the variable-time scalar work of this prepared backend. + /// + /// Returns `Some(true)` when the backend's conservative scalar-work model + /// accepts `candidate` over `baseline`, and `Some(false)` when it does not. + /// Returns `None` when the backend cannot compare the inputs at all. The + /// slices must have equal length and represent one evaluation each. Unlike + /// the evaluation methods, they may be samples of any length. The result + /// describes only the backend's modeled dimensions for these slices; it is + /// not an elapsed-time guarantee, a prediction for unsampled values, or a + /// statement of algebraic equivalence. + /// + /// # Security + /// + /// Variable-time in both scalar slices; callers must not use this with + /// secret scalars unless they already accept scalar-dependent timing. + fn scalar_work_is_at_most_vartime( + &self, + _candidate: &[C::ScalarExt], + _baseline: &[C::ScalarExt], + ) -> Option { + None + } + /// Whether $\sum_i \[k_i\] P_i + \sum_j \[s_j\] Q_j$ is the identity. /// /// # Security diff --git a/crates/pasta_curves/src/glv/zero.rs b/crates/pasta_curves/src/glv/zero.rs index f14e9e7a..363beda0 100644 --- a/crates/pasta_curves/src/glv/zero.rs +++ b/crates/pasta_curves/src/glv/zero.rs @@ -105,8 +105,8 @@ use maybe_rayon::prelude::*; use super::orbit; use super::{ - AffinePoint, GlvParams, SignedMagnitude, checked_signed_magnitudes, current_num_threads, - decompose, private, reduce_affine_buckets, reduce_affine_buckets_in_place, + AffinePoint, GLV_COMPONENT_BITS, GlvParams, SignedMagnitude, checked_signed_magnitudes, + current_num_threads, decompose, private, reduce_affine_buckets, reduce_affine_buckets_in_place, }; mod codebook; @@ -162,6 +162,23 @@ enum MainWindowFold { Horner, } +#[derive(Clone, Copy, Debug)] +struct ScalarWorkProfile { + main_active: usize, + main_visits: usize, + tail_active: usize, + tail_visits: usize, +} + +impl ScalarWorkProfile { + fn is_at_most(self, baseline: Self) -> bool { + self.main_active <= baseline.main_active + && self.main_visits <= baseline.main_visits + && self.tail_active <= baseline.tail_active + && self.tail_visits <= baseline.tail_visits + } +} + /// A prepared fixed-base zero-check: reusable tables for testing /// $\sum_i \[k_i\] P_i \stackrel{?}{=} \mathcal{O}$ against the bases given /// at preparation, with per-check extra terms for the non-fixed remainder @@ -301,6 +318,96 @@ impl PreparedZeroMsm { self.live.len() } + /// Profiles the prepared evaluator's scalar-dependent work. + fn scalar_work_profile_vartime(&self, scalars: &[C::ScalarExt]) -> Option { + // Arbitrary samples have no fixed-base indices, so they cannot model + // the evaluator's identity suppression or relation folding. + if !self.merges.is_empty() || self.live.iter().any(|&live| !live) { + return None; + } + + const MAX_MAIN_WINDOWS: usize = GLV_COMPONENT_BITS.div_ceil(codebook::MIN_WINDOW_BITS); + const MAX_TAIL_WINDOWS: usize = orbit::window_count(orbit::MIN_WINDOW_BITS); + + let main_windows = self.codebook.main_windows(); + let mut main_active = 0; + let mut main_visits = 0_usize; + let mut main = [0_u32; MAX_MAIN_WINDOWS]; + let tail_params = &self.tail_params[self.tail_width]; + let tail_windows = tail_params.window_stride(); + let mut tail_active = 0; + let mut tail_visits = 0_usize; + let mut tail = [0_u16; MAX_TAIL_WINDOWS]; + let signed = |component: SignedMagnitude| { + if component.negative { + -(component.magnitude as i128) + } else { + component.magnitude as i128 + } + }; + + for scalar in scalars { + if scalar.is_zero_vartime() { + continue; + } + + let (first, second) = checked_signed_magnitudes(decompose::(scalar))?; + main[..main_windows].fill(0); + let (top, (tail_a, tail_b)) = + self.codebook + .recode_pair(signed(first), signed(second), &mut main[..main_windows]); + main_active = main_active.max(top); + main_visits = + main_visits.checked_add(main[..top].iter().filter(|&&code| code != 0).count())?; + + tail[..tail_windows].fill(0); + let residual = ( + SignedMagnitude::from((tail_a < 0, u128::from(tail_a.unsigned_abs()))), + SignedMagnitude::from((tail_b < 0, u128::from(tail_b.unsigned_abs()))), + ); + let tail_top = orbit::recode_row( + tail_params, + residual.0, + residual.1, + &mut tail[..tail_windows], + ); + tail_active = tail_active.max(tail_top); + tail_visits = tail_visits + .checked_add(tail[..tail_top].iter().filter(|&&code| code != 0).count())?; + } + + Some(ScalarWorkProfile { + main_active, + main_visits, + tail_active, + tail_visits, + }) + } + + /// Compares the prepared evaluator's scalar-dependent work. + fn scalar_work_is_at_most_vartime( + &self, + candidate: &[C::ScalarExt], + baseline: &[C::ScalarExt], + ) -> Option { + if candidate.len() != baseline.len() { + return None; + } + + // Each active window scans a complete code column before its nonzero + // digits are staged. A caller may use these slices as samples of a + // larger evaluation, so also require the baseline to reach the main + // evaluator's maximum span. No unseen candidate value can then add a + // main window. Compare the remaining modeled dimensions without + // assigning backend-specific weights. + let baseline = self.scalar_work_profile_vartime(baseline)?; + if baseline.main_active != self.codebook.main_windows() { + return Some(false); + } + let candidate = self.scalar_work_profile_vartime(candidate)?; + Some(candidate.is_at_most(baseline)) + } + /// Accounted table footprint in bytes: the variant table, tail bases, /// residue entries, and codebook lifts. This intentionally excludes /// small live/merge vectors, the coefficient program, object and @@ -843,6 +950,14 @@ impl crate::arithmetic::PreparedZeroCheck for PreparedZeroMsm Option { + PreparedZeroMsm::scalar_work_is_at_most_vartime(self, candidate, baseline) + } + fn is_zero_with_terms_vartime( &self, scalars: &[C::ScalarExt], @@ -1380,6 +1495,121 @@ mod tests { ] } + /// The comparison follows the actual prepared recoder and recognizes + /// low-work scalars against a full-span baseline. + fn scalar_work_comparison_profiles() { + const TERMS: usize = 64; + + let (random, _, _) = super::super::testutil::verifier_multiexp_inputs::(TERMS); + let generator = C::generator(); + let bases = (1..=TERMS) + .map(|value| { + (generator * C::ScalarExt::from(u64::try_from(value).unwrap())).to_affine() + }) + .collect::>(); + let prepared = PreparedZeroMsm::::prepare_with_mode(&bases, CodebookMode::alpha_only(7)); + let small = (1..=TERMS) + .map(|value| C::ScalarExt::from(u64::try_from(value).unwrap())) + .collect::>(); + let negative = small.iter().map(|scalar| -*scalar).collect::>(); + let zeta = small + .iter() + .map(|scalar| *scalar * C::ScalarExt::ZETA) + .collect::>(); + let is_at_most = |candidate: &[C::ScalarExt], baseline: &[C::ScalarExt]| { + crate::arithmetic::PreparedZeroCheck::scalar_work_is_at_most_vartime( + &prepared, candidate, baseline, + ) + }; + + let zero = [C::ScalarExt::ZERO; TERMS]; + let random_profile = prepared.scalar_work_profile_vartime(&random).unwrap(); + assert_eq!(random_profile.main_active, prepared.codebook.main_windows()); + assert_eq!(is_at_most(&zero, &random), Some(true)); + assert_eq!(is_at_most(&random, &zero), Some(false)); + for low_work in [&small, &negative, &zeta] { + assert_eq!(is_at_most(low_work, &random), Some(true)); + } + assert_eq!(is_at_most(&small[..TERMS - 1], &small), None); + + // A low-span baseline cannot protect a sampled caller against an + // unseen candidate value activating more main windows. + let ones = vec![C::ScalarExt::ONE; TERMS]; + assert_eq!(is_at_most(&zero, &ones), Some(false)); + + // Counting only nonzero digits incorrectly prefers this sparse input: + // each live scalar has one digit, but reaching it activates almost + // every main window. The evaluator scans every term in those windows. + let mut high = C::ScalarExt::ONE; + for _ in 0..119 { + high = high.double(); + } + let sparse_high = (0..TERMS) + .map(|index| { + if index % 4 == 0 { + C::ScalarExt::ZERO + } else { + high + } + }) + .collect::>(); + assert_eq!(is_at_most(&sparse_high, &ones), Some(false)); + + // Canonical magnitude is not a proxy for prepared work: zeta is a + // single free-unit digit even though its canonical representation is + // full-width. The comparison must still reject sparse high powers. + for _ in 119..200 { + high = high.double(); + } + let zeta_units = vec![C::ScalarExt::ZETA; TERMS]; + let sparse_higher = (0..TERMS) + .map(|index| { + if index % 4 == 0 { + C::ScalarExt::ZERO + } else { + high + } + }) + .collect::>(); + assert_eq!(is_at_most(&sparse_higher, &zeta_units), Some(false)); + + let mut identity_bases = bases.clone(); + identity_bases[0] = C::AffineExt::identity(); + let identity_prepared = + PreparedZeroMsm::::prepare_with_mode(&identity_bases, CodebookMode::alpha_only(7)); + assert_eq!( + crate::arithmetic::PreparedZeroCheck::scalar_work_is_at_most_vartime( + &identity_prepared, + &random, + &small, + ), + None, + ); + + let mut duplicate_bases = bases; + duplicate_bases[1] = duplicate_bases[0]; + let relation_prepared = + PreparedZeroMsm::::prepare_with_mode(&duplicate_bases, CodebookMode::alpha_only(7)); + assert_eq!( + crate::arithmetic::PreparedZeroCheck::scalar_work_is_at_most_vartime( + &relation_prepared, + &random, + &small, + ), + None, + ); + } + + #[test] + fn pallas_scalar_work_comparison_profiles() { + scalar_work_comparison_profiles::(); + } + + #[test] + fn vesta_scalar_work_comparison_profiles() { + scalar_work_comparison_profiles::(); + } + /// Exact zero relations verify; one-bit perturbations, all-zero /// scalars, and single-term inputs behave per §23.4 — at every mode. fn zero_checks_across_modes() { diff --git a/crates/pasta_curves/src/glv/zero/codebook.rs b/crates/pasta_curves/src/glv/zero/codebook.rs index 36dc93b7..16567ca7 100644 --- a/crates/pasta_curves/src/glv/zero/codebook.rs +++ b/crates/pasta_curves/src/glv/zero/codebook.rs @@ -840,7 +840,12 @@ impl Codebook { /// by the caller), returning one past the highest nonzero window and /// the residual `t` with `z = Σ B^j d_j + B^L t`. The residual is /// asserted against [`Self::tail_bound`]. - fn recode_pair(&self, mut a: i128, mut b: i128, row: &mut [u32]) -> (usize, (i64, i64)) { + pub(super) fn recode_pair( + &self, + mut a: i128, + mut b: i128, + row: &mut [u32], + ) -> (usize, (i64, i64)) { debug_assert_eq!(row.len(), self.main_windows); let c = self.mode.window_bits(); let mask = (1i128 << c) - 1; diff --git a/docs/changelog/unreleased/328.md b/docs/changelog/unreleased/328.md new file mode 100644 index 00000000..8378e3c8 --- /dev/null +++ b/docs/changelog/unreleased/328.md @@ -0,0 +1,41 @@ +## zakura-pasta-curves + +### Added + +- Added `PreparedZeroCheck::scalar_work_is_at_most_vartime`, an optional + backend-defined comparison for equal-length scalar samples through the same + prepared handle. Pasta's prepared backend requires the baseline sample to + span every main window, and requires the nonzero digit visits and + active-window counts of both its main and residual-tail evaluators to be + nonincreasing + ([#328](https://github.com/zakura-core/common/pull/328)). + +## zakura-halo2-proofs + +### Added + +- Added the feature-gated + `halo2_proofs::arithmetic::PreparedZeroCheck::scalar_work_is_at_most_vartime` + method through the existing re-export from `zakura-pasta-curves` + ([#328](https://github.com/zakura-core/common/pull/328)). + +### Changed + +- Reused prepared `k = 11` advice commitments across batched circuit proofs in + `multicore` builds without `orbits` when exact per-column nonzero counts find + a sparse per-circuit witness delta and per-column prepared-backend samples + predict no digit-visit or active-window regression. The exact count guard + prevents a density regression, while the small prepared-work samples remain + a heuristic for recoding costs at unsampled rows. Proof format, RNG + consumption, and verification are unchanged. + This routing is variable-time and can expose witness similarity through + proving latency, as documented by the affected proving APIs + ([#328](https://github.com/zakura-core/common/pull/328)). + +## zakura-orchard + +### Changed + +- Documented that prepared multi-Action proving can expose witness sparsity and + similarity through proving latency + ([#328](https://github.com/zakura-core/common/pull/328)).