Skip to content

v4: security fixes (12 audit findings + module bump)#332

Open
yycen wants to merge 34 commits into
bnb-chain:masterfrom
yycen:v4-security-fixes
Open

v4: security fixes (12 audit findings + module bump)#332
yycen wants to merge 34 commits into
bnb-chain:masterfrom
yycen:v4-security-fixes

Conversation

@yycen
Copy link
Copy Markdown
Contributor

@yycen yycen commented May 20, 2026

Summary

Major version bump from tss-lib/v3v4 carrying two waves of
security work plus a few module-hygiene commits.

Wave 1 — initial audit response (12 commits, original PR scope):
Paillier constant-time decrypt by default, ZK response-scalar bounds,
panic-DoS guards, NTilde ModProof shipped in keygen, mandatory
SessionNonce, |N| ≥ 2048 floors, unconditional MtA timing padding,
sentinel errors, etc.

Wave 2 — broader ZKP verifier audit pass (17 commits, follow-up
inside this same PR). Triggered by @levymeit's review comment flagging
wrong factors in the NTilde ModProof and the test-side SetNoProofMod()
bypass; both fixes opened up a systematic re-read of every Verify()
in crypto/. Each verifier is now audited for nil checks, range
checks, group membership (gcd(x, N) = 1), canonical-input
enforcement, EC subgroup membership, and Fiat-Shamir transcript
discipline.

Wave 1 — original audit findings (12)

Commit Subject
44a95b2 constant-time Paillier Decrypt enabled by default
3e6f49f upper bounds on ProofBob / RangeProofAlice / DLNProof / FacProof response scalars
72eaa3c panic-DoS guards + opt-in flag warnings + bitlen gate parity
91b8692 ScalarMult / ScalarBaseMult return nil instead of panicking
5946f07 NTilde ModProof shipped + verified during keygen
038f858 keygen / resharing now reject nil SessionNonce
9c81a5f KGRound1Message.ValidateBasic enforces |N| ≥ 2048
b858ae1 DGRound2Message1.ValidateBasic mirrors keygen
0b86e22 timing protection unconditional in AliceEnd / AliceEndWC
f8a894e mta.ErrRangeProofVerify sentinel for cleaner attribution
f8a5030 gitignore _local_only/
0cfe470 bump module path to v4

Wave 2 — ZKP verifier audit pass (17), grouped by area

Paillier / NTilde ModProof:

