Skip to content

Commit edf7952

Browse files
mswilkisonclaude
andcommitted
fix(tbtc/signer): bound interactive session registry and validate threshold
Two findings on the 7.1 path: P2 (liveness/DoS) - an interactive open that later expired or was aborted before Round2 left an otherwise-empty SessionState in the registry. Since open inserts new session IDs and ensure_session_insert_capacity counts every map entry, a caller could churn unique sessions until TBTC_SIGNER_MAX_SESSIONS filled, after which DKG / build_tx / new interactive sessions were rejected until restart. The TTL sweep and abort now drop a session that holds nothing durable once its live attempt is cleared, via a new SessionState::is_disposable that checks EVERY field so a session still carrying consumed markers or DKG material is never removed. P2 (verify-before-consume) - Open accepted a threshold below the key package's min_signers; Round2 would then accept a too-small signing package, persist the consumed marker, and only then have frost::round2::sign fail on the commitment count - burning the nonce for a validation error. Open now rejects threshold != key package min_signers before storing the session. Tests: open-then-abort churn under a 2-session cap stays bounded (no accumulation); the abort-sweep test now asserts the empty session is dropped, not just cleared; threshold-below-min_signers is rejected and the matching threshold opens. Full suite 264 passed / 1 ignored, clippy -D warnings clean, chaos suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0d739a5 commit edf7952

3 files changed

Lines changed: 172 additions & 12 deletions

File tree

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ pub fn interactive_session_open(
9696
"key_package_identifier must match member_identifier".to_string(),
9797
));
9898
}
99+
// The signing threshold is fixed by the key material. Reject a
100+
// mismatch at Open: otherwise Round2 would accept a signing package
101+
// of the requested (wrong) size, persist the consumed marker, and
102+
// only then have frost::round2::sign fail on the commitment count -
103+
// burning the nonce handle for a validation error, against the
104+
// verify-before-consume contract.
105+
if *key_package.min_signers() != request.threshold {
106+
return Err(EngineError::Validation(format!(
107+
"threshold [{}] does not match the key package min_signers [{}]",
108+
request.threshold,
109+
*key_package.min_signers()
110+
)));
111+
}
99112

100113
let request_fingerprint = interactive_open_request_fingerprint(&request)?;
101114
let attempt_id = request.attempt_context.attempt_id.clone();
@@ -524,6 +537,18 @@ pub fn interactive_session_abort(
524537
None => false,
525538
};
526539

