Skip to content

Commit 6b2e2d0

Browse files
mswilkisonclaude
andcommitted
fix(tbtc/signer): honor legacy bare aggregate completion markers (re-review)
Codex re-review (P2, valid): the previous commit made the aggregate completion marker message-bound (attempt_id@digest), but a completion persisted by the pre-binding engine is stored as the BARE attempt_id. The new checks only looked for the bound form, so after an upgrade a previously completed attempt looked unfinished - repeat InteractiveAggregate and the Round2 completion gate would no longer fail closed for it. Add interactive_attempt_aggregated(markers, attempt_id, digest) = bound form OR a legacy bare attempt_id marker (fail-closed on read), mirroring the consumed-marker helper. Use it at the three read sites (the Round2 completion gate and both interactive_aggregate pre-checks). New writes stay bound-only, so the durable record migrates forward on the next completion while legacy completions stay final. Test: interactive_honors_legacy_bare_aggregate_completion_marker injects a bare attempt_id marker (a pre-upgrade completion) and asserts Round2 then fails closed with InteractiveAttemptAlreadyAggregated. All 295 lib tests pass; cargo fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 797aa71 commit 6b2e2d0

2 files changed

Lines changed: 92 additions & 13 deletions

File tree

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

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ pub(crate) fn interactive_aggregated_marker(attempt_id: &str, message_digest_hex
4848
format!("{attempt_id}@{message_digest_hex}")
4949
}
5050

