Skip to content

Commit 797aa71

Browse files
mswilkisonclaude
andcommitted
fix(tbtc/signer): bind the interactive completion marker to the message (re-review)
Codex re-review on the Round2 completion gate: [P1] The gate trusted aggregated_interactive_attempt_markers keyed by attempt_id alone, but interactive_aggregate never binds attempt_id to the aggregated package/ message - it only checks the shares aggregate under the session key. So a caller with any valid aggregate could replay it under a DIFFERENT live attempt's id, set that marker, and the new gate would then refuse the real Round2 before its nonce is consumed (an availability/DoS regression the single-member engine never had). Fix: make the completion marker MESSAGE-BOUND - interactive_aggregated_marker = "{attempt_id}@{message_digest}". interactive_aggregate writes it from the package it actually aggregated (all five marker sites: both completion pre-checks, the capacity guard, the insert, and the persist-rollback). The Round2 gate recomputes it from the message THIS member opened, so it preempts Round2 only for a genuine same-message completion; a replayed aggregate carrying a different message sets an unrelated marker that cannot match. Same-message re-aggregation is still rejected (existing restart/repeat tests pass unchanged - they re-aggregate the same message). [P2] On that genuine completion rejection, the unsigned sibling's entry is now dead, so free it (zeroizing its nonces) instead of letting it hold a live-member cap slot until the TTL sweep. Only the matching member's entry is removed. Tests: the multi-seat refusal test now also asserts the marker is bound (attempt_id@digest, not the bare id) and that the refused sibling's entry is freed. All 294 lib tests pass; cargo fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2684dde commit 797aa71

2 files changed

Lines changed: 79 additions & 14 deletions

File tree

pkg/tbtc/signer/src/engine/interactive.rs

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ pub(crate) fn interactive_attempt_consumed(
3838
|| markers.contains(attempt_id)
3939
}
4040

