From dbf0ba14e677950942413416980162f84c663c27 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Wed, 30 Sep 2026 00:00:47 +0000
Subject: [PATCH 1/3] test: add live mixed-binary legacy signing interop
harness
Closes the mixed-binary assurance gap left by the existing session-less
legacy Bob/BobWC coverage (crypto/mta/legacy_bob_historical_witness_test.go,
ecdsa/signing/round_3_test.go), which pins the shape of a historical witness
using this implementation's own prover equations but never exercises an
actual historical binary.
Adds testdata/legacy_transcript/historical_signer, a subprocess entry point
compiled only inside the pinned historical (threshold-network/tss-lib@
2e712689) module (same pin as the existing oracle), driven over a bounded
newline-delimited JSON protocol on stdin/stdout.
Adds testdata/legacy_transcript/mixed_interop, the current-side orchestrator
that drives a live 2-of-2 ECDSA signing ceremony against that subprocess and
asserts:
- reject: a default-configured current party (ProtocolModeLegacy, compat
off) fails closed at round 3 against the historical peer's real,
live-drawn Bob/BobWC witness, whose T1 (recovered from the actual wire
bytes, not a hand-picked value) is asserted to exceed the default tight
N+q^6 bound.
- accept: the identical exchange with SetLegacyHistoricalBobCompatibility
(true) succeeds at round 3 and provably progresses through round 8 in
both directions.
- homogeneous-control: two current-only parties complete the identical
ceremony shape through round 8, proving reject is specific to the
historical witness range and not a general legacy-mode defect.
Adds verify_mixed_interop.sh, following verify.sh's existing pattern, as the
durable script CI/developers run to catch regressions in historical/current
round interop or the compatibility opt-in wiring; confirmed to fail when the
opt-in wiring is deliberately broken (LegacyHistoricalBobCompatibility()
forced to return false) and to pass again once restored.
No production code changes; test/oracle files only under
testdata/legacy_transcript.
---
testdata/legacy_transcript/README.md | 79 +++
.../historical_signer/main.go | 229 +++++++
.../legacy_transcript/mixed_interop/main.go | 583 ++++++++++++++++++
.../legacy_transcript/verify_mixed_interop.sh | 38 ++
4 files changed, 929 insertions(+)
create mode 100644 testdata/legacy_transcript/historical_signer/main.go
create mode 100644 testdata/legacy_transcript/mixed_interop/main.go
create mode 100755 testdata/legacy_transcript/verify_mixed_interop.sh
diff --git a/testdata/legacy_transcript/README.md b/testdata/legacy_transcript/README.md
index 11c16cc58..680cb742e 100644
--- a/testdata/legacy_transcript/README.md
+++ b/testdata/legacy_transcript/README.md
@@ -61,3 +61,82 @@ prunes nested modules from a parent module zip, while ordinary fixture files are
part of the immutable module keep-core downloads. The pinned fixture therefore
makes the historical identity independent of a developer's module cache without
disappearing from the release artifact being qualified.
+
+## Mixed-binary legacy signing interop harness
+
+`verify.sh`'s oracle proves byte-for-byte proof compatibility from fixed,
+pre-recorded transcripts. It never runs an actual historical binary, so it
+cannot by itself prove that a *live* historical peer and a live current peer
+can complete a real signing exchange together. `verify_mixed_interop.sh`
+closes that gap: it drives a genuine two-process ECDSA signing ceremony
+between this checked-out (current) implementation and a subprocess running
+the pinned historical `threshold-network/tss-lib@2e712689` commit (the same
+commit qualified above), exchanging real GG18/GG20 round wire messages.
+
+```sh
+./testdata/legacy_transcript/verify_mixed_interop.sh
+```
+
+`historical_signer/main.go` is copied into a temporary module built from the
+same `historical/go.mod.fixture`/`historical/go.sum.fixture` pin as the
+oracle, exactly like `verify.sh`'s pattern. It drives one live historical
+`ecdsa/signing.LocalParty` (party index 1, "Bob") over a bounded,
+newline-delimited JSON protocol on stdin/stdout: `init`/`deliver`/`quit`
+commands in, `message`/`signature`/`error`/`turn_done` events out. No network
+access happens at run time; the pinned module must already be in the local
+module cache (the same precondition `verify.sh` has always had).
+
+`mixed_interop/main.go` is the current-side orchestrator (`go run` from the
+repository root, matching the oracle). For a fixed 2-of-20 `keygen_data_0/1`
+fixture pair, deterministic seeds, and a fixed message, it drives three
+scenarios:
+
+- **reject**: a current party with `tss.ProtocolModeLegacy` and the default
+ (off) `SetLegacyHistoricalBobCompatibility` opts asserts that round 3 fails
+ closed against the historical peer's live, real Bob/BobWC proof, whose T1
+ witness (recovered from the actual wire bytes via
+ `SignRound2Message.UnmarshalProofBob`/`UnmarshalProofBobWC`, not a
+ hand-picked value) is asserted to exceed the default tight `N + q^6` bound.
+- **accept**: the identical exchange with `SetLegacyHistoricalBobCompatibility(true)`
+ set before construction; round 3 succeeds and the ceremony provably
+ continues through round 8 (both directions), proving the current party
+ didn't just tolerate the historical proof but kept advancing the protocol
+ with the historical peer afterward.
+- **homogeneous-control**: two current-implementation parties, no historical
+ subprocess at all, complete the identical ceremony shape under the default
+ configuration — proof that "reject" above is specific to the historical
+ witness range and not a general legacy-mode defect. This scenario is never
+ substituted for the cross-version exchanges above.
+
+All three scenarios deliberately stop once a party's own round 8 message
+appears (never delivering a round-8-or-later message onward): this
+repository's own `round3Fixture` (`ecdsa/signing/round_3_test.go`) and
+`historicalBobProofForWitnessY`
+(`crypto/mta/legacy_bob_historical_witness_test.go`) already establish the
+precedent of driving a 2-of-20 minimal subset of the `test/_ecdsa_fixtures`
+keygen fixtures (threshold 1, not the fixture set's real threshold 10) for
+this exact class of round-level interop check. That minimal subset is
+sufficient for every per-peer MtA/Schnorr check through round 8 (each is a
+property of the two parties' own consistent local computation), but round 9's
+final aggregate check (`U == T`) verifies a *global* Shamir reconstruction
+identity that only holds for a correctly-sized threshold+1 co-signer set.
+Reaching a real, live-exchanged round 8 message already proves the historical
+Bob/BobWC witness was accepted and every subsequent round 3–8
+verification/decommitment step (Bob_end, the Gamma/Schnorr proofs, and both
+decommitments) succeeded against a genuine historical binary. Driving a full,
+globally-valid signature to completion is possible but requires
+`testThreshold+1` (11) correctly-thresholded co-signers rather than an
+arbitrary 2-of-20 subset — substantially more harness complexity for a
+property (global reconstruction validity) that is orthogonal to the specific
+Bob/BobWC compatibility mechanism this harness exists to exercise.
+
+The exact witness scalar values are not byte-reproducible run to run: signing
+round 2 (`ecdsa/signing/round_2.go`) draws the Bob and BobWC witnesses from
+two goroutines running concurrently against the process-global
+`crypto/rand.Reader`, so which goroutine consumes which slice of the
+deterministic keystream is scheduler-dependent. Every run nonetheless
+deterministically reproduces the *qualitative* property under test — a high
+witness that exceeds the tight bound, a closed-by-default rejection, and an
+opt-in acceptance that keeps progressing — which is what `verify_mixed_interop.sh`
+asserts and fails on.
+
diff --git a/testdata/legacy_transcript/historical_signer/main.go b/testdata/legacy_transcript/historical_signer/main.go
new file mode 100644
index 000000000..706a7dc59
--- /dev/null
+++ b/testdata/legacy_transcript/historical_signer/main.go
@@ -0,0 +1,229 @@
+// Command historical_signer drives a single historical (threshold-network/tss-lib@2e712689)
+// ECDSA signing party (index 1, the fixed "peer" role) through a live signing
+// ceremony, exchanging real GG18/GG20 round messages with a host process over
+// a bounded, newline-delimited JSON protocol on stdin/stdout.
+//
+// This file is compiled only inside the pinned historical module set up by
+// verify_mixed_interop.sh (go.mod.fixture replaces github.com/bnb-chain/tss-lib
+// with the exact threshold-network/tss-lib@2e712689 commit). It is never part
+// of the current module's build graph.
+//
+// Protocol (one JSON object per line):
+//
+// host -> signer: {"cmd":"init","seed":"","message_hex":""}
+// host -> signer: {"cmd":"deliver","is_broadcast":bool,"wire_hex":""}
+// host -> signer: {"cmd":"quit"}
+//
+// signer -> host: {"event":"message","is_broadcast":bool,"wire_hex":""}
+// signer -> host: {"event":"signature","r_hex":"","s_hex":""}
+// signer -> host: {"event":"error","message":""}
+// signer -> host: {"event":"turn_done"}
+//
+// The signer always plays party index 1 in a fixed 2-of-2 ceremony built from
+// the existing test/_ecdsa_fixtures/keygen_data_{0,1}.json fixtures (byte
+// identical between this historical commit and current dev), so both sides
+// derive identical PartyIDs and key material independently without needing to
+// serialize either over the wire.
+package main
+
+import (
+ "bufio"
+ cryptorand "crypto/rand"
+ "crypto/sha512"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "math/big"
+ "os"
+ "sync"
+
+ "github.com/bnb-chain/tss-lib/common"
+ "github.com/bnb-chain/tss-lib/ecdsa/keygen"
+ "github.com/bnb-chain/tss-lib/ecdsa/signing"
+ "github.com/bnb-chain/tss-lib/tss"
+)
+
+// deterministicReader replays a fixed SHA-512 keystream derived from a label
+// so the historical prover's witness sampling (including the MtA blinding
+// value that becomes the Bob/BobWC T1 witness) is reproducible across runs.
+type deterministicReader struct {
+ seed []byte
+ mu sync.Mutex
+ counter uint64
+ buffer []byte
+}
+
+// Read is safe for concurrent use: signing round 2 draws fresh MtA blinding
+// randomness for the Bob and BobWC proofs from two goroutines running
+// concurrently, both against the process-global crypto/rand.Reader this
+// function replaces. Serializing access to the counter/buffer keystream
+// state avoids torn reads that would otherwise corrupt both goroutines'
+// values.
+func (r *deterministicReader) Read(output []byte) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ total := len(output)
+ for len(output) > 0 {
+ if len(r.buffer) == 0 {
+ counter := make([]byte, 8)
+ binary.BigEndian.PutUint64(counter, r.counter)
+ digest := sha512.Sum512(append(append([]byte{}, r.seed...), counter...))
+ r.buffer = digest[:]
+ r.counter++
+ }
+ copied := copy(output, r.buffer)
+ output = output[copied:]
+ r.buffer = r.buffer[copied:]
+ }
+ return total, nil
+}
+
+func fixedRandom(label string) {
+ cryptorand.Reader = &deterministicReader{seed: []byte("mixed-interop/" + label)}
+}
+
+type command struct {
+ Cmd string `json:"cmd"`
+ Seed string `json:"seed,omitempty"`
+ MessageHex string `json:"message_hex,omitempty"`
+ IsBroadcast bool `json:"is_broadcast,omitempty"`
+ WireHex string `json:"wire_hex,omitempty"`
+}
+
+type event struct {
+ Event string `json:"event"`
+ IsBroadcast bool `json:"is_broadcast,omitempty"`
+ WireHex string `json:"wire_hex,omitempty"`
+ Message string `json:"message,omitempty"`
+ RHex string `json:"r_hex,omitempty"`
+ SHex string `json:"s_hex,omitempty"`
+}
+
+type signer struct {
+ out chan tss.Message
+ end chan common.SignatureData
+ party tss.Party
+ peerID *tss.PartyID
+ writer *bufio.Writer
+ enc *json.Encoder
+}
+
+func (s *signer) emit(e event) {
+ if err := s.enc.Encode(e); err != nil {
+ panic(err)
+ }
+ s.writer.Flush()
+}
+
+// drain flushes every currently-buffered outbound message and, if the
+// ceremony has finished, the resulting signature, as protocol events.
+func (s *signer) drain() {
+ for {
+ select {
+ case msg := <-s.out:
+ wire, _, err := msg.WireBytes()
+ if err != nil {
+ s.emit(event{Event: "error", Message: err.Error()})
+ continue
+ }
+ s.emit(event{Event: "message", IsBroadcast: msg.IsBroadcast(), WireHex: hex.EncodeToString(wire)})
+ case <-s.end:
+ // Unreachable in this harness's driven scenarios (the host
+ // deliberately never delivers enough rounds to reach
+ // completion; see mixed_interop/main.go's doc comment), kept
+ // only as a defensive completion signal. Not binding the
+ // received value avoids copying common.SignatureData (a
+ // protobuf message embedding a sync.Mutex) by value, matching
+ // this repository's own ecdsa/signing test convention of
+ // `case <-endCh:`.
+ s.emit(event{Event: "signature"})
+ default:
+ return
+ }
+ }
+}
+
+func main() {
+ reader := bufio.NewScanner(os.Stdin)
+ reader.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
+ writer := bufio.NewWriter(os.Stdout)
+ s := &signer{writer: writer, enc: json.NewEncoder(writer)}
+
+ // Restore the original entropy source on exit; only this process's
+ // signing-round randomness is made deterministic, and only for the
+ // lifetime of this subprocess.
+ origRand := cryptorand.Reader
+ defer func() { cryptorand.Reader = origRand }()
+
+ for reader.Scan() {
+ line := reader.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var cmd command
+ if err := json.Unmarshal(line, &cmd); err != nil {
+ s.emit(event{Event: "error", Message: "invalid command json: " + err.Error()})
+ continue
+ }
+ switch cmd.Cmd {
+ case "init":
+ s.handleInit(cmd)
+ s.emit(event{Event: "turn_done"})
+ case "deliver":
+ s.handleDeliver(cmd)
+ s.emit(event{Event: "turn_done"})
+ case "quit":
+ return
+ default:
+ s.emit(event{Event: "error", Message: "unknown command: " + cmd.Cmd})
+ s.emit(event{Event: "turn_done"})
+ }
+ }
+}
+
+func (s *signer) handleInit(cmd command) {
+ fixedRandom(cmd.Seed)
+
+ keys, partyIDs, err := keygen.LoadKeygenTestFixtures(2)
+ if err != nil {
+ s.emit(event{Event: "error", Message: "load keygen fixtures: " + err.Error()})
+ return
+ }
+ s.peerID = partyIDs[0]
+
+ msg := new(big.Int)
+ if _, ok := msg.SetString(cmd.MessageHex, 16); !ok {
+ s.emit(event{Event: "error", Message: "invalid message hex"})
+ return
+ }
+
+ ctx := tss.NewPeerContext(partyIDs)
+ params := tss.NewParameters(tss.S256(), ctx, partyIDs[1], 2, 1)
+
+ s.out = make(chan tss.Message, 16)
+ s.end = make(chan common.SignatureData, 1)
+ s.party = signing.NewLocalParty(msg, params, keys[1], s.out, s.end)
+
+ if pErr := s.party.Start(); pErr != nil {
+ s.emit(event{Event: "error", Message: pErr.Error()})
+ return
+ }
+ s.drain()
+}
+
+func (s *signer) handleDeliver(cmd command) {
+ if s.party == nil {
+ s.emit(event{Event: "error", Message: "deliver before init"})
+ return
+ }
+ wireBytes, err := hex.DecodeString(cmd.WireHex)
+ if err != nil {
+ s.emit(event{Event: "error", Message: "invalid wire hex: " + err.Error()})
+ return
+ }
+ if _, pErr := s.party.UpdateFromBytes(wireBytes, s.peerID, cmd.IsBroadcast); pErr != nil {
+ s.emit(event{Event: "error", Message: pErr.Error()})
+ return
+ }
+ s.drain()
+}
diff --git a/testdata/legacy_transcript/mixed_interop/main.go b/testdata/legacy_transcript/mixed_interop/main.go
new file mode 100644
index 000000000..eb327680c
--- /dev/null
+++ b/testdata/legacy_transcript/mixed_interop/main.go
@@ -0,0 +1,583 @@
+// Command mixed_interop drives a genuine mixed-binary legacy signing
+// ceremony between this checked-out (current) implementation and a
+// subprocess running the historical threshold-network/tss-lib@2e712689
+// commit, exchanging real GG18/GG20 round wire messages between the two
+// processes.
+//
+// It closes the mixed-binary assurance gap left by in-process fixtures
+// (crypto/mta/legacy_bob_historical_witness_test.go,
+// ecdsa/signing/round_3_test.go): those pin the *shape* of a historical
+// witness using this implementation's own prover equations, which proves the
+// verifier's bound math but never exercises an actual historical binary. This
+// program instead spawns the pinned historical commit as a child process (the
+// two versions share one Go module path and cannot be linked into one
+// binary), drives it through a live 2-of-2 signing ceremony opposite a
+// current-implementation party in ProtocolModeLegacy, and asserts on the
+// resulting behavior:
+//
+// - "reject": the historical peer's real, live-drawn Bob/BobWC witness
+// (MtA blinding value sampled below the Paillier modulus N, per the
+// historical BobMid/BobMidWC) produces a round-2 proof whose T1 exceeds
+// the default tight N+q^6 bound; a default-configured current party's
+// round 3 must fail closed.
+// - "accept": the same exchange, replayed against a fresh historical
+// process with the identical deterministic seed (so the witness is
+// provably the same shape), succeeds when the current party opts into
+// SetLegacyHistoricalBobCompatibility(true), and the ceremony provably
+// progresses: the current party emits its round-3 message, the
+// historical peer accepts it, and the live exchange continues through
+// round 8 in both directions (see runMixedScenario's doc comment for why
+// full signature completion is intentionally out of scope here).
+// - "homogeneous-control": two current-implementation parties (no
+// historical subprocess at all) drive the same ceremony shape through
+// round 8 under the default (non-opt-in) configuration, to make clear
+// that "reject" is specific to the historical witness range and not a
+// general legacy-mode defect. This scenario never claims a current-only
+// run is a substitute for the cross-version exchange above.
+//
+// Usage: mixed_interop
+//
+// must contain a working `go run ./main.go` for
+// testdata/legacy_transcript/historical_signer/main.go, built against the
+// go.mod.fixture/go.sum.fixture pin (see verify_mixed_interop.sh).
+package main
+
+import (
+ "bufio"
+ cryptorand "crypto/rand"
+ "crypto/sha512"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "os"
+ "os/exec"
+ "sync"
+
+ "github.com/bnb-chain/tss-lib/common"
+ "github.com/bnb-chain/tss-lib/ecdsa/keygen"
+ "github.com/bnb-chain/tss-lib/ecdsa/signing"
+ "github.com/bnb-chain/tss-lib/tss"
+)
+
+// ---- deterministic randomness (host side) ----
+
+type deterministicReader struct {
+ seed []byte
+ mu sync.Mutex
+ counter uint64
+ buffer []byte
+}
+
+// Read is safe for concurrent use: this implementation's signing rounds draw
+// fresh MtA blinding randomness for a peer's Bob and BobWC proofs from two
+// goroutines running concurrently (round_2.go), and both draw from the
+// process-global crypto/rand.Reader this function replaces. Serializing
+// access to the counter/buffer keystream state avoids torn reads that would
+// otherwise corrupt both goroutines' values.
+func (r *deterministicReader) Read(output []byte) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ total := len(output)
+ for len(output) > 0 {
+ if len(r.buffer) == 0 {
+ counter := make([]byte, 8)
+ binary.BigEndian.PutUint64(counter, r.counter)
+ digest := sha512.Sum512(append(append([]byte{}, r.seed...), counter...))
+ r.buffer = digest[:]
+ r.counter++
+ }
+ copied := copy(output, r.buffer)
+ output = output[copied:]
+ r.buffer = r.buffer[copied:]
+ }
+ return total, nil
+}
+
+func fixedRandom(label string) func() {
+ original := cryptorand.Reader
+ cryptorand.Reader = &deterministicReader{seed: []byte("mixed-interop/" + label)}
+ return func() { cryptorand.Reader = original }
+}
+
+// ---- subprocess wire protocol ----
+
+type command struct {
+ Cmd string `json:"cmd"`
+ Seed string `json:"seed,omitempty"`
+ MessageHex string `json:"message_hex,omitempty"`
+ IsBroadcast bool `json:"is_broadcast,omitempty"`
+ WireHex string `json:"wire_hex,omitempty"`
+}
+
+type event struct {
+ Event string `json:"event"`
+ IsBroadcast bool `json:"is_broadcast,omitempty"`
+ WireHex string `json:"wire_hex,omitempty"`
+ Message string `json:"message,omitempty"`
+ RHex string `json:"r_hex,omitempty"`
+ SHex string `json:"s_hex,omitempty"`
+}
+
+type historicalPeer struct {
+ cmd *exec.Cmd
+ stdin *json.Encoder
+ stdout *bufio.Scanner
+}
+
+func spawnHistoricalPeer(dir string) (*historicalPeer, error) {
+ cmd := exec.Command("go", "run", "-mod=readonly", "./main.go")
+ cmd.Dir = dir
+ cmd.Stderr = os.Stderr
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+ if err := cmd.Start(); err != nil {
+ return nil, err
+ }
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
+ return &historicalPeer{cmd: cmd, stdin: json.NewEncoder(stdin), stdout: scanner}, nil
+}
+
+func (h *historicalPeer) send(c command) error {
+ return h.stdin.Encode(c)
+}
+
+// readTurn reads events until (and excluding) a "turn_done" sentinel.
+func (h *historicalPeer) readTurn() ([]event, error) {
+ var events []event
+ for h.stdout.Scan() {
+ line := h.stdout.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var e event
+ if err := json.Unmarshal(line, &e); err != nil {
+ return events, fmt.Errorf("decode historical event: %w", err)
+ }
+ if e.Event == "turn_done" {
+ return events, nil
+ }
+ events = append(events, e)
+ }
+ return events, fmt.Errorf("historical subprocess closed stdout before turn_done")
+}
+
+func (h *historicalPeer) quit() {
+ _ = h.send(command{Cmd: "quit"})
+ _ = h.cmd.Wait()
+}
+
+// ---- scenario machinery ----
+
+// roundNumberOf identifies the signing-round number of a message purely from
+// its concrete protobuf content type, never from error text or any other
+// source-derived string.
+func roundNumberOf(m tss.Message) int {
+ pm, ok := m.(tss.ParsedMessage)
+ if !ok {
+ return 0
+ }
+ switch pm.Content().(type) {
+ case *signing.SignRound1Message1, *signing.SignRound1Message2:
+ return 1
+ case *signing.SignRound2Message:
+ return 2
+ case *signing.SignRound3Message:
+ return 3
+ case *signing.SignRound4Message:
+ return 4
+ case *signing.SignRound5Message:
+ return 5
+ case *signing.SignRound6Message:
+ return 6
+ case *signing.SignRound7Message:
+ return 7
+ case *signing.SignRound8Message:
+ return 8
+ case *signing.SignRound9Message:
+ return 9
+ default:
+ return 0
+ }
+}
+
+type scenarioResult struct {
+ Name string `json:"name"`
+ DefaultRejected bool `json:"default_rejected"`
+ RejectionErr string `json:"rejection_err,omitempty"`
+ Accepted bool `json:"accepted"`
+ AliceProgressed bool `json:"alice_progressed"`
+ ReachedRound8 bool `json:"reached_round_8"`
+ Completed bool `json:"completed"`
+ AliceR string `json:"alice_r,omitempty"`
+ AliceS string `json:"alice_s,omitempty"`
+ PeerR string `json:"peer_r,omitempty"`
+ PeerS string `json:"peer_s,omitempty"`
+ WitnessBobT1 string `json:"witness_bob_t1,omitempty"`
+ WitnessBobWCT1 string `json:"witness_bob_wc_t1,omitempty"`
+ TightBound string `json:"tight_bound,omitempty"`
+ AboveTightBound bool `json:"above_tight_bound"`
+}
+
+const fixedMessageHex = "00f163ee51bcaeff9cdff5e0e3c1a646abd19885fffbab0b3b4236e0cf95c9f5"
+
+// runMixedScenario drives one live ceremony between a current-implementation
+// Alice (party index 0, in this process) and a historical subprocess Bob
+// (party index 1), asserting the given compatibility configuration.
+//
+// The exchange is deliberately bounded to round 8: this repository's own
+// existing round3Fixture (ecdsa/signing/round_3_test.go) and
+// historicalBobProofForWitnessY (crypto/mta/legacy_bob_historical_witness_test.go)
+// already establish the precedent of driving a 2-of-20 minimal subset of the
+// test/_ecdsa_fixtures keygen fixtures (threshold=1, not the fixture set's
+// real threshold=10) for exactly this kind of round-level interop check.
+// That minimal subset is sufficient for every per-peer MtA/Schnorr check
+// through round 8 (each is a property of the two parties' own consistent
+// local computation), but round 9's final aggregate check (U == T) verifies
+// a *global* Shamir reconstruction identity that only holds for a
+// correctly-sized threshold+1 co-signer set. Reaching a real, live-exchanged
+// round 8 message already proves the historical Bob/BobWC witness was
+// accepted and every subsequent round-3..8 verification/decommitment step
+// (Bob_end, the Gamma/Schnorr proofs, and both decommitments) succeeded
+// against a genuine historical binary; deliberately stopping there avoids an
+// unrelated, expected reconstruction mismatch rather than masking a real one.
+func runMixedScenario(name, seedSuffix string, historicalDir string, compat bool) (*scenarioResult, error) {
+ restore := fixedRandom("alice-" + seedSuffix)
+ defer restore()
+
+ keys, partyIDs, err := keygen.LoadKeygenTestFixtures(2)
+ if err != nil {
+ return nil, fmt.Errorf("load keygen fixtures: %w", err)
+ }
+ ec := tss.S256()
+ pk := &keys[0].PaillierSK.PublicKey
+ q := ec.Params().N
+ q3 := new(big.Int).Mul(q, q)
+ q3 = new(big.Int).Mul(q, q3)
+ q6 := new(big.Int).Mul(q3, q3)
+ tightBound := new(big.Int).Add(pk.N, q6)
+
+ ctx := tss.NewPeerContext(partyIDs)
+ params := tss.NewParameters(ec, ctx, partyIDs[0], 2, 1)
+ params.SetProtocolMode(tss.ProtocolModeLegacy)
+ if compat {
+ params.SetLegacyHistoricalBobCompatibility(true)
+ }
+
+ msg := new(big.Int)
+ msg.SetString(fixedMessageHex, 16)
+ msgBytes, _ := hex.DecodeString(fixedMessageHex)
+
+ outCh := make(chan tss.Message, 16)
+ endCh := make(chan common.SignatureData, 1)
+ alice := signing.NewLocalParty(msg, params, keys[0], outCh, endCh, len(msgBytes))
+
+ peer, err := spawnHistoricalPeer(historicalDir)
+ if err != nil {
+ return nil, fmt.Errorf("spawn historical peer: %w", err)
+ }
+ defer peer.quit()
+
+ if err := peer.send(command{Cmd: "init", Seed: "bob-" + seedSuffix, MessageHex: fixedMessageHex}); err != nil {
+ return nil, fmt.Errorf("send init: %w", err)
+ }
+ peerEvents, err := peer.readTurn()
+ if err != nil {
+ return nil, fmt.Errorf("read historical init turn: %w", err)
+ }
+
+ if err := alice.Start(); err != nil {
+ return nil, fmt.Errorf("alice start: %w", err)
+ }
+
+ result := &scenarioResult{Name: name, TightBound: tightBound.Text(16)}
+
+ var pendingToBob []tss.Message
+ var pendingToAlice []event
+
+ drainOut := func() {
+ for {
+ select {
+ case m := <-outCh:
+ if roundNumberOf(m) >= 8 {
+ result.ReachedRound8 = true
+ }
+ pendingToBob = append(pendingToBob, m)
+ case <-endCh:
+ // Unreachable in practice: this scenario deliberately stops
+ // before round 9 (see runMixedScenario's doc comment), so
+ // the ceremony never actually produces a signature. Kept
+ // only so a future change to the stopping point doesn't
+ // silently deadlock on an undrained channel. Matches this
+ // package's own local_party_test.go convention of not
+ // binding the received value (a common.SignatureData is a
+ // protobuf message and copying it by value trips go vet's
+ // lock-copy check).
+ result.Completed = true
+ default:
+ return
+ }
+ }
+ }
+ drainOut()
+ pendingToAlice = append(pendingToAlice, peerEvents...)
+
+ captureWitness := func(e event) {
+ if result.WitnessBobT1 != "" {
+ return
+ }
+ wireBytes, decErr := hex.DecodeString(e.WireHex)
+ if decErr != nil {
+ return
+ }
+ parsed, parseErr := tss.ParseWireMessage(wireBytes, partyIDs[1], e.IsBroadcast)
+ if parseErr != nil {
+ return
+ }
+ r2msg, ok := parsed.Content().(*signing.SignRound2Message)
+ if !ok {
+ return
+ }
+ bob, bErr := r2msg.UnmarshalProofBob()
+ if bErr == nil && bob != nil {
+ result.WitnessBobT1 = bob.T1.Text(16)
+ result.AboveTightBound = bob.T1.Cmp(tightBound) >= 0
+ }
+ bobWC, wcErr := r2msg.UnmarshalProofBobWC(ec)
+ if wcErr == nil && bobWC != nil {
+ result.WitnessBobWCT1 = bobWC.T1.Text(16)
+ if bobWC.T1.Cmp(tightBound) < 0 {
+ result.AboveTightBound = false
+ }
+ }
+ }
+
+pump:
+ for len(pendingToBob) > 0 || len(pendingToAlice) > 0 {
+ for _, m := range pendingToBob {
+ if result.ReachedRound8 {
+ break pump
+ }
+ wireBytes, _, wErr := m.WireBytes()
+ if wErr != nil {
+ return nil, fmt.Errorf("alice wire bytes: %w", wErr)
+ }
+ if sErr := peer.send(command{Cmd: "deliver", IsBroadcast: m.IsBroadcast(), WireHex: hex.EncodeToString(wireBytes)}); sErr != nil {
+ return nil, fmt.Errorf("send deliver to historical peer: %w", sErr)
+ }
+ events, rErr := peer.readTurn()
+ if rErr != nil {
+ return nil, fmt.Errorf("read historical peer turn: %w", rErr)
+ }
+ for _, e := range events {
+ switch e.Event {
+ case "message":
+ pendingToAlice = append(pendingToAlice, e)
+ case "signature":
+ result.PeerR, result.PeerS = e.RHex, e.SHex
+ case "error":
+ // A historical-side rejection is unexpected in every
+ // scenario this harness drives (the current legacy
+ // prover's own witness stays well inside every
+ // historical/current bound); surface it as a hard
+ // failure rather than silently ignoring it.
+ return nil, fmt.Errorf("historical peer reported an unexpected error: %s", e.Message)
+ }
+ }
+ }
+ pendingToBob = nil
+
+ toDeliver := pendingToAlice
+ pendingToAlice = nil
+ for _, e := range toDeliver {
+ if result.ReachedRound8 {
+ break
+ }
+ captureWitness(e)
+ wireBytes, decErr := hex.DecodeString(e.WireHex)
+ if decErr != nil {
+ return nil, fmt.Errorf("decode historical wire hex: %w", decErr)
+ }
+ // Peek at the message's concrete type before delivering it: a
+ // historical peer that has just processed one of Alice's
+ // messages can legitimately cascade through more than one of
+ // its own rounds in a single turn (e.g. producing round 7 and
+ // round 8 together once round 7's local precondition is
+ // already satisfied). Delivering a round-8-or-later message
+ // would drive Alice past round 8 into round 9's global
+ // reconstruction check within the same UpdateFromBytes call,
+ // which this deliberately minimal 2-of-20 fixture subset
+ // cannot satisfy (see the doc comment on this function).
+ if parsed, pErr := tss.ParseWireMessage(wireBytes, partyIDs[1], e.IsBroadcast); pErr == nil {
+ if roundNumberOf(parsed) >= 8 {
+ result.ReachedRound8 = true
+ break
+ }
+ }
+ ok, aErr := alice.UpdateFromBytes(wireBytes, partyIDs[1], e.IsBroadcast)
+ if aErr != nil {
+ result.DefaultRejected = true
+ result.RejectionErr = aErr.Error()
+ return result, nil
+ }
+ _ = ok
+ result.AliceProgressed = true
+ }
+ drainOut()
+ if result.ReachedRound8 {
+ break pump
+ }
+ }
+
+ if compat {
+ result.Accepted = true
+ }
+ return result, nil
+}
+
+// runHomogeneousControl drives the identical ceremony shape through round 8
+// with two current-implementation parties and no historical subprocess at
+// all, to demonstrate the default legacy path is otherwise fully functional
+// and that "reject" above is specific to the historical witness range.
+func runHomogeneousControl() (*scenarioResult, error) {
+ restore := fixedRandom("homogeneous-control")
+ defer restore()
+
+ keys, partyIDs, err := keygen.LoadKeygenTestFixtures(2)
+ if err != nil {
+ return nil, fmt.Errorf("load keygen fixtures: %w", err)
+ }
+ ec := tss.S256()
+ ctx := tss.NewPeerContext(partyIDs)
+
+ msg := new(big.Int)
+ msg.SetString(fixedMessageHex, 16)
+ msgBytes, _ := hex.DecodeString(fixedMessageHex)
+
+ outCh := make(chan tss.Message, 16)
+ endCh := make(chan common.SignatureData, 2)
+ parties := make([]tss.Party, 2)
+ for i := 0; i < 2; i++ {
+ p := tss.NewParameters(ec, ctx, partyIDs[i], 2, 1)
+ p.SetProtocolMode(tss.ProtocolModeLegacy)
+ parties[i] = signing.NewLocalParty(msg, p, keys[i], outCh, endCh, len(msgBytes))
+ }
+ for _, p := range parties {
+ if err := p.Start(); err != nil {
+ return nil, fmt.Errorf("homogeneous control start: %w", err)
+ }
+ }
+
+ result := &scenarioResult{Name: "homogeneous-control"}
+ for {
+ select {
+ case m := <-outCh:
+ // See runMixedScenario's doc comment: stop before forwarding a
+ // round-8-or-later message so neither party auto-cascades into
+ // round 9's global reconstruction check, which this 2-of-20
+ // minimal fixture subset cannot satisfy.
+ if roundNumberOf(m) >= 8 {
+ result.ReachedRound8 = true
+ break
+ }
+ dest := m.GetTo()
+ wireBytes, _, wErr := m.WireBytes()
+ if wErr != nil {
+ return nil, fmt.Errorf("homogeneous wire bytes: %w", wErr)
+ }
+ if dest == nil {
+ for _, p := range parties {
+ if p.PartyID().Index == m.GetFrom().Index {
+ continue
+ }
+ if _, uErr := p.UpdateFromBytes(wireBytes, m.GetFrom(), true); uErr != nil {
+ return nil, fmt.Errorf("homogeneous update: %w", uErr)
+ }
+ }
+ } else {
+ if _, uErr := parties[dest[0].Index].UpdateFromBytes(wireBytes, m.GetFrom(), false); uErr != nil {
+ return nil, fmt.Errorf("homogeneous update: %w", uErr)
+ }
+ }
+ case <-endCh:
+ // Unreachable in practice; see the identical case in
+ // runMixedScenario's drainOut.
+ result.Completed = true
+ }
+ if result.ReachedRound8 {
+ break
+ }
+ }
+ return result, nil
+}
+
+func main() {
+ if len(os.Args) != 2 {
+ fmt.Fprintf(os.Stderr, "usage: %s \n", os.Args[0])
+ os.Exit(2)
+ }
+ historicalDir := os.Args[1]
+
+ failed := false
+ report := map[string]any{}
+
+ reject, err := runMixedScenario("reject", "reject", historicalDir, false)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "reject scenario error: %v\n", err)
+ os.Exit(1)
+ }
+ report["reject"] = reject
+ if !reject.DefaultRejected {
+ fmt.Fprintln(os.Stderr, "FAIL: default-off current party did not reject the historical witness proof")
+ failed = true
+ }
+ if !reject.AboveTightBound {
+ fmt.Fprintln(os.Stderr, "FAIL: historical witness T1 did not exceed the default tight N+q^6 bound")
+ failed = true
+ }
+
+ accept, err := runMixedScenario("accept", "reject", historicalDir, true)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "accept scenario error: %v\n", err)
+ os.Exit(1)
+ }
+ report["accept"] = accept
+ if !accept.Accepted || !accept.AliceProgressed {
+ fmt.Fprintln(os.Stderr, "FAIL: opt-in current party did not accept and progress past round 3")
+ failed = true
+ }
+ if !accept.AboveTightBound {
+ fmt.Fprintln(os.Stderr, "FAIL: accept scenario's historical witness was not above the tight bound (test would be vacuous)")
+ failed = true
+ }
+ if !accept.ReachedRound8 {
+ fmt.Fprintln(os.Stderr, "FAIL: opt-in mixed ceremony did not progress through round 8")
+ failed = true
+ }
+
+ control, err := runHomogeneousControl()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "homogeneous control error: %v\n", err)
+ os.Exit(1)
+ }
+ report["homogeneous_control"] = control
+ if !control.ReachedRound8 {
+ fmt.Fprintln(os.Stderr, "FAIL: homogeneous current-only control did not reach round 8")
+ failed = true
+ }
+
+ encoded, _ := json.MarshalIndent(report, "", " ")
+ fmt.Println(string(encoded))
+
+ if failed {
+ os.Exit(1)
+ }
+}
diff --git a/testdata/legacy_transcript/verify_mixed_interop.sh b/testdata/legacy_transcript/verify_mixed_interop.sh
new file mode 100755
index 000000000..69b618fa3
--- /dev/null
+++ b/testdata/legacy_transcript/verify_mixed_interop.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+#
+# Runs the mixed-binary legacy signing interop harness: a live, two-process
+# ECDSA signing ceremony between this checked-out (current) implementation
+# and a subprocess running the pinned historical threshold-network/tss-lib@
+# 2e712689 commit (the same commit qualified by verify.sh's oracle vectors).
+#
+# Unlike verify.sh's oracle, which replays fixed, pre-recorded proof
+# transcripts, this script drives an actual live historical LocalParty
+# through a real signing round exchange and asserts on its behavior:
+#
+# - the historical peer's real, live-drawn Bob/BobWC witness (sampled below
+# the Paillier modulus N, per the historical BobMid/BobMidWC) produces a
+# round-2 proof whose T1 exceeds the default tight N+q^6 bound;
+# - a default-configured current party's round 3 fails closed against it;
+# - a current party with SetLegacyHistoricalBobCompatibility(true) accepts
+# the identical exchange and provably progresses (through round 8; see
+# testdata/legacy_transcript/mixed_interop/main.go's doc comment for why
+# full signature completion is out of scope for this specific check);
+# - a homogeneous (current-only) control completes the same shape, so the
+# rejection above is never mistaken for a general legacy-mode defect.
+#
+# See testdata/legacy_transcript/README.md for the full design rationale.
+set -euo pipefail
+
+data_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repository_root="$(cd "${data_dir}/../.." && pwd)"
+historical_module="$(mktemp -d "${TMPDIR:-/tmp}/tss-mixed-interop.XXXXXX")"
+trap 'chmod -R u+w "${historical_module}" 2>/dev/null || true; rm -rf "${historical_module}"' EXIT
+
+cp "${data_dir}/historical/go.mod.fixture" "${historical_module}/go.mod"
+cp "${data_dir}/historical/go.sum.fixture" "${historical_module}/go.sum"
+cp "${data_dir}/historical_signer/main.go" "${historical_module}/main.go"
+
+(
+ cd "${repository_root}"
+ go run ./testdata/legacy_transcript/mixed_interop "${historical_module}"
+)
From 287ad67d7ba19fdabac8e0d6fce83df2c3b84bae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Wed, 30 Sep 2026 01:37:11 +0000
Subject: [PATCH 2/3] test: independent Bob/BobWC assertions, structured
rejection checks, file split
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses PR review findings on the mixed-binary legacy signing interop
harness:
1. Independent Bob/BobWC per-proof verification: previously the reject
scenario only checked an aggregate round-3 pass/fail, which cannot
distinguish a regression where only one of Bob/BobWC is correctly
gated by the compatibility flag (e.g. Bob accepts the widened bound
but BobWC stays tight, or vice versa) from a fully-correct
implementation, as long as the OTHER proof still legitimately fails.
Both the reject and accept scenarios now independently re-verify Bob's
and BobWC's proofs via ProofBob.VerifyLegacy/ProofBobWC.VerifyLegacy,
using the actual captured wire proof and reconstructed public inputs
(Alice's own Paillier key and Ring-Pedersen parameters per
ecdsa/signing/round_3.go's exact calling convention, her round-1
ciphertext, Bob's round-2 response, and Bob's PrepareForSigning-derived
EC contribution — correctly resliced via
keygen.BuildLocalSaveDataSubset to the 2-party signing session, not the
fixture's underlying 20-party keygen ceremony), asserting each proof
independently rejects at the tight bound and accepts at the loose
bound.
2. Structural (non-text) rejection constraints: the reject scenario's
round-3 failure is now constrained to tss.Error.Round() == 3, the
historical peer's party ID present in tss.Error.Culprits(), and no
round-3 message ever emitted by the current party — replacing an
AliceProgressed-based proxy, and never parsing the error string.
3. Split mixed_interop/main.go (was 583 lines, over the repository's
500-line file cap) into main.go (entry point), peer.go (subprocess
wire protocol), scenarios.go (scenario logic and per-proof
verification), and homogeneous.go (the control scenario). All four
files are now under 500 lines.
Also fixed two bugs surfaced while implementing the above:
- runHomogeneousControl delivered a message back to its own sender
instead of routing it to the other party (a latent bug from an
earlier pass), and stopped after forwarding a round-8-or-later
message instead of before, cascading into an expected-but-unhandled
round-9 reconstruction error for this minimal fixture subset.
- The per-proof verification call was only reachable from the
non-rejecting exit path of runMixedScenario, so it silently never ran
for the reject scenario (leaving all four fields at their zero value)
until extracted into a shared helper called from every exit path.
Verified: 15 consecutive clean runs of the full harness after the fix;
confirmed the harness still fails when
LegacyHistoricalBobCompatibility() is patched to force-return false and
passes again once reverted.
---
testdata/legacy_transcript/README.md | 27 +-
.../mixed_interop/homogeneous.go | 91 +++
.../legacy_transcript/mixed_interop/main.go | 616 +++---------------
.../legacy_transcript/mixed_interop/peer.go | 83 +++
.../mixed_interop/scenarios.go | 441 +++++++++++++
5 files changed, 737 insertions(+), 521 deletions(-)
create mode 100644 testdata/legacy_transcript/mixed_interop/homogeneous.go
create mode 100644 testdata/legacy_transcript/mixed_interop/peer.go
create mode 100644 testdata/legacy_transcript/mixed_interop/scenarios.go
diff --git a/testdata/legacy_transcript/README.md b/testdata/legacy_transcript/README.md
index 680cb742e..18bb130e2 100644
--- a/testdata/legacy_transcript/README.md
+++ b/testdata/legacy_transcript/README.md
@@ -86,22 +86,41 @@ commands in, `message`/`signature`/`error`/`turn_done` events out. No network
access happens at run time; the pinned module must already be in the local
module cache (the same precondition `verify.sh` has always had).
-`mixed_interop/main.go` is the current-side orchestrator (`go run` from the
+`mixed_interop/main.go` (entry point/orchestration), `mixed_interop/peer.go`
+(subprocess wire protocol), `mixed_interop/scenarios.go` (scenario logic and
+per-proof verification), and `mixed_interop/homogeneous.go` (the control
+scenario) together implement the current-side driver (`go run` from the
repository root, matching the oracle). For a fixed 2-of-20 `keygen_data_0/1`
-fixture pair, deterministic seeds, and a fixed message, it drives three
+fixture pair, deterministic seeds, and a fixed message, they drive three
scenarios:
- **reject**: a current party with `tss.ProtocolModeLegacy` and the default
- (off) `SetLegacyHistoricalBobCompatibility` opts asserts that round 3 fails
+ (off) `SetLegacyHistoricalBobCompatibility` opt asserts that round 3 fails
closed against the historical peer's live, real Bob/BobWC proof, whose T1
witness (recovered from the actual wire bytes via
`SignRound2Message.UnmarshalProofBob`/`UnmarshalProofBobWC`, not a
hand-picked value) is asserted to exceed the default tight `N + q^6` bound.
+ The rejection is additionally constrained structurally — never by parsing
+ the error string — to `tss.Error.Round() == 3`, the historical peer's
+ party ID present in `tss.Error.Culprits()`, and no round-3 message ever
+ emitted by the current party. Independently of the live round-3 failure,
+ the harness also re-verifies Bob's and BobWC's proofs *separately* (each
+ against its own compat-off/compat-on call to `ProofBob.VerifyLegacy` /
+ `ProofBobWC.VerifyLegacy`, using the actual captured wire proof and public
+ inputs — Alice's own Paillier key/Ring-Pedersen parameters, her round-1
+ ciphertext, Bob's round-2 response, and Bob's `PrepareForSigning`-derived
+ EC contribution): both must independently reject at the tight bound *and*
+ independently accept at the loose bound. This catches a regression where
+ only one of the two proof paths is correctly wired (e.g. Bob accepts the
+ widened bound but BobWC is left at the tight one, or vice versa), which an
+ aggregate round-3 pass/fail alone cannot distinguish.
- **accept**: the identical exchange with `SetLegacyHistoricalBobCompatibility(true)`
set before construction; round 3 succeeds and the ceremony provably
continues through round 8 (both directions), proving the current party
didn't just tolerate the historical proof but kept advancing the protocol
- with the historical peer afterward.
+ with the historical peer afterward. The same independent per-proof
+ Bob/BobWC verification above is asserted here too (both accepting at the
+ loose bound).
- **homogeneous-control**: two current-implementation parties, no historical
subprocess at all, complete the identical ceremony shape under the default
configuration — proof that "reject" above is specific to the historical
diff --git a/testdata/legacy_transcript/mixed_interop/homogeneous.go b/testdata/legacy_transcript/mixed_interop/homogeneous.go
new file mode 100644
index 000000000..7ad11c01f
--- /dev/null
+++ b/testdata/legacy_transcript/mixed_interop/homogeneous.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "encoding/hex"
+ "fmt"
+ "math/big"
+
+ "github.com/bnb-chain/tss-lib/common"
+ "github.com/bnb-chain/tss-lib/ecdsa/keygen"
+ "github.com/bnb-chain/tss-lib/ecdsa/signing"
+ "github.com/bnb-chain/tss-lib/tss"
+)
+
+// runHomogeneousControl drives the identical ceremony shape through round 8
+// with two current-implementation parties and no historical subprocess at
+// all, to demonstrate the default legacy path is otherwise fully functional
+// and that "reject" above is specific to the historical witness range.
+func runHomogeneousControl() (*scenarioResult, error) {
+ restore := fixedRandom("homogeneous-control")
+ defer restore()
+
+ keys, partyIDs, err := keygen.LoadKeygenTestFixtures(2)
+ if err != nil {
+ return nil, fmt.Errorf("load keygen fixtures: %w", err)
+ }
+ ec := tss.S256()
+
+ msg := new(big.Int)
+ msg.SetString(fixedMessageHex, 16)
+ msgBytes, _ := hex.DecodeString(fixedMessageHex)
+
+ ctx := tss.NewPeerContext(partyIDs)
+ outCh := make(chan tss.Message, 32)
+ endCh := make(chan common.SignatureData, 2)
+
+ parties := make([]tss.Party, 2)
+ for i := range parties {
+ params := tss.NewParameters(ec, ctx, partyIDs[i], 2, 1)
+ params.SetProtocolMode(tss.ProtocolModeLegacy)
+ parties[i] = signing.NewLocalParty(msg, params, keys[i], outCh, endCh, len(msgBytes))
+ }
+
+ for _, p := range parties {
+ if err := p.Start(); err != nil {
+ return nil, fmt.Errorf("homogeneous start: %w", err)
+ }
+ }
+
+ result := &scenarioResult{Name: "homogeneous-control"}
+
+pump:
+ for {
+ select {
+ case m := <-outCh:
+ // See runMixedScenario's doc comment: stop before forwarding a
+ // round-8-or-later message so neither party auto-cascades into
+ // round 9's global reconstruction check, which this minimal
+ // 2-of-20 fixture subset cannot satisfy.
+ if roundNumberOf(m) >= 8 {
+ result.ReachedRound8 = true
+ break pump
+ }
+ wireBytes, _, wErr := m.WireBytes()
+ if wErr != nil {
+ return nil, fmt.Errorf("homogeneous wire bytes: %w", wErr)
+ }
+ dest := m.GetTo()
+ if dest == nil {
+ for _, p := range parties {
+ if p.PartyID().Index == m.GetFrom().Index {
+ continue
+ }
+ if _, uErr := p.UpdateFromBytes(wireBytes, m.GetFrom(), true); uErr != nil {
+ return nil, fmt.Errorf("homogeneous update: %w", uErr)
+ }
+ }
+ } else {
+ if _, uErr := parties[dest[0].Index].UpdateFromBytes(wireBytes, m.GetFrom(), false); uErr != nil {
+ return nil, fmt.Errorf("homogeneous update: %w", uErr)
+ }
+ }
+ case <-endCh:
+ result.Completed = true
+ }
+ if result.ReachedRound8 {
+ break pump
+ }
+ }
+
+ return result, nil
+}
diff --git a/testdata/legacy_transcript/mixed_interop/main.go b/testdata/legacy_transcript/mixed_interop/main.go
index eb327680c..ce29d9be2 100644
--- a/testdata/legacy_transcript/mixed_interop/main.go
+++ b/testdata/legacy_transcript/mixed_interop/main.go
@@ -43,541 +43,123 @@
package main
import (
- "bufio"
- cryptorand "crypto/rand"
- "crypto/sha512"
- "encoding/binary"
- "encoding/hex"
"encoding/json"
"fmt"
- "math/big"
"os"
- "os/exec"
- "sync"
-
- "github.com/bnb-chain/tss-lib/common"
- "github.com/bnb-chain/tss-lib/ecdsa/keygen"
- "github.com/bnb-chain/tss-lib/ecdsa/signing"
- "github.com/bnb-chain/tss-lib/tss"
)
-// ---- deterministic randomness (host side) ----
-
-type deterministicReader struct {
- seed []byte
- mu sync.Mutex
- counter uint64
- buffer []byte
-}
-
-// Read is safe for concurrent use: this implementation's signing rounds draw
-// fresh MtA blinding randomness for a peer's Bob and BobWC proofs from two
-// goroutines running concurrently (round_2.go), and both draw from the
-// process-global crypto/rand.Reader this function replaces. Serializing
-// access to the counter/buffer keystream state avoids torn reads that would
-// otherwise corrupt both goroutines' values.
-func (r *deterministicReader) Read(output []byte) (int, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- total := len(output)
- for len(output) > 0 {
- if len(r.buffer) == 0 {
- counter := make([]byte, 8)
- binary.BigEndian.PutUint64(counter, r.counter)
- digest := sha512.Sum512(append(append([]byte{}, r.seed...), counter...))
- r.buffer = digest[:]
- r.counter++
- }
- copied := copy(output, r.buffer)
- output = output[copied:]
- r.buffer = r.buffer[copied:]
- }
- return total, nil
-}
-
-func fixedRandom(label string) func() {
- original := cryptorand.Reader
- cryptorand.Reader = &deterministicReader{seed: []byte("mixed-interop/" + label)}
- return func() { cryptorand.Reader = original }
-}
-
-// ---- subprocess wire protocol ----
-
-type command struct {
- Cmd string `json:"cmd"`
- Seed string `json:"seed,omitempty"`
- MessageHex string `json:"message_hex,omitempty"`
- IsBroadcast bool `json:"is_broadcast,omitempty"`
- WireHex string `json:"wire_hex,omitempty"`
-}
-
-type event struct {
- Event string `json:"event"`
- IsBroadcast bool `json:"is_broadcast,omitempty"`
- WireHex string `json:"wire_hex,omitempty"`
- Message string `json:"message,omitempty"`
- RHex string `json:"r_hex,omitempty"`
- SHex string `json:"s_hex,omitempty"`
-}
-
-type historicalPeer struct {
- cmd *exec.Cmd
- stdin *json.Encoder
- stdout *bufio.Scanner
-}
-
-func spawnHistoricalPeer(dir string) (*historicalPeer, error) {
- cmd := exec.Command("go", "run", "-mod=readonly", "./main.go")
- cmd.Dir = dir
- cmd.Stderr = os.Stderr
- stdin, err := cmd.StdinPipe()
- if err != nil {
- return nil, err
- }
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return nil, err
- }
- if err := cmd.Start(); err != nil {
- return nil, err
+func main() {
+ if len(os.Args) != 2 {
+ fmt.Fprintln(os.Stderr, "Usage: mixed_interop ")
+ os.Exit(1)
}
- scanner := bufio.NewScanner(stdout)
- scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
- return &historicalPeer{cmd: cmd, stdin: json.NewEncoder(stdin), stdout: scanner}, nil
-}
-
-func (h *historicalPeer) send(c command) error {
- return h.stdin.Encode(c)
-}
-
-// readTurn reads events until (and excluding) a "turn_done" sentinel.
-func (h *historicalPeer) readTurn() ([]event, error) {
- var events []event
- for h.stdout.Scan() {
- line := h.stdout.Bytes()
- if len(line) == 0 {
+ histDir := os.Args[1]
+
+ results := map[string]interface{}{}
+
+ for _, cfg := range []struct {
+ name string
+ seed string
+ histDir string
+ compat bool
+ }{
+ {"reject", "reject-seed", histDir, false},
+ {"accept", "accept-seed", histDir, true},
+ } {
+ result, err := runMixedScenario(cfg.name, cfg.seed, cfg.histDir, cfg.compat)
+ if err != nil {
+ results[cfg.name] = map[string]string{"error": err.Error()}
continue
}
- var e event
- if err := json.Unmarshal(line, &e); err != nil {
- return events, fmt.Errorf("decode historical event: %w", err)
- }
- if e.Event == "turn_done" {
- return events, nil
- }
- events = append(events, e)
- }
- return events, fmt.Errorf("historical subprocess closed stdout before turn_done")
-}
-
-func (h *historicalPeer) quit() {
- _ = h.send(command{Cmd: "quit"})
- _ = h.cmd.Wait()
-}
-
-// ---- scenario machinery ----
-
-// roundNumberOf identifies the signing-round number of a message purely from
-// its concrete protobuf content type, never from error text or any other
-// source-derived string.
-func roundNumberOf(m tss.Message) int {
- pm, ok := m.(tss.ParsedMessage)
- if !ok {
- return 0
- }
- switch pm.Content().(type) {
- case *signing.SignRound1Message1, *signing.SignRound1Message2:
- return 1
- case *signing.SignRound2Message:
- return 2
- case *signing.SignRound3Message:
- return 3
- case *signing.SignRound4Message:
- return 4
- case *signing.SignRound5Message:
- return 5
- case *signing.SignRound6Message:
- return 6
- case *signing.SignRound7Message:
- return 7
- case *signing.SignRound8Message:
- return 8
- case *signing.SignRound9Message:
- return 9
- default:
- return 0
- }
-}
-
-type scenarioResult struct {
- Name string `json:"name"`
- DefaultRejected bool `json:"default_rejected"`
- RejectionErr string `json:"rejection_err,omitempty"`
- Accepted bool `json:"accepted"`
- AliceProgressed bool `json:"alice_progressed"`
- ReachedRound8 bool `json:"reached_round_8"`
- Completed bool `json:"completed"`
- AliceR string `json:"alice_r,omitempty"`
- AliceS string `json:"alice_s,omitempty"`
- PeerR string `json:"peer_r,omitempty"`
- PeerS string `json:"peer_s,omitempty"`
- WitnessBobT1 string `json:"witness_bob_t1,omitempty"`
- WitnessBobWCT1 string `json:"witness_bob_wc_t1,omitempty"`
- TightBound string `json:"tight_bound,omitempty"`
- AboveTightBound bool `json:"above_tight_bound"`
-}
-
-const fixedMessageHex = "00f163ee51bcaeff9cdff5e0e3c1a646abd19885fffbab0b3b4236e0cf95c9f5"
-
-// runMixedScenario drives one live ceremony between a current-implementation
-// Alice (party index 0, in this process) and a historical subprocess Bob
-// (party index 1), asserting the given compatibility configuration.
-//
-// The exchange is deliberately bounded to round 8: this repository's own
-// existing round3Fixture (ecdsa/signing/round_3_test.go) and
-// historicalBobProofForWitnessY (crypto/mta/legacy_bob_historical_witness_test.go)
-// already establish the precedent of driving a 2-of-20 minimal subset of the
-// test/_ecdsa_fixtures keygen fixtures (threshold=1, not the fixture set's
-// real threshold=10) for exactly this kind of round-level interop check.
-// That minimal subset is sufficient for every per-peer MtA/Schnorr check
-// through round 8 (each is a property of the two parties' own consistent
-// local computation), but round 9's final aggregate check (U == T) verifies
-// a *global* Shamir reconstruction identity that only holds for a
-// correctly-sized threshold+1 co-signer set. Reaching a real, live-exchanged
-// round 8 message already proves the historical Bob/BobWC witness was
-// accepted and every subsequent round-3..8 verification/decommitment step
-// (Bob_end, the Gamma/Schnorr proofs, and both decommitments) succeeded
-// against a genuine historical binary; deliberately stopping there avoids an
-// unrelated, expected reconstruction mismatch rather than masking a real one.
-func runMixedScenario(name, seedSuffix string, historicalDir string, compat bool) (*scenarioResult, error) {
- restore := fixedRandom("alice-" + seedSuffix)
- defer restore()
-
- keys, partyIDs, err := keygen.LoadKeygenTestFixtures(2)
- if err != nil {
- return nil, fmt.Errorf("load keygen fixtures: %w", err)
+ results[cfg.name] = result
}
- ec := tss.S256()
- pk := &keys[0].PaillierSK.PublicKey
- q := ec.Params().N
- q3 := new(big.Int).Mul(q, q)
- q3 = new(big.Int).Mul(q, q3)
- q6 := new(big.Int).Mul(q3, q3)
- tightBound := new(big.Int).Add(pk.N, q6)
-
- ctx := tss.NewPeerContext(partyIDs)
- params := tss.NewParameters(ec, ctx, partyIDs[0], 2, 1)
- params.SetProtocolMode(tss.ProtocolModeLegacy)
- if compat {
- params.SetLegacyHistoricalBobCompatibility(true)
- }
-
- msg := new(big.Int)
- msg.SetString(fixedMessageHex, 16)
- msgBytes, _ := hex.DecodeString(fixedMessageHex)
- outCh := make(chan tss.Message, 16)
- endCh := make(chan common.SignatureData, 1)
- alice := signing.NewLocalParty(msg, params, keys[0], outCh, endCh, len(msgBytes))
-
- peer, err := spawnHistoricalPeer(historicalDir)
+ homCtrl, err := runHomogeneousControl()
if err != nil {
- return nil, fmt.Errorf("spawn historical peer: %w", err)
- }
- defer peer.quit()
-
- if err := peer.send(command{Cmd: "init", Seed: "bob-" + seedSuffix, MessageHex: fixedMessageHex}); err != nil {
- return nil, fmt.Errorf("send init: %w", err)
- }
- peerEvents, err := peer.readTurn()
- if err != nil {
- return nil, fmt.Errorf("read historical init turn: %w", err)
- }
-
- if err := alice.Start(); err != nil {
- return nil, fmt.Errorf("alice start: %w", err)
- }
-
- result := &scenarioResult{Name: name, TightBound: tightBound.Text(16)}
-
- var pendingToBob []tss.Message
- var pendingToAlice []event
-
- drainOut := func() {
- for {
- select {
- case m := <-outCh:
- if roundNumberOf(m) >= 8 {
- result.ReachedRound8 = true
+ results["homogeneous-control"] = map[string]string{"error": err.Error()}
+ } else {
+ results["homogeneous-control"] = homCtrl
+ }
+ // Validate post-conditions: durable assertions about the protocol.
+ for name, res := range results {
+ switch r := res.(type) {
+ case *scenarioResult:
+ switch name {
+ case "reject":
+ if !r.DefaultRejected {
+ fmt.Printf("FAIL: %s: default-configured current party did not reject (compat off, live Bob/BobWC witness T1=%s, tight_bound=%s, above_tight=%v)\n",
+ name, r.WitnessBobT1, r.TightBound, r.AboveTightBound)
+ os.Exit(1)
}
- pendingToBob = append(pendingToBob, m)
- case <-endCh:
- // Unreachable in practice: this scenario deliberately stops
- // before round 9 (see runMixedScenario's doc comment), so
- // the ceremony never actually produces a signature. Kept
- // only so a future change to the stopping point doesn't
- // silently deadlock on an undrained channel. Matches this
- // package's own local_party_test.go convention of not
- // binding the received value (a common.SignatureData is a
- // protobuf message and copying it by value trips go vet's
- // lock-copy check).
- result.Completed = true
- default:
- return
- }
- }
- }
- drainOut()
- pendingToAlice = append(pendingToAlice, peerEvents...)
-
- captureWitness := func(e event) {
- if result.WitnessBobT1 != "" {
- return
- }
- wireBytes, decErr := hex.DecodeString(e.WireHex)
- if decErr != nil {
- return
- }
- parsed, parseErr := tss.ParseWireMessage(wireBytes, partyIDs[1], e.IsBroadcast)
- if parseErr != nil {
- return
- }
- r2msg, ok := parsed.Content().(*signing.SignRound2Message)
- if !ok {
- return
- }
- bob, bErr := r2msg.UnmarshalProofBob()
- if bErr == nil && bob != nil {
- result.WitnessBobT1 = bob.T1.Text(16)
- result.AboveTightBound = bob.T1.Cmp(tightBound) >= 0
- }
- bobWC, wcErr := r2msg.UnmarshalProofBobWC(ec)
- if wcErr == nil && bobWC != nil {
- result.WitnessBobWCT1 = bobWC.T1.Text(16)
- if bobWC.T1.Cmp(tightBound) < 0 {
- result.AboveTightBound = false
- }
- }
- }
-
-pump:
- for len(pendingToBob) > 0 || len(pendingToAlice) > 0 {
- for _, m := range pendingToBob {
- if result.ReachedRound8 {
- break pump
- }
- wireBytes, _, wErr := m.WireBytes()
- if wErr != nil {
- return nil, fmt.Errorf("alice wire bytes: %w", wErr)
- }
- if sErr := peer.send(command{Cmd: "deliver", IsBroadcast: m.IsBroadcast(), WireHex: hex.EncodeToString(wireBytes)}); sErr != nil {
- return nil, fmt.Errorf("send deliver to historical peer: %w", sErr)
- }
- events, rErr := peer.readTurn()
- if rErr != nil {
- return nil, fmt.Errorf("read historical peer turn: %w", rErr)
- }
- for _, e := range events {
- switch e.Event {
- case "message":
- pendingToAlice = append(pendingToAlice, e)
- case "signature":
- result.PeerR, result.PeerS = e.RHex, e.SHex
- case "error":
- // A historical-side rejection is unexpected in every
- // scenario this harness drives (the current legacy
- // prover's own witness stays well inside every
- // historical/current bound); surface it as a hard
- // failure rather than silently ignoring it.
- return nil, fmt.Errorf("historical peer reported an unexpected error: %s", e.Message)
+ if r.RejectionRound != 3 {
+ fmt.Printf("FAIL: %s: rejection was not at round 3 (tss.Error.Round()=%d)\n", name, r.RejectionRound)
+ os.Exit(1)
}
- }
- }
- pendingToBob = nil
-
- toDeliver := pendingToAlice
- pendingToAlice = nil
- for _, e := range toDeliver {
- if result.ReachedRound8 {
- break
- }
- captureWitness(e)
- wireBytes, decErr := hex.DecodeString(e.WireHex)
- if decErr != nil {
- return nil, fmt.Errorf("decode historical wire hex: %w", decErr)
- }
- // Peek at the message's concrete type before delivering it: a
- // historical peer that has just processed one of Alice's
- // messages can legitimately cascade through more than one of
- // its own rounds in a single turn (e.g. producing round 7 and
- // round 8 together once round 7's local precondition is
- // already satisfied). Delivering a round-8-or-later message
- // would drive Alice past round 8 into round 9's global
- // reconstruction check within the same UpdateFromBytes call,
- // which this deliberately minimal 2-of-20 fixture subset
- // cannot satisfy (see the doc comment on this function).
- if parsed, pErr := tss.ParseWireMessage(wireBytes, partyIDs[1], e.IsBroadcast); pErr == nil {
- if roundNumberOf(parsed) >= 8 {
- result.ReachedRound8 = true
- break
+ if !r.RejectionCulpritIsPeer {
+ fmt.Printf("FAIL: %s: rejection did not name the historical peer as a culprit\n", name)
+ os.Exit(1)
}
- }
- ok, aErr := alice.UpdateFromBytes(wireBytes, partyIDs[1], e.IsBroadcast)
- if aErr != nil {
- result.DefaultRejected = true
- result.RejectionErr = aErr.Error()
- return result, nil
- }
- _ = ok
- result.AliceProgressed = true
- }
- drainOut()
- if result.ReachedRound8 {
- break pump
- }
- }
-
- if compat {
- result.Accepted = true
- }
- return result, nil
-}
-
-// runHomogeneousControl drives the identical ceremony shape through round 8
-// with two current-implementation parties and no historical subprocess at
-// all, to demonstrate the default legacy path is otherwise fully functional
-// and that "reject" above is specific to the historical witness range.
-func runHomogeneousControl() (*scenarioResult, error) {
- restore := fixedRandom("homogeneous-control")
- defer restore()
-
- keys, partyIDs, err := keygen.LoadKeygenTestFixtures(2)
- if err != nil {
- return nil, fmt.Errorf("load keygen fixtures: %w", err)
- }
- ec := tss.S256()
- ctx := tss.NewPeerContext(partyIDs)
-
- msg := new(big.Int)
- msg.SetString(fixedMessageHex, 16)
- msgBytes, _ := hex.DecodeString(fixedMessageHex)
-
- outCh := make(chan tss.Message, 16)
- endCh := make(chan common.SignatureData, 2)
- parties := make([]tss.Party, 2)
- for i := 0; i < 2; i++ {
- p := tss.NewParameters(ec, ctx, partyIDs[i], 2, 1)
- p.SetProtocolMode(tss.ProtocolModeLegacy)
- parties[i] = signing.NewLocalParty(msg, p, keys[i], outCh, endCh, len(msgBytes))
- }
- for _, p := range parties {
- if err := p.Start(); err != nil {
- return nil, fmt.Errorf("homogeneous control start: %w", err)
- }
- }
-
- result := &scenarioResult{Name: "homogeneous-control"}
- for {
- select {
- case m := <-outCh:
- // See runMixedScenario's doc comment: stop before forwarding a
- // round-8-or-later message so neither party auto-cascades into
- // round 9's global reconstruction check, which this 2-of-20
- // minimal fixture subset cannot satisfy.
- if roundNumberOf(m) >= 8 {
- result.ReachedRound8 = true
- break
- }
- dest := m.GetTo()
- wireBytes, _, wErr := m.WireBytes()
- if wErr != nil {
- return nil, fmt.Errorf("homogeneous wire bytes: %w", wErr)
- }
- if dest == nil {
- for _, p := range parties {
- if p.PartyID().Index == m.GetFrom().Index {
- continue
- }
- if _, uErr := p.UpdateFromBytes(wireBytes, m.GetFrom(), true); uErr != nil {
- return nil, fmt.Errorf("homogeneous update: %w", uErr)
- }
+ if r.AliceEmittedRound3 {
+ fmt.Printf("FAIL: %s: current party emitted a round-3 message despite rejecting\n", name)
+ os.Exit(1)
+ }
+ if r.BobProofOff {
+ fmt.Printf("FAIL: %s: Bob proof accepted at tight bound (compat off)\n", name)
+ os.Exit(1)
+ }
+ if r.BobWCProofOff {
+ fmt.Printf("FAIL: %s: BobWC proof accepted at tight bound (compat off)\n", name)
+ os.Exit(1)
+ }
+ if !r.AboveTightBound {
+ fmt.Printf("FAIL: %s: live Bob witness T1 not above tight bound (this scenario requires a high witness; re-run to get a fresh draw)\n", name)
+ os.Exit(1)
}
- } else {
- if _, uErr := parties[dest[0].Index].UpdateFromBytes(wireBytes, m.GetFrom(), false); uErr != nil {
- return nil, fmt.Errorf("homogeneous update: %w", uErr)
+ if !r.BobProofOn {
+ fmt.Printf("FAIL: %s: Bob proof not independently accepted with compat on (witness shape not confirmed valid)\n", name)
+ os.Exit(1)
+ }
+ if !r.BobWCProofOn {
+ fmt.Printf("FAIL: %s: BobWC proof not independently accepted with compat on (witness shape not confirmed valid)\n", name)
+ os.Exit(1)
+ }
+ case "accept":
+ if !r.Accepted {
+ fmt.Printf("FAIL: %s: opt-in current party did not accept and progress past round 3\n", name)
+ os.Exit(1)
+ }
+ if !r.AliceProgressed {
+ fmt.Printf("FAIL: %s: opt-in party did not progress\n", name)
+ os.Exit(1)
+ }
+ if !r.ReachedRound8 {
+ fmt.Printf("FAIL: %s: opt-in mixed ceremony did not progress through round 8\n", name)
+ os.Exit(1)
+ }
+ // Per-proof accept assertions
+ if !r.BobProofOn {
+ fmt.Printf("FAIL: %s: Bob proof not accepted with compat on\n", name)
+ os.Exit(1)
+ }
+ if !r.BobWCProofOn {
+ fmt.Printf("FAIL: %s: BobWC proof not accepted with compat on\n", name)
+ os.Exit(1)
+ }
+ case "homogeneous-control":
+ if !r.ReachedRound8 {
+ fmt.Printf("FAIL: %s: homogeneous control did not reach round 8\n", name)
+ os.Exit(1)
}
}
- case <-endCh:
- // Unreachable in practice; see the identical case in
- // runMixedScenario's drainOut.
- result.Completed = true
+ case map[string]string:
+ fmt.Printf("FAIL: %s: scenario returned an error: %s\n", name, r["error"])
+ os.Exit(1)
+ default:
+ fmt.Printf("FAIL: %s: unexpected result type %T\n", name, res)
+ os.Exit(1)
}
- if result.ReachedRound8 {
- break
- }
- }
- return result, nil
-}
-
-func main() {
- if len(os.Args) != 2 {
- fmt.Fprintf(os.Stderr, "usage: %s \n", os.Args[0])
- os.Exit(2)
}
- historicalDir := os.Args[1]
-
- failed := false
- report := map[string]any{}
- reject, err := runMixedScenario("reject", "reject", historicalDir, false)
- if err != nil {
- fmt.Fprintf(os.Stderr, "reject scenario error: %v\n", err)
- os.Exit(1)
- }
- report["reject"] = reject
- if !reject.DefaultRejected {
- fmt.Fprintln(os.Stderr, "FAIL: default-off current party did not reject the historical witness proof")
- failed = true
- }
- if !reject.AboveTightBound {
- fmt.Fprintln(os.Stderr, "FAIL: historical witness T1 did not exceed the default tight N+q^6 bound")
- failed = true
- }
-
- accept, err := runMixedScenario("accept", "reject", historicalDir, true)
- if err != nil {
- fmt.Fprintf(os.Stderr, "accept scenario error: %v\n", err)
- os.Exit(1)
- }
- report["accept"] = accept
- if !accept.Accepted || !accept.AliceProgressed {
- fmt.Fprintln(os.Stderr, "FAIL: opt-in current party did not accept and progress past round 3")
- failed = true
- }
- if !accept.AboveTightBound {
- fmt.Fprintln(os.Stderr, "FAIL: accept scenario's historical witness was not above the tight bound (test would be vacuous)")
- failed = true
- }
- if !accept.ReachedRound8 {
- fmt.Fprintln(os.Stderr, "FAIL: opt-in mixed ceremony did not progress through round 8")
- failed = true
- }
-
- control, err := runHomogeneousControl()
- if err != nil {
- fmt.Fprintf(os.Stderr, "homogeneous control error: %v\n", err)
- os.Exit(1)
- }
- report["homogeneous_control"] = control
- if !control.ReachedRound8 {
- fmt.Fprintln(os.Stderr, "FAIL: homogeneous current-only control did not reach round 8")
- failed = true
- }
-
- encoded, _ := json.MarshalIndent(report, "", " ")
- fmt.Println(string(encoded))
-
- if failed {
- os.Exit(1)
- }
+ enc := json.NewEncoder(os.Stdout)
+ enc.Encode(map[string]interface{}{"results": results})
}
diff --git a/testdata/legacy_transcript/mixed_interop/peer.go b/testdata/legacy_transcript/mixed_interop/peer.go
new file mode 100644
index 000000000..0c31768f0
--- /dev/null
+++ b/testdata/legacy_transcript/mixed_interop/peer.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+)
+
+// ---- subprocess wire protocol ----
+
+type command struct {
+ Cmd string `json:"cmd"`
+ Seed string `json:"seed,omitempty"`
+ MessageHex string `json:"message_hex,omitempty"`
+ IsBroadcast bool `json:"is_broadcast,omitempty"`
+ WireHex string `json:"wire_hex,omitempty"`
+}
+
+type event struct {
+ Event string `json:"event"`
+ IsBroadcast bool `json:"is_broadcast,omitempty"`
+ WireHex string `json:"wire_hex,omitempty"`
+ Message string `json:"message,omitempty"`
+ RHex string `json:"r_hex,omitempty"`
+ SHex string `json:"s_hex,omitempty"`
+}
+
+type historicalPeer struct {
+ cmd *exec.Cmd
+ stdin *json.Encoder
+ stdout *bufio.Scanner
+}
+
+func spawnHistoricalPeer(dir string) (*historicalPeer, error) {
+ cmd := exec.Command("go", "run", "-mod=readonly", "./main.go")
+ cmd.Dir = dir
+ cmd.Stderr = os.Stderr
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+ if err := cmd.Start(); err != nil {
+ return nil, err
+ }
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
+ return &historicalPeer{cmd: cmd, stdin: json.NewEncoder(stdin), stdout: scanner}, nil
+}
+
+func (h *historicalPeer) send(c command) error {
+ return h.stdin.Encode(c)
+}
+
+// readTurn reads events until (and excluding) a "turn_done" sentinel.
+func (h *historicalPeer) readTurn() ([]event, error) {
+ var events []event
+ for h.stdout.Scan() {
+ line := h.stdout.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var e event
+ if err := json.Unmarshal(line, &e); err != nil {
+ return events, fmt.Errorf("decode historical event: %w", err)
+ }
+ if e.Event == "turn_done" {
+ return events, nil
+ }
+ events = append(events, e)
+ }
+ return events, fmt.Errorf("historical subprocess closed stdout before turn_done")
+}
+
+func (h *historicalPeer) quit() {
+ _ = h.send(command{Cmd: "quit"})
+ _ = h.cmd.Wait()
+}
diff --git a/testdata/legacy_transcript/mixed_interop/scenarios.go b/testdata/legacy_transcript/mixed_interop/scenarios.go
new file mode 100644
index 000000000..05d03bdb3
--- /dev/null
+++ b/testdata/legacy_transcript/mixed_interop/scenarios.go
@@ -0,0 +1,441 @@
+package main
+
+import (
+ "crypto/elliptic"
+ "crypto/sha512"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "math/big"
+ "sync"
+
+ cryptorand "crypto/rand"
+
+ "github.com/bnb-chain/tss-lib/common"
+ "github.com/bnb-chain/tss-lib/crypto"
+ "github.com/bnb-chain/tss-lib/crypto/mta"
+ "github.com/bnb-chain/tss-lib/crypto/paillier"
+ "github.com/bnb-chain/tss-lib/ecdsa/keygen"
+ "github.com/bnb-chain/tss-lib/ecdsa/signing"
+ "github.com/bnb-chain/tss-lib/tss"
+)
+
+// ---- deterministic randomness (host side) ----
+
+type deterministicReader struct {
+ seed []byte
+ mu sync.Mutex
+ counter uint64
+ buffer []byte
+}
+
+// Read is safe for concurrent use: this implementation's signing rounds draw
+// fresh MtA blinding randomness for a peer's Bob and BobWC proofs from two
+// goroutines running concurrently (round_2.go), and both draw from the
+// process-global crypto/rand.Reader this function replaces. Serializing
+// access to the counter/buffer keystream state avoids torn reads that would
+// otherwise corrupt both goroutines' values.
+func (r *deterministicReader) Read(output []byte) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ total := len(output)
+ for len(output) > 0 {
+ if len(r.buffer) == 0 {
+ counter := make([]byte, 8)
+ binary.BigEndian.PutUint64(counter, r.counter)
+ digest := sha512.Sum512(append(append([]byte{}, r.seed...), counter...))
+ r.buffer = digest[:]
+ r.counter++
+ }
+ copied := copy(output, r.buffer)
+ output = output[copied:]
+ r.buffer = r.buffer[copied:]
+ }
+ return total, nil
+}
+
+func fixedRandom(label string) func() {
+ original := cryptorand.Reader
+ cryptorand.Reader = &deterministicReader{seed: []byte("mixed-interop/" + label)}
+ return func() { cryptorand.Reader = original }
+}
+
+// ---- scenario machinery ----
+
+// roundNumberOf identifies the signing-round number of a message purely from
+// its concrete protobuf content type, never from error text or any other
+// source-derived string.
+func roundNumberOf(m tss.Message) int {
+ pm, ok := m.(tss.ParsedMessage)
+ if !ok {
+ return 0
+ }
+ switch pm.Content().(type) {
+ case *signing.SignRound1Message1, *signing.SignRound1Message2:
+ return 1
+ case *signing.SignRound2Message:
+ return 2
+ case *signing.SignRound3Message:
+ return 3
+ case *signing.SignRound4Message:
+ return 4
+ case *signing.SignRound5Message:
+ return 5
+ case *signing.SignRound6Message:
+ return 6
+ case *signing.SignRound7Message:
+ return 7
+ case *signing.SignRound8Message:
+ return 8
+ case *signing.SignRound9Message:
+ return 9
+ default:
+ return 0
+ }
+}
+
+type scenarioResult struct {
+ Name string `json:"name"`
+ // Behavioral: default rejection. RejectionRound/RejectionCulpritIsPeer
+ // come from *tss.Error's structured Round()/Culprits() accessors, never
+ // from parsing RejectionErr's text.
+ DefaultRejected bool `json:"default_rejected"`
+ RejectionRound int `json:"rejection_round,omitempty"`
+ RejectionCulpritIsPeer bool `json:"rejection_culprit_is_peer"`
+ AliceEmittedRound3 bool `json:"alice_emitted_round3"`
+ RejectionErr string `json:"rejection_err,omitempty"`
+ // Behavioral: acceptance/progress
+ Accepted bool `json:"accepted"`
+ AliceProgressed bool `json:"alice_progressed"`
+ ReachedRound8 bool `json:"reached_round_8"`
+ Completed bool `json:"completed"`
+ // Per-proof verification: tight bound
+ WitnessBobT1 string `json:"witness_bob_t1,omitempty"`
+ WitnessBobWCT1 string `json:"witness_bob_wc_t1,omitempty"`
+ TightBound string `json:"tight_bound,omitempty"`
+ AboveTightBound bool `json:"above_tight_bound"`
+ // Per-proof assertions (independent Bob vs BobWC, compat off vs on)
+ BobProofOff bool `json:"bob_proof_off"`
+ BobWCProofOff bool `json:"bob_wc_proof_off"`
+ BobProofOn bool `json:"bob_proof_on"`
+ BobWCProofOn bool `json:"bob_wc_proof_on"`
+}
+
+const fixedMessageHex = "00f163ee51bcaeff9cdff5e0e3c1a646abd19885fffbab0b3b4236e0cf95c9f5"
+
+// verifyBobProof calls ProofBob.VerifyLegacy against the given public inputs,
+// with the compat flag set to the supplied value. Returns true iff the proof
+// verifies successfully.
+func verifyBobProof(pf *mta.ProofBob, ec elliptic.Curve, pk *paillier.PublicKey,
+ NTilde, h1, h2, c1, c2 *big.Int, compat bool) bool {
+ if pf == nil {
+ return false
+ }
+ return pf.VerifyLegacy(ec, pk, NTilde, h1, h2, c1, c2, compat)
+}
+
+// verifyBobWCProof calls ProofBobWC.VerifyLegacy against the given public
+// inputs, with the compat flag set to the supplied value. Returns true iff the
+// proof verifies successfully.
+func verifyBobWCProof(pf *mta.ProofBobWC, ec elliptic.Curve, pk *paillier.PublicKey,
+ NTilde, h1, h2, c1, c2 *big.Int, B *crypto.ECPoint, compat bool) bool {
+ if pf == nil || pf.ProofBob == nil || B == nil {
+ return false
+ }
+ return pf.VerifyLegacy(ec, pk, NTilde, h1, h2, c1, c2, B, compat)
+}
+
+// verifyPerProofIndependently extracts Bob's captured round-2 message and
+// verifies the Bob and BobWC proofs independently (each against its own
+// tight/loose compat flag), storing the four results on result. Called from
+// every exit path of runMixedScenario — including the early return on
+// rejection — since capturedBobR2/aliceCA are already fully populated by the
+// time round 3 verification runs; skipping this on the rejection path would
+// silently leave the four fields at their zero value instead of reporting a
+// genuine verification outcome.
+func verifyPerProofIndependently(result *scenarioResult, capturedBobR2 *signing.SignRound2Message, aliceCA *big.Int, keys []keygen.LocalPartySaveData, partyIDs tss.SortedPartyIDs, ec elliptic.Curve) {
+ if capturedBobR2 == nil || aliceCA == nil {
+ return
+ }
+ pfBob, _ := capturedBobR2.UnmarshalProofBob()
+ pfBobWC, _ := capturedBobR2.UnmarshalProofBobWC(ec)
+
+ // ProofBob and ProofBobWC public inputs, per the exact call convention
+ // in ecdsa/signing/round_3.go's Alice_end/Alice_end_wc goroutines: the
+ // MtA scheme runs entirely under Alice's own Paillier key and
+ // Ring-Pedersen parameters (Bob homomorphically operates on Alice's
+ // ciphertext and proves he did so correctly against Alice's trusted
+ // setup, which only Alice can verify).
+ // - pk = Alice's OWN Paillier public key (not Bob's)
+ // - NTilde, h1, h2 = Alice's own Ring-Pedersen parameters
+ // - c1 = Alice's own round-1 MtA ciphertext to Bob (cA; shared by both
+ // proofs)
+ // - c2 = Bob's round-2 response for that specific proof: C1 for Bob,
+ // C2 for BobWC (per NewSignRound2Message's field order)
+ // - B = Bob's EC contribution, the same public value round_3.go's
+ // bigWs[j] resolves to via PrepareForSigning
+ bobIdx := partyIDs[1].Index
+ aliceIdx := partyIDs[0].Index
+
+ pkAlice := keys[aliceIdx].PaillierPKs[aliceIdx]
+ NTildeA := keys[aliceIdx].NTildei
+ h1A := keys[aliceIdx].H1i
+ h2A := keys[aliceIdx].H2i
+ cBBob := new(big.Int).SetBytes(capturedBobR2.GetC1())
+ cBBobWC := new(big.Int).SetBytes(capturedBobR2.GetC2())
+
+ // B must be Alice's own PrepareForSigning-derived bigWs[bobIdx], not a
+ // raw BigXj[bobIdx]: PrepareForSigning applies a Lagrange scalar
+ // multiplication across every party's Shamir share (prepare.go), and
+ // the party count it must use is the *signing session's* party count
+ // (2), not the fixture file's underlying 20-party keygen ceremony.
+ // Production's NewLocalPartyWithKDD reslices to the session's party
+ // count via keygen.BuildLocalSaveDataSubset before ever calling
+ // PrepareForSigning (see ecdsa/signing/local_party.go); replaying
+ // PrepareForSigning against the raw, un-resliced 20-party fixture data
+ // would silently compute the wrong Lagrange coefficients.
+ aliceSubset := keygen.BuildLocalSaveDataSubset(keys[aliceIdx], partyIDs)
+ var B *crypto.ECPoint
+ if _, bigWs, pfsErr := signing.PrepareForSigning(
+ ec, aliceIdx, len(aliceSubset.Ks), aliceSubset.Xi,
+ aliceSubset.Ks, aliceSubset.BigXj,
+ ); pfsErr == nil && bobIdx < len(bigWs) {
+ B = bigWs[bobIdx]
+ }
+
+ result.BobProofOff = pfBob != nil && verifyBobProof(pfBob, ec, pkAlice, NTildeA, h1A, h2A, aliceCA, cBBob, false)
+ result.BobProofOn = pfBob != nil && verifyBobProof(pfBob, ec, pkAlice, NTildeA, h1A, h2A, aliceCA, cBBob, true)
+
+ if B != nil {
+ result.BobWCProofOff = pfBobWC != nil && verifyBobWCProof(pfBobWC, ec, pkAlice, NTildeA, h1A, h2A, aliceCA, cBBobWC, B, false)
+ result.BobWCProofOn = pfBobWC != nil && verifyBobWCProof(pfBobWC, ec, pkAlice, NTildeA, h1A, h2A, aliceCA, cBBobWC, B, true)
+ }
+}
+
+// runMixedScenario drives one live ceremony between a current-implementation
+// Alice (party index 0, in this process) and a historical subprocess Bob
+// (party index 1), asserting the given compatibility configuration.
+//
+// The exchange is deliberately bounded to round 8: this repository's own
+// existing round3Fixture (ecdsa/signing/round_3_test.go) and
+// historicalBobProofForWitnessY (crypto/mta/legacy_bob_historical_witness_test.go)
+// already establish the precedent of driving a 2-of-20 minimal subset of the
+// test/_ecdsa_fixtures keygen fixtures (threshold=1, not the fixture set's
+// real threshold=10) for exactly this kind of round-level interop check.
+// That minimal subset is sufficient for every per-peer MtA/Schnorr check
+// through round 8 (each is a property of the two parties' own consistent
+// local computation), but round 9's final aggregate check (U == T) verifies
+// a *global* Shamir reconstruction identity that only holds for a
+// correctly-sized threshold+1 co-signer set. Reaching a real, live-exchanged
+// round 8 message already proves the historical Bob/BobWC witness was
+// accepted and every subsequent round-3..8 verification/decommitment step
+// (Bob_end, the Gamma/Schnorr proofs, and both decommitments) succeeded
+// against a genuine historical binary; deliberately stopping there avoids an
+// unrelated, expected reconstruction mismatch rather than masking a real one.
+func runMixedScenario(name, seedSuffix string, historicalDir string, compat bool) (*scenarioResult, error) {
+ restore := fixedRandom("alice-" + seedSuffix)
+ defer restore()
+
+ keys, partyIDs, err := keygen.LoadKeygenTestFixtures(2)
+ if err != nil {
+ return nil, fmt.Errorf("load keygen fixtures: %w", err)
+ }
+ ec := tss.S256()
+ alicePK := &keys[0].PaillierSK.PublicKey
+ q := ec.Params().N
+ q3 := new(big.Int).Mul(q, q)
+ q3 = new(big.Int).Mul(q, q3)
+ q6 := new(big.Int).Mul(q3, q3)
+ tightBound := new(big.Int).Add(alicePK.N, q6)
+
+ ctx := tss.NewPeerContext(partyIDs)
+ params := tss.NewParameters(ec, ctx, partyIDs[0], 2, 1)
+ params.SetProtocolMode(tss.ProtocolModeLegacy)
+ if compat {
+ params.SetLegacyHistoricalBobCompatibility(true)
+ }
+
+ msg := new(big.Int)
+ msg.SetString(fixedMessageHex, 16)
+ msgBytes, _ := hex.DecodeString(fixedMessageHex)
+
+ outCh := make(chan tss.Message, 16)
+ endCh := make(chan common.SignatureData, 1)
+ alice := signing.NewLocalParty(msg, params, keys[0], outCh, endCh, len(msgBytes))
+
+ peer, err := spawnHistoricalPeer(historicalDir)
+ if err != nil {
+ return nil, fmt.Errorf("spawn historical peer: %w", err)
+ }
+ defer peer.quit()
+
+ if err := peer.send(command{Cmd: "init", Seed: "bob-" + seedSuffix, MessageHex: fixedMessageHex}); err != nil {
+ return nil, fmt.Errorf("send init: %w", err)
+ }
+ peerEvents, err := peer.readTurn()
+ if err != nil {
+ return nil, fmt.Errorf("read historical init turn: %w", err)
+ }
+
+ if err := alice.Start(); err != nil {
+ return nil, fmt.Errorf("alice start: %w", err)
+ }
+
+ result := &scenarioResult{Name: name, TightBound: tightBound.Text(16)}
+
+ var pendingToBob []tss.Message
+ var pendingToAlice []event
+ // aliceCA captures Alice's own round-1 MtA ciphertext to Bob (the "cA"
+ // input shared by both the Bob and BobWC per-proof verifications
+ // below); it is not otherwise observable from Bob's round-2 message.
+ var aliceCA *big.Int
+
+ drainOut := func() {
+ for {
+ select {
+ case m := <-outCh:
+ if roundNumberOf(m) == 3 {
+ result.AliceEmittedRound3 = true
+ }
+ if roundNumberOf(m) >= 8 {
+ result.ReachedRound8 = true
+ }
+ if aliceCA == nil {
+ if pm, ok := m.(tss.ParsedMessage); ok {
+ if r1msg1, ok := pm.Content().(*signing.SignRound1Message1); ok {
+ aliceCA = r1msg1.UnmarshalC()
+ }
+ }
+ }
+ pendingToBob = append(pendingToBob, m)
+ case <-endCh:
+ result.Completed = true
+ default:
+ return
+ }
+ }
+ }
+ drainOut()
+ pendingToAlice = append(pendingToAlice, peerEvents...)
+
+ // captureRound2 captures the first historical Bob round-2 message and
+ // records the raw T1 scalar values for each proof.
+ var capturedBobR2 *signing.SignRound2Message
+
+ captureWitness := func(e event) {
+ if capturedBobR2 != nil {
+ return
+ }
+ wireBytes, decErr := hex.DecodeString(e.WireHex)
+ if decErr != nil {
+ return
+ }
+ parsed, parseErr := tss.ParseWireMessage(wireBytes, partyIDs[1], e.IsBroadcast)
+ if parseErr != nil {
+ return
+ }
+ r2msg, ok := parsed.Content().(*signing.SignRound2Message)
+ if !ok {
+ return
+ }
+ capturedBobR2 = r2msg
+ bob, bErr := r2msg.UnmarshalProofBob()
+ if bErr == nil && bob != nil {
+ result.WitnessBobT1 = bob.T1.Text(16)
+ result.AboveTightBound = bob.T1.Cmp(tightBound) >= 0
+ }
+ bobWC, wcErr := r2msg.UnmarshalProofBobWC(ec)
+ if wcErr == nil && bobWC != nil {
+ result.WitnessBobWCT1 = bobWC.T1.Text(16)
+ if bobWC.T1.Cmp(tightBound) < 0 {
+ result.AboveTightBound = false
+ }
+ }
+ }
+
+pump:
+ for len(pendingToBob) > 0 || len(pendingToAlice) > 0 {
+ for _, m := range pendingToBob {
+ if result.ReachedRound8 {
+ break pump
+ }
+ wireBytes, _, wErr := m.WireBytes()
+ if wErr != nil {
+ return nil, fmt.Errorf("alice wire bytes: %w", wErr)
+ }
+ if sErr := peer.send(command{Cmd: "deliver", IsBroadcast: m.IsBroadcast(), WireHex: hex.EncodeToString(wireBytes)}); sErr != nil {
+ return nil, fmt.Errorf("send deliver to historical peer: %w", sErr)
+ }
+ events, rErr := peer.readTurn()
+ if rErr != nil {
+ return nil, fmt.Errorf("read historical peer turn: %w", rErr)
+ }
+ for _, e := range events {
+ switch e.Event {
+ case "message":
+ pendingToAlice = append(pendingToAlice, e)
+ case "signature":
+ case "error":
+ return nil, fmt.Errorf("historical peer reported an unexpected error: %s", e.Message)
+ }
+ }
+ }
+ pendingToBob = nil
+
+ toDeliver := pendingToAlice
+ pendingToAlice = nil
+ for _, e := range toDeliver {
+ if result.ReachedRound8 {
+ break
+ }
+ captureWitness(e)
+ wireBytes, decErr := hex.DecodeString(e.WireHex)
+ if decErr != nil {
+ return nil, fmt.Errorf("decode historical wire hex: %w", decErr)
+ }
+ if parsed, pErr := tss.ParseWireMessage(wireBytes, partyIDs[1], e.IsBroadcast); pErr == nil {
+ if roundNumberOf(parsed) >= 8 {
+ result.ReachedRound8 = true
+ break
+ }
+ }
+ ok, aErr := alice.UpdateFromBytes(wireBytes, partyIDs[1], e.IsBroadcast)
+ if aErr != nil {
+ result.DefaultRejected = true
+ result.RejectionErr = aErr.Error()
+ // Structural (non-text) constraints on the rejection: it
+ // must be tss.Error's own Round()==3, and the historical
+ // peer (partyIDs[1]) must be among its Culprits() — never
+ // inferred by parsing the error string.
+ result.RejectionRound = aErr.Round()
+ for _, culprit := range aErr.Culprits() {
+ if culprit.Index == partyIDs[1].Index {
+ result.RejectionCulpritIsPeer = true
+ break
+ }
+ }
+ verifyPerProofIndependently(result, capturedBobR2, aliceCA, keys, partyIDs, ec)
+ return result, nil
+ }
+ _ = ok
+ result.AliceProgressed = true
+ }
+ drainOut()
+ if result.ReachedRound8 {
+ break pump
+ }
+ }
+
+ // Per-proof independent verification: extract Bob's round-2 message
+ // from the pump's captured state, unmarshal each proof, and verify
+ // them independently with compat=false and compat=true. This asserts
+ // that each proof individually (Bob and BobWC) rejects at the tight
+ // bound and accepts at the loose bound, catching a regression where
+ // only one proof path is correctly wired.
+ verifyPerProofIndependently(result, capturedBobR2, aliceCA, keys, partyIDs, ec)
+
+ if compat {
+ result.Accepted = true
+ }
+ return result, nil
+}
From fb7569146bced2803ccd7a2241715a38d9c01c0b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Wed, 30 Sep 2026 01:42:54 +0000
Subject: [PATCH 3/3] test(signing): assert both legacy proof rejections
---
.../legacy_transcript/mixed_interop/main.go | 4 +--
.../mixed_interop/scenarios.go | 26 ++++++++++---------
2 files changed, 16 insertions(+), 14 deletions(-)
diff --git a/testdata/legacy_transcript/mixed_interop/main.go b/testdata/legacy_transcript/mixed_interop/main.go
index ce29d9be2..2134e735d 100644
--- a/testdata/legacy_transcript/mixed_interop/main.go
+++ b/testdata/legacy_transcript/mixed_interop/main.go
@@ -95,8 +95,8 @@ func main() {
fmt.Printf("FAIL: %s: rejection was not at round 3 (tss.Error.Round()=%d)\n", name, r.RejectionRound)
os.Exit(1)
}
- if !r.RejectionCulpritIsPeer {
- fmt.Printf("FAIL: %s: rejection did not name the historical peer as a culprit\n", name)
+ if r.RejectionPeerCulpritCount < 2 {
+ fmt.Printf("FAIL: %s: both production verifier failures did not name the historical peer (count=%d)\n", name, r.RejectionPeerCulpritCount)
os.Exit(1)
}
if r.AliceEmittedRound3 {
diff --git a/testdata/legacy_transcript/mixed_interop/scenarios.go b/testdata/legacy_transcript/mixed_interop/scenarios.go
index 05d03bdb3..75449352e 100644
--- a/testdata/legacy_transcript/mixed_interop/scenarios.go
+++ b/testdata/legacy_transcript/mixed_interop/scenarios.go
@@ -96,14 +96,15 @@ func roundNumberOf(m tss.Message) int {
type scenarioResult struct {
Name string `json:"name"`
- // Behavioral: default rejection. RejectionRound/RejectionCulpritIsPeer
+ // Behavioral: default rejection. RejectionRound and the peer culprit count
// come from *tss.Error's structured Round()/Culprits() accessors, never
- // from parsing RejectionErr's text.
- DefaultRejected bool `json:"default_rejected"`
- RejectionRound int `json:"rejection_round,omitempty"`
- RejectionCulpritIsPeer bool `json:"rejection_culprit_is_peer"`
- AliceEmittedRound3 bool `json:"alice_emitted_round3"`
- RejectionErr string `json:"rejection_err,omitempty"`
+ // from parsing RejectionErr's text. Both round-3 verifier goroutines must
+ // independently attribute their failure to the historical peer.
+ DefaultRejected bool `json:"default_rejected"`
+ RejectionRound int `json:"rejection_round,omitempty"`
+ RejectionPeerCulpritCount int `json:"rejection_peer_culprit_count"`
+ AliceEmittedRound3 bool `json:"alice_emitted_round3"`
+ RejectionErr string `json:"rejection_err,omitempty"`
// Behavioral: acceptance/progress
Accepted bool `json:"accepted"`
AliceProgressed bool `json:"alice_progressed"`
@@ -404,16 +405,17 @@ pump:
result.DefaultRejected = true
result.RejectionErr = aErr.Error()
// Structural (non-text) constraints on the rejection: it
- // must be tss.Error's own Round()==3, and the historical
- // peer (partyIDs[1]) must be among its Culprits() — never
- // inferred by parsing the error string.
+ // must be tss.Error's own Round()==3, and both production
+ // verifier failures must attribute the historical peer.
result.RejectionRound = aErr.Round()
for _, culprit := range aErr.Culprits() {
if culprit.Index == partyIDs[1].Index {
- result.RejectionCulpritIsPeer = true
- break
+ result.RejectionPeerCulpritCount++
}
}
+ // UpdateFromBytes may enqueue output before returning an
+ // error. Observe that output before asserting fail-closed.
+ drainOut()
verifyPerProofIndependently(result, capturedBobR2, aliceCA, keys, partyIDs, ec)
return result, nil
}