540+
// Drop the session if aborting left it with nothing durable, so an
541+
// open-then-abort churn cannot accumulate empty entries against
542+
// TBTC_SIGNER_MAX_SESSIONS.
543+
if aborted
544+
&& guard
545+
.sessions
546+
.get(&request.session_id)
547+
.is_some_and(SessionState::is_disposable)
548+
{
549+
guard.sessions.remove(&request.session_id);
550+
}
551+
527552
record_hardening_telemetry(|telemetry| {
528553
telemetry.interactive_session_abort_success_total = telemetry
529554
.interactive_session_abort_success_total
@@ -704,19 +729,28 @@ pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningSta
704729
pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) {
705730
let ttl_seconds = interactive_session_ttl_seconds();
706731
let now = now_unix();
707-
for session in engine_state.sessions.values_mut() {
732+
engine_state.sessions.retain(|_session_id, session| {
708733
let expired = session
709734
.interactive_signing
710735
.as_ref()
711736
.is_some_and(|interactive| {
712737
now.saturating_sub(interactive.opened_at_unix) > ttl_seconds
713738
});
714-
if expired {
715-
if let Some(mut removed) = session.interactive_signing.take() {
716-
zeroize_interactive_round1(&mut removed);
717-
}
739+
if !expired {
740+
// Untouched sessions are kept as-is; only sessions whose
741+
// live attempt we just expired are candidates for removal.
742+
return true;
718743
}
719-
}
744+
if let Some(mut removed) = session.interactive_signing.take() {
745+
zeroize_interactive_round1(&mut removed);
746+
}
747+
// Having cleared the expired attempt, drop the session if it now
748+
// holds nothing durable, so churned interactive opens cannot
749+
// accumulate empty entries against TBTC_SIGNER_MAX_SESSIONS. A
750+
// session that still carries consumed markers or DKG material is
751+
// kept.
752+
!session.is_disposable()
753+
});
720754
}
721755

722756
pub(crate) fn max_live_interactive_sessions_limit() -> usize {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,44 @@ pub(crate) struct SessionState {
113113
pub(crate) consumed_interactive_attempt_markers: HashSet<String>,
114114
}
115115

116+
impl SessionState {
117+
// True when the session holds no durable or live state worth a
118+
// registry slot: removing it loses nothing. Used to drop a session
119+
// that only ever held a now-cleared interactive attempt, so churned
120+
// interactive opens (open -> expire/abort before Round2) cannot
121+
// accumulate empty entries and exhaust TBTC_SIGNER_MAX_SESSIONS.
122+
//
123+
// EVERY field must be checked here: a field omitted from this
124+
// conjunction risks dropping a session that still carries replay
125+
// protection (consumed markers) or DKG material. When adding a
126+
// field to SessionState, add it here too.
127+
pub(crate) fn is_disposable(&self) -> bool {
128+
self.dkg_request_fingerprint.is_none()
129+
&& self.dkg_key_packages.is_none()
130+
&& self.dkg_public_key_package.is_none()
131+
&& self.dkg_result.is_none()
132+
&& self.sign_request_fingerprint.is_none()
133+
&& self.sign_message_bytes.is_none()
134+
&& self.round_state.is_none()
135+
&& self.active_attempt_context.is_none()
136+
&& self.attempt_transition_records.is_empty()
137+
&& self.consumed_attempt_ids.is_empty()
138+
&& self.consumed_sign_round_ids.is_empty()
139+
&& self.finalize_request_fingerprint.is_none()
140+
&& self.signature_result.is_none()
141+
&& self.consumed_finalize_round_ids.is_empty()
142+
&& self.consumed_finalize_request_fingerprints.is_empty()
143+
&& self.build_tx_request_fingerprint.is_none()
144+
&& self.tx_result.is_none()
145+
&& self.refresh_request_fingerprint.is_none()
146+
&& self.refresh_result.is_none()
147+
&& self.refresh_history.is_empty()
148+
&& self.emergency_rekey_event.is_none()
149+
&& self.interactive_signing.is_none()
150+
&& self.consumed_interactive_attempt_markers.is_empty()
151+
}
152+
}
153+
116154
#[derive(Default)]
117155
pub(crate) struct EngineState {
118156
pub(crate) sessions: HashMap<String, SessionState>,

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

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12444,14 +12444,13 @@ fn interactive_abort_sweeps_expired_sessions() {
1244412444
})
1244512445
.expect("abort for an unrelated session");
1244612446

12447+
// Session A held only its (now-expired) interactive attempt, so the
12448+
// sweep must remove the whole entry, not just clear the live state -
12449+
// otherwise empty sessions accumulate against TBTC_SIGNER_MAX_SESSIONS.
1244712450
let guard = state().expect("state").lock().expect("lock");
12448-
let session = guard
12449-
.sessions
12450-
.get("interactive-abort-sweep-a")
12451-
.expect("session A still present");
1245212451
assert!(
12453-
session.interactive_signing.is_none(),
12454-
"an abort must sweep expired interactive state in other sessions"
12452+
!guard.sessions.contains_key("interactive-abort-sweep-a"),
12453+
"an abort must sweep AND drop an otherwise-empty expired session"
1245512454
);
1245612455
}
1245712456

@@ -12687,3 +12686,92 @@ fn interactive_round2_rechecks_gates_at_share_release() {
1268712686
})
1268812687
.expect("the same attempt completes once the kill switch clears");
1268912688
}
12689+
12690+
#[test]
12691+
fn interactive_open_rejects_threshold_below_key_package_min_signers() {
12692+
let _guard = lock_test_state();
12693+
reset_for_tests();
12694+
12695+
// The fixture key packages are min_signers = 2. A request threshold
12696+
// of 3 must be rejected at Open: otherwise Round2 would accept a
12697+
// 3-commitment package, persist the marker, and only then have
12698+
// frost::round2::sign fail on the count - burning the nonce for a
12699+
// validation error.
12700+
let key_packages = interactive_test_key_packages();
12701+
let mismatch = open_interactive_for_test(
12702+
&key_packages,
12703+
"interactive-threshold-mismatch",
12704+
"interactive-test-key-group",
12705+
&[0x1au8; 32],
12706+
&[1u16, 2, 3],
12707+
1,
12708+
1,
12709+
3,
12710+
)
12711+
.expect_err("a threshold below the key package min_signers must be rejected");
12712+
assert!(
12713+
matches!(mismatch, EngineError::Validation(ref m)
12714+
if m.contains("does not match the key package min_signers")),
12715+
"unexpected error: {mismatch:?}"
12716+
);
12717+
12718+
// The matching threshold (2) opens.
12719+
open_interactive_for_test(
12720+
&key_packages,
12721+
"interactive-threshold-match",
12722+
"interactive-test-key-group",
12723+
&[0x1au8; 32],
12724+
&[1u16, 2],
12725+
1,
12726+
1,
12727+
2,
12728+
)
12729+
.expect("the key-package-matching threshold opens");
12730+
}
12731+
12732+
#[test]
12733+
fn interactive_open_abort_churn_does_not_exhaust_session_registry() {
12734+
let _guard = lock_test_state();
12735+
reset_for_tests();
12736+
12737+
// A tiny global session cap: if open-then-abort left empty session
12738+
// entries behind, this churn would fill the registry and then reject
12739+
// a fresh open. The disposal on abort must keep the registry clear.
12740+
std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2");
12741+
12742+
let key_packages = interactive_test_key_packages();
12743+
let key_group = "interactive-test-key-group";
12744+
let message = [0x1bu8; 32];
12745+
let included = [1u16, 2];
12746+
12747+
let outcome = (|| -> Result<(), EngineError> {
12748+
for cycle in 0..16 {
12749+
let session_id = format!("interactive-churn-{cycle}");
12750+
open_interactive_for_test(
12751+
&key_packages,
12752+
&session_id,
12753+
key_group,
12754+
&message,
12755+
&included,
12756+
1,
12757+
1,
12758+
2,
12759+
)?;
12760+
interactive_session_abort(InteractiveSessionAbortRequest {
12761+
session_id: session_id.clone(),
12762+
attempt_id: None,
12763+
})?;
12764+
}
12765+
// The registry is clear, so the global cap still has room.
12766+
let guard = state().expect("state").lock().expect("lock");
12767+
assert!(
12768+
guard.sessions.is_empty(),
12769+
"open-then-abort churn must not accumulate session entries: {} present",
12770+
guard.sessions.len()
12771+
);
12772+
Ok(())
12773+
})();
12774+
12775+
std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV);
12776+
outcome.expect("session churn stays bounded");
12777+
}

0 commit comments

Comments
 (0)