diff --git a/crates/halo2_proofs/src/plonk/verifier.rs b/crates/halo2_proofs/src/plonk/verifier.rs index 0274ed71..a07ac9d9 100644 --- a/crates/halo2_proofs/src/plonk/verifier.rs +++ b/crates/halo2_proofs/src/plonk/verifier.rs @@ -58,7 +58,10 @@ impl<'params, C: CurveAffine> VerificationStrategy<'params, C> for SingleVerifie ) -> Result { let guard = f(self.msm)?; let msm = guard.use_challenges(); - if msm.eval() { + // The IPA challenge expansion normally produces random-like, + // full-width generator coefficients. Correctness does not depend on + // their density. + if msm.eval_full_width() { Ok(()) } else { Err(Error::ConstraintSystemFailure) diff --git a/crates/halo2_proofs/src/plonk/verifier/batch.rs b/crates/halo2_proofs/src/plonk/verifier/batch.rs index 49f5488b..0910e361 100644 --- a/crates/halo2_proofs/src/plonk/verifier/batch.rs +++ b/crates/halo2_proofs/src/plonk/verifier/batch.rs @@ -280,7 +280,13 @@ where ); match final_msm { - Ok(msm) => msm.eval(), + // Each contribution passed through the IPA challenge expansion, + // which normally produces random-like, full-width coefficients, + // before being scaled into the batch. The batch coefficients are + // fresh, sampled after every proof equation is fixed, and discarded + // after this check, so batch verification explicitly accepts the + // variable-time MSM's timing leakage. + Ok(msm) => msm.eval_full_width(), Err(_) => false, } } diff --git a/crates/halo2_proofs/src/poly/commitment/msm.rs b/crates/halo2_proofs/src/poly/commitment/msm.rs index 100c8d03..ed176639 100644 --- a/crates/halo2_proofs/src/poly/commitment/msm.rs +++ b/crates/halo2_proofs/src/poly/commitment/msm.rs @@ -1,5 +1,5 @@ use super::Params; -use crate::arithmetic::{CurveAffine, best_multiexp}; +use crate::arithmetic::{CurveAffine, CurveExt, best_multiexp}; use ff::{Field, PrimeField}; use group::Group; @@ -79,6 +79,12 @@ type OtherTerms = BTreeMap< (::ScalarExt, ::Base), >; +#[derive(Clone, Copy, Eq, PartialEq)] +enum ScalarProfile { + General, + FullWidth, +} + /// A multiscalar multiplication in the polynomial commitment scheme #[derive(Debug, Clone)] pub struct MSM<'a, C: CurveAffine> { @@ -283,7 +289,7 @@ impl<'a, C: CurveAffine> MSM<'a, C> { self.u_scalar = self.u_scalar.map(|a| a * &factor); } - fn multiexp(self) -> C::Curve { + fn multiexp_terms(self) -> (Vec, Vec) { let Self { params, g_scalars, @@ -340,12 +346,27 @@ impl<'a, C: CurveAffine> MSM<'a, C> { assert_eq!(scalars.len(), len); + (scalars, bases) + } + + fn multiexp(self) -> C::Curve { + let (scalars, bases) = self.multiexp_terms(); best_multiexp(&scalars, &bases) } - /// Perform multiexp and check that it results in zero + /// Perform multiexp and check that it results in zero. + pub fn eval(self) -> bool { + self.eval_with_profile(ScalarProfile::General) + } + + /// Evaluates a verifier MSM whose dominant generator-scalar vector is + /// expected to be random-like and full-width. + pub(crate) fn eval_full_width(self) -> bool { + self.eval_with_profile(ScalarProfile::FullWidth) + } + #[cfg_attr(not(feature = "orbits"), allow(unused_mut))] - pub fn eval(mut self) -> bool { + fn eval_with_profile(mut self, scalar_profile: ScalarProfile) -> bool { // A prepared fixed-base zero-check over [g..., w, u] (built by // `Params::prepare_zero_checks`, under the opt-in `orbits` // feature) evaluates the identity test @@ -417,7 +438,17 @@ impl<'a, C: CurveAffine> MSM<'a, C> { } } - bool::from(self.multiexp().is_identity()) + if scalar_profile == ScalarProfile::General { + return bool::from(self.multiexp().is_identity()); + } + + let (scalars, bases) = self.multiexp_terms(); + if let Some(is_identity) = + C::CurveExt::try_multiexp_full_width_is_identity_vartime(&scalars, &bases) + { + return is_identity; + } + bool::from(best_multiexp(&scalars, &bases).is_identity()) } /// The MSM's accumulated terms, exposed for capturing the verifier fingerprint without consuming @@ -456,8 +487,12 @@ impl<'a, C: CurveAffine> MSM<'a, C> { mod tests { use super::CanonicalFieldKey; use crate::poly::commitment::{MSM, Params}; - use group::Curve; - use pasta_curves::{EpAffine, EqAffine, Fp, Fq, arithmetic::CurveAffine}; + use ff::Field; + use group::{Curve, Group}; + use pasta_curves::{ + EpAffine, EqAffine, Fp, Fq, + arithmetic::{CurveAffine, CurveExt}, + }; fn assert_cached_key_order(values: &[F]) { for left in values { @@ -491,6 +526,46 @@ mod tests { assert_cached_key_order(&vesta_xs); } + fn cancelling_full_width_msm(params: &Params) -> MSM<'_, EpAffine> { + let g_scalars = (0..params.n) + .map(|index| Fq::from(index + 2).invert().unwrap()) + .collect::>(); + let mut msm = MSM::new(params); + msm.add_to_g_scalars(&g_scalars); + let sum = msm.clone().multiexp(); + assert!(!bool::from(sum.is_identity())); + msm.append_term(-Fq::ONE, sum.to_affine()); + msm + } + + #[test] + fn eval_full_width_is_exact() { + let params = Params::::new(8); + let valid = cancelling_full_width_msm(¶ms); + assert!(valid.clone().eval_full_width()); + + let mut invalid = valid; + invalid.append_term(Fq::ONE, params.w); + assert!(!invalid.eval_full_width()); + } + + #[test] + fn eval_full_width_falls_back_below_glv_threshold() { + let params = Params::::new(4); + let valid = cancelling_full_width_msm(¶ms); + let (scalars, bases) = valid.clone().multiexp_terms(); + assert!( + <::CurveExt as CurveExt>:: + try_multiexp_full_width_is_identity_vartime(&scalars, &bases) + .is_none() + ); + assert!(valid.clone().eval_full_width()); + + let mut invalid = valid; + invalid.append_term(Fq::ONE, params.w); + assert!(!invalid.eval_full_width()); + } + #[test] fn msm_arithmetic() { // Once plain, once with the prepared fixed-base zero-check armed diff --git a/crates/pasta_curves/src/arithmetic/curves.rs b/crates/pasta_curves/src/arithmetic/curves.rs index 721ca1cf..765a43b9 100644 --- a/crates/pasta_curves/src/arithmetic/curves.rs +++ b/crates/pasta_curves/src/arithmetic/curves.rs @@ -131,6 +131,34 @@ pub trait CurveExt: None } + /// Attempts an optimized variable-time identity test for a multiscalar + /// multiplication whose nonzero scalars are expected to be random-like + /// and full-width. + /// + /// The full-width profile is a performance hint only. The result must be + /// exact for every scalar value, including zero and sparse or small + /// nonzero scalars. [`Some(true)`](Some) means the multiscalar + /// multiplication is the identity, [`Some(false)`](Some) means it is not, + /// and [`None`] means that this implementation declined the optimization. + /// Implementations without a specialized backend return [`None`]. + /// + /// # Security + /// + /// This method may run in variable time with respect to `scalars` and + /// `bases`. Inputs should be public unless the caller explicitly accepts + /// timing leakage from secret material. + /// + /// # Panics + /// + /// Implementations may panic if `scalars` and `bases` have different + /// lengths. + fn try_multiexp_full_width_is_identity_vartime( + _scalars: &[Self::ScalarExt], + _bases: &[Self::AffineExt], + ) -> Option { + None + } + /// Attempts an affine, variable-time FFT specialized for this curve. /// /// `input` and `output` must have the same power-of-two length, equal to diff --git a/crates/pasta_curves/src/curves.rs b/crates/pasta_curves/src/curves.rs index 0aab8df4..fc101a07 100644 --- a/crates/pasta_curves/src/curves.rs +++ b/crates/pasta_curves/src/curves.rs @@ -86,6 +86,14 @@ macro_rules! impl_multiexp_vartime { ) -> Option { crate::glv::try_multiexp::<$name>(scalars, bases) } + + #[cfg(feature = "glv")] + fn try_multiexp_full_width_is_identity_vartime( + scalars: &[Self::ScalarExt], + bases: &[Self::AffineExt], + ) -> Option { + crate::glv::try_multiexp_full_width_is_identity::<$name>(scalars, bases) + } }; (native, $name:ident) => {}; } diff --git a/crates/pasta_curves/src/glv.rs b/crates/pasta_curves/src/glv.rs index 9c3c2e7d..b150a51d 100644 --- a/crates/pasta_curves/src/glv.rs +++ b/crates/pasta_curves/src/glv.rs @@ -101,10 +101,11 @@ mod zero; mod private { use crate::arithmetic::CurveExt; - /// Proof-of-crate argument for [`Sealed::affine_unchecked`]: unnameable - /// outside this crate, with a `pub(super)` field, so downstream code - /// cannot construct one — not even through a `C: GlvParams` bound, which - /// does expose supertrait items to generic code. + /// Proof-of-crate argument for representation-sensitive [`Sealed`] + /// operations: unnameable outside this crate, with a `pub(super)` field, + /// so downstream code cannot construct one — not even through a + /// `C: GlvParams` bound, which does expose supertrait items to generic + /// code. #[derive(Debug)] pub struct CrateToken(pub(super) ()); @@ -138,6 +139,11 @@ mod private { z: Self::Base, token: CrateToken, ) -> Self; + + /// Returns the four little-endian limbs of the reduced integer + /// $Rk \bmod q$, where `k` is `scalar` and $R$ is this scalar field's + /// Montgomery factor. + fn scalar_montgomery_limbs(scalar: &Self::ScalarExt, token: CrateToken) -> [u64; 4]; } impl Sealed for crate::pallas::Point { @@ -158,6 +164,10 @@ mod private { use crate::arithmetic::CurveExtUnchecked as _; Self::new_jacobian_unchecked(x, y, z) } + + fn scalar_montgomery_limbs(scalar: &Self::ScalarExt, _: CrateToken) -> [u64; 4] { + scalar.0 + } } impl Sealed for crate::vesta::Point { @@ -178,6 +188,10 @@ mod private { use crate::arithmetic::CurveExtUnchecked as _; Self::new_jacobian_unchecked(x, y, z) } + + fn scalar_montgomery_limbs(scalar: &Self::ScalarExt, _: CrateToken) -> [u64; 4] { + scalar.0 + } } } @@ -374,10 +388,11 @@ fn scalar_limbs(k: &F) -> [u64; 4] { limbs } -/// GLV split: `k = k1 + k2 * lambda (mod n)` with `|k1|`, `|k2|` strictly -/// below `2^127`, each half returned as `(is_negative, magnitude)`. -fn decompose(k: &C::ScalarExt) -> ((bool, u128), (bool, u128)) { - let kl = scalar_limbs(k); +/// GLV-splits the scalar integer encoded by `kl`: `k = k1 + k2 * lambda +/// (mod n)` with `|k1|`, `|k2|` strictly below `2^127`, each half returned as +/// `(is_negative, magnitude)`. +#[inline(always)] +fn decompose_limbs(kl: [u64; 4]) -> ((bool, u128), (bool, u128)) { let c1 = round_mul_shift(&C::G1, &kl); let c2 = round_mul_shift(&C::G2, &kl); // k1 = k - c1*V1A - c2*V2A (two's complement over 256 bits) @@ -387,6 +402,17 @@ fn decompose(k: &C::ScalarExt) -> ((bool, u128), (bool, u128)) { (signed_halves(k1), signed_halves(k2)) } +fn decompose(k: &C::ScalarExt) -> ((bool, u128), (bool, u128)) { + decompose_limbs::(scalar_limbs(k)) +} + +fn decompose_montgomery(k: &C::ScalarExt) -> ((bool, u128), (bool, u128)) { + decompose_limbs::(::scalar_montgomery_limbs( + k, + private::CrateToken(()), + )) +} + /// The eight Eisenstein digit-orbit representatives $\Delta$, as coefficient /// pairs $(a, b)$ of $a + b\omega$ (norms 1, 3, 7, 7, 9, 13, 13, 19). The /// 48 nonzero digits are $U\Delta$ for the six units @@ -1957,19 +1983,36 @@ pub(crate) fn try_multiexp( return Some(result); } + try_large_multiexp::(scalars, bases, num_threads, decompose::) +} + +fn try_large_multiexp( + scalars: &[C::ScalarExt], + bases: &[C::AffineExt], + num_threads: usize, + decompose_scalar: D, +) -> Option +where + C: GlvParams, + D: Fn(&C::ScalarExt) -> ((bool, u128), (bool, u128)), +{ + if scalars.len() < MIN_GLV_MULTIEXP_TERMS { + return None; + } + + #[cfg(not(feature = "orbits"))] + let window_bits = glv_multiexp_window_bits::(scalars.len(), num_threads)?; + + // Decompose once for the selected backend. With `orbits`, the planner + // also prices these exact component magnitudes. + let components = scalars + .iter() + .map(decompose_scalar) + .map(checked_signed_magnitudes) + .collect::>>()?; + #[cfg(feature = "orbits")] { - if scalars.len() < MIN_GLV_MULTIEXP_TERMS { - return None; - } - - // Decompose before planning: both backends consume the components, - // and the planner prices them by their actual magnitudes. - let components = scalars - .iter() - .map(decompose::) - .map(checked_signed_magnitudes) - .collect::>>()?; let profile = MagnitudeProfile::new(&components); let plan = plan_multiexp::(&profile, num_threads)?; match plan { @@ -1985,17 +2028,30 @@ pub(crate) fn try_multiexp( #[cfg(not(feature = "orbits"))] { - let window_bits = glv_multiexp_window_bits::(scalars.len(), num_threads)?; - let components = scalars - .iter() - .map(decompose::) - .map(checked_signed_magnitudes) - .collect::>>()?; let bases = multiexp_bases::(bases); multiexp(&components, &bases, window_bits, num_threads) } } +/// Attempts an exact identity test for a full-width Pasta MSM by interpreting +/// every scalar's internal Montgomery residue as its integer value. This +/// scales the ordinary MSM result by the Montgomery factor $R$. Because $R$ +/// is invertible modulo the prime group order, this preserves whether the +/// result is the identity while avoiding canonical scalar conversion. +pub(crate) fn try_multiexp_full_width_is_identity( + scalars: &[C::ScalarExt], + bases: &[C::AffineExt], +) -> Option { + assert_eq!(scalars.len(), bases.len()); + try_large_multiexp::( + scalars, + bases, + current_num_threads(), + decompose_montgomery::, + ) + .map(|sum| bool::from(sum.is_identity())) +} + /// The GLV digit window for one base point: the eight Eisenstein orbit /// representatives $[\Delta_i]P$ in affine coordinates, with each /// x-coordinate stored in all three $\zeta$-rotations (so applying the @@ -4046,6 +4102,48 @@ mod tests { ); } + fn full_width_identity_multiexp_at_boundary() { + for terms in [ + MIN_GLV_MULTIEXP_TERMS - 1, + MIN_GLV_MULTIEXP_TERMS, + MIN_GLV_MULTIEXP_TERMS + 1, + ] { + let (mut scalars, mut bases, expected) = verifier_multiexp_inputs::(terms - 1); + assert!(!bool::from(expected.is_identity())); + + // Complete the known sum to the identity while preserving the + // requested term count. + scalars.push(-C::ScalarExt::ONE); + bases.push(expected.to_affine()); + + let evaluate = |scalars: &[C::ScalarExt]| { + try_large_multiexp::(scalars, &bases, 1, decompose_montgomery::) + .map(|sum| bool::from(sum.is_identity())) + }; + + if terms < MIN_GLV_MULTIEXP_TERMS { + assert_eq!(evaluate(&scalars), None); + continue; + } + + assert_eq!(evaluate(&scalars), Some(true)); + + // Removing the cancelling term leaves the known nonidentity sum. + scalars[terms - 1] = C::ScalarExt::ZERO; + assert_eq!(evaluate(&scalars), Some(false)); + } + } + + #[test] + fn full_width_identity_multiexp_at_boundary_pallas() { + full_width_identity_multiexp_at_boundary::(); + } + + #[test] + fn full_width_identity_multiexp_at_boundary_vesta() { + full_width_identity_multiexp_at_boundary::(); + } + fn duplicate_base_multiexp_matches_expected() { let terms = VERIFIER_MULTIEXP_SIZES[0]; let scalar = scalars::(1) diff --git a/docs/changelog/unreleased/292.md b/docs/changelog/unreleased/292.md new file mode 100644 index 00000000..c14246a2 --- /dev/null +++ b/docs/changelog/unreleased/292.md @@ -0,0 +1,17 @@ +## zakura-pasta-curves + +### Added + +- Added `CurveExt::try_multiexp_full_width_is_identity_vartime`, an optional + exact variable-time identity test optimized for public full-width scalar + multiscalar multiplications + ([#292](https://github.com/zakura-core/common/pull/292)). + +## zakura-halo2-proofs + +### Changed + +- Built-in single and batch verifiers now use optimized full-width identity + multiscalar multiplications when supported, reducing measured verification + latency by about 1–2% + ([#292](https://github.com/zakura-core/common/pull/292)).