Skip to content

Commit 73fabaf

Browse files
mswilkisonclaude
andcommitted
fix(tbtc/signer): bind the taproot root into the completion marker (re-review)
Codex re-review (P1, valid): attempt_id is derived from session/message/attempt#/ coordinator/included - NOT the taproot root. So the message-bound completion marker (attempt_id@message_digest) still collided across taproot tweaks: a completion aggregated for one root (or key-path None) recorded the same marker the Round2 gate checks for a live seat opened with a different root, wrongly preempting that seat's Round2 (and zeroizing it) even though the signatures differ per tweak. Bind the canonical taproot root into the marker too - interactive_aggregated_marker(attempt_id, message_digest, taproot_root) = "{attempt_id}@{message_digest}@{root}" (root = hex, or "keypath" for None, which cannot collide with a 64-hex root). interactive_aggregate writes it from the root it aggregated under; the Round2 gate recomputes it from the root THIS member opened with. The completion marker now binds the full signing-task identity (attempt + msg + root); the legacy bare-id fallback is unchanged. Test interactive_round2_completion_marker_binds_taproot_root: a completion recorded for a different root does not preempt a key-path member's Round2, while the same-root completion does. All 296 lib tests pass; cargo fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6b2e2d0 commit 73fabaf

2 files changed

Lines changed: 132 additions & 18 deletions

File tree

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

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,17 @@ pub(crate) fn interactive_attempt_consumed(
4444
// aggregate preempt an unrelated live attempt's Round2 (the Round2 completion gate).
4545
// interactive_aggregate writes it from the package it aggregated; Round2 and the
4646
// 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}")
47+
pub(crate) fn interactive_aggregated_marker(
48+
attempt_id: &str,
49+
message_digest_hex: &str,
50+
taproot_merkle_root: Option<&[u8; 32]>,
51+
) -> String {
52+
// The signature differs per taproot tweak, so the completion is per (message,
53+
// root). "keypath" (a key-path / None spend) cannot collide with a 64-hex root.
54+
let root = taproot_merkle_root
55+
.map(hex::encode)
56+
.unwrap_or_else(|| "keypath".to_string());
57+
format!("{attempt_id}@{message_digest_hex}@{root}")
4958
}
5059

5160
// Aggregate-completion check honoring legacy markers: the new bound form for THIS
@@ -57,10 +66,12 @@ pub(crate) fn interactive_attempt_aggregated(
5766
markers: &HashSet<String>,
5867
attempt_id: &str,
5968
message_digest_hex: &str,
69+
taproot_merkle_root: Option<&[u8; 32]>,
6070
) -> bool {
6171
markers.contains(&interactive_aggregated_marker(
6272
attempt_id,
6373
message_digest_hex,
74+
taproot_merkle_root,
6475
)) || markers.contains(attempt_id)
6576
}
6677

@@ -507,20 +518,22 @@ pub fn interactive_round2(
507518
// member's live Round2. It fires only for a genuine same-message completion; the
508519
// member's entry for that finalized attempt is then dead, so free it (zeroizing
509520
// its nonces) rather than holding a live-member slot until the TTL sweep.
510-
let member_attempt_message_digest = session
521+
let member_attempt_finalization = session
511522
.interactive_signing
512523
.get(&request.member_identifier)
513524
.filter(|entry| entry.attempt_context.attempt_id == attempt_id)
514-
.map(|entry| hash_hex(&entry.message_bytes));
515-
let attempt_finalized = member_attempt_message_digest
516-
.as_deref()
517-
.is_some_and(|digest| {
518-
interactive_attempt_aggregated(
519-
&session.aggregated_interactive_attempt_markers,
520-
&attempt_id,
521-
digest,
522-
)
523-
});
525+
.map(|entry| (hash_hex(&entry.message_bytes), entry.taproot_merkle_root));
526+
let attempt_finalized =
527+
member_attempt_finalization
528+
.as_ref()
529+
.is_some_and(|(digest, taproot_merkle_root)| {
530+
interactive_attempt_aggregated(
531+
&session.aggregated_interactive_attempt_markers,
532+
&attempt_id,
533+
digest,
534+
taproot_merkle_root.as_ref(),
535+
)
536+
});
524537
if attempt_finalized {
525538
if let Some(mut removed) = session
526539
.interactive_signing
@@ -721,15 +734,20 @@ pub fn interactive_aggregate(
721734
"InteractiveAggregate: invalid signing package: {e}"
722735
))
723736
})?;
724-
// The completion marker binds attempt_id to THIS aggregated message digest, so a
725-
// valid aggregate over a different message cannot finalize this attempt id (and
726-
// so cannot, via the Round2 completion gate, preempt an unrelated live attempt).
727-
let aggregated_message_digest = hash_hex(signing_package.message().as_slice());
728-
let aggregated_marker = interactive_aggregated_marker(&attempt_id, &aggregated_message_digest);
729737
let signature_shares =
730738
decode_signature_share_map("InteractiveAggregate", &request.signature_shares)?;
731739
let mut taproot_merkle_root_hex = request.taproot_merkle_root_hex.clone();
732740
let taproot_merkle_root = canonicalize_taproot_merkle_root_hex(&mut taproot_merkle_root_hex)?;
741+
// The completion marker binds attempt_id to THIS aggregated message digest AND the
742+
// canonical taproot root, so a valid aggregate over a different message or root
743+
// cannot finalize this attempt id (and so cannot, via the Round2 completion gate,
744+
// preempt an unrelated live attempt or root - the signature differs per tweak).
745+
let aggregated_message_digest = hash_hex(signing_package.message().as_slice());
746+
let aggregated_marker = interactive_aggregated_marker(
747+
&attempt_id,
748+
&aggregated_message_digest,
749+
taproot_merkle_root.as_ref(),
750+
);
733751

