From f2c2f9c72d2c366b52ba5993dab4ee4641649468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 00:05:13 +0000 Subject: [PATCH 1/3] test: add holistic signing boundary regressions Add high-value durable regression tests for three validated holistic findings: 1. Security-v2 ceremony aborts when fixture-backed parties diverge on message or fullBytesLen - TestSecurityV2CeremonyAbortsOnMessageMismatch/opposingPartySignsDifferentMessage: 11-party security-v2 ceremony where party 1 uses a different message. Round-2 BobMid verification fails (SSID mismatch), ceremony aborts. Asserts no signature on any end channel and culprit attributed to opposing party. - TestSecurityV2CeremonyAbortsOnMessageMismatch/opposingPartyUsesDifferentFullBytesLen: Same integer (42), but party 1 uses width 31 vs others' 32. SSID differs by width, same abort behavior with opposing culprit attribution. 2. Signing Start range gate: m == curve order rejected, m == q-1 accepted - TestSigningStartMessageRangeGate/rejectsMessageEqualToCurveOrder: Start() with m=N returns error, no round-1 messages emitted. - TestSigningStartMessageRangeGate/acceptsMessageEqualToOrderMinusOne: Start() with m=N-1 succeeds, emits 2 round-1 messages (directed + broadcast). 3. Deterministic out-of-order readiness accumulation for round.ok behavior - TestSigningRound1OutOfOrderReadinessAccumulation: Signing round1.Update accumulates ok[2]=true even when earlier peer 1 is incomplete. Proves the changed loop-continue behavior (commit db3fca8) is preserved. - TestKeygenRound1OutOfOrderReadinessAccumulation: Analogous keygen round1.Update test with real KGRound1Message fixtures. All tests reuse existing fixtures and helpers; no production changes. --- ecdsa/keygen/keygen_boundaries_test.go | 156 ++++++++++++ ecdsa/signing/signing_boundaries_test.go | 310 +++++++++++++++++++++++ go.mod | 2 +- 3 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 ecdsa/keygen/keygen_boundaries_test.go create mode 100644 ecdsa/signing/signing_boundaries_test.go diff --git a/ecdsa/keygen/keygen_boundaries_test.go b/ecdsa/keygen/keygen_boundaries_test.go new file mode 100644 index 000000000..9a67a51db --- /dev/null +++ b/ecdsa/keygen/keygen_boundaries_test.go @@ -0,0 +1,156 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package keygen + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/crypto" + "github.com/bnb-chain/tss-lib/crypto/commitments" + "github.com/bnb-chain/tss-lib/crypto/dlnproof" + "github.com/bnb-chain/tss-lib/crypto/paillier" + "github.com/bnb-chain/tss-lib/crypto/vss" + "github.com/bnb-chain/tss-lib/tss" +) + +// ----- Deterministic out-of-order readiness accumulation for round.ok ----- + +// keygenRound1ReadinessFixture constructs a round1 with temp arrays pre-allocated +// and ok[0]=true (self). Returns the round and party IDs. +func keygenRound1ReadinessFixture(t *testing.T, n int) (*round1, tss.SortedPartyIDs) { + t.Helper() + + keys, pIDs, err := LoadKeygenTestFixtures(n) + require.NoError(t, err) + + threshold := n - 1 + params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], n, threshold) + params.SetProtocolMode(tss.ProtocolModeLegacy) + + // Initialize localTempData with the embedded localMessageStore + temp := &localTempData{ + localMessageStore: localMessageStore{ + kgRound1Messages: make([]tss.ParsedMessage, n), + }, + } + base := &base{ + Parameters: params, + save: &keys[0], + temp: temp, + out: make(chan tss.Message, n), + end: nil, + ok: make([]bool, n), + started: true, + number: 1, + } + // Mark self as ready (as round1.Start does) + base.ok[0] = true + + return &round1{base}, pIDs +} + +// buildMinimalKGRound1Message constructs a valid KGRound1Message for testing. +// Uses real fixture pre-params for cryptographic correctness. +func buildMinimalKGRound1Message(t *testing.T, from *tss.PartyID, fixtureIdx int) tss.ParsedMessage { + t.Helper() + + // Load enough fixtures to satisfy the threshold (which is 3 for n=4) + fixtureCount := 4 // threshold = 3, need 4 parties for VSS + keys, pIDs, err := LoadKeygenTestFixtures(fixtureCount) + require.NoError(t, err) + fixture := keys[fixtureIdx] + + preParams := fixture.LocalPreParams + require.True(t, preParams.ValidateWithProof(), "fixture must have pre-params with proof") + + // Commitment: need at least threshold+1 parties for VSS + ec := tss.S256() + vs, _, err := vss.Create(ec, fixtureCount-1, common.GetRandomPositiveInt(ec.Params().N), pIDs.Keys()) + require.NoError(t, err) + pGFlat, err := crypto.FlattenECPoints(vs) + require.NoError(t, err) + cmt := commitments.NewHashCommitment(pGFlat...) + + // DLN proofs (legacy, no session) + h1i, h2i, alpha, beta, p, q, NTildei := + preParams.H1i, + preParams.H2i, + preParams.Alpha, + preParams.Beta, + preParams.P, + preParams.Q, + preParams.NTildei + + dlnProof1 := dlnproof.NewDLNProof( + h1i, h2i, alpha, p, q, NTildei, + ) + dlnProof2 := dlnproof.NewDLNProof( + h2i, h1i, beta, p, q, NTildei, + ) + + // Mod proofs (legacy, no session) + modProof := preParams.PaillierSK.ModProof() + pp := new(big.Int).Add(p, p) + qq := new(big.Int).Add(q, q) + phiNTilde := new(big.Int).Mul(pp, qq) + gcdTilde := new(big.Int).GCD(nil, nil, pp, qq) + lambdaNTilde := new(big.Int).Div(phiNTilde, gcdTilde) + pkTilde := &paillier.PublicKey{N: NTildei} + skTilde := &paillier.PrivateKey{PublicKey: *pkTilde, LambdaN: lambdaNTilde, PhiN: phiNTilde} + modProofTilde := skTilde.ModProof() + + msg, err := NewKGRound1Message( + from, + cmt.C, + &preParams.PaillierSK.PublicKey, + NTildei, + h1i, + h2i, + dlnProof1, + dlnProof2, + modProof, + modProofTilde, + ) + require.NoError(t, err) + return msg +} + +func TestKeygenRound1OutOfOrderReadinessAccumulation(t *testing.T) { + n := 3 // self + 2 peers (indices 0,1,2) (need later-ready peer after earlier-incomplete) + rnd, pIDs := keygenRound1ReadinessFixture(t, n) + + // Build real KGRound1Messages from fixtures for peers 1 and 2 + msgPeer1 := buildMinimalKGRound1Message(t, pIDs[1], 1) + msgPeer2 := buildMinimalKGRound1Message(t, pIDs[2], 2) + + // First delivery: peer 2 (later index) complete, peer 1 (earlier index) incomplete + rnd.temp.kgRound1Messages[2] = msgPeer2 + + ret, err := rnd.Update() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + require.False(t, ret, "not all peers ready") + require.False(t, rnd.ok[1], "peer 1 must not be ready") + require.True(t, rnd.ok[2], "peer 2 must be recorded as ready despite peer 1 being incomplete") + + // Second delivery: now complete peer 1 + rnd.temp.kgRound1Messages[1] = msgPeer1 + + ret, err = rnd.Update() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + require.True(t, ret, "all peers now ready") + require.True(t, rnd.ok[1]) + require.True(t, rnd.ok[2]) + require.True(t, rnd.CanProceed()) +} \ No newline at end of file diff --git a/ecdsa/signing/signing_boundaries_test.go b/ecdsa/signing/signing_boundaries_test.go new file mode 100644 index 000000000..8b96b08f1 --- /dev/null +++ b/ecdsa/signing/signing_boundaries_test.go @@ -0,0 +1,310 @@ +// Copyright © 2019 Binance +// +// This file is part of Binance. The full Binance copyright notice, including +// terms governing use, modification, and redistribution, is contained in the +// file LICENSE at the root of the source code distribution tree. + +package signing + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bnb-chain/tss-lib/common" + "github.com/bnb-chain/tss-lib/crypto/commitments" + "github.com/bnb-chain/tss-lib/crypto/mta" + "github.com/bnb-chain/tss-lib/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/test" + "github.com/bnb-chain/tss-lib/tss" +) + +// ----- Security-v2 ceremony abort: message / width mismatch ----- + +// securityV2CeremonyAbortFixture constructs N parties from keygen fixtures with +// the given message and width overrides per party. Returns parties, their out +// channels, end channels, and the peer context used for routing. +func securityV2CeremonyAbortFixture(t *testing.T, n int, overrides func(i int) (*big.Int, int)) ([]*LocalParty, []chan tss.Message, []chan common.SignatureData, *tss.PeerContext) { + t.Helper() + + keys, signPIDs, err := keygen.LoadKeygenTestFixtures(n) + if err != nil { t.Fatalf("unexpected error: %v", err) } + require.Equal(t, n, len(keys)) + require.Equal(t, n, len(signPIDs)) + + p2pCtx := tss.NewPeerContext(signPIDs) + parties := make([]*LocalParty, n) + outChs := make([]chan tss.Message, n) + endChs := make([]chan common.SignatureData, n) + + ceremonyNonce := big.NewInt(1) + for i := 0; i < n; i++ { + msg, width := overrides(i) + params := tss.NewParameters(tss.S256(), p2pCtx, signPIDs[i], n, test.TestThreshold) + params.SetProtocolMode(tss.ProtocolModeSecurityV2) + params.SetSessionNonce(ceremonyNonce) + + outCh := make(chan tss.Message, n) + endCh := make(chan common.SignatureData, 1) + outChs[i] = outCh + endChs[i] = endCh + + P := NewLocalParty(msg, params, keys[i], outCh, endCh, width).(*LocalParty) + parties[i] = P + } + return parties, outChs, endChs, p2pCtx +} + +// collectRound1Messages drains each party's out channel and returns the +// round-1 messages intended for the target party (directed to target, or broadcast). +func collectRound1Messages(t *testing.T, parties []*LocalParty, outChs []chan tss.Message, target *tss.PartyID) []tss.ParsedMessage { + t.Helper() + + var msgs []tss.ParsedMessage + for i, P := range parties { + if P.PartyID().Index == target.Index { + continue + } + // Each peer emits (n-1) directed messages and 1 broadcast; drain all. + for range len(parties) { + select { + case msg := <-outChs[i]: + if dest := msg.GetTo(); dest == nil || dest[0].Index == target.Index { + bz, _, err := msg.WireBytes() + if err != nil { t.Fatalf("unexpected error: %v", err) } + parsed, err := tss.ParseWireMessage(bz, msg.GetFrom(), msg.IsBroadcast()) + if err != nil { t.Fatalf("unexpected error: %v", err) } + msgs = append(msgs, parsed) + } + } + } + } + return msgs +} + +func TestSecurityV2CeremonyAbortsOnMessageMismatch(t *testing.T) { + t.Run("opposingPartySignsDifferentMessage", func(t *testing.T) { + n := test.TestThreshold + 1 // 11 parties + overrides := func(i int) (*big.Int, int) { + if i == 1 { + return big.NewInt(43), 32 // party 1 signs a different message + } + return big.NewInt(42), 32 + } + + parties, outChs, endChs, _ := securityV2CeremonyAbortFixture(t, n, overrides) + + // Start all parties (round 1) + errCh := make(chan *tss.Error, n) + for _, P := range parties { + go func(P *LocalParty) { + if err := P.Start(); err != nil { + errCh <- err + } + }(P) + } + + // Drain round-1 messages from peers and feed to party 0 + msgs := collectRound1Messages(t, parties, outChs, parties[0].PartyID()) + + // Feed messages to party 0 via Update + var lastErr *tss.Error + for _, msg := range msgs { + _, err := parties[0].Update(msg) + if err != nil { + lastErr = err + } + } + + // The last Update should have triggered round-2 Start and failed + require.NotNil(t, lastErr, "expected round-2 proof verification to fail") + + // Culprit must be the opposing party (party 1) + require.NotEmpty(t, lastErr.Culprits(), "culprits should be attributed to the peer with mismatched context") + require.Equal(t, parties[1].PartyID().Index, lastErr.Culprits()[0].Index, "culprit should be the party with the different message") + + // No signature may be produced on any end channel + for i, endCh := range endChs { + select { + case <-endCh: + t.Errorf("party %d produced a signature despite mismatched context", i) + default: + } + } + }) + + t.Run("opposingPartyUsesDifferentFullBytesLen", func(t *testing.T) { + n := test.TestThreshold + 1 + overrides := func(i int) (*big.Int, int) { + if i == 1 { + return big.NewInt(42), 31 // same message, different width + } + return big.NewInt(42), 32 + } + + parties, outChs, endChs, _ := securityV2CeremonyAbortFixture(t, n, overrides) + + errCh := make(chan *tss.Error, n) + for _, P := range parties { + go func(P *LocalParty) { + if err := P.Start(); err != nil { + errCh <- err + } + }(P) + } + + msgs := collectRound1Messages(t, parties, outChs, parties[0].PartyID()) + + var lastErr *tss.Error + for _, msg := range msgs { + _, err := parties[0].Update(msg) + if err != nil { + lastErr = err + } + } + + require.NotNil(t, lastErr, "expected round-2 proof verification to fail") + require.NotEmpty(t, lastErr.Culprits(), "culprits should be attributed to the peer with mismatched width") + require.Equal(t, parties[1].PartyID().Index, lastErr.Culprits()[0].Index) + + for i, endCh := range endChs { + select { + case <-endCh: + t.Errorf("party %d produced a signature despite mismatched width", i) + default: + } + } + }) +} + +// ----- Signing Start range gate: m == N rejected, m == N-1 accepted ----- + +func TestSigningStartMessageRangeGate(t *testing.T) { + q := tss.S256().Params().N + // Use a minimal 2-party setup (threshold 1) for speed + keys, pIDs, err := keygen.LoadKeygenTestFixtures(2) + if err != nil { t.Fatalf("unexpected error: %v", err) } + + params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], 2, 1) + params.SetProtocolMode(tss.ProtocolModeLegacy) + + t.Run("rejectsMessageEqualToCurveOrder", func(t *testing.T) { + out := make(chan tss.Message, 2) + end := make(chan common.SignatureData, 1) + msg := new(big.Int).Set(q) + + P := NewLocalParty(msg, params, keys[0], out, end, 32).(*LocalParty) + err := P.Start() + require.NotNil(t, err, "Start must reject m == curve order") + + // No messages should have been emitted (gate is before any emission) + assert.Empty(t, out, "no round-1 messages emitted on rejection") + }) + + t.Run("acceptsMessageEqualToOrderMinusOne", func(t *testing.T) { + out := make(chan tss.Message, 2) + end := make(chan common.SignatureData, 1) + msg := new(big.Int).Sub(q, big.NewInt(1)) + + P := NewLocalParty(msg, params, keys[0], out, end, 32).(*LocalParty) + err := P.Start() + require.Nil(t, err, "Start must accept m == q-1 through the range gate") + + // Round-1 messages must be emitted: 1 directed + 1 broadcast = 2 + msgs := make([]tss.Message, 0, 2) + for range 2 { + select { + case m := <-out: + msgs = append(msgs, m) + } + } + assert.Len(t, msgs, 2, "expected 2 round-1 messages (directed + broadcast)") + }) +} + +// ----- Deterministic out-of-order readiness accumulation for round.ok ----- + +// signingRound1ReadinessFixture constructs a round1 with temp arrays pre-allocated +// and ok[0]=true (self). Returns the round and party IDs. +func signingRound1ReadinessFixture(t *testing.T, n int) (*round1, tss.SortedPartyIDs) { + t.Helper() + + keys, pIDs, err := keygen.LoadKeygenTestFixtures(n) + if err != nil { t.Fatalf("unexpected error: %v", err) } + + threshold := n - 1 + params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], n, threshold) + params.SetProtocolMode(tss.ProtocolModeLegacy) + + // Initialize localTempData with the embedded localMessageStore + temp := &localTempData{ + localMessageStore: localMessageStore{ + signRound1Message1s: make([]tss.ParsedMessage, n), + signRound1Message2s: make([]tss.ParsedMessage, n), + }, + } + base := &base{ + Parameters: params, + key: &keys[0], + data: &common.SignatureData{}, + temp: temp, + out: make(chan tss.Message, n), + end: nil, + ok: make([]bool, n), + started: true, + number: 1, + } + // Mark self as ready (as round1.Start does) + base.ok[0] = true + + return &round1{base}, pIDs +} + +func TestSigningRound1OutOfOrderReadinessAccumulation(t *testing.T) { + n := 3 // 3 parties, threshold 2 + rnd, pIDs := signingRound1ReadinessFixture(t, n) + + // Construct valid round-1 messages for peers 1 and 2 + // Message1: directed to self, not broadcast + // Message2: broadcast with HashCommitment (type *big.Int) + buildMsg := func(peerIdx int) (tss.ParsedMessage, tss.ParsedMessage) { + from := pIDs[peerIdx] + to := pIDs[0] + c := big.NewInt(int64(peerIdx*100 + 1)) + // Dummy range proof: zero big ints pass ValidateBasic and CanAccept (no proof verification in round 1) + proof := &mta.RangeProofAlice{ + Z: big.NewInt(0), U: big.NewInt(0), W: big.NewInt(0), + S: big.NewInt(0), S1: big.NewInt(0), S2: big.NewInt(0), + } + msg1 := NewSignRound1Message1(to, from, c, proof) + // HashCommitment is *big.Int; use a simple secret to create one + commitment := commitments.NewHashCommitment(big.NewInt(int64(peerIdx*10 + 1))).C + msg2 := NewSignRound1Message2(from, commitment) + return msg1, msg2 + } + + // First delivery: peer 2 (later index) complete, peer 1 (earlier index) incomplete + msg1_2, msg2_2 := buildMsg(2) + rnd.temp.signRound1Message1s[2] = msg1_2 + rnd.temp.signRound1Message2s[2] = msg2_2 + + ret, err := rnd.Update() + if err != nil { t.Fatalf("unexpected error: %v", err) } + require.False(t, ret, "not all peers ready") + require.False(t, rnd.ok[1], "peer 1 must not be ready") + require.True(t, rnd.ok[2], "peer 2 must be recorded as ready despite peer 1 being incomplete") + + // Second delivery: now complete peer 1 + msg1_1, msg2_1 := buildMsg(1) + rnd.temp.signRound1Message1s[1] = msg1_1 + rnd.temp.signRound1Message2s[1] = msg2_1 + + ret, err = rnd.Update() + if err != nil { t.Fatalf("unexpected error: %v", err) } + require.True(t, ret, "all peers now ready") + require.True(t, rnd.ok[1]) + require.True(t, rnd.ok[2]) + require.True(t, rnd.CanProceed()) +} \ No newline at end of file diff --git a/go.mod b/go.mod index 8430fb7c8..cbfc14890 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bnb-chain/tss-lib -go 1.25.7 +go 1.25.6 toolchain go1.26.8 From 02fbc2aa34c5d3b0fabe1d10dd7a55d48b595ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 00:23:33 +0000 Subject: [PATCH 2/3] test: address PR review blockers on holistic signing boundary tests 1. Drain party 0's own round-1 output before mismatch delivery, and use a larger out-channel buffer, so a regression that removes SSID binding (round 2 unexpectedly succeeding) cannot deadlock on a full channel. 2. Await every party's Start() completion via a done-channel barrier before collecting/delivering messages, instead of relying on goroutine scheduling or leaving errCh unread. 3. Assert abort Round()==2 and that the full culprit set (accounting for the BobMid/BobMidWC duplicate-sender pair) is exactly the opposing party, plus bounded-deadline no-signature checks on every end channel. 4. Replace the keygen readiness fixture's real DLN/Mod proof generation (which only exercised round1.Update's type/broadcast check) with a minimal deterministic KGRound1Message construction; drops runtime from ~8s to ~7ms. --- ecdsa/keygen/keygen_boundaries_test.go | 116 +++++-------- ecdsa/signing/signing_boundaries_test.go | 212 ++++++++++++++--------- 2 files changed, 174 insertions(+), 154 deletions(-) diff --git a/ecdsa/keygen/keygen_boundaries_test.go b/ecdsa/keygen/keygen_boundaries_test.go index 9a67a51db..76d7ab9a8 100644 --- a/ecdsa/keygen/keygen_boundaries_test.go +++ b/ecdsa/keygen/keygen_boundaries_test.go @@ -12,12 +12,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/bnb-chain/tss-lib/common" - "github.com/bnb-chain/tss-lib/crypto" "github.com/bnb-chain/tss-lib/crypto/commitments" - "github.com/bnb-chain/tss-lib/crypto/dlnproof" - "github.com/bnb-chain/tss-lib/crypto/paillier" - "github.com/bnb-chain/tss-lib/crypto/vss" "github.com/bnb-chain/tss-lib/tss" ) @@ -57,79 +52,58 @@ func keygenRound1ReadinessFixture(t *testing.T, n int) (*round1, tss.SortedParty return &round1{base}, pIDs } -// buildMinimalKGRound1Message constructs a valid KGRound1Message for testing. -// Uses real fixture pre-params for cryptographic correctness. -func buildMinimalKGRound1Message(t *testing.T, from *tss.PartyID, fixtureIdx int) tss.ParsedMessage { +// buildMinimalKGRound1Message constructs a minimal valid KGRound1Message for testing. +// Round1.Update only checks CanAccept (type + IsBroadcast), not cryptographic content. +// We construct a ParsedMessage directly with the correct content type and broadcast flag. +func buildMinimalKGRound1Message(t *testing.T, from *tss.PartyID) tss.ParsedMessage { t.Helper() - // Load enough fixtures to satisfy the threshold (which is 3 for n=4) - fixtureCount := 4 // threshold = 3, need 4 parties for VSS - keys, pIDs, err := LoadKeygenTestFixtures(fixtureCount) - require.NoError(t, err) - fixture := keys[fixtureIdx] - - preParams := fixture.LocalPreParams - require.True(t, preParams.ValidateWithProof(), "fixture must have pre-params with proof") + // Minimal content with correct type + content := &KGRound1Message{ + Commitment: commitments.NewHashCommitment(big.NewInt(1)).C.Bytes(), + PaillierN: big.NewInt(1).Bytes(), + NTilde: big.NewInt(1).Bytes(), + H1: big.NewInt(1).Bytes(), + H2: big.NewInt(1).Bytes(), + Dlnproof_1: &KGRound1Message_DLNProof{ + Alpha: [][]byte{{1}}, + T: [][]byte{{1}}, + }, + Dlnproof_2: &KGRound1Message_DLNProof{ + Alpha: [][]byte{{1}}, + T: [][]byte{{1}}, + }, + Modproof: &KGRound1Message_ModProof{ + W: big.NewInt(1).Bytes(), + X: [][]byte{{1}}, + A: []bool{true}, + B: []bool{true}, + Z: [][]byte{{1}}, + }, + ModproofTilde: &KGRound1Message_ModProof{ + W: big.NewInt(1).Bytes(), + X: [][]byte{{1}}, + A: []bool{true}, + B: []bool{true}, + Z: [][]byte{{1}}, + }, + } - // Commitment: need at least threshold+1 parties for VSS - ec := tss.S256() - vs, _, err := vss.Create(ec, fixtureCount-1, common.GetRandomPositiveInt(ec.Params().N), pIDs.Keys()) - require.NoError(t, err) - pGFlat, err := crypto.FlattenECPoints(vs) - require.NoError(t, err) - cmt := commitments.NewHashCommitment(pGFlat...) - - // DLN proofs (legacy, no session) - h1i, h2i, alpha, beta, p, q, NTildei := - preParams.H1i, - preParams.H2i, - preParams.Alpha, - preParams.Beta, - preParams.P, - preParams.Q, - preParams.NTildei - - dlnProof1 := dlnproof.NewDLNProof( - h1i, h2i, alpha, p, q, NTildei, - ) - dlnProof2 := dlnproof.NewDLNProof( - h2i, h1i, beta, p, q, NTildei, - ) - - // Mod proofs (legacy, no session) - modProof := preParams.PaillierSK.ModProof() - pp := new(big.Int).Add(p, p) - qq := new(big.Int).Add(q, q) - phiNTilde := new(big.Int).Mul(pp, qq) - gcdTilde := new(big.Int).GCD(nil, nil, pp, qq) - lambdaNTilde := new(big.Int).Div(phiNTilde, gcdTilde) - pkTilde := &paillier.PublicKey{N: NTildei} - skTilde := &paillier.PrivateKey{PublicKey: *pkTilde, LambdaN: lambdaNTilde, PhiN: phiNTilde} - modProofTilde := skTilde.ModProof() - - msg, err := NewKGRound1Message( - from, - cmt.C, - &preParams.PaillierSK.PublicKey, - NTildei, - h1i, - h2i, - dlnProof1, - dlnProof2, - modProof, - modProofTilde, - ) - require.NoError(t, err) - return msg + meta := tss.MessageRouting{ + From: from, + IsBroadcast: true, + } + wrapper := tss.NewMessageWrapper(meta, content) + return tss.NewMessage(meta, content, wrapper) } func TestKeygenRound1OutOfOrderReadinessAccumulation(t *testing.T) { - n := 3 // self + 2 peers (indices 0,1,2) (need later-ready peer after earlier-incomplete) + n := 3 // self + 2 peers (indices 0,1,2) rnd, pIDs := keygenRound1ReadinessFixture(t, n) - // Build real KGRound1Messages from fixtures for peers 1 and 2 - msgPeer1 := buildMinimalKGRound1Message(t, pIDs[1], 1) - msgPeer2 := buildMinimalKGRound1Message(t, pIDs[2], 2) + // Build minimal KGRound1Messages for peers 1 and 2 + msgPeer1 := buildMinimalKGRound1Message(t, pIDs[1]) + msgPeer2 := buildMinimalKGRound1Message(t, pIDs[2]) // First delivery: peer 2 (later index) complete, peer 1 (earlier index) incomplete rnd.temp.kgRound1Messages[2] = msgPeer2 @@ -153,4 +127,4 @@ func TestKeygenRound1OutOfOrderReadinessAccumulation(t *testing.T) { require.True(t, rnd.ok[1]) require.True(t, rnd.ok[2]) require.True(t, rnd.CanProceed()) -} \ No newline at end of file +} diff --git a/ecdsa/signing/signing_boundaries_test.go b/ecdsa/signing/signing_boundaries_test.go index 8b96b08f1..f169f14cc 100644 --- a/ecdsa/signing/signing_boundaries_test.go +++ b/ecdsa/signing/signing_boundaries_test.go @@ -7,8 +7,10 @@ package signing import ( + "context" "math/big" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,7 +32,7 @@ func securityV2CeremonyAbortFixture(t *testing.T, n int, overrides func(i int) ( t.Helper() keys, signPIDs, err := keygen.LoadKeygenTestFixtures(n) - if err != nil { t.Fatalf("unexpected error: %v", err) } + require.NoError(t, err) require.Equal(t, n, len(keys)) require.Equal(t, n, len(signPIDs)) @@ -46,7 +48,10 @@ func securityV2CeremonyAbortFixture(t *testing.T, n int, overrides func(i int) ( params.SetProtocolMode(tss.ProtocolModeSecurityV2) params.SetSessionNonce(ceremonyNonce) - outCh := make(chan tss.Message, n) + // Buffer large enough to hold both a full round-1 emission (n messages) + // and, if the SSID check regresses and round 2 unexpectedly succeeds, a + // full round-2 emission (n-1 messages) without blocking a Start() call. + outCh := make(chan tss.Message, 2*n) endCh := make(chan common.SignatureData, 1) outChs[i] = outCh endChs[i] = endCh @@ -57,7 +62,44 @@ func securityV2CeremonyAbortFixture(t *testing.T, n int, overrides func(i int) ( return parties, outChs, endChs, p2pCtx } -// collectRound1Messages drains each party's out channel and returns the +// startAllAndAwait starts every party concurrently and blocks until every +// Start() call has returned, recording any error per party index. This +// avoids relying on goroutine scheduling order and ensures round-1 output is +// fully queued before any message collection begins. +func startAllAndAwait(parties []*LocalParty) []*tss.Error { + n := len(parties) + errs := make([]*tss.Error, n) + done := make(chan struct{}, n) + for i, P := range parties { + go func(idx int, P *LocalParty) { + defer func() { done <- struct{}{} }() + if err := P.Start(); err != nil { + errs[idx] = err + } + }(i, P) + } + for range n { + <-done + } + return errs +} + +// drainOwnRound1Output drains a party's own out channel of its round-1 +// emission (n-1 directed messages + 1 broadcast = n messages), so that a +// later round-2 emission (on unexpected success) cannot block on a full +// channel. +func drainOwnRound1Output(t *testing.T, outCh chan tss.Message, n int) { + t.Helper() + for range n { + select { + case <-outCh: + case <-time.After(time.Second): + t.Fatal("timed out draining party's own round-1 output") + } + } +} + +// collectRound1Messages drains each peer's out channel and returns the // round-1 messages intended for the target party (directed to target, or broadcast). func collectRound1Messages(t *testing.T, parties []*LocalParty, outChs []chan tss.Message, target *tss.PartyID) []tss.ParsedMessage { t.Helper() @@ -73,66 +115,98 @@ func collectRound1Messages(t *testing.T, parties []*LocalParty, outChs []chan ts case msg := <-outChs[i]: if dest := msg.GetTo(); dest == nil || dest[0].Index == target.Index { bz, _, err := msg.WireBytes() - if err != nil { t.Fatalf("unexpected error: %v", err) } + require.NoError(t, err) parsed, err := tss.ParseWireMessage(bz, msg.GetFrom(), msg.IsBroadcast()) - if err != nil { t.Fatalf("unexpected error: %v", err) } + require.NoError(t, err) msgs = append(msgs, parsed) } + case <-time.After(time.Second): + t.Fatal("timed out collecting round-1 messages from peer") } } } return msgs } -func TestSecurityV2CeremonyAbortsOnMessageMismatch(t *testing.T) { - t.Run("opposingPartySignsDifferentMessage", func(t *testing.T) { - n := test.TestThreshold + 1 // 11 parties - overrides := func(i int) (*big.Int, int) { - if i == 1 { - return big.NewInt(43), 32 // party 1 signs a different message - } - return big.NewInt(42), 32 - } +// assertOpposingCulpritAndNoSignature drives the mismatch scenario: starts +// all parties, drains party 0's own round-1 output (deadlock guard), feeds +// party 0 every peer round-1 message, and asserts round-2 aborts with the +// opposing party attributed among the culprits (BobMid and BobMidWC may both +// attribute to the same peer, so the culprit set can contain a duplicate). +// Asserts no signature is produced on any party's end channel within a +// bounded deadline. +func assertOpposingCulpritAndNoSignature(t *testing.T, n int, overrides func(i int) (*big.Int, int)) { + t.Helper() - parties, outChs, endChs, _ := securityV2CeremonyAbortFixture(t, n, overrides) + parties, outChs, endChs, _ := securityV2CeremonyAbortFixture(t, n, overrides) - // Start all parties (round 1) - errCh := make(chan *tss.Error, n) - for _, P := range parties { - go func(P *LocalParty) { - if err := P.Start(); err != nil { - errCh <- err - } - }(P) - } + // Start every party and await completion before touching any channel. + startErrs := startAllAndAwait(parties) + for i, err := range startErrs { + require.Nil(t, err, "party %d Start must not fail in round 1", i) + } - // Drain round-1 messages from peers and feed to party 0 - msgs := collectRound1Messages(t, parties, outChs, parties[0].PartyID()) + // Drain party 0's own round-1 emission so a possible (regressed) round-2 + // success cannot block on a full out channel. + drainOwnRound1Output(t, outChs[0], n) - // Feed messages to party 0 via Update - var lastErr *tss.Error - for _, msg := range msgs { - _, err := parties[0].Update(msg) - if err != nil { - lastErr = err - } + // Collect round-1 messages destined for party 0 from every peer. + msgs := collectRound1Messages(t, parties, outChs, parties[0].PartyID()) + + // Feed messages to party 0; the message that completes round 1 triggers + // round-2 Start synchronously and returns its error (if any). + var lastErr *tss.Error + for _, msg := range msgs { + _, err := parties[0].Update(msg) + if err != nil { + lastErr = err } + } - // The last Update should have triggered round-2 Start and failed - require.NotNil(t, lastErr, "expected round-2 proof verification to fail") + require.NotNil(t, lastErr, "expected round-2 proof verification to fail") + require.Equal(t, 2, lastErr.Round(), "abort must occur in round 2, the first proof-verification stage") + + // Culprits must include the opposing party (party 1). BobMid and BobMidWC + // both attribute failures to the same peer, so the culprit set is + // expected to contain that party's ID (possibly more than once). + require.NotEmpty(t, lastErr.Culprits(), "culprits must be attributed to the peer with mismatched context") + opposingIdx := parties[1].PartyID().Index + found := false + for _, c := range lastErr.Culprits() { + if c.Index == opposingIdx { + found = true + break + } + } + require.True(t, found, "culprit set must include the party with the mismatched context") + for _, c := range lastErr.Culprits() { + require.Equal(t, opposingIdx, c.Index, "every culprit must be the single opposing party, not an unrelated peer") + } - // Culprit must be the opposing party (party 1) - require.NotEmpty(t, lastErr.Culprits(), "culprits should be attributed to the peer with mismatched context") - require.Equal(t, parties[1].PartyID().Index, lastErr.Culprits()[0].Index, "culprit should be the party with the different message") + // No signature may be produced on any end channel; give a bounded + // deadline in case the check above regressed and the ceremony proceeded. + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + for i, endCh := range endChs { + select { + case <-endCh: + t.Errorf("party %d produced a signature despite mismatched context", i) + case <-ctx.Done(): + // timeout expected; no signature was produced + } + } +} - // No signature may be produced on any end channel - for i, endCh := range endChs { - select { - case <-endCh: - t.Errorf("party %d produced a signature despite mismatched context", i) - default: +func TestSecurityV2CeremonyAbortsOnMessageMismatch(t *testing.T) { + t.Run("opposingPartySignsDifferentMessage", func(t *testing.T) { + n := test.TestThreshold + 1 // 11 parties + overrides := func(i int) (*big.Int, int) { + if i == 1 { + return big.NewInt(43), 32 // party 1 signs a different message } + return big.NewInt(42), 32 } + assertOpposingCulpritAndNoSignature(t, n, overrides) }) t.Run("opposingPartyUsesDifferentFullBytesLen", func(t *testing.T) { @@ -143,39 +217,7 @@ func TestSecurityV2CeremonyAbortsOnMessageMismatch(t *testing.T) { } return big.NewInt(42), 32 } - - parties, outChs, endChs, _ := securityV2CeremonyAbortFixture(t, n, overrides) - - errCh := make(chan *tss.Error, n) - for _, P := range parties { - go func(P *LocalParty) { - if err := P.Start(); err != nil { - errCh <- err - } - }(P) - } - - msgs := collectRound1Messages(t, parties, outChs, parties[0].PartyID()) - - var lastErr *tss.Error - for _, msg := range msgs { - _, err := parties[0].Update(msg) - if err != nil { - lastErr = err - } - } - - require.NotNil(t, lastErr, "expected round-2 proof verification to fail") - require.NotEmpty(t, lastErr.Culprits(), "culprits should be attributed to the peer with mismatched width") - require.Equal(t, parties[1].PartyID().Index, lastErr.Culprits()[0].Index) - - for i, endCh := range endChs { - select { - case <-endCh: - t.Errorf("party %d produced a signature despite mismatched width", i) - default: - } - } + assertOpposingCulpritAndNoSignature(t, n, overrides) }) } @@ -185,7 +227,7 @@ func TestSigningStartMessageRangeGate(t *testing.T) { q := tss.S256().Params().N // Use a minimal 2-party setup (threshold 1) for speed keys, pIDs, err := keygen.LoadKeygenTestFixtures(2) - if err != nil { t.Fatalf("unexpected error: %v", err) } + require.NoError(t, err) params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], 2, 1) params.SetProtocolMode(tss.ProtocolModeLegacy) @@ -232,7 +274,7 @@ func signingRound1ReadinessFixture(t *testing.T, n int) (*round1, tss.SortedPart t.Helper() keys, pIDs, err := keygen.LoadKeygenTestFixtures(n) - if err != nil { t.Fatalf("unexpected error: %v", err) } + require.NoError(t, err) threshold := n - 1 params := tss.NewParameters(tss.S256(), tss.NewPeerContext(pIDs), pIDs[0], n, threshold) @@ -263,7 +305,7 @@ func signingRound1ReadinessFixture(t *testing.T, n int) (*round1, tss.SortedPart } func TestSigningRound1OutOfOrderReadinessAccumulation(t *testing.T) { - n := 3 // 3 parties, threshold 2 + n := 3 // self + 2 peers (indices 0,1,2) (need later-ready peer after earlier-incomplete) rnd, pIDs := signingRound1ReadinessFixture(t, n) // Construct valid round-1 messages for peers 1 and 2 @@ -291,7 +333,9 @@ func TestSigningRound1OutOfOrderReadinessAccumulation(t *testing.T) { rnd.temp.signRound1Message2s[2] = msg2_2 ret, err := rnd.Update() - if err != nil { t.Fatalf("unexpected error: %v", err) } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } require.False(t, ret, "not all peers ready") require.False(t, rnd.ok[1], "peer 1 must not be ready") require.True(t, rnd.ok[2], "peer 2 must be recorded as ready despite peer 1 being incomplete") @@ -302,9 +346,11 @@ func TestSigningRound1OutOfOrderReadinessAccumulation(t *testing.T) { rnd.temp.signRound1Message2s[1] = msg2_1 ret, err = rnd.Update() - if err != nil { t.Fatalf("unexpected error: %v", err) } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } require.True(t, ret, "all peers now ready") require.True(t, rnd.ok[1]) require.True(t, rnd.ok[2]) require.True(t, rnd.CanProceed()) -} \ No newline at end of file +} From 1533c6cbe79695dd776fbf613530e7136a23cc54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 00:30:06 +0000 Subject: [PATCH 3/3] test: route ceremony-abort messages to both receivers, fix no-signature race 1. Route real round-1 messages to BOTH opposing-context receivers instead of only party 0. collectRound1MessagesForTargets drains every party's own out channel exactly once and partitions each message (broadcast or directed) to whichever of the two targets it's addressed to. Asserts: - party 0 (majority context) rejects only the single opposing party (its culprit set is exactly {party 1}) - party 1 (minority context) rejects every other party, since its own SSID differs from all of them (its culprit set is exactly the other n-1 parties) Both must abort in round 2 with these exact culprit sets. 2. Fix a no-signature check race: the previous sequential loop shared one context.WithTimeout across selects, so once it fired on an earlier channel, later channels' selects raced an already-closed ctx.Done() against a possibly-ready endCh, non-deterministically masking a real signature. assertNoSignatureWithinWindow now monitors every end channel concurrently for the full window via one goroutine per channel (sharing ctx.Done() is safe here since closing a channel wakes every receiver), then performs a final nonblocking check per channel with no competing canceled context to catch anything buffered exactly at the boundary. --- ecdsa/signing/signing_boundaries_test.go | 241 +++++++++++++++-------- 1 file changed, 156 insertions(+), 85 deletions(-) diff --git a/ecdsa/signing/signing_boundaries_test.go b/ecdsa/signing/signing_boundaries_test.go index f169f14cc..16dbf4bc4 100644 --- a/ecdsa/signing/signing_boundaries_test.go +++ b/ecdsa/signing/signing_boundaries_test.go @@ -9,6 +9,7 @@ package signing import ( "context" "math/big" + "sync" "testing" "time" @@ -27,8 +28,8 @@ import ( // securityV2CeremonyAbortFixture constructs N parties from keygen fixtures with // the given message and width overrides per party. Returns parties, their out -// channels, end channels, and the peer context used for routing. -func securityV2CeremonyAbortFixture(t *testing.T, n int, overrides func(i int) (*big.Int, int)) ([]*LocalParty, []chan tss.Message, []chan common.SignatureData, *tss.PeerContext) { +// channels, and end channels. +func securityV2CeremonyAbortFixture(t *testing.T, n int, overrides func(i int) (*big.Int, int)) ([]*LocalParty, []chan tss.Message, []chan common.SignatureData) { t.Helper() keys, signPIDs, err := keygen.LoadKeygenTestFixtures(n) @@ -59,7 +60,7 @@ func securityV2CeremonyAbortFixture(t *testing.T, n int, overrides func(i int) ( P := NewLocalParty(msg, params, keys[i], outCh, endCh, width).(*LocalParty) parties[i] = P } - return parties, outChs, endChs, p2pCtx + return parties, outChs, endChs } // startAllAndAwait starts every party concurrently and blocks until every @@ -84,117 +85,187 @@ func startAllAndAwait(parties []*LocalParty) []*tss.Error { return errs } -// drainOwnRound1Output drains a party's own out channel of its round-1 -// emission (n-1 directed messages + 1 broadcast = n messages), so that a -// later round-2 emission (on unexpected success) cannot block on a full -// channel. -func drainOwnRound1Output(t *testing.T, outCh chan tss.Message, n int) { +// collectRound1MessagesForTargets drains every party's round-1 output exactly +// once (n messages per party: n-1 directed + 1 broadcast) and partitions the +// messages addressed to target0 and target1 (a broadcast message is +// delivered to both targets except the originating target itself, which +// already has its own broadcast stored locally by Start(); a directed +// message only goes to its specific recipient). Draining every party's own +// channel, not just the two targets', also guards against a full +// out-channel deadlocking a later round if the SSID check regresses and +// round 2 unexpectedly succeeds. +func collectRound1MessagesForTargets( + t *testing.T, + parties []*LocalParty, + outChs []chan tss.Message, + target0, target1 *tss.PartyID, +) (forTarget0, forTarget1 []tss.ParsedMessage) { t.Helper() - for range n { - select { - case <-outCh: - case <-time.After(time.Second): - t.Fatal("timed out draining party's own round-1 output") + n := len(parties) + + for i, P := range parties { + for range n { + var msg tss.Message + select { + case msg = <-outChs[i]: + case <-time.After(time.Second): + t.Fatalf("timed out draining round-1 output from party %d", i) + } + + dest := msg.GetTo() + isBroadcast := dest == nil + wantsTarget := func(target *tss.PartyID) bool { + if P.PartyID().Index == target.Index { + // The sender's own broadcast is already stored locally by + // its own Start(); it never needs to be delivered back. + return false + } + return isBroadcast || dest[0].Index == target.Index + } + + want0 := wantsTarget(target0) + want1 := wantsTarget(target1) + if !want0 && !want1 { + continue + } + + bz, _, err := msg.WireBytes() + require.NoError(t, err) + parsed, err := tss.ParseWireMessage(bz, msg.GetFrom(), msg.IsBroadcast()) + require.NoError(t, err) + + if want0 { + forTarget0 = append(forTarget0, parsed) + } + if want1 { + forTarget1 = append(forTarget1, parsed) + } } } + return } -// collectRound1Messages drains each peer's out channel and returns the -// round-1 messages intended for the target party (directed to target, or broadcast). -func collectRound1Messages(t *testing.T, parties []*LocalParty, outChs []chan tss.Message, target *tss.PartyID) []tss.ParsedMessage { +// assertNoSignatureWithinWindow concurrently monitors every end channel for +// the given window, so that channels later in the slice are not shortchanged +// by a shared timeout that already elapsed while sequentially checking +// earlier ones (which could randomly mask an already-buffered signature via +// select's race between a ready channel and an already-canceled context). A +// final nonblocking check per channel, with no competing canceled context, +// guards against a signature buffered exactly at the window boundary. +func assertNoSignatureWithinWindow(t *testing.T, endChs []chan common.SignatureData, window time.Duration) { t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), window) + defer cancel() - var msgs []tss.ParsedMessage - for i, P := range parties { - if P.PartyID().Index == target.Index { - continue - } - // Each peer emits (n-1) directed messages and 1 broadcast; drain all. - for range len(parties) { + got := make([]bool, len(endChs)) + var wg sync.WaitGroup + for i, endCh := range endChs { + wg.Add(1) + go func(i int, endCh chan common.SignatureData) { + defer wg.Done() select { - case msg := <-outChs[i]: - if dest := msg.GetTo(); dest == nil || dest[0].Index == target.Index { - bz, _, err := msg.WireBytes() - require.NoError(t, err) - parsed, err := tss.ParseWireMessage(bz, msg.GetFrom(), msg.IsBroadcast()) - require.NoError(t, err) - msgs = append(msgs, parsed) - } - case <-time.After(time.Second): - t.Fatal("timed out collecting round-1 messages from peer") + case <-endCh: + got[i] = true + case <-ctx.Done(): } + }(i, endCh) + } + wg.Wait() + + // Final nonblocking check with no competing canceled context: catches a + // signature buffered exactly at the boundary that the racy select above + // could have missed by resolving via ctx.Done() instead of the ready endCh. + for i, endCh := range endChs { + if got[i] { + continue + } + select { + case <-endCh: + got[i] = true + default: } } - return msgs + + for i, produced := range got { + if produced { + t.Errorf("party %d produced a signature despite mismatched context", i) + } + } +} + +// culpritIndexSet returns the unique set of party indices among err's culprits. +func culpritIndexSet(err *tss.Error) map[int]struct{} { + set := make(map[int]struct{}) + for _, c := range err.Culprits() { + set[c.Index] = struct{}{} + } + return set +} + +func indexSet(pids ...*tss.PartyID) map[int]struct{} { + set := make(map[int]struct{}, len(pids)) + for _, p := range pids { + set[p.Index] = struct{}{} + } + return set } -// assertOpposingCulpritAndNoSignature drives the mismatch scenario: starts -// all parties, drains party 0's own round-1 output (deadlock guard), feeds -// party 0 every peer round-1 message, and asserts round-2 aborts with the -// opposing party attributed among the culprits (BobMid and BobMidWC may both -// attribute to the same peer, so the culprit set can contain a duplicate). -// Asserts no signature is produced on any party's end channel within a -// bounded deadline. -func assertOpposingCulpritAndNoSignature(t *testing.T, n int, overrides func(i int) (*big.Int, int)) { +// assertOpposingCulpritsAndNoSignature drives the mismatch scenario from both +// receivers' viewpoints. Party 0 (majority context) should reject only the +// single opposing party (party 1), since every other peer shares party 0's +// context. Party 1 (minority context) should reject every other party, since +// its own SSID differs from all of them. Both must abort in round 2 (the +// first proof-verification stage) with the exact expected culprit set, and +// no party may ever produce a signature. +func assertOpposingCulpritsAndNoSignature(t *testing.T, n int, overrides func(i int) (*big.Int, int)) { t.Helper() - parties, outChs, endChs, _ := securityV2CeremonyAbortFixture(t, n, overrides) + parties, outChs, endChs := securityV2CeremonyAbortFixture(t, n, overrides) - // Start every party and await completion before touching any channel. startErrs := startAllAndAwait(parties) for i, err := range startErrs { require.Nil(t, err, "party %d Start must not fail in round 1", i) } - // Drain party 0's own round-1 emission so a possible (regressed) round-2 - // success cannot block on a full out channel. - drainOwnRound1Output(t, outChs[0], n) - - // Collect round-1 messages destined for party 0 from every peer. - msgs := collectRound1Messages(t, parties, outChs, parties[0].PartyID()) + msgsFor0, msgsFor1 := collectRound1MessagesForTargets(t, parties, outChs, parties[0].PartyID(), parties[1].PartyID()) - // Feed messages to party 0; the message that completes round 1 triggers - // round-2 Start synchronously and returns its error (if any). - var lastErr *tss.Error - for _, msg := range msgs { + var lastErr0 *tss.Error + for _, msg := range msgsFor0 { _, err := parties[0].Update(msg) if err != nil { - lastErr = err + lastErr0 = err } } - - require.NotNil(t, lastErr, "expected round-2 proof verification to fail") - require.Equal(t, 2, lastErr.Round(), "abort must occur in round 2, the first proof-verification stage") - - // Culprits must include the opposing party (party 1). BobMid and BobMidWC - // both attribute failures to the same peer, so the culprit set is - // expected to contain that party's ID (possibly more than once). - require.NotEmpty(t, lastErr.Culprits(), "culprits must be attributed to the peer with mismatched context") - opposingIdx := parties[1].PartyID().Index - found := false - for _, c := range lastErr.Culprits() { - if c.Index == opposingIdx { - found = true - break + var lastErr1 *tss.Error + for _, msg := range msgsFor1 { + _, err := parties[1].Update(msg) + if err != nil { + lastErr1 = err } } - require.True(t, found, "culprit set must include the party with the mismatched context") - for _, c := range lastErr.Culprits() { - require.Equal(t, opposingIdx, c.Index, "every culprit must be the single opposing party, not an unrelated peer") - } - // No signature may be produced on any end channel; give a bounded - // deadline in case the check above regressed and the ceremony proceeded. - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - for i, endCh := range endChs { - select { - case <-endCh: - t.Errorf("party %d produced a signature despite mismatched context", i) - case <-ctx.Done(): - // timeout expected; no signature was produced + // Party 0 (majority context) only disagrees with party 1. + require.NotNil(t, lastErr0, "party 0 expected round-2 proof verification to fail") + require.Equal(t, 2, lastErr0.Round(), "party 0 abort must occur in round 2") + require.NotEmpty(t, lastErr0.Culprits()) + wantCulprits0 := indexSet(parties[1].PartyID()) + require.Equal(t, wantCulprits0, culpritIndexSet(lastErr0), "party 0's culprit set must be exactly the opposing party") + + // Party 1 (minority context) disagrees with every other party. + require.NotNil(t, lastErr1, "party 1 expected round-2 proof verification to fail") + require.Equal(t, 2, lastErr1.Round(), "party 1 abort must occur in round 2") + require.NotEmpty(t, lastErr1.Culprits()) + wantOthers := make([]*tss.PartyID, 0, n-1) + for i, P := range parties { + if i == 1 { + continue } + wantOthers = append(wantOthers, P.PartyID()) } + wantCulprits1 := indexSet(wantOthers...) + require.Equal(t, wantCulprits1, culpritIndexSet(lastErr1), "party 1's culprit set must be every other party") + + assertNoSignatureWithinWindow(t, endChs, 500*time.Millisecond) } func TestSecurityV2CeremonyAbortsOnMessageMismatch(t *testing.T) { @@ -206,7 +277,7 @@ func TestSecurityV2CeremonyAbortsOnMessageMismatch(t *testing.T) { } return big.NewInt(42), 32 } - assertOpposingCulpritAndNoSignature(t, n, overrides) + assertOpposingCulpritsAndNoSignature(t, n, overrides) }) t.Run("opposingPartyUsesDifferentFullBytesLen", func(t *testing.T) { @@ -217,7 +288,7 @@ func TestSecurityV2CeremonyAbortsOnMessageMismatch(t *testing.T) { } return big.NewInt(42), 32 } - assertOpposingCulpritAndNoSignature(t, n, overrides) + assertOpposingCulpritsAndNoSignature(t, n, overrides) }) }