51+
// Aggregate-completion check honoring legacy markers: the new bound form for THIS
52+
// message, OR a legacy bare attempt_id marker (written by the pre-binding engine and
53+
// reloaded from durable state) - the latter fail-closed, exactly like the consumed
54+
// markers, so a completion persisted before this format change stays final (no repeat
55+
// aggregate, no fresh Round2 share) after an upgrade.
56+
pub(crate) fn interactive_attempt_aggregated(
57+
markers: &HashSet<String>,
58+
attempt_id: &str,
59+
message_digest_hex: &str,
60+
) -> bool {
61+
markers.contains(&interactive_aggregated_marker(
62+
attempt_id,
63+
message_digest_hex,
64+
)) || markers.contains(attempt_id)
65+
}
66+
5167
pub fn interactive_session_open(
5268
mut request: InteractiveSessionOpenRequest,
5369
) -> Result<InteractiveSessionOpenResult, EngineError> {
@@ -499,9 +515,11 @@ pub fn interactive_round2(
499515
let attempt_finalized = member_attempt_message_digest
500516
.as_deref()
501517
.is_some_and(|digest| {
502-
session
503-
.aggregated_interactive_attempt_markers
504-
.contains(&interactive_aggregated_marker(&attempt_id, digest))
518+
interactive_attempt_aggregated(
519+
&session.aggregated_interactive_attempt_markers,
520+
&attempt_id,
521+
digest,
522+
)
505523
});
506524
if attempt_finalized {
507525
if let Some(mut removed) = session
@@ -706,8 +724,8 @@ pub fn interactive_aggregate(
706724
// The completion marker binds attempt_id to THIS aggregated message digest, so a
707725
// valid aggregate over a different message cannot finalize this attempt id (and
708726
// 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()));
727+
let aggregated_message_digest = hash_hex(signing_package.message().as_slice());
728+
let aggregated_marker = interactive_aggregated_marker(&attempt_id, &aggregated_message_digest);
711729
let signature_shares =
712730
decode_signature_share_map("InteractiveAggregate", &request.signature_shares)?;
713731
let mut taproot_merkle_root_hex = request.taproot_merkle_root_hex.clone();
@@ -736,10 +754,11 @@ pub fn interactive_aggregate(
736754
// Reject a completed attempt: re-aggregation is not a recovery path (a
737755
// lost signature is recovered with a fresh attempt), and the marker is
738756
// durable so a completed attempt stays rejected across a restart.
739-
if session
740-
.aggregated_interactive_attempt_markers
741-
.contains(&aggregated_marker)
742-
{
757+
if interactive_attempt_aggregated(
758+
&session.aggregated_interactive_attempt_markers,
759+
&attempt_id,
760+
&aggregated_message_digest,
761+
) {
743762
return Err(EngineError::InteractiveAttemptAlreadyAggregated {
744763
session_id: request.session_id.clone(),
745764
attempt_id,
@@ -847,10 +866,11 @@ pub fn interactive_aggregate(
847866
// A concurrent aggregate that raced past the pre-check may have completed
848867
// this attempt first; if the marker is now present, reject this call's
849868
// re-aggregation - the winner already produced the attempt's signature.
850-
if session
851-
.aggregated_interactive_attempt_markers
852-
.contains(&aggregated_marker)
853-
{
869+
if interactive_attempt_aggregated(
870+
&session.aggregated_interactive_attempt_markers,
871+
&attempt_id,
872+
&aggregated_message_digest,
873+
) {
854874
return Err(EngineError::InteractiveAttemptAlreadyAggregated {
855875
session_id: request.session_id.clone(),
856876
attempt_id,

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11934,6 +11934,65 @@ fn interactive_open_advances_only_the_opening_member_attempt() {
1193411934
assert!(m2_reopen.idempotent);
1193511935
}
1193611936

11937+
#[test]
11938+
fn interactive_honors_legacy_bare_aggregate_completion_marker() {
11939+
// Backward compat: a completion persisted by the pre-binding engine is the BARE
11940+
// attempt_id (not attempt_id@digest). After an upgrade it must still finalize the
11941+
// attempt fail-closed - the Round2 completion gate refuses a fresh share for it.
11942+
let _guard = lock_test_state();
11943+
reset_for_tests();
11944+
11945+
let key_packages = interactive_test_key_packages();
11946+
let session_id = "interactive-legacy-aggregate-marker";
11947+
let key_group = "interactive-test-key-group";
11948+
let message = [0x66u8; 32];
11949+
let included = [1u16, 2];
11950+
11951+
let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2)
11952+
.expect("member 1 opens");
11953+
let c1 = interactive_round1(InteractiveRound1Request {
11954+
session_id: session_id.to_string(),
11955+
attempt_id: opened.attempt_id.clone(),
11956+
member_identifier: 1,
11957+
})
11958+
.expect("member 1 round 1");
11959+
11960+
// Simulate a completion persisted by the pre-binding engine: the BARE attempt_id.
11961+
{
11962+
let mut guard = state().expect("state").lock().expect("lock");
11963+
guard
11964+
.sessions
11965+
.get_mut(session_id)
11966+
.expect("session")
11967+
.aggregated_interactive_attempt_markers
11968+
.insert(opened.attempt_id.clone());
11969+
}
11970+
11971+
// Round2 must treat the bare legacy marker as a completed attempt and fail closed
11972+
// before any share is released.
11973+
let package = interactive_package_for_test(
11974+
&message,
11975+
vec![NativeFrostCommitment {
11976+
identifier: key_packages[&1].identifier.clone(),
11977+
data_hex: c1.commitments_hex.clone(),
11978+
}],
11979+
);
11980+
let refused = interactive_round2(InteractiveRound2Request {
11981+
session_id: session_id.to_string(),
11982+
attempt_id: opened.attempt_id.clone(),
11983+
member_identifier: 1,
11984+
signing_package_hex: package,
11985+
})
11986+
.expect_err("a legacy bare completion marker must finalize the attempt");
11987+
assert!(
11988+
matches!(
11989+
refused,
11990+
EngineError::InteractiveAttemptAlreadyAggregated { .. }
11991+
),
11992+
"unexpected error: {refused:?}"
11993+
);
11994+
}
11995+
1193711996
#[test]
1193811997
fn interactive_round1_is_idempotent_until_consumed() {
1193911998
let _guard = lock_test_state();

0 commit comments

Comments
 (0)