41+
// The aggregate completion marker binds attempt_id to the AGGREGATED message digest,
42+
// so the durable "this attempt is final" record cannot be set for one attempt id via
43+
// a valid aggregate over a DIFFERENT message - which would otherwise let a replayed
44+
// aggregate preempt an unrelated live attempt's Round2 (the Round2 completion gate).
45+
// interactive_aggregate writes it from the package it aggregated; Round2 and the
46+
// re-aggregate guard recompute it from the message they actually hold.
47+
pub(crate) fn interactive_aggregated_marker(attempt_id: &str, message_digest_hex: &str) -> String {
48+
format!("{attempt_id}@{message_digest_hex}")
49+
}
50+
4151
pub fn interactive_session_open(
4252
mut request: InteractiveSessionOpenRequest,
4353
) -> Result<InteractiveSessionOpenResult, EngineError> {
@@ -473,15 +483,33 @@ pub fn interactive_round2(
473483
}
474484

475485
// A completed attempt releases no further shares. Once interactive_aggregate has
476-
// produced the attempt's signature (aggregated_interactive_attempt_markers), an
477-
// open sibling seat that never signed has NO per-member consumed marker, so the
478-
// consumed gate above does not cover it. Gate on the completion marker here too:
479-
// re-aggregation is not a recovery path (a lost signature is recovered with a
480-
// fresh attempt), so no fresh share may leave for a finished attempt.
481-
if session
482-
.aggregated_interactive_attempt_markers
483-
.contains(&attempt_id)
484-
{
486+
// produced the attempt's signature, an open sibling seat that never signed has NO
487+
// per-member consumed marker, so the consumed gate above does not cover it. Gate
488+
// on the completion marker too - but matched to the MESSAGE this member opened
489+
// (the marker binds attempt_id to the aggregated message digest), so a replayed
490+
// aggregate carrying a different message for this attempt id cannot preempt this
491+
// member's live Round2. It fires only for a genuine same-message completion; the
492+
// member's entry for that finalized attempt is then dead, so free it (zeroizing
493+
// its nonces) rather than holding a live-member slot until the TTL sweep.
494+
let member_attempt_message_digest = session
495+
.interactive_signing
496+
.get(&request.member_identifier)
497+
.filter(|entry| entry.attempt_context.attempt_id == attempt_id)
498+
.map(|entry| hash_hex(&entry.message_bytes));
499+
let attempt_finalized = member_attempt_message_digest
500+
.as_deref()
501+
.is_some_and(|digest| {
502+
session
503+
.aggregated_interactive_attempt_markers
504+
.contains(&interactive_aggregated_marker(&attempt_id, digest))
505+
});
506+
if attempt_finalized {
507+
if let Some(mut removed) = session
508+
.interactive_signing
509+
.remove(&request.member_identifier)
510+
{
511+
zeroize_interactive_round1(&mut removed);
512+
}
485513
return Err(EngineError::InteractiveAttemptAlreadyAggregated {
486514
session_id: request.session_id.clone(),
487515
attempt_id,
@@ -675,6 +703,11 @@ pub fn interactive_aggregate(
675703
"InteractiveAggregate: invalid signing package: {e}"
676704
))
677705
})?;
706+
// The completion marker binds attempt_id to THIS aggregated message digest, so a
707+
// valid aggregate over a different message cannot finalize this attempt id (and
708+
// so cannot, via the Round2 completion gate, preempt an unrelated live attempt).
709+
let aggregated_marker =
710+
interactive_aggregated_marker(&attempt_id, &hash_hex(signing_package.message().as_slice()));
678711
let signature_shares =
679712
decode_signature_share_map("InteractiveAggregate", &request.signature_shares)?;
680713
let mut taproot_merkle_root_hex = request.taproot_merkle_root_hex.clone();
@@ -705,7 +738,7 @@ pub fn interactive_aggregate(
705738
// durable so a completed attempt stays rejected across a restart.
706739
if session
707740
.aggregated_interactive_attempt_markers
708-
.contains(&attempt_id)
741+
.contains(&aggregated_marker)
709742
{
710743
return Err(EngineError::InteractiveAttemptAlreadyAggregated {
711744
session_id: request.session_id.clone(),
@@ -816,7 +849,7 @@ pub fn interactive_aggregate(
816849
// re-aggregation - the winner already produced the attempt's signature.
817850
if session
818851
.aggregated_interactive_attempt_markers
819-
.contains(&attempt_id)
852+
.contains(&aggregated_marker)
820853
{
821854
return Err(EngineError::InteractiveAttemptAlreadyAggregated {
822855
session_id: request.session_id.clone(),
@@ -825,21 +858,21 @@ pub fn interactive_aggregate(
825858
}
826859
ensure_consumed_registry_insert_capacity(
827860
&session.aggregated_interactive_attempt_markers,
828-
&attempt_id,
861+
&aggregated_marker,
829862
"aggregated_interactive_attempt_markers",
830863
&request.session_id,
831864
)?;
832865
session
833866
.aggregated_interactive_attempt_markers
834-
.insert(attempt_id.clone());
867+
.insert(aggregated_marker.clone());
835868
if let Err(persist_error) = persist_engine_state_to_storage(&guard) {
836869
let session = guard
837870
.sessions
838871
.get_mut(&request.session_id)
839872
.expect("session existed under the held engine lock");
840873
session
841874
.aggregated_interactive_attempt_markers
842-
.remove(&attempt_id);
875+
.remove(&aggregated_marker);
843876
return Err(persist_error);
844877
}
845878
drop(guard);

pkg/tbtc/signer/src/engine/tests.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11808,6 +11808,27 @@ fn interactive_round2_refused_after_aggregate_for_unsigned_sibling() {
1180811808
})
1180911809
.expect("interactive aggregate completes the attempt");
1181011810

11811+
// The completion marker is MESSAGE-BOUND (attempt_id@digest), not the bare
11812+
// attempt_id - so it cannot be set for this attempt id via an aggregate over a
11813+
// different message (which would otherwise preempt this attempt's live Round2).
11814+
{
11815+
let guard = state().expect("state").lock().expect("lock");
11816+
let session = guard.sessions.get(session_id).expect("session exists");
11817+
assert!(
11818+
session
11819+
.aggregated_interactive_attempt_markers
11820+
.iter()
11821+
.any(|marker| marker.starts_with(&format!("{}@", opened.attempt_id))),
11822+
"completion marker binds attempt_id to the aggregated message digest"
11823+
);
11824+
assert!(
11825+
!session
11826+
.aggregated_interactive_attempt_markers
11827+
.contains(&opened.attempt_id),
11828+
"the bare (unbound) attempt_id marker is not written"
11829+
);
11830+
}
11831+
1181111832
// Seat 3 (open, round1'd, never signed) tries to release a share for the now
1181211833
// completed attempt, in a subset it WOULD be valid for ({1,3}). Without the
1181311834
// completion gate this releases a fresh share; with it the attempt is final.
@@ -11838,6 +11859,17 @@ fn interactive_round2_refused_after_aggregate_for_unsigned_sibling() {
1183811859
),
1183911860
"unexpected error: {refused:?}"
1184011861
);
11862+
11863+
// The refused sibling's now-dead entry is freed (its nonces zeroized) rather
11864+
// than lingering against the live-member cap until the TTL sweep.
11865+
{
11866+
let guard = state().expect("state").lock().expect("lock");
11867+
let session = guard.sessions.get(session_id).expect("session exists");
11868+
assert!(
11869+
!session.interactive_signing.contains_key(&3),
11870+
"seat 3's dead entry is freed when Round2 is refused for the finalized attempt"
11871+
);
11872+
}
1184111873
}
1184211874

1184311875
#[test]

0 commit comments

Comments
 (0)