Skip to content

Commit 67b9530

Browse files
mswilkisonclaude
andcommitted
test(frost): real-cgo interactive signing e2e via a DKG-persisted keyGroup (RunDKG)
Completes the real-cgo INTERACTIVE signing end-to-end test: a full FROST DKG that PERSISTS a key group (RunDKG), followed by an interactive ROAST signing round driven through the engine's interactive session API (DeriveInteractiveAttemptContext -> InteractiveSessionOpen -> InteractiveRound1 -> NewSigningPackage -> InteractiveRound2 -> InteractiveAggregate) with real frost-secp256k1-tr crypto. It fills the gap in real-cgo coverage: - _WithLinkedSigner proves the real crypto over the LOW-LEVEL Sign/Aggregate API (fed KeyPackage bytes directly); - the fake-engine runner suite proves the Go-side orchestration without crypto. This is the missing combination - the interactive session API the runner uses, with real crypto - the prerequisite toward coarse-path retirement. THE keyGroup GLUE = RunDKG. InteractiveSessionOpen resolves the signing key by a keyGroup IDENTIFIER (engine-internal persisted material), not KeyPackage bytes, so the low-level DKG result the test holds as bytes is not loadable as an interactive keyGroup. RunDKG runs the full DKG, persists the result, and returns the keyGroup the interactive (and coarse) signing path resolves - the same flow production uses (the wallet runs DKG, gets a keyGroup, then signs). Correctness is proven by the engine's SUCCESSFUL InteractiveAggregate: FROST validates the shares and the aggregate internally, so a non-error 64-byte BIP-340 signature is a valid threshold signature over the message under the keyGroup's group key. The one remaining nicety, an ADDITIONAL external schnorr.Verify, is intentionally omitted: the engine exposes no keyGroup -> group-pubkey accessor (RunDKG returns only the keyGroup id), so external verification would need a new engine API. The e2e itself is complete via the internal validation. Skip-guarded for absent linked tbtc-signer FFI symbols, so it is inert in CI builds that do not link the Rust signer and runs only where the lib is present. Verifiable in this env (no lib): cgo build/vet + gofmt clean and a clean SKIP; the DKG and interactive round run only where the lib is linked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 054f51b commit 67b9530

