Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ jobs:

- name: Run Tests
run: make test_unit

- name: Verify bidirectional legacy transcript vectors
run: ./testdata/legacy_transcript/verify.sh
93 changes: 70 additions & 23 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,67 @@ belongs to PR #2 (the base BNB hardening integration) unless it is tagged with a
- **PR #5** — removal of EdDSA and ECDSA resharing protocols (stacked on PR #4).
- **PR #6** — remaining BNB cryptographic hardening follow-ups (stacked on PR #5).
- **PR #7** — signing round-9 decommitment validation and related fixes (stacked on PR #6).
- **PR #9** — immutable per-party legacy/security-v2 transcript selection and
exact historical legacy compatibility (stacked on PR #7).

### ⚠️ Compatibility — read before upgrading

**This release is a protocol/wire compatibility break and must be rolled out as a
coordinated protocol upgrade.** Fiat-Shamir proof challenges now use tagged hashing,
session context, and fixed-width message encoding. Parties running pre-upgrade code
**cannot** interoperate with upgraded parties in the same keygen or signing ceremony,
even though the Go API remains source-compatible.

Do not mix pre- and post-upgrade parties in one ceremony; all participants must run the
upgraded build simultaneously.

Two new caller obligations are enforced at runtime (see Breaking Changes 1 and 2):
1. Set a per-ceremony session nonce before `Start()`.
2. Pass a positive `fullBytesLen` to every signing constructor.
**Security-v2 is a protocol/wire compatibility break.** Its Fiat-Shamir proof
challenges use tagged hashing, session context, and fixed-width message
encoding, so a security-v2 party cannot interoperate with a historical party.

PR #9 adds an explicit incremental-rollout mode. A party in
`ProtocolModeLegacy` reproduces the historical untagged challenges, including
the exact Schnorr ZK/ZKV `HashToN` input ordering and modular reduction, and can
therefore share a legacy ceremony with the pre-upgrade binary. A ceremony must
still be homogeneous by transcript mode: mixing legacy and security-v2 in one
run fails cryptographic verification, and there is no negotiation, downgrade,
fallback, or retry between modes.

Three caller obligations are enforced at runtime (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.

#### PR #9. Explicit dual-mode transcript contract
- **What:** ECDSA keygen/signing parameters require an explicit immutable
`ProtocolModeLegacy` or `ProtocolModeSecurityV2`. Legacy round code calls the
historical no-session proof APIs; security-v2 round code calls the tagged,
session-bound APIs. `schnorr.NewZKProof` / `NewZKVProof` and `Verify` reproduce
the exact `2e712689` `HashToN` transcript. `New*WithSession` rejects nil and
non-nil empty sessions, and `VerifyWithSession` returns false for either, so
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.
- **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
refuses one.
- **Motivation:** Preserve byte-compatible pre-cutover ceremonies during an
incremental binary rollout without weakening the post-cutover session-bound
transcript or allowing an in-flight downgrade.
- **Provenance:** `threshold-original`, PR #9. Historical oracle:
`threshold-network/tss-lib@2e712689cfbeefede15f95a0ec7112227d86f702`.
- **Qualification:** `crypto/schnorr/testdata/legacy_transcript_vectors.json`
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.
Cross-verification uses the historical formulas or the module pinned to
`2e712689`; it does not infer compatibility from two parties running the new
implementation.

### Breaking changes

#### 1. Session nonce is now mandatory and fails closed
- **What:** ECDSA keygen and ECDSA signing now require a positive session nonce. Each
#### 1. Session nonce is mandatory in security-v2 and forbidden in legacy
- **What:** ECDSA keygen and ECDSA signing in security-v2 require a positive session nonce. Each
protocol's `Start()` (round 1) returns an error if `Parameters.SetSessionNonce` /
`SetSessionNonceBytes` was not called, e.g.
`"keygen requires tss.Parameters.SetSessionNonce(...) before Start"`
Expand All @@ -69,10 +110,12 @@ Two new caller obligations are enforced at runtime (see Breaking Changes 1 and 2
fail-closed-with-no-fallback decision being `threshold-original`. (Note: the threshold
base had no SSID machinery at all; the "previous zero / `SHA512_256(messageBytes)`
fallback" described in upstream history never shipped in this fork's base.)
- **Migration:** Before `Start()`, on the constructing goroutine, call
- **Migration:** Select `ProtocolModeSecurityV2`, then before constructing the
local party call
`params.SetSessionNonce(<unique positive *big.Int>)` or
`params.SetSessionNonceBytes(<>=16-byte high-entropy session ID>)`. All parties in a run
must agree on the same value.
must agree on the same value. A legacy party selects `ProtocolModeLegacy` and
must not set a nonce.

#### 2. `fullBytesLen` is required at runtime for signing
- **What:** The ECDSA signing constructors (`NewLocalParty`, `NewLocalPartyWithKDD`) accept
Expand All @@ -93,20 +136,24 @@ Two new caller obligations are enforced at runtime (see Breaking Changes 1 and 2
- **What:** Challenge derivation for DLN (`crypto/dlnproof`), Schnorr
(`crypto/schnorr`), MtA `ProofBob`/`ProofBobWC` (`crypto/mta/proofs.go`), and
`RangeProofAlice` (`crypto/mta/range_proof.go`) now uses length-delimited tagged hashing
(`common.SHA512_256i_TAGGED`) plus optional session context. **The challenge bytes change
unconditionally** — even on the default/nil-session path — because the underlying hash
construction itself changed. Per-party proof contexts also append a fixed-width `uint64`
party index so party 0 no longer collapses to the bare SSID.
- **Break type:** Wire/protocol (old and new proofs do not cross-verify).
(`common.SHA512_256i_TAGGED`) plus session context in security-v2. The
no-session legacy path retains the exact historical construction and input
list; the challenge bytes change only when security-v2 is selected. Per-party
security-v2 proof contexts also append a fixed-width `uint64` party index so
party 0 no longer collapses to the bare SSID.
- **Break type:** Wire/protocol in security-v2 (legacy and security-v2 proofs do
not cross-verify). The explicit legacy mode is byte-compatible with the
historical transcript.
- **Motivation:** Domain separation binds each proof to its session/sub-protocol context,
defeating cross-protocol and cross-session proof replay. The MtA path additionally binds
`NTilde, h1, h2` into the transcript so a malicious verifier cannot swap ring-Pedersen
parameters.
- **Provenance:** `BNB #252` (`3d95e54`), `BNB #256` (`1a14f3a`), `BNB #257` (`ff989bf`,
tagged hashing), `BNB b59ed36` (DLN/MtA session context); party-index append is
`threshold-original`.
- **Migration:** Coordinated network-wide upgrade; no mixed old/new parties. Any persisted
pre-upgrade proofs are not re-verifiable.
- **Migration:** Use legacy for work anchored before the coordinated cutover and
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)
- **What:** `ModProof`/`ModVerify` and `FactorProof`/`FactorVerify`
Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,48 @@ When you build a transport, it should offer a broadcast channel as well as point

Within your transport, each message should be wrapped with a **session ID** that is unique to a single run of the keygen or signing rounds. This session ID should be agreed upon out-of-band and known only by the participating parties before the rounds begin. Upon receiving any message, your program should make sure that the received session ID matches the one that was agreed upon at the start.

The same session ID should be bound into the protocol parameters before constructing local parties:
The proof-transcript mode must be selected explicitly before constructing an
ECDSA keygen or signing party. New ceremonies should use the session-bound
security-v2 mode and bind the same session ID into the protocol parameters:

```go
params := tss.NewParameters(curve, ctx, thisParty, len(parties), threshold)
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.

`ProtocolModeLegacy` exists only for coordinated compatibility with peers that
use the historical untagged GG20 transcript. A ceremony must be homogeneous:
legacy and security-v2 parties cannot interoperate. The selected mode is frozen
when the local party is constructed and cannot change while the protocol is in
flight.

The low-level proof APIs follow the same unambiguous split. The historical
generic APIs (for example `schnorr.NewZKProof` / `ZKProof.Verify`) reproduce the
exact pre-hardening `HashToN` transcript. Their `WithSession` counterparts are
security-v2-only, require a non-empty session, and never interpret nil or an
empty slice as legacy. DLN, MtA range/Bob/BobWC, ModProof, and FactorProof retain
their source-compatible optional session argument: omitting the argument is
legacy, while supplying one requires a non-empty value and selects the tagged
transcript. The legacy Bob/BobWC path also restores the historical `tau` and
relatively-prime Paillier `gamma` sampling ranges; security-v2 retains its
hardened `q^3*N-tilde` and `q^7` ranges.

The checked-in bidirectional compatibility oracle and release test commands
are:

```sh
./testdata/legacy_transcript/verify.sh
go test ./crypto/schnorr ./ecdsa/signing ./ecdsa/keygen -count=20
go test ./... -count=1
```

On slower builders, preserve the package list and repeat count while extending
only Go's harness timeout, for example `-timeout=30m`. The default ten-minute
timeout can expire while the keygen package is still generating safe primes.

Additionally, there should be a mechanism in your transport to allow for "reliable broadcasts", meaning parties can broadcast a message to other parties such that it's guaranteed that each one receives the same message. There are several examples of algorithms online that do this by sharing and comparing hashes of received messages.

Timeouts and errors should be handled by your application. The method `WaitingFor` may be called on a `Party` to get the set of other parties that it is still waiting for messages from. You may also get the set of culprit parties that caused an error from a `*tss.Error`.
Expand Down
11 changes: 9 additions & 2 deletions crypto/dlnproof/proof.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func NewDLNProof(h1, h2, x, p, q, N *big.Int, session ...[]byte) *Proof {
alpha[i] = modN.Exp(h1, a[i])
}
msg := append([]*big.Int{h1, h2, N}, alpha[:]...)
c := common.SHA512_256i_TAGGED(fsSessionDLNProof(Session), msg...)
c := proofChallenge(Session, msg...)
t := [Iterations]*big.Int{}
cIBI := new(big.Int)
for i := range t {
Expand Down Expand Up @@ -87,7 +87,7 @@ func (p *Proof) Verify(h1, h2, N *big.Int, session ...[]byte) bool {
}
}
msg := append([]*big.Int{h1, h2, N}, p.Alpha[:]...)
c := common.SHA512_256i_TAGGED(fsSessionDLNProof(Session), msg...)
c := proofChallenge(Session, msg...)
cIBI := new(big.Int)
for i := 0; i < Iterations; i++ {
cI := c.Bit(i)
Expand All @@ -102,6 +102,13 @@ func (p *Proof) Verify(h1, h2, N *big.Int, session ...[]byte) bool {
return true
}

func proofChallenge(session []byte, values ...*big.Int) *big.Int {
if session == nil {
return common.SHA512_256i(values...)
}
return common.SHA512_256i_TAGGED(fsSessionDLNProof(session), values...)
}

func optionalSession(session [][]byte) []byte {
if len(session) == 0 {
return nil
Expand Down
17 changes: 17 additions & 0 deletions crypto/dlnproof/proof_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,25 @@ package dlnproof
import (
"math/big"
"testing"

"github.com/bnb-chain/tss-lib/common"
)

func TestLegacyChallengeMatchesHistoricalTranscript(t *testing.T) {
values := []*big.Int{
big.NewInt(2),
big.NewInt(3),
big.NewInt(5),
big.NewInt(7),
}

expected := common.SHA512_256i(values...)
actual := proofChallenge(nil, values...)
if expected.Cmp(actual) != 0 {
t.Fatalf("legacy challenge changed: expected %v, got %v", expected, actual)
}
}

func TestDLNProofRejectsEmptySessionTag(t *testing.T) {
assertPanics(t, func() {
_ = NewDLNProof(nil, nil, nil, nil, nil, nil, []byte{})
Expand Down
Loading
Loading