From 69d026ed109d0f54b45451e63bb61f8b11d29003 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Wed, 30 Sep 2026 08:21:34 +0000
Subject: [PATCH] fix(proofs): bound unknown-order verification
---
common/validation.go | 32 +++-
crypto/dlnproof/proof.go | 13 +-
crypto/mta/constant_time_equiv_test.go | 6 +-
.../mta/legacy_bob_historical_witness_test.go | 21 +--
crypto/mta/proofs.go | 53 +++---
crypto/mta/range_proof.go | 12 +-
crypto/mta/share_protocol.go | 2 +-
crypto/paillier/constant_time_equiv_test.go | 43 +++++
crypto/paillier/factor_proof.go | 14 +-
crypto/paillier/mod_proof.go | 163 ++++++++++++------
crypto/paillier/mod_proof_test.go | 71 ++++----
crypto/paillier/paillier.go | 39 ++---
12 files changed, 304 insertions(+), 165 deletions(-)
diff --git a/common/validation.go b/common/validation.go
index 3cf09e145..594070c70 100644
--- a/common/validation.go
+++ b/common/validation.go
@@ -8,7 +8,37 @@ package common
import "math/big"
-const primalityRounds = 30
+const (
+ primalityRounds = 30
+ // MinUnknownOrderModulusBitLen is the width floor of every unknown-order
+ // modulus this library's exported verifiers will accept. The wire path
+ // pins a peer's modulus to exactly 2048 bits before any proof is
+ // verified; below 2048 bits the secp256k1-scale challenge space (2^256)
+ // no longer dominates the modulus, and no supported key can have a
+ // narrower unknown-order modulus.
+ MinUnknownOrderModulusBitLen = 2048
+
+ // MaxUnknownOrderModulusBitLen is a resource ceiling on the width of an
+ // unknown-order modulus the exported verifiers will accept. Everything a
+ // verifier does with a caller-supplied modulus -- the ProbablyPrime call
+ // below, any modular reduction, GCD, sampler and per-candidate
+ // exponentiation -- is O(bitLen), so without a ceiling a direct API call
+ // allocates and hashes proportional to a caller-controlled width. This
+ // ceiling is 32x the 2048-bit wire-pinned width, so it excludes nothing
+ // this library's wire path can produce; it only bounds work an
+ // untrusted caller can force through an exported verify entry point.
+ MaxUnknownOrderModulusBitLen = 65536
+)
+
+// ExceedsUnknownOrderModulusCeiling reports whether N is wider than
+// MaxUnknownOrderModulusBitLen. It is a standalone O(bitLen) predicate (no
+// primality work, no modulus-sized allocation) so exported unknown-order
+// verifiers can reject oversized caller-supplied moduli before
+// IsUsableUnknownOrderModulus's ProbablyPrime call or any other
+// modulus-sized work runs against them.
+func ExceedsUnknownOrderModulusCeiling(N *big.Int) bool {
+ return N != nil && N.BitLen() > MaxUnknownOrderModulusBitLen
+}
func IsUsableUnknownOrderModulus(N *big.Int, minBitLen int) bool {
return N != nil &&
diff --git a/crypto/dlnproof/proof.go b/crypto/dlnproof/proof.go
index 71d676065..03de2511f 100644
--- a/crypto/dlnproof/proof.go
+++ b/crypto/dlnproof/proof.go
@@ -19,9 +19,8 @@ import (
)
const (
- Iterations = 128
- verifyMinModulusBitLen = 2048
- fsDomainTagDLNProof = "tss-lib.threshold.dlnproof"
+ Iterations = 128
+ fsDomainTagDLNProof = "tss-lib.threshold.dlnproof"
)
func fsSessionDLNProof(session []byte) []byte {
@@ -87,7 +86,13 @@ func (p *Proof) Verify(h1, h2, N *big.Int, session ...[]byte) bool {
if p == nil {
return false
}
- if !common.IsUsableUnknownOrderModulus(N, verifyMinModulusBitLen) {
+ // Width policy: reject a caller-supplied modulus wider than the shared
+ // ceiling before any ProbablyPrime call or per-candidate modular
+ // exponentiation is run against it.
+ if common.ExceedsUnknownOrderModulusCeiling(N) {
+ return false
+ }
+ if !common.IsUsableUnknownOrderModulus(N, common.MinUnknownOrderModulusBitLen) {
return false
}
modN := common.ModInt(N)
diff --git a/crypto/mta/constant_time_equiv_test.go b/crypto/mta/constant_time_equiv_test.go
index 6e7ecae70..2476ab340 100644
--- a/crypto/mta/constant_time_equiv_test.go
+++ b/crypto/mta/constant_time_equiv_test.go
@@ -163,7 +163,7 @@ func TestBobProofUnequalWidthsCTEquivalence(t *testing.T) {
//
// The protocol's internal Verify calls must complete, so the keygen fixture's ~2048-bit
// auxiliary moduli are used instead of #10's tiny 11*23 modulus, which the current
-// hardened verifiers reject (verifyMinModulusBitLen floor). The witness betaPrm is a
+// hardened verifiers reject (MinUnknownOrderModulusBitLen floor). The witness betaPrm is a
// q5-width random that can approach the public plaintext domain width; it is a valid
// Paillier plaintext and the homomorphic result does not wrap the domain.
func TestShareProtocolUnequalWidthsCTEquivalence(t *testing.T) {
@@ -177,8 +177,8 @@ func TestShareProtocolUnequalWidthsCTEquivalence(t *testing.T) {
require.NoError(t, err)
// The fixture auxiliary moduli must clear the verifier floor so the full share
// protocol's internal Verify calls complete.
- require.True(t, common.IsUsableUnknownOrderModulus(NTildei, verifyMinModulusBitLen), "fixture auxiliary modulus must clear the verifier floor")
- require.True(t, common.IsUsableUnknownOrderModulus(NTildej, verifyMinModulusBitLen), "fixture auxiliary modulus must clear the verifier floor")
+ require.True(t, common.IsUsableUnknownOrderModulus(NTildei, common.MinUnknownOrderModulusBitLen), "fixture auxiliary modulus must clear the verifier floor")
+ require.True(t, common.IsUsableUnknownOrderModulus(NTildej, common.MinUnknownOrderModulusBitLen), "fixture auxiliary modulus must clear the verifier floor")
a, b := big.NewInt(1<<24+3), big.NewInt(1<<32+5)
B := crypto.ScalarBaseMult(tss.EC(), b)
diff --git a/crypto/mta/legacy_bob_historical_witness_test.go b/crypto/mta/legacy_bob_historical_witness_test.go
index e8136b946..0719afd77 100644
--- a/crypto/mta/legacy_bob_historical_witness_test.go
+++ b/crypto/mta/legacy_bob_historical_witness_test.go
@@ -88,15 +88,10 @@ func TestLegacyBobWidenedVerifierAcceptsHistoricalWitnessRange(t *testing.T) {
q5 := new(big.Int).Exp(q, big.NewInt(5), nil)
q6 := new(big.Int).Mul(q5, q)
tightMaxT1 := new(big.Int).Add(pk.N, q6)
- widenedMaxT1 := legacyT1Max(ec, pk, true)
+ widenedMaxT1 := new(big.Int).Mul(new(big.Int).Add(q, big.NewInt(1)), pk.N)
// The widened bound must be exactly the historical witness-range bound:
// T1 = e*y + gamma with e < q, y < N, gamma < N gives T1 < q*N + N.
- if widenedMaxT1.Cmp(new(big.Int).Mul(new(big.Int).Add(q, big.NewInt(1)), pk.N)) != 0 {
- t.Fatal("widened legacy T1 bound is not (q+1)*N")
- }
- if legacyT1Max(ec, pk, false).Cmp(tightMaxT1) != 0 {
- t.Fatal("tight legacy T1 bound is not N + q^6")
- }
+ // (The verifier computes it inline in VerifyLegacy when compat is on.)
X := crypto.ScalarBaseMult(ec, x)
cases := []struct {
@@ -168,9 +163,11 @@ func TestLegacyHistoricalWitnessRangeRejectsWiderT1(t *testing.T) {
c1 := fixedPaillierEncryption(pk, big.NewInt(5), firstSmallUnit(pk.N, 3))
y := new(big.Int).Sub(pk.N, big.NewInt(1))
bobProof, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, nil)
-
- if bobProof.T1.Cmp(legacyT1Max(ec, pk, false)) < 0 {
- t.Fatal("y = N - 1 witness did not place T1 above the tight bound; the y = N-T wrap family is not exercised")
+ // The shared verify core owns the default tight legacy bound: N + q^6.
+ q6 := new(big.Int).Exp(ec.Params().N, big.NewInt(6), nil)
+ tightT1Max := new(big.Int).Add(pk.N, q6)
+ if bobProof.T1.Cmp(tightT1Max) < 0 {
+ t.Fatal("y = N - 1 witness did not place T1 above the tight N + q^6 bound; the y = N-T wrap family is not exercised")
}
if _, err := AliceEnd(ec, pk, bobProof.ProofBob, aux.H1i, aux.H2i, c1, c2, aux.NTildei, owner.PaillierSK); err == nil {
t.Fatal("default AliceEnd accepted a proof with T1 above the tight bound")
@@ -195,8 +192,8 @@ func TestLegacyHistoricalWitnessRangeRejectsWiderT1(t *testing.T) {
X := crypto.ScalarBaseMult(ec, x)
wcProof, _ := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, X)
- if wcProof.T1.Cmp(legacyT1Max(ec, pk, false)) < 0 {
- t.Fatal("y = N - 1 WC witness did not place T1 above the tight bound; the y = N-T wrap family is not exercised")
+ if wcProof.T1.Cmp(tightT1Max) < 0 {
+ t.Fatal("y = N - 1 WC witness did not place T1 above the tight N + q^6 bound; the y = N-T wrap family is not exercised")
}
if _, err := AliceEndWC(ec, pk, wcProof, X, c1, c2, aux.NTildei, aux.H1i, aux.H2i, owner.PaillierSK); err == nil {
t.Fatal("default AliceEndWC accepted a proof with T1 above the tight bound")
diff --git a/crypto/mta/proofs.go b/crypto/mta/proofs.go
index db276f01f..8c30c1a95 100644
--- a/crypto/mta/proofs.go
+++ b/crypto/mta/proofs.go
@@ -230,32 +230,15 @@ func (pf *ProofBobWC) Verify(ec elliptic.Curve, pk *paillier.PublicKey, NTilde,
return pf.verify(ec, pk, NTilde, h1, h2, c1, c2, X, optionalProofSession(session), nil)
}
-// legacyT1Max returns the exclusive T1 upper bound for the session-less
-// legacy Bob/BobWC verifier.
-//
-// The tight bound N + q^6 admits this implementation's own legacy prover:
-// T1 = e*y + gamma with e < q, y < q^5 and gamma < N, so an honest legacy
-// response is below q^6 + N.
-//
-// The widened bound (q+1)*N admits the historical (2e712689) BobMid/BobMidWC
-// witness range, which samples y below the Paillier modulus: T1 = e*y + gamma
-// with e < q, y < N, gamma < N, hence T1 < (q-1)*N + N < (q+1)*N.
-func legacyT1Max(ec elliptic.Curve, pk *paillier.PublicKey, historicalBobCompat bool) *big.Int {
- if historicalBobCompat {
- return new(big.Int).Mul(new(big.Int).Add(ec.Params().N, one), pk.N)
- }
- q := ec.Params().N
- q3 := new(big.Int).Mul(q, q)
- q3 = new(big.Int).Mul(q, q3)
- q6 := new(big.Int).Mul(q3, q3)
- return new(big.Int).Add(pk.N, q6)
-}
-
// VerifyLegacy verifies a session-less legacy Bob/BobWC proof (the exact
// 2e712689 untagged challenge). It is the explicit compatibility-aware entry
-// point used by signing round 3 for legacy parties: the default bound is the
-// tight N + q^6 cap, and historicalBobCompat widens it to the historical
-// witness-range bound (q+1)*N described on legacyT1Max.
+// point used by signing round 3 for legacy parties. The shared verify core
+// owns the default tight legacy bound, N + q^6: the session-less prover
+// samples y below q^5 with T1 = e*y + gamma, e < q, gamma < N, so an honest
+// legacy response stays below q^6 + N. historicalBobCompat passes the
+// widened historical witness-range bound (q+1)*N as an explicit override,
+// computed and passed only in that case; every other entry point gets the
+// core's tight bound.
func (pf *ProofBobWC) VerifyLegacy(
ec elliptic.Curve,
pk *paillier.PublicKey,
@@ -263,7 +246,14 @@ func (pf *ProofBobWC) VerifyLegacy(
X *crypto.ECPoint,
historicalBobCompat bool,
) bool {
- return pf.verify(ec, pk, NTilde, h1, h2, c1, c2, X, nil, legacyT1Max(ec, pk, historicalBobCompat))
+ var maxT1Override *big.Int
+ if historicalBobCompat {
+ // The historical (2e712689) prover samples y below the Paillier
+ // modulus: T1 = e*y + gamma with e < q, y < N, gamma < N, hence
+ // T1 < (q-1)*N + N < (q+1)*N.
+ maxT1Override = new(big.Int).Mul(new(big.Int).Add(ec.Params().N, one), pk.N)
+ }
+ return pf.verify(ec, pk, NTilde, h1, h2, c1, c2, X, nil, maxT1Override)
}
// verify is the shared Bob/BobWC verification core. maxT1Override, when
@@ -290,8 +280,15 @@ func (pf *ProofBobWC) verify(
} else if !pf.ProofBob.ValidateBasic() {
return false
}
- if !common.IsUsableUnknownOrderModulus(pk.N, verifyMinModulusBitLen) ||
- !common.IsUsableUnknownOrderModulus(NTilde, verifyMinModulusBitLen) {
+ // Width policy: reject caller-supplied moduli wider than the shared
+ // ceiling before IsUsableUnknownOrderModulus's ProbablyPrime call or any
+ // modulus-sized work is run against them.
+ if common.ExceedsUnknownOrderModulusCeiling(pk.N) ||
+ common.ExceedsUnknownOrderModulusCeiling(NTilde) {
+ return false
+ }
+ if !common.IsUsableUnknownOrderModulus(pk.N, common.MinUnknownOrderModulusBitLen) ||
+ !common.IsUsableUnknownOrderModulus(NTilde, common.MinUnknownOrderModulusBitLen) {
return false
}
if !common.IsCanonicalGenerator(NTilde, h1) || !common.IsCanonicalGenerator(NTilde, h2) || h1.Cmp(h2) == 0 {
@@ -578,7 +575,7 @@ func (pf *ProofBob) Verify(ec elliptic.Curve, pk *paillier.PublicKey, NTilde, h1
// ProveBob.VerifyLegacy is the explicit compatibility-aware session-less
// counterpart of ProveBob.Verify: historicalBobCompat widens the T1 bound to
-// the historical witness range per legacyT1Max; the prover and every other
+// the historical witness range via VerifyLegacy's (q+1)*N override; the prover and every other
// check are unchanged.
func (pf *ProofBob) VerifyLegacy(
ec elliptic.Curve,
diff --git a/crypto/mta/range_proof.go b/crypto/mta/range_proof.go
index d3da75349..c30a34580 100644
--- a/crypto/mta/range_proof.go
+++ b/crypto/mta/range_proof.go
@@ -18,7 +18,6 @@ import (
const (
RangeProofAliceBytesParts = 6
- verifyMinModulusBitLen = 2048
fsDomainTagRangeAlice = "tss-lib.threshold.mta.range-alice"
fsDomainTagBob = "tss-lib.threshold.mta.bob"
fsDomainTagBobWC = "tss-lib.threshold.mta.bob-wc"
@@ -136,8 +135,15 @@ func (pf *RangeProofAlice) Verify(ec elliptic.Curve, pk *paillier.PublicKey, NTi
NTilde == nil || h1 == nil || h2 == nil || c == nil {
return false
}
- if !common.IsUsableUnknownOrderModulus(pk.N, verifyMinModulusBitLen) ||
- !common.IsUsableUnknownOrderModulus(NTilde, verifyMinModulusBitLen) {
+ // Width policy: reject caller-supplied moduli wider than the shared
+ // ceiling before any ProbablyPrime call or modulus-sized work is run
+ // against them.
+ if common.ExceedsUnknownOrderModulusCeiling(pk.N) ||
+ common.ExceedsUnknownOrderModulusCeiling(NTilde) {
+ return false
+ }
+ if !common.IsUsableUnknownOrderModulus(pk.N, common.MinUnknownOrderModulusBitLen) ||
+ !common.IsUsableUnknownOrderModulus(NTilde, common.MinUnknownOrderModulusBitLen) {
return false
}
if !common.IsCanonicalGenerator(NTilde, h1) || !common.IsCanonicalGenerator(NTilde, h2) || h1.Cmp(h2) == 0 {
diff --git a/crypto/mta/share_protocol.go b/crypto/mta/share_protocol.go
index 4ac7120f0..dacb96d13 100644
--- a/crypto/mta/share_protocol.go
+++ b/crypto/mta/share_protocol.go
@@ -143,7 +143,7 @@ func AliceEndWC(
// AliceEndLegacy verifies a session-less legacy Bob proof using the legacy
// T1 bound, then decrypts cB. historicalBobCompat widens the T1 bound to the
-// historical (2e712689) witness range per legacyT1Max; the default (false)
+// historical (2e712689) witness range via VerifyLegacy's (q+1)*N override;
// keeps the tight N + q^6 bound. This is the signing round-3 entry point for
// legacy parties; AliceEnd is unchanged for standalone callers.
func AliceEndLegacy(
diff --git a/crypto/paillier/constant_time_equiv_test.go b/crypto/paillier/constant_time_equiv_test.go
index 336a7515c..985b14f07 100644
--- a/crypto/paillier/constant_time_equiv_test.go
+++ b/crypto/paillier/constant_time_equiv_test.go
@@ -432,3 +432,46 @@ func TestModProofHelpersCTEquivalence(t *testing.T) {
assert.Equal(t, qrCompOff, qrCompOn, "isQuadResidueModComposite must agree")
assert.Zero(t, rootOff.Cmp(rootOn), "quadResidueModComposite must be byte-identical")
}
+
+// TestModProofCTEquivalence: ModProof is randomised only through w; replaying
+// the entropy stream makes every derived challenge and 4th root deterministic,
+// so CT-on and CT-off proofs must be byte-identical and both must verify.
+func TestModProofCTEquivalence(t *testing.T) {
+ facSetUp(t)
+
+ var proofOff, proofOn *ModProof
+ for _, enabled := range []bool{false, true} {
+ t.Run(fmt.Sprintf("CT=%t", enabled), func(t *testing.T) {
+ previousReader := rand.Reader
+ t.Cleanup(func() {
+ rand.Reader = previousReader
+ })
+ rand.Reader = mathrand.New(mathrand.NewSource(42))
+ setPaillierCTTestMode(t, enabled)
+ proof := privateKey.ModProof()
+ if enabled {
+ proofOn = proof
+ } else {
+ proofOff = proof
+ }
+ })
+ }
+
+ require.NotNil(t, proofOff)
+ require.NotNil(t, proofOn)
+ require.Zero(t, proofOff.W.Cmp(proofOn.W), "w must be identical")
+ for i := range PARAM_M {
+ require.Zero(t, proofOff.X[i].Cmp(proofOn.X[i]), "X[%d] must be byte-identical", i)
+ require.Zero(t, proofOff.Z[i].Cmp(proofOn.Z[i]), "Z[%d] must be byte-identical", i)
+ require.Equal(t, proofOff.A[i], proofOn.A[i], "A[%d] must match", i)
+ require.Equal(t, proofOff.B[i], proofOn.B[i], "B[%d] must match", i)
+ }
+
+ ok, err := proofOff.ModVerify(publicKey.N)
+ assert.NoError(t, err)
+ assert.True(t, ok, "proofOff must verify")
+
+ ok, err = proofOn.ModVerify(publicKey.N)
+ assert.NoError(t, err)
+ assert.True(t, ok, "proofOn must verify")
+}
diff --git a/crypto/paillier/factor_proof.go b/crypto/paillier/factor_proof.go
index 315ba97b7..e0860a2c3 100644
--- a/crypto/paillier/factor_proof.go
+++ b/crypto/paillier/factor_proof.go
@@ -107,10 +107,20 @@ func (pf FactorProof) FactorVerify(pkN, N, s, t *big.Int, session ...[]byte) (bo
if common.AnyIsNil(pf.P, pf.Q, pf.A, pf.B, pf.T, pf.Sigma, pf.Z1, pf.Z2, pf.W1, pf.W2, pf.V) {
return false, fmt.Errorf("fac proof verify: nil bigint present in proof")
}
- if !common.IsUsableUnknownOrderModulus(pkN, verifyMinModulusBitLen) {
+ // Width policy: reject moduli wider than the shared ceiling before any
+ // ProbablyPrime call or modulus-sized work is run against them.
+ if common.ExceedsUnknownOrderModulusCeiling(pkN) {
+ return false, fmt.Errorf("fac proof verify: Paillier modulus bit length %d exceeds maximum %d",
+ pkN.BitLen(), common.MaxUnknownOrderModulusBitLen)
+ }
+ if common.ExceedsUnknownOrderModulusCeiling(N) {
+ return false, fmt.Errorf("fac proof verify: auxiliary modulus bit length %d exceeds maximum %d",
+ N.BitLen(), common.MaxUnknownOrderModulusBitLen)
+ }
+ if !common.IsUsableUnknownOrderModulus(pkN, common.MinUnknownOrderModulusBitLen) {
return false, fmt.Errorf("fac proof verify: invalid Paillier modulus %x", pkN)
}
- if !common.IsUsableUnknownOrderModulus(N, verifyMinModulusBitLen) {
+ if !common.IsUsableUnknownOrderModulus(N, common.MinUnknownOrderModulusBitLen) {
return false, fmt.Errorf("fac proof verify: invalid auxiliary modulus %x", N)
}
for name, base := range map[string]*big.Int{
diff --git a/crypto/paillier/mod_proof.go b/crypto/paillier/mod_proof.go
index 02d385872..5d4fa746a 100644
--- a/crypto/paillier/mod_proof.go
+++ b/crypto/paillier/mod_proof.go
@@ -2,6 +2,7 @@ package paillier
import (
"encoding/binary"
+ "errors"
"fmt"
"math/big"
@@ -13,6 +14,10 @@ const (
fsDomainTagModProof = "tss-lib.threshold.modproof"
)
+// errModulusCeiling distinguishes the shared width-ceiling rejection in
+// package tests without making error text or a new exported API contractual.
+var errModulusCeiling = errors.New("unknown-order modulus exceeds the shared ceiling")
+
func fsSessionModProof(session []byte) []byte {
return append([]byte(fsDomainTagModProof+"|"), session...)
}
@@ -52,23 +57,26 @@ func (privateKey *PrivateKey) ModProof(session ...[]byte) *ModProof {
// requires an odd modulus); it is a prover-side value, never transmitted. Only the
// Exp mod N (odd) below carries the secret exponent and gets the constant-time path.
invN := new(big.Int).ModInverse(N, phiN)
- // Use one snapshot for context creation and every use, even if the global
+ // One snapshot for context creation and every use, even if the global
// toggle changes while this proof is being generated.
useCT := common.IsConstantTimeEnabled()
- var ctModN *common.CTModInt
- if useCT {
- ctModN = common.NewCTModInt(N)
- }
+ // Build the p/q/N constant-time contexts and the secret-derived fixed
+ // exponents once, reused across all PARAM_M iterations. Each bigmod
+ // modulus and byte pool is otherwise re-created inside every QR
+ // predicate and root computation; the exponents are fixed for the key,
+ // so recomputing them per iteration only adds setup work without
+ // changing any proof byte.
+ ctx := newModProofCTContext(p, q, N, phiN, useCT)
for i, y_i := range y {
- a_i, b_i, x_i := defineXi(w, y_i, p, q, N, phiN, useCT)
+ a_i, b_i, x_i := ctx.defineXi(w, y_i, p, q, N)
x[i] = x_i
a[i] = a_i
b[i] = b_i
var z_i *big.Int
if useCT {
- z_i = ctModN.ExpCT(y_i, invN)
+ z_i = ctx.ctN.ExpCT(y_i, invN)
} else {
z_i = new(big.Int).Exp(y_i, invN, N)
}
@@ -85,15 +93,6 @@ func (privateKey *PrivateKey) ModProof(session ...[]byte) *ModProof {
}
}
-// exceedsModulusBitLenCeiling reports whether N is wider than
-// verifyMaxModulusBitLen; see that constant for why the bound exists. It is
-// a standalone predicate (rather than inlined in ModVerify) so the boundary
-// can be pinned directly in tests without depending on ModVerify's error
-// wording or measuring allocation.
-func exceedsModulusBitLenCeiling(N *big.Int) bool {
- return N != nil && N.BitLen() > verifyMaxModulusBitLen
-}
-
// Verification: Accept iff all of the following hold:
// – N is an odd composite number.
// – z_i^N = y_i for every i ∈ [m]
@@ -103,11 +102,14 @@ func (pf ModProof) ModVerify(N *big.Int, session ...[]byte) (bool, error) {
return false, fmt.Errorf("mod proof verify: nil inputs in proof")
}
- if exceedsModulusBitLenCeiling(N) {
- return false, fmt.Errorf("mod proof verify: modulus bit length %d exceeds maximum %d", N.BitLen(), verifyMaxModulusBitLen)
+ // Width policy: reject a caller-supplied modulus wider than the shared
+ // ceiling before IsUsableUnknownOrderModulus's ProbablyPrime call, the
+ // sampler and any per-candidate exponentiation is run against it.
+ if common.ExceedsUnknownOrderModulusCeiling(N) {
+ return false, fmt.Errorf("mod proof verify: modulus bit length %d exceeds maximum %d: %w", N.BitLen(), common.MaxUnknownOrderModulusBitLen, errModulusCeiling)
}
- if !common.IsUsableUnknownOrderModulus(N, verifyMinModulusBitLen) {
+ if !common.IsUsableUnknownOrderModulus(N, common.MinUnknownOrderModulusBitLen) {
return false, fmt.Errorf("mod proof verify: invalid modulus %d", N)
}
@@ -213,9 +215,73 @@ func sampleYModN(tag []byte, N *big.Int, inputs ...*big.Int) *big.Int {
}
}
-// Determine values a_i and b_i so that a valid x_i exists,
-// and return a_i, b_i and x_i.
-func defineXi(w, y_i, p, q, N, phiN *big.Int, useCT bool) (bool, bool, *big.Int) {
+// modProofCTContext is the precomputed state ModProof reuses across all
+// PARAM_M iterations: the constant-time modular contexts for p, q and N,
+// plus the secret-derived exponents fixed for the key ((p-1)/2, (q-1)/2
+// and the fourth-root exponent (phiN+4)/8). Without this context every
+// quadratic-residue check and fourth-root computation re-created its
+// bigmod modulus and byte pool; the proof bytes are identical either way,
+// so the precomputed form only removes repeated setup from the hot path.
+type modProofCTContext struct {
+ useCT bool
+ ctP *common.CTModInt
+ ctQ *common.CTModInt
+ ctN *common.CTModInt
+
+ psP *big.Int // (p-1)/2: Euler's-criterion exponent mod p
+ psQ *big.Int // (q-1)/2: Euler's-criterion exponent mod q
+ rootExp *big.Int // (phiN+4)/8: fourth-root exponent mod N
+}
+
+func newModProofCTContext(p, q, N, phiN *big.Int, useCT bool) *modProofCTContext {
+ ctx := &modProofCTContext{
+ useCT: useCT,
+ psP: new(big.Int).Div(new(big.Int).Sub(p, big.NewInt(1)), big.NewInt(2)),
+ psQ: new(big.Int).Div(new(big.Int).Sub(q, big.NewInt(1)), big.NewInt(2)),
+ rootExp: new(big.Int).Div(new(big.Int).Add(phiN, big.NewInt(4)), big.NewInt(8)),
+ }
+ if useCT {
+ // SECURITY: p and q are secret primes and rootExp is secret-derived;
+ // build the constant-time contexts once here instead of re-creating
+ // them inside every iteration.
+ ctx.ctP = common.NewCTModInt(p)
+ ctx.ctQ = common.NewCTModInt(q)
+ ctx.ctN = common.NewCTModInt(N)
+ }
+ return ctx
+}
+
+// isQuadResidueModPrime is x^ps == 1 mod prime, where ps is the precomputed
+// (prime-1)/2 exponent of this context.
+func (ctx *modProofCTContext) isQuadResidueModPrime(x, prime *big.Int, ct *common.CTModInt, ps *big.Int) bool {
+ if ctx.useCT {
+ return common.Eq(ct.ExpCT(x, ps), one)
+ }
+ return common.Eq(new(big.Int).Exp(x, ps, prime), one)
+}
+
+// x is a quadratic residue modulo pq iff x is one modulo p and q
+func (ctx *modProofCTContext) isQuadResidueModComposite(x, p, q *big.Int) bool {
+ return ctx.isQuadResidueModPrime(x, p, ctx.ctP, ctx.psP) &&
+ ctx.isQuadResidueModPrime(x, q, ctx.ctQ, ctx.psQ)
+}
+
+// fourthRoot computes the fourth root of a quadratic residue x modulo
+// n = pq by squaring x^((phiN+4)/8) twice.
+func (ctx *modProofCTContext) fourthRoot(x, n *big.Int) *big.Int {
+ if ctx.useCT {
+ // SECURITY: rootExp derives from secret phiN; the modulus n is odd,
+ // so both square-root steps stay on the constant-time path.
+ res := ctx.ctN.ExpCT(x, ctx.rootExp)
+ return ctx.ctN.ExpCT(res, ctx.rootExp)
+ }
+ res := new(big.Int).Exp(x, ctx.rootExp, n)
+ return res.Exp(res, ctx.rootExp, n)
+}
+
+// defineXi determines values a_i and b_i so that a valid x_i exists,
+// and returns a_i, b_i and x_i.
+func (ctx *modProofCTContext) defineXi(w, y_i, p, q, N *big.Int) (bool, bool, *big.Int) {
bools := [...]bool{false, true}
for _, a := range bools {
@@ -232,8 +298,8 @@ func defineXi(w, y_i, p, q, N, phiN *big.Int, useCT bool) (bool, bool, *big.Int)
yy_i.Mod(yy_i, N)
- if isQuadResidueModCompositeWithMode(yy_i, p, q, useCT) {
- return a, b, quadResidueModCompositeWithMode(yy_i, p, q, N, phiN, useCT)
+ if ctx.isQuadResidueModComposite(yy_i, p, q) {
+ return a, b, ctx.fourthRoot(yy_i, N)
}
}
}
@@ -241,53 +307,48 @@ func defineXi(w, y_i, p, q, N, phiN *big.Int, useCT bool) (bool, bool, *big.Int)
panic("no root found") // this should not be reached with n=pq for safe primes p, q
}
-// x is quadratic residue modulo pq if x is a quadratic residue modulo p and q
-func isQuadResidueModComposite(x, p, q *big.Int) bool {
- return isQuadResidueModCompositeWithMode(x, p, q, common.IsConstantTimeEnabled())
-}
+// Standalone one-shot wrappers: they pay no context setup and snapshot the
+// constant-time toggle for the call only.
-func isQuadResidueModCompositeWithMode(x, p, q *big.Int, useCT bool) bool {
- return isQuadResidueModPrimeWithMode(x, p, useCT) && isQuadResidueModPrimeWithMode(x, q, useCT)
+// x is a quadratic residue modulo pq if x is a quadratic residue modulo p
+// and q
+func isQuadResidueModComposite(x, p, q *big.Int) bool {
+ useCT := common.IsConstantTimeEnabled()
+ psP := new(big.Int).Div(new(big.Int).Sub(p, big.NewInt(1)), big.NewInt(2))
+ psQ := new(big.Int).Div(new(big.Int).Sub(q, big.NewInt(1)), big.NewInt(2))
+ return isQuadResidueModPrimeWithExponent(x, p, useCT, psP) && isQuadResidueModPrimeWithExponent(x, q, useCT, psQ)
}
// x is a quadratic residue modulo p if x^((p-1)/2) = 1
func isQuadResidueModPrime(x, p *big.Int) bool {
- return isQuadResidueModPrimeWithMode(x, p, common.IsConstantTimeEnabled())
+ ps := new(big.Int).Div(new(big.Int).Sub(p, big.NewInt(1)), big.NewInt(2))
+ return isQuadResidueModPrimeWithExponent(x, p, common.IsConstantTimeEnabled(), ps)
}
-func isQuadResidueModPrimeWithMode(x, p *big.Int, useCT bool) bool {
- ps := new(big.Int).Sub(p, big.NewInt(1))
- ps = ps.Div(ps, big.NewInt(2))
-
+func isQuadResidueModPrimeWithExponent(x, p *big.Int, useCT bool, ps *big.Int) bool {
if useCT {
- // SECURITY: p is a secret prime (odd) and the exponent (p-1)/2 is secret-derived;
- // use the constant-time path.
- return common.Eq(common.NewCTModInt(p).ExpCT(x, ps), big.NewInt(1))
+ // SECURITY: p is a secret prime (odd) and the exponent (p-1)/2 is
+ // secret-derived; use the constant-time path.
+ return common.Eq(common.NewCTModInt(p).ExpCT(x, ps), one)
}
- return common.Eq(new(big.Int).Exp(x, ps, p), big.NewInt(1))
+ return common.Eq(new(big.Int).Exp(x, ps, p), one)
}
// the square root of x can be calculated as x^((phiN+4)/8)
// apply this twice to get the 4th root
func quadResidueModComposite(x, p, q, n, phiN *big.Int) *big.Int {
- return quadResidueModCompositeWithMode(x, p, q, n, phiN, common.IsConstantTimeEnabled())
-}
-
-func quadResidueModCompositeWithMode(x, p, q, n, phiN *big.Int, useCT bool) *big.Int {
- e := new(big.Int).Add(phiN, big.NewInt(4))
- e = e.Div(e, big.NewInt(8))
-
+ e := new(big.Int).Div(new(big.Int).Add(phiN, big.NewInt(4)), big.NewInt(8))
+ useCT := common.IsConstantTimeEnabled()
if useCT {
- // SECURITY: the fourth-root exponent e derives from secret phiN; the modulus
- // n = N is odd, so use the constant-time path for both square-root steps.
+ // SECURITY: the fourth-root exponent e derives from secret phiN; the
+ // modulus n = N is odd, so both square-root steps stay on the
+ // constant-time path.
ctModN := common.NewCTModInt(n)
res := ctModN.ExpCT(x, e)
return ctModN.ExpCT(res, e)
}
res := new(big.Int).Exp(x, e, n)
- res = res.Exp(res, e, n)
-
- return res
+ return res.Exp(res, e, n)
}
func UnmarshalModProof(ws []byte, xs [][]byte, as []bool, bs []bool, zs [][]byte) (*ModProof, error) {
diff --git a/crypto/paillier/mod_proof_test.go b/crypto/paillier/mod_proof_test.go
index 0f164a365..9e24de7e7 100644
--- a/crypto/paillier/mod_proof_test.go
+++ b/crypto/paillier/mod_proof_test.go
@@ -1,12 +1,14 @@
package paillier
import (
+ "errors"
"fmt"
"math/big"
"testing"
"github.com/bnb-chain/tss-lib/common"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
// modSetUp shares the package-level fixture loaded by setUp: the
@@ -291,50 +293,57 @@ func TestModSqrt(t *testing.T) {
assert.Equal(b(37), quadResidueModComposite(b(58), b(7), b(11), b(77), b(60)))
}
-// TestModulusBitLenCeilingPredicate pins the exceedsModulusBitLenCeiling
-// boundary directly: a modulus at exactly verifyMaxModulusBitLen bits must
-// not exceed the ceiling, and a modulus one bit wider must. This is checked
-// against the predicate itself, not against ModVerify's error wording or
-// allocation behavior, so it cannot drift from what ModVerify actually
-// consults.
+// TestModulusBitLenCeilingPredicate pins the common.ExceedsUnknownOrderModulusCeiling
+// boundary directly: a modulus at exactly MaxUnknownOrderModulusBitLen bits must
+// not exceed the ceiling, and a modulus one bit wider must.
func TestModulusBitLenCeilingPredicate(t *testing.T) {
- atCeiling := new(big.Int).Lsh(one, uint(verifyMaxModulusBitLen-1))
- if atCeiling.BitLen() != verifyMaxModulusBitLen {
- t.Fatalf("test modulus is %d bits, wanted %d", atCeiling.BitLen(), verifyMaxModulusBitLen)
+ atCeiling := new(big.Int).Lsh(one, uint(common.MaxUnknownOrderModulusBitLen-1))
+ if atCeiling.BitLen() != common.MaxUnknownOrderModulusBitLen {
+ t.Fatalf("test modulus is %d bits, wanted %d", atCeiling.BitLen(), common.MaxUnknownOrderModulusBitLen)
}
- if exceedsModulusBitLenCeiling(atCeiling) {
- t.Fatal("a modulus at exactly verifyMaxModulusBitLen must not exceed the ceiling")
+ if common.ExceedsUnknownOrderModulusCeiling(atCeiling) {
+ t.Fatal("a modulus at exactly MaxUnknownOrderModulusBitLen must not exceed the ceiling")
}
- pastCeiling := new(big.Int).Lsh(one, uint(verifyMaxModulusBitLen))
- if pastCeiling.BitLen() != verifyMaxModulusBitLen+1 {
- t.Fatalf("test modulus is %d bits, wanted %d", pastCeiling.BitLen(), verifyMaxModulusBitLen+1)
+ pastCeiling := new(big.Int).Lsh(one, uint(common.MaxUnknownOrderModulusBitLen))
+ if pastCeiling.BitLen() != common.MaxUnknownOrderModulusBitLen+1 {
+ t.Fatalf("test modulus is %d bits, wanted %d", pastCeiling.BitLen(), common.MaxUnknownOrderModulusBitLen+1)
}
- if !exceedsModulusBitLenCeiling(pastCeiling) {
- t.Fatal("a modulus one bit past verifyMaxModulusBitLen must exceed the ceiling")
+ if !common.ExceedsUnknownOrderModulusCeiling(pastCeiling) {
+ t.Fatal("a modulus one bit past MaxUnknownOrderModulusBitLen must exceed the ceiling")
}
}
-// TestModProofVerifyModulusBitLenCeilingBehavior preserves the end-to-end
-// ModVerify behavior at the boundary: a modulus at the ceiling is not
-// rejected by the ceiling (pinned directly by TestModulusBitLenCeilingPredicate
-// above), and a modulus one bit past it is rejected by ModVerify.
+// TestModProofVerifyModulusBitLenCeilingBehavior tests the end-to-end ModVerify
+// wiring: an odd modulus just past the ceiling (passing simple parity and
+// minimum-size guards) is rejected specifically by the ceiling check, without
+// pinning formatted error text.
func TestModProofVerifyModulusBitLenCeilingBehavior(t *testing.T) {
proof := minimalModProof()
- atCeiling := new(big.Int).Lsh(one, uint(verifyMaxModulusBitLen-1))
- if ok, err := proof.ModVerify(atCeiling); ok || err == nil {
- t.Fatal("expected ModVerify to still reject the (even) boundary modulus on a later check")
- }
-
- pastCeiling := new(big.Int).Lsh(one, uint(verifyMaxModulusBitLen))
- if ok, err := proof.ModVerify(pastCeiling); ok || err == nil {
- t.Fatal("expected ModVerify to reject a modulus past the ceiling")
- }
+ // Odd modulus one bit past the ceiling: 2^65536 + 1.
+ // Clears the odd parity check and minimum bit length floor.
+ pastCeiling := new(big.Int).Lsh(one, uint(common.MaxUnknownOrderModulusBitLen))
+ pastCeiling.Add(pastCeiling, one)
+ require.Equal(t, common.MaxUnknownOrderModulusBitLen+1, pastCeiling.BitLen())
+ require.Equal(t, uint(1), pastCeiling.Bit(0), "must be odd")
+
+ ok, err := proof.ModVerify(pastCeiling)
+ assert.False(t, ok)
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, errModulusCeiling), "must reject with the modulus-ceiling sentinel")
+
+ // At-ceiling modulus: 2^(65536-1) (even). Passes the ceiling check, but
+ // gets rejected on later parity check — proving the ceiling check itself
+ // does not reject at-ceiling moduli.
+ atCeiling := new(big.Int).Lsh(one, uint(common.MaxUnknownOrderModulusBitLen-1))
+ ok, err = proof.ModVerify(atCeiling)
+ assert.False(t, ok)
+ require.Error(t, err)
+ assert.False(t, errors.Is(err, errModulusCeiling), "at-ceiling modulus must not be rejected by ceiling")
}
-// minimalModProof builds a ModProof that clears every per-member check, so
-// control reaches the ceiling comparison and later checks.
+// minimalModProof builds a ModProof that clears per-member nil checks.
func minimalModProof() *ModProof {
proof := &ModProof{W: big.NewInt(2)}
for i := range proof.X {
diff --git a/crypto/paillier/paillier.go b/crypto/paillier/paillier.go
index b1b4eb490..6a39a1e94 100644
--- a/crypto/paillier/paillier.go
+++ b/crypto/paillier/paillier.go
@@ -31,34 +31,9 @@ import (
)
const (
- ProofIters = 13
- verifyPrimesUntil = 1000 // Verify uses primes <1000
- pQBitLenDifference = 3 // >1020-bit P-Q
- verifyMinModulusBitLen = 2048
- // verifyMaxModulusBitLen is a resource ceiling on the modulus width the
- // exported ModVerify will accept.
- //
- // This is ported from upstream e65fb36, but the upstream rationale does
- // not transfer exactly: upstream's sampler tags each 256-bit expansion
- // block with a single byte, so the tag itself wraps (and the sampler's
- // output stops being the challenge the proof claims) once blocks > 256,
- // i.e. bitLen > 65536 -- there, the bound is a correctness requirement.
- // This fork's sampleYModN (mod_proof.go) instead tags each block with a
- // 4-byte uint32 index, which does not wrap until 2^32 blocks (~2^40
- // bits); the sampler here stays correct far past this ceiling.
- //
- // The bound is kept anyway as an allocation/operational cap, matching
- // the width upstream chose for the same class of problem: N arrives
- // through the exported ModVerify, and everything derived from it --
- // common.IsUsableUnknownOrderModulus's ProbablyPrime call, the sampler's
- // mask, expansion buffer, and every one of the PARAM_M candidates -- is
- // O(bitLen), so without a ceiling an exported call allocates and hashes
- // proportional to a caller-controlled, unbounded width. The wire path
- // already pins a peer's modulus to exactly paillierBitsLen (2048) bits
- // before any proof is verified, verifyMinModulusBitLen is that same
- // 2048, and this ceiling is 32x it -- it excludes nothing this library's
- // wire path can produce.
- verifyMaxModulusBitLen = 65536
+ ProofIters = 13
+ verifyPrimesUntil = 1000 // Verify uses primes <1000
+ pQBitLenDifference = 3 // >1020-bit P-Q
)
type (
@@ -309,7 +284,13 @@ func (pf Proof) Verify(pkN, k *big.Int, ecdsaPub *crypto2.ECPoint) (bool, error)
if k.Sign() < 0 {
return false, nil
}
- if !common.IsUsableUnknownOrderModulus(pkN, verifyMinModulusBitLen) {
+ // Width policy: reject a caller-supplied modulus wider than the shared
+ // ceiling before IsUsableUnknownOrderModulus's ProbablyPrime call or any
+ // modulus-sized work is run against it.
+ if common.ExceedsUnknownOrderModulusCeiling(pkN) {
+ return false, nil
+ }
+ if !common.IsUsableUnknownOrderModulus(pkN, common.MinUnknownOrderModulusBitLen) {
return false, nil
}
iters := ProofIters