Skip to content

Commit 3f091da

Browse files
authored
feat(frost): carry coordinator-equivocation proofs into the bundle (7.3 PR2b-2 step 2b) (#4089)
## RFC-21 Phase 7.3 PR2b-2 step 2b — coordinator-equivocation exclusion wiring Follows step 2a (#4088). 2a routed the coarse f+1 evidence (rejects/overflows/conflicts) into the transition bundle; 2b wires the **other** exclusion path — proof-carrying coordinator equivocation. ### The gap `verifiedCoordinatorEquivocations` (`next_attempt.go`) scans every bundle snapshot's `CoordinatorPackageProofs`, authenticates each (coordinator operator-sig over body + attempt), dedupes by body hash, and **instant-excludes** a coordinator that signed ≥2 distinct authentic bodies — no f+1 gate, unforgeable. But nothing populated those proofs in the live path: the interactive runner's `Round2Collector` retains the coordinator-signed packages (and is **not** pruned on failure), yet they never reached the broadcast snapshot. ### The bridge Reuse 2a's pending-evidence stash, extended to a union `{evidence, coordinatorProofs}`: - On an interactive `runner.Run` failure, `driveInteractiveRoastSigningIfEnabled` surfaces `collector.CoordinatorPackageProofs(hash)` and **stashes** them by `(RoastSessionID, member, attemptHash)`. - `BroadcastForcedSnapshot` **consumes** them and passes them to the variadic `NewLocalEvidenceSnapshot(member, hash, evidence, proofs...)` — the seat's **one** signed snapshot carries them (the constructor sorts + owns them; `VerifyBundle`'s censorship check is over the body already compared). ### Why publish-always Publishing the authoritative package on **every** failed interactive attempt (1 envelope when honest, 2 when the coordinator equivocated to this seat) is what lets `NextAttempt` aggregate bodies **across observers** and catch **targeted** equivocation — a coordinator that sends different packages to different members, where no single observer sees both. Identical authoritative proofs dedupe to one body → no false positive. Wire cost is bounded (`MaxCoordinatorPackageProofs = 2`, failure-path only). ### Safety - Each writer **upserts only its own field** — coarse and interactive are mutually exclusive per attempt, but the union never assumes XOR and never drops the sibling's data. - Proofs deep-copied on store; lifecycle (consume-on-broadcast, clear-on-success, session-end, TTL) unchanged. - **No publish on success** (the runner prunes the collector and the failure block is skipped). - A Byzantine observer cannot frame an honest coordinator (`AuthenticateSigningPackage` requires the coordinator's own signature). - Default-build no-op stub added (the drive path is `frost_native`). Stale "deferred cleanup stashes the transition bundle" comments retired. ### Scope / gating Prod-dormant until the cgo interactive engine is registered (gated on the `frost-secp256k1-tr` audit). **2c** (fault injection) follows. ### 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 green incl. an e2e (two observers stash **distinct** coordinator proofs → bundle → `NextAttempt` instant-excludes the coordinator — the targeted-split case) + union/deep-copy/consume unit tests; `-race` on `pkg/frost/signing`; gofmt clean - design locked via Codex + Gemini consult (both PROCEED, no holes) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents c3aa36c + 79014c9 commit 3f091da

7 files changed

Lines changed: 323 additions & 36 deletions

pkg/frost/signing/roast_interactive_signing_drive_frost_native.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ import (
2626
// Retry-loop ownership (per the Phase 7.3 executor-wiring design consult): the
2727
// existing tBTC signingRetryLoop owns retries -- it re-invokes the executor
2828
// (and therefore this helper) once per attempt with a fresh attempt context.
29-
// This helper drives exactly one attempt; it never loops. On a runner failure
30-
// it returns the error so the outer loop advances, and the deferred
31-
// orchestration cleanup stashes the transition bundle the (later PR) blame/retry
32-
// selector consumes.
29+
// This helper drives exactly one attempt; it never loops. On a runner failure it
30+
// stashes any coordinator-equivocation proofs the collector retained (consumed by
31+
// the transition exchange's BroadcastForcedSnapshot) and returns the error so the
32+
// outer loop advances and drives the transition. The deferred orchestration
33+
// cleanup only clears the per-attempt handle binding.
3334
//
3435
// Return contract -- (signature, error):
3536
// - (sig, nil) the interactive attempt completed; the executor returns sig
@@ -144,9 +145,22 @@ func driveInteractiveRoastSigningIfEnabled(
144145

145146
signatureBytes, err := runner.Run(ctx)
146147
if err != nil {
147-
// The attempt was driven and failed. Propagate so the outer tBTC
148-
// signingRetryLoop advances; the deferred orchestration cleanup stashes
149-
// the transition bundle for the next attempt's selector.
148+
// The attempt was driven and failed. Before propagating, surface any
149+
// coordinator-signed package proofs the collector retained -- it is NOT
150+
// pruned on failure (roast_runner_frost_native.go), so the authoritative
151+
// package (plus any body-different one the coordinator equivocated to this
152+
// seat) is still held. Stashing them lets BroadcastForcedSnapshot carry them
153+
// in this seat's snapshot; the bundle's aggregated proofs let NextAttempt
154+
// instant-exclude an equivocating coordinator (RFC-21 Phase 7.3 PR2b-2 step
155+
// 2b). An empty / unknown-attempt result stashes nothing.
156+
attemptHash := attemptCtx.Hash()
157+
if proofs, perr := collector.CoordinatorPackageProofs(attemptHash[:]); perr == nil {
158+
stashPendingCoordinatorProofs(
159+
attemptCtx.SessionID, request.MemberIndex, attemptHash, proofs,
160+
)
161+
}
162+
// Propagate so the outer signingRetryLoop advances and drives the transition
163+
// exchange (OnAttemptFailed -> BroadcastForcedSnapshot).
150164
return nil, fmt.Errorf("interactive ROAST signing attempt: %w", err)
151165
}
152166

pkg/frost/signing/roast_retry_evidence_stash_default_build.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,15 @@ func stashPendingEvidence(
1818
_ attempt.Evidence,
1919
) {
2020
}
21+
22+
// stashPendingCoordinatorProofs is a no-op in the default build, mirroring
23+
// stashPendingEvidence: the interactive drive path (frost_native) calls it, but
24+
// with no ROAST-retry orchestration there is no transition exchange to consume the
25+
// proofs. The build-tagged implementation does the real stashing.
26+
func stashPendingCoordinatorProofs(
27+
_ string,
28+
_ group.MemberIndex,
29+
_ [attempt.MessageDigestLength]byte,
30+
_ [][]byte,
31+
) {
32+
}

pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,15 @@ type pendingEvidenceKey struct {
3939
}
4040

4141
type pendingEvidenceEntry struct {
42-
evidence attempt.Evidence
43-
createdAt time.Time
42+
evidence attempt.Evidence
43+
// coordinatorProofs holds the coordinator-signed signing-package proof
44+
// 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).
49+
coordinatorProofs [][]byte
50+
createdAt time.Time
4451
}
4552

4653
var (
@@ -61,33 +68,63 @@ func stashPendingEvidence(
6168
key := pendingEvidenceKey{sessionID, member, attemptHash}
6269
pendingEvidenceMu.Lock()
6370
defer pendingEvidenceMu.Unlock()
64-
pendingEvidenceRegistry[key] = pendingEvidenceEntry{
65-
evidence: copyEvidence(evidence),
66-
createdAt: time.Now(),
71+
// 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.
75+
entry := pendingEvidenceRegistry[key]
76+
entry.evidence = copyEvidence(evidence)
77+
entry.createdAt = time.Now()
78+
pendingEvidenceRegistry[key] = entry
79+
}
80+
81+
// stashPendingCoordinatorProofs stores deep COPIES of the coordinator-signed
82+
// signing-package proof envelope(s) the interactive runner's Round2Collector
83+
// retained for (sessionID, member, attemptHash). BroadcastForcedSnapshot carries
84+
// them in this seat's snapshot so the bundle's aggregated proofs let NextAttempt
85+
// instant-exclude an equivocating coordinator (RFC-21 Phase 7.3 PR2b-2 step 2b).
86+
// A no-op when proofs is empty. Like stashPendingEvidence it upserts only its own
87+
// field, preserving any coarse evidence already stashed for the attempt.
88+
func stashPendingCoordinatorProofs(
89+
sessionID string,
90+
member group.MemberIndex,
91+
attemptHash [attempt.MessageDigestLength]byte,
92+
proofs [][]byte,
93+
) {
94+
if len(proofs) == 0 {
95+
return
6796
}
97+
key := pendingEvidenceKey{sessionID, member, attemptHash}
98+
pendingEvidenceMu.Lock()
99+
defer pendingEvidenceMu.Unlock()
100+
entry := pendingEvidenceRegistry[key]
101+
entry.coordinatorProofs = copyProofs(proofs)
102+
entry.createdAt = time.Now()
103+
pendingEvidenceRegistry[key] = entry
68104
}
69105

70-
// takePendingEvidence returns the stashed evidence for (sessionID, member,
71-
// attemptHash) and a presence flag, REMOVING it (consume-on-read). The returned
72-
// value is the sole reference -- the entry is deleted from the registry -- so the
73-
// caller owns it exclusively without a further copy. BroadcastForcedSnapshot calls
74-
// it: a present entry means the coarse receive loop observed real evidence for the
75-
// attempt; absent means none was captured (the broadcast then carries an empty
76-
// proof-of-attendance snapshot).
106+
// takePendingEvidence returns the stashed evidence AND coordinator proofs for
107+
// (sessionID, member, attemptHash) plus a presence flag, REMOVING the entry
108+
// (consume-on-read). The returned values are the sole references -- the entry is
109+
// deleted from the registry -- so the caller owns them exclusively without a
110+
// further copy. BroadcastForcedSnapshot calls it: a present entry means the coarse
111+
// receive loop observed evidence (Evidence) and/or the interactive path retained
112+
// coordinator-equivocation proofs ([][]byte) for the attempt; absent means neither
113+
// was captured (the broadcast then carries an empty proof-of-attendance snapshot).
77114
func takePendingEvidence(
78115
sessionID string,
79116
member group.MemberIndex,
80117
attemptHash [attempt.MessageDigestLength]byte,
81-
) (attempt.Evidence, bool) {
118+
) (attempt.Evidence, [][]byte, bool) {
82119
key := pendingEvidenceKey{sessionID, member, attemptHash}
83120
pendingEvidenceMu.Lock()
84121
defer pendingEvidenceMu.Unlock()
85122
entry, ok := pendingEvidenceRegistry[key]
86123
if !ok {
87-
return attempt.Evidence{}, false
124+
return attempt.Evidence{}, nil, false
88125
}
89126
delete(pendingEvidenceRegistry, key)
90-
return entry.evidence, true
127+
return entry.evidence, entry.coordinatorProofs, true
91128
}
92129

93130
// clearPendingEvidence removes any stashed evidence for (sessionID, member,
@@ -202,3 +239,17 @@ func copyEvidence(e attempt.Evidence) attempt.Evidence {
202239
}
203240
return out
204241
}
242+
243+
// copyProofs returns a deep copy of a slice of proof envelopes: the outer slice
244+
// and each inner []byte are re-allocated, so a later mutation of the caller's
245+
// slice cannot race the exchange's read. nil/empty stays nil.
246+
func copyProofs(proofs [][]byte) [][]byte {
247+
if len(proofs) == 0 {
248+
return nil
249+
}
250+
out := make([][]byte, len(proofs))
251+
for i, p := range proofs {
252+
out[i] = append([]byte(nil), p...)
253+
}
254+
return out
255+
}

pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry_test.go

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package signing
44

55
import (
6+
"bytes"
67
"testing"
78
"time"
89

@@ -36,14 +37,14 @@ func TestPendingEvidenceStash_StoreTakeConsumes(t *testing.T) {
3637
if !PendingEvidenceStashedForTest("s", 1) {
3738
t.Fatal("entry must be present after store")
3839
}
39-
got, ok := takePendingEvidence("s", 1, hash)
40+
got, _, ok := takePendingEvidence("s", 1, hash)
4041
if !ok {
4142
t.Fatal("take must find the stored entry")
4243
}
4344
if got.Overflows[2] != 1 || got.Conflicts[4] != 1 || len(got.Rejects[3]) != 1 {
4445
t.Fatalf("taken evidence does not match stored: %+v", got)
4546
}
46-
if _, ok := takePendingEvidence("s", 1, hash); ok {
47+
if _, _, ok := takePendingEvidence("s", 1, hash); ok {
4748
t.Fatal("take must consume: a second take finds nothing")
4849
}
4950
if PendingEvidenceStashedForTest("s", 1) {
@@ -68,7 +69,7 @@ func TestPendingEvidenceStash_DeepCopyIsolatesCallerMutation(t *testing.T) {
6869
src.Rejects[3][0].Count = 99
6970
src.Conflicts[4] = 99
7071

71-
got, ok := takePendingEvidence("s", 1, hash)
72+
got, _, ok := takePendingEvidence("s", 1, hash)
7273
if !ok {
7374
t.Fatal("take must find the stored entry")
7475
}
@@ -97,8 +98,8 @@ func TestPendingEvidenceStash_MemberKeyedIsolation(t *testing.T) {
9798
stashPendingEvidence("s", 1, hash, attempt.Evidence{Overflows: map[group.MemberIndex]uint{7: 1}})
9899
stashPendingEvidence("s", 2, hash, attempt.Evidence{Overflows: map[group.MemberIndex]uint{8: 1}})
99100

100-
got1, ok1 := takePendingEvidence("s", 1, hash)
101-
got2, ok2 := takePendingEvidence("s", 2, hash)
101+
got1, _, ok1 := takePendingEvidence("s", 1, hash)
102+
got2, _, ok2 := takePendingEvidence("s", 2, hash)
102103
if !ok1 || !ok2 {
103104
t.Fatalf("both member entries must exist; ok1=%v ok2=%v", ok1, ok2)
104105
}
@@ -163,3 +164,95 @@ func TestEvictStalePendingEvidence(t *testing.T) {
163164
t.Fatal("entry must be gone after the sweep")
164165
}
165166
}
167+
168+
// TestStashPendingCoordinatorProofs_StoreTakeConsumes is the 2b proof path: the
169+
// interactive drive stashes coordinator-package proofs; takePendingEvidence returns
170+
// them (with empty Evidence) and consumes the entry.
171+
func TestStashPendingCoordinatorProofs_StoreTakeConsumes(t *testing.T) {
172+
ResetPendingEvidenceRegistryForTest()
173+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
174+
175+
hash := stashTestHash(0x61)
176+
proofs := [][]byte{[]byte("auth-package"), []byte("conflicting-package")}
177+
stashPendingCoordinatorProofs("s", 1, hash, proofs)
178+
179+
if !PendingEvidenceStashedForTest("s", 1) {
180+
t.Fatal("entry must be present after stashing proofs")
181+
}
182+
gotEv, gotProofs, ok := takePendingEvidence("s", 1, hash)
183+
if !ok {
184+
t.Fatal("take must find the stored proofs")
185+
}
186+
if len(gotEv.Overflows)+len(gotEv.Rejects)+len(gotEv.Conflicts) != 0 {
187+
t.Fatalf("proof-only entry must carry empty evidence; got %+v", gotEv)
188+
}
189+
if len(gotProofs) != 2 ||
190+
!bytes.Equal(gotProofs[0], proofs[0]) ||
191+
!bytes.Equal(gotProofs[1], proofs[1]) {
192+
t.Fatalf("taken proofs do not match stored: %q", gotProofs)
193+
}
194+
if _, _, ok := takePendingEvidence("s", 1, hash); ok {
195+
t.Fatal("take must consume the proof entry")
196+
}
197+
}
198+
199+
// TestStashPendingCoordinatorProofs_EmptyIsNoOp guards the empty guard: an attempt
200+
// with no retained packages (CoordinatorPackageProofs returned nothing) stashes
201+
// nothing.
202+
func TestStashPendingCoordinatorProofs_EmptyIsNoOp(t *testing.T) {
203+
ResetPendingEvidenceRegistryForTest()
204+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
205+
206+
stashPendingCoordinatorProofs("s", 1, stashTestHash(0x62), nil)
207+
stashPendingCoordinatorProofs("s", 1, stashTestHash(0x62), [][]byte{})
208+
if PendingEvidenceStashedForTest("s", 1) {
209+
t.Fatal("empty proofs must not create a stash entry")
210+
}
211+
}
212+
213+
// TestStashPendingCoordinatorProofs_DeepCopied proves copyProofs isolates the
214+
// stash from later caller mutation of the proof bytes.
215+
func TestStashPendingCoordinatorProofs_DeepCopied(t *testing.T) {
216+
ResetPendingEvidenceRegistryForTest()
217+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
218+
219+
hash := stashTestHash(0x63)
220+
proof := []byte("package-bytes")
221+
stashPendingCoordinatorProofs("s", 1, hash, [][]byte{proof})
222+
223+
proof[0] = 'X' // mutate the caller's slice after the store
224+
225+
_, gotProofs, ok := takePendingEvidence("s", 1, hash)
226+
if !ok || len(gotProofs) != 1 {
227+
t.Fatalf("expected one stashed proof; ok=%v got=%q", ok, gotProofs)
228+
}
229+
if !bytes.Equal(gotProofs[0], []byte("package-bytes")) {
230+
t.Fatalf("proof must reflect store-time bytes, not the mutation; got %q", gotProofs[0])
231+
}
232+
}
233+
234+
// TestPendingEvidenceStash_EvidenceAndProofsUnion asserts the union entry: stashing
235+
// evidence and proofs under the SAME key (which the mutually-exclusive coarse and
236+
// interactive paths normally never do) carries BOTH -- neither writer clobbers the
237+
// other's field (Codex's "never an XOR assumption that drops data").
238+
func TestPendingEvidenceStash_EvidenceAndProofsUnion(t *testing.T) {
239+
ResetPendingEvidenceRegistryForTest()
240+
t.Cleanup(ResetPendingEvidenceRegistryForTest)
241+
242+
hash := stashTestHash(0x64)
243+
stashPendingEvidence("s", 1, hash, attempt.Evidence{
244+
Overflows: map[group.MemberIndex]uint{9: 1},
245+
})
246+
stashPendingCoordinatorProofs("s", 1, hash, [][]byte{[]byte("pkg")})
247+
248+
gotEv, gotProofs, ok := takePendingEvidence("s", 1, hash)
249+
if !ok {
250+
t.Fatal("union entry must exist")
251+
}
252+
if gotEv.Overflows[9] != 1 {
253+
t.Fatalf("proof stash must not clobber the evidence field; got %+v", gotEv.Overflows)
254+
}
255+
if len(gotProofs) != 1 || !bytes.Equal(gotProofs[0], []byte("pkg")) {
256+
t.Fatalf("evidence stash must not clobber the proofs field; got %q", gotProofs)
257+
}
258+
}

pkg/frost/signing/roast_retry_submit_frost_roast_retry_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func TestSubmitSnapshotIfActive_StashesEvidenceWhenBoundAndPopulated(t *testing.
104104
recorder.RecordOverflow(5)
105105
submitSnapshotIfActive("session-real", selfMember, recorder)
106106

107-
evidence, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash())
107+
evidence, _, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash())
108108
if !ok {
109109
t.Fatal("expected stashed evidence after a populated submit")
110110
}
@@ -116,7 +116,7 @@ func TestSubmitSnapshotIfActive_StashesEvidenceWhenBoundAndPopulated(t *testing.
116116
t.Fatalf("stashed overflow counts wrong: %+v", evidence.Overflows)
117117
}
118118
// take consumes: a second take finds nothing.
119-
if _, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash()); ok {
119+
if _, _, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash()); ok {
120120
t.Fatal("take must consume the stash entry")
121121
}
122122
}
@@ -141,7 +141,7 @@ func TestSubmitSnapshotIfActive_StashesRejectOnlySnapshot(t *testing.T) {
141141
recorder.RecordReject(2, "attempt_context_hash_mismatch")
142142
submitSnapshotIfActive("session-reject-only", selfMember, recorder)
143143

144-
evidence, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash())
144+
evidence, _, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash())
145145
if !ok {
146146
t.Fatal("reject-only snapshot must be stashed")
147147
}
@@ -173,8 +173,8 @@ func TestSubmitSnapshotIfActive_MultiSeatStashesPerSeat(t *testing.T) {
173173
submitSnapshotIfActive("session-ms", 1, rec1)
174174
submitSnapshotIfActive("session-ms", 2, rec2)
175175

176-
ev1, ok1 := takePendingEvidence(ctx.SessionID, 1, ctx.Hash())
177-
ev2, ok2 := takePendingEvidence(ctx.SessionID, 2, ctx.Hash())
176+
ev1, _, ok1 := takePendingEvidence(ctx.SessionID, 1, ctx.Hash())
177+
ev2, _, ok2 := takePendingEvidence(ctx.SessionID, 2, ctx.Hash())
178178
if !ok1 || !ok2 {
179179
t.Fatalf("both seats must stash their own evidence; got ok1=%v ok2=%v", ok1, ok2)
180180
}

pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,14 @@ func (e *RoastTransitionExchange) BroadcastForcedSnapshot(
205205
if !ok {
206206
return
207207
}
208-
// takePendingEvidence returns the zero Evidence on a miss, which
208+
// takePendingEvidence returns the zero Evidence + nil proofs on a miss, which
209209
// NewLocalEvidenceSnapshot renders as the empty proof-of-attendance snapshot --
210-
// still broadcast so the seat is not silence-parked.
211-
evidence, _ := takePendingEvidence(e.roastSessionID, e.member, attemptHash)
212-
snapshot := roast.NewLocalEvidenceSnapshot(e.member, attemptHash, evidence)
210+
// still broadcast so the seat is not silence-parked. When present, the snapshot
211+
// carries the coarse path's evidence and/or the interactive path's
212+
// coordinator-equivocation proofs (RFC-21 Phase 7.3 PR2b-2 step 2b); the
213+
// constructor sorts + owns the proofs and the single signing happens below.
214+
evidence, proofs, _ := takePendingEvidence(e.roastSessionID, e.member, attemptHash)
215+
snapshot := roast.NewLocalEvidenceSnapshot(e.member, attemptHash, evidence, proofs...)
213216
payload, err := snapshot.SignableBytes()
214217
if err != nil {
215218
e.logger.Warnf("roast transition: forced snapshot signable bytes: [%v]", err)

0 commit comments

Comments
 (0)