734752
let mut guard = state()?
735753
.lock()
@@ -758,6 +776,7 @@ pub fn interactive_aggregate(
758776
&session.aggregated_interactive_attempt_markers,
759777
&attempt_id,
760778
&aggregated_message_digest,
779+
taproot_merkle_root.as_ref(),
761780
) {
762781
return Err(EngineError::InteractiveAttemptAlreadyAggregated {
763782
session_id: request.session_id.clone(),
@@ -870,6 +889,7 @@ pub fn interactive_aggregate(
870889
&session.aggregated_interactive_attempt_markers,
871890
&attempt_id,
872891
&aggregated_message_digest,
892+
taproot_merkle_root.as_ref(),
873893
) {
874894
return Err(EngineError::InteractiveAttemptAlreadyAggregated {
875895
session_id: request.session_id.clone(),

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11993,6 +11993,100 @@ fn interactive_honors_legacy_bare_aggregate_completion_marker() {
1199311993
);
1199411994
}
1199511995

11996+
#[test]
11997+
fn interactive_round2_completion_marker_binds_taproot_root() {
11998+
// The completion marker binds the taproot root: a completion recorded for one
11999+
// root must NOT finalize the same attempt/message for a member opened with a
12000+
// different root (the signature differs per tweak), else Round2 for the live root
12001+
// is wrongly preempted.
12002+
let _guard = lock_test_state();
12003+
reset_for_tests();
12004+
12005+
let key_packages = interactive_test_key_packages();
12006+
let session_id = "interactive-taproot-root-binding";
12007+
let key_group = "interactive-test-key-group";
12008+
let message = [0x44u8; 32];
12009+
let included = [1u16, 2];
12010+
12011+
// Member 1 opens key-path (no taproot root).
12012+
let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2)
12013+
.expect("member 1 opens key-path");
12014+
let c1 = interactive_round1(InteractiveRound1Request {
12015+
session_id: session_id.to_string(),
12016+
attempt_id: opened.attempt_id.clone(),
12017+
member_identifier: 1,
12018+
})
12019+
.expect("member 1 round 1");
12020+
12021+
let digest = hash_hex(&message);
12022+
let package = interactive_package_for_test(
12023+
&message,
12024+
vec![NativeFrostCommitment {
12025+
identifier: key_packages[&1].identifier.clone(),
12026+
data_hex: c1.commitments_hex.clone(),
12027+
}],
12028+
);
12029+
12030+
// A completion recorded for a DIFFERENT taproot root must not finalize this
12031+
// member's key-path attempt: Round2 gets past the completion gate (and then fails
12032+
// on the deliberately sub-threshold package, not as already-aggregated).
12033+
{
12034+
let mut guard = state().expect("state").lock().expect("lock");
12035+
guard
12036+
.sessions
12037+
.get_mut(session_id)
12038+
.expect("session")
12039+
.aggregated_interactive_attempt_markers
12040+
.insert(interactive_aggregated_marker(
12041+
&opened.attempt_id,
12042+
&digest,
12043+
Some(&[0x22u8; 32]),
12044+
));
12045+
}
12046+
let not_preempted = interactive_round2(InteractiveRound2Request {
12047+
session_id: session_id.to_string(),
12048+
attempt_id: opened.attempt_id.clone(),
12049+
member_identifier: 1,
12050+
signing_package_hex: package.clone(),
12051+
});
12052+
assert!(
12053+
!matches!(
12054+
not_preempted,
12055+
Err(EngineError::InteractiveAttemptAlreadyAggregated { .. })
12056+
),
12057+
"a different-root completion must not finalize this attempt: {not_preempted:?}"
12058+
);
12059+
12060+
// A completion for THIS member's root (key-path) does finalize it.
12061+
{
12062+
let mut guard = state().expect("state").lock().expect("lock");
12063+
guard
12064+
.sessions
12065+
.get_mut(session_id)
12066+
.expect("session")
12067+
.aggregated_interactive_attempt_markers
12068+
.insert(interactive_aggregated_marker(
12069+
&opened.attempt_id,
12070+
&digest,
12071+
None,
12072+
));
12073+
}
12074+
let preempted = interactive_round2(InteractiveRound2Request {
12075+
session_id: session_id.to_string(),
12076+
attempt_id: opened.attempt_id.clone(),
12077+
member_identifier: 1,
12078+
signing_package_hex: package,
12079+
})
12080+
.expect_err("a same-root completion finalizes the attempt");
12081+
assert!(
12082+
matches!(
12083+
preempted,
12084+
EngineError::InteractiveAttemptAlreadyAggregated { .. }
12085+
),
12086+
"unexpected error: {preempted:?}"
12087+
);
12088+
}
12089+
1199612090
#[test]
1199712091
fn interactive_round1_is_idempotent_until_consumed() {
1199812092
let _guard = lock_test_state();

0 commit comments

Comments
 (0)