Skip to content

Commit 4940a9c

Browse files
mswilkisonclaude
andcommitted
fix(tbtc/signer): quarantine the full Round2 subset; reject phantom included IDs
Two findings: P1 (quarantine bypass) - the Round2 gate recheck only quarantine-checked this node's own member_identifier. The chosen signing subset (the package's participants) is known at Round2, so this node could release a share into a package that includes a quarantined co-signer, bypassing the all-signing-participants quarantine the coarse path enforces. Round2 now computes the package's u16 subset (after verify confirms it is a threshold-sized subset of the included set) and quarantine-checks ALL of it before consuming the nonce. The Open gate keeps checking only this member (the responsive subset is not chosen until Round2); the gate helper now takes the identifier set to check so the two sites stay aligned. P2 (phantom participants) - Open validated the attempt context's internal consistency but never checked that the included participants are real DKG members. A caller could pad the included set with phantom ids to bias the RFC-21 coordinator/attempt derivation, with Round2 then releasing a share under an attempt context that is not a genuine DKG subset. Open now rejects any included participant absent from the session's dkg_key_packages. Tests: a co-signer quarantined after round 1 blocks the Round2 share without consuming the attempt (clearing it lets the attempt complete); a phantom included id is rejected at Open even with a valid local member. Full suite 266 passed / 1 ignored, clippy -D warnings clean, chaos green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 289df95 commit 4940a9c

2 files changed

Lines changed: 197 additions & 10 deletions