1 file changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
//go:build frost_native && frost_tbtc_signer && cgo
2+
3+
package signing
4+
5+
import (
6+
"encoding/hex"
7+
"errors"
8+
"testing"
9+
10+
"github.com/keep-network/keep-core/pkg/chain/local_v1"
11+
"github.com/keep-network/keep-core/pkg/operator"
12+
)
13+
14+
// This file is the REAL-cgo interactive signing end-to-end test: a full FROST DKG
15+
// that PERSISTS a key group, followed by an interactive ROAST signing round
16+
// driven through the engine's INTERACTIVE session API
17+
// (DeriveInteractiveAttemptContext -> InteractiveSessionOpen -> InteractiveRound1
18+
// -> NewSigningPackage -> InteractiveRound2 -> InteractiveAggregate) with real
19+
// frost-secp256k1-tr cryptography. It complements:
20+
// - TestBuildTaggedTBTCSignerInteractiveFROSTBridge_WithLinkedSigner, which
21+
// proves the real crypto over the LOW-LEVEL Sign/Aggregate API by feeding
22+
// KeyPackage bytes directly; and
23+
// - the fake-engine runner suite, which proves the Go-side orchestration
24+
// without crypto.
25+
// This is the missing combination: the INTERACTIVE session API the runner uses,
26+
// with real crypto, the prerequisite toward coarse-path retirement.
27+
//
28+
// The DKG -> interactive keyGroup glue is RunDKG: InteractiveSessionOpen resolves
29+
// the signing key by a keyGroup IDENTIFIER (engine-internal persisted material),
30+
// not KeyPackage bytes - so the low-level DKG (Part1/2/3) result, which the test
31+
// holds as bytes, is NOT loadable as an interactive keyGroup. RunDKG runs the full
32+
// DKG and PERSISTS the result under a returned keyGroup the interactive (and
33+
// coarse) signing path then resolves - the same flow production uses (the wallet
34+
// layer runs DKG, gets a keyGroup, then signs).
35+
//
36+
// Correctness is proven by the engine's SUCCESSFUL InteractiveAggregate: FROST
37+
// validates the signature shares and the aggregate internally, so a non-error
38+
// 64-byte BIP-340 signature is a valid threshold signature over the message under
39+
// the keyGroup's group key. An ADDITIONAL external schnorr.Verify is intentionally
40+
// NOT done here: the engine exposes no API to retrieve a keyGroup's group public
41+
// key (RunDKG returns only the keyGroup id, and ExtractDkgGroupPublicKeyFromMaterial
42+
// needs the persisted material bytes the test does not hold), so external
43+
// verification would need a new keyGroup->group-pubkey accessor. That is the one
44+
// remaining nicety; the e2e itself is complete via the internal validation.
45+
//
46+
// The whole test is skip-guarded for the absence of the linked tbtc-signer FFI
47+
// symbols, matching the other real-cgo tests, so it is inert in CI builds that do
48+
// not link the Rust signer and runs only where the lib is present.
49+
50+
func TestRealCgoInteractiveSigning_EndToEnd(t *testing.T) {
51+
t.Setenv("TBTC_SIGNER_PROFILE", "development")
52+
t.Setenv("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false")
53+
54+
engine := &buildTaggedTBTCSignerEngine{}
55+
56+
const groupSize = 3
57+
const threshold = 2
58+
participantIDs := []byte{1, 2, 3}
59+
signingMembers := []byte{1, 2}
60+
message := bytesOf(0x42, 32)
61+
62+
// 1. Full DKG that persists a key group. RunDKG runs the whole DKG over the
63+
// participants and returns a keyGroup the signing path resolves. Skips if the
64+
// linked tbtc-signer FFI symbols are absent (no Rust lib in this build).
65+
keyGroup := runRealCgoDKGKeyGroup(t, engine, participantIDs, threshold)
66+
67+
// 2. Interactive signing over the chosen t-subset, driven through the engine's
68+
// interactive session API - the same calls the interactiveSigningRunner makes,
69+
// here with real crypto against the DKG-persisted keyGroup.
70+
derived, err := engine.DeriveInteractiveAttemptContext(
71+
"real-cgo-session-1",
72+
message,
73+
keyGroup,
74+
threshold,
75+
0, // 0-based attempt number; the bridge converts to the 1-based wire value
76+
uint16sOf(signingMembers),
77+
)
78+
if err != nil {
79+
t.Fatalf("derive interactive attempt context: %v", err)
80+
}
81+
frostIDByMember := map[byte]string{}
82+
for _, id := range derived.FrostIdentifiers {
83+
frostIDByMember[byte(id.ParticipantIdentifier)] = id.FrostIdentifier
84+
}
85+
86+
attemptIDByMember := make(map[byte]string, len(signingMembers))
87+
commitments := make([]nativeFROSTCommitment, 0, len(signingMembers))
88+
for _, member := range signingMembers {
89+
open, err := engine.InteractiveSessionOpen(
90+
"real-cgo-session-1",
91+
uint16(member),
92+
message,
93+
keyGroup,
94+
threshold,
95+
nil, // key-path spend
96+
derived.AttemptContext,
97+
)
98+
if err != nil {
99+
t.Fatalf("interactive session open (member %d): %v", member, err)
100+
}
101+
attemptIDByMember[member] = open.AttemptID
102+
103+
commitmentData, err := engine.InteractiveRound1(
104+
"real-cgo-session-1", open.AttemptID, uint16(member),
105+
)
106+
if err != nil {
107+
t.Fatalf("interactive round 1 (member %d): %v", member, err)
108+
}
109+
commitments = append(commitments, nativeFROSTCommitment{
110+
Identifier: frostIDByMember[member],
111+
Data: commitmentData,
112+
})
113+
}
114+
115+
signingPackage, err := engine.NewSigningPackage(message, commitments)
116+
if err != nil {
117+
t.Fatalf("new signing package: %v", err)
118+
}
119+
120+
signatureShares := make([]nativeFROSTSignatureShare, 0, len(signingMembers))
121+
for _, member := range signingMembers {
122+
shareData, err := engine.InteractiveRound2(
123+
"real-cgo-session-1",
124+
attemptIDByMember[member],
125+
uint16(member),
126+
signingPackage,
127+
)
128+
if err != nil {
129+
t.Fatalf("interactive round 2 (member %d): %v", member, err)
130+
}
131+
signatureShares = append(signatureShares, nativeFROSTSignatureShare{
132+
Identifier: frostIDByMember[member],
133+
Data: shareData,
134+
})
135+
}
136+
137+
signatureBytes, err := engine.InteractiveAggregate(
138+
"real-cgo-session-1",
139+
attemptIDByMember[signingMembers[0]],
140+
signingPackage,
141+
signatureShares,
142+
nil,
143+
)
144+
if err != nil {
145+
// A failed aggregate IS the verification: real FROST would reject invalid
146+
// shares or a key/package mismatch here, so a non-error result is the
147+
// proof the interactive round produced a valid threshold signature.
148+
t.Fatalf("interactive aggregate: %v", err)
149+
}
150+
151+
// 3. The successful aggregate is a valid 64-byte BIP-340 signature (the engine
152+
// validated the shares and the aggregate internally). External schnorr.Verify
153+
// is omitted only for want of a keyGroup->group-pubkey accessor (see the file
154+
// comment); assert well-formedness.
155+
if len(signatureBytes) != 64 {
156+
t.Fatalf("unexpected interactive signature length: %d", len(signatureBytes))
157+
}
158+
}
159+
160+
// runRealCgoDKGKeyGroup runs a full real FROST DKG over the participants via
161+
// RunDKG, which persists the result and returns the keyGroup the signing path
162+
// resolves. It skips the test if the linked tbtc-signer FFI symbols are absent.
163+
// Each participant carries a freshly generated operator public key (the DKG's
164+
// per-participant identifying key), so the request is well-formed.
165+
func runRealCgoDKGKeyGroup(
166+
t *testing.T,
167+
engine *buildTaggedTBTCSignerEngine,
168+
participantIDs []byte,
169+
threshold uint16,
170+
) string {
171+
t.Helper()
172+
173+
participants := make([]NativeTBTCSignerDKGParticipant, 0, len(participantIDs))
174+
for _, id := range participantIDs {
175+
_, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve)
176+
if err != nil {
177+
t.Fatalf("operator key (participant %d): %v", id, err)
178+
}
179+
participants = append(participants, NativeTBTCSignerDKGParticipant{
180+
Identifier: uint16(id),
181+
PublicKeyHex: hex.EncodeToString(operator.MarshalUncompressed(publicKey)),
182+
})
183+
}
184+
185+
result, err := engine.RunDKG("real-cgo-dkg-session-1", participants, threshold)
186+
if err != nil {
187+
if errors.Is(err, ErrNativeCryptographyUnavailable) {
188+
t.Skip("linked tbtc-signer FFI symbols unavailable")
189+
}
190+
t.Fatalf("run DKG: %v", err)
191+
}
192+
if result.KeyGroup == "" {
193+
t.Fatal("RunDKG returned an empty key group")
194+
}
195+
return result.KeyGroup
196+
}
197+
198+
// uint16sOf widens member ids to the uint16 participant list the engine's
199+
// DeriveInteractiveAttemptContext expects.
200+
func uint16sOf(members []byte) []uint16 {
201+
out := make([]uint16, 0, len(members))
202+
for _, member := range members {
203+
out = append(out, uint16(member))
204+
}
205+
return out
206+
}

0 commit comments

Comments
 (0)