Skip to content

Commit 0f6e5f9

Browse files
authored
feat(frost): wire interactive share-verification blame into the bundle (7.3 share-blame) (#4091)
## RFC-21 Phase 7.3 — interactive share-verification blame (the third fault source) Follows PR2b-2 (the blame layer: 2a #4088 coarse f+1 evidence, 2b #4089 coordinator-equivocation proofs, 2c #4090 tests). This closes the **last** blame gap. ### The gap The blame layer excluded members for coarse faults and coordinator equivocation, but an **interactive bad-share submitter escaped blame entirely**. The engine's `InteractiveAggregate` surfaces candidate culprits in a typed `InteractiveAggregateShareVerificationError{CandidateCulprits []uint16}`, yet the runner just wrapped and returned the error, **dropping them**. `EngineRound2ShareVerifier` and `Round2Collector.ClassifyCandidateCulprits` were both complete but constructed/called **nowhere** — so a member that submitted an invalid FROST signature share was never excluded and the retry could loop. ### The wiring On a `runner.Run` share-verification failure, `driveInteractiveRoastSigningIfEnabled` (where 2b already extracts proofs): 1. `errors.As` the typed `*InteractiveAggregateShareVerificationError` (through the runner's `%w` wrap); 2. converts the engine's `uint16` candidates → `MemberIndex`, **dropping `0` / out-of-range** so a malformed candidate can't truncate into an honest seat; 3. type-asserts the engine to `Round2ShareVerifyingEngine` (skip if absent — still stash 2b proofs); 4. builds an `EngineRound2ShareVerifier` bound to the attempt (`SessionID == active.SessionID()`, `AttemptContextHash`, `TaprootMerkleRoot`); 5. `ClassifyCandidateCulprits` re-verifies each candidate's **retained** share (frozen-Q1 boundary: only an **ACCEPTED** retained share that re-verifies **INVALID** is blamed; every not-the-member's-fault condition fails closed); 6. stashes the reject accusations in the **same union pending-evidence entry** as the 2b proofs. `BroadcastForcedSnapshot` carries them; `computeNextAttempt`'s f+1 reject gate excludes an f+1-corroborated bad-share member. ### Why f+1 (not instant) A share is self-incriminating only against the package **this observer accepted** — a byzantine coordinator's targeted split could otherwise make one honest observer instant-exclude an honest peer. f+1 corroboration (honest observers re-verify identical retained bytes deterministically) closes that. Only unforgeable coordinator-equivocation proofs (2b) are instant. ### Safety Best-effort + fail-safe: a non-share error, a verifier-incapable engine, malformed candidates, or an empty classification all stash nothing. `EngineRound2ShareVerifier` already fails closed against false blame (bound-attempt / root / submitter / package-body checks). Compile-time assertion that the cgo engine satisfies `Round2ShareVerifyingEngine`. Stale "evidence/proofs mutually exclusive" comments corrected (one interactive failure now carries both). ### Scope / gating Prod-dormant until the cgo interactive engine is registered (gated on the `frost-secp256k1-tr` audit). ### Verification - build + vet across 5 tag combos (default / `frost_native` / `frost_roast_retry` / `frost_native frost_roast_retry` / `frost_native frost_tbtc_signer` w/ CGO) - tests: blame-fires (engine-verified-invalid share → reject through the real drive+runner+collector+verifier), skip-without-verifier, malformed-candidates-dropped; `-race`; gofmt clean - design locked via Codex + Gemini consult (both PROCEED, no holes) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 33fee4b + 96ae5a3 commit 0f6e5f9

5 files changed

Lines changed: 238 additions & 7 deletions

pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,12 @@ type buildTaggedTBTCSignerEngine struct{}
377377
// constructs one.
378378
var _ interactiveSigningEngine = (*buildTaggedTBTCSignerEngine)(nil)
379379

380+
// The cgo engine must also satisfy the share-blame re-verifier boundary
381+
// (Round2ShareVerifyingEngine, RFC-21 Phase 7.3 share-blame): the drive type-asserts
382+
// the registered engine to it to classify interactive aggregate share-verification
383+
// culprits. Compile-check it here against the real engine.
384+
var _ Round2ShareVerifyingEngine = (*buildTaggedTBTCSignerEngine)(nil)
385+
380386
type buildTaggedTBTCSignerRunDKGRequest struct {
381387
SessionID string `json:"session_id"`
382388
Participants []buildTaggedTBTCSignerDKGParticipant `json:"participants"`

pkg/frost/signing/roast_interactive_signing_drive_frost_native.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ func driveInteractiveRoastSigningIfEnabled(
159159
attemptCtx.SessionID, request.MemberIndex, attemptHash, proofs,
160160
)
161161
}
162+
// RFC-21 Phase 7.3 share-blame (the third fault source): if the aggregate
163+
// failed on share verification, classify the engine's candidate culprits
164+
// against this seat's retained shares and stash the resulting f+1 reject
165+
// accusations -- carried alongside the proofs in the same union
166+
// pending-evidence entry, so one failed attempt can publish both.
167+
stashInteractiveShareBlame(err, attemptCtx, request, collector, engine)
162168
// Propagate so the outer signingRetryLoop advances and drives the transition
163169
// exchange (OnAttemptFailed -> BroadcastForcedSnapshot).
164170
return nil, fmt.Errorf("interactive ROAST signing attempt: %w", err)

pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,111 @@ func TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure(t *testi
244244
t.Fatalf("interactive failure must stash proofs only, no coarse evidence; got %+v", evidence)
245245
}
246246
}
247+
248+
// verifierCapableFakeEngine wraps the fake interactive engine with a configurable
249+
// share re-verification verdict, so it ALSO satisfies Round2ShareVerifyingEngine
250+
// (the plain fake does not). Used to drive the share-blame path (RFC-21 Phase 7.3).
251+
type verifierCapableFakeEngine struct {
252+
*fakeInteractiveSigningEngine
253+
shareVerdict NativeShareVerificationVerdict
254+
shareErr error
255+
}
256+
257+
func (e *verifierCapableFakeEngine) VerifySignatureShare(
258+
_ string, _ []byte, _ []byte, _ uint16, _ *[32]byte,
259+
) (NativeShareVerificationVerdict, error) {
260+
return e.shareVerdict, e.shareErr
261+
}
262+
263+
// TestDriveInteractiveRoastSigning_SkipsShareBlameWithoutVerifierEngine asserts the
264+
// share-blame path fails safe when the engine cannot re-verify shares: the plain
265+
// fake engine does not implement Round2ShareVerifyingEngine, so the type-assert in
266+
// the drive skips classification. The 2b coordinator-proof stash still fires (a
267+
// 1-of-1 attempt retains its authoritative package), so the entry exists but
268+
// carries NO reject evidence -- no false blame from a missing verifier.
269+
func TestDriveInteractiveRoastSigning_SkipsShareBlameWithoutVerifierEngine(t *testing.T) {
270+
ResetPendingEvidenceRegistryForTest()
271+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
272+
273+
f := newDriveFixture(t)
274+
f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{1}}
275+
RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine })
276+
t.Setenv(InteractiveSigningOptInEnvVar, "true")
277+
278+
if _, err := f.run(t); err == nil {
279+
t.Fatal("expected a hard-fail error on share-verification failure")
280+
}
281+
282+
evidence, proofs, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash())
283+
if !ok {
284+
t.Fatal("the 2b proof stash should still produce an entry")
285+
}
286+
if len(evidence.Rejects) != 0 {
287+
t.Fatalf("share-blame must be skipped without a verifier engine; got rejects %+v", evidence.Rejects)
288+
}
289+
if len(proofs) == 0 {
290+
t.Fatal("the 2b authoritative package proof should still be stashed")
291+
}
292+
}
293+
294+
// TestDriveInteractiveRoastSigning_StashesShareRejectBlameOnVerifiedInvalidShare is
295+
// the share-blame happy path (RFC-21 Phase 7.3, the third fault source): an
296+
// interactive aggregate that fails naming member 1 a candidate culprit, with an
297+
// engine that re-verifies member 1's retained share INVALID, stashes an f+1 reject
298+
// accusation against member 1 (alongside the 2b authoritative proof in the same
299+
// union entry).
300+
func TestDriveInteractiveRoastSigning_StashesShareRejectBlameOnVerifiedInvalidShare(t *testing.T) {
301+
ResetPendingEvidenceRegistryForTest()
302+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
303+
304+
f := newDriveFixture(t)
305+
f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{1}}
306+
verifierEngine := &verifierCapableFakeEngine{
307+
fakeInteractiveSigningEngine: f.engine,
308+
shareVerdict: NativeShareVerdictInvalid,
309+
}
310+
RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return verifierEngine })
311+
t.Setenv(InteractiveSigningOptInEnvVar, "true")
312+
313+
if _, err := f.run(t); err == nil {
314+
t.Fatal("expected a hard-fail error on share-verification failure")
315+
}
316+
317+
evidence, _, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash())
318+
if !ok {
319+
t.Fatal("share-verification failure must stash reject evidence")
320+
}
321+
if len(evidence.Rejects[1]) == 0 {
322+
t.Fatalf("member 1's engine-verified-invalid share must produce a reject accusation; got %+v", evidence.Rejects)
323+
}
324+
}
325+
326+
// TestDriveInteractiveRoastSigning_SkipsShareBlameForMalformedCandidates guards the
327+
// uint16->MemberIndex conversion: a zero or out-of-range (> uint8 max) candidate is
328+
// dropped BEFORE classification, so a malformed engine candidate can never truncate
329+
// into -- and falsely blame -- an honest seat. With every candidate dropped, no
330+
// reject evidence is stashed (the 2b proof still is).
331+
func TestDriveInteractiveRoastSigning_SkipsShareBlameForMalformedCandidates(t *testing.T) {
332+
ResetPendingEvidenceRegistryForTest()
333+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
334+
335+
f := newDriveFixture(t)
336+
f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{0, 300}}
337+
verifierEngine := &verifierCapableFakeEngine{
338+
fakeInteractiveSigningEngine: f.engine,
339+
shareVerdict: NativeShareVerdictInvalid,
340+
}
341+
RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return verifierEngine })
342+
t.Setenv(InteractiveSigningOptInEnvVar, "true")
343+
344+
if _, err := f.run(t); err == nil {
345+
t.Fatal("expected a hard-fail error")
346+
}
347+
evidence, _, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash())
348+
if !ok {
349+
t.Fatal("the 2b proof stash should still produce an entry")
350+
}
351+
if len(evidence.Rejects) != 0 {
352+
t.Fatalf("malformed candidates must produce no reject blame; got %+v", evidence.Rejects)
353+
}
354+
}

pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ type pendingEvidenceEntry struct {
4242
evidence attempt.Evidence
4343
// coordinatorProofs holds the coordinator-signed signing-package proof
4444
// envelope(s) the interactive path retained for the attempt (RFC-21 Phase 7.3
45-
// PR2b-2 step 2b). Empty for a coarse attempt. The two sources are mutually
46-
// exclusive per attempt, so in practice an entry carries evidence XOR proofs --
47-
// but both fields are independent so an entry carrying both stays structurally
48-
// valid (NextAttempt reads the categories independently).
45+
// PR2b-2 step 2b). Empty for a coarse attempt. evidence and coordinatorProofs are
46+
// independent: a coarse attempt carries only evidence, while ONE interactive
47+
// failure can carry BOTH a coordinator proof (2b) and share-verification reject
48+
// evidence (7.3 share-blame) -- NextAttempt reads the categories independently.
4949
coordinatorProofs [][]byte
5050
createdAt time.Time
5151
}
@@ -69,9 +69,10 @@ func stashPendingEvidence(
6969
pendingEvidenceMu.Lock()
7070
defer pendingEvidenceMu.Unlock()
7171
// Upsert the evidence field, preserving any coordinator proofs already stashed
72-
// for this attempt. The coarse and interactive paths are mutually exclusive per
73-
// attempt, so normally only one writer fires; preserving the sibling field keeps
74-
// the entry valid if that ever changes, never an XOR assumption that drops data.
72+
// for this attempt. An interactive failure legitimately stashes BOTH coordinator
73+
// proofs (2b) and share-verification reject evidence (7.3 share-blame) for one
74+
// attempt, so preserving the sibling field is load-bearing -- never an XOR
75+
// assumption that would drop the other writer's data.
7576
entry := pendingEvidenceRegistry[key]
7677
entry.evidence = copyEvidence(evidence)
7778
entry.createdAt = time.Now()
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//go:build frost_native
2+
3+
package signing
4+
5+
import (
6+
"errors"
7+
8+
"github.com/keep-network/keep-core/pkg/frost/roast"
9+
"github.com/keep-network/keep-core/pkg/frost/roast/attempt"
10+
"github.com/keep-network/keep-core/pkg/protocol/group"
11+
)
12+
13+
// stashInteractiveShareBlame is the third blame-layer fault source (RFC-21 Phase
14+
// 7.3, after PR2b-2's 2a coarse evidence + 2b coordinator-equivocation proofs): it
15+
// turns the engine's interactive aggregate share-verification culprits into f+1
16+
// reject accusations carried in the transition bundle.
17+
//
18+
// When InteractiveAggregate fails because a member submitted a bad FROST signature
19+
// share, the engine names CANDIDATE culprits (pure crypto, no blame). This re-runs
20+
// each candidate's RETAINED operator-signed share through the engine-backed
21+
// Round2ShareVerifier: collector.ClassifyCandidateCulprits applies the frozen Q1
22+
// boundary -- only an ACCEPTED retained share that re-verifies INVALID is blamed;
23+
// every not-the-member's-fault condition (mis-binding, cross-attempt, wrong root,
24+
// no retained share, divergent share, indeterminate) fails closed -- and the
25+
// resulting reject accusations are stashed so BroadcastForcedSnapshot carries them
26+
// in this seat's snapshot. computeNextAttempt's f+1 reject gate then excludes a
27+
// member that enough honest observers independently re-verified as a bad-share
28+
// submitter.
29+
//
30+
// f+1 (not instant, unlike 2b coordinator equivocation): a member's share is
31+
// self-incriminating only against THE PACKAGE THIS OBSERVER ACCEPTED. A byzantine
32+
// coordinator's targeted split (different packages to different members) could
33+
// otherwise make one honest observer instant-exclude an honest peer; requiring f+1
34+
// independent observers -- who re-verify identical retained bytes deterministically
35+
// -- to agree closes that hole.
36+
//
37+
// Best-effort and fail-safe: a runErr that is not a share-verification failure, an
38+
// engine without share re-verification, malformed candidates, a verifier-build
39+
// failure, or an empty classification all stash nothing. It layers ON TOP of the
40+
// 2b coordinator-proof stash for the same attempt -- the union pending-evidence
41+
// entry carries both -- so a single failed attempt can publish reject evidence AND
42+
// equivocation proofs.
43+
func stashInteractiveShareBlame(
44+
runErr error,
45+
attemptCtx attempt.AttemptContext,
46+
request *NativeExecutionFFISigningRequest,
47+
collector *roast.Round2Collector,
48+
engine interactiveSigningEngine,
49+
) {
50+
var shareErr *InteractiveAggregateShareVerificationError
51+
if !errors.As(runErr, &shareErr) || len(shareErr.CandidateCulprits) == 0 {
52+
return
53+
}
54+
// The engine-backed share re-verifier is an OPTIONAL capability (interface
55+
// segregation): absent (e.g. a deployment whose engine cannot re-verify shares)
56+
// -> skip share-blame. The 2b coordinator proofs were stashed separately.
57+
verifyEngine, ok := engine.(Round2ShareVerifyingEngine)
58+
if !ok {
59+
return
60+
}
61+
// Convert the engine's wire uint16 candidates to MemberIndex (uint8), dropping 0
62+
// and any value above the max member index: a malformed candidate must never
63+
// truncate into -- and so falsely blame -- an honest seat.
64+
candidates := make([]group.MemberIndex, 0, len(shareErr.CandidateCulprits))
65+
for _, c := range shareErr.CandidateCulprits {
66+
if c == 0 || c > uint16(group.MaxMemberIndex) {
67+
continue
68+
}
69+
candidates = append(candidates, group.MemberIndex(c))
70+
}
71+
if len(candidates) == 0 {
72+
return
73+
}
74+
75+
attemptHash := attemptCtx.Hash()
76+
// The binding's SessionID is the STABLE engine/ROAST session
77+
// (attemptCtx.SessionID == active.SessionID()), consistent with attemptHash by
78+
// construction (both derive from attemptCtx) -- the verifier's hard
79+
// construction-time contract that keeps it from turning an honest share invalid.
80+
verifier, err := NewEngineRound2ShareVerifier(verifyEngine, Round2ShareVerificationBinding{
81+
SessionID: attemptCtx.SessionID,
82+
AttemptContextHash: attemptHash,
83+
TaprootMerkleRoot: request.TaprootMerkleRoot,
84+
})
85+
if err != nil {
86+
return
87+
}
88+
89+
rejects, err := collector.ClassifyCandidateCulprits(attemptHash[:], candidates, verifier)
90+
if err != nil || len(rejects) == 0 {
91+
return
92+
}
93+
94+
evidence := attempt.Evidence{
95+
Rejects: make(map[group.MemberIndex][]attempt.RejectEntry, len(rejects)),
96+
}
97+
for _, re := range rejects {
98+
if re.Count == 0 {
99+
continue
100+
}
101+
evidence.Rejects[re.Sender] = append(evidence.Rejects[re.Sender], attempt.RejectEntry{
102+
Reason: re.Reason,
103+
Count: re.Count,
104+
})
105+
}
106+
if len(evidence.Rejects) == 0 {
107+
return
108+
}
109+
stashPendingEvidence(attemptCtx.SessionID, request.MemberIndex, attemptHash, evidence)
110+
}

0 commit comments

Comments
 (0)