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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions testdata/legacy_transcript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,101 @@ 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` (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, they drive three
scenarios:

- **reject**: a current party with `tss.ProtocolModeLegacy` and the default
(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. 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
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.

229 changes: 229 additions & 0 deletions testdata/legacy_transcript/historical_signer/main.go
Original file line number Diff line number Diff line change
@@ -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":"<label>","message_hex":"<hex>"}
// host -> signer: {"cmd":"deliver","is_broadcast":bool,"wire_hex":"<hex>"}
// host -> signer: {"cmd":"quit"}
//
// signer -> host: {"event":"message","is_broadcast":bool,"wire_hex":"<hex>"}
// signer -> host: {"event":"signature","r_hex":"<hex>","s_hex":"<hex>"}
// signer -> host: {"event":"error","message":"<diagnostic text, not asserted on>"}
// 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()
}
Loading
Loading