Skip to content

Commit 8c369c3

Browse files
authored
feat(frost): member-key the ROAST session-handle binding (7.3 PR2b-2 step 1) (#4087)
## What RFC-21 Phase 7.3 PR2b-2 **step 1**: member-key the ROAST session-handle binding. Re-keys `sessionAttemptBindings` in `pkg/frost/signing` from `sessionID` alone to `(sessionID, member)`, threading the local member (`request.MemberIndex`) through the receive-loop validators and the evidence-submission path. ## Why A multi-seat operator runs one receive loop per local seat. With the binding keyed by `sessionID` alone, sibling seats collided: - the later seat's `Set` **overwrote** the earlier seat's binding, so evidence submission used the wrong handle (mis-attribution); - one seat's cleanup `Clear(sessionID)` deleted the **shared** binding, silently disabling the surviving seat's inbound attempt-context-hash enforcement (a security regression). PR2b-1.5 papered over this with multi-seat fail-closed guards; this PR makes the path actually member-safe and retires those guards. ## Changes - **Registry** (both build flavors): `sessionMemberKey{sessionID, member}` key; `Set`/`Clear`/`currentAttemptHandleForCollect`/`CurrentAttemptHandleForSession` take `group.MemberIndex`. - **Read path**: `verifyMessageAttemptContextHash` / `setMessageAttemptContextHashIfBound` take `member`; the 3 receive-loop callers pass `request.MemberIndex` (the local receiver, **never** the inbound sender). - **Write path**: `BeginOrchestrationForSession` / `EndOrchestrationForSession` / cleanup bind & clear per `(sessionID, member)`. - **Submit**: `submitSnapshotIfActive` becomes member-aware (`RegisteredRoastRetryCoordinatorForMember`). - **Guards retired**: `count>1` terminal in `Begin`; `count>1` no-op in submit. A fully-registered multi-seat operator now proceeds per-seat with isolated bindings. - **Guard kept**: partial-registration fail-closed. ## Design note — partial-registration fail-closed is kept deliberately Member-keying isolates *registered* seats; it does nothing for a seat with **no** coordinator. The ROAST-vs-legacy selector is process-uniform, so an unregistered sibling running legacy while siblings drive bound ROAST would fracture the attempt. So `!ok && count>0 → ErrTerminalSigningFailure` (fail closed); only `count==0` (no seat registered anywhere) → the static legacy sentinel. Design locked via Codex + Gemini consult (both ratified). ## Why it's safe `AttemptContext` has no per-seat fields, so `ctx.Hash()` is identical across sibling seats — member-keying does **not** change validation semantics; it only isolates handles and prevents cross-seat cleanup. ## Tests / verification - New: `_MultiSeatProceedsPerSeat` (isolation via survival-after-sibling-cleanup; sibling handles are equal so cleanup-survival is the proof), `_IsolatesByMember` (registry, distinct handles), `_MultiSeatSubmitsPerSeat`, `_BindingIsMemberScoped`. Flipped the old full-multi-seat fail-closed test; kept the partial one. - All 5 tag combos build+vet+test green; `-race` green on the combined combo; gofmt clean; `pkg/tbtc` signing-loop tests green (tagged + default). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 2cc36ef + 5aa9953 commit 8c369c3

16 files changed

Lines changed: 507 additions & 187 deletions

pkg/frost/signing/attempt_context_binding_validation_default_build_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestVerifyMessageAttemptContextHash_DefaultBuildPassesEverything(t *testing
1414
// build, matching the rollback promise made in the rollout
1515
// guide (docs/development/frost-roast-retry-rollout.adoc).
1616
msg := stubDefaultBuildMessage{}
17-
if err := verifyMessageAttemptContextHash(msg, "any-session"); err != nil {
17+
if err := verifyMessageAttemptContextHash(msg, "any-session", 1); err != nil {
1818
t.Fatalf(
1919
"default build must always pass; got %v",
2020
err,

pkg/frost/signing/attempt_context_binding_validation_frost_native.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package signing
55
import (
66
"errors"
77
"fmt"
8+
9+
"github.com/keep-network/keep-core/pkg/protocol/group"
810
)
911

1012
// attemptContextHashCarrier is implemented by every protocol
@@ -49,12 +51,18 @@ var ErrAttemptContextHashMismatch = errors.New(
4951
// optional to required at the receive boundary, but only when the
5052
// session has a ROAST-attempt binding registered.
5153
//
52-
// When no session-handle binding exists for sessionID (the typical
53-
// state for non-ROAST sessions and for default builds), this
54+
// When no session-handle binding exists for (sessionID, member) (the
55+
// typical state for non-ROAST sessions and for default builds), this
5456
// function returns nil and lets the message through. The receive
5557
// loop's other gates (shouldAcceptNativeFROSTMessage, etc.) still
5658
// apply.
5759
//
60+
// member is the LOCAL receiver seat's member index (request.MemberIndex),
61+
// NOT the inbound message's sender: the binding being enforced is the
62+
// one THIS seat's orchestration set for the attempt it is running. A
63+
// multi-seat operator keys its bindings per seat (RFC-21 Phase 7.3
64+
// PR2b-2), so looking up by sender would read the wrong (or no) binding.
65+
//
5866
// When a binding exists -- i.e. the orchestration layer has begun
5967
// an attempt for this session and is expecting the receive loops
6068
// to participate -- the message must carry an AttemptContextHash
@@ -64,8 +72,9 @@ var ErrAttemptContextHashMismatch = errors.New(
6472
func verifyMessageAttemptContextHash(
6573
msg attemptContextHashCarrier,
6674
sessionID string,
75+
member group.MemberIndex,
6776
) error {
68-
_, ctx, ok := currentAttemptHandleForCollect(sessionID)
77+
_, ctx, ok := currentAttemptHandleForCollect(sessionID, member)
6978
if !ok {
7079
// No binding: legacy / non-ROAST mode. Skip enforcement
7180
// so default builds and non-ROAST sessions stay
@@ -91,12 +100,16 @@ func verifyMessageAttemptContextHash(
91100
// setMessageAttemptContextHashIfBound attaches the current ROAST
92101
// attempt binding to an outbound message. Default/non-ROAST sessions
93102
// have no binding, so the field stays absent for backward
94-
// compatibility.
103+
// compatibility. member is the local sender seat's member index
104+
// (request.MemberIndex); the binding is looked up per (sessionID,
105+
// member) so a multi-seat operator tags each seat's outbound message
106+
// with that seat's own bound context (RFC-21 Phase 7.3 PR2b-2).
95107
func setMessageAttemptContextHashIfBound(
96108
msg outboundAttemptContextHashCarrier,
97109
sessionID string,
110+
member group.MemberIndex,
98111
) {
99-
_, ctx, ok := currentAttemptHandleForCollect(sessionID)
112+
_, ctx, ok := currentAttemptHandleForCollect(sessionID, member)
100113
if !ok {
101114
return
102115
}

pkg/frost/signing/attempt_context_binding_validation_frost_native_test.go

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func TestVerifyMessageAttemptContextHash_NoBindingPasses(t *testing.T) {
6262
{present: true, hash: [AttemptContextHashFieldLength]byte{0x01}},
6363
}
6464
for _, msg := range cases {
65-
if err := verifyMessageAttemptContextHash(msg, "session-x"); err != nil {
65+
if err := verifyMessageAttemptContextHash(msg, "session-x", 1); err != nil {
6666
t.Fatalf(
6767
"no-binding path must pass; got %v for msg %+v",
6868
err, msg,
@@ -76,11 +76,11 @@ func TestVerifyMessageAttemptContextHash_BindingPresent_MatchingHashPasses(t *te
7676
t.Cleanup(ResetSessionHandleRegistryForTest)
7777

7878
ctx := newOrchestrationTestContextForValidation(t)
79-
SetCurrentAttemptHandleForSession("session-match", roast.AttemptHandle{}, ctx)
79+
SetCurrentAttemptHandleForSession("session-match", 1, roast.AttemptHandle{}, ctx)
8080

8181
expected := ctx.Hash()
8282
msg := stubMessage{hash: expected, present: true}
83-
if err := verifyMessageAttemptContextHash(msg, "session-match"); err != nil {
83+
if err := verifyMessageAttemptContextHash(msg, "session-match", 1); err != nil {
8484
t.Fatalf("matching hash must pass; got %v", err)
8585
}
8686
}
@@ -90,10 +90,10 @@ func TestVerifyMessageAttemptContextHash_BindingPresent_MissingHashFails(t *test
9090
t.Cleanup(ResetSessionHandleRegistryForTest)
9191

9292
ctx := newOrchestrationTestContextForValidation(t)
93-
SetCurrentAttemptHandleForSession("session-missing", roast.AttemptHandle{}, ctx)
93+
SetCurrentAttemptHandleForSession("session-missing", 1, roast.AttemptHandle{}, ctx)
9494

9595
msg := stubMessage{present: false}
96-
err := verifyMessageAttemptContextHash(msg, "session-missing")
96+
err := verifyMessageAttemptContextHash(msg, "session-missing", 1)
9797
if !errors.Is(err, ErrAttemptContextHashMissing) {
9898
t.Fatalf(
9999
"expected ErrAttemptContextHashMissing; got %v",
@@ -107,14 +107,14 @@ func TestVerifyMessageAttemptContextHash_BindingPresent_MismatchedHashFails(t *t
107107
t.Cleanup(ResetSessionHandleRegistryForTest)
108108

109109
ctx := newOrchestrationTestContextForValidation(t)
110-
SetCurrentAttemptHandleForSession("session-mismatch", roast.AttemptHandle{}, ctx)
110+
SetCurrentAttemptHandleForSession("session-mismatch", 1, roast.AttemptHandle{}, ctx)
111111

112112
wrong := [AttemptContextHashFieldLength]byte{}
113113
for i := range wrong {
114114
wrong[i] = 0xff
115115
}
116116
msg := stubMessage{hash: wrong, present: true}
117-
err := verifyMessageAttemptContextHash(msg, "session-mismatch")
117+
err := verifyMessageAttemptContextHash(msg, "session-mismatch", 1)
118118
if !errors.Is(err, ErrAttemptContextHashMismatch) {
119119
t.Fatalf(
120120
"expected ErrAttemptContextHashMismatch; got %v",
@@ -123,6 +123,37 @@ func TestVerifyMessageAttemptContextHash_BindingPresent_MismatchedHashFails(t *t
123123
}
124124
}
125125

126+
// TestVerifyMessageAttemptContextHash_BindingIsMemberScoped asserts the binding
127+
// lookup is keyed by the LOCAL receiver seat's member (request.MemberIndex), not
128+
// shared across seats: a binding set for member 1 enforces the hash for member 1
129+
// but is invisible to member 2's receive loop (which has its own binding or, here,
130+
// none -> passes through). This is the PR2b-2 member-keying applied to the receive
131+
// validation path; under the old sessionID-only key, member 2 would have enforced
132+
// member 1's binding.
133+
func TestVerifyMessageAttemptContextHash_BindingIsMemberScoped(t *testing.T) {
134+
ResetSessionHandleRegistryForTest()
135+
t.Cleanup(ResetSessionHandleRegistryForTest)
136+
137+
ctx := newOrchestrationTestContextForValidation(t)
138+
SetCurrentAttemptHandleForSession("session-scoped", 1, roast.AttemptHandle{}, ctx)
139+
140+
// A message that does NOT match the bound context.
141+
wrong := [AttemptContextHashFieldLength]byte{}
142+
for i := range wrong {
143+
wrong[i] = 0xff
144+
}
145+
msg := stubMessage{hash: wrong, present: true}
146+
147+
// Member 1 has the binding -> enforcement runs -> mismatch.
148+
if err := verifyMessageAttemptContextHash(msg, "session-scoped", 1); !errors.Is(err, ErrAttemptContextHashMismatch) {
149+
t.Fatalf("member 1 must enforce its binding; got %v", err)
150+
}
151+
// Member 2 has no binding for this session -> passes through (no enforcement).
152+
if err := verifyMessageAttemptContextHash(msg, "session-scoped", 2); err != nil {
153+
t.Fatalf("member 2 (no binding) must pass through; got %v", err)
154+
}
155+
}
156+
126157
func TestVerifyMessageAttemptContextHash_RealMessageTypeIntegration(t *testing.T) {
127158
// Exercise the helper against a real protocol message type
128159
// (the tbtc-signer round contribution) rather than just the stub,
@@ -132,7 +163,7 @@ func TestVerifyMessageAttemptContextHash_RealMessageTypeIntegration(t *testing.T
132163
t.Cleanup(ResetSessionHandleRegistryForTest)
133164

134165
ctx := newOrchestrationTestContextForValidation(t)
135-
SetCurrentAttemptHandleForSession("session-real-msg", roast.AttemptHandle{}, ctx)
166+
SetCurrentAttemptHandleForSession("session-real-msg", 1, roast.AttemptHandle{}, ctx)
136167

137168
expected := ctx.Hash()
138169
msg := &buildTaggedTBTCSignerRoundContributionMessage{
@@ -143,7 +174,7 @@ func TestVerifyMessageAttemptContextHash_RealMessageTypeIntegration(t *testing.T
143174
}
144175
msg.SetAttemptContextHash(expected)
145176

146-
if err := verifyMessageAttemptContextHash(msg, "session-real-msg"); err != nil {
177+
if err := verifyMessageAttemptContextHash(msg, "session-real-msg", 1); err != nil {
147178
t.Fatalf("real-message integration must pass; got %v", err)
148179
}
149180

@@ -157,9 +188,9 @@ func TestVerifyMessageAttemptContextHash_RealMessageTypeIntegration(t *testing.T
157188
[]group.MemberIndex{1, 2, 3, 4, 5},
158189
nil,
159190
)
160-
SetCurrentAttemptHandleForSession("session-real-msg", roast.AttemptHandle{}, differentCtx)
191+
SetCurrentAttemptHandleForSession("session-real-msg", 1, roast.AttemptHandle{}, differentCtx)
161192

162-
err := verifyMessageAttemptContextHash(msg, "session-real-msg")
193+
err := verifyMessageAttemptContextHash(msg, "session-real-msg", 1)
163194
if !errors.Is(err, ErrAttemptContextHashMismatch) {
164195
t.Fatalf("rebinding must cause mismatch; got %v", err)
165196
}
@@ -170,10 +201,10 @@ func TestSetMessageAttemptContextHashIfBound_AttachesBoundHash(t *testing.T) {
170201
t.Cleanup(ResetSessionHandleRegistryForTest)
171202

172203
ctx := newOrchestrationTestContextForValidation(t)
173-
SetCurrentAttemptHandleForSession("session-outbound", roast.AttemptHandle{}, ctx)
204+
SetCurrentAttemptHandleForSession("session-outbound", 1, roast.AttemptHandle{}, ctx)
174205

175206
msg := &stubMessage{}
176-
setMessageAttemptContextHashIfBound(msg, "session-outbound")
207+
setMessageAttemptContextHashIfBound(msg, "session-outbound", 1)
177208

178209
got, present := msg.GetAttemptContextHash()
179210
if !present {
@@ -189,7 +220,7 @@ func TestSetMessageAttemptContextHashIfBound_NoBindingLeavesAbsent(t *testing.T)
189220
t.Cleanup(ResetSessionHandleRegistryForTest)
190221

191222
msg := &stubMessage{}
192-
setMessageAttemptContextHashIfBound(msg, "session-no-binding")
223+
setMessageAttemptContextHashIfBound(msg, "session-no-binding", 1)
193224

194225
if _, present := msg.GetAttemptContextHash(); present {
195226
t.Fatal("expected no attempt context hash without a session binding")
@@ -201,7 +232,7 @@ func TestSetMessageAttemptContextHashIfBound_AllOutboundMessageTypes(t *testing.
201232
t.Cleanup(ResetSessionHandleRegistryForTest)
202233

203234
ctx := newOrchestrationTestContextForValidation(t)
204-
SetCurrentAttemptHandleForSession("session-all-types", roast.AttemptHandle{}, ctx)
235+
SetCurrentAttemptHandleForSession("session-all-types", 1, roast.AttemptHandle{}, ctx)
205236
expected := ctx.Hash()
206237

207238
messages := []attemptContextHashCarrier{
@@ -214,7 +245,7 @@ func TestSetMessageAttemptContextHashIfBound_AllOutboundMessageTypes(t *testing.
214245
t.Fatalf("%T does not implement outbound carrier", msg)
215246
}
216247

217-
setMessageAttemptContextHashIfBound(outbound, "session-all-types")
248+
setMessageAttemptContextHashIfBound(outbound, "session-all-types", 1)
218249

219250
got, present := msg.GetAttemptContextHash()
220251
if !present {

pkg/frost/signing/attempt_context_bound_exchange_frost_native_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@ func bindAttemptContextHashForExchangeTest(
3838
t.Fatalf("failed creating attempt context: [%v]", err)
3939
}
4040

41-
SetCurrentAttemptHandleForSession(sessionID, roast.AttemptHandle{}, ctx)
41+
// Bind the (identical, group-level) attempt context for EVERY participating
42+
// local member seat. The bootstrap round runs one receive loop per member, and
43+
// each loop looks the binding up by its own (sessionID, member) after PR2b-2
44+
// member-keyed the registry; binding only one member would leave the others'
45+
// loops unbound and silently skip the hash enforcement this test exercises.
46+
for _, member := range members {
47+
SetCurrentAttemptHandleForSession(sessionID, member, roast.AttemptHandle{}, ctx)
48+
}
4249
}
4350

4451
func TestBuildTaggedTBTCSignerBootstrapCoarseRound_BoundAttemptContextHashExchange(

pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ func buildTaggedTBTCSignerRoundContributions(
10861086
ContributionIdentifier: ownContribution.Identifier,
10871087
ContributionData: append([]byte{}, ownContribution.Data...),
10881088
}
1089-
setMessageAttemptContextHashIfBound(roundContributionMessage, request.SessionID)
1089+
setMessageAttemptContextHashIfBound(roundContributionMessage, request.SessionID, request.MemberIndex)
10901090

10911091
if err := request.Channel.Send(
10921092
ctx,
@@ -1102,7 +1102,7 @@ func buildTaggedTBTCSignerRoundContributions(
11021102
// when nothing is registered preserves Phase 2 receive
11031103
// semantics.
11041104
contributionsRecorder := roastRetryRecorderForCollect()
1105-
defer submitSnapshotIfActive(request.SessionID, contributionsRecorder)
1105+
defer submitSnapshotIfActive(request.SessionID, request.MemberIndex, contributionsRecorder)
11061106
peerMessages, err := collectBuildTaggedTBTCSignerRoundContributionMessages(
11071107
ctx,
11081108
request,
@@ -1237,7 +1237,7 @@ func collectBuildTaggedTBTCSignerRoundContributionMessages(
12371237
return
12381238
}
12391239

1240-
if err := verifyMessageAttemptContextHash(payload, request.SessionID); err != nil {
1240+
if err := verifyMessageAttemptContextHash(payload, request.SessionID, request.MemberIndex); err != nil {
12411241
evidence.RecordReject(payload.SenderID(), "attempt_context_hash_mismatch")
12421242
return
12431243
}

pkg/frost/signing/roast_retry_attempt_handle_default_build.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,24 @@ package signing
55
import (
66
"github.com/keep-network/keep-core/pkg/frost/roast"
77
"github.com/keep-network/keep-core/pkg/frost/roast/attempt"
8+
"github.com/keep-network/keep-core/pkg/protocol/group"
89
)
910

1011
// SetCurrentAttemptHandleForSession is a no-op in the default build:
11-
// the receive loops will never find a handle for any session, so the
12-
// snapshot submission path is dormant. The build-tagged
12+
// the receive loops will never find a handle for any (session, member),
13+
// so the snapshot submission path is dormant. The build-tagged
1314
// implementation does the real registration.
1415
func SetCurrentAttemptHandleForSession(
1516
_ string,
17+
_ group.MemberIndex,
1618
_ roast.AttemptHandle,
1719
_ attempt.AttemptContext,
1820
) {
1921
}
2022

2123
// ClearCurrentAttemptHandleForSession is a no-op in the default
2224
// build.
23-
func ClearCurrentAttemptHandleForSession(_ string) {}
25+
func ClearCurrentAttemptHandleForSession(_ string, _ group.MemberIndex) {}
2426

2527
// ResetSessionHandleRegistryForTest is a no-op in the default
2628
// build.
@@ -35,6 +37,7 @@ func StartSessionHandleSweeper() {}
3537
// the RecordEvidence call.
3638
func currentAttemptHandleForCollect(
3739
_ string,
40+
_ group.MemberIndex,
3841
) (roast.AttemptHandle, attempt.AttemptContext, bool) {
3942
return roast.AttemptHandle{}, attempt.AttemptContext{}, false
4043
}
@@ -45,6 +48,7 @@ func currentAttemptHandleForCollect(
4548
// always returns ok=false.
4649
func CurrentAttemptHandleForSession(
4750
sessionID string,
51+
member group.MemberIndex,
4852
) (roast.AttemptHandle, attempt.AttemptContext, bool) {
49-
return currentAttemptHandleForCollect(sessionID)
53+
return currentAttemptHandleForCollect(sessionID, member)
5054
}

0 commit comments

Comments
 (0)