Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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()
Expand Down
110 changes: 110 additions & 0 deletions pkg/frost/signing/roast_share_blame_frost_native.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading