|
| 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