File tree

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

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,11 @@ pub fn interactive_session_open(
101101
request.threshold, dkg.threshold
102102
)));
103103
}
104-
let key_package = session
104+
let dkg_key_packages = session
105105
.dkg_key_packages
106106
.as_ref()
107-
.ok_or_else(|| EngineError::Internal("missing DKG key package cache".to_string()))?
107+
.ok_or_else(|| EngineError::Internal("missing DKG key package cache".to_string()))?;
108+
let key_package = dkg_key_packages
108109
.get(&request.member_identifier)
109110
.ok_or_else(|| {
110111
EngineError::Validation(
@@ -118,10 +119,12 @@ pub fn interactive_session_open(
118119
// runs again at Round2 (the share-release moment) so a policy
119120
// change recorded after Open - emergency rekey, finalization,
120121
// quarantine, or a re-bound policy-checked tx - cannot let a
121-
// share escape.
122+
// share escape. At Open only this node's own member is known to
123+
// sign; Round2 re-checks quarantine over the actual chosen
124+
// subset.
122125
enforce_interactive_signing_gates(
123126
&request.session_id,
124-
request.member_identifier,
127+
&[request.member_identifier],
125128
&request.message_hex,
126129
session.emergency_rekey_event.as_ref(),
127130
session.finalize_request_fingerprint.is_some(),
@@ -153,6 +156,19 @@ pub fn interactive_session_open(
153156
.to_string(),
154157
));
155158
}
159+
// Every included participant must be a real DKG member of this
160+
// session. Otherwise a caller could pad the included set with
161+
// phantom identifiers to bias the RFC-21 coordinator/attempt
162+
// derivation, and Round2 could release a share under an attempt
163+
// context that is not a genuine DKG subset.
164+
for participant in &canonical_included_participants {
165+
if !dkg_key_packages.contains_key(participant) {
166+
return Err(EngineError::Validation(format!(
167+
"attempt_context.included_participants contains [{participant}], \
168+
which is not a DKG participant for this session"
169+
)));
170+
}
171+
}
156172
(key_package, canonical_included_participants)
157173
};
158174

@@ -418,9 +434,13 @@ pub fn interactive_round2(
418434
&& interactive.member_identifier == request.member_identifier
419435
}) {
420436
let bound_message_hex = hex::encode(interactive.message_bytes.as_slice());
437+
// Fast-path lifecycle/firewall and this node's own quarantine.
438+
// The full chosen signing subset is quarantine-checked after the
439+
// package is verified (below), once it is known to be a real
440+
// subset of the included set.
421441
enforce_interactive_signing_gates(
422442
&request.session_id,
423-
request.member_identifier,
443+
&[request.member_identifier],
424444
&bound_message_hex,
425445
session.emergency_rekey_event.as_ref(),
426446
session.finalize_request_fingerprint.is_some(),
@@ -450,6 +470,20 @@ pub fn interactive_round2(
450470
// the consumption marker is written before the share is released.
451471
verify_round2_signing_package(interactive, &signing_package)?;
452472

473+
// The package is now confirmed to be a threshold-sized subset of the
474+
// attempt's included set, so the chosen signing subset is known.
475+
// Quarantine-check ALL of it before releasing a share: this node
476+
// must not contribute to a signature whose subset includes a
477+
// locally quarantined co-signer, matching the coarse path's
478+
// all-signing-participants quarantine enforcement.
479+
let signing_subset = round2_signing_subset(interactive, &signing_package)?;
480+
enforce_not_quarantined_identifiers(
481+
&request.session_id,
482+
&signing_subset,
483+
&quarantined_operator_identifiers,
484+
auto_quarantine_config.as_ref(),
485+
)?;
486+
453487
// Consumption-before-release: the durable marker is persisted
454488
// BEFORE the share is computed and returned. If persistence fails,
455489
// the marker is rolled back and the nonces remain live - no share
@@ -690,13 +724,20 @@ fn verify_round2_signing_package(
690724
// The signing gates the interactive path enforces at BOTH Open and
691725
// the Round2 share-release moment, mirroring the coarse
692726
// start_sign_round: emergency-rekey and finalized lifecycle, quarantine
693-
// of this node's own member, and the signing-policy firewall binding of
694-
// the message to a policy-checked build_taproot_tx. Centralized in one
695-
// function so the two call sites cannot drift apart.
727+
// of the signing participants, and the signing-policy firewall binding
728+
// of the message to a policy-checked build_taproot_tx. Centralized in
729+
// one function so the two call sites cannot drift apart.
730+
//
731+
// quarantine_identifiers is the set to quarantine-check: at Open only
732+
// this node's own member is known to sign; at Round2 it is the full
733+
// chosen signing subset (the package's participants), so this node
734+
// refuses to contribute a share to a package that includes any
735+
// quarantined co-signer - the same all-participants check the coarse
736+
// path applies.
696737
#[allow(clippy::too_many_arguments)]
697738
fn enforce_interactive_signing_gates(
698739
session_id: &str,
699-
member_identifier: u16,
740+
quarantine_identifiers: &[u16],
700741
message_hex: &str,
701742
emergency_rekey_event: Option<&EmergencyRekeyEvent>,
702743
session_finalized: bool,
@@ -721,7 +762,7 @@ fn enforce_interactive_signing_gates(
721762
}
722763
enforce_not_quarantined_identifiers(
723764
session_id,
724-
&[member_identifier],
765+
quarantine_identifiers,
725766
quarantined_operator_identifiers,
726767
auto_quarantine_config,
727768
)?;
@@ -737,6 +778,30 @@ fn canonical_attempt_id(attempt_id: &str) -> String {
737778
attempt_id.to_ascii_lowercase()
738779
}
739780

781+
// The chosen signing subset as Go u16 identifiers: the included
782+
// participants whose commitment appears in the signing package. The
783+
// caller MUST have run verify_round2_signing_package first (which
784+
// confirms the package is a threshold-sized subset of the included
785+
// set), so every package participant maps back to an included member.
786+
fn round2_signing_subset(
787+
interactive: &InteractiveSigningState,
788+
signing_package: &frost::SigningPackage,
789+
) -> Result<Vec<u16>, EngineError> {
790+
let package_identifiers = signing_package
791+
.signing_commitments()
792+
.keys()
793+
.copied()
794+
.collect::<BTreeSet<_>>();
795+
let mut subset = Vec::with_capacity(package_identifiers.len());
796+
for participant in &interactive.canonical_included_participants {
797+
let frost_identifier = participant_identifier_to_frost_identifier(*participant)?;
798+
if package_identifiers.contains(&frost_identifier) {
799+
subset.push(*participant);
800+
}
801+
}
802+
Ok(subset)
803+
}
804+
740805
pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningState) {
741806
if let Some(mut round1) = interactive.round1.take() {
742807
round1.nonces.zeroize();

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12612,3 +12612,125 @@ fn interactive_open_requires_an_existing_dkg_session() {
1261212612
"unexpected error: {absent:?}"
1261312613
);
1261412614
}
12615+
12616+
#[test]
12617+
fn interactive_round2_rejects_quarantined_co_signer_in_package() {
12618+
let _guard = lock_test_state();
12619+
reset_for_tests();
12620+
12621+
let session_id = "interactive-round2-quarantined-cosigner";
12622+
let key_group = "interactive-test-key-group";
12623+
let message = [0x1cu8; 32];
12624+
let included = [1u16, 2];
12625+
let key_packages = ensure_interactive_dkg_session(session_id, key_group);
12626+
12627+
std::env::set_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, "true");
12628+
std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV, "2");
12629+
std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV, "1");
12630+
std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV, "2");
12631+
12632+
let outcome = (|| -> Result<(), EngineError> {
12633+
// This member (1) opens and runs round 1 while no one is
12634+
// quarantined; the co-signer (2) is quarantined afterward.
12635+
let opened =
12636+
open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2)?;
12637+
let round1 = interactive_round1(InteractiveRound1Request {
12638+
session_id: session_id.to_string(),
12639+
attempt_id: opened.attempt_id.clone(),
12640+
member_identifier: 1,
12641+
})?;
12642+
let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest {
12643+
key_package_identifier: key_packages[&2].identifier.clone(),
12644+
key_package_hex: key_packages[&2].data_hex.clone(),
12645+
})?;
12646+
let signing_package_hex = interactive_package_for_test(
12647+
&message,
12648+
vec![
12649+
NativeFrostCommitment {
12650+
identifier: key_packages[&1].identifier.clone(),
12651+
data_hex: round1.commitments_hex,
12652+
},
12653+
member2.commitment,
12654+
],
12655+
);
12656+
12657+
// Quarantine the co-signer (member 2) after round 1.
12658+
{
12659+
let mut guard = state().expect("state").lock().expect("lock");
12660+
guard.quarantined_operator_identifiers.insert(2);
12661+
}
12662+
12663+
// Round2 must refuse: this node will not contribute a share to a
12664+
// package whose subset includes a quarantined co-signer, even
12665+
// though this node (member 1) is not itself quarantined.
12666+
let blocked = interactive_round2(InteractiveRound2Request {
12667+
session_id: session_id.to_string(),
12668+
attempt_id: opened.attempt_id.clone(),
12669+
member_identifier: 1,
12670+
signing_package_hex,
12671+
})
12672+
.expect_err("a quarantined co-signer in the package must block the share");
12673+
assert!(
12674+
matches!(blocked, EngineError::QuarantinePolicyRejected { ref reason_code, .. }
12675+
if reason_code == "operator_auto_quarantined"),
12676+
"unexpected error: {blocked:?}"
12677+
);
12678+
12679+
// Fail-closed without consuming: clearing the quarantine lets the
12680+
// same attempt complete (the rejection preceded consumption).
12681+
{
12682+
let mut guard = state().expect("state").lock().expect("lock");
12683+
assert!(
12684+
!guard
12685+
.sessions
12686+
.get(session_id)
12687+
.expect("session")
12688+
.consumed_interactive_attempt_markers
12689+
.contains(&opened.attempt_id),
12690+
"a quarantine rejection must not consume the attempt"
12691+
);
12692+
guard.quarantined_operator_identifiers.remove(&2);
12693+
}
12694+
Ok(())
12695+
})();
12696+
12697+
std::env::remove_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV);
12698+
std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV);
12699+
std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV);
12700+
std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV);
12701+
outcome.expect("round2 co-signer quarantine lifecycle");
12702+
}
12703+
12704+
#[test]
12705+
fn interactive_open_rejects_phantom_included_participant() {
12706+
let _guard = lock_test_state();
12707+
reset_for_tests();
12708+
12709+
// The session's DKG group is members 1..3. An attempt context whose
12710+
// included set names a phantom id (99) must be rejected even though
12711+
// the local member (1) is a real participant - otherwise a caller
12712+
// could bias the RFC-21 coordinator/attempt derivation with
12713+
// non-participants.
12714+
let session_id = "interactive-phantom-included";
12715+
let key_group = "interactive-test-key-group";
12716+
let message = [0x1du8; 32];
12717+
ensure_interactive_dkg_session(session_id, key_group);
12718+
12719+
let attempt_context =
12720+
interactive_test_attempt_context(session_id, key_group, &message, &[1u16, 99], 1);
12721+
let err = interactive_session_open(InteractiveSessionOpenRequest {
12722+
session_id: session_id.to_string(),
12723+
member_identifier: 1,
12724+
message_hex: hex::encode(message),
12725+
key_group: key_group.to_string(),
12726+
threshold: 2,
12727+
taproot_merkle_root_hex: None,
12728+
attempt_context,
12729+
})
12730+
.expect_err("a phantom included participant must be rejected");
12731+
assert!(
12732+
matches!(err, EngineError::Validation(ref m)
12733+
if m.contains("not a DKG participant for this session")),
12734+
"unexpected error: {err:?}"
12735+
);
12736+
}

0 commit comments

Comments
 (0)