Commit Subject
6ba5e0d use safe-prime factors of NTilde for ModProof; remove SetNoProofMod() in e2e (fixes @levymeit's review comment)
d3e8502 paillier.Proof.Verify rejects prime pkN (Fermat bypass), nil inputs, non-unit pf[i]
89892a1 modproof.Verify validates N before big.Jacobi; 2048-bit floor; reject prime
a756377 ModProof Y_i uniform sampling via sampleYModN (expand-then-reject); closes 256-bit support set under 2048-bit N
726937a ECDSA resharing DGRound2Message1.nTildeModProof generate + verify, mirrors keygen 6ba5e0d

DLN / FacProof / MtA group-membership:

Commit Subject
0f67b50 helpers IsUsableUnknownOrderModulus / IsCanonicalGenerator; apply to DLN + FacProof (canonical h1, h2, P/Q/A/B/T)
087f6ae MtA RangeProofAlice / ProofBob{,WC}: canonical Paillier ciphertext + NTilde/h1/h2 generators + WC EC branch nil-safe + e == 0 guard
625b8bf ProofBobWC redundant gcd(V, N) dedupe + missing gcd(S, N) in RangeProofAlice
004f48e MtA IsInIntervalPositive consistency with FacProof
bbbc9e7 misc hardening (modproof gcd Z[i]/X[i]; VSS vjt nil guard; e == 0 in FacProof / RangeProofAlice / ProofBob; explicit pf.U.ValidateBasic; HashCommitDecommit nil guards)

EC point / curve handling:

Commit Subject
eff1777 ECPoint.IsIdentity rejects Edwards (0, 1); Schnorr same-curve check + nil-return guards
ba34365 ECPoint.IsInPrimeOrderSubgroup + ValidateInSubgroup; tss.HasCompositeCofactor; applied to Schnorr / VSS / MtA — rejects Ed25519 low-order points

Fiat-Shamir / transcript hygiene:

Commit Subject
4a9a88b per-proof-type Fiat-Shamir domain labels (tss-lib.v4.<proof>); 8 disjoint tags
3091f00 common.IsInIntervalPositive; paillier.Proof.Verify rejects negative k + curve-doc; rename RejectionSampleModReduceHash

VSS:

Commit Subject
76fa474 VSS Create / Verify / ReConstruct hardening (mod-q ID collision → error not panic; nil-share guard; Create boundary off-by-one; remove SetCurve mutation)

Party-ID / Lagrange invariants:

Commit Subject
dcb631d tss.NewParameters rejects mod-q duplicate PartyIDs (signing-time Lagrange DoS); PrepareForSigning panic → error
ba6d6f3 pre-push self-review: assertDistinctIDsModQ also rejects zero-residue Keys; GetRandomPositiveInt returns strictly (0, lessThan)

Breaking changes (justifying v4)

Original (Wave 1):

  • NewKGRound2Message2 takes a new required nTildeProof *modproof.ProofMod.
  • KGRound2Message2 wire format adds nTildeModProof (proto field 3).
  • Keygen / resharing round 1 error if Parameters.SessionNonce is unset; callers must call SetSessionNonce with a coordinator-assigned value.
  • (*ECPoint).ScalarMult and crypto.ScalarBaseMult return nil on identity / off-curve results instead of panicking. Explicit-error variants ScalarMultErr / ScalarBaseMultErr are added.
  • KGRound1Message / DGRound2Message1 ValidateBasic reject sub-2048-bit PaillierN / NTilde.
  • AliceEnd / AliceEndWC always apply 200ms timing-protection padding; the previous IsConstantTimeEnabled() gate is removed.
  • BobMid / BobMidWC return the new typed mta.ErrRangeProofVerify sentinel when the peer's RangeProofAlice rejects.

Additional (Wave 2):

  • NewDGRound2Message1 takes a new required nTildeModProof *modproof.ProofMod; wire format adds nTildeModProof (proto field 8).
  • {ecdsa,eddsa}/signing.PrepareForSigning returns an additional error (was panic on mod-q collision).
  • Fiat-Shamir transcript prefix changes for every proof type — proofs minted under v3 do not verify under v4 (consumed by the v4 module bump).
  • ModProof Y_i derivation changed to expand-then-reject sampling — same v3↔v4 wire incompatibility.
  • tss.NewParameters / NewReSharingParameters now panic up-front on duplicate-mod-q or zero-residue PartyID configurations (the panic was previously thrown deeper inside signing/prepare.go).
  • vss.Share.Verify no longer mutates the caller's *ECPoint via SetCurve; same-curve mismatch is now an explicit rejection.
  • common.RejectionSample is now a Deprecated: alias of common.ModReduceHash (semantics unchanged; old name was misleading).
  • common.GetRandomPositiveInt now returns strictly > 0; previously could return 0 with 1/lessThan probability.

Test plan

  • go build ./... — clean
  • go test -short -timeout 1200s ./... — all 18 packages pass
  • Module path consistency: go.mod, Makefile, all import sites at v4
  • Pre-push self security review (no new HIGH/MEDIUM findings)
  • Per-proof-type new rejection-path tests across modproof / facproof / dlnproof / schnorr / mta / vss / paillier / commitments / tss
  • CI green
  • Reviewer confirms downstream callers updated (any consumer of NewKGRound2Message2, NewDGRound2Message1, or signing.PrepareForSigning)

🤖 Generated with Claude Code

yycen and others added 12 commits May 20, 2026 23:17
Flip constantTimeEnabled from 0 to 1 so the existing bigmod-backed
constant-time path is the default for Paillier Decrypt and related
private-key operations. The previous opt-in default left signing
sessions exposed to a Kocher-class timing side channel that recovers
LambdaN and, via the MtA share decryption, an honest party's ECDSA
share — a P1 vulnerability per BNB Chain bug bounty (decryption vuln).

Add TestConstantTimeOpsEnabledByDefault as a regression guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three ZK Verify paths previously admitted attacker-controlled
multi-megabyte exponents into modular exponentiation, allowing any
participant to peg honest verifiers' CPU for seconds per session:

- crypto/mta/proofs.go (ProofBobWC.Verify): add upper bound
  S2/T2 < 2*q^3*NTilde derived from honest-prover sampling
  (e*rho + rhoPrm, rho < q*NTilde, rhoPrm < q^3*NTilde, e < q).
  Closes SRC-2026-771 / bnb-chain#15 ProofBob branch.

- crypto/mta/range_proof.go (RangeProofAlice.Verify): same
  2*q^3*NTilde upper bound for S2. Closes the same-shape DoS
  reported in v1.18 (RangeProofAlice has identical structure
  to ProofBob in the malformed-exponent path).

- crypto/dlnproof/proof.go: switch raw range check on T[i]/Alpha[i]
  to operate on the unreduced big.Int (was `Mod(p.T[i], N)` which
  only constrained the residue). Adds nil/h1/h2/N guards and
  removes the redundant nil check inside the inner loop.
  Closes B17 (DLNProof oversized T[i] amplification, ~200x on
  toy 1024-bit N) and B14 (nil-deref in same loop) together.

- crypto/facproof/proof.go (ProofFac.Verify): bound W1/W2 by
  2*q^3*NCap, V by 4*q^3*N0*NCap, Sigma by q*N0*NCap, all
  derived from Fig.28 sampling (alpha,beta < q^3*sqrt(N0);
  mu,nu < q*NCap; sigma < q*N0*NCap; r < q^3*N0*NCap; e < q).
  Closes B18 (FacProof W1/W2/V amplification, ~165x on toy
  2048-bit NCap).

Production tests added in proof_test.go (facproof) and
range_proof_test.go (mta) assert oversized response scalars are
rejected before any modexp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tightens six paths previously prone to panics, wire-reachable
malformed scalars, or unauthenticated parameters:

- ecdsa/signing/round_4.go: nil guard on thetaInverse after
  ModInverse, preventing the round_5.go ScalarMult nil-deref
  panic when malicious peers craft theta values summing to 0
  mod q. Closes SRC-2026-759 / bnb-chain#12.

- ecdsa/signing/round_9.go: change `!ok && len(values) != 4`
  to `!ok || len(values) != 4` so a peer cannot ship a 3-secret
  decommitment that survives the guard and panics at
  `values[3]` out-of-bounds. Closes B13.

- crypto/schnorr/schnorr_proof.go: ZKProof.Verify and
  ZKVProof.Verify now reject zero-scalar T/U (and on-curve
  validate the public point) BEFORE entering ScalarMult.
  Closes B16 / SRC-2026-776 Path A (wire-reachable
  []byte{0x00} → ScalarBaseMult(0) panic) and the off-curve
  point sub-case (B15).

- crypto/vss/feldman_vss.go: Share.Verify rejects zero/over-q
  shares, nil fields, and off-curve verifiers BEFORE the
  ScalarBaseMult call that produces the panic. Closes B19
  (wire-reachable Share=[0x00] → ScalarBaseMult(0) panic).

- ecdsa/resharing/round_4_new_step_2.go: enforce
  PaillierN.BitLen() == 2048 and NTilde.BitLen() == 2048,
  bringing resharing in line with keygen round_2. Closes B21.

- tss/params.go: SetNoProofMod and SetNoProofFac now emit a
  warning log; struct comment marks them legacy compatibility
  only. Closes B20 (the unregistered NoProofMod counterpart of
  bnb-chain#9 NoProofFac) and addresses bnb-chain#9 deprecation messaging.

Production tests added across schnorr_proof_test.go and
feldman_vss_test.go validate that the new guards reject the
attacker payloads at the wire-level entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chain#5)

Replace the panic-on-error path in (*ECPoint).ScalarMult and
crypto.ScalarBaseMult with a nil return. Both functions previously
panicked when the curve produced an identity result (k ≡ 0 mod n)
or any other off-curve coordinate, dragging the entire host process
down via the wrapper's `panic(fmt.Errorf(...))`. The nil return
lets callers detect the degenerate case and abort the session
gracefully — the pattern adopted by the recent Schnorr (B16),
VSS (B19), and Round-4 ModInverse (bnb-chain#12 / SRC-2026-759) hardening
PRs, which had to layer upstream guards because of this panic
behavior.

Also add explicit-error variants `ScalarMultErr` and
`ScalarBaseMultErr` for internal callers that prefer to propagate
the off-curve error rather than the nil-on-error convention.

EightInvEight now short-circuits to nil when the first ScalarMult
returns nil, matching the rest of the API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a ModProof for each party's own NTilde to KGRound2Message2 so
the receiver can verify that NTilde is a Blum-integer product of
safe primes — closing the smooth-subgroup NTilde injection path
that the existing 2048-bit bit-length check cannot detect.

- protob/ecdsa-keygen.proto: new repeated bytes field
  `nTildeModProof` on KGRound2Message2 (proto field 3).

- ecdsa/keygen/round_2.go: each peer creates a second ModProof
  using LocalPreParams.{P, Q} (the safe primes of NTilde, NOT
  the Paillier SK primes) and includes it in the round-2 broadcast.

- ecdsa/keygen/round_3.go: verify the NTildeModProof against
  round.save.NTildej[j]. Backward compatible — peers shipping
  an empty NTildeModProof are accepted under the existing
  NoProofMod() compatibility branch with a warning log,
  matching the Paillier-N ModProof's compatibility handling.

- ecdsa/keygen/messages.go: NewKGRound2Message2 takes the new
  proof and writes it into the wire field; adds
  UnmarshalNTildeModProof.

Resharing path is unaffected (NTilde comes from the existing
keygen save data); the smooth-N concern there is mitigated by
the resharing round_4 bit-length gate added in B21.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nb-chain#13)

Previously ecdsa/keygen, eddsa/keygen, and ecdsa/resharing fell back
to ssidNonce = SetUint64(0) when the caller did not provide a
SessionNonce, causing every keygen run on a given peer set to share
the same SSID. Under SSID collision, an attacker who observes one
honest keygen could replay DLN / ModProof / FacProof from session A
into session B (specifically when the caller reuses preParams), per
the v1.11 audit of CVE-2022-47930's residual gap.

Hard-error rather than silently default — the caller is responsible
for coordinating a fresh, agreed-upon nonce for each keygen /
resharing run, as the GG20 session-binding doc has stated since the
SessionNonce API was introduced. Signing keeps its message-hash
fallback (per-message uniqueness is the right shape for that path).

Test fixtures in ecdsa/{keygen,resharing}/local_party_test.go,
eddsa/{keygen,resharing}/local_party_test.go are updated to call
params.SetSessionNonce(big.NewInt(1)) immediately after
NewParameters / NewReSharingParameters.

BREAKING: callers of NewLocalParty for keygen or resharing must
call params.SetSessionNonce(...) with a coordinator-assigned value
before starting the protocol. Callers that previously relied on the
implicit 0 fallback will receive a clear error message from round 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GG18 §3 (defect D3) requires PaillierN > q^8 for secp256k1 — i.e.
|N| >= 2048. Previously the only place this floor was enforced was
ecdsa/keygen/round_2.go via `BitLen() != paillierBitsLen` after
Unmarshal. Move the check into ValidateBasic so a 1-byte
PaillierN / NTilde is rejected at the message boundary — useful
for any caller that runs ValidateBasic without reaching round_2
(monitoring code, replay-detection, integration tests, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the keygen B5 fix on the resharing message-decode layer.
DGRound2Message1.ValidateBasic now rejects PaillierN / NTilde
below 2048 bits at the message boundary, in addition to the
existing bit-length check in resharing/round_4_new_step_2.go
(B21).

The previous NonEmptyBytes-only check matched the keygen behavior
that B5 just tightened; resharing should not be the weaker layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (B7)

Previously mtaTimingProtection.ProtectBigInt was applied only when
common.IsConstantTimeEnabled() returned true — the same flag that
governs the CT modular-exponentiation path. With bnb-chain#14, the flag now
defaults to true, but the response-time normalisation is a separate
correctness concern (it caps the observable latency of Paillier
Decrypt regardless of the modexp implementation) and must not be
contingent on caller opt-in.

Closes B7 in SECURITY_AUDIT_REPORT v1.22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…C-2026-723 / bnb-chain#10)

BobMid / BobMidWC previously returned errors.New(...) for a failed
RangeProofAlice verification, identical in shape to internal
arithmetic failures. round_2.go's error-attribution then blamed Pj
for everything, including failures that may have been caused by
the local party's own gamma / w inputs interacting with Pj's
peer-supplied pkA.

Introduce a sentinel `mta.ErrRangeProofVerify` that BobMid /
BobMidWC return when the peer's RangeProofAlice rejection is the
root cause. The signing round 2 attribution helper now wraps
errors with a clearer "peer RangeProofAlice rejected" message in
that case, and labels other arithmetic failures separately.

Both error paths still attribute the culprit to Pj because
HomoMult / HomoAdd / pkA-arithmetic failures most plausibly stem
from peer-supplied pkA; the distinction is in diagnostic clarity,
not blame routing. A full identifiable-abort framework (B9 / GG20
§3 IA) would route to the actual misbehaving party — that is left
for the long-term CGGMP21 migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
local_only/ holds copies of audit reports, PoC tests, and any
other untracked artefacts that should not enter the published
history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The session-bound NTilde ModProof, mandatory SessionNonce, hardened
ZK Verify bounds, ScalarMult panic-to-nil shift, and stricter
ValidateBasic semantics introduced in the previous ten commits all
break the v3 public API, requiring a major version bump per Go
semver:

- NewKGRound2Message2 gained a required nTildeProof parameter
- KGRound1Message / DGRound2Message1 ValidateBasic now reject N < 2048 bits
- Keygen / resharing now error if Parameters.SessionNonce is unset
- ScalarMult / ScalarBaseMult return nil instead of panicking
- AliceEnd / AliceEndWC apply timing protection unconditionally
- BobMid / BobMidWC surface a typed ErrRangeProofVerify sentinel
- The KGRound2Message2 wire format adds nTildeModProof (proto field 3)

Adjust go.mod, Makefile, and every internal import accordingly.
The Makefile MODULE value was inadvertently left at v2 in a prior
PR; it is restored to track go.mod (now v4).

Also gitignore _local_only/ for off-tree audit artefacts. The
leading underscore makes Go's `./...` skip the directory
automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hashdit-bot
Copy link
Copy Markdown

hashdit-bot Bot commented May 20, 2026

Pull Request Review

This PR performs a major upgrade from module path github.com/bnb-chain/tss-lib/v3 to v4 and applies a broad set of security hardening changes across ECDSA/EdDSA keygen, signing, resharing, and crypto proof verification logic. It enables constant-time behavior by default, enforces stricter input/range/null validation, introduces required session nonce checks for keygen/resharing to prevent replay, and adds NTilde modulus proofs in ECDSA keygen round messages and verification. It also includes safety behavior changes (e.g., scalar multiplication functions returning nil instead of panicking), typed MtA error attribution, and additional tests to cover malformed/edge-case rejection paths.

Sensitive Content

No sensitive content detected.

Security Issues

No serious security issues detected.


Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits.

yycen and others added 4 commits May 24, 2026 01:09
SortedPartyIDs.Less previously used `Cmp <= 0`, returning `true` for
both `Less(a,b)` and `Less(b,a)` when keys are equal — a violation of
Go's sort.Interface strict-weak-ordering contract that produces
undefined behaviour with sort.Sort. Tighten to `Cmp < 0`.

In the distinct-key case this is a behaviour-preserving change; the
bug surfaces only when duplicate keys are passed to SortPartyIDs.
Add an explicit duplicate-key panic at the entry of SortPartyIDs to
fail fast on caller-side configuration errors that would otherwise
produce subtle sort-order divergences.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add panic-on-construction checks for invalid threshold / partyCount
combinations: require partyCount >= 2 (a real threshold scheme has at
least two parties), threshold >= 1, and threshold < partyCount.
Without these checks, invalid configurations propagated silently and
surfaced as opaque panics deep in protocol execution (e.g. the
PrepareForSigning "index of two parties are equal" trap or the VSS
polynomial Create error path).

Failing at construction time gives callers an immediate, actionable
signal instead of a runtime crash buried in the protocol state
machine.

Two test fixtures (TestStartRound1Paillier, TestFinishAndSaveH1H2)
were using a degenerate n=1 / t=1 scaffold to exercise Paillier
prep in isolation; they now use n=2 / t=1 to satisfy the validation
without changing the actual prep behaviour being tested (fixtures
are overridden from disk shortly after).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tighten EdDSA Round 3 / finalisation against malformed partial
signatures:

- eddsa/signing/messages.go: SignRound3Message.ValidateBasic now
  rejects payloads exceeding 32 bytes. Previously NonEmptyBytes alone
  admitted >32-byte values, which then silently truncated inside
  bigIntToEncodedBytes / copyBytes and produced a scalar different
  from the one on the wire.

- eddsa/signing/finalize.go: explicitly reject sj outside [1, L-1]
  before bigIntToEncodedBytes; attribute the failure to the actual
  culprit Pj rather than producing an opaque aggregate-level
  "signature verification failed".

Both fixes are defensive: an attacker can still cause session abort
by refusing to participate, but they no longer get to exploit the
silent-truncation / no-blame paths to produce a degenerate verify
failure with no attribution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each of the six protocol drivers (ecdsa/{signing,keygen,resharing},
eddsa/{signing,keygen,resharing}) previously stored incoming peer
messages with unconditional overwrite. A peer could send a second
message of the same round-type and silently replace the originally-
stored value — defeating the GG18/GG20 commit-reveal binding intent
(the attack chain itself does not close, because the commitment
scheme is hiding and Round 2/3 outputs lock in the original gamma,
but the library's contract was: caller is responsible for replay
protection).

Each case arm now rejects a stored-then-different message from a
peer:

  selfIdx := p.PartyID().Index
  isDup := fromPIdx != selfIdx
  ...
  if isDup && p.temp.<round>s[fromPIdx] != nil &&
      !tss.IsSameMessage(p.temp.<round>s[fromPIdx], msg) {
      return dupErr()
  }
  p.temp.<round>s[fromPIdx] = msg

Two tss-lib semantics had to be preserved:

1. Round Start() pre-populates `temp.<round>s[self.Index]` with the
   party's own outgoing message before broadcasting. The check skips
   the slot when fromPIdx == self.Index so the party doesn't reject
   its own pre-stored content.

2. The resharing test dispatcher (and any real at-least-once
   transport) re-delivers identical messages to a party. The new
   tss.IsSameMessage helper compares wire-encoded bytes so legitimate
   redelivery is idempotent; only content-different replacement
   trips the error.

The pre-existing comment "this does not handle message replays. we
expect the caller to apply replay and spoofing protection" is
updated to reflect the now-library-enforced behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hashdit-bot
Copy link
Copy Markdown

hashdit-bot Bot commented May 24, 2026

Pull Request Review

This PR introduces a major-version bump from v3 to v4 and applies a broad set of security hardenings across ECDSA/EdDSA keygen, resharing, signing, and crypto proof verification logic. It tightens validation and range checks (including Paillier/NTilde bit-length floors, proof scalar bounds, and nil/input guards), makes timing protections and constant-time defaults safer, and adds duplicate-message replacement defenses in party message stores. It also extends keygen wire format and flow to include nTildeModProof verification, requires explicit session nonces for keygen/resharing, and regenerates protobuf outputs accordingly.

Sensitive Content

No sensitive content detected.

Security Issues

No serious security issues detected.


Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits.

Re-run gofmt across the tree to normalize generated *.pb.go files and
a struct field alignment in crypto/paillier/paillier.go that drifted
from canonical formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hashdit-bot
Copy link
Copy Markdown

hashdit-bot Bot commented May 24, 2026

Pull Request Review

This PR delivers a major v4 module migration and applies a broad set of cryptographic hardening changes across ECDSA/EdDSA keygen, signing, and resharing flows. The patch introduces stricter input validation (nil checks, scalar/range bounds, bit-length floors), replay/duplication protections in message storage, and stronger protocol requirements such as mandatory SessionNonce for keygen/resharing. It also updates crypto behavior to safer defaults (constant-time enabled by default, unconditional timing padding for MtA decryption), adds NTilde ModProof support/verification, and replaces several panic paths with safer error/nil-return handling.

Sensitive Content

No sensitive content detected.

Security Issues

No serious security issues detected.


Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits.

Comment thread ecdsa/keygen/round_2.go Outdated
// LocalPreParams stores NTilde = P*Q where P, Q are safe primes; use
// these same primes (NOT the Paillier SK primes) for the NTilde proof.
nTildeModProof, err = modproof.NewProof(ContextI, round.save.NTildei,
round.save.LocalPreParams.P, round.save.LocalPreParams.Q, round.Rand())
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

NTilde = (2p+1)(2q+1), but you passed Germain p, q into the mod proof.
Wrong factors, the proof would fail for honest parties.

The tests are still passing because e2e uses SetNoProofMod(), which skips this check. E2E should not set these noProof flags if you want proof verifications to run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks @levymeit — both observations were spot on, and both are fixed in commit 6ba5e0d (now pushed to this branch):

  1. Wrong factors: round_2.go now derives the safe primes (2p+1, 2q+1) from LocalPreParams.P/Q (which store the Germain primes, used for the DLN subgroup order) and passes those to modproof.NewProof for the NTilde proof. The same shape is applied to the resharing path's new nTildeModProof in 726937a.

  2. SetNoProofMod() in tests: removed from both ecdsa/keygen/local_party_test.go and ecdsa/resharing/local_party_test.go in the same commit, so e2e tests now exercise the modProof and facProof verification paths end-to-end.

yycen and others added 7 commits May 24, 2026 19:44
NTilde = (2p+1)(2q+1), but round_2 was passing LocalPreParams.P, Q —
the Germain primes p, q used for the DLN subgroup order — as the
factors of NTilde to modproof.NewProof. The proof was built from
wrong factors, so honest verifiers would reject it.

The bug was masked because the keygen and resharing e2e tests called
SetNoProofMod()/SetNoProofFac(), skipping proof generation and
verification entirely. Remove those flags so the proof paths are
exercised end-to-end, and derive the safe primes 2p+1, 2q+1 in
round_2 from the stored Germain primes.

Reported by @levymeit on PR bnb-chain#332.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-existing Verify only ran trial division up to 1000 against pkN
and checked the per-iteration equality xi == pf[i]^pkN mod pkN. By
Fermat's little theorem, every prime pkN > 1000 satisfies
x^pkN ≡ x (mod pkN) for all x ∈ Z_{pkN}*, so a malicious prover with
a prime modulus could set pf[i] = xs[i] (the verifier-derived
challenge) and pass without proving any factorization. The
TestProofVerifyRejectsPrimePkN test constructs exactly that proof.

Add up-front validation that pkN is non-nil, positive, odd, ≥2048
bits, and composite (ProbablyPrime(30) → reject), and that every
pf[i] is a non-nil unit in Z_{pkN}*. Also reject nil k / nil or
off-curve ecdsaPub so GenerateXs cannot panic or loop on malformed
inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…site

ProofMod.Verify called isQuadraticResidue (big.Jacobi) on W and N
before checking N's shape, so a malformed message with N == nil,
N <= 0, or even N would panic inside big.Jacobi. The oddness and
ProbablyPrime checks were also late, leaving an obvious DoS surface
on the verifier.

Move the N validation (nil, sign, odd, ≥ 2048 bits, composite via
Miller-Rabin 30 rounds) to the top of Verify so the proof is rejected
cleanly before any modular arithmetic runs. Drop the TODO comment
("add basic properties checker") that was tracking this gap. Add
TestVerifyRejectsMalformedN covering each rejected shape including
the prime-N case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add two cross-cutting validation helpers in common/:
- IsUsableUnknownOrderModulus(n, minBits): non-nil, positive, odd,
  composite (Miller-Rabin 30 rounds), and at least minBits long. Used
  to reject prime / undersized / malformed N and NCap before any
  modular operation that would otherwise panic or accept a trivially
  passing modulus.
- IsCanonicalGenerator(n, v): v in (1, n) and coprime to n. Stricter
  than IsNumberInMultiplicativeGroup which accepts v == 1.

Apply to DLN Proof.Verify:
- Require N to satisfy IsUsableUnknownOrderModulus(N, 2048).
- Require h1, h2, and each Alpha[i] to be canonical generators.
  Replaces the earlier (h mod N) check, which let non-canonical wire
  bytes h1+N pass even though their mod-N reduction landed in (1, N);
  the raw bytes are what downstream consumers see when they hash or
  compare h1/h2 outside the Verify boundary.

Apply to FacProof.Verify:
- Require N0 and NCap to satisfy IsUsableUnknownOrderModulus.
- Require s, t to be canonical generators of Z_NCap*, and s != t.
- Require prover-supplied commitments P, Q, A, B, T to be units of
  Z_NCap* (IsNumberInMultiplicativeGroup). The equality checks in the
  Sigma relation took these as raw big integers, so non-unit / zero
  / >= NCap values could otherwise pass without binding the prover to
  a real factorization commitment.

Add TestDLNVerifyRejectsMalformedInputs and extend the FacProof test
file with TestVerifyRejectsMalformedInputs covering each rejected
shape (prime modulus, h = 1, h = N, h shares factor, s == t, P = 0,
T = NCap, etc.). DLN test uses a sync.Once-cached safe-prime fixture
so each subtest reuses the parameters (~2s total instead of ~150s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add IsCanonicalPaillierCiphertext(c, N) to common/validation.go and
apply group-membership / canonical-encoding checks to both MtA
verifiers:

RangeProofAlice.Verify:
- pk.N and NTilde must satisfy IsUsableUnknownOrderModulus.
- h1, h2 must be canonical generators of Z_NTilde* and distinct.
- c must be a canonical Paillier ciphertext (0 < c < N², gcd(c,N)=1).
  Replaces the standalone gcd(c, N) check.

ProofBobWC.Verify / ProofBob.Verify:
- Same modulus / generator checks for pk.N, NTilde, h1, h2.
- c1 and c2 must both be canonical Paillier ciphertexts.
- In the with-check branch, reject e == 0 (consistent with Schnorr),
  enforce X.ValidateBasic() and same-curve as ec, and guard
  X.ScalarMult(e) and ScalarBaseMult against nil returns before the
  .Add() / .Equals() chain. Previously a malformed direct-API X or a
  zero challenge could panic with nil dereference inside the proof
  equality check.

Add TestRangeProofAliceVerifyRejectsMalformedInputs and
TestProofBobWCVerifyRejectsMalformedInputs covering each rejected
shape (prime moduli, h1 == h2, h1 == 1, c == 0, c ≥ N², etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For Weierstrass curves (secp256k1) the identity is the point-at-infinity
encoded as (0, 0), which fails the affine curve equation and is already
rejected by isOnCurve. For Edwards curves (Ed25519) the affine identity
is (0, 1) and IS on-curve, so it passed ValidateBasic untouched. A
malicious party could submit (0, 1) as a Schnorr commitment Alpha or as
the proven public key X in EdDSA keygen / signing message unmarshalling
(both go through crypto.NewECPoint), reducing the proof to trivial
"prove discrete log of identity is 0".

Changes:
- crypto/ecpoint.go: add IsIdentity() method covering Edwards (0, 1)
  and the (0, 0) infinity placeholder. ValidateBasic now additionally
  rejects identity. Honest paths in tss-lib never produce identity as
  a commitment (proven by the full e2e test suite still passing).
- crypto/schnorr/schnorr_proof.go: in ZKProof.Verify and ZKVProof.Verify
  add a same-curve check (tss.SameCurve) between Alpha / X and Alpha /
  V / R, plus nil-return guards around ScalarBaseMult / ScalarMult so
  degenerate cases return false rather than panic on a nil .Add chain.

Tests:
- TestIsIdentityAndValidateBasic covers Edwards (0, 1) rejection plus
  the (0, 0) placeholder.
- TestSchnorrEdwardsIdentityRejected exercises both Alpha = identity
  and X = identity rejection paths on tss.Edwards().
- TestSchnorrCurveMismatch covers cross-curve Alpha vs X rejection.

Low-order Edwards point rejection (cofactor-8 subgroup) is deferred:
the existing EightInvEight projection in VSS / round_3 already clears
the small-subgroup component for protocol-critical commitments, and a
standalone subgroup-membership check would cost a full ScalarMult by
the curve order per verification. Tracked as a follow-up in the audit
document.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProofBobWC.Verify checked gcd(V, pk.N²) at line ~273 and gcd(V, pk.N)
at line ~290. These are equivalent (N and N² share the same prime
factors), so the second was redundant — drop it.

RangeProofAlice.Verify missed the gcd(S, pk.N) check that its sibling
ProofBob.Verify already had. Honest S = r^e · beta mod N is a unit
(beta is sampled coprime to N, r is in Z_N*), so requiring it directly
matches the existing pattern and removes a small asymmetry between the
two MtA verifiers. Add a test that forges S = sk.P (a prime factor of
pk.N, so gcd > 1 but still inside the pk.N range) to confirm rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
yycen and others added 10 commits May 24, 2026 22:18
Clears the trivial P2/P3 backlog from the ZKP audit:

- modproof.Verify: gcd(Z[i],N)==1 and gcd(X[i],N)==1 for prover-supplied
  roots. Honest provers always produce units; the checks close a defense-
  in-depth gap. Covered by TestVerifyRejectsNonUnitZX.
- vss.Share.Verify: guard vjt = vs[j].ScalarMult(t) against nil before
  calling .Add to remove a latent nil-deref. (The earlier proposal to
  also enforce canonical 0 < share.ID < q was dropped: tss generates
  256-bit random PartyID keys and Ed25519's q ≈ 2^252, so most honest
  IDs exceed q. share.ID is a Lagrange x-coordinate reduced mod q
  downstream, so the existing `idModQ.Sign() == 0` rejection is the
  correct binding requirement.)
- facproof.Verify, mta.RangeProofAlice.Verify, mta.ProofBob/WC.Verify:
  reject e == 0 after Fiat-Shamir challenge derivation, matching the
  Schnorr verifier. Negligible under Fiat-Shamir but a zero challenge
  collapses the Σ-relation binding.
- mta.ProofBobWC.Verify: explicit pf.U.ValidateBasic() in the WC branch.
  The deserialization path already validates via NewECPoint, but direct
  API consumers can bypass that.
- commitments.HashCommitDecommit.Verify: nil-receiver guard and per-
  element nil check for the decommitment array. common.SHA512_256i
  panics on nil *big.Int entries (unlike _TAGGED). Covered by
  TestVerifyHandlesMalformedInputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tss.SortPartyIDs deduplicates PartyIDs by raw KeyInt() bytes, but every
downstream consumer (vss.Shares.ReConstruct, ecdsa/eddsa signing
prepare.go Lagrange coefficient computation) treats the key as
`KeyInt() mod q`. A malicious party that registers with key =
honest_key + q has byte-distinct but mod-q-equal keys; SortPartyIDs
lets the configuration through, then signing prepare.go computes
`ModInverse((ksj - ksi) mod q, q) = ModInverse(0, q) = nil` and panics
on the next `modQ.Mul`. The result is a remote-triggerable signing-time
DoS.

Two layers of fix:

1) tss/params.go: NewParameters and NewReSharingParameters now panic
   up-front if any pair of party keys collides mod q. The check is a
   one-pass scan keyed on `KeyInt() mod q .Text(16)`. This catches the
   bad configuration before any protocol round runs and gives a clear,
   locally-attributable error pointing at the two colliding keys.

2) {ecdsa,eddsa}/signing/prepare.go: PrepareForSigning previously
   panicked on `ksj.Cmp(ksi) == 0` (raw-byte equality only). Convert
   to a returned error and compare residues `ksj mod q` vs `ksi mod q`
   so direct API consumers that bypass tss.NewParameters still get a
   clean error instead of a deep ModInverse panic. Callers in
   {ecdsa,eddsa}/signing/round_1.go and ecdsa/resharing/round_1 and
   eddsa/resharing/round_1 updated to propagate the error.

Tests in tss/params_test.go cover the rejected configuration (k vs
k+q) and the honest configuration (k vs k+1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four bite-sized items from the ZKP audit cleanup pass:

1) common.IsInIntervalPositive(b, bound) helper added (open lower
   bound `(0, bound)`, vs IsInInterval's `[0, bound)`). Applied to
   FacProof's six prover-supplied range-check fields (Z1, Z2, W1, W2,
   Sigma, V); the honest prover samples all six via GetRandomPositiveInt,
   so b == 0 is never produced by the spec and is now rejected.

2) paillier.Proof.Verify rejects k.Sign() < 0. GenerateXs hashes
   k.Bytes() (absolute value), so a negative k would alias to the same
   transcript as +|k| and could silently produce ambiguous proofs.

3) paillier.Proof.Verify docstring now states that the caller is
   responsible for verifying ecdsaPub.Curve() matches the expected
   curve. The verifier itself only consults ecdsaPub through
   ValidateBasic (on-curve relative to the point's own stored curve);
   external reuse outside the keygen flow should add an explicit
   tss.SameCurve check up-front.

4) common.ModReduceHash is the new primary name for what was
   misleadingly called common.RejectionSample. The function is a
   one-line `eHash.Mod(eHash, q)` — not rejection sampling. The
   updated docstring explains the actual bias bound and points callers
   that need a uniform sample to paillier.GenerateXs (which does real
   rejection sampling). RejectionSample is kept as a Deprecated alias.
   All internal call sites in crypto/{facproof,modproof,mta,schnorr}
   migrated to ModReduceHash to clear the deprecation warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every proof type previously used the caller-supplied Session as the
sole tag input to SHA512_256i_TAGGED. Because each proof's transcript
has a different arity / length-encoded shape, cross-proof collisions
were not an immediate concern, but the lack of explicit domain
separation made auditing harder and would have left a trap for any
future proof that happens to align with an existing transcript shape.

Each proof package now prepends a stable, hierarchical FS domain tag
to Session before hashing:

  tss-lib.v4.modproof          (modproof.NewProof / Verify)
  tss-lib.v4.facproof          (facproof.NewProof / Verify)
  tss-lib.v4.dlnproof          (dlnproof.NewDLNProof / Verify)
  tss-lib.v4.schnorr.zk        (schnorr.NewZKProof / ZKProof.Verify)
  tss-lib.v4.schnorr.zkv       (schnorr.NewZKVProof / ZKVProof.Verify)
  tss-lib.v4.mta.range-alice   (mta.ProveRangeAlice / RangeProofAlice.Verify)
  tss-lib.v4.mta.bob           (mta.ProveBob / ProofBob.Verify variant)
  tss-lib.v4.mta.bob-wc        (mta.ProveBobWC / ProofBobWC.Verify variant)

The tags are inlined into a per-package `fsSession*` helper alongside
the existing constants. The "bob" vs "bob-wc" split mirrors the
existing if-X-nil dispatch inside ProofBob{,WC}, making the two MtA
Bob variants disjoint even at the FS tag level.

This is wire-incompatible with v3 by design — proofs minted under the
v3 module path will not verify under v4. Consumed by the existing v4
module bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both NewProof and Verify previously derived each Fiat-Shamir challenge
Y_i as `ModReduceHash(N, SHA512_256i_TAGGED(...))`. For 2048-bit
NTilde this was effectively a no-op reduction — the 256-bit hash
output fit entirely inside [0, 2^256) ⊂ [0, N), so Y_i lived in a
roughly 2^-1792 fraction of the intended Z_N domain. The
Paillier-Blum proof's formal soundness assumes Y <- Z_N (or J_N);
collapsing the support to a 256-bit subset does not introduce a known
attack against the 80-iteration repetition, but it's a textbook-vs-
implementation gap that a reviewer should not have to defend.

Replace with `sampleYModN`, an expand-then-reject sampler:

1. Seed = SHA512_256i_TAGGED(fsSession(Session), transcript...), padded
   to 32 bytes.
2. For counter = 0, 1, 2, ... expand `ceil(N.BitLen()/256)` blocks via
   counter-mode SHA512_256(seed, counter, blockIdx).
3. Mask the concatenated output to exactly N.BitLen() bits.
4. Accept if candidate < N; otherwise retry with next counter.

Because N ∈ (2^{bitlen-1}, 2^bitlen] after masking, the rejection
probability per attempt is at most 1/2 — practical loop length is
1–2 iterations. The pad-to-32-byte step makes the seed → block
function invariant of leading-zero quirks in big.Int.Bytes(). The
counter is 4 bytes (uint32, big-endian) and the block index is a
single byte, so they don't ambiguously concatenate with the seed.

Wire-incompatible with v3 by design — proofs minted under the v3
module path will not verify under v4. Consumed by the existing v4
module bump. Existing modproof e2e tests (TestMod, TestModCT,
TestAttackMod, TestVerifyRejectsNonUnitZX, TestVerifyRejectsMalformedN)
all still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch RangeProofAlice.Verify and ProofBob{,WC}.Verify range checks
on prover-supplied Z / U / W / S / ZPrm / T / V from
`common.IsInInterval(_, bound)` (closed lower bound `[0, bound)`) to
`common.IsInIntervalPositive(_, bound)` (open lower bound
`(0, bound)`).

All six values are derived from group operations on units in
`Z_NTilde*` (Z, ZPrm, T, W), `Z_{N²}*` (U, V), or `Z_N*` (S), so an
honest prover never produces 0 — the iteration that would produce 0
has negligible probability and is anyway already caught downstream by
the gcd checks added in `0f67b50`/`087f6ae`/`625b8bf`. Tightening the
lower bound now matches the policy applied to FacProof in `3091f00`
and removes a small asymmetry the audit doc had flagged as a
candidate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For Ed25519 (cofactor 8) on-curve membership is strictly weaker than
prime-order subgroup membership: there are 8 small-order points
(orders 1, 2, 4, 8) that satisfy the curve equation and would pass
ECPoint.ValidateBasic, but lie outside the prime-order subgroup. A
malicious party can submit such a point as a Schnorr commitment, VSS
share, or MtA WC commitment to mount small-subgroup-style attacks.

Add ECPoint.IsInPrimeOrderSubgroup() — computes `[curve.N] * p` and
checks the result is identity. For prime-order curves (secp256k1,
NIST family, cofactor 1) this is structurally guaranteed by IsOnCurve
and the explicit check costs only one extra ScalarMult; for Ed25519
the check is load-bearing.

Add ECPoint.ValidateInSubgroup() as the stricter sibling of
ValidateBasic, used wherever an EC point arrives from attacker-
controlled wire bytes:

- crypto/schnorr/schnorr_proof.go: ZKProof.Verify (X, Alpha) and
  ZKVProof.Verify (V, R, Alpha).
- crypto/vss/feldman_vss.go: every vs[j] commitment.
- crypto/mta/proofs.go: ProofBobWC.Verify's X and pf.U in the with-
  check branch.

To avoid paying the ScalarMult on prime-order curves where the check
is structurally redundant, ValidateInSubgroup short-circuits via
tss.HasCompositeCofactor(p.curve). tss/curve.go gains the per-curve
cofactor classification (secp256k1 → false, ed25519 → true, unknown
→ true conservatively).

Closes the explicitly-deferred 🔥 P1 item from the ZKP audit. The
EightInvEight() projection in eddsa/{keygen,signing,resharing}/
round_* still runs at the protocol level, so the new check is a
defense-in-depth backstop for direct API callers and for code paths
not covered by EightInvEight (Schnorr Alpha, MtA pf.U). Performance
cost: ~30-50µs per Ed25519 point validated; ECDSA paths are unchanged.

Test in crypto/ecpoint_test.go covers a synthesized Ed25519 order-2
point (0, p-1), a prime-order point, the identity (which is rejected
by ValidateBasic before the subgroup check), and a secp256k1 sanity
case. Full ECDSA / EdDSA e2e keygen / resharing / signing suites
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six audit follow-ups in the VSS module, all in crypto/vss:

1. Shares.ReConstruct: reject mod-q ID collision with a returned error
   instead of triggering ModInverse(0, q) → nil → modN.Mul nil-receiver
   panic. Same shape as the dcb631d signing/prepare.go fix. Not on the
   production code path (no call sites outside tests) but exported, so
   external callers shouldn't be able to crash the library by submitting
   shares with k vs k+q IDs.

2. Shares.ReConstruct: reject empty shares slice up-front instead of
   panicking on shares[0].Threshold index access for non-nil but empty
   input.

3. Shares.ReConstruct: per-share validation — non-nil share, non-nil
   ID/Share, and consistent threshold across all shares. Without these
   the Lagrange loop would silently mix shares of different threshold
   polynomials or nil-deref.

4. Create: bound check num < threshold+1 (was num < threshold). A
   degree-`threshold` polynomial needs at least threshold+1 distinct
   shares to reconstruct; the old check admitted num == threshold which
   produced an unreconstructable share set. Production callers (keygen
   round_1, resharing round_1_old_step_1) already satisfy the tighter
   bound via tss.NewParameters' partyCount > threshold invariant, so no
   honest path regresses.

5. Create: reject nil ec and nil rand up-front; the previous code would
   nil-deref ec.Params().N or panic inside GetRandomPositiveInt.

6. Share.Verify: replace four `vs[j].SetCurve(ec)` mutations with an
   explicit `tss.SameCurve(vs[j].Curve(), ec)` gate. The earlier code
   silently re-attached ec to whatever curve the caller had assigned to
   vs[j], which hid curve-confusion mistakes and leaked a mutating
   side-effect into the caller's ECPoint instances. The new check
   rejects mismatched-curve vs[j] up-front; honest paths (production
   keygen always passes vs constructed via UnFlattenECPoints(ec, ...))
   are unaffected.

New tests cover each rejection path: nil ec/rand, num == threshold
boundary, curve mismatch, empty shares, nil share element, mixed
threshold, and the k-vs-k+q mod-q collision case (with NotPanics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ECDSA resharing was the last ⚠️ P1 item in the ZKP audit. Keygen
already linked peer NTilde to a Blum-integer ModProof in `6ba5e0d`
(via `KGRound2Message2.nTildeModProof`, verified in
ecdsa/keygen/round_3.go before NTildej is saved). Resharing didn't
carry the same binding: `DGRound2Message1` shipped Paillier ModProof
+ NTilde + H1 + H2 + two DLN proofs, and round_4_new_step_2 saved
NTildej / H1j / H2j after the Paillier and DLN checks — leaving
NTilde itself only "locally plausible" rather than proven to be the
safe-prime product the QR-group argument assumes.

This commit mirrors the keygen pattern in resharing:

- protob/ecdsa-resharing.proto: `DGRound2Message1` gains a
  `repeated bytes nTildeModProof = 8` field. Optional on the wire so
  v3-vintage peers that omit it can be accepted under
  `params.NoProofMod()`; v4 default mode (the production setting in
  this branch's e2e test, see `0f67b50` removing the resharing
  SetNoProofMod) requires the proof.
- ecdsa/resharing/messages.go: `NewDGRound2Message1` takes the new
  `nTildeModProof` argument; `UnmarshalNTildeModProof` mirrors the
  keygen helper.
- ecdsa/resharing/round_2_new_step_1.go: generate the proof
  alongside the Paillier ModProof, using the safe-prime factors
  derived from `LocalPreParams.P/Q` (same fix shape as keygen's
  `6ba5e0d`).
- ecdsa/resharing/round_4_new_step_2.go: verify the proof in the
  same goroutine as the Paillier ModProof, attributing failures to
  the peer via `paiProofCulprits`. Honors `NoProofMod()` for v3
  backward compatibility.

Other regenerated `.pb.go` diffs in the commit are protoc-tooling
churn from `make protob` rebuilding all protos; no semantic changes
to other messages. Full e2e ecdsa/resharing and eddsa/resharing
suites still pass.

Closes the last ⚠️ P1 row in `_local_only/ZKP_BASIC_PROPERTY_AUDIT.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two self-review findings on top of the pre-push review pass:

1. tss/params.go assertDistinctIDsModQ now also panics when any
   PartyID's KeyInt() mod q is exactly 0 (previously it only caught
   collisions between two distinct keys with the same residue). A
   party with KeyInt() ≡ 0 mod q is fatal: in Shamir secret sharing
   f(0) is the secret, so the polynomial evaluated at that party's
   "x-coordinate" gives them the raw secret outright. Even if keygen
   blocks the bad ID via vss.Create's CheckIndexes, the signing path
   (PrepareForSigning in ecdsa/signing/prepare.go) would, when the
   quorum includes the zero-residue party as one of the j != i others,
   compute iota = ksc * ModInverse(ksc - ksj) = 0, then
   bigWj.ScalarMult(0) returns nil, then the next ScalarMult panics.
   Direct API consumers loading legacy LocalPartySaveData and going
   straight to signing previously bypassed the vss.Create check;
   asserting at the NewParameters layer closes that path.

2. common/random.go GetRandomPositiveInt now strictly returns a value
   in the open interval (0, lessThan). The previous implementation
   sampled from [0, lessThan), so the name's "Positive" claim was
   technically false and verifiers using IsInIntervalPositive (added
   in 3091f00 / 004f48e) on these sampler outputs had a
   1/lessThan ≈ 2^-256 chance of incorrectly rejecting an honest
   proof. Probability was negligible but the API/comment contract was
   inconsistent; the loop now rejects try.Sign() == 0 alongside the
   existing try >= lessThan rejection. Also reject lessThan <= 1
   up-front (no value exists in (0, 1)).

Tests in common/random_test.go and tss/params_test.go cover the new
rejection paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

3 participants