diff --git a/.github/workflows/gofmt.yml b/.github/workflows/gofmt.yml index 93a69a924..f82ae6b1f 100644 --- a/.github/workflows/gofmt.yml +++ b/.github/workflows/gofmt.yml @@ -2,7 +2,7 @@ name: Go-fmt on: push: branches: - # Post-merge signal only: dev and master (D9). release/* push runs are + # Post-merge signal only: dev and master. release/* push runs are # dropped; merges into dev are covered by the PR check that allowed the # merge, and master push keeps the post-merge signal. - dev @@ -16,9 +16,9 @@ on: permissions: contents: read -# R2: cancel superseded PR runs. Only pull_request events cancel in-progress; +# Cancel superseded PR runs. Only pull_request events cancel in-progress; # push/dispatch runs use their own run-scoped group, so a push can never cancel -# another push (orchestrator concurrency guidance). +# another push. concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/race.yml b/.github/workflows/race.yml index 437dddd2e..4a3334188 100644 --- a/.github/workflows/race.yml +++ b/.github/workflows/race.yml @@ -1,6 +1,6 @@ name: Go Race on: - # D11: weekly off-PR -race full-suite run against master and dev. Schedule + # Weekly off-PR -race full-suite run against master and dev. Schedule # and workflow_dispatch become available after this file reaches the default # branch (master). schedule: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee9055734..64653cede 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Go Test on: push: branches: - # Post-merge signal only: dev and master (D9). release/* push runs are + # Post-merge signal only: dev and master. release/* push runs are # dropped; merges into dev are covered by the PR check that allowed the # merge, and master push keeps the post-merge signal. - dev @@ -16,15 +16,20 @@ on: permissions: contents: read -# R2: cancel superseded PR runs. Only pull_request events cancel in-progress; +# Cancel superseded PR runs. Only pull_request events cancel in-progress; # push/dispatch runs use their own run-scoped group, so a push can never cancel -# another push (orchestrator concurrency guidance). +# another push. concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Development and CI toolchain for every job except go-minimum-build, which +# pins the documented minimum instead. +env: + GO_VERSION: '1.26.8' + jobs: - # R4: cheap early gate - fails a broken PR in ~1 min instead of ~10. go vet is + # Cheap early gate - fails a broken PR in ~1 min instead of ~10. go vet is # the repo's only vet entry point (Makefile has no vet target), so run it # here. The gofmt gate lives in the dedicated gofmt workflow ("Go fmt # project"); it is not re-run here. @@ -39,7 +44,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.8' + go-version: ${{ env.GO_VERSION }} cache: true # The documented minimum (README) and the module's enforced minimum @@ -57,7 +62,7 @@ jobs: - name: Vet run: go vet ./... - # R4: unit-level keygen slice (~30 s, no ceremony). Runs in parallel with the + # Unit-level keygen slice (~30 s, no ceremony). Runs in parallel with the # full suite so a broken keygen change fails early. The full-suite job below # still covers every test, so nothing is skipped. keygen-units: @@ -71,7 +76,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.8' + go-version: ${{ env.GO_VERSION }} cache: true - name: Run Keygen Units @@ -93,7 +98,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.8' + go-version: ${{ env.GO_VERSION }} cache: true - name: Run Tests @@ -101,3 +106,62 @@ jobs: - name: Verify bidirectional legacy transcript vectors run: ./testdata/legacy_transcript/verify.sh + + # Live mixed-binary harness and its own tests. verify_mixed_interop.sh spins + # up the pinned historical binary as a subprocess, so the pin (and its + # modules) must be fetchable; the same dependency is pulled by verify.sh + # above, so this job adds no new fetch surface. + mixed-binary-interop: + name: Mixed-binary interop + runs-on: ubuntu-latest + steps: + + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + # The harness also downloads the pinned historical module's + # dependencies, so key the module cache on its go.sum too. + cache-dependency-path: | + go.sum + testdata/legacy_transcript/historical/go.sum.fixture + + - name: Vet harness + run: | + go vet ./testdata/legacy_transcript/oracle/ + go vet ./testdata/legacy_transcript/mixed_interop/ + + - name: Run harness unit tests + run: go test ./testdata/legacy_transcript/mixed_interop/ + + - name: Run mixed-binary interop + run: ./testdata/legacy_transcript/verify_mixed_interop.sh + + # The documented Go minimum (README and the go.mod `go` directive) is + # normally only text-checked. Building and vetting with the minimum + # toolchain catches code that silently uses a newer API. GOTOOLCHAIN=local + # prevents the toolchain directive from auto-upgrading past 1.25.7. + go-minimum-build: + name: Go minimum build + runs-on: ubuntu-latest + env: + GOTOOLCHAIN: local + steps: + + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.7' + cache: true + + - name: Build and vet at the minimum toolchain + run: | + go build ./... + go vet ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ceb41bd..80ebd5a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This fork follows the upstream [`bnb-chain/tss-lib`](https://github.com/bnb-chain/tss-lib) SemVer line for provenance but has not yet published its own tagged release; all changes below are therefore listed under `[Unreleased]`. +The module path has no `/vN` suffix (the upstream `/v2` path bump was not ported; +see Not ported / deferred), so a Go-consumable release tag must stay on the `v0.x` +or `v1.x` line — for example `v1.4.0-threshold.1` — because the `go` command +rejects `v2`-and-later tags for this module path. Provenance notation. Each entry carries two kinds of reference: - **Upstream source** — `BNB #NNN` / `BNB ` is the upstream pull request or commit the @@ -87,9 +91,15 @@ run fails cryptographic verification, and there is no negotiation, downgrade, fallback, or retry between modes. Once every peer is upgraded, the rollout-only compatibility opt-in is disabled and the fleet is coordinated onto `ProtocolModeSecurityV2` with a shared per-ceremony session ID. - -Four caller obligations are enforced at runtime (see Breaking Changes 1 and 2 -and the PR #9 entry below): +Keygen (DKG) interop with the pre-upgrade binary is qualified only by the +primitive-level oracle vectors (DLN, ModProof, FactorProof), not by a live +mixed-version keygen run; there is no mixed-version DKG exercise. + +Obligations 1 and 3 panic at construction; obligation 2 panics at construction +in legacy mode and fails `Start()` in security-v2; uniqueness per ceremony and +agreement between signers are left to the caller; obligation 4 is an +operational rollout step (see Breaking Changes 1 and 2 and the PR #9 entry +below): 1. Select exactly one protocol mode before constructing a local party. 2. In security-v2, set a unique per-ceremony session nonce; in legacy, leave it unset. 3. Pass a positive `fullBytesLen` to every signing constructor. @@ -108,11 +118,12 @@ and the PR #9 entry below): an ambiguous empty value can never select a transcript. DLN, range, Bob/BobWC, ModProof, and FactorProof keep their existing optional-session shape: no argument is legacy; one non-empty argument is security-v2. - Legacy Bob/BobWC proof generation also restores PRIOR's exact `tau` and - relatively-prime Paillier `gamma` sampling ranges, and its verifier retains - PRIOR's honest response range: legacy samples `gamma` below `N`, whereas - security-v2 samples it below `q^7`; applying the latter bound to historical - proofs rejects valid mixed-version signing transcripts. + Legacy Bob/BobWC proof generation also restores the historical `2e712689` + implementation's exact `tau` and relatively-prime Paillier `gamma` sampling + ranges, and its verifier retains that implementation's honest response range: + legacy samples `gamma` below `N`, whereas security-v2 samples it below `q^7`; + applying the latter bound to historical proofs rejects valid mixed-version + signing transcripts. - **Break type:** Runtime/source-compatible configuration obligation. A local party constructed without selecting a mode fails closed. A mode cannot be changed after construction, security-v2 requires a session nonce, and legacy @@ -126,8 +137,9 @@ and the PR #9 entry below): records fixed public points, legacy/security-v2 challenges, proof scalars, deterministic round-4/round-6 wire messages, source identities/digests, and a SHA-256 sidecar. `testdata/legacy_transcript` adds independent bidirectional - PRIOR/R1 oracles and raw vectors for DLN, range, Bob/BobWC, ModProof, and - FactorProof, including the serialized protocol messages that carry them. + oracles for the historical `2e712689` implementation and this fork's legacy + path, plus raw vectors for DLN, range, Bob/BobWC, ModProof, and FactorProof, + including the serialized protocol messages that carry them. Cross-verification uses the historical formulas or the module pinned to `2e712689`; it does not infer compatibility from two parties running the new implementation. @@ -139,10 +151,10 @@ and the PR #9 entry below): round 3, from the default tight `N + q^6` to the exclusive historical witness-range bound `(q+1)*N` (derived from the historical `2e712689` `BobMid`/`BobMidWC` sampling `T1 = e*y + gamma` with `e < q`, `y < N`, - `gamma < N`). It is `false` by default, is only legal with - `ProtocolModeLegacy`, and is frozen for the party's lifetime by - `FreezeProtocolMode`; it panics if selected while the mode is unset or - security-v2. With the toggle off, the default legacy verifier remains + `gamma < N`). It is `false` by default, and any call — with `true` or `false` — + panics unless `ProtocolModeLegacy` has already been selected; the value is + frozen for the party's lifetime by `FreezeProtocolMode` (or local-party + construction). With the toggle off, the default legacy verifier remains tight and the standalone `ProofBob.Verify`/`ProofBobWC.Verify`/ `AliceEnd`/`AliceEndWC` public APIs are unchanged. - **Risk:** Enabling it accepts pre-upgrade `2e712689` Bob/BobWC proofs @@ -155,11 +167,11 @@ and the PR #9 entry below): default remains the hardened tight bound, and security-v2 is unaffected. - **Break type:** None (additive, opt-in). Callers that never set it keep the tightened legacy behavior. -- **Provenance:** `threshold-original`, PR #9: P1 `9-F1` of the pre-merge +- **Provenance:** `threshold-original`, PR #9: the pre-merge review identified that the default legacy bound was derived from this - branch's own `y < q^5` prover, rejecting honest historical `y < N` proofs - and aborting mixed-version legacy signing at - `AliceEnd`/`AliceEndWC`. + branch's legacy-mode prover, whose `betaPrm` sampler feeds `y` below + `q^5`, so it rejects honest historical `y < N` proofs and aborts + mixed-version legacy signing at `AliceEnd`/`AliceEndWC`. #### PR #16. ECDSA signing context binding in the security-v2 SSID (wire incompatibility) - **What:** The security-v2 signing SSID now binds both the message integer and its @@ -240,6 +252,45 @@ and the PR #9 entry below): rejection have consumer-visible regressions. The Go module minimum is aligned with the documented Go 1.25.7 requirement; the preferred development and CI toolchain remains Go 1.26.8. +- **Narrower constant-time exponent padding (PR #41):** the secret MtA + exponents `x` (`ProofBob`/`ProofBobWC`), `m` (`ProveRangeAlice`), and `b` + (`BobMid`/`BobMidWC`) are padded to the curve-order width `q.BitLen()` + instead of the Paillier modulus width. `y` stays in its Paillier plaintext + domain `[0, N)` and keeps `N.BitLen()` padding. `BobMid` takes 58.7 ms + instead of 73.6 ms (-20%), and one full MtA exchange (`AliceInit`, `BobMid`, + `AliceEnd`) takes about 10% less time (constant-time on, security-v2 session, + `BenchmarkBobMid` in `crypto/mta`, `-benchtime=15x -count=3 -cpu=1`). + Proof bytes for in-domain witnesses do not change. The witness domain checks + that come with this change are listed under Security & correctness + hardening. +- **`paillier.PublicKey.HomoMultBounded` (PR #41):** new homomorphic + multiplication with a public exclusive bound; see Added. +- **Prover input validation (PR #41):** the MtA provers return errors instead + of panicking on degenerate `pk`/`N`/`NTilde`/`X` inputs. + `common.GetRandomPositiveInt` and the other samplers return nil instead of + panicking when the limit is wider than the 5000-bit sampler cap, and the + provers return an error in that case. `paillier.HomoAdd` checks the key + modulus (`paillier.ErrInvalidModulus`) before use. + `common.IsUsableUnknownOrderModulus` rejects moduli wider than + `common.MaxUnknownOrderModulusBitLen` before the primality test. +- **`ckd` curve selection (PR #41):** `ckd.NewExtendedKeyFromString` picks its + public-key parser by curve parameters instead of the concrete curve type. + It decodes secp256k1 keys with btcec and other curves with + `elliptic.UnmarshalCompressed`, and returns an error for key data it cannot + decode instead of a key with nil coordinates. +- **Keygen (PR #41):** the ring-Pedersen `beta = alpha^-1 mod pq` uses a + constant-time inverse when constant-time operations are enabled. The + reduction of `alpha` mod `pq` before the inverse is still variable-time; + see the known gap under Breaking Change 8. +- **Signing (PR #41):** the signing constructors copy `keyDerivationDelta`, so + later caller mutations cannot change the party's key. Round 2 checks the + local `gamma` and `w` are in `[0, q)` before it starts the per-peer MtA + work; if they are not, it reports a local failure and blames no peer. +- **CI (PR #41):** adds the `Mixed-binary interop` and `Go minimum build` jobs + and a HEAD-prover to historical-verifier leg in `verify.sh`. +- **Rollback (PR #41):** all of these changes are local. No wire format or + proof bytes change, so reverting them restores the previous padding with no + interop impact. ### Breaking changes @@ -305,27 +356,36 @@ and the PR #9 entry below): security-v2 for work anchored at or after it. All parties in one ceremony use the same mode. Historical proofs remain re-verifiable through the legacy API. -#### 4. Tagged Fiat-Shamir for Paillier ModProof / FactorProof (active on the protocol path) +#### 4. Tagged Fiat-Shamir for Paillier ModProof / FactorProof (active in security-v2) - **What:** `ModProof`/`ModVerify` and `FactorProof`/`FactorVerify` (`crypto/paillier/mod_proof.go`, `factor_proof.go`) gain an optional session tag. When a - session tag **is** supplied they use tagged hashing (`common.HashToNTagged`, sized to the - modulus to avoid challenge bias) and are **not** wire-compatible with pre-upgrade peers. - With **no** session tag the challenge bytes are unchanged (backward-compatible default). -- **Break type:** Wire/protocol **only when a session tag is supplied**. + session tag **is** supplied, the challenges are derived from + `common.SHA512_256i_TAGGED`: the `ModProof` path samples each challenge uniformly in + `[0, N)` by expand-then-reject (`sampleYModN` under the `fsSessionModProof` tag, + chaining the previously-derived challenges `y[:i]` into each hash input, which is what + avoids the challenge bias of a bare modular reduction), and the `FactorProof` path + takes a 256-bit challenge by design (`SHA512_256i_TAGGED` under the + `fsSessionFactorProof` tag, reduced mod `2^256`). The session-tagged path is **not** + wire-compatible with pre-upgrade peers. With **no** session tag the challenge bytes + are unchanged (backward-compatible default). +- **Break type:** Wire/protocol **only when a session tag is supplied** (in security-v2; + legacy parties pass no tag and keep their historical challenges). - **Motivation:** Domain separation for the Paillier proofs without weakening Threshold's existing `N`/`NTilde` `ModProof`/`FactorProof` remediation. Threshold's stronger coverage was retained; no BNB no-proof escape hatches were introduced. -- **Provenance:** `BNB #252`, `BNB #257`; the in-tree round code now passes session tags, - so in practice this is active on the protocol path. +- **Provenance:** `BNB #252`, `BNB #257`; in-tree round code passes session tags in + security-v2 only, so the tagged path is active on the security-v2 protocol path. - **Migration:** Covered by the coordinated upgrade in Breaking Change 1/3. #### 5. Per-proof-system Fiat-Shamir domain tags (PR #6) - **What:** DLN, Schnorr, MtA, and Paillier challenges now prepend a per-proof-system domain tag (e.g. `dlnproof|`, `zk|`, `zkv|`, via `fsDomainTag*` / `fsSession*`) to the session - before tagged hashing. This further changes every proof transcript relative to Breaking - Change 3. -- **Break type:** Wire/protocol — compounds Breaking Change 3; still a single coordinated - upgrade (a PR #6 node and a PR #2–#5 node will not cross-verify). + before tagged hashing. This further changes every security-v2 proof transcript relative + to Breaking Change 3; legacy (no-session) challenges keep the historical untagged + construction. +- **Break type:** Wire/protocol (security-v2 only; legacy keeps the historical untagged + challenges) — compounds Breaking Change 3; still a single coordinated upgrade (a PR #6 + node and a PR #2–#5 node will not cross-verify). - **Motivation:** Distinct domain separation per proof system, so a challenge from one proof type can never be reused in another. - **Provenance:** `BNB #252` / `BNB #256` domain-tag design; PR #6. @@ -376,7 +436,7 @@ and the PR #9 entry below): | `crypto/schnorr/schnorr_proof.go` | `NewZKVProofWithSession` | `MulCT(c, s)`, `MulCT(c, l)` → `t = a + c·s`, `u = b + c·l` | `s`, `l` | | `ecdsa/signing/round_3.go` | `round3.Start` | `MulCT(k, gamma)`, `MulCT(k, w)` → `thelta`, `sigma` | `k`, `gamma`, `w` | | `ecdsa/signing/round_4.go` | `round4.Start` | `ModInverseCT(theta)` → `thetaInverse` | `theta` | - | `ecdsa/signing/round_5.go` | `round5.Start` | `MulCT(m, k)`, `MulCT(rx, sigma)` → `si` | `k`, `sigma`, `rx` (`m` public message hash; `rx` remains secret until round 10) | + | `ecdsa/signing/round_5.go` | `round5.Start` | `MulCT(m, k)`, `MulCT(rx, sigma)` → `si` | `k`, `sigma` (`m` is the public message hash; `rx = R.X()` is derived from public values by round 5, so its mod-N reduction is not a secret-dependent operation) | - **Known residual gap (read before relying on "constant-time enabled"):** in `crypto/mta.AliceEnd`/`AliceEndWC` (signing rounds 2-3), the Paillier exponentiation inside `Decrypt` uses the constant-time path, but the surrounding `math/big` @@ -384,13 +444,29 @@ and the PR #9 entry below): normalized. Upstream adds a ~200ms sleep-based normalizer that this fork deliberately did not port (latency cost); the gap is disclosed in the COVERAGE comment in `common/constant_time.go`. + The one-time MtA blind exponents (`alpha`, `rho`, `rhoPrm`, `sigma`, + `gamma`, `tau`, and the `beta^N` term in `crypto/mta`) stay on `math/big`, + and each masks a secret witness in a published response (for example + S1 = e*x + alpha in the Bob proof), so a timing leak of a blind can leak the + witness. + Elliptic-curve scalar multiplication on `tss.S256()` uses the variable-time + btcec/v2 (Decred) routines, including for secret scalars. +- **Known gap: keygen ring-Pedersen inverse (PR #41):** `beta = alpha^-1 mod pq` + uses a constant-time inverse, but the secret `alpha` (sampled below + `NTilde`) is first reduced mod `p*q` with variable-time `math/big`, once per + keygen. +- **Known gap: legacy MtA prover `tau` range:** in `ProtocolModeLegacy`, + `ProofBob`/`ProofBobWC` sample `tau` below `q*NTilde`, as the deployed + historical prover `2e712689` does; this keeps legacy proofs byte-identical. + GG18 specifies `q^3*NTilde` so that `e*sigma` is statistically hidden. + Security-v2 samples `tau` below `q^3*NTilde`. Verifiers are unaffected. - **Break type:** Performance only. Same mathematical result on every path (see the constant-time equivalence tests added alongside each hardened package); no wire, source, or runtime-input behavior changes. A microbenchmark (`go test ./common/... -bench 'BenchmarkExp(CT|Standard)' -benchtime=2s`) measured constant-time modexp at parity with the standard path on this fork's test hardware (~2.7ms vs ~2.8ms per op, n≈900 CT samples, n≈800 standard samples). The 256-bit-class `MulCT` and `ModInverseCT` - operations this PR's Schnorr/signing-rounds extension actually uses are measured by the + operations PR #23's Schnorr/signing-rounds extension actually uses are measured by the paired `BenchmarkMulCT`/`BenchmarkMulStandard` and `BenchmarkModInverseCT`/`BenchmarkModInverseStandard` benchmarks (256-bit prime modulus): `MulCT` runs at roughly 2x the standard `math/big` multiply (≈2.3µs vs ≈1.1µs per op on @@ -477,7 +553,9 @@ and the PR #9 entry below): > `ecdsa/resharing.NewDGRound1Message` API (see Removed). Every session / `fullBytesLen` > parameter was added as a trailing variadic argument, so other call sites compile > unchanged; those breaks are runtime/wire. Verified by diffing `go doc` exported -> signatures between `master` and `dev`. +> signatures between the threshold base `2e712689` and `dev`. Breaking Changes 10 +> and 12 are type-identity and toolchain breaks, not signature changes, and sit +> outside that diff. ### Removed @@ -590,7 +668,8 @@ rejecting input that an honest caller would previously have produced. - **Review follow-up correctness fixes (PR #6):** Schnorr verification accepts unregistered generic curves; `common.GetRandomInt`'s zero-inclusive range is corrected; message wire bytes are made deterministic; large-modulus `sampleYModN` block indexing is fixed; and - canonical-generator checks were added in `crypto/commitments` and `crypto/paillier`. + canonical-generator checks were added in `crypto/paillier`, and + `crypto/commitments` validates decommitment payloads for part count and nil parts. _Provenance: `BNB #332` + `threshold-original`, PR #6._ - **ECDSA signing round-9 decommitment curve-point validation (PR #7):** decommitted `Uj`/`Tj` coordinates are now validated as canonical curve points (`crypto.NewECPoint`) @@ -610,17 +689,32 @@ rejecting input that an honest caller would previously have produced. distinct-generator policy already enforced by DLN and MtA proofs (`crypto/paillier/factor_proof.go`). Honest setups use distinct generators. _Provenance: `threshold-original`, PR #7._ +- **MtA prover witness domains (PR #41):** the MtA provers now require + curve-order witnesses. `ProveRangeAlice` rejects `m` in `[q, N)`; + `ProofBob`/`ProofBobWC` reject `x >= q`; `BobMid`/`BobMidWC` reject `b` + outside `[0, q)`. `AliceInit` with `a >= q` now returns an error after it + encrypts `a`, because its range proof rejects the witness. In-tree signing + always passes curve-order scalars, so honest ceremonies are unaffected; a + direct caller that passed a wider witness now gets an error. + _Provenance: `threshold-original`, PR #41._ ### Added -- `common.SHA512_256i_TAGGED` and `common.HashToNTagged` — length-delimited, domain- - separated tagged hashing primitives. _Provenance: `BNB #257`._ +- `common.SHA512_256i_TAGGED` and `common.HashToNTagged` — length-delimited, + domain-separated tagged hashing primitives. `common.HashToNTagged` is exported + but not yet used by the in-tree Paillier proofs, which derive their + session-tagged challenges via `common.SHA512_256i_TAGGED` plus + expand-then-reject sampling or a 256-bit modular reduction. _Provenance: `BNB #257`._ - `tss.Parameters.SessionNonce`, `SetSessionNonce`, `SetSessionNonceBytes` — session-nonce API. `SetSessionNonce` rejects non-positive nonces; `SetSessionNonceBytes` requires a session ID of at least 16 bytes. _Provenance: `BNB fc38979`._ -- `common.IsInInterval`, `common.AppendUint64ToBytesSlice`, - `common.AppendBigIntToBytesSlice` (the last currently unused), and `tss.SameCurve` — - helpers backing the hardened range checks and session/transcript context construction. +- `common.IsInInterval`, `common.AppendUint64ToBytesSlice` (used for per-party + transcript context), `common.AppendBigIntToBytesSlice`, and `tss.SameCurve` — + helpers backing the hardened range checks and session/transcript context + construction. `common.IsInInterval`, `common.AppendBigIntToBytesSlice`, and + `tss.SameCurve` are currently unused by library code (production range checks + use `common.IsInIntervalPositive`, and production curve checks use + `crypto.SameCurve`); the exported symbols are kept to avoid breaking consumers. - `schnorr.NewZKProofWithSession`, `NewZKVProofWithSession`, `VerifyWithSession` — session- aware Schnorr proof overloads. The original signatures retain their source shape and call the historical `HashToN` challenge directly; the @@ -645,11 +739,16 @@ rejecting input that an honest caller would previously have produced. `CTModInt.ExpCTWithBitLen` (PR #17); `CTModInt.ExpCTWithBytes`, `ExpCTCanonicalWithBitLen`, `ExpCTCanonicalWithBytes`, `MulCTCanonical` (review fixes `873b8ad`..`fa6ef4d`). -- `common.MinUnknownOrderModulusBitLen` (2048), `MaxUnknownOrderModulusBitLen` (65536), - and `ExceedsUnknownOrderModulusCeiling` — the shared unknown-order modulus width - policy. _PR #34._ +- `common.MinUnknownOrderModulusBitLen` (2048) and `MaxUnknownOrderModulusBitLen` (65536) + — the shared unknown-order modulus width policy. _PR #34._ - `paillier.ErrInvalidModulus` and `paillier.ErrMalformedKey` — sentinel errors for invalid Paillier keys (review fixes `873b8ad`..`fa6ef4d`). +- `paillier.PublicKey.HomoMultBounded(m, c1, bound)` and `paillier.ErrInvalidBound` — + homomorphic multiplication with a public exclusive bound on `m`. In constant-time mode + the exponent is padded to `bound.BitLen()` bits. It returns `ErrInvalidBound` when + `bound` is nil, not positive, or greater than `N`, and `ErrMessageTooLong` when `m` is + outside `[0, bound)`. `HomoMult(m, c1)` is `HomoMultBounded(m, c1, N)`. + _Provenance: `threshold-original`, PR #41._ ### Notes diff --git a/README.md b/README.md index e007e5432..e4e8a8d3f 100644 --- a/README.md +++ b/README.md @@ -55,32 +55,35 @@ parties := tss.SortPartyIDs(getParticipantPartyIDs()) // The `id` should be a unique string representing this party in the network and `moniker` can be anything (even left blank). // The `uniqueKey` is a unique identifying key for this peer (such as its p2p public key) as a big.Int. thisParty := tss.NewPartyID(id, moniker, uniqueKey) -ctx := tss.NewPeerContext(parties) - // Select an elliptic curve. curve := tss.S256() -params := tss.NewParameters(curve, ctx, thisParty, len(parties), threshold) -// New ceremonies must select a protocol mode and bind a per-ceremony session -// ID before constructing a local party; the mode is frozen at construction. -// For the legacy mixed-binary rollout procedure, see "How to use this securely" -// below. -params.SetProtocolMode(tss.ProtocolModeSecurityV2) -params.SetSessionNonceBytes([]byte(sessionID)) // high-entropy, >=16 bytes, unique to this ceremony - // You should keep a local mapping of `id` strings to `*PartyID` instances so that an incoming message can have its origin party's `*PartyID` recovered for passing to `UpdateFromBytes` (see below) -partyIDMap := make(map[string]*PartyID) +partyIDMap := make(map[string]*tss.PartyID) for _, id := range parties { partyIDMap[id.Id] = id } ``` +Each keygen and each signing ceremony gets its own `tss.NewParameters(...)` value +with its own session nonce: keygen uses the full peer set, signing uses the `t+1` +signers' `PeerContext`. The protocol mode and session nonce freeze when the first +local party is constructed from a `Parameters` value, and the setters panic +afterwards, so a `Parameters` built for one ceremony must not be reused for another. +For the legacy mixed-binary rollout procedure, see "How to use this securely" below. ### Keygen Use the `keygen.LocalParty` for the keygen protocol. The save data you receive through the `endCh` upon completion of the protocol should be persisted to secure storage. ```go +ctx := tss.NewPeerContext(parties) +params := tss.NewParameters(curve, ctx, thisParty, len(parties), threshold) +// New ceremonies must select a protocol mode and bind a per-ceremony session +// ID before constructing a local party; the mode and nonce are frozen at +// construction. +params.SetProtocolMode(tss.ProtocolModeSecurityV2) +params.SetSessionNonceBytes([]byte(keygenSessionID)) // high-entropy, >=16 bytes, unique to this keygen ceremony endCh := make(chan keygen.LocalPartySaveData, 1) -party := keygen.NewLocalParty(params, outCh, endCh, preParams) // Omit the last arg to compute the pre-params in round 1 +party := keygen.NewLocalParty(params, outCh, endCh, *preParams) // Omit the last arg to compute the pre-params in round 1 go func() { err := party.Start() // handle err ... @@ -93,6 +96,13 @@ Use the `signing.LocalParty` for signing and provide it with a `message` to sign Please note that `t+1` signers are required to sign a message and for optimal usage no more than this should be involved. Each signer should have the same view of who the `t+1` signers are. ```go +// Signing builds its own Parameters over the t+1 signers and its own session +// nonce; the keygen ceremony's Parameters and nonce are not reused. +signers := tss.SortPartyIDs(getSignerPartyIDs()) // the t+1 signers, agreed out-of-band +signerCtx := tss.NewPeerContext(signers) +params := tss.NewParameters(curve, signerCtx, thisParty, len(signers), threshold) +params.SetProtocolMode(tss.ProtocolModeSecurityV2) +params.SetSessionNonceBytes([]byte(signingSessionID)) // high-entropy, >=16 bytes, unique to this signing ceremony endCh := make(chan *common.SignatureData, 1) fullBytesLen := (params.EC().Params().N.BitLen() + 7) / 8 party := signing.NewLocalParty(message, params, ourKeyData, outCh, endCh, fullBytesLen) @@ -147,7 +157,7 @@ params.SetProtocolMode(tss.ProtocolModeSecurityV2) params.SetSessionNonceBytes([]byte(sessionID)) ``` -All parties in the run must use the same high-entropy session ID of at least 16 bytes, and it must be unique to the ceremony. Keygen and signing fail closed if no session nonce is set; reusing a session ID across otherwise identical ceremonies reintroduces transcript-splicing risk. +All parties in the run must use the same high-entropy session ID of at least 16 bytes, and it must be unique to the ceremony. Security-v2 keygen and signing fail closed at `Start` if no session nonce is set; legacy parties must leave it unset, and setting one panics at party construction. Reusing a session ID across otherwise identical ceremonies reintroduces transcript-splicing risk. `ProtocolModeLegacy` exists only for coordinated compatibility with peers that use the historical untagged GG20 transcript. A ceremony must be homogeneous: @@ -160,8 +170,10 @@ version, select `ProtocolModeLegacy`, call local party, and leave the session nonce unset. Without this rollout-only opt-in the default tighter Bob/BobWC verification can reject the historical `y < N` proofs that pre-upgrade peers produce. Once every peer is upgraded, -disable `SetLegacyHistoricalBobCompatibility` for all parties, then coordinate -the cutover to `ProtocolModeSecurityV2` with a shared per-ceremony session ID. +stop calling `SetLegacyHistoricalBobCompatibility` (or pass `false` only while still +in `ProtocolModeLegacy`, before the local party is constructed — any call, `true` +or `false`, panics outside legacy mode), then coordinate the cutover to +`ProtocolModeSecurityV2` with a shared per-ceremony session ID. Security-v2 ECDSA signing binds both the message integer and `fullBytesLen` into the signing SSID. All signers must agree on the message and its byte width diff --git a/common/constant_time.go b/common/constant_time.go index f757297a7..2e11d02e5 100644 --- a/common/constant_time.go +++ b/common/constant_time.go @@ -11,14 +11,16 @@ // secret-exponent operations enumerated below, using filippo.io/bigmod (the same // constant-time core used by Go's crypto/rsa). // -// COVERAGE: Enabled by default, the bigmod path is applied -// to modular exponentiations/multiplications whose operand is a long-term secret, -// witness, trapdoor, or secret plaintext/scalar: Paillier Decrypt / Encrypt (gamma^m) / -// HomoMult, the Paillier mod- and factor-proofs, the DLN proof, the ring-Pedersen -// trapdoor setup in keygen, the MtA range and regular proofs, the Schnorr proof -// responses (crypto/schnorr), and ECDSA signing rounds 3-5 (thelta/sigma/thetaInverse/si). -// This is limited coverage: conversion, reduction, and other surrounding math/big -// operations remain variable-time. It does not make the whole protocol constant-time. +// COVERAGE: enabled by default, the bigmod path is applied to +// exponentiations/multiplications whose operand is a long-term secret, +// witness, trapdoor, or secret plaintext/scalar: Paillier Decrypt / Encrypt +// (the m*N term of gamma^m) / HomoMult, the Paillier mod- and factor-proofs, +// the DLN proof, the ring-Pedersen trapdoor setup in keygen, the MtA range +// and regular proofs, the Schnorr proof responses (crypto/schnorr), and ECDSA +// signing rounds 3-5 (thelta/sigma/thetaInverse/si). +// This is limited coverage: conversion, reduction, and other surrounding +// math/big operations remain variable-time; the protocol is not fully +// constant-time. // // Deliberately NOT hardened (left on math/big), and the reasons: // - Public-exponent operations, where the timing reveals only public data: x^N in @@ -29,13 +31,22 @@ // leaving them on math/big is a pragmatic deferral, NOT a safety guarantee. // - Exponentiations modulo an even value (e.g. inverses mod phi(N)): bigmod requires // an odd modulus, so these stay on math/big. -// - crypto/mta.AliceEnd/AliceEndWC's Paillier decrypt: upstream (BNB 3709c25) protects -// this with a *different* mechanism entirely -- a sleep-based response-time -// normalization wrapper (NewTimingProtection, ~200ms target + jitter), not a bigmod -// constant-time path. This gap predates the current extension and is tracked -// separately; it was not addressed here because adding that primitive would -// inject a fixed ~200ms delay into every MtA share round, a real latency/throughput -// cost nobody has signed off on. +// - crypto/mta.AliceEnd/AliceEndWC's Paillier decrypt: c^LambdaN and the +// inverse of LambdaN mod N run in constant time, but the steps around +// them stay on math/big: the LambdaN mod N reduction before the inverse, +// the L(u) = (u-1)/N division, the math/big multiplication of L(u) by the +// inverse mod N, and decryptShare's reduction of the secret plaintext +// mod q. The only known mitigation is a sleep-based response-time +// normalization (~200ms target plus jitter), which was not adopted +// because it would add about 200ms to every MtA share round, a latency +// cost not signed off on. The gap remains open. +// - Elliptic-curve scalar multiplication on secp256k1: tss.S256() uses the +// variable-time btcec/v2 (Decred) routines, so secret scalars such as +// Schnorr nonces and key shares are multiplied in variable time. +// - The keygen ring-Pedersen inverse beta = alpha^-1 mod p*q: the inverse +// itself is constant-time, but ModInverseCT first reduces the secret +// alpha (sampled below NTilde, so wider than p*q) mod p*q with math/big. +// This happens once per keygen. // // Reference: https://github.com/golang/go/issues/20654 @@ -124,21 +135,25 @@ func NewCTModInt(mod *big.Int) *CTModInt { if mod.Bit(0) == 0 { panic("NewCTModInt: modulus must be odd") } + // Pre-compute mod-2 for Fermat inverse: a^(-1) = a^(mod-2) mod mod + return newCTModInt(mod, new(big.Int).Sub(mod, big.NewInt(2))) +} + +// newCTModInt builds the shared CTModInt state for an odd modulus with a +// precomputed inverse exponent: mod-2 for prime moduli (Fermat inverse) or +// groupExponent-1 for composite moduli (Euler inverse). +func newCTModInt(mod, inverseExp *big.Int) *CTModInt { modBytes := mod.Bytes() m, err := bigmod.NewModulus(modBytes) if err != nil { // Fallback: should not happen for valid modulus panic(err) } - - // Pre-compute mod-2 for Fermat inverse: a^(-1) = a^(mod-2) mod mod - modMinusTwo := new(big.Int).Sub(mod, big.NewInt(2)) - byteLen := len(modBytes) return &CTModInt{ mod: m, modBigInt: new(big.Int).Set(mod), - inverseExp: leftPad(modMinusTwo.Bytes(), byteLen), + inverseExp: leftPad(inverseExp.Bytes(), byteLen), byteLen: byteLen, bytePool: sync.Pool{ New: func() interface{} { @@ -154,18 +169,22 @@ func NewCTModInt(mod *big.Int) *CTModInt { // suitable for bigmod.Nat.SetBytes. The caller must pass the returned // pointer to releasePadded (typically via defer) to return the same pooled // buffer instead of leaking it. -// NOTE: big.Int.Mod is not constant-time, but it is applied unconditionally (no -// secret-dependent branch) and the bases reduced here are public or already in range -// at every call site. A caller passing a secret base near the modulus should be aware -// the reduction's timing depends on the value. One exception: in -// `ecdsa/signing/round_5.go`, the operand `rx = R.X()` is a field-prime (mod p) -// coordinate that is not yet reduced mod the curve order N when fed into -// `MulCT(rx, sigma)`. The reduction is correctness-required (both the CT and non-CT -// paths always performed it), pre-existing (not introduced by the CT branch), -// and `rx` is still secret here — it becomes the public signature `r` component -// only in round 10 (finalize.go:60), five rounds later. The variable-time -// reduction of a secret value is a bounded timing leak; making this reduction -// constant-time is a tracked follow-up. +// NOTE: big.Int.Mod is not constant-time, but it is applied unconditionally +// (no secret-dependent branch) and, with one exception, the values reduced +// here are public or already in range. A caller passing a secret value at +// or above the modulus should know that the reduction's timing depends on +// the value. +// The exception is the keygen ring-Pedersen inverse in +// `ecdsa/keygen/prepare.go`: ModInverseCT(alpha) reduces the secret alpha, +// sampled below NTilde, mod p*q here. This is a known gap. +// An example of a safe reduction of a public value: in +// `ecdsa/signing/round_5.go`, `rx = R.X()` is a field-prime (mod p) +// coordinate that is not yet reduced mod the curve order N when it goes into +// `MulCT(rx, sigma)`. The reduction is required for correctness (both the CT +// and non-CT paths always did it), and `rx` comes from public values: R is +// built from the round-4 decommitted Gamma_j points and delta (the sum of the +// theta_i values broadcast in round 3). So the variable-time reduction mod N +// leaks nothing secret. func (ct *CTModInt) reduceToPaddedBytes(val *big.Int) *[]byte { reduced := new(big.Int).Mod(val, ct.modBigInt) @@ -289,6 +308,9 @@ var ctModIntCache sync.Map // key: mod.Bytes() string -> *CTModInt // The modulus must be odd. Repeated calls with the same modulus value return // the same *CTModInt, so the per-call bigmod.NewModulus construction and // sync.Pool setup are paid once per distinct modulus rather than every call. +// Only for a small fixed set of public moduli (e.g. curve orders); entries +// are never evicted and modulus bytes are retained for the process lifetime. +// Use NewCTModInt for per-key or secret moduli. func GetCTModInt(mod *big.Int) *CTModInt { if mod.Bit(0) == 0 { panic("GetCTModInt: modulus must be odd") @@ -311,26 +333,6 @@ func NewCTModIntWithPhi(mod, phiN *big.Int) *CTModInt { if mod.Bit(0) == 0 { panic("NewCTModIntWithPhi: modulus must be odd") } - modBytes := mod.Bytes() - m, err := bigmod.NewModulus(modBytes) - if err != nil { - panic(err) - } - // For a unit a and known group exponent: a^(-1) = a^(phiN-1) mod n. - groupExponentMinusOne := new(big.Int).Sub(phiN, big.NewInt(1)) - - byteLen := len(modBytes) - return &CTModInt{ - mod: m, - modBigInt: new(big.Int).Set(mod), - inverseExp: leftPad(groupExponentMinusOne.Bytes(), byteLen), - byteLen: byteLen, - bytePool: sync.Pool{ - New: func() interface{} { - buf := make([]byte, byteLen) - return &buf - }, - }, - } + return newCTModInt(mod, new(big.Int).Sub(phiN, big.NewInt(1))) } diff --git a/common/constant_time_test.go b/common/constant_time_test.go index dc9aa229a..fc995ab03 100644 --- a/common/constant_time_test.go +++ b/common/constant_time_test.go @@ -11,7 +11,6 @@ import ( "crypto/rand" "math/big" "testing" - "time" ) func TestConstantTimeOpsEnabledByDefault(t *testing.T) { @@ -423,133 +422,3 @@ func BenchmarkModInverseStandard(b *testing.B) { new(big.Int).ModInverse(x, p) } } - -// TestMulCTTimingConsistency compares MulCT's mean elapsed time for a -// zero operand vs a near-modulus operand. A non-constant-time implementation -// would show a measurable spread; wall-clock noise on CI exceeds that, so -// a drift beyond the 0.5x-2.0x band is logged (not a failure), matching -// TestExpCTTimingConsistency's style. -func TestMulCTTimingConsistency(t *testing.T) { - if testing.Short() { - t.Skip("skipping timing test in short mode") - } - - p, _ := rand.Prime(rand.Reader, 256) - ctMod := NewCTModInt(p) - - xZero := big.NewInt(0) - xNear := new(big.Int).Sub(p, big.NewInt(1)) - y, _ := rand.Int(rand.Reader, p) - - const iterations = 100 - var timesZero, timesNear []time.Duration - - for i := 0; i < iterations; i++ { - start := time.Now() - ctMod.MulCT(xZero, y) - timesZero = append(timesZero, time.Since(start)) - - start = time.Now() - ctMod.MulCT(xNear, y) - timesNear = append(timesNear, time.Since(start)) - } - - var sumZero, sumNear time.Duration - for i := 0; i < iterations; i++ { - sumZero += timesZero[i] - sumNear += timesNear[i] - } - meanZero := sumZero / time.Duration(iterations) - meanNear := sumNear / time.Duration(iterations) - - ratio := float64(meanNear) / float64(meanZero) - if ratio < 0.5 || ratio > 2.0 { - t.Logf("Warning: MulCT timing ratio between zero and near-modulus operands: %.2f", ratio) - t.Logf("Zero-operand mean: %v, near-modulus mean: %v", meanZero, meanNear) - } -} - -// TestModInverseCTTimingConsistency is the ModInverseCT analogue of -// TestMulCTTimingConsistency: a non-constant-time inverse would time differently -// for zero, one, and near-modulus inputs. -func TestModInverseCTTimingConsistency(t *testing.T) { - if testing.Short() { - t.Skip("skipping timing test in short mode") - } - - p, _ := rand.Prime(rand.Reader, 256) - ctMod := NewCTModInt(p) - - one := big.NewInt(1) - near := new(big.Int).Sub(p, big.NewInt(1)) - - const iterations = 100 - var timesOne, timesNear []time.Duration - - for i := 0; i < iterations; i++ { - start := time.Now() - ctMod.ModInverseCT(one) - timesOne = append(timesOne, time.Since(start)) - - start = time.Now() - ctMod.ModInverseCT(near) - timesNear = append(timesNear, time.Since(start)) - } - - var sumOne, sumNear time.Duration - for i := 0; i < iterations; i++ { - sumOne += timesOne[i] - sumNear += timesNear[i] - } - meanOne := sumOne / time.Duration(iterations) - meanNear := sumNear / time.Duration(iterations) - - ratio := float64(meanNear) / float64(meanOne) - if ratio < 0.5 || ratio > 2.0 { - t.Logf("Warning: ModInverseCT timing ratio between one and near-modulus operands: %.2f", ratio) - t.Logf("One mean: %v, near-modulus mean: %v", meanOne, meanNear) - } -} - -func TestExpCTTimingConsistency(t *testing.T) { - if testing.Short() { - t.Skip("skipping timing test in short mode") - } - - p, _ := rand.Prime(rand.Reader, 512) - q, _ := rand.Prime(rand.Reader, 512) - N := new(big.Int).Mul(p, q) - ctMod := NewCTModInt(N) - - base, _ := rand.Int(rand.Reader, N) - - expLowHamming := new(big.Int).Lsh(big.NewInt(1), 511) - expHighHamming := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 512), big.NewInt(1)) - - const iterations = 100 - var timesLow, timesHigh []time.Duration - - for i := 0; i < iterations; i++ { - start := time.Now() - ctMod.ExpCT(base, expLowHamming) - timesLow = append(timesLow, time.Since(start)) - - start = time.Now() - ctMod.ExpCT(base, expHighHamming) - timesHigh = append(timesHigh, time.Since(start)) - } - - var sumLow, sumHigh time.Duration - for i := 0; i < iterations; i++ { - sumLow += timesLow[i] - sumHigh += timesHigh[i] - } - meanLow := sumLow / time.Duration(iterations) - meanHigh := sumHigh / time.Duration(iterations) - - ratio := float64(meanHigh) / float64(meanLow) - if ratio < 0.5 || ratio > 2.0 { - t.Logf("Warning: Timing ratio between high and low Hamming weight exponents: %.2f", ratio) - t.Logf("Low Hamming mean: %v, High Hamming mean: %v", meanLow, meanHigh) - } -} diff --git a/common/random.go b/common/random.go index 3a4f4097f..2a18d0070 100644 --- a/common/random.go +++ b/common/random.go @@ -35,8 +35,18 @@ func MustGetRandomInt(bits int) *big.Int { return n } +// exceedsSamplerCap reports whether limit is wider than the widest value +// MustGetRandomInt can draw. The samplers below return nil for such limits +// instead of letting MustGetRandomInt panic. +func exceedsSamplerCap(limit *big.Int) bool { + return limit.BitLen() > mustGetRandomIntMaxBits +} + +// GetRandomPositiveInt returns a random integer in [1, lessThan). +// It returns nil when lessThan is nil, not greater than 1, or wider than +// mustGetRandomIntMaxBits bits. func GetRandomPositiveInt(lessThan *big.Int) *big.Int { - if lessThan == nil || lessThan.Cmp(one) <= 0 { + if lessThan == nil || lessThan.Cmp(one) <= 0 || exceedsSamplerCap(lessThan) { return nil } var try *big.Int @@ -49,8 +59,11 @@ func GetRandomPositiveInt(lessThan *big.Int) *big.Int { return try } +// getRandomNonNegativeInt returns a random integer in [0, lessThan). +// It returns nil when lessThan is nil, not positive, or wider than +// mustGetRandomIntMaxBits bits. func getRandomNonNegativeInt(lessThan *big.Int) *big.Int { - if lessThan == nil || lessThan.Sign() <= 0 { + if lessThan == nil || lessThan.Sign() <= 0 || exceedsSamplerCap(lessThan) { return nil } var try *big.Int @@ -64,6 +77,8 @@ func getRandomNonNegativeInt(lessThan *big.Int) *big.Int { } // Sample an integer in range (-limit, limit) +// Returns nil when limit is nil, not positive, or when 2*limit-1 is wider +// than mustGetRandomIntMaxBits bits. func GetRandomInt(limit *big.Int) *big.Int { if limit == nil || limit.Sign() <= 0 { return nil @@ -73,10 +88,16 @@ func GetRandomInt(limit *big.Int) *big.Int { // get an integer in [0, 2*limit-1) and subtract limit-1 // to get an integer in [-limit+1, limit-1] i := getRandomNonNegativeInt(limitDoubleMinus1) + if i == nil { + return nil + } i = i.Sub(i, limitMinus1) return i } +// GetRandomPrimeInt returns a random prime with `bits` bits. If rand.Prime +// fails, the fallback path returns a prime of at most `bits` bits. +// It returns nil when bits is less than 2. func GetRandomPrimeInt(bits int) *big.Int { if bits < 2 { return nil @@ -97,8 +118,10 @@ func GetRandomPrimeInt(bits int) *big.Int { // Generate a random element in the group of all the elements in Z/nZ that // has a multiplicative inverse. +// Returns nil when n is nil, not greater than 1, or wider than +// mustGetRandomIntMaxBits bits. func GetRandomPositiveRelativelyPrimeInt(n *big.Int) *big.Int { - if n == nil || n.Cmp(one) <= 0 { + if n == nil || n.Cmp(one) <= 0 || exceedsSamplerCap(n) { return nil } var try *big.Int @@ -120,10 +143,13 @@ func IsNumberInMultiplicativeGroup(n, v *big.Int) bool { gcd.GCD(nil, nil, v, n).Cmp(one) == 0 } -// Return a random generator of RQn with high probability. -// THIS METHOD ONLY WORKS IF N IS THE PRODUCT OF TWO SAFE PRIMES! +// Return a random generator of RQn with high probability. +// THIS METHOD ONLY WORKS IF N IS THE PRODUCT OF TWO SAFE PRIMES! // // https://github.com/didiercrunch/paillier/blob/d03e8850a8e4c53d04e8016a2ce8762af3278b71/utils.go#L39 +// +// Returns nil when n is nil, not greater than 1, or wider than +// mustGetRandomIntMaxBits bits. func GetRandomGeneratorOfTheQuadraticResidue(n *big.Int) *big.Int { f := GetRandomPositiveRelativelyPrimeInt(n) if f == nil { @@ -134,6 +160,7 @@ func GetRandomGeneratorOfTheQuadraticResidue(n *big.Int) *big.Int { } // Sample an integer in range (-2^power, 2^power) +// Returns nil when the range is wider than the GetRandomInt cap. func GetRandomIntIn2PowerRange(power uint) *big.Int { limit := big.NewInt(1) limit.Lsh(limit, power) @@ -141,6 +168,8 @@ func GetRandomIntIn2PowerRange(power uint) *big.Int { } // Sample an integer in range (-2^power * multiplier, 2^power * multiplier) +// Returns nil when multiplier is not positive or the range is wider than +// the GetRandomInt cap. func GetRandomIntIn2PowerMulRange(power uint, multiplier *big.Int) *big.Int { limit := big.NewInt(1) limit.Lsh(limit, power) diff --git a/common/random_test.go b/common/random_test.go index ca1b2c9f4..9114b60ea 100644 --- a/common/random_test.go +++ b/common/random_test.go @@ -81,3 +81,38 @@ func TestGetRandomPrimeIntRejectsEmptyDomain(t *testing.T) { assert.True(t, prime.ProbablyPrime(50)) } } + +// TestSamplersReturnNilAboveCap pins the sampler width contract: a limit +// wider than the 5000-bit MustGetRandomInt cap gives nil instead of a panic, +// so callers that check for nil can return an error to their caller. A +// limit at exactly the cap still samples. +func TestSamplersReturnNilAboveCap(t *testing.T) { + const capBits = 5000 + atCap := new(big.Int).Lsh(big.NewInt(1), capBits-1) + atCap.Add(atCap, big.NewInt(1)) // odd, exactly capBits bits + aboveCap := new(big.Int).Lsh(big.NewInt(1), capBits) + aboveCap.Add(aboveCap, big.NewInt(1)) // odd, capBits+1 bits + + samplers := map[string]func(*big.Int) *big.Int{ + "GetRandomPositiveInt": common.GetRandomPositiveInt, + "GetRandomPositiveRelativelyPrimeInt": common.GetRandomPositiveRelativelyPrimeInt, + "GetRandomGeneratorOfTheQuadraticResidue": common.GetRandomGeneratorOfTheQuadraticResidue, + "GetRandomInt": common.GetRandomInt, + } + for name, sample := range samplers { + t.Run(name, func(t *testing.T) { + assert.NotPanics(t, func() { + assert.Nil(t, sample(aboveCap), "limit above the cap must give nil") + }) + }) + } + // GetRandomInt samples below 2*limit-1, so its widest usable limit is + // one bit narrower than the cap. + assert.Nil(t, common.GetRandomInt(atCap)) + assert.NotNil(t, common.GetRandomInt(new(big.Int).Rsh(atCap, 1))) + assert.Nil(t, common.GetRandomIntIn2PowerRange(capBits)) + assert.Nil(t, common.GetRandomIntIn2PowerMulRange(capBits-10, big.NewInt(1<<20))) + + assert.NotNil(t, common.GetRandomPositiveInt(atCap)) + assert.NotNil(t, common.GetRandomPositiveRelativelyPrimeInt(atCap)) +} diff --git a/common/validation.go b/common/validation.go index 594070c70..3651d8c76 100644 --- a/common/validation.go +++ b/common/validation.go @@ -11,41 +11,46 @@ import "math/big" 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. + // modulus this library's exported verifiers will accept. 2048 bits is + // the width the wire path pins and the smallest unknown-order modulus + // this protocol supports. 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. + // unknown-order modulus the exported verifiers will accept. Verifier CPU + // cost with a caller-supplied modulus grows with width -- at least + // linearly for the width and modexp-style work, superlinearly for the + // ProbablyPrime call below and for GCD -- so this ceiling caps the + // worst-case work an untrusted caller can force through an exported + // verify entry point; it bounds memory and worst-case cost, it does not + // make that cost linear. The ceiling is 32x the 2048-bit wire-pinned + // width, so it excludes nothing this library's wire path can produce. 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 { +// exceedsUnknownOrderModulusCeiling reports whether N is wider than +// MaxUnknownOrderModulusBitLen. It is an O(1) width check on public data +// (no primality work, no modulus-sized allocation). +// IsUsableUnknownOrderModulus runs it before its ProbablyPrime call, so an +// oversized caller-supplied modulus is rejected before any modulus-sized +// work runs against it; verifiers need no separate width check. +func exceedsUnknownOrderModulusCeiling(N *big.Int) bool { return N != nil && N.BitLen() > MaxUnknownOrderModulusBitLen } +// IsUsableUnknownOrderModulus reports whether N is an acceptable +// unknown-order modulus for the exported verifiers: positive, odd, at least +// minBitLen bits, not wider than MaxUnknownOrderModulusBitLen, and composite +// (it fails the probabilistic primality test). The width checks run before +// the ProbablyPrime call (see exceedsUnknownOrderModulusCeiling). func IsUsableUnknownOrderModulus(N *big.Int, minBitLen int) bool { - return N != nil && - N.Sign() == 1 && - N.Bit(0) == 1 && - N.BitLen() >= minBitLen && - !N.ProbablyPrime(primalityRounds) + if N == nil || N.Sign() != 1 || N.Bit(0) != 1 { + return false + } + if exceedsUnknownOrderModulusCeiling(N) || N.BitLen() < minBitLen { + return false + } + return !N.ProbablyPrime(primalityRounds) } func IsCanonicalGenerator(N, v *big.Int) bool { diff --git a/common/validation_test.go b/common/validation_test.go new file mode 100644 index 000000000..a703a5745 --- /dev/null +++ b/common/validation_test.go @@ -0,0 +1,158 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package common_test + +import ( + "crypto/rand" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" +) + +// oddCompositeAtBits builds an odd composite with exactly `bits` bits +// (bits >= 4): 3 * 2^(bits-2) + 3 is odd and divisible by 3, sits above +// 2^(bits-1), and stays below 2^bits, so it is a deterministic odd +// composite of the requested width. +func oddCompositeAtBits(bits int) *big.Int { + n := new(big.Int).Lsh(big.NewInt(3), uint(bits-2)) + n.Add(n, big.NewInt(3)) + return n +} + +// oddPrime2048 returns a 2048-bit prime from crypto/rand with a bounded +// retry, so the suite never spins on a bad entropy draw. +func oddPrime2048(t *testing.T) *big.Int { + t.Helper() + for range 25 { + if p, err := rand.Prime(rand.Reader, 2048); err == nil { + return p + } + } + t.Fatal("crypto/rand failed to generate a 2048-bit prime") + return nil +} + +func TestIsUsableUnknownOrderModulus(t *testing.T) { + // A 2048-bit prime passes the width and parity checks but fails the + // primality test: a known-order modulus is not an unknown-order one. + // The usable case is the deterministic odd composite at exactly the + // floor width. + cases := []struct { + name string + N *big.Int + want bool + }{ + {"nil", nil, false}, + {"negative", big.NewInt(-2049), false}, + {"zero", big.NewInt(0), false}, + {"one", big.NewInt(1), false}, + {"small_odd", big.NewInt(2049), false}, + {"even_at_floor_width", new(big.Int).Lsh(big.NewInt(1), common.MinUnknownOrderModulusBitLen-1), false}, + {"odd_composite_below_floor", oddCompositeAtBits(common.MinUnknownOrderModulusBitLen - 1), false}, + {"odd_composite_at_floor", oddCompositeAtBits(common.MinUnknownOrderModulusBitLen), true}, + {"odd_composite_inside_ceiling", oddCompositeAtBits(2060), true}, + {"odd_composite_past_ceiling", oddCompositeAtBits(common.MaxUnknownOrderModulusBitLen + 1), false}, + {"prime_at_floor", oddPrime2048(t), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := common.IsUsableUnknownOrderModulus(tc.N, common.MinUnknownOrderModulusBitLen) + require.Equal(t, tc.want, got, "IsUsableUnknownOrderModulus") + }) + } +} + +// TestIsUsableUnknownOrderModulusCeilingFirst pins the width-first behavior: +// a modulus wider than the ceiling is rejected by IsUsableUnknownOrderModulus +// on width alone, before any 65537-bit primality work; the same construction +// at exactly the ceiling does not trip the width check. +func TestIsUsableUnknownOrderModulusCeilingFirst(t *testing.T) { + wide := oddCompositeAtBits(common.MaxUnknownOrderModulusBitLen + 1) + require.Equal(t, common.MaxUnknownOrderModulusBitLen+1, wide.BitLen()) + require.Equal(t, uint(1), wide.Bit(0)) + // Without the width check this composite would be reported usable; the + // width rejection is the whole point. + require.False(t, common.IsUsableUnknownOrderModulus(wide, common.MinUnknownOrderModulusBitLen), + "65537-bit odd composite must be rejected by the width check") + + atCeiling := oddCompositeAtBits(common.MaxUnknownOrderModulusBitLen) + require.Equal(t, common.MaxUnknownOrderModulusBitLen, atCeiling.BitLen()) + // The width check is inclusive: the 65536-bit composite still passes + // width and is accepted on its (composite) order. + require.True(t, common.IsUsableUnknownOrderModulus(atCeiling, common.MinUnknownOrderModulusBitLen), + "65536-bit odd composite must stay usable") +} + +func TestIsCanonicalGenerator(t *testing.T) { + // Deterministic 2048-bit odd composite modulus: 3 * 2^2046 + 3. + n := oddCompositeAtBits(common.MinUnknownOrderModulusBitLen) + require.Equal(t, common.MinUnknownOrderModulusBitLen, n.BitLen()) + require.Equal(t, uint(1), n.Bit(0)) + + // 2^1024 is in (1, n) and coprime to the odd modulus n, so it must be + // accepted as a canonical generator. 3 divides n, so it must not be. + vValid := new(big.Int).Lsh(big.NewInt(1), 1024) + + cases := []struct { + name string + N *big.Int + v *big.Int + want bool + }{ + {"nil_N", nil, big.NewInt(5), false}, + {"zero_v", n, big.NewInt(0), false}, + {"one_v", n, big.NewInt(1), false}, + {"v_at_modulus", n, new(big.Int).Set(n), false}, + {"v_above_modulus", n, new(big.Int).Add(n, big.NewInt(1)), false}, + {"negative_v", n, big.NewInt(-5), false}, + {"shares_factor_v", n, big.NewInt(3), false}, + {"valid_generator", n, vValid, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, common.IsCanonicalGenerator(tc.N, tc.v), + "IsCanonicalGenerator") + }) + } +} + +func TestIsCanonicalPaillierCiphertext(t *testing.T) { + // Deterministic 2048-bit odd composite modulus; N^2 bounds the range. + n := oddCompositeAtBits(common.MinUnknownOrderModulusBitLen) + require.Equal(t, common.MinUnknownOrderModulusBitLen, n.BitLen()) + nSquared := new(big.Int).Mul(n, n) + + // 2^1024 is in (0, N^2) and coprime to the odd modulus n, so it is a + // canonical ciphertext. 3 divides n, so a ciphertext of 3 is not. + cValid := new(big.Int).Lsh(big.NewInt(1), 1024) + + cases := []struct { + name string + c *big.Int + N *big.Int + want bool + }{ + {"nil_c", nil, n, false}, + {"nil_N", cValid, nil, false}, + {"negative_c", big.NewInt(-4097), n, false}, + {"zero_c", big.NewInt(0), n, false}, + {"one_c", big.NewInt(1), n, true}, + {"in_range_coprime", cValid, n, true}, + {"shares_factor", big.NewInt(3), n, false}, + {"at_nsquared", nSquared, n, false}, + {"above_nsquared", new(big.Int).Add(nSquared, big.NewInt(1)), n, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, common.IsCanonicalPaillierCiphertext(tc.c, tc.N), + "IsCanonicalPaillierCiphertext") + }) + } +} diff --git a/crypto/ckd/child_key_derivation.go b/crypto/ckd/child_key_derivation.go index 3632f301e..d9d6cb5e4 100644 --- a/crypto/ckd/child_key_derivation.go +++ b/crypto/ckd/child_key_derivation.go @@ -105,7 +105,7 @@ func NewExtendedKeyFromString(key string, curve elliptic.Curve) (*ExtendedKey, e var pubKey ecdsa.PublicKey - if _, ok := curve.(*btcec.KoblitzCurve); ok { + if crypto.SameCurve(curve, btcec.S256()) { // Ensure the public key parses correctly and is actually on the // secp256k1 curve. pk, err := btcec.ParsePubKey(keyData) @@ -115,7 +115,13 @@ func NewExtendedKeyFromString(key string, curve elliptic.Curve) (*ExtendedKey, e pubKey = *pk.ToECDSA() pubKey.Curve = curve } else { - px, py := elliptic.Unmarshal(curve, keyData) + // keyData is the 33-byte compressed point, so decode it as one. + px, py := elliptic.UnmarshalCompressed(curve, keyData) + // elliptic.UnmarshalCompressed reports an undecodable point as nil + // coordinates; fail closed instead of returning a key with nil X/Y. + if px == nil || py == nil { + return nil, errors.New("invalid extended key: cannot decode public key") + } pubKey = ecdsa.PublicKey{ Curve: curve, X: px, diff --git a/crypto/ckd/child_key_derivation_test.go b/crypto/ckd/child_key_derivation_test.go index 0fe661392..36a12f4f5 100644 --- a/crypto/ckd/child_key_derivation_test.go +++ b/crypto/ckd/child_key_derivation_test.go @@ -7,6 +7,10 @@ package ckd_test import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "math/big" "testing" . "github.com/bnb-chain/tss-lib/crypto/ckd" @@ -162,3 +166,84 @@ tests: } } } + +// koblitzShim is a non-btcec elliptic.Curve value with the secp256k1 domain +// parameters. Its IsOnCurve always reports failure, so the stdlib point +// decoding path would reject every key. +type koblitzShim struct { + elliptic.Curve +} + +func (koblitzShim) IsOnCurve(x, y *big.Int) bool { + return false +} + +// TestNewExtendedKeyFromStringParsesByCurveParameters covers a non-btcec +// secp256k1 curve value: the parser must be selected by the curve's domain +// parameters, so a key that the stdlib decode path would silently mangle (nil +// coordinates) still parses successfully. +func TestNewExtendedKeyFromStringParsesByCurveParameters(t *testing.T) { + const master = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8" + inner := btcec.S256() + shim := koblitzShim{Curve: inner} + + key, err := NewExtendedKeyFromString(master, &shim) + if err != nil { + t.Fatalf("NewExtendedKeyFromString with a secp256k1-parameters shim curve: %v", err) + } + if key.X == nil || key.Y == nil { + t.Fatal("parsed key has nil coordinates on a secp256k1-parameters curve") + } + if !inner.IsOnCurve(key.X, key.Y) { + t.Fatal("parsed key coordinates are not on secp256k1") + } + if key.Curve != &shim { + t.Fatal("parsed key did not retain the supplied curve instance") + } + if got, want := key.String(), master; got != want { + t.Fatalf("parsed key serializes to %q, want %q", got, want) + } +} + +// TestNewExtendedKeyFromStringRejectsUnparseableCurve covers a curve that +// cannot decode the key data: 33 bytes is not a compressed P-521 point, so +// the function must return an error instead of a key with nil coordinates. +func TestNewExtendedKeyFromStringRejectsUnparseableCurve(t *testing.T) { + const master = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8" + + _, err := NewExtendedKeyFromString(master, elliptic.P521()) + if err == nil { + t.Fatal("NewExtendedKeyFromString must fail when the curve cannot parse the key data") + } +} + +// TestNewExtendedKeyFromStringRoundTripsP256 covers the non-secp256k1 parse +// path with a curve that fits the 33-byte compressed key field: a P-256 key +// serialized by String must parse back to the same point. +func TestNewExtendedKeyFromStringRoundTripsP256(t *testing.T) { + curve := elliptic.P256() + x, y := curve.ScalarBaseMult(big.NewInt(0x5eed).Bytes()) + key := &ExtendedKey{ + PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, + Depth: 1, + ChildIndex: 7, + ChainCode: bytes.Repeat([]byte{0x42}, 32), + ParentFP: []byte{1, 2, 3, 4}, + Version: []byte{0x04, 0x88, 0xb2, 0x1e}, + } + encoded := key.String() + + parsed, err := NewExtendedKeyFromString(encoded, curve) + if err != nil { + t.Fatalf("NewExtendedKeyFromString on a P-256 key: %v", err) + } + if parsed.X.Cmp(x) != 0 || parsed.Y.Cmp(y) != 0 { + t.Fatal("parsed P-256 key does not match the serialized point") + } + if parsed.Curve != curve { + t.Fatal("parsed key did not retain the supplied curve instance") + } + if parsed.String() != encoded { + t.Fatal("parsed P-256 key does not serialize back to the same string") + } +} diff --git a/crypto/dlnproof/proof.go b/crypto/dlnproof/proof.go index 03de2511f..422209e7b 100644 --- a/crypto/dlnproof/proof.go +++ b/crypto/dlnproof/proof.go @@ -86,12 +86,6 @@ func (p *Proof) Verify(h1, h2, N *big.Int, session ...[]byte) bool { if p == nil { return false } - // 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 } diff --git a/crypto/dlnproof/proof_test.go b/crypto/dlnproof/proof_test.go index 9b9243b6a..5852b2394 100644 --- a/crypto/dlnproof/proof_test.go +++ b/crypto/dlnproof/proof_test.go @@ -7,8 +7,15 @@ package dlnproof import ( + "context" + "errors" "math/big" + "runtime" + "sync" "testing" + "time" + + "github.com/stretchr/testify/assert" "github.com/bnb-chain/tss-lib/common" ) @@ -29,95 +36,275 @@ func TestLegacyChallengeMatchesHistoricalTranscript(t *testing.T) { } func TestDLNProofRejectsEmptySessionTag(t *testing.T) { - assertPanics(t, func() { + assert.PanicsWithValue(t, "dlnproof: session tag must be non-empty", func() { _ = NewDLNProof(nil, nil, nil, nil, nil, nil, []byte{}) }) } -func TestDLNProofVerifyRejectsOverwideT(t *testing.T) { +func TestDLNProofVerifyRejectsNilInputs(t *testing.T) { + // N=23 keeps these as nil-input no-panic checks; the width floor rejects + // before the nil-input checks run. proof := &Proof{} - for i := 0; i < Iterations; i++ { + for i := range Iterations { proof.Alpha[i] = big.NewInt(2) proof.T[i] = big.NewInt(2) } - proof.T[0] = big.NewInt(25) - if proof.Verify(big.NewInt(2), big.NewInt(3), big.NewInt(23)) { - t.Fatal("Verify must reject T values outside [2, N)") + if proof.Verify(nil, big.NewInt(3), big.NewInt(23)) { + t.Fatal("Verify must reject nil h1") + } + if proof.Verify(big.NewInt(2), nil, big.NewInt(23)) { + t.Fatal("Verify must reject nil h2") + } + if proof.Verify(big.NewInt(2), big.NewInt(3), nil) { + t.Fatal("Verify must reject nil N") } } -func TestDLNProofVerifyRejectsOverwideAlpha(t *testing.T) { - proof := &Proof{} - for i := 0; i < Iterations; i++ { - proof.Alpha[i] = big.NewInt(2) - proof.T[i] = big.NewInt(2) - } - proof.Alpha[0] = big.NewInt(25) +// dlnVerifyFixture holds a 2048-bit ring-Pedersen instance and a valid DLN proof +// of the witness alpha, built once per test binary so the expensive 1024-bit +// safe-prime generation runs at most once. +type dlnVerifyFixture struct { + proof *Proof + h1, h2 *big.Int + N *big.Int + alpha *big.Int // the witness: h2 = h1^alpha mod N + p, q *big.Int // Germain primes with N = (2p+1)(2q+1); ord(h1) divides p*q + factor *big.Int // the safe prime 2p+1, a divisor of N and so not a unit mod N +} + +var ( + dlnVerifyFixtureOnce sync.Once + dlnVerifyFixtureVal *dlnVerifyFixture + dlnVerifyFixtureErr error +) - if proof.Verify(big.NewInt(2), big.NewInt(3), big.NewInt(23)) { - t.Fatal("Verify must reject Alpha values outside [2, N)") +// newDLNVerifyFixture returns the shared 2048-bit DLN instance. The N=23 +// moduli used elsewhere cannot exercise the field checks because the width +// floor rejects them first. +func newDLNVerifyFixture(t *testing.T) *dlnVerifyFixture { + t.Helper() + dlnVerifyFixtureOnce.Do(func() { + dlnVerifyFixtureVal, dlnVerifyFixtureErr = buildDLNVerifyFixture() + }) + if dlnVerifyFixtureErr != nil { + t.Fatalf("building DLN verify fixture: %v", dlnVerifyFixtureErr) } + return dlnVerifyFixtureVal } -func TestDLNProofVerifyRejectsNilInputs(t *testing.T) { - proof := &Proof{} - for i := 0; i < Iterations; i++ { - proof.Alpha[i] = big.NewInt(2) - proof.T[i] = big.NewInt(2) +func buildDLNVerifyFixture() (*dlnVerifyFixture, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + sgps, err := common.GetRandomSafePrimesConcurrent(ctx, 1024, 2, runtime.NumCPU()) + if err != nil { + return nil, err } + np, nq := sgps[0].SafePrime(), sgps[1].SafePrime() + N := new(big.Int).Mul(np, nq) + modN := common.ModInt(N) - if proof.Verify(nil, big.NewInt(3), big.NewInt(23)) { - t.Fatal("Verify must reject nil h1") + f1 := common.GetRandomPositiveRelativelyPrimeInt(N) + alpha := common.GetRandomPositiveRelativelyPrimeInt(N) + h1 := modN.Mul(f1, f1) + h2 := modN.Exp(h1, alpha) + + proof := NewDLNProof(h1, h2, alpha, sgps[0].Prime(), sgps[1].Prime(), N) + if !proof.Verify(h1, h2, N) { + return nil, errors.New("fixture DLN proof did not verify on its own domain") } - if proof.Verify(big.NewInt(2), nil, big.NewInt(23)) { - t.Fatal("Verify must reject nil h2") + if N.BitLen() != common.MinUnknownOrderModulusBitLen { + return nil, errors.New("fixture modulus is not the minimum width") } - if proof.Verify(big.NewInt(2), big.NewInt(3), nil) { - t.Fatal("Verify must reject nil N") + return &dlnVerifyFixture{ + proof: proof, + h1: h1, + h2: h2, + N: N, + alpha: alpha, + p: sgps[0].Prime(), + q: sgps[1].Prime(), + factor: np, + }, nil +} + +// TestDLNProofVerifyValidFixture is the positive control for the reject +// tests: the unmutated fixture proof must verify on its own 2048-bit modulus, +// so any reject that asserts false is pinning the check it names rather than +// a broken accept path. +func TestDLNProofVerifyValidFixture(t *testing.T) { + fx := newDLNVerifyFixture(t) + if !fx.proof.Verify(fx.h1, fx.h2, fx.N) { + t.Fatal("a valid DLN proof must verify on its own 2048-bit modulus") } } -func TestDLNProofVerifyRejectsNilProofElements(t *testing.T) { - proof := &Proof{} - for i := 0; i < Iterations; i++ { - proof.Alpha[i] = big.NewInt(2) - proof.T[i] = big.NewInt(2) +// TestDLNProofVerifyRejectsOverwideT rejects T[0] values that fall outside +// the canonical (1, N) range. Without a real modulus width the named check +// is never reached. +func TestDLNProofVerifyRejectsOverwideT(t *testing.T) { + fx := newDLNVerifyFixture(t) + + for _, tc := range []struct { + name string + T *big.Int + }{ + {"nil", nil}, + {"one", big.NewInt(1)}, + {"modulus", fx.N}, + {"overwide", new(big.Int).Add(fx.N, big.NewInt(1))}, + } { + t.Run(tc.name, func(t *testing.T) { + proof := *fx.proof + proof.T[0] = tc.T + assert.NotPanics(t, func() { + if proof.Verify(fx.h1, fx.h2, fx.N) { + t.Fatalf("Verify must reject T[0] = %v outside (1, N)", tc.T) + } + }) + }) + } +} + +// TestDLNProofVerifyRejectsEquationValidOverwideT isolates the T range +// check. h1 is a square mod N, so its order divides p*q, and T[0] + 5*p*q +// gives the same h1^T[0]. N = 4pq + 2p + 2q + 1 < 5pq, so the shifted T[0] +// is at least N. T is not part of the challenge, so every verification +// equation still holds and only the range check can reject the proof. +func TestDLNProofVerifyRejectsEquationValidOverwideT(t *testing.T) { + fx := newDLNVerifyFixture(t) + modN := common.ModInt(fx.N) + + shift := new(big.Int).Mul(fx.p, fx.q) + shift.Mul(shift, big.NewInt(5)) + proof := *fx.proof + proof.T[0] = new(big.Int).Add(fx.proof.T[0], shift) + + assert.GreaterOrEqual(t, proof.T[0].Cmp(fx.N), 0, "shifted T[0] must be at least N") + assert.Zero(t, modN.Exp(fx.h1, proof.T[0]).Cmp(modN.Exp(fx.h1, fx.proof.T[0])), + "the shift must keep h1^T[0], so the equation still holds") + assert.False(t, proof.Verify(fx.h1, fx.h2, fx.N), + "Verify must reject T[0] >= N even when the equation holds") +} + +// TestDLNProofVerifyRejectsOverwideAlpha rejects Alpha[0] values that are +// not canonical generators of the modulus. "non-unit" is the safe prime +// 2p+1, a divisor of N, so it is outside the multiplicative group mod N. +func TestDLNProofVerifyRejectsOverwideAlpha(t *testing.T) { + fx := newDLNVerifyFixture(t) + + for _, tc := range []struct { + name string + Alpha *big.Int + }{ + {"nil", nil}, + {"one", big.NewInt(1)}, + {"modulus", fx.N}, + {"non-unit", fx.factor}, + } { + t.Run(tc.name, func(t *testing.T) { + proof := *fx.proof + proof.Alpha[0] = tc.Alpha + assert.NotPanics(t, func() { + if proof.Verify(fx.h1, fx.h2, fx.N) { + t.Fatalf("Verify must reject Alpha[0] = %v outside the canonical generators", tc.Alpha) + } + }) + }) + } +} + +// TestDLNProofVerifyRejectsEquationValidNonCanonicalAlpha isolates the +// Alpha canonical-generator check. The proof is built by the honest +// prover steps, except that N is added to Alpha[0] before the challenge is +// computed. The verifier reduces Alpha[0] mod N in the equation, so the +// equation still holds and the challenge matches. Only the +// canonical-generator check on Alpha can reject this proof. +func TestDLNProofVerifyRejectsEquationValidNonCanonicalAlpha(t *testing.T) { + fx := newDLNVerifyFixture(t) + + // Control: the same steps without the offset give a proof that + // verifies, so the reject below comes from the offset alone. + control := proveDLNWithAlpha0Offset(fx, big.NewInt(0)) + assert.True(t, control.Verify(fx.h1, fx.h2, fx.N), "control proof must verify") + + proof := proveDLNWithAlpha0Offset(fx, fx.N) + assert.False(t, common.IsCanonicalGenerator(fx.N, proof.Alpha[0]), "Alpha[0] must not be canonical") + assert.False(t, proof.Verify(fx.h1, fx.h2, fx.N), + "Verify must reject a non-canonical Alpha[0] even when the equation holds") +} + +// proveDLNWithAlpha0Offset runs the math/big prover steps of NewDLNProof on +// the fixture witness, but adds offset to Alpha[0] before the challenge is +// computed. +func proveDLNWithAlpha0Offset(fx *dlnVerifyFixture, offset *big.Int) *Proof { + pq := new(big.Int).Mul(fx.p, fx.q) + modN, modPQ := common.ModInt(fx.N), common.ModInt(pq) + var a, alpha, t [Iterations]*big.Int + for i := range alpha { + a[i] = common.GetRandomPositiveInt(pq) + alpha[i] = modN.Exp(fx.h1, a[i]) } + alpha[0] = new(big.Int).Add(alpha[0], offset) + c := proofChallenge(nil, append([]*big.Int{fx.h1, fx.h2, fx.N}, alpha[:]...)...) + for i := range t { + t[i] = modPQ.Add(a[i], modPQ.Mul(big.NewInt(int64(c.Bit(i))), fx.alpha)) + } + return &Proof{Alpha: alpha, T: t} +} - badAlpha := *proof - badAlpha.Alpha[0] = nil - assertNotPanics(t, func() { - if badAlpha.Verify(big.NewInt(2), big.NewInt(3), big.NewInt(23)) { - t.Fatal("Verify must reject nil Alpha values") - } +// TestDLNProofVerifyRejectsInvalidGenerators rejects degenerate generator +// pairs (h1 == h2) and the unit h1 = 1, on a valid 2048-bit domain. +func TestDLNProofVerifyRejectsInvalidGenerators(t *testing.T) { + fx := newDLNVerifyFixture(t) + + // h2 = h1^1, so the honest prover with witness x = 1 gives a proof + // whose equations all hold for the pair (h1, h1). Only the h1 == h2 + // check can reject it. + t.Run("h1 equals h2", func(t *testing.T) { + proof := NewDLNProof(fx.h1, fx.h1, big.NewInt(1), fx.p, fx.q, fx.N) + assert.NotPanics(t, func() { + assert.False(t, proof.Verify(fx.h1, fx.h1, fx.N), "Verify must reject h1 == h2") + }) }) - badT := *proof - badT.T[0] = nil - assertNotPanics(t, func() { - if badT.Verify(big.NewInt(2), big.NewInt(3), big.NewInt(23)) { - t.Fatal("Verify must reject nil T values") - } + t.Run("h1 is one", func(t *testing.T) { + assert.NotPanics(t, func() { + if fx.proof.Verify(big.NewInt(1), fx.h2, fx.N) { + t.Fatal("Verify must reject h1 = 1, which is not a canonical generator") + } + }) }) } -func assertNotPanics(t *testing.T, f func()) { - t.Helper() - defer func() { - if r := recover(); r != nil { - t.Fatalf("unexpected panic: %v", r) - } - }() - f() -} +// TestDLNProofVerifyModulusPolicy pins the unknown-order modulus policy on +// a valid 2048-bit domain. The valid proof is reused, only the modulus +// argument changes; each reject comes from a distinct width/parity/ceiling +// predicate. +func TestDLNProofVerifyModulusPolicy(t *testing.T) { + fx := newDLNVerifyFixture(t) -func assertPanics(t *testing.T, f func()) { - t.Helper() - defer func() { - if recover() == nil { - t.Fatal("expected panic") - } - }() - f() + underFloor := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), uint(common.MinUnknownOrderModulusBitLen-2)), big.NewInt(1)) // 2^2046 + 1: 2047 bits, odd + overCeiling := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), uint(common.MaxUnknownOrderModulusBitLen)), big.NewInt(1)) // 2^65536 + 1: 65537 bits, odd + evenAtFloor := new(big.Int).Lsh(big.NewInt(1), uint(common.MinUnknownOrderModulusBitLen-1)) // 2^2047: 2048 bits, even + + for _, tc := range []struct { + name string + N *big.Int + want bool + }{ + {"2048-bit control accepts", fx.N, true}, + {"2047-bit rejects by width floor", underFloor, false}, + {"65537-bit rejects by width ceiling", overCeiling, false}, + {"even rejects by parity", evenAtFloor, false}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.NotPanics(t, func() { + if got := fx.proof.Verify(fx.h1, fx.h2, tc.N); got != tc.want { + t.Fatalf("Verify with %d-bit modulus = %v, want %v", tc.N.BitLen(), got, tc.want) + } + }) + }) + } } diff --git a/crypto/mta/bob_bounds_test.go b/crypto/mta/bob_bounds_test.go new file mode 100644 index 000000000..7768c8c93 --- /dev/null +++ b/crypto/mta/bob_bounds_test.go @@ -0,0 +1,285 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package mta + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/crypto" + "github.com/bnb-chain/tss-lib/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/tss" +) + +// TestBobVerifierT1BoundEdges pins the exact T1 upper bound of each Bob +// verifier mode. The proofs use the honest prover equations with y = 0, so +// T1 = gamma + e*y = gamma for every challenge e. Setting gamma to the bound +// minus one and to the bound gives two proofs whose equations all hold and +// whose T1 sits on each side of the edge. Only the T1 bound check can tell +// them apart. +func TestBobVerifierT1BoundEdges(t *testing.T) { + fixtures, _, err := keygen.LoadKeygenTestFixtures(2) + if err != nil { + t.Fatal(err) + } + owner := fixtures[0] + aux := fixtures[1] + pk := &owner.PaillierSK.PublicKey + ec := tss.EC() + + x := big.NewInt(7) + y := big.NewInt(0) + r := firstSmallUnit(pk.N, 2) + c1 := fixedPaillierEncryption(pk, big.NewInt(5), firstSmallUnit(pk.N, 3)) + X := crypto.ScalarBaseMult(ec, x) + + q := ec.Params().N + q6 := new(big.Int).Exp(q, big.NewInt(6), nil) + q7 := new(big.Int).Mul(q6, q) + session := []byte("mta-test/bob/t1-bound") + + // verify checks a Bob proof when wcX is nil, and a BobWC proof for wcX + // otherwise. + type verifyFn func(pf *ProofBobWC, c2 *big.Int, wcX *crypto.ECPoint) bool + cases := []struct { + name string + bound *big.Int // exclusive upper bound on T1 + session []byte + verify verifyFn + }{ + { + // Session-less default: T1 < N + q^6. + name: "legacy default", + bound: new(big.Int).Add(pk.N, q6), + verify: func(pf *ProofBobWC, c2 *big.Int, wcX *crypto.ECPoint) bool { + if wcX == nil { + return pf.ProofBob.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2) + } + return pf.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, wcX) + }, + }, + { + // Opt-in historical compatibility: T1 < (q+1)*N. + name: "legacy historical compatibility", + bound: new(big.Int).Mul(new(big.Int).Add(q, one), pk.N), + verify: func(pf *ProofBobWC, c2 *big.Int, wcX *crypto.ECPoint) bool { + if wcX == nil { + return pf.ProofBob.VerifyLegacy(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, true) + } + return pf.VerifyLegacy(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, wcX, true) + }, + }, + { + // Session-bound: T1 <= q^7. + name: "session", + bound: new(big.Int).Add(q7, one), + session: session, + verify: func(pf *ProofBobWC, c2 *big.Int, wcX *crypto.ECPoint) bool { + if wcX == nil { + return pf.ProofBob.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, session) + } + return pf.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, wcX, session) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, edge := range []struct { + name string + gamma *big.Int + want bool + }{ + {"bound minus one accepted", new(big.Int).Sub(tc.bound, one), true}, + {"bound rejected", new(big.Int).Set(tc.bound), false}, + } { + t.Run(edge.name, func(t *testing.T) { + bob, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, edge.gamma, nil, tc.session) + bobWC, _ := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, edge.gamma, X, tc.session) + require.Zero(t, bob.T1.Cmp(edge.gamma), "y = 0 must give T1 = gamma") + require.Zero(t, bobWC.T1.Cmp(edge.gamma), "y = 0 must give T1 = gamma") + + assert.Equal(t, edge.want, tc.verify(bob, c2, nil), "Bob verdict") + assert.Equal(t, edge.want, tc.verify(bobWC, c2, X), "BobWC verdict") + }) + } + }) + } +} + +// TestAliceEndLegacyCompatFlagPlumbing pins the historicalBobCompat flag +// plumbing of the AliceEndLegacy/AliceEndWCLegacy entry points: with the flag +// off the tight N + q^6 bound rejects the historical-witness (y = N-1) +// proof, while the flag on admits it and decrypts. +func TestAliceEndLegacyCompatFlagPlumbing(t *testing.T) { + fixtures, _, err := keygen.LoadKeygenTestFixtures(2) + if err != nil { + t.Fatal(err) + } + owner := fixtures[0] + aux := fixtures[1] + pk := &owner.PaillierSK.PublicKey + ec := tss.EC() + + x := big.NewInt(7) + r := firstSmallUnit(pk.N, 2) + c1 := fixedPaillierEncryption(pk, big.NewInt(5), firstSmallUnit(pk.N, 3)) + y := new(big.Int).Sub(pk.N, one) + bobProof, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, nil, nil, nil) + q := ec.Params().N + tightMaxT1 := new(big.Int).Add(pk.N, new(big.Int).Exp(q, big.NewInt(6), nil)) + if bobProof.T1.Cmp(tightMaxT1) < 0 { + t.Fatal("y = N-1 did not place T1 above the tight N + q^6 bound; the compat flag is not exercised") + } + if _, err := AliceEndLegacy(ec, pk, bobProof.ProofBob, aux.H1i, aux.H2i, c1, c2, aux.NTildei, owner.PaillierSK, false); err == nil { + t.Fatal("AliceEndLegacy with compat off accepted a historical-witness proof") + } + if _, err := AliceEndLegacy(ec, pk, bobProof.ProofBob, aux.H1i, aux.H2i, c1, c2, aux.NTildei, owner.PaillierSK, true); err != nil { + t.Fatalf("AliceEndLegacy with compat on rejected the historical-witness proof: %v", err) + } + + X := crypto.ScalarBaseMult(ec, x) + wcProof, wcC2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, nil, X, nil) + if wcProof.T1.Cmp(tightMaxT1) < 0 { + t.Fatal("y = N-1 WC did not place T1 above the tight N + q^6 bound; the compat flag is not exercised") + } + if _, err := AliceEndWCLegacy(ec, pk, wcProof, X, c1, wcC2, aux.NTildei, aux.H1i, aux.H2i, owner.PaillierSK, false); err == nil { + t.Fatal("AliceEndWCLegacy with compat off accepted a historical-witness proof") + } + if _, err := AliceEndWCLegacy(ec, pk, wcProof, X, c1, wcC2, aux.NTildei, aux.H1i, aux.H2i, owner.PaillierSK, true); err != nil { + t.Fatalf("AliceEndWCLegacy with compat on rejected the historical-witness proof: %v", err) + } +} + +// ringPedersenOrderMultiple returns 4*P*Q for the keygen fixture party. With +// NTilde = (2P+1)(2Q+1), this is a multiple of the order of every unit mod +// NTilde, so adding it to an exponent of h1 or h2 does not change the +// result. +func ringPedersenOrderMultiple(t *testing.T, P, Q *big.Int) *big.Int { + t.Helper() + if P == nil || Q == nil { + t.Skip("keygen fixture party does not expose the NTilde safe-prime halves") + } + lambda := new(big.Int).Mul(P, Q) + return lambda.Lsh(lambda, 2) +} + +// shiftAroundBound returns v + (k-1)*step and v + k*step, where k >= 1 is +// the smallest value with v + k*step >= bound. The first value is below the +// bound and the second is at or above it. +func shiftAroundBound(v, step, bound *big.Int) (below, atOrAbove *big.Int) { + gap := new(big.Int).Sub(bound, v) + k := new(big.Int).Add(gap, new(big.Int).Sub(step, one)) + k.Div(k, step) + if k.Sign() <= 0 { + k.SetInt64(1) + } + atOrAbove = new(big.Int).Add(v, new(big.Int).Mul(k, step)) + below = new(big.Int).Sub(atOrAbove, step) + return below, atOrAbove +} + +// TestBobBoundCheckRejectsShiftedResponses pins the S2 and T2 bounds of the +// Bob verifier (S2, T2 < 2*q^3*NTilde). S2 and T2 appear only in the h1/h2 +// equations mod NTilde, so adding a multiple of 4*P*Q keeps every equation +// true. The response is shifted to the last value below the bound (must +// verify, which also proves the equations still hold) and to the first +// value at or above it (must be rejected by the bound check alone). +func TestBobBoundCheckRejectsShiftedResponses(t *testing.T) { + fixtures, _, err := keygen.LoadKeygenTestFixtures(2) + if err != nil { + t.Fatal(err) + } + owner := fixtures[0] + aux := fixtures[1] + lambda := ringPedersenOrderMultiple(t, aux.P, aux.Q) + pk := &owner.PaillierSK.PublicKey + ec := tss.EC() + + x := big.NewInt(7) + y := big.NewInt(11) + r := firstSmallUnit(pk.N, 2) + c1 := fixedPaillierEncryption(pk, big.NewInt(5), firstSmallUnit(pk.N, 3)) + + q := ec.Params().N + q3 := new(big.Int).Exp(q, big.NewInt(3), nil) + maxResponse := new(big.Int).Lsh(new(big.Int).Mul(q3, aux.NTildei), 1) + session := []byte("mta-test/bob/response-bound") + // gamma = q^7/2 keeps T1 below the session bound, so the unshifted + // proof is fully honest. + gamma := new(big.Int).Rsh(new(big.Int).Exp(q, big.NewInt(7), nil), 1) + proof, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, gamma, nil, session) + require.True(t, proof.ProofBob.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, session), + "unshifted proof must verify") + + for _, field := range []string{"S2", "T2"} { + t.Run(field, func(t *testing.T) { + value := proof.S2 + if field == "T2" { + value = proof.T2 + } + below, atOrAbove := shiftAroundBound(value, lambda, maxResponse) + for _, tc := range []struct { + name string + value *big.Int + want bool + }{ + {"last shift below the bound verifies", below, true}, + {"first shift at or above the bound is rejected", atOrAbove, false}, + } { + t.Run(tc.name, func(t *testing.T) { + shifted := cloneProofBobWC(proof) + if field == "S2" { + shifted.S2 = tc.value + } else { + shifted.T2 = tc.value + } + assert.Equal(t, tc.want, shifted.ProofBob.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, session)) + }) + } + }) + } +} + +// TestAliceBoundCheckRejectsShiftedS2 pins the S2 bound of the Alice range +// proof verifier (S2 < 2*q^3*NTilde) the same way: S2 appears only in the +// h1/h2 equation mod NTilde, so a shift by a multiple of 4*P*Q keeps the +// equations true and only the bound check can reject. +func TestAliceBoundCheckRejectsShiftedS2(t *testing.T) { + fixtures, _, err := keygen.LoadKeygenTestFixtures(2) + if err != nil { + t.Fatal(err) + } + owner := fixtures[0] + aux := fixtures[1] + lambda := ringPedersenOrderMultiple(t, aux.P, aux.Q) + pk := &owner.PaillierSK.PublicKey + ec := tss.EC() + + m := big.NewInt(424242) + rMsg := firstSmallUnit(pk.N, 2) + c := fixedPaillierEncryption(pk, m, rMsg) + session := []byte("mta-test/alice/s2-bound") + proof, err := ProveRangeAlice(ec, pk, c, aux.NTildei, aux.H1i, aux.H2i, m, rMsg, session) + require.NoError(t, err) + require.True(t, proof.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c, session), "unshifted proof must verify") + + q3 := new(big.Int).Exp(ec.Params().N, big.NewInt(3), nil) + maxS2 := new(big.Int).Lsh(new(big.Int).Mul(q3, aux.NTildei), 1) + below, atOrAbove := shiftAroundBound(proof.S2, lambda, maxS2) + + accepted := *proof + accepted.S2 = below + assert.True(t, accepted.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c, session), + "last shift below the bound must verify") + rejected := *proof + rejected.S2 = atOrAbove + assert.False(t, rejected.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c, session), + "first shift at or above the bound must be rejected") +} diff --git a/crypto/mta/bob_mid_bench_test.go b/crypto/mta/bob_mid_bench_test.go new file mode 100644 index 000000000..bea00db4f --- /dev/null +++ b/crypto/mta/bob_mid_bench_test.go @@ -0,0 +1,105 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package mta + +import ( + "math/big" + "testing" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/crypto" + "github.com/bnb-chain/tss-lib/crypto/paillier" + "github.com/bnb-chain/tss-lib/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/tss" +) + +// bobMidBenchInput holds the fixed inputs for one Bob step of the MtA share +// protocol. Alice's ciphertext and range proof are made once, outside the +// timed loop, so the benchmark measures only Bob's work. +type bobMidBenchInput struct { + pk *paillier.PublicKey + b, cA *big.Int + NTildeA, h1A, h2A *big.Int + NTildeB, h1B, h2B *big.Int + pfA *RangeProofAlice + session []byte +} + +// setupBobMidBench loads committed fixtures, turns constant-time mode on and +// binds the proofs to a session, which is the security-v2 signing path. +func setupBobMidBench(b *testing.B) (*bobMidBenchInput, func()) { + b.Helper() + previousMode := common.IsConstantTimeEnabled() + common.EnableConstantTimeOps() + restore := func() { + if previousMode { + common.EnableConstantTimeOps() + } else { + common.DisableConstantTimeOps() + } + } + + _, pk, err := loadPaillierKeyFixture(0) + if err != nil { + restore() + b.Fatal(err) + } + NTildeA, h1A, h2A, err := keygen.LoadNTildeH1H2FromTestFixture(0) + if err != nil { + restore() + b.Fatal(err) + } + NTildeB, h1B, h2B, err := keygen.LoadNTildeH1H2FromTestFixture(1) + if err != nil { + restore() + b.Fatal(err) + } + + q := tss.EC().Params().N + session := []byte("bob-mid-benchmark-session") + a := common.GetRandomPositiveInt(q) + cA, pfA, err := AliceInit(tss.EC(), pk, a, NTildeB, h1B, h2B, session) + if err != nil { + restore() + b.Fatal(err) + } + in := &bobMidBenchInput{ + pk: pk, + b: common.GetRandomPositiveInt(q), + cA: cA, + NTildeA: NTildeA, h1A: h1A, h2A: h2A, + NTildeB: NTildeB, h1B: h1B, h2B: h2B, + pfA: pfA, + session: session, + } + return in, restore +} + +func BenchmarkBobMid(b *testing.B) { + in, restore := setupBobMidBench(b) + defer restore() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, _, _, _, err := BobMid(tss.EC(), in.pk, in.pfA, in.b, in.cA, + in.NTildeA, in.h1A, in.h2A, in.NTildeB, in.h1B, in.h2B, in.session); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkBobMidWC(b *testing.B) { + in, restore := setupBobMidBench(b) + defer restore() + B := crypto.ScalarBaseMult(tss.EC(), in.b) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, _, _, _, err := BobMidWC(tss.EC(), in.pk, in.pfA, in.b, in.cA, + in.NTildeA, in.h1A, in.h2A, in.NTildeB, in.h1B, in.h2B, B, in.session); err != nil { + b.Fatal(err) + } + } +} diff --git a/crypto/mta/bob_mid_witness_test.go b/crypto/mta/bob_mid_witness_test.go new file mode 100644 index 000000000..355b9ed01 --- /dev/null +++ b/crypto/mta/bob_mid_witness_test.go @@ -0,0 +1,59 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package mta + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/crypto" + "github.com/bnb-chain/tss-lib/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/tss" +) + +// TestBobMidRejectsOutOfRangeWitness pins that Bob's MtA scalar b must be a +// curve-order scalar. b = q and b = -1 must return an error, not a share: +// the constant-time exponent is padded to q's width, so a b outside [0, q) +// must never reach it, and a result for such b would not be a valid share. +func TestBobMidRejectsOutOfRangeWitness(t *testing.T) { + ec := tss.EC() + q := ec.Params().N + _, pk, err := loadPaillierKeyFixture(0) + require.NoError(t, err) + NTildeA, h1A, h2A, err := keygen.LoadNTildeH1H2FromTestFixture(0) + require.NoError(t, err) + NTildeB, h1B, h2B, err := keygen.LoadNTildeH1H2FromTestFixture(1) + require.NoError(t, err) + session := []byte("bob-mid-witness") + + cA, pfA, err := AliceInit(ec, pk, common.GetRandomPositiveInt(q), NTildeB, h1B, h2B, session) + require.NoError(t, err) + B := crypto.ScalarBaseMult(ec, big.NewInt(1)) + + for name, b := range map[string]*big.Int{ + "b=q": new(big.Int).Set(q), + "b=-1": big.NewInt(-1), + } { + t.Run(name, func(t *testing.T) { + _, cB, _, piB, err := BobMid(ec, pk, pfA, b, cA, NTildeA, h1A, h2A, NTildeB, h1B, h2B, session) + assert.Error(t, err, "BobMid") + assert.NotErrorIs(t, err, ErrRangeProofVerify, "a local witness error is not a peer proof failure") + assert.Nil(t, cB) + assert.Nil(t, piB) + + _, cB, _, piBWC, err := BobMidWC(ec, pk, pfA, b, cA, NTildeA, h1A, h2A, NTildeB, h1B, h2B, B, session) + assert.Error(t, err, "BobMidWC") + assert.NotErrorIs(t, err, ErrRangeProofVerify, "a local witness error is not a peer proof failure") + assert.Nil(t, cB) + assert.Nil(t, piBWC) + }) + } +} diff --git a/crypto/mta/constant_time_equiv_test.go b/crypto/mta/constant_time_equiv_test.go index 2476ab340..2da3643a7 100644 --- a/crypto/mta/constant_time_equiv_test.go +++ b/crypto/mta/constant_time_equiv_test.go @@ -286,3 +286,73 @@ func TestShareProtocolWCConstantTime(t *testing.T) { aTimesBPlusBetaModQ := new(big.Int).Mod(aTimesBPlusBeta, q) assert.Equal(t, 0, alpha.Cmp(aTimesBPlusBetaModQ), "constant-time MtA must yield alpha = ab + betaPrm") } + +// TestMtaProversAtCurveOrderEdge pins the constant-time exponent widths at +// the top of the curve-order witness domain: x = q-1 for ProveBob and +// ProveBobWC, m = q-1 for ProveRangeAlice, and b = q-1 for BobMid and +// BobMidWC. These witnesses use the full q.BitLen() width, so an exponent +// padded to fewer bits panics or gives a wrong result. +func TestMtaProversAtCurveOrderEdge(t *testing.T) { + key := mtaFixtureKey(t) + pk := &key.PublicKey + NTildei, h1i, h2i, err := keygen.LoadNTildeH1H2FromTestFixture(0) + require.NoError(t, err) + NTildej, h1j, h2j, err := keygen.LoadNTildeH1H2FromTestFixture(1) + require.NoError(t, err) + ec := tss.EC() + q := ec.Params().N + qMinus1 := new(big.Int).Sub(q, one) + require.Equal(t, q.BitLen(), qMinus1.BitLen()) + + for _, enabled := range []bool{false, true} { + t.Run(fmt.Sprintf("CT=%t", enabled), func(t *testing.T) { + setMTAProofTestMode(t, enabled) + + t.Run("ProveRangeAlice m = q-1", func(t *testing.T) { + c, r, err := key.EncryptAndReturnRandomness(qMinus1) + require.NoError(t, err) + proof, err := ProveRangeAlice(ec, pk, c, NTildei, h1i, h2i, qMinus1, r) + require.NoError(t, err) + assert.True(t, proof.Verify(ec, pk, NTildei, h1i, h2i, c)) + }) + + t.Run("ProveBob and ProveBobWC x = q-1", func(t *testing.T) { + y, r := big.NewInt(11), firstSmallUnit(pk.N, 2) + c1 := fixedPaillierEncryption(pk, big.NewInt(5), firstSmallUnit(pk.N, 3)) + c2 := c2ForWitness(t, ec, pk, c1, qMinus1, y, r) + X := crypto.ScalarBaseMult(ec, qMinus1) + proofBob, err := ProveBob(ec, pk, NTildei, h1i, h2i, c1, c2, qMinus1, y, r) + require.NoError(t, err) + assert.True(t, proofBob.Verify(ec, pk, NTildei, h1i, h2i, c1, c2), "ProveBob") + proofBobWC, err := ProveBobWC(ec, pk, NTildei, h1i, h2i, c1, c2, qMinus1, y, r, X) + require.NoError(t, err) + assert.True(t, proofBobWC.Verify(ec, pk, NTildei, h1i, h2i, c1, c2, X), "ProveBobWC") + }) + + t.Run("BobMid and BobMidWC b = q-1", func(t *testing.T) { + a := big.NewInt(424242) + cA, pfA, err := AliceInit(ec, pk, a, NTildej, h1j, h2j) + require.NoError(t, err) + // Alice's share must equal a*b + betaPrm mod q. + want := func(betaPrm *big.Int) *big.Int { + v := new(big.Int).Mul(a, qMinus1) + v.Add(v, betaPrm) + return v.Mod(v, q) + } + + _, cB, betaPrm, pfB, err := BobMid(ec, pk, pfA, qMinus1, cA, NTildei, h1i, h2i, NTildej, h1j, h2j) + require.NoError(t, err) + alpha, err := AliceEnd(ec, pk, pfB, h1i, h2i, cA, cB, NTildei, key) + require.NoError(t, err) + assert.Zero(t, alpha.Cmp(want(betaPrm)), "BobMid share") + + B := crypto.ScalarBaseMult(ec, qMinus1) + _, cBWC, betaPrmWC, pfBWC, err := BobMidWC(ec, pk, pfA, qMinus1, cA, NTildei, h1i, h2i, NTildej, h1j, h2j, B) + require.NoError(t, err) + alphaWC, err := AliceEndWC(ec, pk, pfBWC, B, cA, cBWC, NTildei, h1i, h2i, key) + require.NoError(t, err) + assert.Zero(t, alphaWC.Cmp(want(betaPrmWC)), "BobMidWC share") + }) + }) + } +} diff --git a/crypto/mta/legacy_bob_compatibility_test.go b/crypto/mta/legacy_bob_compatibility_test.go index 89fba68aa..6508ea8ed 100644 --- a/crypto/mta/legacy_bob_compatibility_test.go +++ b/crypto/mta/legacy_bob_compatibility_test.go @@ -13,7 +13,8 @@ import ( ) // TestLegacyBobVerifierAcceptsHistoricalGammaRange pins a historical proof -// shape the security-v2 verifier intentionally rejects. PRIOR and the restored +// shape the security-v2 verifier intentionally rejects. The historical +// 2e712689 prover (see testdata/legacy_transcript/README.md) and the restored // legacy prover sample gamma as a unit below the 2048-bit Paillier modulus; // the security-v2 prover samples below q^7 instead. The proof is otherwise // generated from the exact historical equations with fixed test-only witnesses @@ -32,72 +33,22 @@ func TestLegacyBobVerifierAcceptsHistoricalGammaRange(t *testing.T) { y := big.NewInt(11) r := firstSmallUnit(pk.N, 2) c1 := fixedPaillierEncryption(pk, big.NewInt(5), firstSmallUnit(pk.N, 3)) - modNSquared := common.ModInt(pk.NSquare()) - c2 := modNSquared.Mul( - modNSquared.Exp(c1, x), - modNSquared.Mul( - modNSquared.Exp(pk.Gamma(), y), - modNSquared.Exp(r, pk.N), - ), - ) + X := crypto.ScalarBaseMult(ec, x) - legacy := fixedHistoricalBobProof( - ec, - pk, - aux.NTildei, - aux.H1i, - aux.H2i, - c1, - c2, - x, - y, - r, - nil, - ) - if !legacy.ProofBob.Verify( - ec, - pk, - aux.NTildei, - aux.H1i, - aux.H2i, - c1, - c2, - ) { - t.Fatal("legacy Bob verifier rejected a proof in PRIOR's gamma range") + legacy, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, nil, nil, nil) + if !legacy.ProofBob.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2) { + t.Fatal("legacy Bob verifier rejected a proof in the historical gamma range") } - - X := crypto.ScalarBaseMult(ec, x) - legacyWC := fixedHistoricalBobProof( - ec, - pk, - aux.NTildei, - aux.H1i, - aux.H2i, - c1, - c2, - x, - y, - r, - X, - ) - if !legacyWC.Verify( - ec, - pk, - aux.NTildei, - aux.H1i, - aux.H2i, - c1, - c2, - X, - ) { - t.Fatal("legacy BobWC verifier rejected a proof in PRIOR's gamma range") + legacyWC, _ := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, nil, X, nil) + if !legacyWC.Verify(ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, c2, X) { + t.Fatal("legacy BobWC verifier rejected a proof in the historical gamma range") } q := ec.Params().N q3 := new(big.Int).Exp(q, big.NewInt(3), nil) q7 := new(big.Int).Exp(q, big.NewInt(7), nil) if legacy.T1.Cmp(q7) <= 0 || legacyWC.T1.Cmp(q7) <= 0 { - t.Fatal("fixture does not exercise PRIOR's wider gamma range") + t.Fatal("fixture does not exercise the historical wider gamma range") } // BobMid samples the T1 witness y below q^5, so the exact honest legacy // range is gamma + e*y < N + q^6. @@ -109,7 +60,7 @@ func TestLegacyBobVerifierAcceptsHistoricalGammaRange(t *testing.T) { t.Fatal("fixture accidentally exceeds the shared S1 bound") } - session := []byte("R01/security-v2/bob/range") + session := []byte("mta-test/bob/historical-range") if legacy.ProofBob.Verify( ec, pk, @@ -134,11 +85,18 @@ func TestLegacyBobVerifierAcceptsHistoricalGammaRange(t *testing.T) { } } +// fixedHistoricalBobProof builds a Bob proof (a BobWC proof when X is not +// nil) from the exact historical 2e712689 prover equations with fixed +// test-only blinders. gamma is the T1 blinder; nil selects N-1, a valid +// historical gamma (a unit below pk.N) that is wider than q^7 for the +// 2048-bit fixture modulus. A nil session gives the legacy untagged +// challenge; a non-nil session gives the session-tagged challenge. func fixedHistoricalBobProof( ec elliptic.Curve, pk *paillier.PublicKey, - nTilde, h1, h2, c1, c2, x, y, r *big.Int, + nTilde, h1, h2, c1, c2, x, y, r, gamma *big.Int, X *crypto.ECPoint, + session []byte, ) *ProofBobWC { q := ec.Params().N q3 := new(big.Int).Exp(q, big.NewInt(3), nil) @@ -148,17 +106,17 @@ func fixedHistoricalBobProof( alpha := new(big.Int).Rsh(new(big.Int).Set(q3), 1) rho := new(big.Int).Rsh(new(big.Int).Set(qNTilde), 2) sigma := new(big.Int).Rsh(new(big.Int).Set(qNTilde), 3) - // PRIOR sampled tau below q*NTilde. + // The historical prover sampled tau below q*NTilde. tau := new(big.Int).Sub(qNTilde, big.NewInt(1)) rhoPrime := new(big.Int).Rsh(new(big.Int).Set(q3NTilde), 2) beta := firstSmallUnit(pk.N, 5) - // This is a valid PRIOR gamma (< pk.N and coprime to it) but is wider - // than q^7 for the 2048-bit fixture modulus. - gamma := new(big.Int).Sub(pk.N, big.NewInt(1)) + if gamma == nil { + gamma = new(big.Int).Sub(pk.N, big.NewInt(1)) + } var u *crypto.ECPoint if X != nil { - u = crypto.ScalarBaseMult(tss.EC(), alpha) + u = crypto.ScalarBaseMult(ec, alpha) } modNTilde := common.ModInt(nTilde) z := modNTilde.ExpMulExp(h1, x, h2, rho) @@ -171,7 +129,7 @@ func fixedHistoricalBobProof( w := modNTilde.ExpMulExp(h1, gamma, h2, tau) e := bobProofChallenge( - nil, + session, q, pk, nTilde, diff --git a/crypto/mta/legacy_bob_historical_witness_test.go b/crypto/mta/legacy_bob_historical_witness_test.go index 0719afd77..6e093ea40 100644 --- a/crypto/mta/legacy_bob_historical_witness_test.go +++ b/crypto/mta/legacy_bob_historical_witness_test.go @@ -18,30 +18,24 @@ import ( "github.com/bnb-chain/tss-lib/tss" ) -// historicalBobProofForWitnessY builds a session-less legacy Bob/BobWC proof -// from the exact historical (2e712689) prover equations via -// fixedHistoricalBobProof, with the MtA blinding witness y and the c2 -// ciphertext parameterized so the honest T1 = e*y + gamma range can be -// exercised at the documented acceptance boundary. It returns the proof and -// the c2 ciphertext used in its verification. +// historicalBobProofForWitnessY builds a Bob/BobWC proof from the exact +// historical (2e712689) prover equations via fixedHistoricalBobProof, with +// the MtA blinding witness y parameterized so the honest T1 = e*y + gamma +// range can be exercised at the documented acceptance boundary. gamma and +// session are passed through to fixedHistoricalBobProof. It returns the +// proof and the c2 ciphertext used in its verification. func historicalBobProofForWitnessY( t *testing.T, ec elliptic.Curve, pk *paillier.PublicKey, nTilde, h1, h2, c1 *big.Int, - x, y, r *big.Int, + x, y, r, gamma *big.Int, X *crypto.ECPoint, + session []byte, ) (*ProofBobWC, *big.Int) { t.Helper() - modNSquared := common.ModInt(pk.NSquare()) - c2 := modNSquared.Mul( - modNSquared.Exp(c1, x), - modNSquared.Mul( - modNSquared.Exp(pk.Gamma(), y), - modNSquared.Exp(r, pk.N), - ), - ) - return fixedHistoricalBobProof(ec, pk, nTilde, h1, h2, c1, c2, x, y, r, X), c2 + c2 := c2ForWitness(t, ec, pk, c1, x, y, r) + return fixedHistoricalBobProof(ec, pk, nTilde, h1, h2, c1, c2, x, y, r, gamma, X, session), c2 } // deterministicHistoricalWitnessY returns a deterministic witness y in @@ -107,8 +101,8 @@ func TestLegacyBobWidenedVerifierAcceptsHistoricalWitnessRange(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - bobProof, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, tc.y, r, nil) - wcProof, _ := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, tc.y, r, X) + bobProof, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, tc.y, r, nil, nil, nil) + wcProof, _ := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, tc.y, r, nil, X, nil) // An honest historical proof must never exceed the widened // historical bound, in either check mode. if bobProof.T1.Cmp(widenedMaxT1) >= 0 || wcProof.T1.Cmp(widenedMaxT1) >= 0 { @@ -162,7 +156,7 @@ func TestLegacyHistoricalWitnessRangeRejectsWiderT1(t *testing.T) { r := firstSmallUnit(pk.N, 2) 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) + bobProof, c2 := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, nil, nil, nil) // 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) @@ -191,7 +185,7 @@ 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) + wcProof, _ := historicalBobProofForWitnessY(t, ec, pk, aux.NTildei, aux.H1i, aux.H2i, c1, x, y, r, nil, X, nil) 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") } diff --git a/crypto/mta/modulus_policy_test.go b/crypto/mta/modulus_policy_test.go new file mode 100644 index 000000000..9c25e006e --- /dev/null +++ b/crypto/mta/modulus_policy_test.go @@ -0,0 +1,220 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package mta + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/crypto" + "github.com/bnb-chain/tss-lib/crypto/paillier" + "github.com/bnb-chain/tss-lib/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/tss" +) + +// TestMtaModulusPolicy pins the shared unknown-order modulus policy of +// validateVerifierParams (common.IsUsableUnknownOrderModulus: odd, between +// 2048 and MaxUnknownOrderModulusBitLen bits, composite) on all +// three MtA verifier entry points: ProofBob.Verify, ProofBobWC.Verify and +// RangeProofAlice.Verify. +// +// A valid fixture proof is built with the committed keygen fixture, then +// exactly one modulus is swapped at a time. For pk.N the table covers: +// +// - the fixture value: accepted (positive control); +// - a 2047-bit odd composite: rejected by the width floor +// (2047 < MinUnknownOrderModulusBitLen); +// - a 65537-bit odd composite: rejected by the width ceiling +// (65537 > MaxUnknownOrderModulusBitLen); +// - an even 2048-bit value: rejected by the parity check. +// +// For NTilde the same shape is repeated: a 2047-bit odd composite above +// every fixture generator (so the rejection is pinned to the width floor, +// not the canonical-generator width guard), a 65537-bit odd composite, +// and an even 2048-bit value. +// +// The positive control and the swapped rows run in the default timing mode +// only: the shared preamble does not depend on it. +// +// The swapped rows can also be rejected by later checks or equation +// failures, so this table alone does not prove that each Verify calls +// validateVerifierParams. The direct validateVerifierParams assertions +// below pin what the gate checks; TestMtaVerifiersCallSharedGate pins that +// each verifier calls it. +func TestMtaModulusPolicy(t *testing.T) { + key := mtaFixtureKey(t) + fixturePk := &key.PublicKey + NTilde, h1, h2, err := keygen.LoadNTildeH1H2FromTestFixture(0) + require.NoError(t, err) + ec := tss.EC() + + // Fixture sanity: the verifiers are exercised on a known-usable pair + // of unknown-order moduli, so a swapped-modulus rejection is + // attributable to the swap, not to the fixture. + require.Equal(t, common.MinUnknownOrderModulusBitLen, fixturePk.N.BitLen(), + "fixture Paillier modulus must sit at the 2048-bit width floor") + require.True(t, common.IsUsableUnknownOrderModulus(fixturePk.N, common.MinUnknownOrderModulusBitLen), + "fixture pk.N must be a usable unknown-order modulus") + require.True(t, common.IsUsableUnknownOrderModulus(NTilde, common.MinUnknownOrderModulusBitLen), + "fixture NTilde must be a usable unknown-order modulus") + + // Constructed swap moduli, all deterministic: + // - belowFloorN = 3*2^2045+3: 2047 bits, odd, divisible by 3. Only + // the width floor rejects it; the canonical-generator and + // ciphertext checks still hold for every fixture value against a + // 2047-bit modulus, so the gate assertion on it pins the floor. + // - pastCeiling = 3*2^65535+5: 65537 bits, odd, composite, and + // coprime to every fixture generator and ciphertext, so the gate + // assertions on it pin the width ceiling. + // - evenAtFloor: fixture N-1 (and NTilde-1): even, 2048 bits, so + // the parity check rejects them. + // - belowFloorTilde = 3*2^2045+1: 2047 bits, odd, composite (2^5+1 + // divides 2^2045+1), and above every fixture generator and + // ciphertext, so the 2047-bit NTilde row is pinned to the width + // floor instead of the generator width guard. + belowFloorN := oddCompositeAt(common.MinUnknownOrderModulusBitLen-1, 3) + pastCeiling := oddCompositeAt(common.MaxUnknownOrderModulusBitLen+1, 5) + evenN := new(big.Int).Sub(fixturePk.N, one) + evenTilde := new(big.Int).Sub(NTilde, one) + belowFloorTilde := oddCompositeAt(common.MinUnknownOrderModulusBitLen-1, 1) + + require.Equal(t, common.MinUnknownOrderModulusBitLen-1, belowFloorN.BitLen()) + require.Equal(t, common.MaxUnknownOrderModulusBitLen+1, pastCeiling.BitLen()) + require.Equal(t, common.MinUnknownOrderModulusBitLen-1, belowFloorTilde.BitLen()) + require.Equal(t, uint(1), pastCeiling.Bit(0), "past-ceiling swap value must be odd") + require.Equal(t, uint(1), belowFloorN.Bit(0), "below-floor pk.N swap value must be odd") + require.Equal(t, uint(1), belowFloorTilde.Bit(0), "below-floor NTilde swap value must be odd") + require.Equal(t, common.MinUnknownOrderModulusBitLen, evenN.BitLen()) + require.Equal(t, common.MinUnknownOrderModulusBitLen, evenTilde.BitLen()) + require.Zero(t, evenN.Bit(0), "even pk.N swap value must be even") + require.Zero(t, evenTilde.Bit(0), "even NTilde swap value must be even") + + // The 2047-bit NTilde swap must exceed every value the + // canonical-generator and ciphertext checks compare against, so + // those checks pass and the rejection is pinned to the width floor. + for _, v := range []*big.Int{h1, h2} { + require.True(t, v.Cmp(belowFloorTilde) < 0, "fixture generator must be narrower than the 2047-bit NTilde swap") + } + + // The 65537-bit swap must be coprime to every fixture value so the + // ceiling row cannot be explained by a coincidental shared factor. + for _, v := range []*big.Int{h1, h2, NTilde} { + require.Zero(t, new(big.Int).GCD(nil, nil, v, pastCeiling).Cmp(one), + "fixture value must be coprime to the 65537-bit swap value") + } + + // Build the valid fixture proofs. x and y are small in-domain + // witnesses; c2 is the MtA-shaped ciphertext c1^x * G^y * r^N mod N^2. + // The session-less (legacy) verifier bounds admit these proofs. + x, y, r := big.NewInt(7), big.NewInt(11), firstSmallUnit(fixturePk.N, 2) + c1 := fixedPaillierEncryption(fixturePk, big.NewInt(5), firstSmallUnit(fixturePk.N, 3)) + c2 := c2ForWitness(t, ec, fixturePk, c1, x, y, r) + X := crypto.ScalarBaseMult(ec, x) + proofBob, err := ProveBob(ec, fixturePk, NTilde, h1, h2, c1, c2, x, y, r) + require.NoError(t, err) + proofBobWC, err := ProveBobWC(ec, fixturePk, NTilde, h1, h2, c1, c2, x, y, r, X) + require.NoError(t, err) + m, rMsg := big.NewInt(424242), big.NewInt(2) + cMsg := fixedPaillierEncryption(fixturePk, m, rMsg) + proofRange, err := ProveRangeAlice(ec, fixturePk, cMsg, NTilde, h1, h2, m, rMsg) + require.NoError(t, err) + + // Gate pinning on the shared preamble itself, on the same constructed + // values. The positive control must pass the gate; every swapped row + // must fail it, so a mutation that removes a gate check from + // validateVerifierParams turns at least one assertion below red. + require.True(t, validateVerifierParams(fixturePk, NTilde, h1, h2, c1, c2, cMsg), + "fixture moduli, generators, and ciphertexts must pass the shared gate") + require.False(t, validateVerifierParams(&paillier.PublicKey{N: belowFloorN}, NTilde, h1, h2, c1, c2, cMsg), + "2047-bit pk.N must fail the gate's width floor") + require.False(t, validateVerifierParams(fixturePk, belowFloorTilde, h1, h2, c1, c2, cMsg), + "2047-bit NTilde must fail the gate's width floor") + require.False(t, validateVerifierParams(&paillier.PublicKey{N: pastCeiling}, NTilde, h1, h2, c1, c2, cMsg), + "65537-bit pk.N must fail the gate's width ceiling") + require.False(t, validateVerifierParams(fixturePk, pastCeiling, h1, h2, c1, c2, cMsg), + "65537-bit NTilde must fail the gate's width ceiling") + require.False(t, validateVerifierParams(&paillier.PublicKey{N: evenN}, NTilde, h1, h2, c1, c2, cMsg), + "even 2048-bit pk.N must fail the gate's parity check") + require.False(t, validateVerifierParams(fixturePk, evenTilde, h1, h2, c1, c2, cMsg), + "even 2048-bit NTilde must fail the gate's parity check") + + type mtaModulusCase struct { + name string + N *big.Int + NTilde *big.Int + want bool + } + // Swap pk.N first, then NTilde. The positive control row (fixture + // values) must verify on all three verifiers; every swapped value + // must be rejected. + cases := []mtaModulusCase{ + {"N_fixture_accepted", fixturePk.N, NTilde, true}, + {"N_odd_composite_2047bit_rejected", belowFloorN, NTilde, false}, + {"N_odd_composite_65537bit_rejected", pastCeiling, NTilde, false}, + {"N_even_2048bit_rejected", evenN, NTilde, false}, + {"NTilde_odd_composite_2047bit_rejected", fixturePk.N, belowFloorTilde, false}, + {"NTilde_odd_composite_65537bit_rejected", fixturePk.N, pastCeiling, false}, + {"NTilde_even_2048bit_rejected", fixturePk.N, evenTilde, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + swpk := &paillier.PublicKey{N: tc.N} + gotBob := proofBob.Verify(ec, swpk, tc.NTilde, h1, h2, c1, c2) + assert.Equal(t, tc.want, gotBob, "ProofBob.Verify") + gotWC := proofBobWC.Verify(ec, swpk, tc.NTilde, h1, h2, c1, c2, X) + assert.Equal(t, tc.want, gotWC, "ProofBobWC.Verify") + gotRange := proofRange.Verify(ec, swpk, tc.NTilde, h1, h2, cMsg) + assert.Equal(t, tc.want, gotRange, "RangeProofAlice.Verify") + }) + } +} + +// oddCompositeAt returns 3*2^(bits-2)+extra: an odd value of exactly bits +// bits. extra = 3 keeps divisibility by 3 (ProbablyPrime rejects it in +// the first round); extra = 5 avoids 3 where coprimality to the fixture +// ciphertexts is needed. No keygen or primality generation is involved. +func oddCompositeAt(bits int, extra int64) *big.Int { + v := new(big.Int).Lsh(big.NewInt(3), uint(bits-2)) + return new(big.Int).Add(v, big.NewInt(extra)) +} + +// TestMtaVerifiersCallSharedGate pins that every MtA verifier calls +// validateVerifierParams. The proofs are honest proofs for the generator +// pair (h1, h1), so every response bound and verification equation holds. +// Of all the verifier checks, only the gate rejects equal generators, so a +// verifier that does not call the gate accepts these proofs. +func TestMtaVerifiersCallSharedGate(t *testing.T) { + key := mtaFixtureKey(t) + pk := &key.PublicKey + NTilde, h1, _, err := keygen.LoadNTildeH1H2FromTestFixture(0) + require.NoError(t, err) + ec := tss.EC() + + x, y, r := big.NewInt(7), big.NewInt(11), firstSmallUnit(pk.N, 2) + c1 := fixedPaillierEncryption(pk, big.NewInt(5), firstSmallUnit(pk.N, 3)) + c2 := c2ForWitness(t, ec, pk, c1, x, y, r) + X := crypto.ScalarBaseMult(ec, x) + proofBob, err := ProveBob(ec, pk, NTilde, h1, h1, c1, c2, x, y, r) + require.NoError(t, err) + proofBobWC, err := ProveBobWC(ec, pk, NTilde, h1, h1, c1, c2, x, y, r, X) + require.NoError(t, err) + m, rMsg := big.NewInt(424242), big.NewInt(2) + cMsg := fixedPaillierEncryption(pk, m, rMsg) + proofRange, err := ProveRangeAlice(ec, pk, cMsg, NTilde, h1, h1, m, rMsg) + require.NoError(t, err) + + require.False(t, validateVerifierParams(pk, NTilde, h1, h1, c1, c2, cMsg), "the gate must reject h1 == h2") + assert.False(t, proofBob.Verify(ec, pk, NTilde, h1, h1, c1, c2), "ProofBob.Verify") + assert.False(t, proofBob.VerifyLegacy(ec, pk, NTilde, h1, h1, c1, c2, true), "ProofBob.VerifyLegacy") + assert.False(t, proofBobWC.Verify(ec, pk, NTilde, h1, h1, c1, c2, X), "ProofBobWC.Verify") + assert.False(t, proofRange.Verify(ec, pk, NTilde, h1, h1, cMsg), "RangeProofAlice.Verify") +} diff --git a/crypto/mta/paillier_fixture_test.go b/crypto/mta/paillier_fixture_test.go index 28981984d..95f8d7a00 100644 --- a/crypto/mta/paillier_fixture_test.go +++ b/crypto/mta/paillier_fixture_test.go @@ -20,7 +20,7 @@ import ( // mtaFixturePaillierSK mirrors the committed keygen fixture format // (test/_ecdsa_fixtures/keygen_data_*.json). The mta package is an internal // test, so it cannot import the paillier package's test-only loader; reading -// the JSON directly here is the sanctioned small-helper path (D10). +// the JSON directly here is the sanctioned small-helper path. type mtaFixturePaillierSK struct { N, LambdaN, PhiN *big.Int } diff --git a/crypto/mta/proof_bob_verifier.go b/crypto/mta/proof_bob_verifier.go index 0ac3eb5ef..7c8cfe8a4 100644 --- a/crypto/mta/proof_bob_verifier.go +++ b/crypto/mta/proof_bob_verifier.go @@ -41,21 +41,8 @@ func (pf *ProofBobWC) verify( } else if !pf.ProofBob.ValidateBasic() { return false } - // 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 { - return false - } - if !common.IsCanonicalPaillierCiphertext(c1, pk.N) || !common.IsCanonicalPaillierCiphertext(c2, pk.N) { + // Shared modulus/generator/ciphertext preamble. + if !validateVerifierParams(pk, NTilde, h1, h2, c1, c2) { return false } @@ -83,14 +70,17 @@ func (pf *ProofBobWC) verify( // preserves that behavior exactly. maxT1 = new(big.Int).Add(q7, big.NewInt(1)) default: - // The historical prover sampled gamma in [1, pk.N), while the - // security-v2 prover samples it below q^7. Since T1 = e*y + gamma - // with e < q and the MtA blinding value y < q^5, an honest legacy - // response is below pk.N + q^6. - // Applying the security-v2 q^7 cap to a PRIOR proof rejects almost - // every legitimate 2048-bit gamma and breaks mixed-binary legacy - // signing. Keep a finite legacy-specific cap so adversarial exponents - // remain bounded without rewriting the historical acceptance range. + // This branch's legacy-mode (session-less) prover samples the + // blinding value y below q^5 and gamma as a unit below pk.N, so an + // honest T1 = e*y + gamma with e < q is below pk.N + q^6: the + // default strict bound admits exactly that prover's responses. + // The 2e712689 historical prover instead samples y below pk.N, so + // its T1 reaches (q+1)*N and requires the historicalBobCompat + // widened bound; applying the strict cap to the historical + // 2e712689 prover's proofs rejects nearly all legitimate 2048-bit + // y values and breaks mixed-binary legacy signing. Keep a finite + // legacy-specific cap so adversarial exponents remain bounded, + // without rewriting the historical acceptance range. q6 := new(big.Int).Mul(q3, q3) maxT1 = new(big.Int).Add(pk.N, q6) } @@ -327,3 +317,29 @@ func bobProofChallenge( ) return common.ModReduceHash(q, challengeHash) } + +// validateVerifierParams is the shared modulus/generator/ciphertext preamble +// of this package's unknown-order verifiers: usable moduli, then canonical +// generators, then canonical ciphertexts. +// All moduli and generators must be non-nil. +func validateVerifierParams( + pk *paillier.PublicKey, + NTilde, h1, h2 *big.Int, + ciphertexts ...*big.Int, +) bool { + 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 { + return false + } + for _, c := range ciphertexts { + if !common.IsCanonicalPaillierCiphertext(c, pk.N) { + return false + } + } + return true +} diff --git a/crypto/mta/proof_input_regression_test.go b/crypto/mta/proof_input_regression_test.go index bb12b852a..3dd89d916 100644 --- a/crypto/mta/proof_input_regression_test.go +++ b/crypto/mta/proof_input_regression_test.go @@ -21,10 +21,11 @@ import ( ) // TestProveBobRejectsOutOfDomainWitnesses pins the exported Bob prover guard: -// negative or over-wide x/y must return an error (no panic) under both CT -// settings, through both the with-check and without-check constructors. The -// historical y < N path stays valid, so the boundary witness y = N-1 keeps -// proving. +// x must sit in the curve-order domain and y in the Paillier plaintext +// domain; negative or over-wide witnesses must return an error (no panic) +// under both CT settings, through both the with-check and without-check +// constructors. The historical y < N path stays valid, so the boundary +// witness y = N-1 keeps proving. func TestProveBobRejectsOutOfDomainWitnesses(t *testing.T) { key := mtaFixtureKey(t) pk := &key.PublicKey @@ -41,21 +42,31 @@ func TestProveBobRejectsOutOfDomainWitnesses(t *testing.T) { for _, enabled := range []bool{false, true} { t.Run(fmt.Sprintf("CT=%t", enabled), func(t *testing.T) { setMTAProofTestMode(t, enabled) - // Invalid x/y (negative or over-wide) must error, not panic, - // in both the without-check and with-check constructors. - for _, w := range []*big.Int{big.NewInt(-1), new(big.Int).Set(pk.N), new(big.Int).Add(pk.N, one)} { + q := ec.Params().N + // Invalid x (negative or ≥ q) must error, not panic, in both + // the without-check and with-check constructors. + for _, wx := range []*big.Int{big.NewInt(-1), q, new(big.Int).Set(pk.N)} { assert.NotPanics(t, func() { - p, e := ProveBob(ec, pk, NTilde, h1, h2, c1, c1, w, big.NewInt(3), r) + p, e := ProveBob(ec, pk, NTilde, h1, h2, c1, c1, wx, big.NewInt(3), r) assert.Error(t, e) assert.Nil(t, p) }) assert.NotPanics(t, func() { - p, e := ProveBob(ec, pk, NTilde, h1, h2, c1, c1, big.NewInt(3), w, r) + p, e := ProveBobWC(ec, pk, NTilde, h1, h2, c1, c1, wx, big.NewInt(3), r, X) + assert.Error(t, e) + assert.Nil(t, p) + }) + } + // Invalid y (negative or ≥ N) must error, not panic, in both + // the without-check and with-check constructors. + for _, wy := range []*big.Int{big.NewInt(-1), new(big.Int).Set(pk.N), new(big.Int).Add(pk.N, one)} { + assert.NotPanics(t, func() { + p, e := ProveBob(ec, pk, NTilde, h1, h2, c1, c1, big.NewInt(3), wy, r) assert.Error(t, e) assert.Nil(t, p) }) assert.NotPanics(t, func() { - p, e := ProveBobWC(ec, pk, NTilde, h1, h2, c1, c1, w, big.NewInt(3), r, X) + p, e := ProveBobWC(ec, pk, NTilde, h1, h2, c1, c1, big.NewInt(3), wy, r, X) assert.Error(t, e) assert.Nil(t, p) }) @@ -95,8 +106,9 @@ func TestProveRangeAliceRejectsOutOfDomainWitness(t *testing.T) { for _, enabled := range []bool{false, true} { t.Run(fmt.Sprintf("CT=%t", enabled), func(t *testing.T) { setMTAProofTestMode(t, enabled) - // Negative and over-wide m must error, not panic. - for _, m := range []*big.Int{big.NewInt(-1), new(big.Int).Set(pk.N), new(big.Int).Add(pk.N, one)} { + q := ec.Params().N + // Negative or curve-external m must error, not panic. + for _, m := range []*big.Int{big.NewInt(-1), q, new(big.Int).Set(pk.N), new(big.Int).Add(pk.N, one)} { assert.NotPanics(t, func() { p, e := ProveRangeAlice(ec, pk, c, NTilde, h1, h2, m, r) assert.Error(t, e) diff --git a/crypto/mta/proofs.go b/crypto/mta/proofs.go index 2a3d2c555..c2323fa3f 100644 --- a/crypto/mta/proofs.go +++ b/crypto/mta/proofs.go @@ -40,20 +40,35 @@ func ProveBobWC(ec elliptic.Curve, pk *paillier.PublicKey, NTilde, h1, h2, c1, c if ec == nil || pk == nil || NTilde == nil || h1 == nil || h2 == nil || c1 == nil || c2 == nil || x == nil || y == nil || r == nil { return nil, errors.New("ProveBob() received a nil argument") } + if pk.N == nil || pk.N.Cmp(one) <= 0 { + return nil, errors.New("ProveBob: invalid Paillier modulus N") + } + // Reject a degenerate NTilde before any exponentiation runs against it: + // an even NTilde makes the constant-time modular context panic. + if NTilde.Cmp(one) <= 0 || NTilde.Bit(0) == 0 { + return nil, errors.New("ProveBob: invalid auxiliary modulus N-tilde") + } + if X != nil && !X.ValidateBasic() { + return nil, errors.New("ProveBob: invalid with-check point X") + } + + q := ec.Params().N - // x and y are Bob's secret MtA inputs and Paillier plaintexts, so they - // must sit in the intended 0 <= v < pk.N domain. Validate before any - // sampling or exponentiation so a malformed direct-API witness returns an - // error in either timing mode. Honest legacy and security-v2 witnesses - // stay valid: b < q < N and the y < q^5 / y < N paths both bound y below - // the Paillier modulus. - if x.Cmp(zero) == -1 || x.Cmp(pk.N) != -1 || y.Cmp(zero) == -1 || y.Cmp(pk.N) != -1 { + // x and y are Bob's secret MtA inputs. x is the MtA scalar, 0 <= x < q, + // and y stays in the Paillier plaintext domain 0 <= y < pk.N: + // historical-shaped witnesses up to N-1 must remain provable, so no + // narrower bound applies to y. Validate before any sampling or + // exponentiation so a malformed direct-API witness returns an error in + // either timing mode. + if x.Cmp(zero) == -1 || x.Cmp(q) != -1 { + return nil, errors.New("ProveBob: witness x outside the curve-order domain") + } + if y.Cmp(zero) == -1 || y.Cmp(pk.N) != -1 { return nil, errors.New("ProveBob: witness outside the Paillier plaintext domain") } NSquared := pk.NSquare() - q := ec.Params().N q3 := new(big.Int).Mul(q, q) q3 = new(big.Int).Mul(q, q3) q7 := new(big.Int).Mul(q3, q3) @@ -83,18 +98,20 @@ func ProveBobWC(ec elliptic.Curve, pk *paillier.PublicKey, NTilde, h1, h2, c1, c // 4. beta := common.GetRandomPositiveRelativelyPrimeInt(pk.N) - if beta == nil { - return nil, errors.New("ProveBob: could not sample randomness") - } var gamma *big.Int if Session == nil { - // Historical Bob proofs sample gamma as a unit modulo the Paillier - // modulus. Besides reproducing PRIOR proof bytes, this is why the legacy - // verifier must accept T1 above q^7. + // The historical 2e712689 prover samples gamma as a unit modulo the + // Paillier modulus. Besides reproducing that prover's proof bytes, + // this is why the legacy verifier must accept T1 above q^7. gamma = common.GetRandomPositiveRelativelyPrimeInt(pk.N) } else { gamma = common.GetRandomPositiveInt(q7) } + // The samplers return nil when a limit is wider than their cap, which + // a wide caller-supplied NTilde or N can cause. + if alpha == nil || rho == nil || sigma == nil || tau == nil || rhoPrm == nil || beta == nil || gamma == nil { + return nil, errors.New("ProveBob: could not sample randomness (modulus too wide?)") + } // 5. u := crypto.NewECPointNoCurveCheck(ec, zero, zero) // initialization suppresses an IDE warning @@ -114,11 +131,13 @@ func ProveBobWC(ec elliptic.Curve, pk *paillier.PublicKey, NTilde, h1, h2, c1, c // time (NTilde is odd). The h2^rho / h2^sigma blinds use one-time randomness and // stay on math/big (see the coverage note in common/constant_time.go). ctModNTilde := common.NewCTModInt(NTilde) - // Both inputs are Paillier plaintexts bounded by pk.N, which can be - // wider than NTilde. Do not derive the exponent width from NTilde. - exponentBits := pk.N.BitLen() - z = modNTilde.Mul(ctModNTilde.ExpCTWithBitLen(h1, x, exponentBits), modNTilde.Exp(h2, rho)) - t = modNTilde.Mul(ctModNTilde.ExpCTWithBitLen(h1, y, exponentBits), modNTilde.Exp(h2, sigma)) + // x is a curve-order scalar, narrower than both the auxiliary + // modulus and the Paillier modulus: pad its exponent width to q so + // the constant-time path does not run the wider modulus width. + // y stays in the Paillier plaintext domain 0 <= y < pk.N, so its + // exponent pads to pk.N.BitLen(). + z = modNTilde.Mul(ctModNTilde.ExpCTWithBitLen(h1, x, q.BitLen()), modNTilde.Exp(h2, rho)) + t = modNTilde.Mul(ctModNTilde.ExpCTWithBitLen(h1, y, pk.N.BitLen()), modNTilde.Exp(h2, sigma)) } else { z = modNTilde.Mul(modNTilde.Exp(h1, x), modNTilde.Exp(h2, rho)) t = modNTilde.Mul(modNTilde.Exp(h1, y), modNTilde.Exp(h2, sigma)) @@ -243,14 +262,16 @@ func (pf *ProofBobWC) Verify(ec elliptic.Curve, pk *paillier.PublicKey, NTilde, } // 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 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 selects the -// widened historical witness-range bound (q+1)*N; the shared core derives -// it behind its malformed-input guard so nil curve/key/modulus inputs -// return false instead of panicking. +// 2e712689 untagged challenge). It is the compatibility-aware entry point +// explicitly used by signing round 3 for legacy parties. The shared verify +// core owns the default strict legacy bound, N + q^6: this branch's +// legacy-mode (session-less) prover samples y below q^5, T1 = e*y + gamma, +// e < q, gamma < N, so an honest response from that prover stays below +// q^6 + N. The 2e712689 historical prover samples y below N instead, so +// its T1 reaches the widened historical witness-range bound (q+1)*N, +// which historicalBobCompat admits; the shared core derives this behind +// its malformed-input guard, so nil curve/key/modulus inputs return +// false instead of panicking. func (pf *ProofBobWC) VerifyLegacy( ec elliptic.Curve, pk *paillier.PublicKey, diff --git a/crypto/mta/prover_input_guard_test.go b/crypto/mta/prover_input_guard_test.go new file mode 100644 index 000000000..07fb849cc --- /dev/null +++ b/crypto/mta/prover_input_guard_test.go @@ -0,0 +1,57 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package mta + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/crypto" + "github.com/bnb-chain/tss-lib/crypto/paillier" + "github.com/bnb-chain/tss-lib/tss" +) + +// TestProversRejectSamplerWideNTilde pins that a caller-supplied NTilde +// narrower than the 5000-bit sampler cap, but wide enough that q^3*NTilde +// exceeds it, makes the provers return an error. The sampler gives nil for +// that limit; before it did, the sampler panicked out of the exported API. +func TestProversRejectSamplerWideNTilde(t *testing.T) { + ec := tss.EC() + one := big.NewInt(1) + NTilde := new(big.Int).Add(new(big.Int).Lsh(one, 4500), one) // odd, 4501 bits + pk := &paillier.PublicKey{N: new(big.Int).Add(new(big.Int).Lsh(one, 2047), one)} + h1, h2 := big.NewInt(4), big.NewInt(9) + c1, c2 := big.NewInt(2), big.NewInt(3) + x, y, r := big.NewInt(5), big.NewInt(6), big.NewInt(7) + X := crypto.ScalarBaseMult(ec, x) + + for name, session := range map[string][][]byte{ + "legacy": nil, + "session": {[]byte("prover-input-guard")}, + } { + t.Run(name, func(t *testing.T) { + require.NotPanics(t, func() { + pf, err := ProveBob(ec, pk, NTilde, h1, h2, c1, c2, x, y, r, session...) + assert.Error(t, err, "ProveBob") + assert.Nil(t, pf) + }) + require.NotPanics(t, func() { + pf, err := ProveBobWC(ec, pk, NTilde, h1, h2, c1, c2, x, y, r, X, session...) + assert.Error(t, err, "ProveBobWC") + assert.Nil(t, pf) + }) + require.NotPanics(t, func() { + pf, err := ProveRangeAlice(ec, pk, c1, NTilde, h1, h2, x, r, session...) + assert.Error(t, err, "ProveRangeAlice") + assert.Nil(t, pf) + }) + }) + } +} diff --git a/crypto/mta/range_proof.go b/crypto/mta/range_proof.go index 9dad639a7..7deda06bf 100644 --- a/crypto/mta/range_proof.go +++ b/crypto/mta/range_proof.go @@ -52,17 +52,25 @@ func ProveRangeAlice(ec elliptic.Curve, pk *paillier.PublicKey, c, NTilde, h1, h if ec == nil || pk == nil || NTilde == nil || h1 == nil || h2 == nil || c == nil || m == nil || r == nil { return nil, errors.New("ProveRangeAlice constructor received nil value(s)") } + if pk.N == nil || pk.N.Cmp(one) <= 0 { + return nil, errors.New("ProveRangeAlice: invalid Paillier modulus N") + } + // Reject a degenerate NTilde before any exponentiation runs against it: + // an even NTilde makes the constant-time modular context panic. + if NTilde.Cmp(one) <= 0 || NTilde.Bit(0) == 0 { + return nil, errors.New("ProveRangeAlice: invalid auxiliary modulus N-tilde") + } - // m is Alice's secret value and a Paillier plaintext, so it must sit in - // the intended 0 <= m < pk.N domain. Validate before any sampling or + q := ec.Params().N + // m is Alice's secret value and the MtA range message, so it must sit in + // the curve-order domain 0 <= m < q. Validate before any sampling or // exponentiation so a malformed direct-API witness returns a constructor // error in either timing mode. Honest protocol witnesses stay valid: - // AliceInit encrypts a, whose Paillier domain is exactly 0 <= m < N. - if m.Cmp(zero) == -1 || m.Cmp(pk.N) != -1 { - return nil, errors.New("ProveRangeAlice: witness m outside the Paillier plaintext domain") + // AliceInit encrypts a, whose MtA domain is exactly 0 <= m < q. + if m.Cmp(zero) == -1 || m.Cmp(q) != -1 { + return nil, errors.New("ProveRangeAlice: witness m outside the curve-order domain") } - q := ec.Params().N q3 := new(big.Int).Mul(q, q) q3 = new(big.Int).Mul(q, q3) qNTilde := new(big.Int).Mul(q, NTilde) @@ -72,15 +80,17 @@ func ProveRangeAlice(ec elliptic.Curve, pk *paillier.PublicKey, c, NTilde, h1, h alpha := common.GetRandomPositiveInt(q3) // 2. beta := common.GetRandomPositiveRelativelyPrimeInt(pk.N) - if beta == nil { - return nil, errors.New("ProveRangeAlice: could not sample randomness") - } // 3. gamma := common.GetRandomPositiveInt(q3NTilde) // 4. rho := common.GetRandomPositiveInt(qNTilde) + // The samplers return nil when a limit is wider than their cap, which + // a wide caller-supplied NTilde or N can cause. + if alpha == nil || beta == nil || gamma == nil || rho == nil { + return nil, errors.New("ProveRangeAlice: could not sample randomness (modulus too wide?)") + } // 5. modNTilde := common.ModInt(NTilde) @@ -89,8 +99,10 @@ func ProveRangeAlice(ec elliptic.Curve, pk *paillier.PublicKey, c, NTilde, h1, h // SECURITY: m is Alice's secret value used as the exponent; exponentiate in // constant time (NTilde is odd). The h2^rho blind and the u/w terms use one-time // randomness and stay on math/big (see common/constant_time.go). - // The plaintext is bounded by pk.N, independently of NTilde's width. - z = modNTilde.Mul(common.NewCTModInt(NTilde).ExpCTWithBitLen(h1, m, pk.N.BitLen()), modNTilde.Exp(h2, rho)) + // m is a curve-order scalar, a narrower public bound than the + // auxiliary modulus; pad to that width so the constant-time + // exponentiation does not run the wider modulus width. + z = modNTilde.Mul(common.NewCTModInt(NTilde).ExpCTWithBitLen(h1, m, q.BitLen()), modNTilde.Exp(h2, rho)) } else { z = modNTilde.Exp(h1, m) z = modNTilde.Mul(z, modNTilde.Exp(h2, rho)) @@ -144,21 +156,8 @@ func (pf *RangeProofAlice) Verify(ec elliptic.Curve, pk *paillier.PublicKey, NTi NTilde == nil || h1 == nil || h2 == nil || c == nil { return false } - // 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 { - return false - } - if !common.IsCanonicalPaillierCiphertext(c, pk.N) { + // Shared modulus/generator/ciphertext preamble. + if !validateVerifierParams(pk, NTilde, h1, h2, c) { return false } diff --git a/crypto/mta/range_proof_test.go b/crypto/mta/range_proof_test.go index daf05eafa..e30695780 100644 --- a/crypto/mta/range_proof_test.go +++ b/crypto/mta/range_proof_test.go @@ -180,7 +180,7 @@ func TestRangeProofAliceRejectsMalformedInputs(t *testing.T) { tooLargeS2.Add(tooLargeS2, big.NewInt(1)) badS2 := *proof badS2.S2 = tooLargeS2 - assert.False(t, badS2.Verify(tss.EC(), pk, NTildei, h1i, h2i, c), "overwide S2 must fail before exponentiation") + assert.False(t, badS2.Verify(tss.EC(), pk, NTildei, h1i, h2i, c), "S2 above the 2*q^3*NTilde bound must be rejected") badZ := *proof badZ.Z = big.NewInt(1) diff --git a/crypto/mta/share_protocol.go b/crypto/mta/share_protocol.go index dacb96d13..165b8ee49 100644 --- a/crypto/mta/share_protocol.go +++ b/crypto/mta/share_protocol.go @@ -9,6 +9,7 @@ package mta import ( "crypto/elliptic" "errors" + "fmt" "math/big" "github.com/bnb-chain/tss-lib/common" @@ -55,8 +56,12 @@ func BobMid( if err != nil { return } - cB, err = pkA.HomoMult(b, cA) + // b is the MtA scalar this party contributes, a curve-order scalar. + // HomoMultBounded rejects b outside [0, q) and pads the constant-time + // exponent to q's width instead of the wider Paillier modulus width. + cB, err = pkA.HomoMultBounded(b, cA, q) if err != nil { + err = fmt.Errorf("BobMid: HomoMultBounded: %w", err) return } cB, err = pkA.HomoAdd(cB, cBetaPrm) @@ -89,8 +94,12 @@ func BobMidWC( if err != nil { return } - cB, err = pkA.HomoMult(b, cA) + // b is the MtA scalar this party contributes, a curve-order scalar. + // HomoMultBounded rejects b outside [0, q) and pads the constant-time + // exponent to q's width instead of the wider Paillier modulus width. + cB, err = pkA.HomoMultBounded(b, cA, q) if err != nil { + err = fmt.Errorf("BobMidWC: HomoMultBounded: %w", err) return } cB, err = pkA.HomoAdd(cB, cBetaPrm) @@ -102,6 +111,22 @@ func BobMidWC( return } +// decryptShare is the shared tail of every AliceEnd entry point: decrypt cB +// with the local Paillier secret key and reduce the plaintext to the curve +// order. The entry points differ only in which Bob proof they check first. +func decryptShare( + ec elliptic.Curve, + sk *paillier.PrivateKey, + cB *big.Int, +) (*big.Int, error) { + alphaPrm, err := sk.Decrypt(cB) + if err != nil { + return nil, err + } + q := ec.Params().N + return new(big.Int).Mod(alphaPrm, q), nil +} + func AliceEnd( ec elliptic.Curve, pkA *paillier.PublicKey, @@ -113,12 +138,7 @@ func AliceEnd( if !pf.Verify(ec, pkA, NTildeA, h1A, h2A, cA, cB, session...) { return nil, errors.New("ProofBob.Verify() returned false") } - alphaPrm, err := sk.Decrypt(cB) - if err != nil { - return nil, err - } - q := ec.Params().N - return new(big.Int).Mod(alphaPrm, q), nil + return decryptShare(ec, sk, cB) } func AliceEndWC( @@ -133,19 +153,15 @@ func AliceEndWC( if !pf.Verify(ec, pkA, NTildeA, h1A, h2A, cA, cB, B, session...) { return nil, errors.New("ProofBobWC.Verify() returned false") } - alphaPrm, err := sk.Decrypt(cB) - if err != nil { - return nil, err - } - q := ec.Params().N - return new(big.Int).Mod(alphaPrm, q), nil + return decryptShare(ec, sk, cB) } // 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 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. +// T1 bound, then decrypts cB. When historicalBobCompat is true the T1 bound +// is widened to the historical (2e712689) witness range (q+1)*N via +// VerifyLegacy; when false the tight N + q^6 bound applies. This is the +// signing round-3 entry point for legacy parties; AliceEnd is unchanged for +// standalone callers. func AliceEndLegacy( ec elliptic.Curve, pkA *paillier.PublicKey, @@ -157,12 +173,7 @@ func AliceEndLegacy( if !pf.VerifyLegacy(ec, pkA, NTildeA, h1A, h2A, cA, cB, historicalBobCompat) { return nil, errors.New("ProofBob.VerifyLegacy() returned false") } - alphaPrm, err := sk.Decrypt(cB) - if err != nil { - return nil, err - } - q := ec.Params().N - return new(big.Int).Mod(alphaPrm, q), nil + return decryptShare(ec, sk, cB) } // AliceEndWCLegacy is the with-check counterpart of AliceEndLegacy. @@ -178,10 +189,5 @@ func AliceEndWCLegacy( if !pf.VerifyLegacy(ec, pkA, NTildeA, h1A, h2A, cA, cB, B, historicalBobCompat) { return nil, errors.New("ProofBobWC.VerifyLegacy() returned false") } - alphaPrm, err := sk.Decrypt(cB) - if err != nil { - return nil, err - } - q := ec.Params().N - return new(big.Int).Mod(alphaPrm, q), nil + return decryptShare(ec, sk, cB) } diff --git a/crypto/mta/share_protocol_test.go b/crypto/mta/share_protocol_test.go index 0b9a4990a..02c31f90b 100644 --- a/crypto/mta/share_protocol_test.go +++ b/crypto/mta/share_protocol_test.go @@ -196,11 +196,11 @@ func TestShareProtocolWC(t *testing.T) { badS2 := cloneProofBobWC(pfB) badS2.S2 = new(big.Int).Set(tooLargeBlind) - assert.False(t, badS2.Verify(tss.EC(), pk, NTildei, h1i, h2i, cA, cB, gBPoint), "overwide S2 must fail before exponentiation") + assert.False(t, badS2.Verify(tss.EC(), pk, NTildei, h1i, h2i, cA, cB, gBPoint), "S2 above the 2*q^3*NTilde bound must be rejected") badT2 := cloneProofBobWC(pfB) badT2.T2 = new(big.Int).Set(tooLargeBlind) - assert.False(t, badT2.Verify(tss.EC(), pk, NTildei, h1i, h2i, cA, cB, gBPoint), "overwide T2 must fail before exponentiation") + assert.False(t, badT2.Verify(tss.EC(), pk, NTildei, h1i, h2i, cA, cB, gBPoint), "T2 above the 2*q^3*NTilde bound must be rejected") badV := cloneProofBobWC(pfB) badV.V = big.NewInt(0) diff --git a/crypto/paillier/constant_time_equiv_test.go b/crypto/paillier/constant_time_equiv_test.go index 985b14f07..c5a689e63 100644 --- a/crypto/paillier/constant_time_equiv_test.go +++ b/crypto/paillier/constant_time_equiv_test.go @@ -392,45 +392,49 @@ func TestModProofCTVerifies(t *testing.T) { assert.True(t, res, "CT ModProof must verify") } -// TestModProofHelpersCTEquivalence: the QR helpers are deterministic; CT and non-CT -// must agree (boolean predicates and the byte-identical fourth root) on real-key inputs. -func TestModProofHelpersCTEquivalence(t *testing.T) { - setPaillierCTTestMode(t, false) +// TestModProofContextsCTEquivalence: the QR predicates and the fourth root +// of the modProofCTContext are deterministic; the constant-time and the +// plain context must agree on real-key inputs (boolean predicates and the +// byte-identical fourth root). +func TestModProofContextsCTEquivalence(t *testing.T) { facSetUp(t) p, q := privateKey.GetPQ() N := publicKey.N phiN := privateKey.PhiN + invN := new(big.Int).ModInverse(N, phiN) + require.NotNil(t, invN, "the fixture key must admit N^(-1) mod phiN") + + ctxOff := newModProofCTContext(p, q, N, phiN, invN, false) + ctxOn := newModProofCTContext(p, q, N, phiN, invN, true) // x = r^2 mod N is a quadratic residue mod N (exercises the true branch). r := common.GetRandomPositiveRelativelyPrimeInt(N) x := new(big.Int).Mod(new(big.Int).Mul(r, r), N) // nr is a known quadratic NON-residue mod p (exercises the false branch of - // isQuadResidueModPrime); located using the standard (non-CT) predicate. + // the context's QR predicate); located using the math/big reference. nr := big.NewInt(2) - for isQuadResidueModPrime(nr, p) { + for new(big.Int).Exp(nr, ctxOff.psP, p).Cmp(one) == 0 { nr.Add(nr, big.NewInt(1)) } - qrPOff := isQuadResidueModPrime(x, p) - nrPOff := isQuadResidueModPrime(nr, p) - qrCompOff := isQuadResidueModComposite(x, p, q) - rootOff := quadResidueModComposite(x, p, q, N, phiN) + qrPOff := ctxOff.isQuadResidueModPrime(x, p, nil, ctxOff.psP, nil) + nrPOff := ctxOff.isQuadResidueModPrime(nr, p, nil, ctxOff.psP, nil) + qrCompOff := ctxOff.isQuadResidueModComposite(x, p, q) + rootOff := ctxOff.fourthRoot(x, N) - setPaillierCTTestMode(t, true) - assert.True(t, common.IsConstantTimeEnabled(), "CT must be engaged (else this test is vacuous)") - qrPOn := isQuadResidueModPrime(x, p) - nrPOn := isQuadResidueModPrime(nr, p) - qrCompOn := isQuadResidueModComposite(x, p, q) - rootOn := quadResidueModComposite(x, p, q, N, phiN) + qrPOn := ctxOn.isQuadResidueModPrime(x, p, ctxOn.ctP, ctxOn.psP, ctxOn.psPExp) + nrPOn := ctxOn.isQuadResidueModPrime(nr, p, ctxOn.ctP, ctxOn.psP, ctxOn.psPExp) + qrCompOn := ctxOn.isQuadResidueModComposite(x, p, q) + rootOn := ctxOn.fourthRoot(x, N) assert.True(t, qrPOff, "x=r^2 must be a residue mod p") assert.False(t, nrPOff, "nr must be a non-residue mod p") - assert.Equal(t, qrPOff, qrPOn, "isQuadResidueModPrime must agree (residue)") - assert.Equal(t, nrPOff, nrPOn, "isQuadResidueModPrime must agree (non-residue)") + assert.Equal(t, qrPOff, qrPOn, "the QR predicate must agree (residue)") + assert.Equal(t, nrPOff, nrPOn, "the QR predicate must agree (non-residue)") assert.Equal(t, qrCompOff, qrCompOn, "isQuadResidueModComposite must agree") - assert.Zero(t, rootOff.Cmp(rootOn), "quadResidueModComposite must be byte-identical") + assert.Zero(t, rootOff.Cmp(rootOn), "fourthRoot must be byte-identical") } // TestModProofCTEquivalence: ModProof is randomised only through w; replaying diff --git a/crypto/paillier/ct_key_ops.go b/crypto/paillier/ct_key_ops.go index 10b596db1..777fb7ef0 100644 --- a/crypto/paillier/ct_key_ops.go +++ b/crypto/paillier/ct_key_ops.go @@ -27,10 +27,8 @@ var ( // ErrMalformedKey is returned when the private key material is not a // usable decryption trapdoor: LambdaN is missing, non-positive, wider // than the modulus bit-bound the fixed-width constant-time exponent - // encoding requires, or the decryption coefficient - // L((N+1)^LambdaN mod N^2) has no inverse modulo N. The error carries no - // key values and is returned consistently by both the constant-time and - // variable-time paths. + // encoding requires, or the decryption coefficient (LambdaN mod N) is + // not invertible modulo N. The error carries no key values. ErrMalformedKey = errors.New("paillier: malformed private key material") ) diff --git a/crypto/paillier/factor_proof.go b/crypto/paillier/factor_proof.go index e0860a2c3..650a3b658 100644 --- a/crypto/paillier/factor_proof.go +++ b/crypto/paillier/factor_proof.go @@ -107,21 +107,11 @@ 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") } - // 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) + return false, fmt.Errorf("fac proof verify: invalid Paillier modulus (bit length %d)", pkN.BitLen()) } if !common.IsUsableUnknownOrderModulus(N, common.MinUnknownOrderModulusBitLen) { - return false, fmt.Errorf("fac proof verify: invalid auxiliary modulus %x", N) + return false, fmt.Errorf("fac proof verify: invalid auxiliary modulus (bit length %d)", N.BitLen()) } for name, base := range map[string]*big.Int{ "s": s, diff --git a/crypto/paillier/key_reuse_regression_test.go b/crypto/paillier/key_reuse_regression_test.go index 0aa696861..f98905408 100644 --- a/crypto/paillier/key_reuse_regression_test.go +++ b/crypto/paillier/key_reuse_regression_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" ) // TestConcurrentSameKeyOperationsDecryptCorrectMessages runs concurrent @@ -77,12 +79,14 @@ func TestConcurrentSameKeyOperationsDecryptCorrectMessages(t *testing.T) { // TestKeyReuseAfterKeyValuesReplaced verifies that after a private key // object's exported N and LambdaN are sequentially replaced (or mutated in // place) with a second valid fixture key's values, the same reused object -// correctly decrypts ciphertexts produced under that second key. +// correctly decrypts ciphertexts produced under that second key and its +// cache-dependent public-key arithmetic (Encrypt, HomoMult) tracks the +// new values instead of stale cached state. func TestKeyReuseAfterKeyValuesReplaced(t *testing.T) { setPaillierCTTestMode(t, true) sk, pk, err := loadFixturePaillierKey(0) require.NoError(t, err) - sk2, pk2, err := loadFixturePaillierKey(1) + key2Sk, key2Pk, err := loadFixturePaillierKey(1) require.NoError(t, err) // Warm the cached state on the original key's values. @@ -91,6 +95,13 @@ func TestKeyReuseAfterKeyValuesReplaced(t *testing.T) { m0, err := sk.Decrypt(c0) require.NoError(t, err) require.Equal(t, 0, m0.Cmp(big.NewInt(5))) + hc0, err := pk.HomoMult(big.NewInt(2), c0) + require.NoError(t, err) + // Decrypt the warmed-up HomoMult result to prove the cached path + // produced a valid ciphertext of 2*5=10. + mh0, err := sk.Decrypt(hc0) + require.NoError(t, err) + require.Equal(t, 0, mh0.Cmp(big.NewInt(10)), "warmed key must decrypt its own HomoMult result") // Preserve the original values so we can mutate back and forth. origN := new(big.Int).Set(sk.N) @@ -100,53 +111,130 @@ func TestKeyReuseAfterKeyValuesReplaced(t *testing.T) { // In-place value mutation of the exported fields to the second key's // values; the cache must detect the value change, not just reuse a // stale context by pointer identity. - sk.N.Set(sk2.N) - sk.LambdaN.Set(sk2.LambdaN) - sk.PhiN.Set(sk2.PhiN) - c1, err := pk2.Encrypt(big.NewInt(9)) + sk.N.Set(key2Sk.N) + sk.LambdaN.Set(key2Sk.LambdaN) + sk.PhiN.Set(key2Sk.PhiN) + c1, err := key2Pk.Encrypt(big.NewInt(9)) require.NoError(t, err) m1, err := sk.Decrypt(c1) require.NoError(t, err) assert.Equal(t, 0, m1.Cmp(big.NewInt(9)), "in-place mutated key must decrypt the second key's ciphertext") - - // Field reassignment (pointer replacement) back to the original values. + // The public-key arithmetic path (cache-using) must also operate under + // the second key's modulus after the in-place mutation: encrypt a fresh + // message with the mutated key's N and verify the ciphertext is valid + // under the second key, and that HomoMult matches the math/big + // reference. + mutatedPk := &sk.PublicKey + cmut, err := mutatedPk.Encrypt(big.NewInt(7)) + require.NoError(t, err) + mmut, err := key2Sk.Decrypt(cmut) + require.NoError(t, err) + assert.Equal(t, 0, mmut.Cmp(big.NewInt(7)), "cache-dependent Encrypt on mutated key must decrypt under the second key") + hmut, err := mutatedPk.HomoMult(big.NewInt(3), cmut) + require.NoError(t, err) + N2 := key2Pk.NSquare() + wantH := common.ModInt(N2).Exp(cmut, big.NewInt(3)) + assert.Equal(t, 0, hmut.Cmp(wantH), "HomoMult on mutated key must match the math/big reference") + // Restore the in-place mutation to the original values. + sk.N.Set(origN) + sk.LambdaN.Set(origLambda) + sk.PhiN.Set(origPhi) + + // Field reassignment (pointer replacement) to the second key's values. + sk.N = key2Sk.N + sk.LambdaN = key2Sk.LambdaN + sk.PhiN = key2Sk.PhiN + c2, err := key2Pk.Encrypt(big.NewInt(11)) + require.NoError(t, err) + m2, err := sk.Decrypt(c2) + require.NoError(t, err) + assert.Equal(t, 0, m2.Cmp(big.NewInt(11)), "reassigned key must decrypt the second key's ciphertext") + // The cache-dependent public-key path must also track the reassignment: + // encrypt under the reassigned key and verify decryption. + reassignedPk := &sk.PublicKey + cra, err := reassignedPk.Encrypt(big.NewInt(13)) + require.NoError(t, err) + mra, err := key2Sk.Decrypt(cra) + require.NoError(t, err) + assert.Equal(t, 0, mra.Cmp(big.NewInt(13)), "cache-dependent Encrypt on reassigned key must decrypt under the second key") + hra, err := reassignedPk.HomoMult(big.NewInt(5), cra) + require.NoError(t, err) + wantHr := common.ModInt(N2).Exp(cra, big.NewInt(5)) + assert.Equal(t, 0, hra.Cmp(wantHr), "HomoMult on reassigned key must match the math/big reference") + // Restore pointer reassignment back to the original values. sk.N = origN sk.LambdaN = origLambda sk.PhiN = origPhi - m2, err := sk.Decrypt(c0) + m3, err := sk.Decrypt(c0) require.NoError(t, err) - assert.Equal(t, 0, m2.Cmp(big.NewInt(5)), "reassigned key must decrypt the original key's ciphertext again") + assert.Equal(t, 0, m3.Cmp(big.NewInt(5)), "restored key must decrypt the original key's ciphertext again") } // TestByValueKeyCopyIsIndependent verifies that a by-value copy of a private -// key keeps correct, independent behavior: mutating the original's exported -// fields must not disturb the copy's own cached state or decryption result. +// key keeps correct, independent behavior: re-pointing the original's +// exported fields to a second key's values must not disturb the copy's +// cached public-key state or its decryption under the original key, while +// the original's own cache-dependent arithmetic must pick up the new values. func TestByValueKeyCopyIsIndependent(t *testing.T) { setPaillierCTTestMode(t, true) sk, pk, err := loadFixturePaillierKey(0) require.NoError(t, err) - sk2, pk2, err := loadFixturePaillierKey(1) + key2Sk, key2Pk, err := loadFixturePaillierKey(1) require.NoError(t, err) + // The copy's embedded public key is a distinct cache owner from the + // original's. skCopy := *sk pkCopy := *pk - // Mutate the original to the second key's values (pointer replacement). - sk.N = sk2.N - sk.LambdaN = sk2.LambdaN - sk.PhiN = sk2.PhiN + // Warm both cache entries under the original key's values before any + // mutation, so the later assertions cannot be satisfied by a first-use + // build. + cw, err := pk.Encrypt(big.NewInt(5)) + require.NoError(t, err) + hw, err := pk.HomoMult(big.NewInt(2), cw) + require.NoError(t, err) + mw, err := sk.Decrypt(hw) + require.NoError(t, err) + require.Equal(t, 0, mw.Cmp(big.NewInt(10)), "warmed original must decrypt its own HomoMult result") + cCopy, err := pkCopy.Encrypt(big.NewInt(7)) + require.NoError(t, err) + hCopy, err := pkCopy.HomoMult(big.NewInt(3), cCopy) + require.NoError(t, err) + mCopy, err := skCopy.Decrypt(hCopy) + require.NoError(t, err) + require.Equal(t, 0, mCopy.Cmp(big.NewInt(21)), "warmed copy must decrypt its own HomoMult result") + + // Re-point the original's fields to the second key's values. The copy + // holds its own field pointers, so this cannot reach it. + sk.N = key2Sk.N + sk.LambdaN = key2Sk.LambdaN + sk.PhiN = key2Sk.PhiN - // The copy must still operate under the original key's values. - c0, err := pkCopy.Encrypt(big.NewInt(11)) + // The original's cache-dependent path must now operate under the second + // key's values. + cOrig, err := sk.Encrypt(big.NewInt(11)) require.NoError(t, err) - m0, err := skCopy.Decrypt(c0) + mOrig, err := key2Sk.Decrypt(cOrig) require.NoError(t, err) - assert.Equal(t, 0, m0.Cmp(big.NewInt(11)), "by-value copy must retain the original key's behavior") + assert.Equal(t, 0, mOrig.Cmp(big.NewInt(11)), "re-pointed original must encrypt under the second key") + hOrig, err := sk.HomoMult(big.NewInt(4), cOrig) + require.NoError(t, err) + wantOrig := new(big.Int).Exp(cOrig, big.NewInt(4), key2Pk.NSquare()) + assert.Equal(t, 0, hOrig.Cmp(wantOrig), "HomoMult on the re-pointed original must match the math/big reference") + mDirect, err := sk.Decrypt(cOrig) + require.NoError(t, err) + assert.Equal(t, 0, mDirect.Cmp(big.NewInt(11)), "re-pointed original must decrypt under the second key") - // The mutated original must now operate under the second key's values. - c1, err := pk2.Encrypt(big.NewInt(13)) + // The copy must still operate under the original key's values. + cKeep, err := pkCopy.Encrypt(big.NewInt(13)) require.NoError(t, err) - m1, err := sk.Decrypt(c1) + mKeep, err := skCopy.Decrypt(cKeep) + require.NoError(t, err) + assert.Equal(t, 0, mKeep.Cmp(big.NewInt(13)), "by-value copy must retain the original key's behavior") + hKeep, err := pkCopy.HomoMult(big.NewInt(5), cKeep) + require.NoError(t, err) + mhKeep, err := skCopy.Decrypt(hKeep) require.NoError(t, err) - assert.Equal(t, 0, m1.Cmp(big.NewInt(13)), "mutated original must decrypt the second key's ciphertext") + assert.Equal(t, 0, mhKeep.Cmp(big.NewInt(65)), "by-value copy must keep HomoMult under the original key") } diff --git a/crypto/paillier/mod_proof.go b/crypto/paillier/mod_proof.go index 03f56c1a3..20621518d 100644 --- a/crypto/paillier/mod_proof.go +++ b/crypto/paillier/mod_proof.go @@ -2,7 +2,6 @@ package paillier import ( "encoding/binary" - "errors" "fmt" "math/big" @@ -14,10 +13,6 @@ 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...) } @@ -62,15 +57,15 @@ func (privateKey *PrivateKey) ModProof(session ...[]byte) *ModProof { // One snapshot for context creation and every use, even if the global // toggle changes while this proof is being generated. useCT := common.IsConstantTimeEnabled() - // 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. On the constant-time path the context also - // pre-encodes those exponents at the modulus public byte widths and - // zeroes the owned encodings before the proof is returned. + // The p/q/N contexts and the key's secret-derived exponents are built + // once per proof and shared by every PARAM_M iteration; sharing only + // removes repeated setup and does not change any proof byte. The + // wipe below is deferred so it also runs if a later step aborts. ctx := newModProofCTContext(p, q, N, phiN, invN, useCT) + defer ctx.wipeExponentEncodings() + if modProofTestHook != nil { + modProofTestHook(ctx) + } for i, y_i := range y { a_i, b_i, x_i := ctx.defineXi(w, y_i, p, q, N) @@ -91,10 +86,6 @@ func (privateKey *PrivateKey) ModProof(session ...[]byte) *ModProof { z[i] = z_i } - // Wipe the secret-derived exponent encodings owned by the context; - // the proof values above are already plain big.Ints. - ctx.wipeExponentEncodings() - return &ModProof{ W: w, X: x, @@ -113,15 +104,8 @@ func (pf ModProof) ModVerify(N *big.Int, session ...[]byte) (bool, error) { return false, fmt.Errorf("mod proof verify: nil inputs in proof") } - // 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, common.MinUnknownOrderModulusBitLen) { - return false, fmt.Errorf("mod proof verify: invalid modulus %d", N) + return false, fmt.Errorf("mod proof verify: invalid modulus (bit length %d)", N.BitLen()) } if !common.Gt(pf.W, zero) || !common.Lt(pf.W, N) { @@ -226,21 +210,21 @@ func sampleYModN(tag []byte, N *big.Int, inputs ...*big.Int) *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) and the key's invN = N^(-1) -// mod phiN. 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. +// modProofTestHook lets a test observe the context ModProof creates, after +// the deferred wipe is registered. It is nil outside tests. +var modProofTestHook func(*modProofCTContext) + +// modProofCTContext is the precomputed state ModProof shares across all +// PARAM_M iterations: the p, q and N modular contexts, the key's +// secret-derived exponents ((p-1)/2, (q-1)/2 and the fourth-root +// exponent (phiN+4)/8), and the key's invN = N^(-1) mod phiN. Built once +// per proof and shared by every iteration, it only removes repeated +// setup; the proof bytes do not depend on the sharing. // -// In the constant-time path the context also owns fixed-width encodings -// of those invariant exponents (psPExp, psQExp, rootExpExp, invNExp), -// pre-encoded once at the modulus public byte widths and reused across -// all iterations instead of being re-encoded and re-wiped on every -// call; wipeExponentEncodings zeroes them when proof generation -// finishes. +// On the constant-time path the context also owns fixed-width encodings +// of those exponents (psPExp, psQExp, rootExpExp, invNExp), reused +// across every iteration instead of being re-encoded each time; +// wipeExponentEncodings zeroes them before the proof is returned. type modProofCTContext struct { useCT bool ctP *common.CTModInt @@ -360,50 +344,6 @@ func (ctx *modProofCTContext) defineXi(w, y_i, p, q, N *big.Int) (bool, bool, *b panic("no root found") // this should not be reached with n=pq for safe primes p, q } -// Standalone one-shot wrappers: they pay no context setup and snapshot the -// constant-time toggle for the call only. - -// 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 { - ps := new(big.Int).Div(new(big.Int).Sub(p, big.NewInt(1)), big.NewInt(2)) - return isQuadResidueModPrimeWithExponent(x, p, common.IsConstantTimeEnabled(), ps) -} - -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), one) - } - 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 { - 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 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) - return res.Exp(res, e, n) -} - func UnmarshalModProof(ws []byte, xs [][]byte, as []bool, bs []bool, zs [][]byte) (*ModProof, error) { if len(ws) == 0 { return nil, fmt.Errorf("UnmarshalModProof: W length zero") diff --git a/crypto/paillier/mod_proof_exponents_test.go b/crypto/paillier/mod_proof_exponents_test.go index 09ab5472b..cd6a5f2e1 100644 --- a/crypto/paillier/mod_proof_exponents_test.go +++ b/crypto/paillier/mod_proof_exponents_test.go @@ -1,6 +1,7 @@ package paillier import ( + "errors" "math/big" "testing" @@ -82,3 +83,52 @@ func TestModProofCTContextExponentReuseEquivalence(t *testing.T) { assert.Zero(t, zOn.Cmp(zRef), "CT context z (reused encoded invN) must match the math/big reference") } } + +// TestModProofCTContextExponentWipe pins the advertised secret-wipe of the +// context's owned exponent encodings on the real ModProof path: a +// constant-time proof encodes the four secret-derived exponents (psP, psQ, +// rootExp, invN) into non-zero fixed-width byte strings, and the deferred +// wipe zeroes every byte of all four in place, also when proof generation +// aborts. The hook forces that abort with a panic right after the context is +// created. A regression that drops the deferred wipe, wipes only on the +// success path, or reassigns the slices instead of zeroing them fails the +// assertions below. +func TestModProofCTContextExponentWipe(t *testing.T) { + p, q, N, _, invN := modProofContextInputs(t) + setPaillierCTTestMode(t, true) + t.Cleanup(func() { modProofTestHook = nil }) + + // Each owned encoding must equal the fixed-width encoding of its + // exponent before the wipe, i.e. hold the secret and not a freshly + // zeroed array. + var want, got [][]byte + abort := errors.New("abort ModProof after context creation") + modProofTestHook = func(ctx *modProofCTContext) { + want = [][]byte{ + fixedWidthExponentBytes(ctx.psP, p), + fixedWidthExponentBytes(ctx.psQ, q), + fixedWidthExponentBytes(ctx.rootExp, N), + fixedWidthExponentBytes(invN, N), + } + got = [][]byte{ctx.psPExp, ctx.psQExp, ctx.rootExpExp, ctx.invNExp} + for i := range got { + require.Equal(t, want[i], got[i], "owned encoding %d must hold its exponent before the wipe", i) + } + panic(abort) + } + + func() { + defer func() { + assert.Equal(t, abort, recover(), "ModProof must abort through the test hook") + }() + privateKey.ModProof() + }() + + // After the abort every byte of every owned encoding is zero, and the + // arrays keep their original length (zeroed in place, not reassigned). + require.Len(t, got, 4, "the hook must have captured the context") + for i := range got { + zero := make([]byte, len(want[i])) + assert.Equal(t, zero, got[i], "owned encoding %d must be fully zeroed after the abort", i) + } +} diff --git a/crypto/paillier/mod_proof_test.go b/crypto/paillier/mod_proof_test.go index 9e24de7e7..784f68d8e 100644 --- a/crypto/paillier/mod_proof_test.go +++ b/crypto/paillier/mod_proof_test.go @@ -1,9 +1,10 @@ package paillier import ( - "errors" + "crypto/rand" "fmt" "math/big" + "strings" "testing" "github.com/bnb-chain/tss-lib/common" @@ -164,10 +165,14 @@ func TestModProofVerify_ForgedProof(t *testing.T) { assert.False(t, res, "proof verify result must be false") } +// TestModProofVerify_AttackMod checks a forged proof for a modulus with +// many prime factors: N = P * Q_1 * ... * Q_7. The forged w is 1 mod P and +// 0 mod every Q_i, so it shares a factor with N. P is a large prime chosen +// so that N is at least 2048 bits; this way the width floor does not reject +// N first and the proof reaches the Jacobi check on w. func TestModProofVerify_AttackMod(t *testing.T) { session := []byte("mod-proof-attack-session") - P := mustSetString("11956161572522965463") Q := []*big.Int{ mustSetString("2495927741"), mustSetString("3726287311"), @@ -177,18 +182,33 @@ func TestModProofVerify_AttackMod(t *testing.T) { mustSetString("2316427879"), mustSetString("3704490329"), } - - N := new(big.Int).Set(P) + bigQ := new(big.Int).Set(one) for _, q := range Q { - N.Mul(N, q) + bigQ.Mul(bigQ, q) + } + // P = 7 mod 8, like the original attack prime, so (P+1)/8 is an integer. + pBits := common.MinUnknownOrderModulusBitLen - bigQ.BitLen() + 1 + var P *big.Int + for { + var err error + P, err = rand.Prime(rand.Reader, pBits) + require.NoError(t, err) + if new(big.Int).And(P, big.NewInt(7)).Int64() == 7 { + break + } } + N := new(big.Int).Mul(P, bigQ) + require.GreaterOrEqual(t, N.BitLen(), common.MinUnknownOrderModulusBitLen) + require.True(t, common.IsUsableUnknownOrderModulus(N, common.MinUnknownOrderModulusBitLen), + "the attack modulus must pass the modulus policy") proof, err := newHackedModProof(session, N, P, Q) - assert.NoError(t, err) + require.NoError(t, err) ok, err := proof.ModVerify(N, session) - assert.Error(t, err) + require.Error(t, err) assert.False(t, ok, "false proof should not verify") + assert.Contains(t, err.Error(), "jacobi", "the forged w must be rejected by the Jacobi check") } func newHackedModProof(session []byte, N, P *big.Int, Q []*big.Int) (*ModProof, error) { @@ -276,48 +296,45 @@ func TestModSqrt(t *testing.T) { // 37^2 = 1369 = 60 mod 77 // 60^2 = 3600 = 58 mod 77 - // 37^4 = 58 mod 77 + // 37^4 = 58 mod 77. The context's QR predicate and fourth root must + // agree with the math/big reference in both the plain and the + // constant-time context; the context's mode is fixed at construction. + p, q, N := b(7), b(11), b(77) + phiN := new(big.Int).Mul(new(big.Int).Sub(p, one), new(big.Int).Sub(q, one)) + invN := new(big.Int).ModInverse(N, phiN) + require.NotNil(t, invN, "the 7*11 modulus must admit N^(-1) mod phiN") - // 59 = 3 (mod 7) which is not a residue - // 59 = 4 (mod 11) + ctxOff := newModProofCTContext(p, q, N, phiN, invN, false) - assert.True(isQuadResidueModPrime(b(58), b(7))) - assert.True(isQuadResidueModPrime(b(58), b(11))) + assert.True(ctxOff.isQuadResidueModPrime(b(58), p, nil, ctxOff.psP, nil)) + assert.True(ctxOff.isQuadResidueModPrime(b(58), q, nil, ctxOff.psQ, nil)) - assert.False(isQuadResidueModPrime(b(59), b(7))) - assert.True(isQuadResidueModPrime(b(59), b(11))) + assert.False(ctxOff.isQuadResidueModPrime(b(59), p, nil, ctxOff.psP, nil)) + assert.True(ctxOff.isQuadResidueModPrime(b(59), q, nil, ctxOff.psQ, nil)) - assert.True(isQuadResidueModComposite(b(58), b(7), b(11))) - assert.False(isQuadResidueModComposite(b(59), b(7), b(11))) + assert.True(ctxOff.isQuadResidueModComposite(b(58), b(7), b(11))) + assert.False(ctxOff.isQuadResidueModComposite(b(59), b(7), b(11))) - assert.Equal(b(37), quadResidueModComposite(b(58), b(7), b(11), b(77), b(60))) -} + assert.Equal(b(37), ctxOff.fourthRoot(b(58), b(77))) + ctxOn := newModProofCTContext(p, q, N, phiN, invN, true) -// 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(common.MaxUnknownOrderModulusBitLen-1)) - if atCeiling.BitLen() != common.MaxUnknownOrderModulusBitLen { - t.Fatalf("test modulus is %d bits, wanted %d", atCeiling.BitLen(), common.MaxUnknownOrderModulusBitLen) - } - if common.ExceedsUnknownOrderModulusCeiling(atCeiling) { - t.Fatal("a modulus at exactly MaxUnknownOrderModulusBitLen must not exceed the ceiling") - } + assert.True(ctxOn.isQuadResidueModPrime(b(58), p, ctxOn.ctP, ctxOn.psP, ctxOn.psPExp)) + assert.True(ctxOn.isQuadResidueModPrime(b(58), q, ctxOn.ctQ, ctxOn.psQ, ctxOn.psQExp)) - 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 !common.ExceedsUnknownOrderModulusCeiling(pastCeiling) { - t.Fatal("a modulus one bit past MaxUnknownOrderModulusBitLen must exceed the ceiling") - } + assert.False(ctxOn.isQuadResidueModPrime(b(59), p, ctxOn.ctP, ctxOn.psP, ctxOn.psPExp)) + assert.True(ctxOn.isQuadResidueModPrime(b(59), q, ctxOn.ctQ, ctxOn.psQ, ctxOn.psQExp)) + + assert.True(ctxOn.isQuadResidueModComposite(b(58), b(7), b(11))) + assert.False(ctxOn.isQuadResidueModComposite(b(59), b(7), b(11))) + + assert.Equal(b(37), ctxOn.fourthRoot(b(58), b(77))) } // 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. +// minimum-size guards) is rejected at the modulus gate, before the proof +// members are checked. Without the width ceiling the 65537-bit composite +// would pass the gate and fail a later proof-member check instead. func TestModProofVerifyModulusBitLenCeilingBehavior(t *testing.T) { proof := minimalModProof() @@ -331,16 +348,8 @@ func TestModProofVerifyModulusBitLenCeilingBehavior(t *testing.T) { 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") + assert.True(t, strings.HasPrefix(err.Error(), "mod proof verify: invalid modulus"), + "must reject at the modulus gate") } // minimalModProof builds a ModProof that clears per-member nil checks. diff --git a/crypto/paillier/modulus_policy_test.go b/crypto/paillier/modulus_policy_test.go new file mode 100644 index 000000000..2dd17fbc3 --- /dev/null +++ b/crypto/paillier/modulus_policy_test.go @@ -0,0 +1,444 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package paillier + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/crypto" + "github.com/bnb-chain/tss-lib/tss" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// oddCompositeAtBits deterministically builds an odd composite of exactly +// bitLen bits: 3*2^(bitLen-2)+3 is odd and divisible by 3, so the +// ProbablyPrime rounds inside common.IsUsableUnknownOrderModulus reject it +// via small-prime trial division — no safe-prime or Paillier key +// generation is needed. +func oddCompositeAtBits(bitLen int) *big.Int { + n := new(big.Int).Lsh(big.NewInt(3), uint(bitLen-2)) + n.Add(n, big.NewInt(3)) + return n +} + +// evenAtBits returns an even value of exactly bitLen bits: 2^(bitLen-1). +func evenAtBits(bitLen int) *big.Int { + return new(big.Int).Lsh(big.NewInt(1), uint(bitLen-1)) +} + +// hasWidthPolicyError reports whether err is one of the shared +// width-policy rejections FactorVerify returns: the usability +// floor/ceiling/parity/composite check ("invalid Paillier modulus" / +// "invalid auxiliary modulus"). A width- +// valid 2048-bit odd composite is rejected downstream (generators, +// equations), so a negative assert against this helper proves the width +// policy itself did not fire. +func hasWidthPolicyError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "invalid Paillier modulus") || + strings.Contains(msg, "invalid auxiliary modulus") +} + +// TestFactorVerifyModulusPolicySwaps pins the shared width policy on +// FactorVerify: on a valid fixture proof, only one modulus argument — the +// Paillier pkN or the auxiliary N, separately — is swapped. The fixture +// moduli are the positive control. A 2048-bit odd composite is +// width-valid: its rejection must come from a downstream generator or +// equation check, not the width policy. A 2047-bit odd composite is below +// the width floor, a 65537-bit odd composite is one past the shared +// ceiling, and even values fail parity. Removing a width call changes which +// error text is returned (or drops the error entirely), failing the +// matching assertion. +func TestFactorVerifyModulusPolicySwaps(t *testing.T) { + facSetUp(t) + + // errSubstr pins the rejection to the exact policy call that must fire. + // Empty errSubstr means the swapped modulus is width-valid, so + // FactorVerify must let it through the width policy and reject it at a + // downstream check instead. + cases := []struct { + name string + pkN, N *big.Int + accepted bool + errSubstr string + }{ + { + name: "fixture_modulus_accepted", + pkN: publicKey.N, + N: auxPrime.N, + accepted: true, + }, + { + name: "paillier_2047bit_odd_composite_rejected", + pkN: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen - 1), + N: auxPrime.N, + errSubstr: "invalid Paillier modulus", + }, + { + name: "paillier_2048bit_odd_composite_width_valid", + pkN: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen), + N: auxPrime.N, + }, + { + name: "paillier_65537bit_odd_composite_rejected", + pkN: oddCompositeAtBits(common.MaxUnknownOrderModulusBitLen + 1), + N: auxPrime.N, + errSubstr: "invalid Paillier modulus", + }, + { + name: "paillier_even_rejected", + pkN: evenAtBits(common.MinUnknownOrderModulusBitLen), + N: auxPrime.N, + errSubstr: "invalid Paillier modulus", + }, + { + name: "aux_2047bit_odd_composite_rejected", + pkN: publicKey.N, + N: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen - 1), + errSubstr: "invalid auxiliary modulus", + }, + { + name: "aux_2048bit_odd_composite_width_valid", + pkN: publicKey.N, + N: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen), + }, + { + name: "aux_65537bit_odd_composite_rejected", + pkN: publicKey.N, + N: oddCompositeAtBits(common.MaxUnknownOrderModulusBitLen + 1), + errSubstr: "invalid auxiliary modulus", + }, + { + name: "aux_even_rejected", + pkN: publicKey.N, + N: evenAtBits(common.MinUnknownOrderModulusBitLen), + errSubstr: "invalid auxiliary modulus", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + proof := privateKey.FactorProof(auxPrime.N, s, tt) + res, err := proof.FactorVerify(tc.pkN, tc.N, s, tt) + + if tc.accepted { + assert.NoError(t, err) + assert.True(t, res, "fixture proof must verify under the fixture moduli") + return + } + + assert.Error(t, err, "swapped modulus must be rejected") + assert.False(t, res, "swapped modulus must not verify") + if tc.errSubstr != "" { + assert.Contains(t, err.Error(), tc.errSubstr, + "rejection must come from the expected width-policy call") + return + } + assert.False(t, hasWidthPolicyError(err), + "2048-bit odd composite is width-valid; rejection must not be a width-policy error: %v", err) + }) + } +} + +// TestProofVerifyModulusPolicySwaps pins the shared width policy on +// Proof.Verify: on a valid fixture proof, only pkN is swapped. +// Proof.Verify signals every rejection — width policy included — as a +// plain `false` with a nil error (its error return is reserved for a +// malformed xs generation), so rejected swaps are asserted on `false` +// alone and the fixture modulus is the positive control. +func TestProofVerifyModulusPolicySwaps(t *testing.T) { + setUp(t) + require.NotNil(t, privateKey) + require.NotNil(t, publicKey) + + ki := common.MustGetRandomInt(256) // index + ui := common.GetRandomPositiveInt(tss.EC().Params().N) + yX, yY := tss.EC().ScalarBaseMult(ui.Bytes()) + ecdsaPub := crypto.NewECPointNoCurveCheck(tss.EC(), yX, yY) + + cases := []struct { + name string + pkN *big.Int + accepted bool + }{ + { + name: "fixture_modulus_accepted", + pkN: publicKey.N, + accepted: true, + }, + { + name: "2047bit_odd_composite_rejected", + pkN: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen - 1), + }, + { + // Width-valid composite: the width policy does not reject it; + // a proof built under the fixture modulus simply cannot + // verify under a different modulus. + name: "2048bit_odd_composite_rejected_by_equation", + pkN: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen), + }, + { + name: "65537bit_odd_composite_rejected", + pkN: oddCompositeAtBits(common.MaxUnknownOrderModulusBitLen + 1), + }, + { + name: "even_rejected", + pkN: evenAtBits(common.MinUnknownOrderModulusBitLen), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + proof := privateKey.Proof(ki, ecdsaPub) + res, err := proof.Verify(tc.pkN, ki, ecdsaPub) + assert.NoError(t, err) + assert.Equal(t, tc.accepted, res, + "swapped modulus %s: verify result mismatch", tc.name) + }) + } +} + +// blumKeyBelowFloor returns a Paillier key with a 1024-bit modulus N = p*q, +// where p and q are 512-bit primes congruent to 3 mod 4. Honest Proof and +// ModProof outputs for this key satisfy every verification equation, so +// only the 2048-bit width floor can reject them. +func blumKeyBelowFloor(t *testing.T) *PrivateKey { + t.Helper() + blumPrime := func() *big.Int { + for { + p, err := rand.Prime(rand.Reader, 512) + require.NoError(t, err) + if p.Bit(1) == 1 { // p = 3 mod 4 + return p + } + } + } + p, q := blumPrime(), blumPrime() + for p.Cmp(q) == 0 { + q = blumPrime() + } + N := new(big.Int).Mul(p, q) + pMinus1, qMinus1 := new(big.Int).Sub(p, one), new(big.Int).Sub(q, one) + phiN := new(big.Int).Mul(pMinus1, qMinus1) + gcd := new(big.Int).GCD(nil, nil, pMinus1, qMinus1) + lambdaN := new(big.Int).Div(phiN, gcd) + require.Less(t, N.BitLen(), common.MinUnknownOrderModulusBitLen) + return &PrivateKey{PublicKey: PublicKey{N: N}, LambdaN: lambdaN, PhiN: phiN} +} + +// TestProofVerifyCallsModulusPolicy pins that Proof.Verify calls the +// modulus policy. The proof is an honest proof for a 1024-bit key, so its +// equations hold; only the width floor rejects it. +func TestProofVerifyCallsModulusPolicy(t *testing.T) { + key := blumKeyBelowFloor(t) + k := big.NewInt(1) + ui := common.GetRandomPositiveInt(tss.EC().Params().N) + ecdsaPub := crypto.ScalarBaseMult(tss.EC(), ui) + + proof := key.Proof(k, ecdsaPub) + res, err := proof.Verify(key.N, k, ecdsaPub) + assert.NoError(t, err) + assert.False(t, res, "a modulus below the width floor must be rejected") +} + +// mersennePrime2203 is 2^2203 - 1, a known Mersenne prime: odd and inside +// the width bounds, so only the composite check rejects it. +func mersennePrime2203() *big.Int { + return new(big.Int).Sub(new(big.Int).Lsh(one, 2203), one) +} + +// TestModVerifyModulusPolicySwaps pins the shared modulus policy on +// ModVerify. Policy rejections must carry the "invalid modulus" error; a +// width-valid modulus must get past the policy and fail a later check. +func TestModVerifyModulusPolicySwaps(t *testing.T) { + modSetUp(t) + fixtureProof := privateKey.ModProof() + smallKey := blumKeyBelowFloor(t) + require.True(t, mersennePrime2203().ProbablyPrime(20)) + + const policyErr = "invalid modulus" + cases := []struct { + name string + proof *ModProof + N *big.Int + accepted bool + errSubstr string + }{ + {name: "fixture_modulus_accepted", proof: fixtureProof, N: publicKey.N, accepted: true}, + { + // Honest proof: every equation holds, only the floor rejects. + name: "honest_1024bit_proof_rejected_by_floor", + proof: smallKey.ModProof(), + N: smallKey.N, + errSubstr: policyErr, + }, + { + name: "2047bit_odd_composite_rejected", + proof: fixtureProof, + N: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen - 1), + errSubstr: policyErr, + }, + { + name: "2048bit_odd_composite_width_valid", + proof: fixtureProof, + N: oddCompositeAtBits(common.MinUnknownOrderModulusBitLen), + }, + { + name: "65537bit_odd_composite_rejected", + proof: fixtureProof, + N: oddCompositeAtBits(common.MaxUnknownOrderModulusBitLen + 1), + errSubstr: policyErr, + }, + { + name: "even_rejected", + proof: fixtureProof, + N: evenAtBits(common.MinUnknownOrderModulusBitLen), + errSubstr: policyErr, + }, + { + name: "prime_rejected", + proof: fixtureProof, + N: mersennePrime2203(), + errSubstr: policyErr, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := tc.proof.ModVerify(tc.N) + if tc.accepted { + assert.NoError(t, err) + assert.True(t, res) + return + } + require.Error(t, err) + assert.False(t, res) + if tc.errSubstr != "" { + assert.Contains(t, err.Error(), tc.errSubstr) + return + } + assert.NotContains(t, err.Error(), policyErr, + "a width-valid composite must get past the modulus policy") + }) + } +} + +// factorVerifyBounds recomputes the verifier-side absolute-value limits +// FactorVerify enforces on the responses Z1/Z2/W1/W2/Sigma/V — the same +// expressions as FactorVerify's body — for the fixture key pairs. +func factorVerifyBounds() map[string]*big.Int { + one := big.NewInt(1) + + zLimit := new(big.Int).Lsh(one, PARAM_L+PARAM_E) + zLimit.Mul(zLimit, new(big.Int).Sqrt(publicKey.N)) + + q := new(big.Int).Lsh(one, PARAM_L) + q3 := new(big.Int).Mul(q, q) + q3.Mul(q3, q) + qN := new(big.Int).Mul(q, auxPrime.N) + qPkNN := new(big.Int).Mul(qN, publicKey.N) + q3N := new(big.Int).Mul(q3, auxPrime.N) + q3PkNN := new(big.Int).Mul(q3N, publicKey.N) + limitW := new(big.Int).Lsh(q3N, 1) + limitV := new(big.Int).Lsh(q3PkNN, 2) + + return map[string]*big.Int{ + "Z1": zLimit, + "Z2": zLimit, + "W1": limitW, + "W2": limitW, + "Sigma": qPkNN, + "V": limitV, + } +} + +// setFactorProofField copies the proof struct and replaces exactly one +// response field, leaving every other field unchanged. +func setFactorProofField(proof *FactorProof, name string, value *big.Int) FactorProof { + p := *proof + switch name { + case "Z1": + p.Z1 = value + case "Z2": + p.Z2 = value + case "W1": + p.W1 = value + case "W2": + p.W2 = value + case "Sigma": + p.Sigma = value + case "V": + p.V = value + } + return p +} + +// TestFactorVerifyRejectsResponsesOnePastBound pins the cheap response DoS +// bounds in FactorVerify: a valid fixture proof with exactly one response +// replaced by its verifier bound plus one is rejected by that bound check +// itself — before any modular exponentiation — with the specific +// "exceeds limit" error naming that bound, while the unmutated proof still +// verifies. Removing the bound call or off-by-one-ing the CmpAbs +// comparison makes the matching case fail. +func TestFactorVerifyRejectsResponsesOnePastBound(t *testing.T) { + facSetUp(t) + + bounds := factorVerifyBounds() + over := make(map[string]*big.Int, len(bounds)) + for name, bound := range bounds { + over[name] = new(big.Int).Add(bound, big.NewInt(1)) + } + + for _, name := range []string{"Z1", "Z2", "W1", "W2", "Sigma", "V"} { + t.Run(name, func(t *testing.T) { + proof := privateKey.FactorProof(auxPrime.N, s, tt) + + // Control: the unmutated proof verifies. + res, err := proof.FactorVerify(publicKey.N, auxPrime.N, s, tt) + assert.NoError(t, err) + assert.True(t, res, "unmutated fixture proof must still verify") + + mutated := setFactorProofField(proof, name, over[name]) + res, err = mutated.FactorVerify(publicKey.N, auxPrime.N, s, tt) + assert.Error(t, err) + assert.False(t, res) + assert.Contains(t, err.Error(), "exceeds limit", + "rejection must come from the response bound check") + assert.Contains(t, err.Error(), fmt.Sprintf("%x", bounds[name]), + "the reported bound must be the one enforced for "+name) + }) + } +} + +// TestFactorVerifyResponseAtLimitNotRejectedByBound pins the inclusive +// bound semantics: a response exactly at the limit must pass the bound +// check (CmpAbs > 0, not >= 0) and fail at a later verification equation +// instead. This catches an off-by-one regression of the bound comparison +// that the one-past-bound test would miss. +func TestFactorVerifyResponseAtLimitNotRejectedByBound(t *testing.T) { + facSetUp(t) + + bounds := factorVerifyBounds() + + proof := privateKey.FactorProof(auxPrime.N, s, tt) + mutated := setFactorProofField(proof, "Z1", bounds["Z1"]) + + res, err := mutated.FactorVerify(publicKey.N, auxPrime.N, s, tt) + assert.Error(t, err) + assert.False(t, res) + assert.NotContains(t, err.Error(), "exceeds limit", + "a response exactly at the inclusive limit must not trip the bound check") +} diff --git a/crypto/paillier/paillier.go b/crypto/paillier/paillier.go index f1e2ba0ef..d026a84dc 100644 --- a/crypto/paillier/paillier.go +++ b/crypto/paillier/paillier.go @@ -54,6 +54,9 @@ type ( var ( ErrMessageTooLong = fmt.Errorf("the message is too large or < 0") ErrMessageMalFormed = fmt.Errorf("the message is mal-formed") + // ErrInvalidBound is returned when the public bound passed to + // HomoMultBounded is nil, not positive or greater than N. + ErrInvalidBound = errors.New("paillier: multiplier bound must be positive and at most N") zero = big.NewInt(0) one = big.NewInt(1) @@ -154,7 +157,6 @@ func (publicKey *PublicKey) EncryptAndReturnRandomness(m *big.Int) (c *big.Int, xN := new(big.Int).Exp(x, publicKey.N, N2) // 3. (1) * (2) mod N2 c = common.ModInt(N2).Mul(Gm, xN) - runtime.KeepAlive(publicKey) return } @@ -163,11 +165,24 @@ func (publicKey *PublicKey) Encrypt(m *big.Int) (c *big.Int, err error) { return } -func (publicKey *PublicKey) HomoMult(m, c1 *big.Int) (*big.Int, error) { +// HomoMultBounded returns c1^m mod N^2, an encryption of m times c1's +// plaintext. m is a secret scalar in the MtA protocol, so the exponent is +// evaluated in constant time when constant-time operations are enabled. +// bound is a public exclusive upper bound on m that the caller guarantees, +// never derived from m itself; when constant-time operations are enabled the +// exponent is padded to bound.BitLen() bits. It returns: +// - ErrInvalidModulus when N is nil, even or not greater than one; +// - ErrInvalidBound when bound is nil, not positive or greater than N; +// - ErrMessageTooLong when m is nil or outside [0, bound), or c1 is +// outside [0, N^2). +func (publicKey *PublicKey) HomoMultBounded(m, c1, bound *big.Int) (*big.Int, error) { if err := checkPaillierModulus(publicKey.N); err != nil { return nil, err } - if m.Cmp(zero) == -1 || m.Cmp(publicKey.N) != -1 { // m < 0 || m >= N ? + if bound == nil || bound.Sign() <= 0 || bound.Cmp(publicKey.N) > 0 { + return nil, ErrInvalidBound + } + if m == nil || m.Sign() < 0 || m.Cmp(bound) >= 0 { // m < 0 || m >= bound ? return nil, ErrMessageTooLong } st := publicKey.paillierPublicStateFor() @@ -180,25 +195,39 @@ func (publicKey *PublicKey) HomoMult(m, c1 *big.Int) (*big.Int, error) { } // cipher^m mod N2 if common.IsConstantTimeEnabled() { - // SECURITY: m is the secret scalar multiplier used as the exponent; exponentiate - // in constant time (N2 is odd). The guards above prove c1 is canonical - // modulo N2 (0 <= c1 < N2) and bound 0 <= m < N, so N.BitLen() is a - // proven public bound narrower than N2's default width, halving the - // exponent padding bigmod.Exp works over; the N2 context is reused + // SECURITY: m is the secret scalar multiplier used as the exponent; + // exponentiate in constant time (N2 is odd). The guards above prove + // c1 is canonical modulo N2 (0 <= c1 < N2) and bound m below the + // caller's public bound, so the exponent is padded to the bound's + // width rather than the full modulus width; the N2 context is reused // across calls on this key. ctN2, err := st.ctN2(publicKey) if err != nil { return nil, err } - defer runtime.KeepAlive(publicKey) - return ctN2.ExpCTCanonicalWithBitLen(c1, m, publicKey.N.BitLen()), nil + return ctN2.ExpCTCanonicalWithBitLen(c1, m, bound.BitLen()), nil } - defer runtime.KeepAlive(publicKey) return common.ModInt(N2).Exp(c1, m), nil } +// HomoMult is HomoMultBounded with bound = N, the plaintext domain. It +// returns the same errors; the modulus is checked first, so a nil or +// degenerate N returns ErrInvalidModulus. +func (publicKey *PublicKey) HomoMult(m, c1 *big.Int) (*big.Int, error) { + if err := checkPaillierModulus(publicKey.N); err != nil { + return nil, err + } + return publicKey.HomoMultBounded(m, c1, publicKey.N) +} + func (publicKey *PublicKey) HomoAdd(c1, c2 *big.Int) (*big.Int, error) { - N2 := publicKey.NSquare() + if err := checkPaillierModulus(publicKey.N); err != nil { + return nil, err + } + N2, err := publicKey.paillierPublicStateFor().n2Value(publicKey) + if err != nil { + return nil, err + } if c1.Cmp(zero) == -1 || c1.Cmp(N2) != -1 { // c1 < 0 || c1 >= N2 ? return nil, ErrMessageTooLong } @@ -263,8 +292,8 @@ func (privateKey *PrivateKey) Decrypt(c *big.Int) (m *big.Int, err error) { inv = new(big.Int).ModInverse(L(gammaExpLambda, privateKey.N), privateKey.N) } if inv == nil { - // A non-invertible decryption coefficient means malformed key - // material; both modes return the same error. + // A non-invertible decryption coefficient (LambdaN mod N not + // invertible) means malformed key material. return nil, ErrMalformedKey } @@ -281,7 +310,8 @@ func (privateKey *PrivateKey) Decrypt(c *big.Int) (m *big.Int, err error) { // An efficient non-interactive statistical zero-knowledge proof system for quasi-safe prime products. // In: In Proc. of the 5th ACM Conference on Computer and Communications Security (CCS-98. Citeseer (1998) // -// This only implements the stage 1 proof that N is square-free from 3.1 +// This only implements the stage 1 proof that N is square-free from 3.1. +// // It panics if the private key modulus cannot supply challenges. func (privateKey *PrivateKey) Proof(k *big.Int, ecdsaPub *crypto2.ECPoint) Proof { var pi Proof @@ -314,12 +344,6 @@ func (pf Proof) Verify(pkN, k *big.Int, ecdsaPub *crypto2.ECPoint) (bool, error) if k.Sign() < 0 { return false, nil } - // 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 } diff --git a/crypto/paillier/paillier_errors_test.go b/crypto/paillier/paillier_errors_test.go new file mode 100644 index 000000000..9a671b869 --- /dev/null +++ b/crypto/paillier/paillier_errors_test.go @@ -0,0 +1,322 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +// These regressions pin the range and malformed-ciphertext guards of the +// Paillier arithmetic entrypoints: out-of-domain inputs must surface as +// ordinary errors (ErrMessageTooLong / ErrMessageMalFormed) instead of +// panicking inside the constant-time exponentiation, in both operation +// modes. In-domain boundary inputs must keep working, so the guards are +// pinned against silent over-rejection as well. + +package paillier + +import ( + "errors" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// modeName labels an operation-mode flag in subtest names. +func modeName(ct bool) string { + if ct { + return "ct" + } + return "var" +} + +// TestRangeGuardsRejectOutOfDomainInputs runs every range-guard case in both +// operation modes. setPaillierCTTestMode restores the global flag via +// t.Cleanup, so no mode toggle leaks out of this test. +func TestRangeGuardsRejectOutOfDomainInputs(t *testing.T) { + sk, pk, err := loadFixturePaillierKey(0) + require.NoError(t, err) + N := pk.N + N2 := pk.NSquare() + neg := big.NewInt(-1) + // -N2, a negative ciphertext-domain operand, kept local so N2 itself + // stays intact for the later cases. + negN2 := new(big.Int).Neg(N2) + // A prime factor of N stays inside the canonical ciphertext range + // [0, N^2) yet shares that factor with N^2, so the gcd check must flag + // it malformed. GetPQ recovers it deterministically from the fixture + // key material. + pFactor, _ := sk.GetPQ() + // maxMsg = N-1 is the largest in-domain message. + maxMsg := new(big.Int).Sub(N, one) + // A valid ciphertext once, shared by every in-domain case below. + cValid, err := pk.Encrypt(big.NewInt(3)) + require.NoError(t, err) + + type guardCase struct { + name string + run func() error + want error // nil = expect success and verify the round trip + } + cases := []guardCase{ + { + name: "Encrypt_mNegative", + run: func() error { + _, err := pk.Encrypt(neg) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "Encrypt_mZero", + run: func() error { + c, err := pk.Encrypt(zero) + if err != nil { + return err + } + m, err := sk.Decrypt(c) + if err != nil { + return err + } + if m.Cmp(zero) != 0 { + return errors.New("decrypted value mismatch") + } + return nil + }, + }, + { + name: "Encrypt_mMax", + run: func() error { + c, err := pk.Encrypt(maxMsg) + if err != nil { + return err + } + m, err := sk.Decrypt(c) + if err != nil { + return err + } + if m.Cmp(maxMsg) != 0 { + return errors.New("decrypted value mismatch") + } + return nil + }, + }, + { + name: "Encrypt_mEqualsN", + run: func() error { + _, err := pk.Encrypt(N) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "HomoMult_mNegative", + run: func() error { + _, err := pk.HomoMult(neg, cValid) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "HomoMult_mEqualsN", + run: func() error { + _, err := pk.HomoMult(N, cValid) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "HomoMult_c1EqualsN2", + run: func() error { + _, err := pk.HomoMult(big.NewInt(3), N2) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "HomoMult_c1Negative", + run: func() error { + _, err := pk.HomoMult(big.NewInt(3), neg) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "HomoMult_inDomainRoundTrip", + run: func() error { + hc, err := pk.HomoMult(maxMsg, cValid) + if err != nil { + return err + } + m, err := sk.Decrypt(hc) + if err != nil { + return err + } + want := new(big.Int).Mul(maxMsg, big.NewInt(3)) + want.Mod(want, N) + if m.Cmp(want) != 0 { + return errors.New("decrypted value mismatch") + } + return nil + }, + }, + { + name: "HomoAdd_c1EqualsN2", + run: func() error { + _, err := pk.HomoAdd(N2, cValid) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "HomoAdd_c2Negative", + run: func() error { + _, err := pk.HomoAdd(cValid, neg) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "HomoAdd_inDomainRoundTrip", + run: func() error { + sum, err := pk.HomoAdd(cValid, cValid) + if err != nil { + return err + } + m, err := sk.Decrypt(sum) + if err != nil { + return err + } + if m.Cmp(big.NewInt(6)) != 0 { + return errors.New("decrypted value mismatch") + } + return nil + }, + }, + { + name: "HomoAdd_degenerateKey", + // CONTRACT: like Encrypt/HomoMult/Decrypt, HomoAdd validates + // the key's modulus first and reports it before the ciphertext + // range checks. + run: func() error { + _, err := (&PublicKey{}).HomoAdd(cValid, cValid) + return err + }, + want: ErrInvalidModulus, + }, + { + name: "HomoAdd_degenerateKeyBeforeRangeCheck", + // An out-of-range operand on a degenerate key must report the + // key error, proving the modulus check runs first. + run: func() error { + _, err := (&PublicKey{}).HomoAdd(neg, neg) + return err + }, + want: ErrInvalidModulus, + }, + { + name: "Decrypt_cEqualsN2", + run: func() error { + _, err := sk.Decrypt(N2) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "Decrypt_cNegative", + run: func() error { + _, err := sk.Decrypt(neg) + return err + }, + want: ErrMessageTooLong, + }, + { + name: "Decrypt_cSharesFactorWithN", + run: func() error { + _, err := sk.Decrypt(pFactor) + return err + }, + want: ErrMessageMalFormed, + }, + { + name: "Decrypt_cNegativeN2", + run: func() error { + _, err := sk.Decrypt(negN2) + return err + }, + want: ErrMessageTooLong, + }, + } + + for _, ct := range []bool{true, false} { + setPaillierCTTestMode(t, ct) + for _, tc := range cases { + t.Run(tc.name+"/"+modeName(ct), func(t *testing.T) { + // A panic instead of the expected error means the guard + // was bypassed and the constant-time code saw the + // out-of-domain input. + assert.NotPanics(t, func() { + err := tc.run() + if tc.want == nil { + require.NoError(t, err, "ct=%v", ct) + return + } + assert.ErrorIs(t, err, tc.want, "ct=%v", ct) + }) + }) + } + } +} + +// TestHomoMultBoundedEnforcesPublicBound pins the HomoMultBounded contract +// MtA relies on: the caller's public bound must be a usable padding target +// (positive and at most N), the secret multiplier must sit in [0, bound), and +// a multiplier at the top of that range gives the same ciphertext with the +// constant-time padding as the variable-time HomoMult. +func TestHomoMultBoundedEnforcesPublicBound(t *testing.T) { + _, pk, err := loadFixturePaillierKey(0) + require.NoError(t, err) + N2 := pk.NSquare() + // A 256-bit public bound, the shape of a curve order. + bound := new(big.Int).Sub(new(big.Int).Lsh(one, 256), big.NewInt(189)) + top := new(big.Int).Sub(bound, one) + cValid, err := pk.Encrypt(big.NewInt(3)) + require.NoError(t, err) + + for _, ct := range []bool{true, false} { + setPaillierCTTestMode(t, ct) + t.Run("rejects_"+modeName(ct), func(t *testing.T) { + for name, b := range map[string]*big.Int{ + "nil": nil, + "zero": big.NewInt(0), + "neg": big.NewInt(-1), + "above_N": new(big.Int).Add(pk.N, one), + } { + _, err := pk.HomoMultBounded(big.NewInt(3), cValid, b) + assert.ErrorIs(t, err, ErrInvalidBound, "bound=%s, ct=%v", name, ct) + } + for name, m := range map[string]*big.Int{ + "nil": nil, + "neg": big.NewInt(-1), + "at_bound": bound, + } { + _, err := pk.HomoMultBounded(m, cValid, bound) + assert.ErrorIs(t, err, ErrMessageTooLong, "m=%s, ct=%v", name, ct) + } + // bound = N is the plaintext domain and stays usable. + _, err := pk.HomoMultBounded(big.NewInt(3), cValid, pk.N) + require.NoError(t, err, "ct=%v", ct) + }) + } + + // m = bound-1 has the full padded width; the padded constant-time + // result must match the unpadded variable-time HomoMult. + setPaillierCTTestMode(t, false) + want, err := pk.HomoMult(top, cValid) + require.NoError(t, err) + setPaillierCTTestMode(t, true) + got, err := pk.HomoMultBounded(top, cValid, bound) + require.NoError(t, err) + assert.Equal(t, 0, got.Cmp(want), "padded constant-time result must match HomoMult") + assert.True(t, got.Sign() >= 0 && got.Cmp(N2) < 0, "result must stay in [0, N^2)") +} diff --git a/crypto/schnorr/legacy_compatibility_test.go b/crypto/schnorr/legacy_compatibility_test.go index 5ed3ac089..b8497621d 100644 --- a/crypto/schnorr/legacy_compatibility_test.go +++ b/crypto/schnorr/legacy_compatibility_test.go @@ -140,11 +140,12 @@ func taggedChallenge(tag string, session []byte, values ...*big.Int) *big.Int { } // TestLegacyZKCrossVerification pins both directions against the historical -// formula. The fixture is a PRIOR-style proof built from fixed test-only x/a; -// the generated half calls the R1 public legacy API and verifies it with an -// independent copy of PRIOR's verifier formula. Running the package with -// -count=20 repeats both directions rather than using two homogeneous R1 -// parties as the legacy oracle. +// formula. The fixture is a proof in the shape of the historical 2e712689 +// implementation (see testdata/legacy_transcript/README.md), built from fixed +// test-only x/a; the generated half calls this fork's public legacy API and +// verifies it with an independent copy of the historical verifier formula. +// Running the package with -count=20 repeats both directions rather than +// using two parties of this fork as the legacy oracle. func TestLegacyZKCrossVerification(t *testing.T) { fixture := loadLegacyTranscriptVectors(t) ec := tss.EC() @@ -163,7 +164,7 @@ func TestLegacyZKCrossVerification(t *testing.T) { t.Fatalf("historical ZK challenge = %x, want %s", challenge, fixture.ZK.LegacyChallenge) } if !proof.Verify(X) { - t.Fatal("PRIOR-generated ZK fixture did not verify with the R1 legacy API") + t.Fatal("historical ZK fixture did not verify with this fork's legacy API") } for i := 0; i < 20; i++ { @@ -172,7 +173,7 @@ func TestLegacyZKCrossVerification(t *testing.T) { t.Fatal(err) } if !historicalVerifyZK(generated, X) { - t.Fatalf("R1 legacy ZK proof %d did not verify with the historical formula", i) + t.Fatalf("legacy ZK proof %d from this fork did not verify with the historical formula", i) } } } @@ -192,8 +193,8 @@ func historicalVerifyZK(proof *schnorr.ZKProof, X *crypto.ECPoint) bool { } // TestLegacyZKVCrossVerification is the ZKV counterpart of the ZK oracle -// above, including a PRIOR fixture and R1-generated proofs checked by the -// historical verifier formula. +// above, including a historical 2e712689 fixture and proofs generated by this +// fork, checked by the historical verifier formula. func TestLegacyZKVCrossVerification(t *testing.T) { fixture := loadLegacyTranscriptVectors(t) ec := tss.EC() @@ -219,7 +220,7 @@ func TestLegacyZKVCrossVerification(t *testing.T) { t.Fatalf("historical ZKV challenge = %x, want %s", challenge, fixture.ZKV.LegacyChallenge) } if !proof.Verify(V, R) { - t.Fatal("PRIOR-generated ZKV fixture did not verify with the R1 legacy API") + t.Fatal("historical ZKV fixture did not verify with this fork's legacy API") } for i := 0; i < 20; i++ { @@ -228,7 +229,7 @@ func TestLegacyZKVCrossVerification(t *testing.T) { t.Fatal(err) } if !historicalVerifyZKV(generated, V, R) { - t.Fatalf("R1 legacy ZKV proof %d did not verify with the historical formula", i) + t.Fatalf("legacy ZKV proof %d from this fork did not verify with the historical formula", i) } } } @@ -401,8 +402,9 @@ func TestSecurityV2SchnorrVectorsFailClosed(t *testing.T) { } // TestLegacySchnorrWireVectors pins the exact protobuf payload carrying the -// fixed PRIOR-compatible proofs through signing rounds 4 and 6. This catches a -// proof that cross-verifies mathematically but drifts on serialization. +// fixed proofs compatible with the historical 2e712689 implementation +// through signing rounds 4 and 6. This catches a proof that cross-verifies +// mathematically but drifts on serialization. func TestLegacySchnorrWireVectors(t *testing.T) { fixture := loadLegacyTranscriptVectors(t) party := tss.NewPartyID("r01-vector", "r01-vector", big.NewInt(42)) diff --git a/crypto/utils.go b/crypto/utils.go index 54eddbde1..c4d897dae 100644 --- a/crypto/utils.go +++ b/crypto/utils.go @@ -23,5 +23,8 @@ func GenerateNTildei(safePrimes [2]*big.Int) (NTildei, h1i, h2i *big.Int, err er NTildei = new(big.Int).Mul(safePrimes[0], safePrimes[1]) h1 := common.GetRandomGeneratorOfTheQuadraticResidue(NTildei) h2 := common.GetRandomGeneratorOfTheQuadraticResidue(NTildei) + if h1 == nil || h2 == nil { + return nil, nil, nil, fmt.Errorf("GenerateNTildei: could not sample generators (modulus too wide?)") + } return NTildei, h1, h2, nil } diff --git a/ecdsa/keygen/local_party.go b/ecdsa/keygen/local_party.go index 61bddb74b..e6f8a2c8d 100644 --- a/ecdsa/keygen/local_party.go +++ b/ecdsa/keygen/local_party.go @@ -58,7 +58,18 @@ type ( } ) -// Exported, used in `tss` client +// NewLocalParty returns a keygen party for the given parameters. It is the +// entry point used by `tss` clients. +// +// Panics: +// - if params is nil; +// - if no protocol mode was selected on params; +// - if the protocol mode is legacy and a session nonce was set; +// - if more than one optionalPreParams value is passed; +// - if the optionalPreParams value fails ValidateWithProof. +// +// The panic happens synchronously at the call site, so a party with an +// unclear transcript selection or invalid pre-parameters is never built. func NewLocalParty( params *tss.Parameters, out chan<- tss.Message, diff --git a/ecdsa/keygen/prepare.go b/ecdsa/keygen/prepare.go index b011f0af2..23c081ca8 100644 --- a/ecdsa/keygen/prepare.go +++ b/ecdsa/keygen/prepare.go @@ -123,10 +123,9 @@ consumer: modNTildeI := common.ModInt(NTildei) p, q := sgps[0].Prime(), sgps[1].Prime() - modPQ := common.ModInt(new(big.Int).Mul(p, q)) f1 := common.GetRandomPositiveRelativelyPrimeInt(NTildei) alpha := common.GetRandomPositiveRelativelyPrimeInt(NTildei) - beta := modPQ.ModInverse(alpha) + beta := ringPedersenBeta(alpha, p, q) h1i := modNTildeI.Mul(f1, f1) var h2i *big.Int if common.IsConstantTimeEnabled() { @@ -150,3 +149,16 @@ consumer: } return preParams, nil } + +// ringPedersenBeta returns beta = alpha^-1 mod (p*q). alpha and beta are the +// secret ring-Pedersen trapdoor pair, so the inverse runs in constant time +// when CT ops are enabled (the modulus p*q is odd and phi = (p-1)*(q-1) is +// its group exponent). +func ringPedersenBeta(alpha, p, q *big.Int) *big.Int { + pq := new(big.Int).Mul(p, q) + if common.IsConstantTimeEnabled() { + phiPQ := new(big.Int).Mul(new(big.Int).Sub(p, big.NewInt(1)), new(big.Int).Sub(q, big.NewInt(1))) + return common.NewCTModIntWithPhi(pq, phiPQ).ModInverseCT(alpha) + } + return common.ModInt(pq).ModInverse(alpha) +} diff --git a/ecdsa/keygen/prepare_constant_time_test.go b/ecdsa/keygen/prepare_constant_time_test.go index e91f96b3c..3afad3a25 100644 --- a/ecdsa/keygen/prepare_constant_time_test.go +++ b/ecdsa/keygen/prepare_constant_time_test.go @@ -7,6 +7,8 @@ package keygen import ( + "fmt" + "math/big" "testing" "github.com/stretchr/testify/assert" @@ -44,3 +46,60 @@ func TestPrepareTrapdoorCTEquivalence(t *testing.T) { assert.Zero(t, want.Cmp(got), "CT trapdoor exponentiation must match math/big") assert.Zero(t, pp.H2i.Cmp(got), "recomputed h2i must equal the stored trapdoor element") } + +// TestRingPedersenBeta checks the beta = alpha^-1 mod (p*q) helper that +// GeneratePreParams uses, in both timing modes, against math/big ModInverse. +// The fixture row also checks the stored Beta and the ring-Pedersen +// identity h1 = h2^beta mod NTildei. +func TestRingPedersenBeta(t *testing.T) { + fixtures, _, err := LoadKeygenTestFixtures(1) + if err != nil { + t.Skip("keygen test fixtures are required (avoids safe-prime generation)") + } + pp := fixtures[0].LocalPreParams + + // 11 and 23 are Germain primes (23 = 2*11+1, 47 = 2*23+1), so the + // small rows use the same shape of modulus as the fixture. + small := struct{ p, q *big.Int }{big.NewInt(11), big.NewInt(23)} + cases := []struct { + name string + alpha, p, q *big.Int + checkFixture bool + }{ + {"fixture", pp.Alpha, pp.P, pp.Q, true}, + {"alpha = 1", big.NewInt(1), small.p, small.q, false}, + {"alpha = 2", big.NewInt(2), small.p, small.q, false}, + {"alpha = p*q - 1", big.NewInt(11*23 - 1), small.p, small.q, false}, + } + + previousMode := common.IsConstantTimeEnabled() + t.Cleanup(func() { + if previousMode { + common.EnableConstantTimeOps() + } else { + common.DisableConstantTimeOps() + } + }) + for _, ct := range []bool{false, true} { + if ct { + common.EnableConstantTimeOps() + } else { + common.DisableConstantTimeOps() + } + for _, tc := range cases { + t.Run(fmt.Sprintf("CT=%t/%s", ct, tc.name), func(t *testing.T) { + want := new(big.Int).ModInverse(tc.alpha, new(big.Int).Mul(tc.p, tc.q)) + got := ringPedersenBeta(tc.alpha, tc.p, tc.q) + assert.Zero(t, want.Cmp(got), "beta must match math/big ModInverse") + if !tc.checkFixture { + return + } + assert.Zero(t, pp.Beta.Cmp(got), "beta must equal the stored fixture Beta") + // h1 is a square mod NTildei, so ord(h1) divides p*q and + // h2^beta = h1^(alpha*beta) = h1. + h1 := common.ModInt(pp.NTildei).Exp(pp.H2i, got) + assert.Zero(t, pp.H1i.Cmp(h1), "h1 must equal h2^beta mod NTildei") + }) + } + } +} diff --git a/ecdsa/keygen/protocol_mode_test.go b/ecdsa/keygen/protocol_mode_test.go new file mode 100644 index 000000000..93796b4fc --- /dev/null +++ b/ecdsa/keygen/protocol_mode_test.go @@ -0,0 +1,238 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package keygen + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/tss" +) + +// ----- Constructor protocol-mode enforcement ----- + +// TestNewLocalPartyRequiresProtocolMode pins the constructor-level +// enforcement of the explicit protocol-mode rule: building a keygen party +// without a selected mode, or with a legacy mode plus a session nonce, +// panics synchronously at the call site instead of silently picking a +// transcript. +func TestNewLocalPartyRequiresProtocolMode(t *testing.T) { + pIDs := tss.GenerateTestPartyIDs(2) + p2pCtx := tss.NewPeerContext(pIDs) + + modeless := tss.NewParameters(tss.S256(), p2pCtx, pIDs[0], 2, 1) + // No protocol mode selected: the constructor must fail fast. + assert.PanicsWithValue(t, "tss: protocol mode must be selected before local party construction", + func() { + _ = NewLocalParty(modeless, nil, nil) + }) + + legacyWithNonce := tss.NewParameters(tss.S256(), p2pCtx, pIDs[0], 2, 1) + legacyWithNonce.SetProtocolMode(tss.ProtocolModeLegacy) + legacyWithNonce.SetSessionNonce(big.NewInt(1)) + assert.PanicsWithValue(t, "tss: legacy protocol mode must not set a session nonce", + func() { + _ = NewLocalParty(legacyWithNonce, nil, nil) + }) + + // A security-v2 party with a nonce must construct without panicking, so + // the two panics above are not vacuous. + securityV2 := tss.NewParameters(tss.S256(), p2pCtx, pIDs[0], 2, 1) + securityV2.SetProtocolMode(tss.ProtocolModeSecurityV2) + securityV2.SetSessionNonce(big.NewInt(1)) + require.NotPanics(t, func() { + _ = NewLocalParty(securityV2, nil, nil) + }) +} + +// TestNewLocalPartyFreezesProtocolMode pins that the constructor freezes the +// transcript configuration: after construction the mode, the session nonce, +// and the legacy historical-Bob opt-in are all immutable on the party's own +// Parameters. +func TestNewLocalPartyFreezesProtocolMode(t *testing.T) { + pIDs := tss.GenerateTestPartyIDs(2) + p2pCtx := tss.NewPeerContext(pIDs) + + params := tss.NewParameters(tss.S256(), p2pCtx, pIDs[0], 2, 1) + params.SetProtocolMode(tss.ProtocolModeSecurityV2) + params.SetSessionNonce(big.NewInt(1)) + _ = NewLocalParty(params, nil, nil) + + assert.PanicsWithValue(t, "tss: protocol mode is immutable after local party construction", + func() { + params.SetProtocolMode(tss.ProtocolModeLegacy) + }) + assert.PanicsWithValue(t, "tss: session nonce is immutable after local party construction", + func() { + params.SetSessionNonce(big.NewInt(2)) + }) + assert.PanicsWithValue(t, "tss: legacy historical Bob compatibility is immutable after local party construction", + func() { + params.SetLegacyHistoricalBobCompatibility(true) + }) +} + +// ----- Legacy / security-v2 proof transcript selection ----- + +// keygenBaseForMode builds a bare base round in the given mode with the +// session state round1.Start would establish: security-v2 derives +// round.temp.ssid from the nonce, legacy leaves it nil. +func keygenBaseForMode(t *testing.T, mode tss.ProtocolMode, nonce *big.Int, pIDs tss.SortedPartyIDs) *base { + t.Helper() + + params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], len(pIDs), 1) + params.SetProtocolMode(mode) + temp := &localTempData{} + if nonce != nil { + params.SetSessionNonce(nonce) + temp.ssidNonce = new(big.Int).Set(nonce) + } + round := &base{ + Parameters: params, + temp: temp, + number: 1, + } + if mode == tss.ProtocolModeSecurityV2 { + round.temp.ssid = round.getSSID() + } + return round +} + +// TestKeygenProofTranscriptSelection pins the legacy keygen transcript +// selection: legacy rounds pass no proof session or per-party context so the +// proof primitives reproduce their historical untagged challenges, while +// security-v2 rounds use the ssid and the ssid+index contexts. +func TestKeygenProofTranscriptSelection(t *testing.T) { + pIDs := tss.GenerateTestPartyIDs(3) + + legacy := keygenBaseForMode(t, tss.ProtocolModeLegacy, nil, pIDs) + require.Nil(t, legacy.proofSession(), "legacy keygen must pass no proof session") + require.Nil(t, legacy.proofContext(1), "legacy keygen must pass no per-party proof context") + require.Nil(t, legacy.proofContext(2), "legacy keygen must pass no per-party proof context") + + v2 := keygenBaseForMode(t, tss.ProtocolModeSecurityV2, big.NewInt(42), pIDs) + require.Len(t, v2.temp.ssid, 32, "security-v2 ssid must be the full 32-byte digest") + require.Equal(t, [][]byte{v2.temp.ssid}, v2.proofSession(), + "security-v2 proof session must be the ssid alone") + + // The per-party context must be the ssid with the producer index + // appended and must differ per index. + ctx1 := v2.proofContext(1) + ctx2 := v2.proofContext(2) + require.Len(t, ctx1, 1) + require.Len(t, ctx2, 1) + require.Equal(t, common.AppendUint64ToBytesSlice(v2.temp.ssid, 1), ctx1[0]) + require.Equal(t, common.AppendUint64ToBytesSlice(v2.temp.ssid, 2), ctx2[0]) + assert.NotEqual(t, ctx1, ctx2, "contexts must differ per producing party index") + + // An independently constructed round over identical inputs must derive + // the same ssid and per-party contexts, so a derivation change in + // getSSID or proofContext is caught here rather than only in the E2E. + recomputed := keygenBaseForMode(t, tss.ProtocolModeSecurityV2, big.NewInt(42), pIDs) + require.Equal(t, v2.temp.ssid, recomputed.temp.ssid, + "ssid derivation must be deterministic for identical inputs") + require.Equal(t, v2.proofContext(1), recomputed.proofContext(1), + "per-party context must match the getSSID derivation") +} + +// ----- Mixed-mode keygen ceremony ----- + +// TestMixedModeKeygenFailsClosed pins that a ceremony mixing one legacy +// party with security-v2 peers fails closed on both sides: each side's +// round-2 proof verification attributes the opposing mode as the culprit, +// and no party ever produces save data. The legacy party emits historical +// untagged proofs; the security-v2 party verifies them under its +// ssid-tagged transcript (and vice versa), so every cross-verification is a +// guaranteed mismatch. The ceremony stops at the first fatal round-2 error, +// so no round-2 message is ever exchanged and this test does not cover +// round-2 message handling in a mixed ceremony. +func TestMixedModeKeygenFailsClosed(t *testing.T) { + setUp("info") + + fixtures, pIDs, err := LoadKeygenTestFixtures(2) + if err != nil { + t.Skip("keygen test fixtures are required (avoids safe-prime generation)") + } + require.Equal(t, 2, len(fixtures)) + + p2pCtx := tss.NewPeerContext(pIDs) + + // Party 0 is security-v2 with the per-ceremony nonce; party 1 is legacy + // and deliberately gets no nonce. + v2Params := tss.NewParameters(tss.S256(), p2pCtx, pIDs[0], 2, 1) + v2Params.SetProtocolMode(tss.ProtocolModeSecurityV2) + v2Params.SetSessionNonce(big.NewInt(7)) + legacyParams := tss.NewParameters(tss.S256(), p2pCtx, pIDs[1], 2, 1) + legacyParams.SetProtocolMode(tss.ProtocolModeLegacy) + + // Each party emits exactly one round-1 broadcast; the buffers make both + // Starts non-blocking. + outV2 := make(chan tss.Message, 2) + outLegacy := make(chan tss.Message, 2) + endCh := make(chan LocalPartySaveData, 2) + + v2Party := NewLocalParty(v2Params, outV2, endCh, fixtures[0].LocalPreParams).(*LocalParty) + legacyParty := NewLocalParty(legacyParams, outLegacy, endCh, fixtures[1].LocalPreParams).(*LocalParty) + + // Round 1: both parties start successfully and emit their broadcasts. + require.Nil(t, v2Party.Start(), "security-v2 party round-1 Start must not fail") + require.Nil(t, legacyParty.Start(), "legacy party round-1 Start must not fail") + msgV2 := <-outV2 + msgLegacy := <-outLegacy + + // Deliver the round-1 messages; each Update advances into round 2, where + // the cross-verification of the mismatched transcripts fails. + _, updV2Err := v2Party.Update(parseMessage(t, msgLegacy)) + _, updLegacyErr := legacyParty.Update(parseMessage(t, msgV2)) + + // The security-v2 party aborts in round 2 and attributes the legacy + // party as the sole culprit. + require.NotNil(t, updV2Err, "security-v2 party must fail the mixed ceremony") + require.Equal(t, 2, updV2Err.Round(), "mixed-mode abort must occur in round 2") + require.Len(t, updV2Err.Culprits(), 1, "security-v2 culprit set must contain exactly one party") + assert.Equal(t, legacyParty.PartyID().Index, updV2Err.Culprits()[0].Index, + "security-v2 culprit must be the legacy party") + + // The legacy party aborts in round 2 and attributes the security-v2 + // party as the sole culprit. + require.NotNil(t, updLegacyErr, "legacy party must fail the mixed ceremony") + require.Equal(t, 2, updLegacyErr.Round(), "mixed-mode abort must occur in round 2") + require.Len(t, updLegacyErr.Culprits(), 1, "legacy culprit set must contain exactly one party") + assert.Equal(t, v2Party.PartyID().Index, updLegacyErr.Culprits()[0].Index, + "legacy culprit must be the security-v2 party") + + // Round-2 starts fail before emitting any round-2 messages, so the out + // channels hold only the round-1 broadcasts drained above. + require.Empty(t, outV2, "security-v2 party emitted a round-2 message before aborting") + require.Empty(t, outLegacy, "legacy party emitted a round-2 message before aborting") + + // No party may ever produce save data. + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + select { + case save := <-endCh: + t.Fatalf("mixed-mode ceremony produced save data: %+v", save) + case <-ctx.Done(): + } +} + +// parseMessage parses a wire message for delivery to a receiver, mirroring +// test.SharedPartyUpdater without the error-channel side effect. +func parseMessage(t *testing.T, msg tss.Message) tss.ParsedMessage { + t.Helper() + bz, _, err := msg.WireBytes() + require.NoError(t, err, "wire encoding must succeed") + parsed, err := tss.ParseWireMessage(bz, msg.GetFrom(), msg.IsBroadcast()) + require.NoError(t, err, "wire parsing must succeed") + return parsed +} diff --git a/ecdsa/keygen/rounds.go b/ecdsa/keygen/rounds.go index 9474a2103..6bc39e91d 100644 --- a/ecdsa/keygen/rounds.go +++ b/ecdsa/keygen/rounds.go @@ -122,7 +122,9 @@ func (round *base) proofContext(index int) [][]byte { return nil } -// getSSID derives the session-binding identifier for keygen. +// getSSID derives the security-v2 session-binding identifier for keygen. +// Legacy mode does not call this helper and retains the historical +// untagged proof transcript. // // Callers must invoke this exactly once, in round 1, and store the result in // round.temp.ssid for the rest of the protocol — round.number is hashed in diff --git a/ecdsa/signing/constant_time_e2e_test.go b/ecdsa/signing/constant_time_e2e_test.go deleted file mode 100644 index c5cf5d3e2..000000000 --- a/ecdsa/signing/constant_time_e2e_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright © 2019 Binance -// -// This file is part of Binance. The full Binance copyright notice, including -// terms governing use, modification, and redistribution, is contained in the -// file LICENSE at the root of the source code distribution tree. - -package signing - -import ( - "crypto/ecdsa" - "math/big" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/bnb-chain/tss-lib/common" - "github.com/bnb-chain/tss-lib/ecdsa/keygen" - "github.com/bnb-chain/tss-lib/test" - "github.com/bnb-chain/tss-lib/tss" -) - -// TestE2EConcurrentConstantTime runs the full threshold-signing protocol with -// constant-time cryptographic operations enabled. It exercises the CT-wired -// Paillier Decrypt path (used in MtA), the Schnorr ZK proof response path -// (called from round 4), and the round 3/4/5 ECDSA signing multiplications -// (thelta/sigma/thetaInverse/si) end-to-end across all parties, asserting the -// produced ECDSA signature verifies. -func TestE2EConcurrentConstantTime(t *testing.T) { - setUp("info") - - previousMode := common.IsConstantTimeEnabled() - t.Cleanup(func() { - if previousMode { - common.EnableConstantTimeOps() - } else { - common.DisableConstantTimeOps() - } - }) - common.EnableConstantTimeOps() - assert.True(t, common.IsConstantTimeEnabled(), "constant-time ops must be enabled for this test") - - threshold := testThreshold - - keys, signPIDs, err := keygen.LoadKeygenTestFixturesRandomSet(testThreshold+1, testParticipants) - assert.NoError(t, err, "should load keygen fixtures") - assert.Equal(t, testThreshold+1, len(keys)) - assert.Equal(t, testThreshold+1, len(signPIDs)) - - p2pCtx := tss.NewPeerContext(signPIDs) - parties := make([]*LocalParty, 0, len(signPIDs)) - - errCh := make(chan *tss.Error, len(signPIDs)) - outCh := make(chan tss.Message, len(signPIDs)) - endCh := make(chan *common.SignatureData, len(signPIDs)) - - updater := test.SharedPartyUpdater - - for i := 0; i < len(signPIDs); i++ { - params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[i], len(signPIDs), threshold) - params.SetProtocolMode(tss.ProtocolModeSecurityV2) - params.SetSessionNonce(big.NewInt(1)) - - P := NewLocalParty(big.NewInt(42), params, keys[i], outCh, endCh, 32).(*LocalParty) - parties = append(parties, P) - go func(P *LocalParty) { - if err := P.Start(); err != nil { - errCh <- err - } - }(P) - } - - var ended int32 -signing: - for { - select { - case err := <-errCh: - common.Logger.Errorf("Error: %s", err) - assert.FailNow(t, err.Error()) - break signing - - case msg := <-outCh: - dest := msg.GetTo() - if dest == nil { - for _, P := range parties { - if P.PartyID().Index == msg.GetFrom().Index { - continue - } - go updater(P, msg, errCh) - } - } else { - if dest[0].Index == msg.GetFrom().Index { - t.Fatalf("party %d tried to send a message to itself (%d)", dest[0].Index, msg.GetFrom().Index) - } - go updater(parties[dest[0].Index], msg, errCh) - } - - case <-endCh: - atomic.AddInt32(&ended, 1) - if atomic.LoadInt32(&ended) == int32(len(signPIDs)) { - R := parties[0].temp.bigR - - modN := common.ModInt(tss.S256().Params().N) - sumS := big.NewInt(0) - for _, p := range parties { - sumS = modN.Add(sumS, p.temp.si) - } - - pkX, pkY := keys[0].ECDSAPub.X(), keys[0].ECDSAPub.Y() - pk := ecdsa.PublicKey{ - Curve: tss.EC(), - X: pkX, - Y: pkY, - } - ok := ecdsa.Verify(&pk, big.NewInt(42).Bytes(), R.X(), sumS) - assert.True(t, ok, "ecdsa verify must pass with constant-time ops enabled") - t.Log("ECDSA constant-time signing test done.") - - break signing - } - } - } -} diff --git a/ecdsa/signing/local_party.go b/ecdsa/signing/local_party.go index e0a43c23e..710c13a34 100644 --- a/ecdsa/signing/local_party.go +++ b/ecdsa/signing/local_party.go @@ -98,6 +98,10 @@ type ( } ) +// NewLocalParty returns a local signing party for the GG20 protocol. It is +// NewLocalPartyWithKDD without a key derivation delta. +// +// Panics under the same conditions as NewLocalPartyWithKDD. func NewLocalParty( msg *big.Int, params *tss.Parameters, @@ -110,15 +114,26 @@ func NewLocalParty( } // NewLocalPartyWithKDD returns a party with key derivation delta for HD support. -// The message is copied so later caller mutations cannot change this party's -// signing context. +// The message and the keyDerivationDelta are copied so later caller mutations +// cannot change this party's signing context. // // fullBytesLen fixes the byte width used to encode the message for the final // ECDSA verification/output path (preserving leading zero bytes). Every signer -// in a ceremony must pass the same value. It must be positive, no larger than -// the curve order byte length, and at least ceil(msg.BitLen()/8); violating -// these constraints is a caller bug and the constructor panics at the call site -// rather than later inside a protocol goroutine. +// in a ceremony must pass the same value. +// +// Panics: +// - if fullBytesLen is not passed exactly once; +// - if fullBytesLen is not positive; +// - if fullBytesLen is smaller than ceil(msg.BitLen()/8); +// - if params is nil or has no curve order; +// - if fullBytesLen is larger than the curve order byte length; +// - if no protocol mode was selected on params; +// - if the protocol mode is legacy and a session nonce was set; +// - if key has a nil party key, or a signer in params has no PartyID +// content or is not in key (see keygen.BuildLocalSaveDataSubset). +// +// The panic happens synchronously at the call site rather than later inside +// a protocol goroutine. func NewLocalPartyWithKDD( msg *big.Int, params *tss.Parameters, @@ -153,7 +168,11 @@ func NewLocalPartyWithKDD( p.temp.signRound8Messages = make([]tss.ParsedMessage, partyCount) p.temp.signRound9Messages = make([]tss.ParsedMessage, partyCount) // temp data init - p.temp.keyDerivationDelta = keyDerivationDelta + // Copy the delta so caller mutation of the shared *big.Int cannot change + // this party's signing context between construction and Start. + if keyDerivationDelta != nil { + p.temp.keyDerivationDelta = new(big.Int).Set(keyDerivationDelta) + } if msg != nil { p.temp.m = new(big.Int).Set(msg) } diff --git a/ecdsa/signing/local_party_test.go b/ecdsa/signing/local_party_test.go index 4ac5c6aa1..4d417a9e4 100644 --- a/ecdsa/signing/local_party_test.go +++ b/ecdsa/signing/local_party_test.go @@ -16,6 +16,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/ipfs/go-log" @@ -88,7 +89,16 @@ func newStoreMessageTestParty(t *testing.T) (*LocalParty, tss.SortedPartyIDs) { return lp, pIDs } +// TestE2EConcurrent runs a full signing ceremony in each protocol mode with +// constant-time operations on and off. Constant-time operations are a +// hardening, not a correctness requirement, so every combination must +// complete and deliver a valid signature. func TestE2EConcurrent(t *testing.T) { + msgData, err := hex.DecodeString("00f163ee51bcaeff9cdff5e0e3c1a646abd19885fffbab0b3b4236e0cf95c9f5") + if err != nil { + panic(err) + } + msgInt := new(big.Int).SetBytes(msgData) for _, testCase := range []struct { name string mode tss.ProtocolMode @@ -96,24 +106,48 @@ func TestE2EConcurrent(t *testing.T) { {"legacy", tss.ProtocolModeLegacy}, {"security-v2", tss.ProtocolModeSecurityV2}, } { - t.Run(testCase.name, func(t *testing.T) { - testE2EConcurrent(t, testCase.mode) - }) + for _, ct := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/CT=%t", testCase.name, ct), func(t *testing.T) { + setSigningCTMode(t, ct) + testE2EConcurrent(t, testCase.mode, msgInt, len(msgData)) + }) + } + } +} + +// setSigningCTMode sets the process-wide constant-time mode for one test and +// restores the previous mode when the test ends. +func setSigningCTMode(t *testing.T, enabled bool) { + t.Helper() + previous := common.IsConstantTimeEnabled() + t.Cleanup(func() { + if previous { + common.EnableConstantTimeOps() + } else { + common.DisableConstantTimeOps() + } + }) + if enabled { + common.EnableConstantTimeOps() + } else { + common.DisableConstantTimeOps() } + assert.Equal(t, enabled, common.IsConstantTimeEnabled()) } -func testE2EConcurrent(t *testing.T, mode tss.ProtocolMode) { +// testE2EConcurrent runs a signing ceremony for the given message and encoding width. +// The msg is copied into each party so caller mutation cannot affect the ceremony. +// On completion it verifies the delivered signature: R/S encoding, recovery byte, +// message width, low-S, ECDSA verification, pubkey recovery, and deep-copy isolation. +func testE2EConcurrent(t *testing.T, mode tss.ProtocolMode, msg *big.Int, fullBytesLen int) { setUp("info") threshold := testThreshold - // PHASE: load keygen fixtures keys, signPIDs, err := keygen.LoadKeygenTestFixturesRandomSet(testThreshold+1, testParticipants) assert.NoError(t, err, "should load keygen fixtures") assert.Equal(t, testThreshold+1, len(keys)) assert.Equal(t, testThreshold+1, len(signPIDs)) - // PHASE: signing - // use a shuffled selection of the list of parties for this test p2pCtx := tss.NewPeerContext(signPIDs) parties := make([]*LocalParty, 0, len(signPIDs)) @@ -123,20 +157,15 @@ func testE2EConcurrent(t *testing.T, mode tss.ProtocolMode) { updater := test.SharedPartyUpdater - msgData, err := hex.DecodeString("00f163ee51bcaeff9cdff5e0e3c1a646abd19885fffbab0b3b4236e0cf95c9f5") - assert.NoError(t, err) - msgInt := new(big.Int).SetBytes(msgData) - - // init the parties ceremonyNonce := big.NewInt(1) - for i := 0; i < len(signPIDs); i++ { + for i := range len(signPIDs) { params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[i], len(signPIDs), threshold) params.SetProtocolMode(mode) if mode == tss.ProtocolModeSecurityV2 { params.SetSessionNonce(ceremonyNonce) } - P := NewLocalParty(msgInt, params, keys[i], outCh, endCh, len(msgData)).(*LocalParty) + P := NewLocalParty(msg, params, keys[i], outCh, endCh, fullBytesLen).(*LocalParty) parties = append(parties, P) go func(P *LocalParty) { if err := P.Start(); err != nil { @@ -145,11 +174,16 @@ func testE2EConcurrent(t *testing.T, mode tss.ProtocolMode) { }(P) } + delivered := make([]*common.SignatureData, 0, len(signPIDs)) var ended int32 + deadline := time.After(10 * time.Minute) signing: for { fmt.Printf("ACTIVE GOROUTINES: %d\n", runtime.NumGoroutine()) select { + case <-deadline: + t.Fatalf("signing ceremony did not finish within the deadline; %d of %d parties ended", atomic.LoadInt32(&ended), len(signPIDs)) + case err := <-errCh: common.Logger.Errorf("Error: %s", err) assert.FailNow(t, err.Error()) @@ -171,41 +205,17 @@ signing: go updater(parties[dest[0].Index], msg, errCh) } - case <-endCh: + case sd := <-endCh: + delivered = append(delivered, sd) atomic.AddInt32(&ended, 1) if atomic.LoadInt32(&ended) == int32(len(signPIDs)) { t.Logf("Done. Received signature data from %d participants", ended) - R := parties[0].temp.bigR - r := parties[0].temp.rx - fmt.Printf("sign result: R(%s, %s), r=%s\n", R.X().String(), R.Y().String(), r.String()) - - modN := common.ModInt(tss.S256().Params().N) - - // BEGIN check s correctness - sumS := big.NewInt(0) - for _, p := range parties { - sumS = modN.Add(sumS, p.temp.si) - } - fmt.Printf("S: %s\n", sumS.String()) - // END check s correctness - - // BEGIN ECDSA verify - pkX, pkY := keys[0].ECDSAPub.X(), keys[0].ECDSAPub.Y() - pk := ecdsa.PublicKey{ - Curve: tss.EC(), - X: pkX, - Y: pkY, - } - ok := ecdsa.Verify(&pk, msgData, R.X(), sumS) - assert.True(t, ok, "ecdsa verify must pass") - assert.Equal(t, msgData, parties[0].data.M) - t.Log("ECDSA signing test done.") - // END ECDSA verify - break signing } } } + + verifyDeliveredSignatureData(t, delivered, parties, keys, msg, fullBytesLen) } func TestE2EWithHDKeyDerivation(t *testing.T) { diff --git a/ecdsa/signing/round_2.go b/ecdsa/signing/round_2.go index e32e6fc6a..985012fc1 100644 --- a/ecdsa/signing/round_2.go +++ b/ecdsa/signing/round_2.go @@ -8,6 +8,8 @@ package signing import ( "errors" + "fmt" + "math/big" "sync" errorspkg "github.com/pkg/errors" @@ -24,6 +26,22 @@ func (round *round2) Start() *tss.Error { round.started = true round.resetOK() + // gamma and w are this party's own MtA inputs. Check them once here, so a + // bad local value fails the round with no culprits instead of being + // blamed on every peer by the BobMid/BobMidWC calls below. + q := round.Parameters.EC().Params().N + for _, witness := range []struct { + name string + value *big.Int + }{ + {"gamma", round.temp.gamma}, + {"w", round.temp.w}, + } { + if witness.value == nil || witness.value.Sign() < 0 || witness.value.Cmp(q) >= 0 { + return round.WrapError(fmt.Errorf("local %s outside the curve-order domain", witness.name)) + } + } + i := round.PartyID().Index round.ok[i] = true diff --git a/ecdsa/signing/round_2_test.go b/ecdsa/signing/round_2_test.go new file mode 100644 index 000000000..6cb70f11f --- /dev/null +++ b/ecdsa/signing/round_2_test.go @@ -0,0 +1,56 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package signing + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/tss" +) + +// TestSigningRound2_LocalWitnessErrorHasNoCulprits pins that a bad local MtA +// input (gamma or w outside [0, q)) fails round 2 without blaming any peer. +// Without the local check, each per-peer BobMid call would fail on it and the +// round would name every peer as a culprit. +func TestSigningRound2_LocalWitnessErrorHasNoCulprits(t *testing.T) { + fixtures, pIDs, err := keygen.LoadKeygenTestFixtures(2) + require.NoError(t, err) + ec := tss.S256() + q := ec.Params().N + + for name, setBad := range map[string]func(*localTempData){ + "gamma=q": func(temp *localTempData) { temp.gamma = new(big.Int).Set(q) }, + "gamma=-1": func(temp *localTempData) { temp.gamma = big.NewInt(-1) }, + "w=q": func(temp *localTempData) { temp.w = new(big.Int).Set(q) }, + "w=nil": func(temp *localTempData) { temp.w = nil }, + } { + t.Run(name, func(t *testing.T) { + params := tss.NewParameters(ec, tss.NewPeerContext(pIDs), pIDs[0], len(pIDs), 1) + keys := fixtures[0] + temp := localTempData{} + temp.gamma = big.NewInt(13) + temp.w = big.NewInt(17) + setBad(&temp) + out := make(chan tss.Message, len(pIDs)) + data := common.SignatureData{} + rnd := &round2{&round1{ + &base{params, &keys, &data, &temp, out, nil, make([]bool, len(pIDs)), false, 2}, + }} + + tssErr := rnd.Start() + require.NotNil(t, tssErr, "a bad local witness must fail round 2") + assert.Empty(t, tssErr.Culprits(), "a local witness error must not blame peers") + assert.Empty(t, out, "no round 2 message may be sent on failure") + }) + } +} diff --git a/ecdsa/signing/round_5.go b/ecdsa/signing/round_5.go index 075907236..a344a832d 100644 --- a/ecdsa/signing/round_5.go +++ b/ecdsa/signing/round_5.go @@ -74,7 +74,12 @@ func (round *round5) Start() *tss.Error { ry := R.Y() var si *big.Int if common.IsConstantTimeEnabled() { - // SECURITY: constant-time multiplication for the secret operands k and sigma (m is the public message hash; rx = R.X() is the raw x-coordinate published as the signature r component in round 10, finalize.go — see the recovery-ID convention there). + // SECURITY: constant-time multiplication for the secret operands k and + // sigma. m is the public message hash, and rx = R.X() is derived + // from public values by this round (R is built from the round-4 + // decommitted Gamma_j points and the public delta), so reducing + // rx mod N leaks nothing secret — see reduceToPaddedBytes in + // common/constant_time.go. ctModN := common.GetCTModInt(N) si = modN.Add(ctModN.MulCT(round.temp.m, round.temp.k), ctModN.MulCT(rx, round.temp.sigma)) } else { diff --git a/ecdsa/signing/round_ct_wiring_test.go b/ecdsa/signing/round_ct_wiring_test.go deleted file mode 100644 index 7e09f5a7d..000000000 --- a/ecdsa/signing/round_ct_wiring_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright © 2019-2026 Binance -// -// This file is part of Binance. The full Binance copyright notice, including -// terms governing use, modification, and redistribution, is contained in the -// file LICENSE at the root of the source code distribution tree. - -package signing - -import ( - "math/big" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/bnb-chain/tss-lib/common" - "github.com/bnb-chain/tss-lib/ecdsa/keygen" - "github.com/bnb-chain/tss-lib/test" - "github.com/bnb-chain/tss-lib/tss" -) - -// TestRounds3to5CTWiring is the -short-safe integration gate for the round 3/4/5 -// constant-time if/else branches (branch selection + modulus N). It complements -// the primitive equivalence tests (constant_time_equiv_test.go) and the heavier -// concurrent TestE2EConcurrentConstantTime by asserting the CT-produced temp -// fields after round 5 has started on all parties. -func TestRounds3to5CTWiring(t *testing.T) { - setUp("info") - - previousMode := common.IsConstantTimeEnabled() - t.Cleanup(func() { - if previousMode { - common.EnableConstantTimeOps() - } else { - common.DisableConstantTimeOps() - } - }) - common.EnableConstantTimeOps() - assert.True(t, common.IsConstantTimeEnabled(), "constant-time ops must be enabled for this test") - - keys, signPIDs, err := keygen.LoadKeygenTestFixturesRandomSet(testThreshold+1, testParticipants) - assert.NoError(t, err, "should load keygen fixtures") - assert.Equal(t, testThreshold+1, len(keys)) - assert.Equal(t, testThreshold+1, len(signPIDs)) - - p2pCtx := tss.NewPeerContext(signPIDs) - parties := make([]*LocalParty, 0, len(signPIDs)) - - errCh := make(chan *tss.Error, len(signPIDs)) - outCh := make(chan tss.Message, len(signPIDs)) - endCh := make(chan *common.SignatureData, len(signPIDs)) - - updater := test.SharedPartyUpdater - - for i := 0; i < len(signPIDs); i++ { - params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[i], len(signPIDs), testThreshold) - // Security-v2 + nonce: the CT wiring under test is transcript-mode - // independent, but local-party construction requires an explicit mode. - params.SetProtocolMode(tss.ProtocolModeSecurityV2) - params.SetSessionNonce(big.NewInt(1)) - - P := NewLocalParty(big.NewInt(42), params, keys[i], outCh, endCh, 32).(*LocalParty) - parties = append(parties, P) - go func(P *LocalParty) { - if err := P.Start(); err != nil { - errCh <- err - } - }(P) - } - - // Track which parties have emitted a SignRound5Message (i.e. round 5 has started) - seenR5 := make(map[int]bool) - var ended int32 - -signing: - for { - select { - case err := <-errCh: - common.Logger.Errorf("Error: %s", err) - assert.FailNow(t, err.Error()) - break signing - - case msg := <-outCh: - // Detect round-5 entry by message type - if parsed, ok := msg.(tss.ParsedMessage); ok { - if _, ok := parsed.Content().(*SignRound5Message); ok { - seenR5[msg.GetFrom().Index] = true - if len(seenR5) == len(signPIDs) { - break signing - } - } - } - - dest := msg.GetTo() - if dest == nil { - for _, P := range parties { - if P.PartyID().Index == msg.GetFrom().Index { - continue - } - go updater(P, msg, errCh) - } - } else { - if dest[0].Index == msg.GetFrom().Index { - t.Fatalf("party %d tried to send a message to itself (%d)", dest[0].Index, msg.GetFrom().Index) - } - go updater(parties[dest[0].Index], msg, errCh) - } - - case <-endCh: - atomic.AddInt32(&ended, 1) - if atomic.LoadInt32(&ended) == int32(len(signPIDs)) { - break signing - } - } - } - - // Assert the CT-produced temp fields on parties[0] (any single party suffices) - P := parties[0] - - // Round 3: thelta = k*gamma mod N, sigma = k*w mod N (CT MulCT branch, round_3.go:108-112) - assert.NotNil(t, P.temp.theta, "round 3 CT branch: temp.theta must be populated") - assert.NotNil(t, P.temp.sigma, "round 3 CT branch: temp.sigma must be populated") - - // Round 4: thetaInverse = theta^-1 mod N (CT ModInverseCT branch, round_4.go:43-48) - assert.NotNil(t, P.temp.thetaInverse, "round 4 CT branch: temp.thetaInverse must be populated") - - // Round 5: si = m*k + rx*sigma mod N, rx = R.X() (CT MulCT branch, round_5.go:67-73) - assert.NotNil(t, P.temp.si, "round 5 CT branch: temp.si must be populated") - assert.NotNil(t, P.temp.rx, "round 5 CT branch: temp.rx must be populated") - - // thetaInverse must be in [1, N-1], proving the CT branch reduced mod N (not raw math/big) - N := tss.EC().Params().N - assert.True(t, P.temp.thetaInverse.Sign() > 0 && P.temp.thetaInverse.Cmp(N) < 0, - "thetaInverse must be in [1, N-1], proving CT ModInverseCT with modulus N executed") -} diff --git a/ecdsa/signing/signature_delivery_test.go b/ecdsa/signing/signature_delivery_test.go new file mode 100644 index 000000000..8c74b1051 --- /dev/null +++ b/ecdsa/signing/signature_delivery_test.go @@ -0,0 +1,442 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package signing + +import ( + "crypto/ecdsa" + "math/big" + "testing" + "time" + + btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/test" + "github.com/bnb-chain/tss-lib/tss" +) + +// assertR5Stored asserts that P's round-5 temp state (every field round 5 +// writes) is fully populated: the round stores it in program order before +// P emits its round-5 message. +func assertR5Stored(t *testing.T, P *LocalParty) { + t.Helper() + idx := P.PartyID().Index + assert.NotNil(t, P.temp.signRound5Messages[idx], "round 5 must store its own message before emitting") + assert.NotNil(t, P.temp.li, "round 5 must store li before emitting") + assert.NotNil(t, P.temp.bigAi, "round 5 must store bigAi before emitting") + assert.NotNil(t, P.temp.bigVi, "round 5 must store bigVi before emitting") + assert.NotNil(t, P.temp.roi, "round 5 must store roi before emitting") + assert.NotNil(t, P.temp.DPower, "round 5 must store the decommitment before emitting") + assert.NotNil(t, P.temp.si, "round 5 must store si before emitting") + assert.NotNil(t, P.temp.rx, "round 5 must store rx before emitting") + assert.NotNil(t, P.temp.ry, "round 5 must store ry before emitting") + assert.NotNil(t, P.temp.bigR, "round 5 must store bigR before emitting") +} + +// assertR7Stored asserts that P's round-7 temp state (every field round 7 +// writes) is fully populated before P emits its round-7 message. +func assertR7Stored(t *testing.T, P *LocalParty) { + t.Helper() + idx := P.PartyID().Index + assert.NotNil(t, P.temp.signRound7Messages[idx], "round 7 must store its own message before emitting") + assert.NotNil(t, P.temp.Ui, "round 7 must store Ui before emitting") + assert.NotNil(t, P.temp.Ti, "round 7 must store Ti before emitting") + assert.NotNil(t, P.temp.DTelda, "round 7 must store the decommitment before emitting") +} + +// signatureDataSnapshot copies the caller-visible fields of a SignatureData +// so a test can compare the delivered value with the party's internal state +// after mutating the delivered copy. +type signatureDataSnapshot struct { + signature, + signatureRecovery, + r, + s, + m []byte +} + +func snapshotSignatureData(sd *common.SignatureData) signatureDataSnapshot { + if sd == nil { + return signatureDataSnapshot{} + } + cp := func(b []byte) []byte { + out := make([]byte, len(b)) + copy(out, b) + return out + } + return signatureDataSnapshot{cp(sd.Signature), cp(sd.SignatureRecovery), cp(sd.R), cp(sd.S), cp(sd.M)} +} + +// verifyDeliveredSignatureData checks the *common.SignatureData values +// delivered on the parties' end channel: the R/S encoding, the combined +// signature, the recovery byte, the message width, low-S normalization, +// ECDSA verification, pubkey recovery from the compact form, and that the +// delivered value is a deep copy of the party's internal state. +func verifyDeliveredSignatureData(t *testing.T, delivered []*common.SignatureData, parties []*LocalParty, keys []keygen.LocalPartySaveData, msg *big.Int, fullBytesLen int) { + t.Helper() + require.Len(t, delivered, len(parties)) + + N := tss.S256().Params().N + halfN := new(big.Int).Rsh(N, 1) + pkX, pkY := keys[0].ECDSAPub.X(), keys[0].ECDSAPub.Y() + pk := ecdsa.PublicKey{Curve: tss.EC(), X: pkX, Y: pkY} + + // expectedM is the fixed-width big-endian (left-padded) encoding of msg + expectedM := make([]byte, fullBytesLen) + msg.FillBytes(expectedM) + + // Snapshot the parties' internal signature data before mutating any + // delivered value below. + internal := make([]signatureDataSnapshot, len(parties)) + for i, P := range parties { + internal[i] = snapshotSignatureData(P.data) + require.NotEmpty(t, internal[i].signature, "party %d must hold its signature data", i) + } + + for _, sd := range delivered { + // R and S must each be exactly fullBytesLen bytes of equal width + assert.Equal(t, fullBytesLen, len(sd.R), "R must be fullBytesLen bytes") + assert.Equal(t, fullBytesLen, len(sd.S), "S must be fullBytesLen bytes") + assert.Equal(t, len(sd.R), len(sd.S), "R and S must have equal width") + // M must match the left-padded fixed-width message encoding + assert.Equal(t, expectedM, sd.M, "M encoding must match") + // Signature must be R||S + combined := make([]byte, 0, 2*fullBytesLen) + combined = append(combined, sd.R...) + combined = append(combined, sd.S...) + assert.Equal(t, combined, sd.Signature, "Signature must be R||S") + // Recovery byte must be present and in [0,3] + require.Len(t, sd.SignatureRecovery, 1, "recovery byte must be present") + recid := int(sd.SignatureRecovery[0]) + assert.True(t, recid >= 0 && recid <= 3, "recovery byte must be in [0,3]") + // S must be low (canonical) + sInt := new(big.Int).SetBytes(sd.S) + assert.True(t, sInt.Cmp(halfN) <= 0, "S must be low (S <= N/2)") + // ECDSA verification + rInt := new(big.Int).SetBytes(sd.R) + assert.True(t, ecdsa.Verify(&pk, sd.M, rInt, sInt), "signature must verify") + // Pubkey recovery from the compact signature + compact := make([]byte, 0, 65) + compact = append(compact, byte(27+recid)) + compact = append(compact, sd.R...) + compact = append(compact, sd.S...) + recovered, _, recErr := btcecdsa.RecoverCompact(compact, sd.M) + require.NoError(t, recErr, "pubkey recovery must succeed") + assert.True(t, recovered.X().Cmp(pkX) == 0, "recovered X must match") + assert.True(t, recovered.Y().Cmp(pkY) == 0, "recovered Y must match") + } + + // The delivered values must be deep copies. Mutate every field of every + // delivered value first, then compare every party's internal data with + // its snapshot, so a delivered value that aliases any party's data is + // caught whatever its position in delivered. + for _, sd := range delivered { + for _, b := range [][]byte{sd.Signature, sd.SignatureRecovery, sd.R, sd.S, sd.M} { + if len(b) > 0 { + b[0] ^= 0xff + } + } + } + for i, P := range parties { + assert.Equal(t, internal[i], snapshotSignatureData(P.data), + "mutating the delivered signatures must not affect party %d's internal data", i) + } +} + +// TestRound5AndRound7StoreBeforeEmit pins the store-before-emit ordering +// of signing rounds 5 and 7: by the time a party emits its round-5 or +// round-7 message on its out channel, that party's temp state written by +// the round (its own message slot plus the round's scratch values) must +// already be fully populated. An emit-before-store regression would leave +// the sender's temp fields nil at the moment its message is received. +// +// The test gives each party an unbuffered out channel and drives rounds 1 +// through 7 itself. Receiving party P's round-5 or round-7 message on P's +// channel is the happens-before point of P's send completing, and P's +// round stores P's temp in program order before the send. The test asserts +// P's temp at that point, before routing P's message to any peer, then +// stops; a 2-party subset cannot complete the ceremony. +func TestRound5AndRound7StoreBeforeEmit(t *testing.T) { + keys, signPIDs, err := keygen.LoadKeygenTestFixtures(2) + require.NoError(t, err) + + p2pCtx := tss.NewPeerContext(signPIDs) + parties := make([]*LocalParty, 2) + outChs := make([]chan tss.Message, 2) + endChs := make([]chan *common.SignatureData, 2) + // Unbuffered out channels: a party's emit blocks until the test + // receives it, which is what makes the store-before-emit check + // race-free. + for i := range 2 { + outChs[i] = make(chan tss.Message) + endChs[i] = make(chan *common.SignatureData, 1) + params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[i], 2, 1) + params.SetProtocolMode(tss.ProtocolModeLegacy) + parties[i] = NewLocalParty(big.NewInt(1), params, keys[i], outChs[i], endChs[i], 32).(*LocalParty) + } + + errCh := make(chan *tss.Error, 4) + for i := range 2 { + go func(i int) { + if err := parties[i].Start(); err != nil { + errCh <- err + } + }(i) + } + + sawR5 := [2]bool{} + sawR7 := [2]bool{} + hook := func(sender int, m tss.Message) { + pm, ok := m.(tss.ParsedMessage) + if !ok { + return + } + switch pm.Content().(type) { + case *SignRound5Message: + if !sawR5[sender] { + assertR5Stored(t, parties[sender]) + sawR5[sender] = true + } + case *SignRound7Message: + if !sawR7[sender] { + assertR7Stored(t, parties[sender]) + sawR7[sender] = true + } + } + } + + // Drive the ceremony by routing each party's out-channel messages to + // its peers via fresh goroutines. Stop once both parties have emitted + // their round-5 and round-7 messages; a 2-party (threshold-1) subset + // cannot complete the ceremony and fails later, in round 9. + updater := test.SharedPartyUpdater + route := func(sender int, m tss.Message) { + dest := m.GetTo() + if dest == nil { + go updater(parties[1-sender], m, errCh) + } else { + go updater(parties[dest[0].Index], m, errCh) + } + } + // The parties keep running after the checks below finish. Keep draining + // their channels for a while so blocked sends can complete and no + // party or routing goroutine is left blocked forever. + t.Cleanup(func() { + go func() { + idle := time.NewTimer(30 * time.Second) + defer idle.Stop() + for { + select { + case <-outChs[0]: + case <-outChs[1]: + case <-errCh: + case <-idle.C: + return + } + idle.Reset(30 * time.Second) + } + }() + }) + + deadline := time.After(2 * time.Minute) + for { + if sawR5[0] && sawR5[1] && sawR7[0] && sawR7[1] { + break + } + select { + case err := <-errCh: + // A 2-party subset fails in round 9, which needs both round-7 + // messages; the loop stops as soon as both are seen, so any + // error here came too early. + t.Fatalf("party error before both round-5 and round-7 emissions were observed: %v", err) + case <-deadline: + t.Fatalf("did not observe round-5 and round-7 emissions within timeout") + case m := <-outChs[0]: + hook(0, m) + route(0, m) + case m := <-outChs[1]: + hook(1, m) + route(1, m) + } + } + require.True(t, sawR5[0] && sawR5[1], "both parties must have emitted a round-5 message") + require.True(t, sawR7[0] && sawR7[1], "both parties must have emitted a round-7 message") +} + +// TestSigningSessionFailsClosedMixedMode verifies that a ceremony mixing +// one legacy party with security-v2 peers fails closed with exact culprit +// attribution and no signature. Each security-v2 party verifies its +// peers' range proofs against its session-tagged transcript, so a legacy +// party's untagged proof is the exact rejection culprit from every +// security-v2 party's viewpoint. From the legacy party's viewpoint, every +// security-v2 peer's tagged proof fails under the untagged legacy +// transcript, so its rejection culprit set is exactly every other party. +// Both aborts occur in round 2, the first proof-verification stage. +func TestSigningSessionFailsClosedMixedMode(t *testing.T) { + const legacyIdx = 0 + n := 3 + threshold := 2 + + keys, signPIDs, err := keygen.LoadKeygenTestFixtures(n) + require.NoError(t, err) + + p2pCtx := tss.NewPeerContext(signPIDs) + parties := make([]*LocalParty, n) + outChs := make([]chan tss.Message, n) + endChs := make([]chan *common.SignatureData, n) + + ceremonyNonce := big.NewInt(1) + msg := big.NewInt(42) + for i := range n { + params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[i], n, threshold) + if i == legacyIdx { + params.SetProtocolMode(tss.ProtocolModeLegacy) + } else { + params.SetProtocolMode(tss.ProtocolModeSecurityV2) + params.SetSessionNonce(ceremonyNonce) + } + // Buffer large enough to hold a full round-1 emission (n messages) + // and, if the abort regresses, a full round-2 emission (n-1 + // messages) without blocking a Start() call. + outChs[i] = make(chan tss.Message, 2*n) + endChs[i] = make(chan *common.SignatureData, 1) + parties[i] = NewLocalParty(msg, params, keys[i], outChs[i], endChs[i], 32).(*LocalParty) + } + + startErrs := startAllAndAwait(parties) + for i, startErr := range startErrs { + require.Nil(t, startErr, "party %d Start must not fail in round 1", i) + } + + // Drive the mismatch scenario from both viewpoints: the legacy party + // and one security-v2 peer. + msgsForLegacy, msgsForV2 := collectRound1MessagesForTargets(t, parties, outChs, parties[legacyIdx].PartyID(), parties[1].PartyID()) + + var lastErrLegacy *tss.Error + for _, m := range msgsForLegacy { + if _, err := parties[legacyIdx].Update(m); err != nil { + lastErrLegacy = err + } + } + var lastErrV2 *tss.Error + for _, m := range msgsForV2 { + if _, err := parties[1].Update(m); err != nil { + lastErrV2 = err + } + } + + // The security-v2 peer rejects only the legacy party. + require.NotNil(t, lastErrV2, "security-v2 peer expected a round-2 rejection") + require.Equal(t, 2, lastErrV2.Round(), "security-v2 abort must occur in round 2") + require.Equal(t, indexSet(parties[legacyIdx].PartyID()), culpritIndexSet(lastErrV2), + "security-v2 peer's culprit set must be exactly the legacy party") + + // The legacy party rejects every security-v2 peer. + require.NotNil(t, lastErrLegacy, "legacy party expected a round-2 rejection") + require.Equal(t, 2, lastErrLegacy.Round(), "legacy abort must occur in round 2") + wantV2Peers := make([]*tss.PartyID, 0, n-1) + for i, P := range parties { + if i != legacyIdx { + wantV2Peers = append(wantV2Peers, P.PartyID()) + } + } + require.Equal(t, indexSet(wantV2Peers...), culpritIndexSet(lastErrLegacy), + "legacy party's culprit set must be exactly the security-v2 peers") + + assertNoSignatureWithinWindow(t, endChs, 500*time.Millisecond) +} + +// TestConstructorPanicsWithNoMode verifies that NewLocalParty panics when +// no protocol mode is set on params before construction. +func TestConstructorPanicsWithNoMode(t *testing.T) { + keys, signPIDs, err := keygen.LoadKeygenTestFixtures(2) + require.NoError(t, err) + + p2pCtx := tss.NewPeerContext(signPIDs) + params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[0], 2, 1) + // Do NOT call SetProtocolMode; params has no mode set. + // Pass a valid fullBytesLen so the mode check is reached. + assert.PanicsWithValue(t, + "tss: protocol mode must be selected before local party construction", + func() { + NewLocalParty(big.NewInt(1), params, keys[0], nil, nil, 32) + }, + "must panic when protocol mode is not set") +} + +// TestConstructorPanicsWithLegacyPlusNonce verifies that NewLocalParty +// panics when legacy mode is combined with a session nonce. +func TestConstructorPanicsWithLegacyPlusNonce(t *testing.T) { + keys, signPIDs, err := keygen.LoadKeygenTestFixtures(2) + require.NoError(t, err) + + p2pCtx := tss.NewPeerContext(signPIDs) + params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[0], 2, 1) + params.SetProtocolMode(tss.ProtocolModeLegacy) + params.SetSessionNonce(big.NewInt(1)) // not valid with legacy + assert.PanicsWithValue(t, + "tss: legacy protocol mode must not set a session nonce", + func() { + NewLocalParty(big.NewInt(1), params, keys[0], nil, nil, 32) + }, + "must panic when legacy mode is combined with a session nonce") +} + +// TestConstructorSettersPanicAfterConstruction verifies that the setter +// methods panic when called after the party has been constructed. +func TestConstructorSettersPanicAfterConstruction(t *testing.T) { + keys, signPIDs, err := keygen.LoadKeygenTestFixtures(2) + require.NoError(t, err) + + p2pCtx := tss.NewPeerContext(signPIDs) + params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[0], 2, 1) + params.SetProtocolMode(tss.ProtocolModeLegacy) + + out := make(chan tss.Message, 1) + end := make(chan *common.SignatureData, 1) + P := NewLocalParty(big.NewInt(1), params, keys[0], out, end, 32).(*LocalParty) + + assert.PanicsWithValue(t, + "tss: protocol mode is immutable after local party construction", + func() { + P.params.SetProtocolMode(tss.ProtocolModeSecurityV2) + }, + "SetProtocolMode must panic after construction") + assert.PanicsWithValue(t, + "tss: session nonce is immutable after local party construction", + func() { + P.params.SetSessionNonce(big.NewInt(1)) + }, + "SetSessionNonce must panic after construction") + assert.PanicsWithValue(t, + "tss: legacy historical Bob compatibility is immutable after local party construction", + func() { + P.params.SetLegacyHistoricalBobCompatibility(true) + }, + "SetLegacyHistoricalBobCompatibility must panic after construction") +} + +// TestNewLocalPartyWithKDDCopiesDelta pins that the constructor copies the +// key derivation delta: a caller that later mutates its *big.Int must not +// change the party's signing context. +func TestNewLocalPartyWithKDDCopiesDelta(t *testing.T) { + keys, signPIDs, err := keygen.LoadKeygenTestFixtures(2) + require.NoError(t, err) + + params := tss.NewParameters(tss.S256(), tss.NewPeerContext(signPIDs), signPIDs[0], 2, 1) + params.SetProtocolMode(tss.ProtocolModeLegacy) + delta := big.NewInt(12345) + P := NewLocalPartyWithKDD(big.NewInt(1), params, keys[0], delta, nil, nil, 32).(*LocalParty) + + delta.SetInt64(999) + require.NotNil(t, P.temp.keyDerivationDelta) + assert.Zero(t, P.temp.keyDerivationDelta.Cmp(big.NewInt(12345)), + "mutating the caller's delta must not change the party's copy") +} diff --git a/ecdsa/signing/signing_boundaries_test.go b/ecdsa/signing/signing_boundaries_test.go index 76bf2c485..6393623df 100644 --- a/ecdsa/signing/signing_boundaries_test.go +++ b/ecdsa/signing/signing_boundaries_test.go @@ -300,43 +300,72 @@ func TestSigningStartMessageRangeGate(t *testing.T) { keys, pIDs, err := keygen.LoadKeygenTestFixtures(2) require.NoError(t, err) - params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], 2, 1) - params.SetProtocolMode(tss.ProtocolModeLegacy) + for _, tc := range []struct { + name string + mode tss.ProtocolMode + }{ + {"legacy", tss.ProtocolModeLegacy}, + {"securityV2", tss.ProtocolModeSecurityV2}, + } { + t.Run(tc.name+"/rejectsMessageEqualToCurveOrder", func(t *testing.T) { + params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], 2, 1) + params.SetProtocolMode(tc.mode) + if tc.mode == tss.ProtocolModeSecurityV2 { + params.SetSessionNonce(big.NewInt(1)) + } + out := make(chan tss.Message, 2) + end := make(chan *common.SignatureData, 1) + msg := new(big.Int).Set(q) - t.Run("rejectsMessageEqualToCurveOrder", func(t *testing.T) { - out := make(chan tss.Message, 2) - end := make(chan *common.SignatureData, 1) - msg := new(big.Int).Set(q) + P := NewLocalParty(msg, params, keys[0], out, end, 32).(*LocalParty) + err := P.Start() + require.NotNil(t, err, "Start must reject m == curve order") - P := NewLocalParty(msg, params, keys[0], out, end, 32).(*LocalParty) - err := P.Start() - require.NotNil(t, err, "Start must reject m == curve order") + // No messages should have been emitted (gate is before any emission) + assert.Empty(t, out, "no round-1 messages emitted on rejection") + }) - // No messages should have been emitted (gate is before any emission) - assert.Empty(t, out, "no round-1 messages emitted on rejection") - }) + t.Run(tc.name+"/acceptsMessageEqualToOrderMinusOne", func(t *testing.T) { + requireSigningCeremonyAccepts(t, tc.mode, new(big.Int).Sub(q, big.NewInt(1))) + }) - t.Run("acceptsMessageEqualToOrderMinusOne", func(t *testing.T) { - out := make(chan tss.Message, 2) - end := make(chan *common.SignatureData, 1) - msg := new(big.Int).Sub(q, big.NewInt(1)) - - P := NewLocalParty(msg, params, keys[0], out, end, 32).(*LocalParty) - err := P.Start() - require.Nil(t, err, "Start must accept m == q-1 through the range gate") + } - // Round-1 messages must be emitted: 1 directed + 1 broadcast = 2 - msgs := make([]tss.Message, 0, 2) - for range 2 { - select { - case m := <-out: - msgs = append(msgs, m) - } + // The range gate is mode-independent, but the SSID is a + // security-v2-only concept: two parties with the same nonce and + // identical inputs except the message must derive different SSIDs + // when they differ only in the adjacent messages q-1 vs q-2. + t.Run("securityV2SSIDsForAdjacentMessagesDiffer", func(t *testing.T) { + p2pCtx := tss.NewPeerContext(pIDs) + nonce := big.NewInt(7) + build := func(msg *big.Int) *LocalParty { + params := tss.NewParameters(tss.S256(), p2pCtx, pIDs[0], 2, 1) + params.SetProtocolMode(tss.ProtocolModeSecurityV2) + params.SetSessionNonce(nonce) + out := make(chan tss.Message, 2) + end := make(chan *common.SignatureData, 1) + return NewLocalParty(msg, params, keys[0], out, end, 32).(*LocalParty) } - assert.Len(t, msgs, 2, "expected 2 round-1 messages (directed + broadcast)") + upper := build(new(big.Int).Sub(q, big.NewInt(1))) + lower := build(new(big.Int).Sub(q, big.NewInt(2))) + require.Nil(t, upper.Start()) + require.Nil(t, lower.Start()) + require.NotEmpty(t, upper.temp.ssid, "security-v2 Start must derive an SSID") + require.NotEmpty(t, lower.temp.ssid, "security-v2 Start must derive an SSID") + assert.NotEqual(t, upper.temp.ssid, lower.temp.ssid, + "adjacent messages with the same nonce must derive different SSIDs") }) } +// requireSigningCeremonyAccepts runs a full completing signing ceremony with +// the given message in the given protocol mode (an 11-party threshold-10 +// ceremony via testE2EConcurrent) and verifies the delivered signature data +// with the standard assertions. +func requireSigningCeremonyAccepts(t *testing.T, mode tss.ProtocolMode, msg *big.Int) { + t.Helper() + testE2EConcurrent(t, mode, msg, 32) +} + // ----- Deterministic out-of-order readiness accumulation for round.ok ----- // signingRound1ReadinessFixture constructs a round1 with temp arrays pre-allocated diff --git a/testdata/legacy_transcript/README.md b/testdata/legacy_transcript/README.md index 199b2fe7a..6bd5de659 100644 --- a/testdata/legacy_transcript/README.md +++ b/testdata/legacy_transcript/README.md @@ -4,6 +4,11 @@ This directory contains deterministic cross-version qualification evidence for the transcript-compatible legacy mode. Private values and deterministic random streams in these files are test fixtures only and must never be used by a live protocol. +Labels used in this README and in CI comments: PRIOR is the historical +`2e712689` vector set (`prior_v2.5.2.json`); R1 is this fork's legacy +implementation path (`r1_fixed.json`); R01 is the compatibility-patch +label that also prefixes the generator's deterministic seed strings, so +the checked-in vectors remain byte-for-byte reproducible. `prior_v2.5.2.json` was generated with `threshold-network/tss-lib@2e712689cfbe`. `r1_fixed.json` was generated from @@ -45,21 +50,29 @@ common/random.go ecdsa/keygen/messages.go ecdsa/signing/messages.go ``` +The proof-source digest refers to the listed source files at each +document's recorded `source_basis` revision, not to the current checkout, +and nothing in the harness recomputes it. -Run both independent directions from the repository root: +Run all directions from the repository root: ```sh ./testdata/legacy_transcript/verify.sh ``` -The first oracle is the checked-out implementation verifying PRIOR-generated -proofs. The second runs the same parser and equations in a nested module pinned -to the historical commit, verifying R1-generated proofs. The checked-in -`historical/go.mod.fixture` and `historical/go.sum.fixture` are copied into a -temporary module for that direction. The `.fixture` suffix is intentional: Go -prunes nested modules from a parent module zip, while ordinary fixture files are -part of the immutable module keep-core downloads. The pinned fixture therefore -makes the historical identity independent of a developer's module cache without +`verify.sh` checks three directions. The first runs the checked-out +implementation against the PRIOR-generated proofs. The second runs the same +parser and equations in a nested module pinned to the historical commit, +verifying the R1-generated proofs. The third closes the remaining direction, +HEAD-prover to historical-verifier: the checked-out oracle's generator mode +regenerates a transcript under the same deterministic streams, the historical +module verifies it, and the generated document is asserted equal to the PRIOR +vector outside provenance. The checked-in `historical/go.mod.fixture` and +`historical/go.sum.fixture` are copied into a temporary module for the +nested directions. The `.fixture` suffix is intentional: Go prunes nested +modules from a parent module zip, while ordinary fixture files are part of +the immutable module keep-core downloads. The pinned fixture therefore makes +the historical identity independent of a developer's module cache without disappearing from the release artifact being qualified. ## Mixed-binary legacy signing interop harness @@ -77,6 +90,12 @@ commit qualified above), exchanging real GG18/GG20 round wire messages. ./testdata/legacy_transcript/verify_mixed_interop.sh ``` +This mixed-binary coverage is signing-only: there is no live mixed keygen +exercise, so the keygen (DKG) half of the compatibility claim is qualified +only by the primitive-level oracle vectors that `verify.sh`'s oracle legs +drive in both directions (the DLN, ModProof, and FactorProof +transcripts). + `historical_signer/main.go` is copied into a temporary module built from the same `historical/go.mod.fixture`/`historical/go.sum.fixture` pin as the oracle, exactly like `verify.sh`'s pattern. It drives one live historical @@ -90,9 +109,10 @@ module cache (the same precondition `verify.sh` has always had). (subprocess wire protocol), `mixed_interop/scenarios.go` (scenario logic and per-proof verification), and `mixed_interop/homogeneous.go` (the control scenario) together implement the current-side driver (`go run` from the -repository root, matching the oracle). For a fixed 2-of-20 `keygen_data_0/1` -fixture pair, deterministic seeds, and a fixed message, they drive three -scenarios: +repository root, matching the oracle). For a fixed 2-signer subset of the +20-party `keygen_data_0/1` fixture key set (threshold 1; the key set's +real threshold is 10), with deterministic seeds and a fixed message, they +drive three scenarios: - **reject**: a current party with `tss.ProtocolModeLegacy` and the default (off) `SetLegacyHistoricalBobCompatibility` opt asserts that round 3 fails @@ -135,6 +155,11 @@ scenarios: witness range and not a general legacy-mode defect. This scenario is never substituted for the cross-version exchanges above. +The live reject/accept exchange above is the CI coverage for the +wide-witness historical Bob/BobWC proof shape: it runs proofs actually +produced by the pinned historical binary and asserts each one rejects at +the tight bound and accepts at the widened bound. + All three scenarios deliberately stop once BOTH actors have emitted their own round 8 message (each first round-8-or-later message is captured as per-actor boundary evidence and dropped, never delivered onward; only @@ -142,9 +167,10 @@ lower-round pending messages keep flowing until both sides have emitted round 8): this repository's own `round3Fixture` (`ecdsa/signing/round_3_test.go`) and `historicalBobProofForWitnessY` (`crypto/mta/legacy_bob_historical_witness_test.go`) already establish the -precedent of driving a 2-of-20 minimal subset of the `test/_ecdsa_fixtures` -keygen fixtures (threshold 1, not the fixture set's real threshold 10) for -this exact class of round-level interop check. That minimal subset is +precedent of driving a 2-signer subset of the 20-party +`test/_ecdsa_fixtures` keygen key set (threshold 1; the key set's real +threshold is 10) for this exact class of round-level interop check. That +minimal subset is sufficient for every per-peer MtA/Schnorr check through round 8 (each is a property of the two parties' own consistent local computation), but round 9's final aggregate check (`U == T`) verifies a *global* Shamir reconstruction @@ -157,7 +183,7 @@ round-8-or-later message is ever delivered onward, the threshold-mismatched fixture can never run into round 9. Driving a full, globally-valid signature to completion is possible but requires `testThreshold+1` (11) correctly-thresholded co-signers rather than an -arbitrary 2-of-20 subset — substantially more harness complexity for a +arbitrary 2-signer subset — substantially more harness complexity for a property (global reconstruction validity) that is orthogonal to the specific Bob/BobWC compatibility mechanism this harness exists to exercise. diff --git a/testdata/legacy_transcript/historical_signer/main.go b/testdata/legacy_transcript/historical_signer/main.go index 706a7dc59..fa090daa1 100644 --- a/testdata/legacy_transcript/historical_signer/main.go +++ b/testdata/legacy_transcript/historical_signer/main.go @@ -19,7 +19,7 @@ // signer -> host: {"event":"error","message":""} // signer -> host: {"event":"turn_done"} // -// The signer always plays party index 1 in a fixed 2-of-2 ceremony built from +// The signer always plays party index 1 in a fixed 2-signer subset built from // the existing test/_ecdsa_fixtures/keygen_data_{0,1}.json fixtures (byte // identical between this historical commit and current dev), so both sides // derive identical PartyIDs and key material independently without needing to diff --git a/testdata/legacy_transcript/mixed_interop/homogeneous.go b/testdata/legacy_transcript/mixed_interop/homogeneous.go index 8f407cbcc..f1cdac7c8 100644 --- a/testdata/legacy_transcript/mixed_interop/homogeneous.go +++ b/testdata/legacy_transcript/mixed_interop/homogeneous.go @@ -54,7 +54,7 @@ pump: // parties have emitted their first round-8-or-later message. Each // such message is captured as per-actor evidence and dropped (never // forwarded), and lower-round messages keep flowing until both - // sides have emitted round 8 — so this minimal 2-of-20 fixture + // sides have emitted round 8 — so this minimal 2-signer fixture // subset can never cascade into round 9. if result.round8BothReached() { break pump diff --git a/testdata/legacy_transcript/mixed_interop/main.go b/testdata/legacy_transcript/mixed_interop/main.go index 6fd41cf79..aa099a8ba 100644 --- a/testdata/legacy_transcript/mixed_interop/main.go +++ b/testdata/legacy_transcript/mixed_interop/main.go @@ -11,7 +11,7 @@ // verifier's bound math but never exercises an actual historical binary. This // program instead spawns the pinned historical commit as a child process (the // two versions share one Go module path and cannot be linked into one -// binary), drives it through a live 2-of-2 signing ceremony opposite a +// binary), drives it through a live signing ceremony with a 2-signer subset opposite a // current-implementation party in ProtocolModeLegacy, and asserts on the // resulting behavior: // diff --git a/testdata/legacy_transcript/mixed_interop/scenarios.go b/testdata/legacy_transcript/mixed_interop/scenarios.go index 852552242..92933ae84 100644 --- a/testdata/legacy_transcript/mixed_interop/scenarios.go +++ b/testdata/legacy_transcript/mixed_interop/scenarios.go @@ -237,7 +237,7 @@ func verifyPerProofIndependently(result *scenarioResult, capturedBobR2 *signing. // The exchange is deliberately bounded to round 8: this repository's own // existing round3Fixture (ecdsa/signing/round_3_test.go) and // historicalBobProofForWitnessY (crypto/mta/legacy_bob_historical_witness_test.go) -// already establish the precedent of driving a 2-of-20 minimal subset of the +// already establish the precedent of driving a 2-signer subset of the 20-party // test/_ecdsa_fixtures keygen fixtures (threshold=1, not the fixture set's // real threshold=10) for exactly this kind of round-level interop check. // That minimal subset is sufficient for every per-peer MtA/Schnorr check diff --git a/testdata/legacy_transcript/oracle/main.go b/testdata/legacy_transcript/oracle/main.go index ce5b5973f..0250cd431 100644 --- a/testdata/legacy_transcript/oracle/main.go +++ b/testdata/legacy_transcript/oracle/main.go @@ -305,11 +305,11 @@ func verifyDocument(path string) { } } -// compareDocuments proves the byte-compatibility statement the two checked-in -// vectors are intended to make. Provenance differs by construction; every -// other JSON value — fixed public input, challenge, proof scalar, and wire -// message — must have the same canonical encoding. -func compareDocuments(priorPath, r1Path string) { +// compareDocuments checks that two legacy vectors are byte-compatible. +// Provenance may differ; every other JSON value (fixed public input, +// challenge, proof scalar, and wire message) must have the same canonical +// encoding. +func compareDocuments(firstPath, secondPath string) { canonical := func(path string) []byte { raw, err := os.ReadFile(path) if err != nil { @@ -329,8 +329,8 @@ func compareDocuments(priorPath, r1Path string) { return encoded } - if !bytes.Equal(canonical(priorPath), canonical(r1Path)) { - fmt.Fprintln(os.Stderr, "PRIOR and R1 legacy vectors differ outside provenance") + if !bytes.Equal(canonical(firstPath), canonical(secondPath)) { + fmt.Fprintf(os.Stderr, "legacy vectors %s and %s differ outside provenance\n", firstPath, secondPath) os.Exit(1) } fmt.Println("canonical_legacy_vectors_equal=true") diff --git a/testdata/legacy_transcript/verify.sh b/testdata/legacy_transcript/verify.sh index 2af922989..449c8cd42 100755 --- a/testdata/legacy_transcript/verify.sh +++ b/testdata/legacy_transcript/verify.sh @@ -5,7 +5,8 @@ set -euo pipefail data_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repository_root="$(cd "${data_dir}/../.." && pwd)" historical_module="$(mktemp -d "${TMPDIR:-/tmp}/tss-legacy-oracle.XXXXXX")" -trap 'chmod -R u+w "${historical_module}" 2>/dev/null || true; rm -rf "${historical_module}"' EXIT +generated_vector="$(mktemp "${TMPDIR:-/tmp}/tss-legacy-generated.XXXXXX")" +trap 'chmod -R u+w "${historical_module}" 2>/dev/null || true; rm -rf "${historical_module}"; rm -f "${generated_vector}"' EXIT if command -v sha256sum >/dev/null 2>&1; then ( @@ -19,6 +20,8 @@ else ) fi +# Leg 1: the checked-out implementation must verify the historical PRIOR +# transcript, and PRIOR and R1 must be equal outside provenance. ( cd "${repository_root}" go run ./testdata/legacy_transcript/oracle/main.go \ @@ -28,6 +31,8 @@ fi ./testdata/legacy_transcript/r1_fixed.json ) +# Leg 2: the historical-module oracle (same parser and equations pinned at the +# historical commit) must verify the R1 transcript. cp "${data_dir}/historical/go.mod.fixture" "${historical_module}/go.mod" cp "${data_dir}/historical/go.sum.fixture" "${historical_module}/go.sum" cp "${data_dir}/oracle/main.go" "${historical_module}/main.go" @@ -35,3 +40,30 @@ cp "${data_dir}/oracle/main.go" "${historical_module}/main.go" cd "${historical_module}" go run -mod=readonly ./main.go "${data_dir}/r1_fixed.json" ) + +# Leg 3: the HEAD legacy provers must produce a transcript the historical +# verifier accepts (the HEAD-prover -> historical-verifier direction). The +# oracle's generator mode runs at HEAD with the same deterministic streams +# that produced the checked-in vectors. The historical module must verify the +# generated document, and the document must be equal to PRIOR outside +# provenance. +( + cd "${repository_root}" + go run ./testdata/legacy_transcript/oracle/main.go > "${generated_vector}" +) +if [ ! -s "${generated_vector}" ]; then + echo "generated legacy vector is empty" >&2 + exit 1 +fi +( + cd "${historical_module}" + go run -mod=readonly ./main.go "${generated_vector}" +) +# Under the deterministic streams the generated vector must be equal to PRIOR +# outside provenance. +( + cd "${repository_root}" + go run ./testdata/legacy_transcript/oracle/main.go \ + ./testdata/legacy_transcript/prior_v2.5.2.json \ + "${generated_vector}" +) diff --git a/testdata/legacy_transcript/verify_mixed_interop.sh b/testdata/legacy_transcript/verify_mixed_interop.sh index 69b618fa3..bfe73aae4 100755 --- a/testdata/legacy_transcript/verify_mixed_interop.sh +++ b/testdata/legacy_transcript/verify_mixed_interop.sh @@ -13,8 +13,9 @@ # the Paillier modulus N, per the historical BobMid/BobMidWC) produces a # round-2 proof whose T1 exceeds the default tight N+q^6 bound; # - a default-configured current party's round 3 fails closed against it; -# - a current party with SetLegacyHistoricalBobCompatibility(true) accepts -# the identical exchange and provably progresses (through round 8; see +# - a current party with SetLegacyHistoricalBobCompatibility(true) accepts a +# fresh paired exchange (own seed; not a replay of the reject run) and +# provably progresses (through round 8; see # testdata/legacy_transcript/mixed_interop/main.go's doc comment for why # full signature completion is out of scope for this specific check); # - a homogeneous (current-only) control completes the same shape, so the diff --git a/tss/params.go b/tss/params.go index 494004268..4ebd9be8f 100644 --- a/tss/params.go +++ b/tss/params.go @@ -32,8 +32,8 @@ type ( safePrimeGenTimeout time.Duration // sessionNonce provides per-session SSID uniqueness for security-v2 // GG20 proof binding. Security-v2 keygen and signing require callers - // to coordinate a shared positive nonce before Start; legacy mode must - // leave it unset. + // to coordinate a shared positive nonce before constructing the local + // party; legacy mode must leave it unset. sessionNonce *big.Int // legacyHistoricalBobCompat widens only the legacy-mode Bob/BobWC T1 // verifier bound so pre-upgrade peer proofs (y < N witness range) @@ -194,7 +194,9 @@ func (params *Parameters) SetProtocolMode(mode ProtocolMode) { } // FreezeProtocolMode validates and freezes the transcript configuration. -// ECDSA local-party constructors call it before retaining params. +// ECDSA local-party constructors call it before retaining params. It panics +// if no protocol mode was selected, or if a legacy-mode party has a session +// nonce set. A second call returns without revalidating. func (params *Parameters) FreezeProtocolMode() { if params.protocolModeFrozen { return @@ -224,7 +226,7 @@ func (params *Parameters) SessionNonce() *big.Int { // protocol run must agree on. It must be called before constructing the local // party. Legacy parties must not set a nonce. // -// Keygen and signing fail closed if no nonce is set. The previous zero +// Security-v2 keygen and signing fail closed if no nonce is set. The previous zero // (keygen) and SHA512_256(messageBytes) (signing) fallbacks caused two // ceremonies with otherwise-identical inputs to derive the same SSID, breaking // the session-binding property that the proofs rely on. The caller must supply