|
8 | 8 | "fmt" |
9 | 9 | "os" |
10 | 10 | "path/filepath" |
| 11 | + "strings" |
11 | 12 | "sync/atomic" |
12 | 13 | "testing" |
13 | 14 |
|
@@ -73,31 +74,7 @@ var realCgoSessionSeq atomic.Uint64 |
73 | 74 | // the Rust signer and runs only against a current lib. |
74 | 75 |
|
75 | 76 | 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) |
101 | 78 |
|
102 | 79 | engine := &buildTaggedTBTCSignerEngine{} |
103 | 80 |
|
@@ -155,6 +132,154 @@ func TestRealCgoInteractiveSigning_MemberContribution(t *testing.T) { |
155 | 132 | } |
156 | 133 | } |
157 | 134 |
|
| 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 | + |
158 | 283 | // skipFrostUnavailable turns an engine-call error into the right outcome: a missing |
159 | 284 | // FFI symbol (lib absent, or present but stale and missing a newer symbol) SKIPS |
160 | 285 | // the test naming the operation, while any other error is a real failure. nil is a |
|
0 commit comments