Skip to content

fix(crypto): extend constant-time coverage to Schnorr proofs and signing rounds 3-5 - #23

Open
piotr-roslaniec wants to merge 7 commits into
codex/ct-defaults-and-boundsfrom
ct-hardening-schnorr-signing-coverage
Open

piotr-roslaniec wants to merge 7 commits into
codex/ct-defaults-and-boundsfrom
ct-hardening-schnorr-signing-coverage

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Sep 18, 2026

Copy link
Copy Markdown

Context

This is an additive finding for the existing CT-hardening stack (PRs #8 / #10 / #11 / #17) — submitted for review/consideration, not asking for immediate merge. It stacks on #17 (the head branch), in the same stacked-draft style #10 and #16 already use.

Upstream bnb-chain/tss-lib commit 3709c25 (folded into the BNB #328 series) hardened the constant-time path in two more spots that this fork's stack never picked up:

  • crypto/schnorr/schnorr_proof.goNewZKProofWithSession and NewZKVProofWithSession multiply the challenge c against the secret witnesses x, s, and l to build the proof responses t and u. Without CT, those math/big multiplications leak witness bits through timing.
  • ecdsa/signing/round_3.go / round_4.go / round_5.gothelta = k·γ, sigma = k·w, thetaInverse = θ⁻¹ mod q, and si = m·k + rx·σ are all secret-key multiplications and inverses on the ECDSA signing hot path.

PR #17's own file list (the d08dc73 "extend constant-time coverage" commit) covers Paillier, DLN, MtA, factor/mod proofs, ring-Pedersen keygen, and the existing MulCT/ExpCT/ModInverseCT primitives — but does not touch either of the two files above. Same gap exists in #8, #10, and #11. This PR closes that gap using the exact same pattern already established in common/constant_time.go (common.NewCTModInt(...).MulCT / .ModInverseCT, gated on IsConstantTimeEnabled()).

Why no ExpCTWithBitLen adaptation needed (vs. PR #10)

The new code calls only .MulCT(...) and .ModInverseCT(...)never .ExpCT(...):

  • MulCT takes no exponent at all (it's a constant-time modular multiplication, not an exponentiation), so PR fix(crypto): handle CT exponent bounds, zero values, and toggle changes #10's bounded-exponent-width hardening does not apply.
  • ModInverseCT's internal exponent is the fixed, modulus-derived constant mod-2 (or phiN-1 via NewCTModIntWithPhi) — also a public, fixed-width value, not a caller-supplied variable-width secret. Confirmed by grep -n '\.ExpCT(' crypto/schnorr/schnorr_proof.go ecdsa/signing/round_3.go ecdsa/signing/round_4.go ecdsa/signing/round_5.go returning zero hits on this branch.

So this addition slots in directly under the existing IsConstantTimeEnabled() toggle without re-touching the exponent-bound logic PR #10 added.

Changes

  • crypto/schnorr/schnorr_proof.go — wrap the c·x and c·s / c·l multiplications in NewZKProofWithSession / NewZKVProofWithSession with the CT branch.
  • ecdsa/signing/round_3.go — wrap k·γ and k·w (thelta/sigma computation) with the CT branch.
  • ecdsa/signing/round_4.go — wrap θ⁻¹ mod q with ModInverseCT.
  • ecdsa/signing/round_5.go — wrap m·k and rx·σ (the si addend pair) with the CT branch.
  • crypto/schnorr/constant_time_equiv_test.gonew equivalence test covering both NewZKProof and NewZKVProof: builds a non-CT baseline proof and a CT proof from the same witnesses and asserts both verify.
  • common/constant_time.go — COVERAGE doc comment updated to list the Schnorr proof responses and ECDSA signing rounds 3-5 among the sites now covered. Default value (constantTimeEnabled = 1, set by PR Enable bounded bigmod operations by default #17) and PR Enable bounded bigmod operations by default #17's "limited coverage" warning are preserved verbatim.
  • CHANGELOG.md — added a Breaking Change feat(crypto): opt-in constant-time path for secret-exponent modexps [DO NOT MERGE] #8 entry under the existing [Unreleased] heading describing the now-on-by-default CT framework plus this PR's Schnorr/signing-rounds extension, an Added entry for the CT API symbols, and removed two now-stale deferred-CT lines (the "optional constant-time framework ... deferred to a separate follow-up" bullet and "the optional constant-time work is not integrated" line). (An earlier revision of this branch introduced a dated [1.4.0] section header and removed the "has not yet published its own tagged release" line; both were reverted in commit 17e4771 after review feedback that this fork has no tags/releases yet — this description previously went stale relative to that revert.)

Verification on this branch (pushed)

$ gofmt -l .
(no output)

$ go build ./...
(no output)

$ go vet ./...
(no output)

$ go test ./...
ok    github.com/bnb-chain/tss-lib/common              28.469s
ok    github.com/bnb-chain/tss-lib/crypto              (cached)
ok    github.com/bnb-chain/tss-lib/crypto/ckd          (cached)
ok    github.com/bnb-chain/tss-lib/crypto/commitments  (cached)
ok    github.com/bnb-chain/tss-lib/crypto/dlnproof     23.557s
ok    github.com/bnb-chain/tss-lib/crypto/mta          286.977s
ok    github.com/bnb-chain/tss-lib/crypto/paillier     51.010s
ok    github.com/bnb-chain/tss-lib/crypto/schnorr      0.052s
ok    github.com/bnb-chain/tss-lib/crypto/vss          (cached)
ok    github.com/bnb-chain/tss-lib/ecdsa/keygen        340.405s
ok    github.com/bnb-chain/tss-lib/ecdsa/signing       186.710s
?     github.com/bnb-chain/tss-lib/test                [no test files]
ok    github.com/bnb-chain/tss-lib/tss                 (cached)

The new TestSchnorrProofCTVerifies / TestSchnorrVProofCTVerifies cases pass.

Benchmark (from the local pre-push run on this stack)

Constant-time modexp measured at parity with the standard math/big path on the same hardware:

go test ./common/... -bench BenchmarkExpCT -benchtime=2s
~2.73ms per op (CT,  n≈900)   vs   ~2.83ms per op (std,  n≈800)

The CPU-cost concern that originally motivated upstream's deferral did not materialize for this primitive.

Out of scope / NOT fixed here — for maintainer awareness only

Upstream's same 3709c25 commit also hardened crypto/mta/share_protocol.go's AliceEnd / AliceEndWC Paillier-decrypt paths — but with a different mechanism entirely: a sleep-based response-time normalizer (NewTimingProtection, ~200 ms target + jitter), not the bigmod constant-time path used everywhere else in common/constant_time.go. That primitive does not exist anywhere in this fork's lineage (master, both CT branches, or PRs #12#17), and porting it would inject a fixed ~200 ms delay into every MtA share round — a real latency cost that needs its own sign-off rather than a mechanical extension of the existing CT pattern.

That gap is documented as a known, deliberately-deferred item in this PR's COVERAGE comment update to common/constant_time.go, not implemented in code. If/when a follow-up decides to port NewTimingProtection, it should land as its own PR with its own benchmark and a separate latency sign-off; the bigmod CT path doesn't subsume it.

…t v1.4.0 notes

NOT ready to merge/push. Exploratory work pending reconciliation with the
live #8/#10/#11/#17 stack (mswilkison) which already rebases+hardens the CT
backport more rigorously than this commit (bounded exponent widths, overflow
rejection, mode snapshotting) and already flips constantTimeEnabled's default
directly. This commit's own contribution -- CT coverage for
crypto/schnorr/schnorr_proof.go and ecdsa/signing/round_3-5.go, which none of
#8/#10/#11/#17 touch -- is real and not yet duplicated elsewhere, but should
land as an addition on top of that stack, not a competing branch.

Kept locally, unpushed, for reference pending user/maintainer coordination.
…tion gap

- Remove common/constant_time_init.go: PR #17 (mswilkison, stacked on #11)
  already flips constantTimeEnabled's default to 1 directly in
  common/constant_time.go, making a separate init() redundant. It was also
  actively wrong: dlnproof/constant_time_equiv_test.go (and this commit's own
  new schnorr test) generate their 'non-CT baseline' proof before calling
  EnableConstantTimeOps(), assuming an ambient disabled default -- an
  unconditional init() would silently make that baseline vacuous instead of
  failing loudly.
- Update the COVERAGE doc comment in common/constant_time.go to list the
  Schnorr proof responses and ECDSA signing rounds 3-5 now covered, and to
  record crypto/mta.AliceEnd/AliceEndWC's Paillier-decrypt timing protection
  (upstream BNB 3709c25) as a known, deliberately-NOT-ported gap: upstream
  uses a sleep-based response-time normalizer (NewTimingProtection, ~200ms
  target + jitter), a different mechanism entirely from the bigmod
  constant-time path used everywhere else in this file. That primitive
  exists nowhere in this fork's lineage (checked master, both CT branches,
  and PRs #12-17). Porting it would inject a fixed ~200ms delay into every
  MtA share round -- a real latency cost that needs its own sign-off, not a
  mechanical extension of the existing pattern.
- Fix CHANGELOG.md's now-stale 'has not yet published its own tagged
  release' line (contradicted by the new [1.4.0] section added earlier).
…_init.go

Two spots still described CT-enablement as happening via a package init()
in common/constant_time_init.go. That file was removed before this branch
was ever pushed (superseded by PR #17's direct default-value change,
constantTimeEnabled = 1 in common/constant_time.go) -- these two CHANGELOG
lines were never updated to match and got carried forward by the cherry-pick
onto this branch. Correct the mechanism description in both places.

@mswilkison mswilkison left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two P2 findings from the review.

Validation reported by the review: build and vet passed, and the Schnorr tests compiled; test binaries were not executed.

Comment on lines +37 to +38
common.EnableConstantTimeOps()
defer common.DisableConstantTimeOps()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Explicitly set and restore CT mode in both equivalence tests

CT is enabled by default, so proofOff actually exercises the CT branch when either test runs alone. The deferred disable then leaves subsequent Schnorr tests—including the existing P-256 and session-binding cases—running with CT disabled. This makes coverage depend on test order and removes default-path coverage. Save the previous mode, explicitly disable CT before constructing the baseline, and restore the saved mode during cleanup in both tests.

Comment thread CHANGELOG.md Outdated

---

## [1.4.0] - 2026-09-14 — BNB hardening integration

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the untagged hardening stack under Unreleased

This repository currently has no tags or published GitHub releases, including v1.4.0. Moving the hardening stack into this dated release section therefore advertises security changes as released when consumers cannot obtain that version, and both new comparison links reference a nonexistent tag. Keep these entries under [Unreleased] until an actual release contains them.

Address review findings on PR #23:

- The equivalence tests built their non-CT baseline without disabling
  constant-time mode. Since this branch defaults constantTimeEnabled to 1,
  the baseline actually exercised the CT path, and the bare deferred
  DisableConstantTimeOps leaked disabled state into subsequent Schnorr
  tests, making coverage order-dependent and dropping default-path
  coverage. Save, set, and restore the ambient mode via a withCTMode
  helper, and assert the baseline really is running with CT off.

- Restore the changelog hardening entries to [Unreleased]. This fork has
  no tags or published releases, so a dated [1.4.0] heading advertised
  the security work as obtainable, and both comparison links pointed at
  a tag that does not exist.
@piotr-roslaniec

Copy link
Copy Markdown
Author

Both P2 findings addressed in 17e4771.

Test CT mode. Confirmed the finding is exactly right: this branch sets constantTimeEnabled = 1 (common/constant_time.go:54), so proofOff was exercising the CT branch and the bare defer DisableConstantTimeOps() was leaving CT off for every later test in the package. Added a withCTMode(t, bool) helper that saves the ambient mode, sets the requested one, and restores the saved mode via t.Cleanup; the baseline now explicitly disables CT and asserts IsConstantTimeEnabled() == false so it cannot silently go vacuous again. Verified each CT test passes run alone (the original order-dependence case) and that the full package passes under -count=2 with the pre-existing P-256 and session-binding tests running after them.

Changelog. Also correct, and the [1.4.0] heading plus both comparison links were introduced by this PR, not inherited. Reverted to [Unreleased]; no 1.4.0 reference remains in the file. The changelog diff against this PR's base is now purely the new content entry, with no heading or link churn.

Addresses review findings on PR #23's constant-time hardening entry:

- CHANGELOG.md: tag Breaking Change #8 and the Added CT-symbols entry
  with PR #17/#23, extend the Composing PRs list (F2)
- CHANGELOG.md: soften 'closing the timing side-channel' framing to
  scope it to the operations covered, cross-reference the mta
  AliceEnd/AliceEndWC gap instead of implying full closure (F6)
- CHANGELOG.md: note the coverage broadening from secret-exponent-only
  to secret-operand operations (F7)
- CHANGELOG.md: restore a 'Not ported / deferred' bullet for the mta
  gap so the dangling '(see below)' cross-reference resolves again (F8)
- CHANGELOG.md: fix the benchmark command to actually run both
  BenchmarkExpCT and BenchmarkExpStandard, correct the mismatched
  sample-count claim (F10)
- common/constant_time.go: note in the reduceToPaddedBytes NOTE that
  round_5's rx (a field-prime coordinate) is the one operand reduced
  into group-order space, and why that's still safe (F3)
- common/constant_time_test.go: add BenchmarkMulCT/BenchmarkModInverseCT
  on a 256-bit-class modulus so the CHANGELOG's performance claim can
  cite the operations this PR's stack actually added, not just the
  2048-bit ExpCT benchmark (F5)
Addresses review findings on PR #23's constant-time hardening in
ecdsa/signing:

- round_5.go: correct the SECURITY comment's operand list -- m is the
  public message hash, not secret; name rx = R.X() instead, the
  operand that actually needed flagging (F9)
- constant_time_equiv_test.go (new): add targeted equivalence tests
  for round_3's thelta/sigma, round_4's thetaInverse, and round_5's si
  terms -- the five new MulCT/ModInverseCT call sites this PR's stack
  added had only e2e coverage before this (F13)
- constant_time_e2e_test.go: expand TestE2EConcurrentConstantTime's
  doc comment to name the Schnorr and round 3/4/5 CT paths it now
  also exercises, not just the pre-existing Paillier/MtA path (F14)
Addresses review findings on PR #23's constant-time hardening in
crypto/schnorr:

- constant_time_equiv_test.go: pin rand.Reader to a deterministic
  source (reset immediately before each proof generation, matching
  the crypto/paillier and crypto/mta sibling convention) and assert
  Alpha/T/U are bit-identical between the CT and non-CT paths, not
  just that both proofs verify -- catches a CT branch that silently
  diverges from, or falls back to, the non-CT computation (F12)
- constant_time_equiv_test.go: fix two doc-comment nits -- 'enabled by
  default in this package' should read 'in this library' (the default
  is process-wide, set in common/constant_time.go); the primitive
  equivalence note omitted ModInverseCT, which round-4's change relies
  on (F11)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants