Repository navigation
fix(crypto): extend constant-time coverage to Schnorr proofs and signing rounds 3-5 - #23
Conversation
…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
left a comment
There was a problem hiding this comment.
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.
| common.EnableConstantTimeOps() | ||
| defer common.DisableConstantTimeOps() |
There was a problem hiding this comment.
[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.
|
|
||
| --- | ||
|
|
||
| ## [1.4.0] - 2026-09-14 — BNB hardening integration |
There was a problem hiding this comment.
[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.
|
Both P2 findings addressed in 17e4771. Test CT mode. Confirmed the finding is exactly right: this branch sets Changelog. Also correct, and the |
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)
Addresses the project go-rand-v2 rule on new code. crypto/schnorr/ constant_time_equiv_test.go used math/rand.NewSource for its deterministic rand.Reader override; the sibling paillier/mta/dlnproof files pre-date the rule and stay on legacy math/rand, but new code on this fork uses math/rand/v2 (see PR #4243 for the established v2+PCG+fillRandom pattern). math/rand/v2 dropped the Read method on *Rand, so the v2 *Rand does not implement io.Reader and cannot substitute for crypto/rand.Reader directly. This commit adds a small seededReader type that adapts a v2 PCG to io.Reader (8-byte Little-Endian chunks, matching the byte granularity of the legacy generator the test was written against) and uses it via newSeededReader(1, 1). The two proof generations in each test reseed the same (1, 1) tuple so both CT and non-CT runs draw identical bytes from offset zero, preserving the bit-exact equivalence asserts. Behaviour, determinism, and the test's catching-power property (bit-exact Alpha/T/U identity between CT and non-CT under matched entropy) are unchanged. CHANGELOG notes the refactor under Breaking Change #8.
…curacies The reduceToPaddedBytes COVERAGE note claimed rx 'becomes the public signature r component within the same protocol round'; rx is actually stored in round 5 and only published in round 10 (finalize.go:60), so the variable-time big.Int.Mod(rx, N) runs while rx is still secret. Document the bounded leak and the constant-time-reduction follow-up. The COVERAGE comment's 'deliberately not added' phrasing implied this change deferred the mta AliceEnd gap, which predates it; reword to make the provenance explicit. The round_5 SECURITY comment now states rx is the raw x-coordinate, published as the signature r component in round 10 with the recovery-ID convention.
…chnorr sites NewCTModInt allocates a bigmod.NewModulus + sync.Pool + inverseExp per call; the curve order N is constant for the lifetime of a signing session, so the 5 CT sites (round_3/4/5, schnorr x2) were re-doing that setup on every Start()/proof. Add common.GetCTModInt(mod) returning a sync.Map-memoized shared *CTModInt and route the hot-path sites through it. NewCTModInt is retained for one-shot/test use.
BenchmarkMulCT/BenchmarkModInverseCT had no BenchmarkMulStandard/ BenchmarkModInverseStandard counterpart at the 256-bit size this fork's signing rounds actually use, so the changelog's 'no perf regression' claim was unmeasurable. Add the two paired baselines (rand.Prime(256)) and a PERFORMANCE note on ModInverseCT: the CT inverse is a full 256-bit Fermat modexp vs the standard path's extended-Euclidean inverse (~15-20x slower, the explicit price of the CT guarantee).
…ng gate - M4: TestRound4ThetaInverseCTEquivalence now also covers theta=0, the actually-reachable non-coprime value (round_4 accumulates theta mod N). - M6: TestRound5SiCTEquivalence now covers rx >= N (field coordinate R.X() can exceed the curve order N); pins CT/non-CT equivalence for an out-of-range rx. - M7: the schnorr 'byte-identical under matched entropy' comments now state precisely what is matched: the secret is fixed and only the commitment randomness is seeded. - M8: withCTMode doc notes it mirrors setPaillierCTTestMode/setMTAProofTestMode and why a shared cross-package helper is avoided. - M12: add TestMulCTTimingConsistency + TestModInverseCTTimingConsistency (log-only, -short-safe) so a future variable-time regression in the CT branch is observable. - M5: new TestRounds3to5CTWiring - a -short-safe integration gate that drives the real round 3/4/5 Start() methods with CT enabled and asserts the CT-produced temp fields (theta/sigma/thetaInverse/si/rx) plus that thetaInverse is reduced mod N. The primitive tests and the heavy TestE2EConcurrentConstantTime did not directly assert the if/else CT wiring.
…256-bit perf - M13: add a per-site table (file/func/CT-op/secret-operand) for the 7 CT branches extended by this PR, for audit traceability against BNB bnb-chain#328. - M11: add a prominent 'Known residual gap' bullet at the top of entry 8 - the mta AliceEnd Paillier-decrypt path remains variable-time and enabling CT by default does not close it. - M3: the 'Break type' section now quantifies the measured 256-bit costs (MulCT ~2x, ModInverseCT ~15-20x vs math/big) instead of the unsupported 'far cheaper' claim.
|
Merged into Verification:
Residual limits are recorded, not hidden: surrounding big.Int add/reduce remains variable-time; round 5's rx reduction is value-dependent; secret-scalar EC point multiplication remains non-CT; the AliceEnd sleep normalizer was intentionally not ported because Decrypt itself is now CT. Full report: agent-docs/pr-integration/23.md (local). |
Merge advanced origin/dev (includes #17/#23 CT hardening) into PR #14. Conflict choices: - No textual conflicts; clean automatic merge. - Go toolchain/deps and CT changes kept from origin/dev (#17 defaults/bounds, #23 CT schnorr-signing coverage). - Preserved PR #14 changes on top of dev: BaseParty fatal-error latch (abort/abortedWith) and keygen unmarshalVSSCommitment part-count guard in ecdsa/keygen/round_3.go.
Second integration pass: origin/dev advanced to 0efe90c (merged #17 ct-defaults-and-bounds and #23 ct-hardening-schnorr-signing-coverage after the first merge at 39e7d87). Auto-merged cleanly; key choices: - crypto/mta/proofs.go: disjoint regions survived both sides — #12 decoder guards (ProofBobWC 12-part arity, Bytes nil-receiver panics) plus #17/#23 CT changes on the same file. - ecdsa/signing/round_5.go: #12 R == nil guard retained below the #23 CT-context hunks; no conflict. - go.mod/go.sum: origin/dev dependency set retained.
Bring PR #9 current with dev after #17/#23/#12/#13/#15/#14/#26 landed. Conflicts resolved: - CHANGELOG.md: retain dev's PR #17/#23 composing-PR entry alongside PR #9; keep the rollout-only historical Bob compatibility (8ae2cf8) risk text. - crypto/mta/proofs.go: preserve #9 legacy vs security-v2 tau/gamma sampling branches; keep dev's #15 nil sampling guard on beta. P1 8ae2cf8 opt-in historical Bob compatibility (default tight maxT1, opt-in (q+1)*N bound) unchanged. - crypto/schnorr/schnorr_proof.go: combine #9 legacy/v2 challenge API split with dev's #23 constant-time MulCT branch for t = a + c*x. - ecdsa/keygen/round_3.go: keep #14 unmarshalVSSCommitment part-count guard and re-apply #9 mode-conditional round.proofContext(j) for FactorVerify. - ecdsa/signing/round_ct_wiring_test.go: select ProtocolModeSecurityV2 + nonce for the CT wiring test that constructs local signing parties. Auto-merged and preserved: dev toolchain (Go 1.25.7 / 1.26.8) and deps in go.mod/go.sum, #17 CT default-on behavior and fixed public-width ExpCTWithBitLen calls, #12 arity/input guards, #15 nil/bounds guards, #14 keygen changes, and dev workflows/tests. No transcript mode or CT path was dropped.
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-libcommit3709c25(folded into theBNB #328series) hardened the constant-time path in two more spots that this fork's stack never picked up:crypto/schnorr/schnorr_proof.go—NewZKProofWithSessionandNewZKVProofWithSessionmultiply the challengecagainst the secret witnessesx,s, andlto build the proof responsestandu. Without CT, thosemath/bigmultiplications leak witness bits through timing.ecdsa/signing/round_3.go/round_4.go/round_5.go—thelta = k·γ,sigma = k·w,thetaInverse = θ⁻¹ mod q, andsi = 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 existingMulCT/ExpCT/ModInverseCTprimitives — 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 incommon/constant_time.go(common.NewCTModInt(...).MulCT/.ModInverseCT, gated onIsConstantTimeEnabled()).Why no
ExpCTWithBitLenadaptation needed (vs. PR #10)The new code calls only
.MulCT(...)and.ModInverseCT(...)— never.ExpCT(...):MulCTtakes 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 constantmod-2(orphiN-1viaNewCTModIntWithPhi) — also a public, fixed-width value, not a caller-supplied variable-width secret. Confirmed bygrep -n '\.ExpCT(' crypto/schnorr/schnorr_proof.go ecdsa/signing/round_3.go ecdsa/signing/round_4.go ecdsa/signing/round_5.goreturning 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 thec·xandc·s/c·lmultiplications inNewZKProofWithSession/NewZKVProofWithSessionwith the CT branch.ecdsa/signing/round_3.go— wrapk·γandk·w(thelta/sigma computation) with the CT branch.ecdsa/signing/round_4.go— wrapθ⁻¹ mod qwithModInverseCT.ecdsa/signing/round_5.go— wrapm·kandrx·σ(thesiaddend pair) with the CT branch.crypto/schnorr/constant_time_equiv_test.go— new equivalence test covering bothNewZKProofandNewZKVProof: 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, anAddedentry 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)
The new
TestSchnorrProofCTVerifies/TestSchnorrVProofCTVerifiescases pass.Benchmark (from the local pre-push run on this stack)
Constant-time modexp measured at parity with the standard
math/bigpath on the same hardware: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
3709c25commit also hardenedcrypto/mta/share_protocol.go'sAliceEnd/AliceEndWCPaillier-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 incommon/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 portNewTimingProtection, 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.