diff --git a/banderwagon/multiexp.go b/banderwagon/multiexp.go index ed00293..09ffc55 100644 --- a/banderwagon/multiexp.go +++ b/banderwagon/multiexp.go @@ -7,6 +7,9 @@ package banderwagon import ( + "fmt" + "math/big" + "github.com/luxfi/crypto/ipa/bandersnatch" "github.com/luxfi/crypto/ipa/bandersnatch/fr" ) @@ -33,3 +36,92 @@ func (p *Element) MultiExp(points []Element, scalars []fr.Element, config MultiE return p, err } + +// qMinusTwo is q-2 where q is the Bandersnatch scalar-field modulus, used for +// Fermat-based inversion (a^(q-2) ≡ a^-1 mod q for a ≠ 0). Computing the +// inverse via Exp gives a constant-iteration ladder that does not branch on +// the secret scalar, in contrast to the variable-time extended-Euclidean +// algorithm used by fr.Element.Inverse. +var qMinusTwo = func() *big.Int { + q := fr.Modulus() + return new(big.Int).Sub(q, big.NewInt(2)) +}() + +// MultiExpBlinded computes the multi-exponentiation of points and scalars +// while masking the scalars from cache-timing side channels. Pippenger's +// window method (used by MultiExp) branches on scalar digits, so an attacker +// observing the cache trace can recover information about secret scalars +// passed by the prover (witness halves, blinding factors, opening points). +// +// To frustrate this attack we apply multiplicative randomization: +// +// 1. Sample a fresh random r ∈ Fr* per call (re-rolling on r=0). +// 2. Run MultiExp on (k_i · r) instead of k_i. The result is r · Σ k_i·P_i. +// 3. Multiply the result by r⁻¹ to recover Σ k_i·P_i. +// +// The trace observed by an attacker depends on (k_i · r) where r is fresh +// per call, so traces from repeated calls do not accumulate information +// about the underlying secret scalars k_i. r⁻¹ is computed via Fermat +// (r^(q-2)) so the inversion itself is also independent of secret data. +// +// The returned Element is identical to what MultiExp would produce on the +// same inputs (verified by the test suite); only the side-channel profile +// changes. +func (p *Element) MultiExpBlinded(points []Element, scalars []fr.Element, config MultiExpConfig) (*Element, error) { + if len(points) != len(scalars) { + return nil, fmt.Errorf("points and scalars must have equal length, %d != %d", len(points), len(scalars)) + } + + // Sample r ∈ Fr* using crypto/rand (fr.Element.SetRandom calls + // crypto/rand internally). SetRandom returns a *regular-form* element; + // the rest of the field-arithmetic API (Mul, Exp) assumes Montgomery + // form, so we explicitly convert. Re-roll on the negligible chance + // r = 0. + var r fr.Element + for { + if _, err := r.SetRandom(); err != nil { + return nil, fmt.Errorf("sampling blinding scalar: %w", err) + } + if !r.IsZero() { + break + } + } + r.ToMont() + + // Build (k_i · r) without mutating the caller's scalar slice. Both + // scalars[i] and r are in Montgomery form so the product is also + // Montgomery, matching the ScalarsMont: true expectation downstream. + blinded := make([]fr.Element, len(scalars)) + for i := range scalars { + blinded[i].Mul(&scalars[i], &r) + } + + // Run the underlying (variable-time) MSM on the blinded scalars. + if _, err := p.MultiExp(points, blinded, config); err != nil { + return nil, err + } + + // r⁻¹ = r^(q-2) mod q via Fermat's little theorem. Exp uses + // Square/Mul which both operate in Montgomery form, so feeding it + // Montgomery r yields Montgomery r⁻¹. Constant-iteration ladder, no + // branching on secret bits. + var rInv fr.Element + rInv.Exp(r, qMinusTwo) + + // Recover the unblinded result: P = (r · P) · r⁻¹. ScalarMul on + // banderwagon.Element expects a Montgomery-form scalar. + p.ScalarMul(p, &rInv) + + // gnark's scalarMulGLV produces a non-canonical projective form for + // the identity element (Y=0 instead of Y=1) which trips the early + // IsZero-and-IsZero short-circuit in Element.Equal. The MSM result + // being identity is observable from the compressed bytes (which are + // derived only from public IPA structure such as all-zero witness + // halves), so this normalization is not a side channel on r. Restore + // the canonical identity representation when needed. + if p.Bytes() == Identity.Bytes() { + *p = Identity + } + + return p, nil +} diff --git a/ipa/ipa/config.go b/ipa/ipa/config.go index 0ea69c4..d14be1f 100644 --- a/ipa/ipa/config.go +++ b/ipa/ipa/config.go @@ -46,6 +46,9 @@ func NewIPASettings() (*IPAConfig, error) { } // MultiScalar computes the multi scalar multiplication of points and scalars. +// This is the variable-time path; it MUST only be called with public scalars +// (verifier challenges, public commitments). Prover-side callers operating on +// secret witness data MUST use MultiScalarBlinded instead. func MultiScalar(points []banderwagon.Element, scalars []fr.Element) (banderwagon.Element, error) { var result banderwagon.Element result.SetIdentity() @@ -58,13 +61,42 @@ func MultiScalar(points []banderwagon.Element, scalars []fr.Element) (banderwago return *res, nil } -// Commit calculates the Pedersen Commitment of a polynomial polynomial -// in evaluation form using the SRS. -func (ic *IPAConfig) Commit(polynomial []fr.Element) banderwagon.Element { - return ic.PrecompMSM.MSM(polynomial) +// MultiScalarBlinded computes the multi scalar multiplication of points and +// scalars while masking the scalars from cache-timing side channels. Use this +// for any prover-side call where the scalars are secret (witness halves, +// blinding factors, polynomial openings). See banderwagon.MultiExpBlinded for +// the construction. +func MultiScalarBlinded(points []banderwagon.Element, scalars []fr.Element) (banderwagon.Element, error) { + var result banderwagon.Element + result.SetIdentity() + + res, err := result.MultiExpBlinded(points, scalars, banderwagon.MultiExpConfig{NbTasks: runtime.NumCPU(), ScalarsMont: true}) + if err != nil { + return banderwagon.Element{}, fmt.Errorf("blinded mult exponentiation was not successful: %w", err) + } + + return *res, nil } -// commit commits to a polynomial using the input group elements +// Commit calculates the Pedersen Commitment of a polynomial in evaluation +// form using the SRS. The polynomial coefficients are part of the prover's +// witness, so we route through the blinded MSM path. This sacrifices the +// PrecompMSM precomputed-table speedup in exchange for protection against +// cache-timing recovery of witness scalars. +func (ic *IPAConfig) Commit(polynomial []fr.Element) banderwagon.Element { + res, err := MultiScalarBlinded(ic.SRS, polynomial) + if err != nil { + // SRS length and polynomial length are both common.VectorLength by + // construction; a length mismatch here is a programmer error and the + // only other failure mode is crypto/rand exhaustion which is fatal. + panic(fmt.Sprintf("commit: blinded MSM failed: %v", err)) + } + return res +} + +// commit commits to a polynomial using the input group elements with the +// variable-time MSM path. ONLY use this when scalars are public (verifier +// side). Prover-side callers MUST use commitBlinded. func commit(groupElements []banderwagon.Element, polynomial []fr.Element) (banderwagon.Element, error) { if len(groupElements) != len(polynomial) { return banderwagon.Element{}, fmt.Errorf("group elements and polynomial are different sizes, %d != %d", len(groupElements), len(polynomial)) @@ -72,6 +104,16 @@ func commit(groupElements []banderwagon.Element, polynomial []fr.Element) (bande return MultiScalar(groupElements, polynomial) } +// commitBlinded commits to a polynomial using the input group elements via +// the blinded (cache-timing-masked) MSM path. Use this for every prover-side +// call site that operates on secret scalars. +func commitBlinded(groupElements []banderwagon.Element, polynomial []fr.Element) (banderwagon.Element, error) { + if len(groupElements) != len(polynomial) { + return banderwagon.Element{}, fmt.Errorf("group elements and polynomial are different sizes, %d != %d", len(groupElements), len(polynomial)) + } + return MultiScalarBlinded(groupElements, polynomial) +} + // InnerProd computes the inner product of a and b. func InnerProd(a []fr.Element, b []fr.Element) (fr.Element, error) { if len(a) != len(b) { diff --git a/ipa/ipa/prover.go b/ipa/ipa/prover.go index 3250f72..e7b6393 100644 --- a/ipa/ipa/prover.go +++ b/ipa/ipa/prover.go @@ -106,20 +106,24 @@ func CreateIPAProof(transcript *common.Transcript, ic *IPAConfig, commitment ban return IPAProof{}, fmt.Errorf("could not compute a_L*b_R inner product: %w", err) } - C_L_1, err := commit(G_L, a_R) + // All four MSMs below operate on secret prover-side scalars + // (witness halves a_L/a_R and the blinding-style scalars z_L/z_R), + // so we route through commitBlinded which masks the scalars from + // cache-timing side channels. See banderwagon.MultiExpBlinded. + C_L_1, err := commitBlinded(G_L, a_R) if err != nil { return IPAProof{}, fmt.Errorf("could not do G_L*a_R MSM: %w", err) } - C_L, err := commit([]banderwagon.Element{C_L_1, q}, []fr.Element{fr.One(), z_L}) + C_L, err := commitBlinded([]banderwagon.Element{C_L_1, q}, []fr.Element{fr.One(), z_L}) if err != nil { return IPAProof{}, fmt.Errorf("could not do C_L_1+z_L*q MSM: %w", err) } - C_R_1, err := commit(G_R, a_L) + C_R_1, err := commitBlinded(G_R, a_L) if err != nil { return IPAProof{}, fmt.Errorf("could not do G_R*a_L MSM: %w", err) } - C_R, err := commit([]banderwagon.Element{C_R_1, q}, []fr.Element{fr.One(), z_R}) + C_R, err := commitBlinded([]banderwagon.Element{C_R_1, q}, []fr.Element{fr.One(), z_R}) if err != nil { return IPAProof{}, fmt.Errorf("could not do C_R_1+z_R*q MSM: %w", err) } diff --git a/ipa/ipa/prover_blinding_test.go b/ipa/ipa/prover_blinding_test.go new file mode 100644 index 0000000..c668144 --- /dev/null +++ b/ipa/ipa/prover_blinding_test.go @@ -0,0 +1,204 @@ +// Copyright (C) 2026 Lux Industries Inc. All rights reserved. +// Licensed under Apache-2.0 OR MIT. +// +// Tests for the scalar-blinding wrapper around banderwagon.MultiExp. +// Two properties are checked: +// +// 1. Correctness: MultiExpBlinded(points, scalars) must produce the same +// group element as MultiExp(points, scalars) on identical inputs. This +// is a hard equality requirement — the blinding must cancel exactly. +// 2. Timing variance: across many invocations on the same scalar input, +// the blinded path should exhibit non-trivial wall-clock variance +// because a fresh r is drawn per call. This is an indication, not a +// proof, that the trace observed by an attacker depends on r rather +// than on the (constant) underlying scalars. + +package ipa + +import ( + "math" + "testing" + "time" + + "github.com/luxfi/crypto/banderwagon" + "github.com/luxfi/crypto/ipa/bandersnatch/fr" + "github.com/luxfi/crypto/ipa/common" +) + +// randomScalars returns n random Fr elements in Montgomery form (matching +// the convention used by every prover-side caller and the ScalarsMont: true +// flag passed to MultiExp). +func randomScalars(t *testing.T, n int) []fr.Element { + t.Helper() + s := make([]fr.Element, n) + for i := range s { + if _, err := s[i].SetRandom(); err != nil { + t.Fatalf("SetRandom: %v", err) + } + // SetRandom returns a regular-form element; convert to Montgomery + // form so subsequent arithmetic and MultiExp(ScalarsMont: true) + // see consistent representation. + s[i].ToMont() + } + return s +} + +// TestMultiExpBlinded_Correctness verifies that the blinded MSM produces +// the same group element as the unblinded MSM across 100 random +// (points, scalars) pairs at multiple input lengths. This is the +// load-bearing property: the blinding must cancel exactly so that proofs +// produced through the blinded path verify under the unblinded verifier. +func TestMultiExpBlinded_Correctness(t *testing.T) { + t.Parallel() + + // Use the SRS as a convenient source of group elements. The SRS is + // public and 256 elements long, matching common.VectorLength. + if ipaConf == nil { + t.Fatal("ipaConf not initialized; TestMain should have set it") + } + srs := ipaConf.SRS + + // Sweep a few input sizes, including the 2-element shape used by the + // four prover-side commitBlinded calls and the full vector length used + // by IPAConfig.Commit. + sizes := []int{2, 4, 8, 64, 128, int(common.VectorLength)} + + totalChecks := 0 + for _, sz := range sizes { + points := srs[:sz] + // 100 random scalar vectors per size. + for trial := 0; trial < 100; trial++ { + scalars := randomScalars(t, sz) + + var unblinded banderwagon.Element + unblinded.SetIdentity() + if _, err := unblinded.MultiExp(points, scalars, banderwagon.MultiExpConfig{ScalarsMont: true}); err != nil { + t.Fatalf("size=%d trial=%d MultiExp: %v", sz, trial, err) + } + + var blinded banderwagon.Element + blinded.SetIdentity() + if _, err := blinded.MultiExpBlinded(points, scalars, banderwagon.MultiExpConfig{ScalarsMont: true}); err != nil { + t.Fatalf("size=%d trial=%d MultiExpBlinded: %v", sz, trial, err) + } + + if !blinded.Equal(&unblinded) { + t.Fatalf("size=%d trial=%d: blinded result != unblinded result", sz, trial) + } + totalChecks++ + } + } + t.Logf("verified %d blinded-vs-unblinded equality checks across %d input sizes", totalChecks, len(sizes)) +} + +// TestMultiExpBlinded_DoesNotMutateInputs ensures the helper never modifies +// the caller's scalar slice. The IPA prover passes witness halves directly, +// so silent mutation would corrupt the proof transcript. +func TestMultiExpBlinded_DoesNotMutateInputs(t *testing.T) { + t.Parallel() + + const n = 64 + points := ipaConf.SRS[:n] + scalars := randomScalars(t, n) + + // Snapshot. + snapshot := make([]fr.Element, n) + copy(snapshot, scalars) + + var p banderwagon.Element + p.SetIdentity() + if _, err := p.MultiExpBlinded(points, scalars, banderwagon.MultiExpConfig{ScalarsMont: true}); err != nil { + t.Fatalf("MultiExpBlinded: %v", err) + } + + for i := range scalars { + if !scalars[i].Equal(&snapshot[i]) { + t.Fatalf("scalar[%d] mutated by MultiExpBlinded", i) + } + } +} + +// TestMultiExpBlinded_LengthMismatch checks the explicit length-equality +// guard so that a wiring bug at a call site fails loudly instead of being +// papered over by the underlying MSM panicking. +func TestMultiExpBlinded_LengthMismatch(t *testing.T) { + t.Parallel() + + points := ipaConf.SRS[:4] + scalars := randomScalars(t, 5) + + var p banderwagon.Element + p.SetIdentity() + _, err := p.MultiExpBlinded(points, scalars, banderwagon.MultiExpConfig{ScalarsMont: true}) + if err == nil { + t.Fatal("expected length-mismatch error, got nil") + } +} + +// TestMultiExpBlinded_TimingVariance is an indication-not-proof that +// MultiExpBlinded introduces per-call variance. We time 1000 iterations on a +// fixed scalar input and assert the standard deviation exceeds a small +// threshold (0.1% of the mean). Pippenger on identical inputs is highly +// repeatable, so without blinding the variance would collapse to noise from +// scheduling alone. A meaningful std-dev indicates the trace differs per +// call due to the fresh r. +// +// This is not a side-channel proof. It is a smoke test that the masking +// path is actually being exercised. Real evaluation must use a hardware +// cache-timing rig. +func TestMultiExpBlinded_TimingVariance(t *testing.T) { + if testing.Short() { + t.Skip("skipping timing variance test in -short mode") + } + t.Parallel() + + const n = 32 + const iterations = 1000 + + points := ipaConf.SRS[:n] + scalars := randomScalars(t, n) + + // Warm-up to amortize first-touch costs. + for i := 0; i < 20; i++ { + var p banderwagon.Element + p.SetIdentity() + if _, err := p.MultiExpBlinded(points, scalars, banderwagon.MultiExpConfig{ScalarsMont: true}); err != nil { + t.Fatalf("warmup: %v", err) + } + } + + samples := make([]float64, iterations) + for i := 0; i < iterations; i++ { + var p banderwagon.Element + p.SetIdentity() + start := time.Now() + if _, err := p.MultiExpBlinded(points, scalars, banderwagon.MultiExpConfig{ScalarsMont: true}); err != nil { + t.Fatalf("iter %d: %v", i, err) + } + samples[i] = float64(time.Since(start).Nanoseconds()) + } + + mean := 0.0 + for _, s := range samples { + mean += s + } + mean /= float64(iterations) + + variance := 0.0 + for _, s := range samples { + d := s - mean + variance += d * d + } + variance /= float64(iterations) + std := math.Sqrt(variance) + + t.Logf("MultiExpBlinded over %d iters: mean=%.0fns std=%.0fns (cv=%.2f%%)", iterations, mean, std, 100*std/mean) + + // We expect a non-trivial std-dev. If std is tiny relative to the mean + // either the blinding is being optimized away or the timing rig is + // unable to resolve it — either way, flag it. Threshold deliberately + // loose; this is an indication, not a constant-time proof. + if std < mean*0.001 { + t.Fatalf("std-dev %.0fns is suspiciously small relative to mean %.0fns; blinding may not be exercised", std, mean) + } +}