Skip to content

Commit 9816ccf

Browse files
authored
test(frost): real-cgo multi-seat interactive 2-of-3 aggregate in one process (#4099)
## What Adds `TestRealCgoInteractiveSigning_MultiSeatAggregate`: two local seats driven through the **full** interactive signing flow in **one process** against the real cgo engine — `DeriveInteractiveAttemptContext`, then per seat `InteractiveSessionOpen` + `InteractiveRound1`, then `NewSigningPackage`, `InteractiveRound2` per seat, `InteractiveAggregate` — producing a real **2-of-3 BIP-340 signature**. This is the payoff of the multi-seat engine fix (signer **#4098**, member-keyed `interactive_signing`). Before it, the second seat's `InteractiveSessionOpen` returned `SessionConflict`, so the existing `_MemberContribution` test could only drive one seat (open + round 1). This completes the loop with real crypto over the cgo bridge. ## Notes - Extracts the linked-signer env setup into `setupRealCgoSignerState` (shared by both tests). - **Skip-guarded** (`frost_native && frost_tbtc_signer && cgo`): inert in CI builds that don't link the Rust signer; runs only against a **current** `libfrost_tbtc` that includes the multi-seat fix. - Verified against a freshly rebuilt lib: both real-cgo tests pass, repeatable under `-count=2`; skips cleanly without the lib; cgo vet + gofmt clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents f4953b8 + a13e150 commit 9816ccf

1 file changed

Lines changed: 150 additions & 25 deletions

File tree

pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go

Lines changed: 150 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"os"
1010
"path/filepath"
11+
"strings"
1112
"sync/atomic"
1213
"testing"
1314

@@ -73,31 +74,7 @@ var realCgoSessionSeq atomic.Uint64
7374
// the Rust signer and runs only against a current lib.
7475

7576
func TestRealCgoInteractiveSigning_MemberContribution(t *testing.T) {
76-
t.Setenv("TBTC_SIGNER_PROFILE", "development")
77-
t.Setenv("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false")
78-
// RunDKG persists the DKG result in the signer's ENCRYPTED state, so a linked
79-
// signer needs a state encryption key and a state path. The path must be STABLE
80-
// within the process: the signer binds its process-global state-file lock to the
81-
// first path it sees and refuses to switch, so a fresh t.TempDir() per invocation
82-
// would break in-process repeats (go test -count=2 fails on the second run). Use
83-
// a per-PROCESS path (stable across -count=N, unique across processes so separate
84-
// runs do not contend on one lock) plus a unique session id per invocation
85-
// (below), so repeats add a fresh DKG session rather than conflicting on a fixed
86-
// one. The encryption key is fixed so the persisted state stays decryptable across
87-
// in-process repeats.
88-
stateKey := make([]byte, 32)
89-
for i := range stateKey {
90-
stateKey[i] = byte(i + 1)
91-
}
92-
t.Setenv("TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX", hex.EncodeToString(stateKey))
93-
stateDir := filepath.Join(
94-
os.TempDir(),
95-
fmt.Sprintf("keep-frost-realcgo-state-%d", os.Getpid()),
96-
)
97-
if err := os.MkdirAll(stateDir, 0o700); err != nil {
98-
t.Fatalf("create signer state dir: %v", err)
99-
}
100-
t.Setenv("TBTC_SIGNER_STATE_PATH", filepath.Join(stateDir, "signer-state"))
77+
setupRealCgoSignerState(t)
10178

10279
engine := &buildTaggedTBTCSignerEngine{}
10380

@@ -155,6 +132,154 @@ func TestRealCgoInteractiveSigning_MemberContribution(t *testing.T) {
155132
}
156133
}
157134

135+
// TestRealCgoInteractiveSigning_MultiSeatAggregate drives TWO local seats through the
136+
// FULL interactive signing flow in ONE process against the real cgo engine, producing a
137+
// real 2-of-3 BIP-340 signature. This is the payoff of the multi-seat engine fix
138+
// (member-keyed interactive_signing): before it, the second seat's InteractiveSessionOpen
139+
// failed with SessionConflict, so a single process could only drive one seat's
140+
// contribution (see _MemberContribution). Now both seats Open, Round1, and Round2
141+
// independently and their shares aggregate. Skip-guarded; runs only against a linked,
142+
// CURRENT libfrost_tbtc that includes the multi-seat fix.
143+
func TestRealCgoInteractiveSigning_MultiSeatAggregate(t *testing.T) {
144+
setupRealCgoSignerState(t)
145+
146+
engine := &buildTaggedTBTCSignerEngine{}
147+
148+
const threshold = 2
149+
sessionID := fmt.Sprintf("real-cgo-multiseat-session-%d", realCgoSessionSeq.Add(1))
150+
participantIDs := []byte{1, 2, 3}
151+
// Both seats are LOCAL members in this one process (the multi-seat case).
152+
signingMembers := []byte{1, 2}
153+
message := bytesOf(0x42, 32)
154+
155+
keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold)
156+
157+
derived, err := engine.DeriveInteractiveAttemptContext(
158+
sessionID,
159+
message,
160+
keyGroup,
161+
threshold,
162+
0,
163+
uint16sOf(signingMembers),
164+
)
165+
skipFrostUnavailable(t, "derive interactive attempt context", err)
166+
frostIDByMember := map[byte]string{}
167+
for _, id := range derived.FrostIdentifiers {
168+
frostIDByMember[byte(id.ParticipantIdentifier)] = id.FrostIdentifier
169+
}
170+
171+
// Open + Round1 for BOTH seats in one process. The second Open succeeding (rather
172+
// than SessionConflict) is exactly what the multi-seat engine fix enables.
173+
attemptIDByMember := make(map[byte]string, len(signingMembers))
174+
commitments := make([]nativeFROSTCommitment, 0, len(signingMembers))
175+
for _, member := range signingMembers {
176+
open, err := engine.InteractiveSessionOpen(
177+
sessionID,
178+
uint16(member),
179+
message,
180+
keyGroup,
181+
threshold,
182+
nil, // key-path spend
183+
derived.AttemptContext,
184+
)
185+
if isPreMultiSeatConflict(err) {
186+
t.Skipf(
187+
"linked libfrost_tbtc predates the multi-seat fix (member %d's Open conflicts "+
188+
"with a sibling's; rebuild it from a source with member-keyed "+
189+
"interactive_signing): %v",
190+
member, err,
191+
)
192+
}
193+
skipFrostUnavailable(t, fmt.Sprintf("interactive session open (member %d)", member), err)
194+
attemptIDByMember[member] = open.AttemptID
195+
196+
commitmentData, err := engine.InteractiveRound1(sessionID, open.AttemptID, uint16(member))
197+
skipFrostUnavailable(t, fmt.Sprintf("interactive round 1 (member %d)", member), err)
198+
commitments = append(commitments, nativeFROSTCommitment{
199+
Identifier: frostIDByMember[member],
200+
Data: commitmentData,
201+
})
202+
}
203+
204+
// Both seats derive the SAME attempt id (the engine derives it member-
205+
// independently); the aggregate below keys off one of them, so pin the invariant.
206+
if a, b := attemptIDByMember[signingMembers[0]], attemptIDByMember[signingMembers[1]]; a != b {
207+
t.Fatalf("local seats derived different attempt ids (%q vs %q)", a, b)
208+
}
209+
210+
signingPackage, err := engine.NewSigningPackage(message, commitments)
211+
skipFrostUnavailable(t, "new signing package", err)
212+
213+
// Round2 for BOTH seats: each releases its share independently; member 1's Round2
214+
// must not disturb member 2's live state (the per-member entry isolation).
215+
shares := make([]nativeFROSTSignatureShare, 0, len(signingMembers))
216+
for _, member := range signingMembers {
217+
shareData, err := engine.InteractiveRound2(
218+
sessionID,
219+
attemptIDByMember[member],
220+
uint16(member),
221+
signingPackage,
222+
)
223+
skipFrostUnavailable(t, fmt.Sprintf("interactive round 2 (member %d)", member), err)
224+
shares = append(shares, nativeFROSTSignatureShare{
225+
Identifier: frostIDByMember[member],
226+
Data: shareData,
227+
})
228+
}
229+
230+
// Aggregate the two interactive shares into a real 2-of-3 BIP-340 signature -
231+
// produced by two local seats in ONE process via the cgo bridge. The engine
232+
// validates the shares + aggregate internally, so a non-error 64-byte result is a
233+
// valid threshold signature (see _MemberContribution on the absent external
234+
// keyGroup->pubkey accessor).
235+
signature, err := engine.InteractiveAggregate(
236+
sessionID,
237+
attemptIDByMember[signingMembers[0]],
238+
signingPackage,
239+
shares,
240+
nil,
241+
)
242+
skipFrostUnavailable(t, "interactive aggregate", err)
243+
if len(signature) != 64 {
244+
t.Fatalf("unexpected multi-seat interactive signature length: %d", len(signature))
245+
}
246+
}
247+
248+
// setupRealCgoSignerState sets the linked-signer env the persisted-DKG interactive flow
249+
// needs: the development profile, a fixed state encryption key, and a per-PROCESS state
250+
// path - stable across -count=N (the signer binds its process-global state-file lock to
251+
// the first path and refuses to switch) and unique across processes (so separate runs
252+
// do not contend on one lock). Tests pair it with a unique session id per invocation so
253+
// in-process repeats add a fresh DKG session rather than conflicting on a fixed one.
254+
func setupRealCgoSignerState(t *testing.T) {
255+
t.Helper()
256+
t.Setenv("TBTC_SIGNER_PROFILE", "development")
257+
t.Setenv("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false")
258+
stateKey := make([]byte, 32)
259+
for i := range stateKey {
260+
stateKey[i] = byte(i + 1)
261+
}
262+
t.Setenv("TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX", hex.EncodeToString(stateKey))
263+
stateDir := filepath.Join(
264+
os.TempDir(),
265+
fmt.Sprintf("keep-frost-realcgo-state-%d", os.Getpid()),
266+
)
267+
if err := os.MkdirAll(stateDir, 0o700); err != nil {
268+
t.Fatalf("create signer state dir: %v", err)
269+
}
270+
t.Setenv("TBTC_SIGNER_STATE_PATH", filepath.Join(stateDir, "signer-state"))
271+
}
272+
273+
// isPreMultiSeatConflict reports whether an InteractiveSessionOpen error is the
274+
// SessionConflict a PRE-multi-seat-fix libfrost_tbtc returns when a second LOCAL seat
275+
// opens a session a sibling already opened. The fix (member-keyed interactive_signing)
276+
// makes that Open succeed; against an older but otherwise-linked lib the test skips (a
277+
// stale-lib environment issue, like a missing symbol) rather than failing. Matched on
278+
// the error text - this is test-only environment detection, not production control flow.
279+
func isPreMultiSeatConflict(err error) bool {
280+
return err != nil && strings.Contains(strings.ToLower(err.Error()), "session conflict")
281+
}
282+
158283
// skipFrostUnavailable turns an engine-call error into the right outcome: a missing
159284
// FFI symbol (lib absent, or present but stale and missing a newer symbol) SKIPS
160285
// the test naming the operation, while any other error is a real failure. nil is a

0 commit comments

Comments
 (0)