mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
Merge ipa-prover-blinding-2026-04-28
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user