diff --git a/pkg/tbtc/signer/src/bin/admission_checker.rs b/pkg/tbtc/signer/src/bin/admission_checker.rs index 59d145cf49..048f1bfe06 100644 --- a/pkg/tbtc/signer/src/bin/admission_checker.rs +++ b/pkg/tbtc/signer/src/bin/admission_checker.rs @@ -14,6 +14,7 @@ const SECONDS_PER_DAY: u64 = 86_400; const DEFAULT_DAO_OVERRIDE_MAX_TTL_SECONDS: u64 = 7 * SECONDS_PER_DAY; #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct AdmissionPolicyV1 { #[serde(default)] max_operators_per_provider: Option, @@ -937,6 +938,38 @@ mod tests { ] } + #[test] + fn admission_policy_deserialization_rejects_unknown_fields() { + let valid_policy_json = include_str!("../../scripts/admission-policy-v1.sample.json"); + let typo_policy_json = valid_policy_json.replace( + "\"max_operators_per_provider\"", + "\"max_operator_per_provider\"", + ); + assert_ne!(typo_policy_json, valid_policy_json); + + let error = serde_json::from_str::(&typo_policy_json) + .expect_err("a misspelled security policy field must be rejected"); + + assert!( + error + .to_string() + .contains("unknown field `max_operator_per_provider`"), + "unexpected parse error: {error}" + ); + } + + #[test] + fn admission_policy_deserialization_accepts_supported_fields() { + let policy = serde_json::from_str::(include_str!( + "../../scripts/admission-policy-v1.sample.json" + )) + .expect("the shipped admission policy sample should remain valid"); + + assert_eq!(policy.max_operators_per_provider, Some(2)); + assert_eq!(policy.max_operators_per_region, Some(2)); + assert_eq!(policy.dao_override_max_ttl_seconds, Some(604_800)); + } + fn sign_override_payload(payload_json: String) -> (String, AdmissionOverrideArtifact) { let secp = Secp256k1::new(); let secret_key = diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 15f82a61fc..7513b8894e 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -18,6 +18,41 @@ use super::*; +#[cfg(test)] +static INTERACTIVE_CLOCK_OFFSET_SECONDS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +pub(crate) fn interactive_now() -> Instant { + #[cfg(test)] + { + let offset_seconds = + INTERACTIVE_CLOCK_OFFSET_SECONDS.load(std::sync::atomic::Ordering::SeqCst); + Instant::now() + .checked_add(Duration::from_secs(offset_seconds)) + .expect("interactive test clock offset fits the monotonic clock") + } + #[cfg(not(test))] + { + Instant::now() + } +} + +#[cfg(test)] +pub(crate) fn advance_interactive_clock_for_tests(seconds: u64) { + INTERACTIVE_CLOCK_OFFSET_SECONDS + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |current| current.checked_add(seconds), + ) + .expect("interactive test clock offset does not overflow"); +} + +#[cfg(test)] +pub(crate) fn reset_interactive_clock_for_tests() { + INTERACTIVE_CLOCK_OFFSET_SECONDS.store(0, std::sync::atomic::Ordering::SeqCst); +} + #[cfg(test)] static INTERACTIVE_AGGREGATE_HOLD_AFTER_UNLOCK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -473,6 +508,14 @@ pub fn interactive_session_open( match matching_attempt_idempotent { Some(true) => { + let interactive = guard + .sessions + .get_mut(&request.session_id) + .expect("idempotent Open session exists") + .interactive_signing + .get_mut(&member_identifier) + .expect("idempotent Open member exists"); + interactive.last_activity_at = interactive_now(); return Ok(InteractiveSessionOpenResult { session_id: request.session_id, attempt_id, @@ -706,7 +749,7 @@ pub fn interactive_session_open( taproot_merkle_root, signing_intent: request.signing_intent, key_package, - opened_at_unix: now_unix(), + last_activity_at: interactive_now(), round1: None, }, ); @@ -769,12 +812,15 @@ pub fn interactive_round1( request.member_identifier, )?; - if let Some(round1) = interactive.round1.as_ref() { + if let Some(commitments_hex) = interactive + .round1 + .as_ref() + .map(|round1| round1.commitments_hex.clone()) + { // Idempotent until consumed: the commitments are public and // re-sending them is safe; the nonces never leave. - return Ok(InteractiveRound1Result { - commitments_hex: round1.commitments_hex.clone(), - }); + interactive.last_activity_at = interactive_now(); + return Ok(InteractiveRound1Result { commitments_hex }); } let mut rng = zeroizing_rng_from_os(); @@ -789,6 +835,7 @@ pub fn interactive_round1( nonces, commitments_hex: commitments_hex.clone(), }); + interactive.last_activity_at = interactive_now(); latency_guard.mark_success(); record_hardening_telemetry(|telemetry| { @@ -1039,7 +1086,20 @@ pub fn interactive_round2( // tagged with an old key id that decode rejects on restart. Resolving before // the marker also keeps a key-provider outage failing the attempt cleanly (no // marker written) rather than escaping the rollback below via `?`. - let resolved_state_key = state_encryption_key_material()?; + let resolved_state_key = match state_encryption_key_material() { + Ok(key) => key, + Err(error) => { + // Key-provider commands can run long enough to cross the TTL. The + // validated request still leaves this nonce handle retryable, so + // measure its inactivity from failure completion, not command start. + session + .interactive_signing + .get_mut(&request.member_identifier) + .expect("validated Round2 interactive state remains retryable") + .last_activity_at = interactive_now(); + return Err(error); + } + }; session .consumed_interactive_attempt_markers .insert(consumed_marker.clone()); @@ -1084,6 +1144,14 @@ pub fn interactive_round2( if retires_session { session.retired_interactive_at_unix = None; } + // A pre-replacement failure deliberately leaves the nonce handle + // retryable. Persistence may itself be slow, so restart inactivity + // at failure completion before releasing the engine lock. + session + .interactive_signing + .get_mut(&request.member_identifier) + .expect("pre-replacement Round2 failure leaves interactive state") + .last_activity_at = interactive_now(); restore_compacted_retired_sessions(&mut guard, compacted_retired_sessions); } return Err(persist_error); @@ -1986,8 +2054,8 @@ pub(crate) fn resolve_wallet_session_id( } pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) -> Vec { - let ttl_seconds = interactive_session_ttl_seconds(); - let now = now_unix(); + let ttl = Duration::from_secs(interactive_session_ttl_seconds()); + let now = interactive_now(); let mut changed_session_ids = HashSet::new(); // Open requires an existing wallet DKG, but production signing normally // uses a distinct per-message session bound to that wallet. Expiry clears @@ -1995,11 +2063,14 @@ pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) -> // and replay state until active admission needs the shared slot. for (session_id, session) in &mut engine_state.sessions { // Per-member expiry: each seat's entry expires independently by its own - // opened_at_unix; non-expired sibling seats in the same session survive. + // last successful activity; non-expired sibling seats in the same session + // survive. Rejected traffic cannot keep nonce state resident. let expired_members: Vec = session .interactive_signing .iter() - .filter(|(_, interactive)| now.saturating_sub(interactive.opened_at_unix) > ttl_seconds) + .filter(|(_, interactive)| { + now.saturating_duration_since(interactive.last_activity_at) > ttl + }) .map(|(member, _)| *member) .collect(); if !expired_members.is_empty() { diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index bb74818736..4ad0ffa106 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -71,7 +71,12 @@ pub(crate) struct InteractiveSigningState { /// intent rather than relying on a durable generic-message allowlist. pub(crate) signing_intent: Option, pub(crate) key_package: frost::keys::KeyPackage, - pub(crate) opened_at_unix: u64, + /// Monotonic time of the last successful activity for this member's live + /// attempt. Exact Open and Round1 retries refresh it, as does a validated + /// Round2 whose retry-preserving durability work fails. Rejected traffic + /// does not extend nonce residency. This state is transient, so `Instant` + /// never crosses the persistence boundary. + pub(crate) last_activity_at: Instant, pub(crate) round1: Option, } diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 9f8179d6c7..dac089d1ee 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -7249,6 +7249,11 @@ fn interactive_package_for_test( .signing_package_hex } +fn interactive_last_activity_at_for_test(session_id: &str, member_identifier: u16) -> Instant { + let guard = state().expect("state").lock().expect("lock"); + guard.sessions[session_id].interactive_signing[&member_identifier].last_activity_at +} + fn stateless_package_and_shares_for_test( message_bytes: &[u8], signer_ids: &[u16], @@ -7305,6 +7310,12 @@ fn interactive_round2_state_key_failure_does_not_burn_attempt() { let key_group = "interactive-round2-key-failure-group"; let message = [0x42u8; 32]; let included = [1u16, 2]; + let ttl_seconds = interactive_session_ttl_seconds(); + let margin_seconds = ttl_seconds / 4; + assert!( + margin_seconds > 0, + "the configured interactive TTL must provide a synthetic test margin" + ); let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) .expect("interactive session opens"); @@ -7329,6 +7340,36 @@ fn interactive_round2_state_key_failure_does_not_burn_attempt() { member2.commitment.clone(), ], ); + let rejected_signing_package_hex = interactive_package_for_test( + &[0x43u8; 32], + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + // Advance well within the TTL. Invalid traffic must not refresh this live + // handle, while the later retry-preserving key failure must refresh it at + // failure completion. + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + advance_interactive_clock_for_tests(ttl_seconds / 2); + + let rejected = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: rejected_signing_package_hex, + }) + .expect_err("a wrong-message Round2 package must be rejected"); + assert!(matches!(rejected, EngineError::Validation(_))); + assert_eq!( + interactive_last_activity_at_for_test(session_id, 1), + prior_activity, + "invalid Round2 traffic must not refresh activity" + ); // Make the state-key command fail, then attempt Round2. std::env::set_var( @@ -7337,6 +7378,7 @@ fn interactive_round2_state_key_failure_does_not_burn_attempt() { ); std::env::set_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV, "exit 7"); + let round2_started_at = interactive_now(); let err = interactive_round2(InteractiveRound2Request { session_id: session_id.to_string(), attempt_id: opened.attempt_id.clone(), @@ -7344,11 +7386,12 @@ fn interactive_round2_state_key_failure_does_not_burn_attempt() { signing_package_hex: signing_package_hex.clone(), }) .expect_err("round 2 must fail when the state-key command fails"); + let failure_returned_at = interactive_now(); assert!(matches!(err, EngineError::Internal(_)), "got {err:?}"); // The consumption marker must NOT be set: the failed Round2 must not burn // the attempt. - { + let refreshed_activity = { let guard = state().expect("state").lock().expect("lock"); let session = guard.sessions.get(session_id).expect("session exists"); assert!( @@ -7357,9 +7400,30 @@ fn interactive_round2_state_key_failure_does_not_burn_attempt() { .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), "a Round2 that failed at the state-key step must not leave a consumption marker" ); - } + let interactive = session + .interactive_signing + .get(&1) + .expect("key failure leaves the nonce handle retryable"); + assert!(interactive.round1.is_some(), "Round1 nonces remain live"); + interactive.last_activity_at + }; + assert!( + refreshed_activity > prior_activity, + "validated retry-preserving Round2 failure must advance activity" + ); + assert!( + refreshed_activity >= round2_started_at && refreshed_activity <= failure_returned_at, + "Round2 failure activity must fall within the failed call" + ); - // Restore a working key; the same attempt must still release its share. + // Model substantial but sub-TTL inactivity from the failed call without + // sleeping, then restore a working key. The same attempt must release its + // share rather than being swept on retry. + advance_interactive_clock_for_tests(margin_seconds); + assert!( + interactive_now().saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds) + ); std::env::remove_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV); std::env::remove_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV); @@ -7751,19 +7815,7 @@ fn per_message_abort_and_expiry_retire_without_losing_policy_artifacts() { member_identifier: 1, }) .expect("expired flow round 1"); - { - let mut guard = state().expect("state").lock().expect("lock"); - let interactive = guard - .sessions - .get_mut(expired_session) - .expect("expired session exists") - .interactive_signing - .get_mut(&1) - .expect("expired flow remains live"); - interactive.opened_at_unix = interactive - .opened_at_unix - .saturating_sub(interactive_session_ttl_seconds() + 1); - } + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); let expired = interactive_round1(InteractiveRound1Request { session_id: expired_session.to_string(), attempt_id: expired_open.attempt_id, @@ -8040,19 +8092,15 @@ fn partial_member_expiry_persists_binding_before_restart() { .expect("both members create nonce state"); } - { - let mut guard = state().expect("state").lock().expect("lock"); - let member = guard - .sessions - .get_mut(signing_session) - .expect("signing session remains live") - .interactive_signing - .get_mut(&1) - .expect("member 1 remains live"); - member.opened_at_unix = member - .opened_at_unix - .saturating_sub(interactive_session_ttl_seconds() + 1); - } + let ttl_seconds = interactive_session_ttl_seconds(); + advance_interactive_clock_for_tests(ttl_seconds / 2); + interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: member_1.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 activity refreshes independently"); + advance_interactive_clock_for_tests(ttl_seconds / 2 + 1); let expired = interactive_round1(InteractiveRound1Request { session_id: signing_session.to_string(), attempt_id: member_1.attempt_id, @@ -9671,6 +9719,9 @@ fn interactive_round2_persist_fault_leaves_nonces_live() { let key_group = "interactive-test-key-group"; let message = [0x71u8; 32]; let included = [1u16, 2]; + let ttl_seconds = interactive_session_ttl_seconds(); + let margin_seconds = ttl_seconds / 4; + assert!(margin_seconds > 0); let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) .expect("opens"); @@ -9695,10 +9746,13 @@ fn interactive_round2_persist_fault_leaves_nonces_live() { member2.commitment, ], ); + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + advance_interactive_clock_for_tests(ttl_seconds / 2); // Consumption-before-release: if the durable marker cannot be // persisted, NO share leaves the engine and the nonces stay live. set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let fault_started_at = interactive_now(); let faulted = interactive_round2(InteractiveRound2Request { session_id: session_id.to_string(), attempt_id: opened.attempt_id.clone(), @@ -9706,13 +9760,14 @@ fn interactive_round2_persist_fault_leaves_nonces_live() { signing_package_hex: signing_package_hex.clone(), }) .expect_err("injected persist fault must fail round 2"); + let fault_returned_at = interactive_now(); clear_persist_fault_injection_for_tests(); assert!( matches!(faulted, EngineError::Internal(ref m) if m.contains("injected persist fault")), "unexpected error: {faulted:?}" ); - { + let refreshed_activity = { let guard = state().expect("state").lock().expect("lock"); let session = guard.sessions.get(session_id).expect("session exists"); assert!( @@ -9721,10 +9776,29 @@ fn interactive_round2_persist_fault_leaves_nonces_live() { .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), "a failed persist must roll the consumption marker back" ); - } + let interactive = session + .interactive_signing + .get(&1) + .expect("pre-replacement failure leaves the nonce retryable"); + assert!(interactive.round1.is_some(), "Round1 nonces remain live"); + interactive.last_activity_at + }; + assert!( + refreshed_activity > prior_activity, + "pre-replacement failure completion must advance activity" + ); + assert!( + refreshed_activity >= fault_started_at && refreshed_activity <= fault_returned_at, + "persistence-failure activity must fall within the failed call" + ); // The same attempt completes once persistence recovers - the // nonces were never consumed by the failed call. + advance_interactive_clock_for_tests(margin_seconds); + assert!( + interactive_now().saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds) + ); interactive_round2(InteractiveRound2Request { session_id: session_id.to_string(), attempt_id: opened.attempt_id.clone(), @@ -10083,6 +10157,23 @@ fn interactive_abort_persist_failures_are_retryable_before_replace_and_fail_clos clear_state_storage_policy_overrides(); } +#[test] +fn reset_for_tests_clears_interactive_clock_offset() { + let _guard = lock_test_state(); + reset_for_tests(); + + let baseline = interactive_now(); + advance_interactive_clock_for_tests(3_600); + let advanced = interactive_now(); + assert!(advanced.saturating_duration_since(baseline) >= Duration::from_secs(3_600)); + + reset_for_tests(); + assert!( + interactive_now() < advanced, + "engine reset must clear the test-only interactive clock offset" + ); +} + #[test] fn interactive_session_ttl_expiry_has_abort_semantics() { let _guard = lock_test_state(); @@ -10104,18 +10195,7 @@ fn interactive_session_ttl_expiry_has_abort_semantics() { // Age the session past the TTL directly; the next entry point's // lazy sweep must destroy the nonces with abort semantics. - { - let mut guard = state().expect("state").lock().expect("lock"); - let session = guard.sessions.get_mut(session_id).expect("session exists"); - let interactive = session - .interactive_signing - .values_mut() - .next() - .expect("live interactive state"); - interactive.opened_at_unix = interactive - .opened_at_unix - .saturating_sub(interactive_session_ttl_seconds() + 1); - } + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); let expired = interactive_round1(InteractiveRound1Request { session_id: session_id.to_string(), @@ -10134,6 +10214,172 @@ fn interactive_session_ttl_expiry_has_abort_semantics() { .expect("an expired (never consumed) attempt may reopen"); } +#[test] +fn interactive_inactivity_ttl_refreshes_on_idempotent_open() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-ttl-idempotent-open"; + let key_group = "interactive-test-key-group"; + let message = [0xa2u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let ttl_seconds = interactive_session_ttl_seconds(); + let recent_margin_seconds = ttl_seconds / 4; + assert!( + recent_margin_seconds > 0, + "the configured interactive TTL must provide a synthetic test margin" + ); + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + + // Advance by half the TTL without sleeping. The exact retry is legitimate + // activity and must advance the monotonic timestamp. + advance_interactive_clock_for_tests(ttl_seconds / 2); + + let retry_started_at = interactive_now(); + let retry = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("an exact Open retry remains live"); + assert!(retry.idempotent); + let refreshed_activity = { + let guard = state().expect("state").lock().expect("lock"); + guard.sessions[session_id].interactive_signing[&1].last_activity_at + }; + assert!( + refreshed_activity > prior_activity, + "an idempotent Open must advance the member's activity instant" + ); + assert!( + refreshed_activity >= retry_started_at, + "the refreshed activity instant must belong to the retry" + ); + + // Model substantial but still sub-TTL inactivity from the observed retry + // instant. This stays far from the expiry boundary and requires no sleep. + advance_interactive_clock_for_tests(recent_margin_seconds); + assert!( + interactive_now().saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds), + "the synthetic activity must remain comfortably inside the TTL" + ); + + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + }) + .expect("recent idempotent Open activity keeps the attempt live"); +} + +#[test] +fn interactive_inactivity_ttl_refreshes_fresh_round1_per_member_before_round2() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-ttl-round1-round2"; + let key_group = "interactive-test-key-group"; + let message = [0xa3u8; 32]; + let included = [1u16, 2]; + let key_packages = interactive_test_key_packages(); + let ttl_seconds = interactive_session_ttl_seconds(); + let margin_seconds = ttl_seconds / 4; + assert!( + margin_seconds > 0, + "the configured interactive TTL must provide a synthetic test margin" + ); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens"); + open_interactive_for_test(session_id, key_group, &message, &included, 1, 2, 2) + .expect("member 2 opens"); + let round1_member_2 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 round 1"); + + // Member 1 has not run Round1 yet. Advance it well within the TTL, then + // prove rejected traffic cannot refresh its original Open activity. + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + advance_interactive_clock_for_tests(ttl_seconds / 2); + let rejected_attempt_id = hash_hex(b"rejected-ttl-round1-attempt"); + assert_ne!(rejected_attempt_id, opened.attempt_id); + let rejected = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: rejected_attempt_id, + member_identifier: 1, + }) + .expect_err("a wrong-attempt Round1 must be rejected"); + assert_eq!( + interactive_last_activity_at_for_test(session_id, 1), + prior_activity, + "rejected traffic must not refresh the member's activity instant" + ); + assert!(matches!(rejected, EngineError::Validation(_))); + + // A first, successful Round1 must advance the monotonic activity instant. + let round1_started_at = interactive_now(); + let round1_member_1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 fresh round 1"); + let refreshed_activity = interactive_last_activity_at_for_test(session_id, 1); + assert!( + refreshed_activity > prior_activity, + "fresh Round1 must advance the member's activity instant" + ); + assert!( + refreshed_activity >= round1_started_at, + "the refreshed activity instant must belong to fresh Round1" + ); + + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_member_1.commitments_hex, + }, + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: round1_member_2.commitments_hex, + }, + ], + ); + + // Advance far enough that member 2, idle since offset zero, is beyond the + // TTL while member 1's fresh Round1 remains comfortably live. + advance_interactive_clock_for_tests(ttl_seconds / 2 + margin_seconds); + let synthetic_now = interactive_now(); + let idle_activity = interactive_last_activity_at_for_test(session_id, 2); + assert!( + synthetic_now.saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds) + ); + assert!( + synthetic_now.saturating_duration_since(idle_activity) > Duration::from_secs(ttl_seconds) + ); + + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("recent Round1 activity keeps member 1 live through Round2"); + + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session remains"); + assert!( + !session.interactive_signing.contains_key(&2), + "the genuinely idle sibling expires on the same sweep" + ); +} + #[test] fn interactive_live_session_capacity_fails_closed() { let _guard = lock_test_state(); @@ -11008,21 +11254,7 @@ fn interactive_abort_sweeps_expired_sessions() { member_identifier: 1, }) .expect("round 1"); - { - let mut guard = state().expect("state").lock().expect("lock"); - let session = guard - .sessions - .get_mut("interactive-abort-sweep-a") - .expect("session A exists"); - let interactive = session - .interactive_signing - .values_mut() - .next() - .expect("live interactive state"); - interactive.opened_at_unix = interactive - .opened_at_unix - .saturating_sub(interactive_session_ttl_seconds() + 1); - } + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); // An abort for a DIFFERENT session is the only post-expiry traffic; // it must still sweep session A's expired nonces (the TTL guarantee @@ -12998,20 +13230,7 @@ fn interactive_aggregate_sweeps_expired_sessions() { member_identifier: 1, }) .expect("round 1"); - { - let mut guard = state().expect("state").lock().expect("lock"); - let interactive = guard - .sessions - .get_mut("interactive-aggregate-sweep-a") - .expect("session A") - .interactive_signing - .values_mut() - .next() - .expect("live interactive state"); - interactive.opened_at_unix = interactive - .opened_at_unix - .saturating_sub(interactive_session_ttl_seconds() + 1); - } + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); // A parseable threshold-sized package + share so the aggregate call // reaches the lock and the sweep (it then fails on the missing diff --git a/pkg/tbtc/signer/src/engine/testsupport.rs b/pkg/tbtc/signer/src/engine/testsupport.rs index 8d18c45ede..b0fc7b2b52 100644 --- a/pkg/tbtc/signer/src/engine/testsupport.rs +++ b/pkg/tbtc/signer/src/engine/testsupport.rs @@ -60,6 +60,7 @@ pub(crate) fn establish_clean_signer_test_env() { #[cfg(test)] pub fn reset_for_tests() { + reset_interactive_clock_for_tests(); clear_installed_signer_config_for_tests(); clear_persist_fault_injection_for_tests(); clear_persistence_pending_operations();