Skip to content

Commit 33fee4b

Browse files
authored
test(frost): fault-injection coverage for the ROAST blame bridge (7.3 PR2b-2 step 2c) (#4090)
## RFC-21 Phase 7.3 PR2b-2 step 2c — fault-injection tests Closes Step 2's test coverage (after 2a #4088 and 2b #4089). Two **integration fault-injection** tests that the 2a/2b e2e tests left to direct stash-seeding: ### `TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure` Exercises the **2b extraction seam end-to-end through the real drive + runner + collector**: a committed interactive attempt that fails at aggregation (after the runner records the coordinator package) makes `driveInteractiveRoastSigningIfEnabled` surface `collector.CoordinatorPackageProofs` and stash them under the attempt key. A 1-of-1 attempt retains exactly its own authoritative package, so one proof is stashed — proving the seam fires through real code, not a directly-seeded stash. ### `TestRoastTransitionExchange_CombinedFaultsExcludeBoth` The **2a+2b coexistence capstone**: one transition bundle carrying **both** f+1 reject evidence (against a bad signer) **and** coordinator-equivocation proofs (two distinct authentic bodies) — including snapshots that carry evidence *and* a proof at once — excludes **both** the rejected member and the equivocating coordinator in a single `NextAttempt`, leaving a feasible threshold-sized included set. ### Scope **Test-only; no production change.** The policy layer (categories / soak: overflow-park / silence / reinstatement / infeasibility / equivocation) and runner capture (`RetainsCoordinatorPackageEquivocation`, `PreservesEvidenceOnAggregateFailure`, …) were already covered, so this fills only the remaining `capture → stash → bundle → exclusion` integration gap. ### Verification - build across all 5 tag combos (default / `frost_native` / `frost_roast_retry` / `frost_native frost_roast_retry` / `frost_native frost_tbtc_signer` w/ CGO) — no prod change, trivially green - new tests pass; `-race` on `pkg/frost/signing`; vet + gofmt clean 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 3f091da + 64a727c commit 33fee4b

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,42 @@ func TestDriveInteractiveRoastSigning_RunnerFailureHardFails(t *testing.T) {
205205
t.Fatalf("expected no signature on failure, got %v", sig)
206206
}
207207
}
208+
209+
// TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure covers the
210+
// RFC-21 Phase 7.3 PR2b-2 step 2b extraction seam end-to-end through the real
211+
// drive: when a committed interactive attempt fails AFTER the runner recorded the
212+
// coordinator-signed package, the drive surfaces collector.CoordinatorPackageProofs
213+
// and stashes them so BroadcastForcedSnapshot can carry them into the bundle (where
214+
// NextAttempt's cross-observer tally adjudicates coordinator equivocation). A 1-of-1
215+
// attempt retains only its own authoritative package (no equivocation), so exactly
216+
// one proof is stashed -- proving the seam fires through the real runner+collector,
217+
// not a directly-seeded stash.
218+
func TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure(t *testing.T) {
219+
ResetPendingEvidenceRegistryForTest()
220+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
221+
222+
f := newDriveFixture(t)
223+
// Fail at aggregation -- the runner records the coordinator's signing package
224+
// (obtainSigningPackage -> RecordSigningPackage) BEFORE InteractiveAggregate, so
225+
// the collector still holds it when the drive extracts on failure.
226+
f.engine.aggregateErr = fmt.Errorf("aggregate share verification failed")
227+
RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine })
228+
t.Setenv(InteractiveSigningOptInEnvVar, "true")
229+
230+
if _, err := f.run(t); err == nil {
231+
t.Fatal("expected a hard-fail error on runner failure")
232+
}
233+
234+
// The proofs are stashed under the attempt's (RoastSessionID==ctx.SessionID,
235+
// member, attemptHash) -- the same key BroadcastForcedSnapshot reads.
236+
evidence, proofs, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash())
237+
if !ok {
238+
t.Fatal("a committed interactive failure must stash the retained coordinator package proof")
239+
}
240+
if len(proofs) != 1 {
241+
t.Fatalf("a 1-of-1 attempt retains exactly its own authoritative package; got %d proofs", len(proofs))
242+
}
243+
if len(evidence.Overflows)+len(evidence.Rejects)+len(evidence.Conflicts) != 0 {
244+
t.Fatalf("interactive failure must stash proofs only, no coarse evidence; got %+v", evidence)
245+
}
246+
}

pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,3 +843,134 @@ func TestRoastTransitionExchange_StashedProofsDriveCoordinatorEquivocationExclus
843843
}
844844
}
845845
}
846+
847+
// TestRoastTransitionExchange_CombinedFaultsExcludeBoth is the 2a+2b coexistence
848+
// capstone (RFC-21 Phase 7.3 PR2b-2 step 2c): ONE transition bundle carrying BOTH
849+
// f+1 reject evidence (against a bad signer) AND coordinator-equivocation proofs
850+
// (two distinct authentic bodies) excludes BOTH the rejected member and the
851+
// equivocating coordinator in a single NextAttempt -- the union stash and the two
852+
// independent NextAttempt paths working together, including snapshots that carry
853+
// evidence AND a proof at once.
854+
func TestRoastTransitionExchange_CombinedFaultsExcludeBoth(t *testing.T) {
855+
ResetObservedAttemptRegistryForTest()
856+
ResetRoastTransitionRegistryForTest()
857+
ResetPendingEvidenceRegistryForTest()
858+
t.Cleanup(ResetObservedAttemptRegistryForTest)
859+
t.Cleanup(ResetRoastTransitionRegistryForTest)
860+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
861+
862+
roastSessionID := "exchange-combined-faults-session"
863+
included := []group.MemberIndex{1, 2, 3, 4, 5}
864+
dkgKey := []byte{0x0d, 0x0e}
865+
// original size 5; quorum = 5 - threshold + 1 = 3 at threshold 3. Excluding the
866+
// rejected member + the coordinator leaves 3 == threshold -> feasible.
867+
const threshold uint = 3
868+
869+
ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey)
870+
hash := ctx.Hash()
871+
nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey)
872+
873+
binding, ok := observedAttempt(roastSessionID, 1, hash)
874+
if !ok {
875+
t.Fatal("missing observe binding for member 1")
876+
}
877+
elected, err := nodes[1].coord.SelectedCoordinator(binding.handle)
878+
if err != nil {
879+
t.Fatalf("selected coordinator: %v", err)
880+
}
881+
882+
// A bad signer to reject, distinct from the coordinator.
883+
var rejected group.MemberIndex
884+
for _, m := range included {
885+
if m != elected {
886+
rejected = m
887+
break
888+
}
889+
}
890+
// Three credible accusers (members other than the rejected one) reach the f+1
891+
// reject quorum against it.
892+
accusers := make([]group.MemberIndex, 0, 3)
893+
for _, m := range included {
894+
if m != rejected {
895+
accusers = append(accusers, m)
896+
if len(accusers) == 3 {
897+
break
898+
}
899+
}
900+
}
901+
rejectEvidence := attempt.Evidence{
902+
Rejects: map[group.MemberIndex][]attempt.RejectEntry{
903+
rejected: {{Reason: "validation_gate_rejected", Count: 1}},
904+
},
905+
}
906+
for _, a := range accusers {
907+
stashPendingEvidence(roastSessionID, a, hash, rejectEvidence)
908+
}
909+
910+
// Two of those accusers ALSO each retained a DIFFERENT coordinator package
911+
// (targeted equivocation); their snapshots therefore carry evidence AND a proof.
912+
makeProof := func(body string) []byte {
913+
pkg := &roast.SigningPackage{
914+
AttemptContextHash: append([]byte(nil), hash[:]...),
915+
CoordinatorIDValue: uint32(elected),
916+
SigningPackageBytes: []byte(body),
917+
CoordinatorSignature: []byte{0x01},
918+
}
919+
env, perr := pkg.Marshal()
920+
if perr != nil {
921+
t.Fatalf("marshal proof: %v", perr)
922+
}
923+
return env
924+
}
925+
stashPendingCoordinatorProofs(roastSessionID, accusers[0], hash, [][]byte{makeProof("frost-package-A")})
926+
stashPendingCoordinatorProofs(roastSessionID, accusers[1], hash, [][]byte{makeProof("frost-package-B")})
927+
928+
bundleMsg, electedFromBundle := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes)
929+
if electedFromBundle != elected {
930+
t.Fatalf("elected mismatch: %d vs %d", electedFromBundle, elected)
931+
}
932+
933+
bundle := &roast.TransitionMessage{}
934+
if err := bundle.Unmarshal(bundleMsg.Payload); err != nil {
935+
t.Fatalf("unmarshal bundle: %v", err)
936+
}
937+
938+
// Compute the next attempt from a node that is neither the rejected member nor
939+
// the coordinator -- an honest verifier whose observe binding is intact.
940+
var verifier group.MemberIndex
941+
for _, m := range included {
942+
if m != elected && m != rejected {
943+
verifier = m
944+
break
945+
}
946+
}
947+
vbinding, ok := observedAttempt(roastSessionID, verifier, hash)
948+
if !ok {
949+
t.Fatalf("verifier seat %d must hold its observe binding", verifier)
950+
}
951+
if err := nodes[verifier].coord.VerifyBundle(vbinding.handle, bundle); err != nil {
952+
t.Fatalf("verify bundle: %v", err)
953+
}
954+
next, err := nodes[verifier].coord.NextAttempt(vbinding.handle, bundle, threshold, dkgKey)
955+
if err != nil {
956+
t.Fatalf("next attempt: %v", err)
957+
}
958+
959+
contains := func(s []group.MemberIndex, m group.MemberIndex) bool {
960+
for _, x := range s {
961+
if x == m {
962+
return true
963+
}
964+
}
965+
return false
966+
}
967+
if !contains(next.ExcludedSet, rejected) {
968+
t.Fatalf("f+1-rejected member %d must be excluded; excluded=%v", rejected, next.ExcludedSet)
969+
}
970+
if !contains(next.ExcludedSet, elected) {
971+
t.Fatalf("equivocating coordinator %d must be excluded; excluded=%v", elected, next.ExcludedSet)
972+
}
973+
if contains(next.IncludedSet, rejected) || contains(next.IncludedSet, elected) {
974+
t.Fatalf("excluded members must not remain in included=%v", next.IncludedSet)
975+
}
976+
}

0 commit comments

Comments
 (0)