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