Skip to content

Commit f4953b8

Browse files
authored
test(frost): real-cgo interactive signing — single-signer real-crypto bridge test (#4097)
## What A REAL-cgo interactive signing integration test: a full FROST DKG that **persists a key group** (`RunDKG`), then one signer's interactive contribution driven through the engine's interactive session API (`DeriveInteractiveAttemptContext` → `InteractiveSessionOpen` → `InteractiveRound1`) with real `frost-secp256k1-tr` crypto. It produces a real signing commitment. This fills the gap fake-engine tests can't: it proves the Go cgo bridge runs **real persisted DKG state** through the interactive session surface — FFI marshaling, persisted-state resolution by keyGroup, and real round-1 commitment generation. It complements: - `_WithLinkedSigner` — real-crypto 2-of-3 **aggregate** over the low-level API (the FROST math); - the fake-engine **net e2e** (#4095) — interactive multi-node **orchestration** (t-subset, observers, real transport). ## What it does NOT prove Interactive **round 2 or aggregate** over the cgo session surface. Those require ≥ 2 signers, and the signer engine state is a **process global holding one open interactive member per session** (`Open` is fingerprinted once per session; the round state rejects any non-opener member) — by design, each production node is its own process. `RunDKG` also requires **threshold ≥ 2**. So a single test process can drive exactly one signer's contribution; a full interactive multi-member signature needs a multi-process harness, which (per review) isn't worth building given the two halves above are already covered. This is a real-crypto round-1 / bridge / state-integration test, **not** a full interactive e2e. ## Verification `frost_native && frost_tbtc_signer && cgo`, test-only. **Skip-guarded** on any unavailable FFI symbol (lib absent or stale) naming the operation, so it's inert in CI builds that don't link the Rust signer. - Without the lib: skips cleanly. cgo vet + gofmt clean. - **Against a freshly built `libfrost_tbtc`: passes** — RunDKG, derive, open, and round 1 all execute with real crypto. To run it: `CGO_ENABLED=1 CGO_LDFLAGS="-L<dir> -lfrost_tbtc -Wl,-rpath,<dir>" go test -tags "frost_native frost_tbtc_signer" -run TestRealCgoInteractiveSigning_MemberContribution ./pkg/frost/signing/` > Note: surfaced a production-readiness item — multi-seat operators run concurrent same-session interactive signing in one process, which the engine's one-opener-per-session state would reject. Tracked separately (needs an engine fix keyed by `(session_id, member_identifier)` or a multi-seat gate before enabling interactive signing for multi-seat operators). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 054f51b + d9f9042 commit f4953b8

1 file changed

Lines changed: 221 additions & 0 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
//go:build frost_native && frost_tbtc_signer && cgo
2+
3+
package signing
4+
5+
import (
6+
"encoding/hex"
7+
"errors"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
"sync/atomic"
12+
"testing"
13+
14+
"github.com/keep-network/keep-core/pkg/chain/local_v1"
15+
"github.com/keep-network/keep-core/pkg/operator"
16+
)
17+
18+
// realCgoSessionSeq gives each invocation a unique session id so that in-process
19+
// repeats (go test -count=N) over the shared, process-stable signer state path add
20+
// a fresh DKG session instead of conflicting on a fixed one.
21+
var realCgoSessionSeq atomic.Uint64
22+
23+
// This file is the REAL-cgo interactive signing test: a full FROST DKG that
24+
// PERSISTS a key group, followed by one signer's interactive ROAST contribution
25+
// driven through the engine's INTERACTIVE session API
26+
// (DeriveInteractiveAttemptContext -> InteractiveSessionOpen -> InteractiveRound1)
27+
// with real frost-secp256k1-tr cryptography. It complements:
28+
// - TestBuildTaggedTBTCSignerInteractiveFROSTBridge_WithLinkedSigner, which
29+
// proves the real crypto over the LOW-LEVEL Sign/Aggregate API by feeding
30+
// KeyPackage bytes directly; and
31+
// - the fake-engine runner suite (roast_runner_bus_net_e2e_frost_native_test.go),
32+
// which proves the multi-node Go-side orchestration - a t-subset across nodes,
33+
// observers, the real pkg/net transport - without crypto.
34+
// This test covers the remaining gap: the interactive session API the runner uses,
35+
// with the REAL engine and REAL crypto.
36+
//
37+
// Scope and why it is one member: the signer engine state is a PROCESS GLOBAL (a
38+
// single Mutex<EngineState>) that holds ONE open interactive member per session -
39+
// InteractiveSessionOpen is fingerprinted once per session and
40+
// interactive_state_for_attempt_mut rejects any member_identifier other than the
41+
// opener's. That is by design: each production node is its own process. The engine
42+
// also requires threshold >= 2. So a SINGLE process can faithfully drive exactly
43+
// one signer's real contribution (open + round 1), but NOT the full t-of-n
44+
// finalize (NewSigningPackage over t commitments -> round 2 per member -> aggregate),
45+
// which needs the other members' contributions from their own processes. A
46+
// complete real-crypto signature therefore requires a multi-process harness; the
47+
// multi-member orchestration itself is covered with the fake engine over the real
48+
// transport in the runner net e2e.
49+
//
50+
// What this test does NOT prove: interactive round 2 or aggregate over the cgo
51+
// session surface (those need >= 2 signers, i.e. multiple processes). It is a
52+
// real-crypto round-1 / FFI-bridge / persisted-state integration test, not a full
53+
// interactive end-to-end signature.
54+
//
55+
// The DKG -> interactive keyGroup glue is RunDKG: InteractiveSessionOpen resolves
56+
// the signing key by a keyGroup IDENTIFIER (engine-internal persisted material),
57+
// not KeyPackage bytes. RunDKG runs the full DKG and PERSISTS the result, keyed by
58+
// the SESSION ID, under a returned keyGroup the interactive path resolves - the
59+
// same flow production uses. Open then requires a completed DKG session of the
60+
// same session_id, so RunDKG and the interactive flow share one session id.
61+
//
62+
// To run it, link the signer library so the frost_tbtc_* symbols resolve, e.g.:
63+
//
64+
// CGO_ENABLED=1 \
65+
// CGO_LDFLAGS="-L<dir> -lfrost_tbtc -Wl,-rpath,<dir>" \
66+
// go test -tags "frost_native frost_tbtc_signer" \
67+
// -run TestRealCgoInteractiveSigning_MemberContribution ./pkg/frost/signing/
68+
//
69+
// Every engine call is guarded by skipFrostUnavailable: when the lib is absent, or
70+
// present but stale (an older dylib missing a newer symbol such as
71+
// frost_tbtc_derive_interactive_attempt_context), the test SKIPS naming the
72+
// operation, rather than failing - so it is inert in CI builds that do not link
73+
// the Rust signer and runs only against a current lib.
74+
75+
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"))
101+
102+
engine := &buildTaggedTBTCSignerEngine{}
103+
104+
// The engine requires threshold >= 2; the attempt's included set is the t-subset
105+
// {1,2} over a 3-party DKG. This process drives the one local signer (member 1).
106+
const threshold = 2
107+
// One session id for the whole flow: the engine keys the interactive session by
108+
// the DKG session id, so RunDKG, derive, and open all use sessionID. It is unique
109+
// per invocation so in-process repeats over the stable state path add a fresh
110+
// session instead of conflicting on a fixed one.
111+
sessionID := fmt.Sprintf("real-cgo-session-%d", realCgoSessionSeq.Add(1))
112+
const localMember = uint16(1)
113+
participantIDs := []byte{1, 2, 3}
114+
includedMembers := []byte{1, 2}
115+
message := bytesOf(0x42, 32)
116+
117+
// 1. Full DKG that persists a key group under sessionID, returning the keyGroup
118+
// the signing path resolves.
119+
keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold)
120+
121+
// 2. Derive the attempt context for the t-subset, then drive the local signer's
122+
// interactive contribution - the same calls the interactiveSigningRunner makes,
123+
// here with real crypto against the DKG-persisted keyGroup.
124+
derived, err := engine.DeriveInteractiveAttemptContext(
125+
sessionID,
126+
message,
127+
keyGroup,
128+
threshold,
129+
0, // 0-based attempt number; the bridge converts to the 1-based wire value
130+
uint16sOf(includedMembers),
131+
)
132+
skipFrostUnavailable(t, "derive interactive attempt context", err)
133+
134+
open, err := engine.InteractiveSessionOpen(
135+
sessionID,
136+
localMember,
137+
message,
138+
keyGroup,
139+
threshold,
140+
nil, // key-path spend
141+
derived.AttemptContext,
142+
)
143+
skipFrostUnavailable(t, "interactive session open", err)
144+
if open.AttemptID == "" {
145+
t.Fatal("interactive session open returned an empty attempt id")
146+
}
147+
148+
// Round 1: the real engine generates this member's signing nonces and returns
149+
// its public commitments. A non-empty commitment is the proof the real
150+
// interactive crypto path (DKG-persisted key resolution -> commit) works.
151+
commitmentData, err := engine.InteractiveRound1(sessionID, open.AttemptID, localMember)
152+
skipFrostUnavailable(t, "interactive round 1", err)
153+
if len(commitmentData) == 0 {
154+
t.Fatal("interactive round 1 returned an empty commitment from the real engine")
155+
}
156+
}
157+
158+
// skipFrostUnavailable turns an engine-call error into the right outcome: a missing
159+
// FFI symbol (lib absent, or present but stale and missing a newer symbol) SKIPS
160+
// the test naming the operation, while any other error is a real failure. nil is a
161+
// no-op. Centralizing this makes every step robust to an incomplete lib rather than
162+
// only the first (RunDKG) call.
163+
func skipFrostUnavailable(t *testing.T, op string, err error) {
164+
t.Helper()
165+
if err == nil {
166+
return
167+
}
168+
if errors.Is(err, ErrNativeCryptographyUnavailable) {
169+
t.Skipf(
170+
"linked tbtc-signer FFI symbol for %s unavailable (lib absent or stale; "+
171+
"rebuild libfrost_tbtc): %v",
172+
op, err,
173+
)
174+
}
175+
t.Fatalf("%s: %v", op, err)
176+
}
177+
178+
// runRealCgoDKGKeyGroup runs a full real FROST DKG over the participants via
179+
// RunDKG under sessionID, which persists the result (keyed by that session id) and
180+
// returns the keyGroup the signing path resolves. It skips the test if the linked
181+
// tbtc-signer FFI symbols are absent. Each participant carries a freshly generated
182+
// operator public key (the DKG's per-participant identifying key), so the request
183+
// is well-formed.
184+
func runRealCgoDKGKeyGroup(
185+
t *testing.T,
186+
engine *buildTaggedTBTCSignerEngine,
187+
sessionID string,
188+
participantIDs []byte,
189+
threshold uint16,
190+
) string {
191+
t.Helper()
192+
193+
participants := make([]NativeTBTCSignerDKGParticipant, 0, len(participantIDs))
194+
for _, id := range participantIDs {
195+
_, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve)
196+
if err != nil {
197+
t.Fatalf("operator key (participant %d): %v", id, err)
198+
}
199+
participants = append(participants, NativeTBTCSignerDKGParticipant{
200+
Identifier: uint16(id),
201+
PublicKeyHex: hex.EncodeToString(operator.MarshalUncompressed(publicKey)),
202+
})
203+
}
204+
205+
result, err := engine.RunDKG(sessionID, participants, threshold)
206+
skipFrostUnavailable(t, "run DKG", err)
207+
if result.KeyGroup == "" {
208+
t.Fatal("RunDKG returned an empty key group")
209+
}
210+
return result.KeyGroup
211+
}
212+
213+
// uint16sOf widens member ids to the uint16 participant list the engine's
214+
// DeriveInteractiveAttemptContext expects.
215+
func uint16sOf(members []byte) []uint16 {
216+
out := make([]uint16, 0, len(members))
217+
for _, member := range members {
218+
out = append(out, uint16(member))
219+
}
220+
return out
221+
}

0 commit comments

Comments
 (0)