diff --git a/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go b/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go index 8ebad01dd6..86458b67b6 100644 --- a/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go +++ b/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go @@ -205,3 +205,42 @@ func TestDriveInteractiveRoastSigning_RunnerFailureHardFails(t *testing.T) { t.Fatalf("expected no signature on failure, got %v", sig) } } + +// TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure covers the +// RFC-21 Phase 7.3 PR2b-2 step 2b extraction seam end-to-end through the real +// drive: when a committed interactive attempt fails AFTER the runner recorded the +// coordinator-signed package, the drive surfaces collector.CoordinatorPackageProofs +// and stashes them so BroadcastForcedSnapshot can carry them into the bundle (where +// NextAttempt's cross-observer tally adjudicates coordinator equivocation). A 1-of-1 +// attempt retains only its own authoritative package (no equivocation), so exactly +// one proof is stashed -- proving the seam fires through the real runner+collector, +// not a directly-seeded stash. +func TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + // Fail at aggregation -- the runner records the coordinator's signing package + // (obtainSigningPackage -> RecordSigningPackage) BEFORE InteractiveAggregate, so + // the collector still holds it when the drive extracts on failure. + f.engine.aggregateErr = fmt.Errorf("aggregate share verification failed") + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + if _, err := f.run(t); err == nil { + t.Fatal("expected a hard-fail error on runner failure") + } + + // The proofs are stashed under the attempt's (RoastSessionID==ctx.SessionID, + // member, attemptHash) -- the same key BroadcastForcedSnapshot reads. + evidence, proofs, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("a committed interactive failure must stash the retained coordinator package proof") + } + if len(proofs) != 1 { + t.Fatalf("a 1-of-1 attempt retains exactly its own authoritative package; got %d proofs", len(proofs)) + } + if len(evidence.Overflows)+len(evidence.Rejects)+len(evidence.Conflicts) != 0 { + t.Fatalf("interactive failure must stash proofs only, no coarse evidence; got %+v", evidence) + } +} diff --git a/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go b/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go index 128b36251b..c6243e31c0 100644 --- a/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go +++ b/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go @@ -843,3 +843,134 @@ func TestRoastTransitionExchange_StashedProofsDriveCoordinatorEquivocationExclus } } } + +// TestRoastTransitionExchange_CombinedFaultsExcludeBoth is the 2a+2b coexistence +// capstone (RFC-21 Phase 7.3 PR2b-2 step 2c): ONE transition bundle carrying BOTH +// f+1 reject evidence (against a bad signer) AND coordinator-equivocation proofs +// (two distinct authentic bodies) excludes BOTH the rejected member and the +// equivocating coordinator in a single NextAttempt -- the union stash and the two +// independent NextAttempt paths working together, including snapshots that carry +// evidence AND a proof at once. +func TestRoastTransitionExchange_CombinedFaultsExcludeBoth(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + roastSessionID := "exchange-combined-faults-session" + included := []group.MemberIndex{1, 2, 3, 4, 5} + dkgKey := []byte{0x0d, 0x0e} + // original size 5; quorum = 5 - threshold + 1 = 3 at threshold 3. Excluding the + // rejected member + the coordinator leaves 3 == threshold -> feasible. + const threshold uint = 3 + + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + binding, ok := observedAttempt(roastSessionID, 1, hash) + if !ok { + t.Fatal("missing observe binding for member 1") + } + elected, err := nodes[1].coord.SelectedCoordinator(binding.handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + + // A bad signer to reject, distinct from the coordinator. + var rejected group.MemberIndex + for _, m := range included { + if m != elected { + rejected = m + break + } + } + // Three credible accusers (members other than the rejected one) reach the f+1 + // reject quorum against it. + accusers := make([]group.MemberIndex, 0, 3) + for _, m := range included { + if m != rejected { + accusers = append(accusers, m) + if len(accusers) == 3 { + break + } + } + } + rejectEvidence := attempt.Evidence{ + Rejects: map[group.MemberIndex][]attempt.RejectEntry{ + rejected: {{Reason: "validation_gate_rejected", Count: 1}}, + }, + } + for _, a := range accusers { + stashPendingEvidence(roastSessionID, a, hash, rejectEvidence) + } + + // Two of those accusers ALSO each retained a DIFFERENT coordinator package + // (targeted equivocation); their snapshots therefore carry evidence AND a proof. + makeProof := func(body string) []byte { + pkg := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), hash[:]...), + CoordinatorIDValue: uint32(elected), + SigningPackageBytes: []byte(body), + CoordinatorSignature: []byte{0x01}, + } + env, perr := pkg.Marshal() + if perr != nil { + t.Fatalf("marshal proof: %v", perr) + } + return env + } + stashPendingCoordinatorProofs(roastSessionID, accusers[0], hash, [][]byte{makeProof("frost-package-A")}) + stashPendingCoordinatorProofs(roastSessionID, accusers[1], hash, [][]byte{makeProof("frost-package-B")}) + + bundleMsg, electedFromBundle := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes) + if electedFromBundle != elected { + t.Fatalf("elected mismatch: %d vs %d", electedFromBundle, elected) + } + + bundle := &roast.TransitionMessage{} + if err := bundle.Unmarshal(bundleMsg.Payload); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + + // Compute the next attempt from a node that is neither the rejected member nor + // the coordinator -- an honest verifier whose observe binding is intact. + var verifier group.MemberIndex + for _, m := range included { + if m != elected && m != rejected { + verifier = m + break + } + } + vbinding, ok := observedAttempt(roastSessionID, verifier, hash) + if !ok { + t.Fatalf("verifier seat %d must hold its observe binding", verifier) + } + if err := nodes[verifier].coord.VerifyBundle(vbinding.handle, bundle); err != nil { + t.Fatalf("verify bundle: %v", err) + } + next, err := nodes[verifier].coord.NextAttempt(vbinding.handle, bundle, threshold, dkgKey) + if err != nil { + t.Fatalf("next attempt: %v", err) + } + + contains := func(s []group.MemberIndex, m group.MemberIndex) bool { + for _, x := range s { + if x == m { + return true + } + } + return false + } + if !contains(next.ExcludedSet, rejected) { + t.Fatalf("f+1-rejected member %d must be excluded; excluded=%v", rejected, next.ExcludedSet) + } + if !contains(next.ExcludedSet, elected) { + t.Fatalf("equivocating coordinator %d must be excluded; excluded=%v", elected, next.ExcludedSet) + } + if contains(next.IncludedSet, rejected) || contains(next.IncludedSet, elected) { + t.Fatalf("excluded members must not remain in included=%v", next.IncludedSet) + } +}