diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go index 09491fd09f..e9d5f55a69 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -377,6 +377,12 @@ type buildTaggedTBTCSignerEngine struct{} // constructs one. var _ interactiveSigningEngine = (*buildTaggedTBTCSignerEngine)(nil) +// The cgo engine must also satisfy the share-blame re-verifier boundary +// (Round2ShareVerifyingEngine, RFC-21 Phase 7.3 share-blame): the drive type-asserts +// the registered engine to it to classify interactive aggregate share-verification +// culprits. Compile-check it here against the real engine. +var _ Round2ShareVerifyingEngine = (*buildTaggedTBTCSignerEngine)(nil) + type buildTaggedTBTCSignerRunDKGRequest struct { SessionID string `json:"session_id"` Participants []buildTaggedTBTCSignerDKGParticipant `json:"participants"` diff --git a/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go index 3ad97468eb..557440b8bc 100644 --- a/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go +++ b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go @@ -159,6 +159,12 @@ func driveInteractiveRoastSigningIfEnabled( attemptCtx.SessionID, request.MemberIndex, attemptHash, proofs, ) } + // RFC-21 Phase 7.3 share-blame (the third fault source): if the aggregate + // failed on share verification, classify the engine's candidate culprits + // against this seat's retained shares and stash the resulting f+1 reject + // accusations -- carried alongside the proofs in the same union + // pending-evidence entry, so one failed attempt can publish both. + stashInteractiveShareBlame(err, attemptCtx, request, collector, engine) // Propagate so the outer signingRetryLoop advances and drives the transition // exchange (OnAttemptFailed -> BroadcastForcedSnapshot). return nil, fmt.Errorf("interactive ROAST signing attempt: %w", err) 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 86458b67b6..3a64a414ef 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 @@ -244,3 +244,111 @@ func TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure(t *testi t.Fatalf("interactive failure must stash proofs only, no coarse evidence; got %+v", evidence) } } + +// verifierCapableFakeEngine wraps the fake interactive engine with a configurable +// share re-verification verdict, so it ALSO satisfies Round2ShareVerifyingEngine +// (the plain fake does not). Used to drive the share-blame path (RFC-21 Phase 7.3). +type verifierCapableFakeEngine struct { + *fakeInteractiveSigningEngine + shareVerdict NativeShareVerificationVerdict + shareErr error +} + +func (e *verifierCapableFakeEngine) VerifySignatureShare( + _ string, _ []byte, _ []byte, _ uint16, _ *[32]byte, +) (NativeShareVerificationVerdict, error) { + return e.shareVerdict, e.shareErr +} + +// TestDriveInteractiveRoastSigning_SkipsShareBlameWithoutVerifierEngine asserts the +// share-blame path fails safe when the engine cannot re-verify shares: the plain +// fake engine does not implement Round2ShareVerifyingEngine, so the type-assert in +// the drive skips classification. The 2b coordinator-proof stash still fires (a +// 1-of-1 attempt retains its authoritative package), so the entry exists but +// carries NO reject evidence -- no false blame from a missing verifier. +func TestDriveInteractiveRoastSigning_SkipsShareBlameWithoutVerifierEngine(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{1}} + 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 share-verification failure") + } + + evidence, proofs, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("the 2b proof stash should still produce an entry") + } + if len(evidence.Rejects) != 0 { + t.Fatalf("share-blame must be skipped without a verifier engine; got rejects %+v", evidence.Rejects) + } + if len(proofs) == 0 { + t.Fatal("the 2b authoritative package proof should still be stashed") + } +} + +// TestDriveInteractiveRoastSigning_StashesShareRejectBlameOnVerifiedInvalidShare is +// the share-blame happy path (RFC-21 Phase 7.3, the third fault source): an +// interactive aggregate that fails naming member 1 a candidate culprit, with an +// engine that re-verifies member 1's retained share INVALID, stashes an f+1 reject +// accusation against member 1 (alongside the 2b authoritative proof in the same +// union entry). +func TestDriveInteractiveRoastSigning_StashesShareRejectBlameOnVerifiedInvalidShare(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{1}} + verifierEngine := &verifierCapableFakeEngine{ + fakeInteractiveSigningEngine: f.engine, + shareVerdict: NativeShareVerdictInvalid, + } + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return verifierEngine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + if _, err := f.run(t); err == nil { + t.Fatal("expected a hard-fail error on share-verification failure") + } + + evidence, _, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("share-verification failure must stash reject evidence") + } + if len(evidence.Rejects[1]) == 0 { + t.Fatalf("member 1's engine-verified-invalid share must produce a reject accusation; got %+v", evidence.Rejects) + } +} + +// TestDriveInteractiveRoastSigning_SkipsShareBlameForMalformedCandidates guards the +// uint16->MemberIndex conversion: a zero or out-of-range (> uint8 max) candidate is +// dropped BEFORE classification, so a malformed engine candidate can never truncate +// into -- and falsely blame -- an honest seat. With every candidate dropped, no +// reject evidence is stashed (the 2b proof still is). +func TestDriveInteractiveRoastSigning_SkipsShareBlameForMalformedCandidates(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{0, 300}} + verifierEngine := &verifierCapableFakeEngine{ + fakeInteractiveSigningEngine: f.engine, + shareVerdict: NativeShareVerdictInvalid, + } + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return verifierEngine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + if _, err := f.run(t); err == nil { + t.Fatal("expected a hard-fail error") + } + evidence, _, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("the 2b proof stash should still produce an entry") + } + if len(evidence.Rejects) != 0 { + t.Fatalf("malformed candidates must produce no reject blame; got %+v", evidence.Rejects) + } +} diff --git a/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go b/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go index ebcdccfaab..fb04ac5636 100644 --- a/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go +++ b/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go @@ -42,10 +42,10 @@ type pendingEvidenceEntry struct { evidence attempt.Evidence // coordinatorProofs holds the coordinator-signed signing-package proof // envelope(s) the interactive path retained for the attempt (RFC-21 Phase 7.3 - // PR2b-2 step 2b). Empty for a coarse attempt. The two sources are mutually - // exclusive per attempt, so in practice an entry carries evidence XOR proofs -- - // but both fields are independent so an entry carrying both stays structurally - // valid (NextAttempt reads the categories independently). + // PR2b-2 step 2b). Empty for a coarse attempt. evidence and coordinatorProofs are + // independent: a coarse attempt carries only evidence, while ONE interactive + // failure can carry BOTH a coordinator proof (2b) and share-verification reject + // evidence (7.3 share-blame) -- NextAttempt reads the categories independently. coordinatorProofs [][]byte createdAt time.Time } @@ -69,9 +69,10 @@ func stashPendingEvidence( pendingEvidenceMu.Lock() defer pendingEvidenceMu.Unlock() // Upsert the evidence field, preserving any coordinator proofs already stashed - // for this attempt. The coarse and interactive paths are mutually exclusive per - // attempt, so normally only one writer fires; preserving the sibling field keeps - // the entry valid if that ever changes, never an XOR assumption that drops data. + // for this attempt. An interactive failure legitimately stashes BOTH coordinator + // proofs (2b) and share-verification reject evidence (7.3 share-blame) for one + // attempt, so preserving the sibling field is load-bearing -- never an XOR + // assumption that would drop the other writer's data. entry := pendingEvidenceRegistry[key] entry.evidence = copyEvidence(evidence) entry.createdAt = time.Now() diff --git a/pkg/frost/signing/roast_share_blame_frost_native.go b/pkg/frost/signing/roast_share_blame_frost_native.go new file mode 100644 index 0000000000..5a0fc7576c --- /dev/null +++ b/pkg/frost/signing/roast_share_blame_frost_native.go @@ -0,0 +1,110 @@ +//go:build frost_native + +package signing + +import ( + "errors" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// stashInteractiveShareBlame is the third blame-layer fault source (RFC-21 Phase +// 7.3, after PR2b-2's 2a coarse evidence + 2b coordinator-equivocation proofs): it +// turns the engine's interactive aggregate share-verification culprits into f+1 +// reject accusations carried in the transition bundle. +// +// When InteractiveAggregate fails because a member submitted a bad FROST signature +// share, the engine names CANDIDATE culprits (pure crypto, no blame). This re-runs +// each candidate's RETAINED operator-signed share through the engine-backed +// Round2ShareVerifier: collector.ClassifyCandidateCulprits applies the frozen Q1 +// boundary -- only an ACCEPTED retained share that re-verifies INVALID is blamed; +// every not-the-member's-fault condition (mis-binding, cross-attempt, wrong root, +// no retained share, divergent share, indeterminate) fails closed -- and the +// resulting reject accusations are stashed so BroadcastForcedSnapshot carries them +// in this seat's snapshot. computeNextAttempt's f+1 reject gate then excludes a +// member that enough honest observers independently re-verified as a bad-share +// submitter. +// +// f+1 (not instant, unlike 2b coordinator equivocation): a member's share is +// self-incriminating only against THE PACKAGE THIS OBSERVER ACCEPTED. A byzantine +// coordinator's targeted split (different packages to different members) could +// otherwise make one honest observer instant-exclude an honest peer; requiring f+1 +// independent observers -- who re-verify identical retained bytes deterministically +// -- to agree closes that hole. +// +// Best-effort and fail-safe: a runErr that is not a share-verification failure, an +// engine without share re-verification, malformed candidates, a verifier-build +// failure, or an empty classification all stash nothing. It layers ON TOP of the +// 2b coordinator-proof stash for the same attempt -- the union pending-evidence +// entry carries both -- so a single failed attempt can publish reject evidence AND +// equivocation proofs. +func stashInteractiveShareBlame( + runErr error, + attemptCtx attempt.AttemptContext, + request *NativeExecutionFFISigningRequest, + collector *roast.Round2Collector, + engine interactiveSigningEngine, +) { + var shareErr *InteractiveAggregateShareVerificationError + if !errors.As(runErr, &shareErr) || len(shareErr.CandidateCulprits) == 0 { + return + } + // The engine-backed share re-verifier is an OPTIONAL capability (interface + // segregation): absent (e.g. a deployment whose engine cannot re-verify shares) + // -> skip share-blame. The 2b coordinator proofs were stashed separately. + verifyEngine, ok := engine.(Round2ShareVerifyingEngine) + if !ok { + return + } + // Convert the engine's wire uint16 candidates to MemberIndex (uint8), dropping 0 + // and any value above the max member index: a malformed candidate must never + // truncate into -- and so falsely blame -- an honest seat. + candidates := make([]group.MemberIndex, 0, len(shareErr.CandidateCulprits)) + for _, c := range shareErr.CandidateCulprits { + if c == 0 || c > uint16(group.MaxMemberIndex) { + continue + } + candidates = append(candidates, group.MemberIndex(c)) + } + if len(candidates) == 0 { + return + } + + attemptHash := attemptCtx.Hash() + // The binding's SessionID is the STABLE engine/ROAST session + // (attemptCtx.SessionID == active.SessionID()), consistent with attemptHash by + // construction (both derive from attemptCtx) -- the verifier's hard + // construction-time contract that keeps it from turning an honest share invalid. + verifier, err := NewEngineRound2ShareVerifier(verifyEngine, Round2ShareVerificationBinding{ + SessionID: attemptCtx.SessionID, + AttemptContextHash: attemptHash, + TaprootMerkleRoot: request.TaprootMerkleRoot, + }) + if err != nil { + return + } + + rejects, err := collector.ClassifyCandidateCulprits(attemptHash[:], candidates, verifier) + if err != nil || len(rejects) == 0 { + return + } + + evidence := attempt.Evidence{ + Rejects: make(map[group.MemberIndex][]attempt.RejectEntry, len(rejects)), + } + for _, re := range rejects { + if re.Count == 0 { + continue + } + evidence.Rejects[re.Sender] = append(evidence.Rejects[re.Sender], attempt.RejectEntry{ + Reason: re.Reason, + Count: re.Count, + }) + } + if len(evidence.Rejects) == 0 { + return + } + stashPendingEvidence(attemptCtx.SessionID, request.MemberIndex, attemptHash, evidence) +}