Merge ipa-prover-blinding-2026-04-28

This commit is contained in:
Hanzo AI
2026-04-28 00:54:27 -07:00
4 changed files with 351 additions and 9 deletions
+92
View File
@@ -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
}
+47 -5
View File
@@ -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) {
+8 -4
View File
@@ -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)
}
+204
View File
@@ -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)
}
}