diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index 1ed8fc0d46..3306aac44f 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -10,7 +10,11 @@ in `docs/rust-rewrite-bootstrap.md`. - `StartSignRound` - `FinalizeSignRound` - `BuildTaprootTx` - - `RefreshShares` + - `RefreshShares` (symbol retained in ABI 4.0, but fail-closed with + `cryptographic_refresh_not_supported` until a multi-round FROST refresh + protocol is implemented; metadata from the retired synthetic stub cannot + postpone cadence or establish key continuity, and an unanchored legacy + refresh-only session is immediately overdue) - Exposes fine-grained interactive (member-custodied nonce) signing via: - `InteractiveSessionOpen` - `InteractiveRound1` @@ -361,7 +365,9 @@ storage guarantees for that hardware-level failure boundary. `{"code":"...","message":"...","recovery_class":"..."}` JSON in `buffer`. - `recovery_class` values: - `recoverable`: caller can retry with corrected/updated input. - - `terminal`: session state is terminal for the current operation/session. + - `terminal`: the current operation/session cannot succeed in this runtime; + retry only after changing runtime capability or starting the documented + replacement flow. - `frost_tbtc_roast_liveness_policy` response: - `coordinator_timeout_ms`: effective coordinator-timeout policy in milliseconds. @@ -442,6 +448,11 @@ storage guarantees for that hardware-level failure boundary. transient with the live nonce state, so restart requires a fresh Open. ABI 3.2 adds the independent per-wallet heartbeat rate-limit config and dedicated heartbeat policy-rejection metric. + - ABI 4.0 reserves `RefreshShares` as fail-closed until a real multi-round, + zero-constant FROST refresh protocol exists. Because valid refresh requests + now return terminal `cryptographic_refresh_not_supported` instead of a + synthetic success result, ABI-3 bridges must reject this library during + compatibility negotiation. - ABI-3 migration is intentionally fail closed. A pre-ABI-3 in-flight ROAST session has no stored BIP-341 sighashes and must be abandoned and restarted under a fresh `session_id`; its cached fingerprint cannot be upgraded in diff --git a/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md b/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md index 382bd56005..71c93a6ef4 100644 --- a/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md +++ b/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md @@ -25,7 +25,10 @@ rewrite architecture. - `frost_tbtc_start_sign_round` - `frost_tbtc_finalize_sign_round` - `frost_tbtc_build_taproot_tx` - - `frost_tbtc_refresh_shares` + - `frost_tbtc_refresh_shares` (symbol retained, but ABI 4.0 fails closed; the + one-shot request cannot perform cryptographic FROST share refresh, and the + major bump prevents ABI-3 consumers from accepting the changed response + semantics) - Implemented idempotency and conflict checks for retried operations under the same session ID. - Added file-backed persistent session-state adapter with atomic writes and @@ -286,8 +289,10 @@ rewrite architecture. t-of-n contribution handling and filter signing-package commitments to actual contributing participants; complete keep-core cohort-selection wiring and non-full-cohort integration coverage. -- Refresh epoch policy: keep `refresh_epoch` monotonic via internal counter - semantics (do not use wall-clock values for refresh ordering). +- Share refresh: add a multi-round, zero-constant FROST refresh protocol before + enabling `RefreshShares`; the retained one-shot ABI must fail closed rather + than manufacture replacement share bytes. Persisted metadata from the retired + synthetic stub is non-authoritative for refresh cadence and key continuity. ## Validation command diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index 2514a141f8..02ed504ab0 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -41,6 +41,11 @@ TbtcSignerResult frost_tbtc_persist_distributed_dkg_key_package(const uint8_t* r TbtcSignerResult frost_tbtc_new_signing_package(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_build_taproot_tx(const uint8_t* request_ptr, size_t request_len); +/* + * Reserved ABI: fails closed with terminal error code + * `cryptographic_refresh_not_supported` until a multi-round, zero-constant + * FROST refresh protocol is implemented. + */ TbtcSignerResult frost_tbtc_refresh_shares(const uint8_t* request_ptr, size_t request_len); /* diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index 8c000f9044..5ee7856f10 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -485,6 +485,10 @@ pub struct RefreshSharesRequest { pub current_shares: Vec, } +/// Reserved response shape for a future cryptographic share-refresh protocol. +/// The current one-shot endpoint always rejects because it cannot safely run +/// the required multi-round FROST refresh, returning the terminal error code +/// `cryptographic_refresh_not_supported`. #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct RefreshSharesResult { pub session_id: String, @@ -500,11 +504,19 @@ pub struct RefreshCadenceStatusRequest { #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct RefreshCadenceStatusResult { pub session_id: String, + /// Number of cryptographically valid refreshes. Always zero until the + /// versioned multi-round protocol is implemented. pub refresh_count: u64, + /// Epoch of the last cryptographically valid refresh. Always zero while + /// `RefreshShares` is reserved and fail-closed. pub last_refresh_epoch: u64, pub cadence_seconds: u64, + /// Durable DKG creation deadline, or zero when untrusted legacy refresh + /// metadata has no DKG anchor and must be treated as immediately overdue. pub next_refresh_due_unix: u64, pub overdue: bool, + /// False when persisted metadata from the retired synthetic refresh stub is + /// detected; that metadata never establishes key continuity. pub continuity_preserved: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub continuity_reference_key_group: Option, diff --git a/pkg/tbtc/signer/src/engine/lifecycle.rs b/pkg/tbtc/signer/src/engine/lifecycle.rs index 2fc85e2cc1..afa8a5abf1 100644 --- a/pkg/tbtc/signer/src/engine/lifecycle.rs +++ b/pkg/tbtc/signer/src/engine/lifecycle.rs @@ -2,12 +2,6 @@ use super::*; -/// Upper bound on per-session `refresh_history` length. Older records are -/// dropped once this is exceeded, bounding persisted-state size for a long-lived -/// / frequently-refreshed session. Also bounds the stale-fingerprint detection -/// window (retries older than this many refreshes are no longer recognized). -const MAX_REFRESH_HISTORY: usize = 256; - #[cfg(test)] static CANARY_PROMOTION_HOLD_NEXT_LOCK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -136,36 +130,45 @@ pub(crate) fn refresh_continuity_reference_key_group(session: &SessionState) -> .dkg_result .as_ref() .map(|result| result.key_group.clone()) - .or_else(|| { - session - .refresh_history - .iter() - .find_map(|record| record.key_group.clone()) - }) } -pub(crate) fn refresh_history_continuity_preserved(session: &SessionState) -> bool { - let mut last_refresh_epoch = 0_u64; - let mut reference_key_group: Option<&str> = None; +/// Returns whether this session contains metadata written by the retired +/// synthetic `RefreshShares` implementation. No cryptographically valid refresh +/// record can exist until a versioned multi-round protocol is implemented, so +/// these fields are retained only for persisted-schema compatibility and must +/// never establish cadence or continuity. +pub(crate) fn legacy_synthetic_refresh_artifacts_present(session: &SessionState) -> bool { + session.refresh_request_fingerprint.is_some() + || session.refresh_result.is_some() + || !session.refresh_history.is_empty() + || session.refresh_count != 0 +} - for refresh_record in &session.refresh_history { - if refresh_record.refresh_epoch == 0 || refresh_record.refresh_epoch <= last_refresh_epoch { - return false; - } - last_refresh_epoch = refresh_record.refresh_epoch; +pub(crate) fn refresh_history_continuity_preserved(session: &SessionState) -> bool { + !legacy_synthetic_refresh_artifacts_present(session) +} - if let Some(record_key_group) = refresh_record.key_group.as_deref() { - if let Some(reference_key_group) = reference_key_group { - if !record_key_group.eq_ignore_ascii_case(reference_key_group) { - return false; - } - } else { - reference_key_group = Some(record_key_group); - } - } +pub(crate) fn refresh_cadence_due_unix( + session: &SessionState, + cadence_seconds: u64, +) -> Option { + if let Some(dkg_result) = session.dkg_result.as_ref() { + return Some(dkg_result.created_at_unix.saturating_add(cadence_seconds)); } - true + // The retired synthetic RefreshShares path could create a persisted session + // without ever running DKG. Such state has no trustworthy cadence anchor and + // must fail closed instead of receiving a new `now + cadence` deadline on + // every query. Unix epoch is the explicit "already due" sentinel shared by + // status and telemetry. + legacy_synthetic_refresh_artifacts_present(session).then_some(0) +} + +pub(crate) fn refresh_cadence_is_overdue(now_unix: u64, due_unix: u64) -> bool { + // A zero deadline is the explicit sentinel for unanchored legacy synthetic + // refresh state. It remains overdue even if the system clock rolls back to + // or before UNIX_EPOCH and `now_unix()` saturates to zero. + due_unix == 0 || now_unix > due_unix } pub fn refresh_cadence_status( @@ -185,12 +188,10 @@ pub fn refresh_cadence_status( session_id: request.session_id.clone(), })?; let cadence_seconds = refresh_cadence_seconds(); - let last_refresh_record = session.refresh_history.last(); let now = now_unix(); - let next_refresh_due_unix = last_refresh_record - .map(|record| record.refreshed_at_unix.saturating_add(cadence_seconds)) + let next_refresh_due_unix = refresh_cadence_due_unix(session, cadence_seconds) .unwrap_or_else(|| now.saturating_add(cadence_seconds)); - let overdue = now > next_refresh_due_unix; + let overdue = refresh_cadence_is_overdue(now, next_refresh_due_unix); let continuity_reference_key_group = refresh_continuity_reference_key_group(session); let emergency_rekey_reason = session .emergency_rekey_event @@ -199,10 +200,10 @@ pub fn refresh_cadence_status( Ok(RefreshCadenceStatusResult { session_id: request.session_id, - refresh_count: session.refresh_count, - last_refresh_epoch: last_refresh_record - .map(|record| record.refresh_epoch) - .unwrap_or(0), + // No cryptographically valid refresh can be reported by this build. + // Legacy synthetic metadata is deliberately ignored. + refresh_count: 0, + last_refresh_epoch: 0, cadence_seconds, next_refresh_due_unix, overdue, @@ -574,248 +575,13 @@ pub fn refresh_shares(request: RefreshSharesRequest) -> Result = request - .current_shares - .into_iter() - .map(|share| ShareMaterial { - identifier: share.identifier, - encrypted_share_hex: hash_hex( - format!( - "refresh:{}:{}:{}", - request.session_id, share.identifier, share.encrypted_share_hex - ) - .as_bytes(), - ), - }) - .collect(); - - new_shares.sort_by_key(|share| share.identifier); - - guard.refresh_epoch_counter = guard.refresh_epoch_counter.saturating_add(1); - let refresh_epoch = guard.refresh_epoch_counter; - - let result = RefreshSharesResult { + log_policy_decision( + "lifecycle_policy", + &request.session_id, + "reject", + "cryptographic_refresh_not_supported", + ); + Err(EngineError::CryptographicRefreshNotSupported { session_id: request.session_id, - refresh_epoch, - new_shares, - }; - - let session = guard - .sessions - .entry(result.session_id.clone()) - .or_insert_with(SessionState::default); - if let Some(emergency_rekey_event) = session.emergency_rekey_event.as_ref() { - return Err(EngineError::LifecyclePolicyRejected { - session_id: result.session_id.clone(), - reason_code: "emergency_rekey_required".to_string(), - detail: format!( - "refresh blocked: emergency rekey required since [{}]: {}", - emergency_rekey_event.triggered_at_unix, emergency_rekey_event.reason - ), - }); - } - // Preserve the previously-accepted fingerprint before overwriting it. If the - // last accepted refresh predates RefreshHistoryRecord.request_fingerprint - // (loaded from legacy state, where history records deserialize with None), its - // fingerprint lives only in refresh_request_fingerprint; backfill it onto the - // most-recent history record so a delayed retry of it is still recognized as - // stale instead of being re-executed as a new refresh. - if let Some(previous_fingerprint) = session.refresh_request_fingerprint.clone() { - let already_tracked = session.refresh_history.iter().any(|record| { - record.request_fingerprint.as_deref() == Some(previous_fingerprint.as_str()) - }); - if !already_tracked { - if let Some(last) = session.refresh_history.last_mut() { - if last.request_fingerprint.is_none() { - last.request_fingerprint = Some(previous_fingerprint); - } - } else { - // Legacy/degraded state can carry a fingerprint with an EMPTY - // history (refresh_history postdates refresh_request_fingerprint), - // so there is no record to backfill onto. Synthesize one carrying - // the fingerprint so a delayed retry is still recognized for - // stale-retry rejection instead of being re-executed as a new - // refresh (which would advance the epoch). Prefer the cached - // result's epoch/share_count; when the result is absent (a - // truncated/legacy blob that kept only the fingerprint, or a - // corrupt state where refresh_result deserialized to None) fall - // back to an epoch one below the new refresh so the history stays - // strictly increasing, and a zero share_count. refresh_epoch_counter - // is persisted, so a prior accepted refresh implies refresh_epoch >= 2 - // and the fallback stays non-zero. - let previous_result = session.refresh_result.clone(); - let synthesized_epoch = previous_result - .as_ref() - .map(|previous| previous.refresh_epoch) - .filter(|&epoch| epoch != 0 && epoch < refresh_epoch) - .unwrap_or_else(|| refresh_epoch.saturating_sub(1).max(1)); - let synthesized_share_count = previous_result - .as_ref() - .map(|previous| previous.new_shares.len().min(u16::MAX as usize) as u16) - .unwrap_or(0); - session.refresh_history.push(RefreshHistoryRecord { - refresh_epoch: synthesized_epoch, - refreshed_at_unix: now_unix(), - share_count: synthesized_share_count, - key_group: session.dkg_result.as_ref().map(|dkg| dkg.key_group.clone()), - request_fingerprint: Some(previous_fingerprint), - }); - } - } - } - // Monotonic total refresh count, independent of refresh_history pruning; - // backfilled from the retained history length for sessions written before - // this field existed. - session.refresh_count = session - .refresh_count - .max(session.refresh_history.len() as u64) - .saturating_add(1); - session.refresh_request_fingerprint = Some(request_fingerprint.clone()); - session.refresh_result = Some(result.clone()); - session.refresh_history.push(RefreshHistoryRecord { - refresh_epoch, - refreshed_at_unix: now_unix(), - share_count: result.new_shares.len().min(u16::MAX as usize) as u16, - key_group: session.dkg_result.as_ref().map(|dkg| dkg.key_group.clone()), - request_fingerprint: Some(request_fingerprint), - }); - // Bound per-session history growth (state-at-rest size + stale-detection - // window). Keep the most recent records; epochs stay strictly increasing so - // refresh_history_continuity_preserved still holds. - if session.refresh_history.len() > MAX_REFRESH_HISTORY { - let excess = session.refresh_history.len() - MAX_REFRESH_HISTORY; - session.refresh_history.drain(0..excess); - } - if let Err(persist_error) = persist_engine_state_to_storage(&guard) { - let state_file_replaced = persist_error.state_file_replaced(); - let persist_error = persist_error.into_engine_error(); - if state_file_replaced { - mark_persistence_pending(PersistencePendingOperation::RefreshShares { - session_id: refresh_session_id.clone(), - request_fingerprint: accepted_request_fingerprint, - }); - } else { - guard.refresh_epoch_counter = previous_refresh_epoch_counter; - if let Some((request_fingerprint, result, history, count)) = previous_refresh_state { - let rollback_session = - guard.sessions.get_mut(&refresh_session_id).ok_or_else(|| { - EngineError::Internal(format!( - "refresh session [{refresh_session_id}] disappeared while rolling back a failed persist: {persist_error}" - )) - })?; - rollback_session.refresh_request_fingerprint = request_fingerprint; - rollback_session.refresh_result = result; - rollback_session.refresh_history = history; - rollback_session.refresh_count = count; - } else { - guard.sessions.remove(&refresh_session_id); - } - } - return Err(persist_error); - } - record_hardening_telemetry(|telemetry| { - telemetry.refresh_shares_success_total = - telemetry.refresh_shares_success_total.saturating_add(1); - }); - - Ok(result) + }) } diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs index 6f1029ac34..39fffbf060 100644 --- a/pkg/tbtc/signer/src/engine/mod.rs +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -77,9 +77,9 @@ use crate::api::{ QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult, RefreshSharesRequest, RefreshSharesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundState, - ShareMaterial, SignatureResult, SignerHardeningMetricsResult, TransactionResult, - TranscriptAuditRecord, TranscriptAuditRequest, TranscriptAuditResult, - TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult, VerifyBlameProofRequest, + SignatureResult, SignerHardeningMetricsResult, TransactionResult, TranscriptAuditRecord, + TranscriptAuditRequest, TranscriptAuditResult, TriggerEmergencyRekeyRequest, + TriggerEmergencyRekeyResult, VerifyBlameProofRequest, }; use crate::errors::EngineError; use crate::go_math_rand::select_coordinator_identifier; diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index b162bc6f04..193e6211a0 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -259,10 +259,6 @@ pub(crate) enum PersistencePendingOperation { CanaryRollback { result: RollbackCanaryResult, }, - RefreshShares { - session_id: String, - request_fingerprint: String, - }, InteractiveRound2 { session_id: String, consumed_marker: String, @@ -307,16 +303,6 @@ fn persistence_pending_same_slot( PersistencePendingOperation::CanaryPromotion { .. } | PersistencePendingOperation::CanaryRollback { .. }, ) => true, - ( - PersistencePendingOperation::RefreshShares { - session_id: existing, - .. - }, - PersistencePendingOperation::RefreshShares { - session_id: replacement, - .. - }, - ) => existing == replacement, ( PersistencePendingOperation::InteractiveRound2 { session_id: existing_session, @@ -467,34 +453,6 @@ pub(crate) fn pending_canary_rollback_result(reason: &str) -> Option Option { - persistence_pending_operations() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .iter() - .find(|operation| { - matches!( - operation, - PersistencePendingOperation::RefreshShares { - session_id: pending_session, - .. - } if pending_session == session_id - ) - }) - .cloned() -} - -#[cfg(test)] -pub(crate) fn refresh_persistence_pending(session_id: &str, request_fingerprint: &str) -> bool { - matches!( - pending_refresh_operation(session_id), - Some(PersistencePendingOperation::RefreshShares { - request_fingerprint: pending_fingerprint, - .. - }) if pending_fingerprint == request_fingerprint - ) -} - pub(crate) fn interactive_round2_persistence_pending( session_id: &str, consumed_marker: &str, @@ -1890,10 +1848,10 @@ impl TryFrom for SessionState { tx_result: persisted.tx_result, refresh_request_fingerprint: persisted.refresh_request_fingerprint, refresh_result: persisted.refresh_result, - // Backfill from history length for state written before refresh_count - // existed (serde defaults it to 0), so refresh_cadence_status reports - // the true total immediately after upgrade rather than 0 until the next - // refresh. Evaluated before refresh_history is moved below. + // Preserve the legacy synthetic count losslessly for schema + // compatibility and diagnostics. Lifecycle status deliberately + // ignores it until a versioned cryptographic refresh protocol exists. + // Evaluated before refresh_history is moved below. refresh_count: persisted .refresh_count .max(persisted.refresh_history.len() as u64), diff --git a/pkg/tbtc/signer/src/engine/roast.rs b/pkg/tbtc/signer/src/engine/roast.rs index 779fb3576f..dd674bc238 100644 --- a/pkg/tbtc/signer/src/engine/roast.rs +++ b/pkg/tbtc/signer/src/engine/roast.rs @@ -28,20 +28,6 @@ pub(crate) fn fingerprint(value: &T) -> Result RefreshSharesRequest { - let mut canonical_request = request.clone(); - canonical_request - .current_shares - .sort_unstable_by(|left, right| { - left.identifier - .cmp(&right.identifier) - .then_with(|| left.encrypted_share_hex.cmp(&right.encrypted_share_hex)) - }); - canonical_request -} - pub(crate) fn canonicalize_taproot_merkle_root_hex( taproot_merkle_root_hex: &mut Option, ) -> Result, EngineError> { diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index f2d20c7e7a..b6a68a7879 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -113,9 +113,9 @@ pub(crate) struct SessionState { pub(crate) refresh_request_fingerprint: Option, pub(crate) refresh_result: Option, pub(crate) refresh_history: Vec, - /// Monotonic count of accepted refreshes, independent of refresh_history - /// pruning (refresh_history is capped, so its length undercounts long-lived - /// sessions). Backfilled from history length when first incremented. + /// Legacy count written by the retired synthetic refresh implementation. + /// Retained only so existing pre-release state remains decodable; lifecycle + /// status deliberately treats it as non-authoritative. pub(crate) refresh_count: u64, pub(crate) emergency_rekey_event: Option, /// Transient per-wallet budget for accepted heartbeat Opens. Like the @@ -162,9 +162,9 @@ pub(crate) struct RefreshHistoryRecord { pub(crate) share_count: u16, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) key_group: Option, - /// Fingerprint of the refresh request that produced this record, used to - /// reject stale / out-of-order retries of an already-accepted refresh. - /// Optional for backward compatibility with state written before this field. + /// Legacy request fingerprint retained for persisted-schema compatibility. + /// No record produced by the retired one-shot implementation represents a + /// cryptographically valid share refresh. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) request_fingerprint: Option, } diff --git a/pkg/tbtc/signer/src/engine/telemetry.rs b/pkg/tbtc/signer/src/engine/telemetry.rs index ba5e46e0c1..1adda3ac87 100644 --- a/pkg/tbtc/signer/src/engine/telemetry.rs +++ b/pkg/tbtc/signer/src/engine/telemetry.rs @@ -483,16 +483,14 @@ pub fn hardening_metrics() -> SignerHardeningMetricsResult { .values() .filter(|session| session.emergency_rekey_event.is_some()) .count() as u64; + let now = now_unix(); + let cadence_seconds = refresh_cadence_seconds(); result.refresh_cadence_overdue_sessions = engine_state .sessions .values() .filter(|session| { - session.refresh_history.last().is_some_and(|last_refresh| { - now_unix() - > last_refresh - .refreshed_at_unix - .saturating_add(refresh_cadence_seconds()) - }) + refresh_cadence_due_unix(session, cadence_seconds) + .is_some_and(|due_unix| refresh_cadence_is_overdue(now, due_unix)) }) .count() as u64; } diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index aacb061e02..dc124a9897 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -756,20 +756,16 @@ fn expect_internal_error_contains(err: EngineError, expected_substring: &str) { ); } -fn state_mutation_request(session_id: &str) -> RefreshSharesRequest { - RefreshSharesRequest { - session_id: session_id.to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "1111".to_string(), - }], - } -} - -fn mutate_state_for_key_provider_test( - session_id: &str, -) -> Result { - refresh_shares(state_mutation_request(session_id)) +fn persist_state_for_key_provider_test(session_id: &str) -> Result<(), EngineError> { + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + guard + .sessions + .entry(session_id.to_string()) + .or_default() + .bound_key_group = Some("state-key-provider-test".to_string()); + persist_engine_state_to_storage(&guard).map_err(PersistEngineStateError::into_engine_error) } // Regression for resolving the state-key (a KMS/HSM subprocess for the `command` @@ -2169,32 +2165,254 @@ fn hardening_metrics_count_calls_before_provenance_gate_rejection() { } #[test] -fn hardening_metrics_track_start_sign_round_and_refresh_shares_counters() { +fn refresh_shares_fails_closed_without_mutating_wallet_state() { let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_shares_fail_closed"); reset_for_tests(); clear_state_storage_policy_overrides(); - let _ = build_taproot_tx(build_policy_test_request("session-metrics-build-tx")) - .expect("build taproot tx"); + let session_id = "session-refresh-fail-closed"; + ensure_interactive_dkg_session(session_id, "refresh-fail-closed-key-group"); + let persisted_state_before = std::fs::read(&state_path).expect("read baseline state"); + let (key_packages_before, public_key_package_before, dkg_result_before) = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get(session_id).expect("wallet session"); + ( + session.dkg_key_packages.clone(), + session.dkg_public_key_package.clone(), + session.dkg_result.clone(), + ) + }; - let _ = refresh_shares(RefreshSharesRequest { - session_id: "session-metrics-refresh-only".to_string(), - current_shares: vec![ShareMaterial { + let error = refresh_shares(RefreshSharesRequest { + session_id: session_id.to_string(), + current_shares: vec![crate::api::ShareMaterial { identifier: 1, encrypted_share_hex: "aaaa".to_string(), }], }) - .expect("refresh shares"); + .expect_err("RefreshShares must reject until a cryptographic protocol is implemented"); + assert!(matches!( + error, + EngineError::CryptographicRefreshNotSupported { ref session_id } + if session_id == "session-refresh-fail-closed" + )); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get(session_id).expect("wallet session"); + assert_eq!(guard.refresh_epoch_counter, 0); + assert!(session.refresh_request_fingerprint.is_none()); + assert!(session.refresh_result.is_none()); + assert!(session.refresh_history.is_empty()); + assert_eq!(session.refresh_count, 0); + assert_eq!(session.dkg_key_packages, key_packages_before); + assert_eq!(session.dkg_public_key_package, public_key_package_before); + assert_eq!(session.dkg_result, dkg_result_before); + } + assert_eq!( + std::fs::read(&state_path).expect("read state after rejection"), + persisted_state_before, + ); let metrics = hardening_metrics(); - assert_eq!(metrics.build_taproot_tx_calls_total, 1); - assert_eq!(metrics.build_taproot_tx_success_total, 1); assert_eq!(metrics.refresh_shares_calls_total, 1); - assert_eq!(metrics.refresh_shares_success_total, 1); + assert_eq!(metrics.refresh_shares_success_total, 0); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn first_refresh_deadline_survives_restart_and_becomes_overdue() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("first_refresh_deadline"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "60"); + + let overdue_session_id = "session-first-refresh-overdue"; + let fresh_session_id = "session-first-refresh-fresh"; + ensure_interactive_dkg_session(overdue_session_id, "first-refresh-overdue-key-group"); + ensure_interactive_dkg_session(fresh_session_id, "first-refresh-fresh-key-group"); + + let created_at_unix = now_unix().saturating_sub(600); + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard + .sessions + .get_mut(overdue_session_id) + .expect("overdue wallet session") + .dkg_result + .as_mut() + .expect("DKG result") + .created_at_unix = created_at_unix; + persist_engine_state_to_storage(&guard).expect("persist DKG creation anchors"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + let status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: overdue_session_id.to_string(), + }) + .expect("refresh cadence status"); + assert_eq!(status.refresh_count, 0); + assert_eq!(status.last_refresh_epoch, 0); + assert_eq!(status.next_refresh_due_unix, created_at_unix + 60); + assert!(status.overdue); + + let fresh_status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: fresh_session_id.to_string(), + }) + .expect("fresh refresh cadence status"); + assert!(!fresh_status.overdue); + assert_eq!(hardening_metrics().refresh_cadence_overdue_sessions, 1); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn legacy_synthetic_refresh_metadata_cannot_postpone_cadence_or_claim_continuity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("legacy_synthetic_refresh_metadata"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "60"); + + let session_id = "session-legacy-synthetic-refresh"; + let key_group = "legacy-synthetic-refresh-key-group"; + ensure_interactive_dkg_session(session_id, key_group); + let created_at_unix = now_unix().saturating_sub(600); + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("wallet session"); + session + .dkg_result + .as_mut() + .expect("DKG result") + .created_at_unix = created_at_unix; + session.refresh_request_fingerprint = Some("legacy-synthetic-request".to_string()); + session.refresh_result = Some(RefreshSharesResult { + session_id: session_id.to_string(), + refresh_epoch: 1, + new_shares: vec![crate::api::ShareMaterial { + identifier: 1, + encrypted_share_hex: "synthetic-hash".to_string(), + }], + }); + session.refresh_history = vec![RefreshHistoryRecord { + refresh_epoch: 1, + refreshed_at_unix: now_unix(), + share_count: 1, + key_group: Some(key_group.to_string()), + request_fingerprint: Some("legacy-synthetic-request".to_string()), + }]; + session.refresh_count = 1; + guard.refresh_epoch_counter = 1; + persist_engine_state_to_storage(&guard).expect("persist legacy synthetic metadata"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + let status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: session_id.to_string(), + }) + .expect("refresh cadence status"); + assert_eq!(status.refresh_count, 0); + assert_eq!(status.last_refresh_epoch, 0); + assert_eq!(status.next_refresh_due_unix, created_at_unix + 60); + assert!(status.overdue); + assert!(!status.continuity_preserved); + assert_eq!( + status.continuity_reference_key_group.as_deref(), + Some(key_group) + ); + assert_eq!(hardening_metrics().refresh_cadence_overdue_sessions, 1); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn unanchored_legacy_refresh_session_is_immediately_overdue_after_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("unanchored_legacy_refresh"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "60"); + + let session_id = "session-unanchored-legacy-refresh"; + let plain_session_id = "session-unanchored-without-refresh-artifacts"; + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.entry(session_id.to_string()).or_default(); + assert!(session.dkg_result.is_none()); + session.refresh_request_fingerprint = Some("legacy-refresh-only-request".to_string()); + session.refresh_result = Some(RefreshSharesResult { + session_id: session_id.to_string(), + refresh_epoch: 1, + new_shares: vec![crate::api::ShareMaterial { + identifier: 1, + encrypted_share_hex: "aa".repeat(32), + }], + }); + session.refresh_history = vec![RefreshHistoryRecord { + refresh_epoch: 1, + refreshed_at_unix: now_unix(), + share_count: 1, + key_group: None, + request_fingerprint: Some("legacy-refresh-only-request".to_string()), + }]; + session.refresh_count = 1; + guard.refresh_epoch_counter = 1; + guard + .sessions + .entry(plain_session_id.to_string()) + .or_default(); + persist_engine_state_to_storage(&guard).expect("persist refresh-only legacy session"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + let status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: session_id.to_string(), + }) + .expect("refresh cadence status"); + assert_eq!(status.refresh_count, 0); + assert_eq!(status.last_refresh_epoch, 0); + assert_eq!(status.next_refresh_due_unix, 0); + assert!(status.overdue); + assert!(!status.continuity_preserved); + assert!(status.continuity_reference_key_group.is_none()); + + let plain_status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: plain_session_id.to_string(), + }) + .expect("plain session cadence status"); + assert!(!plain_status.overdue); + assert!(plain_status.continuity_preserved); + assert_eq!(hardening_metrics().refresh_cadence_overdue_sessions, 1); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); clear_state_storage_policy_overrides(); } +#[test] +fn refresh_cadence_overdue_sentinel_survives_clock_rollback() { + assert!(refresh_cadence_is_overdue(0, 0)); + assert!(refresh_cadence_is_overdue(101, 100)); + assert!(!refresh_cadence_is_overdue(100, 100)); + assert!(!refresh_cadence_is_overdue(99, 100)); +} + #[test] fn differential_fuzzing_reports_no_unresolved_critical_divergence() { let _guard = lock_test_state(); @@ -2438,155 +2656,6 @@ fn emergency_rekey_post_replace_state_survives_immediate_process_restart() { clear_state_storage_policy_overrides(); } -#[test] -fn refresh_shares_persist_failure_rolls_back_and_retry_is_durable() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_shares_persist_retry"); - reset_for_tests(); - clear_state_storage_policy_overrides(); - - let session_id = "session-refresh-persist-retry"; - let first_request = RefreshSharesRequest { - session_id: session_id.to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }; - let second_request = RefreshSharesRequest { - session_id: session_id.to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }], - }; - let first_result = refresh_shares(first_request).expect("persist first refresh"); - - set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); - let err = refresh_shares(second_request.clone()) - .expect_err("injected persist fault must fail second refresh"); - clear_persist_fault_injection_for_tests(); - assert!( - matches!(err, EngineError::Internal(ref message) if message.contains("injected persist fault")), - "unexpected error: {err:?}" - ); - - { - let guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard.sessions.get(session_id).expect("refresh session"); - assert_eq!(guard.refresh_epoch_counter, 1); - assert_eq!(session.refresh_result.as_ref(), Some(&first_result)); - assert_eq!(session.refresh_history.len(), 1); - assert_eq!(session.refresh_count, 1); - } - - let second_result = - refresh_shares(second_request.clone()).expect("retry persists second refresh"); - assert_eq!(second_result.refresh_epoch, 2); - - simulate_process_restart_for_tests(); - reload_state_from_storage_for_tests(); - { - let guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard - .sessions - .get(session_id) - .expect("reloaded refresh session"); - assert_eq!(guard.refresh_epoch_counter, 2); - assert_eq!(session.refresh_result.as_ref(), Some(&second_result)); - assert_eq!(session.refresh_history.len(), 2); - assert_eq!(session.refresh_count, 2); - } - assert_eq!( - refresh_shares(second_request).expect("durable idempotent refresh retry"), - second_result - ); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn first_refresh_pre_replace_failure_removes_new_session() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("first_refresh_persist_rollback"); - reset_for_tests(); - clear_state_storage_policy_overrides(); - - let session_id = "session-first-refresh-persist-rollback"; - let request = RefreshSharesRequest { - session_id: session_id.to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "abcd".to_string(), - }], - }; - set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); - let error = refresh_shares(request.clone()) - .expect_err("first refresh must roll back when state was not replaced"); - clear_persist_fault_injection_for_tests(); - assert!(matches!( - error, - EngineError::Internal(ref message) if message.contains("injected persist fault") - )); - { - let guard = state().expect("engine state").lock().expect("engine lock"); - assert_eq!(guard.refresh_epoch_counter, 0); - assert!(!guard.sessions.contains_key(session_id)); - } - - let result = refresh_shares(request).expect("retry performs the first refresh"); - assert_eq!(result.refresh_epoch, 1); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn pending_refresh_retry_rechecks_emergency_rekey_before_returning_shares() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("pending_refresh_rekey_gate"); - reset_for_tests(); - clear_state_storage_policy_overrides(); - - let session_id = "session-pending-refresh-rekey-gate"; - let request = RefreshSharesRequest { - session_id: session_id.to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "beef".to_string(), - }], - }; - set_persist_fault_injection_for_tests( - PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, - ); - refresh_shares(request.clone()) - .expect_err("post-replacement refresh fault leaves a pending result"); - clear_persist_fault_injection_for_tests(); - assert!(pending_refresh_operation(session_id).is_some()); - - trigger_emergency_rekey(TriggerEmergencyRekeyRequest { - session_id: session_id.to_string(), - reason: "kill switch after refresh failure".to_string(), - }) - .expect("persist emergency rekey"); - - let error = refresh_shares(request) - .expect_err("a pending refresh retry must not bypass the active kill switch"); - assert!(matches!( - error, - EngineError::LifecyclePolicyRejected { ref reason_code, .. } - if reason_code == "emergency_rekey_required" - )); - assert!(pending_refresh_operation(session_id).is_none()); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - #[test] fn canary_promotion_persist_failure_rolls_back_and_retry_is_durable() { let _guard = lock_test_state(); @@ -2812,56 +2881,6 @@ fn lifecycle_post_rename_persist_failures_remain_fail_closed_and_retry_durably() assert_eq!(rekey_result.session_id, rekey_session); assert!(pending_emergency_rekey_result(rekey_session, "post-rename containment").is_none()); - let refresh_session = "session-refresh-post-rename"; - let refresh_request = RefreshSharesRequest { - session_id: refresh_session.to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "cafe".to_string(), - }], - }; - let refresh_fingerprint = fingerprint(&canonicalize_refresh_shares_request_for_fingerprint( - &refresh_request, - )) - .expect("refresh request fingerprint"); - set_persist_fault_injection_for_tests( - PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, - ); - let refresh_error = refresh_shares(refresh_request.clone()) - .expect_err("post-rename fault must report refresh failure"); - clear_persist_fault_injection_for_tests(); - assert!(matches!( - refresh_error, - EngineError::Internal(ref message) if message.contains("injected persist fault") - )); - assert!(refresh_persistence_pending( - refresh_session, - &refresh_fingerprint - )); - let pending_refresh_result = { - let guard = state().expect("engine state").lock().expect("engine lock"); - assert_eq!(guard.refresh_epoch_counter, 1); - guard.sessions[refresh_session] - .refresh_result - .clone() - .expect("fail-closed refresh cache") - }; - set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); - refresh_shares(refresh_request.clone()) - .expect_err("a failed pending refresh flush must not return cached success"); - clear_persist_fault_injection_for_tests(); - assert!(refresh_persistence_pending( - refresh_session, - &refresh_fingerprint - )); - let retried_refresh = - refresh_shares(refresh_request).expect("same refresh retry flushes pending state"); - assert_eq!(retried_refresh, pending_refresh_result); - assert!(!refresh_persistence_pending( - refresh_session, - &refresh_fingerprint - )); - let promotion_request = PromoteCanaryRequest { target_percent: 50 }; seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); set_persist_fault_injection_for_tests( @@ -2927,13 +2946,6 @@ fn lifecycle_post_rename_persist_failures_remain_fail_closed_and_retry_durably() assert!(guard.sessions[rekey_session] .emergency_rekey_event .is_some()); - assert_eq!( - guard.sessions[refresh_session] - .refresh_result - .as_ref() - .expect("durable refresh result"), - &pending_refresh_result - ); assert_eq!(guard.canary_rollout.current_percent, 10); assert_eq!(guard.canary_rollout.config_version, 3); drop(guard); @@ -4600,150 +4612,16 @@ fn build_taproot_tx_rejects_new_session_when_session_registry_is_at_capacity() { } #[test] -fn refresh_shares_rejects_new_session_when_session_registry_is_at_capacity() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_session_capacity"); - reset_for_tests(); - std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "1"); +fn persisted_session_state_rejects_empty_consumed_attempt_id() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_attempt_ids = vec!["".to_string()]; - let first_request = RefreshSharesRequest { - session_id: "session-refresh-capacity-a".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aa11".to_string(), - }], + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, }; - refresh_shares(first_request.clone()).expect("first refresh"); - refresh_shares(first_request).expect("idempotent refresh at capacity"); - - let second_request = RefreshSharesRequest { - session_id: "session-refresh-capacity-b".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bb22".to_string(), - }], - }; - let err = refresh_shares(second_request).expect_err("expected session cap rejection"); - let EngineError::Internal(message) = err else { - panic!("unexpected error variant"); - }; - assert!( - message.contains("session registry size [1] reached max [1]"), - "unexpected internal message: {message}" - ); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_shares_retry_is_share_order_insensitive() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_share_order_retry"); - reset_for_tests(); - - let request = RefreshSharesRequest { - session_id: "session-refresh-share-order-retry".to_string(), - current_shares: vec![ - ShareMaterial { - identifier: 3, - encrypted_share_hex: "cccc".to_string(), - }, - ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }, - ShareMaterial { - identifier: 2, - encrypted_share_hex: "bbbb".to_string(), - }, - ], - }; - let mut retry_request = request.clone(); - retry_request.current_shares.reverse(); - - let first_result = refresh_shares(request).expect("initial refresh"); - let retry_result = refresh_shares(retry_request).expect("equivalent refresh retry"); - - assert_eq!(first_result, retry_result); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_shares_rejects_duplicate_current_share_identifiers() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_duplicate_share_identifier"); - reset_for_tests(); - - let err = refresh_shares(RefreshSharesRequest { - session_id: "session-refresh-duplicate-share-id".to_string(), - current_shares: vec![ - ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }, - ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }, - ], - }) - .expect_err("expected duplicate share identifier rejection"); - let EngineError::Validation(message) = err else { - panic!("unexpected error variant"); - }; - assert!( - message.contains("current_shares contains duplicate identifier [1]"), - "unexpected validation message: {message}" - ); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_shares_rejects_zero_current_share_identifier() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_zero_share_identifier"); - reset_for_tests(); - - let err = refresh_shares(RefreshSharesRequest { - session_id: "session-refresh-zero-share-id".to_string(), - current_shares: vec![ShareMaterial { - identifier: 0, - encrypted_share_hex: "aaaa".to_string(), - }], - }) - .expect_err("expected zero share identifier rejection"); - let EngineError::Validation(message) = err else { - panic!("unexpected error variant"); - }; - assert!( - message.contains("current_shares identifiers must be non-zero"), - "unexpected validation message: {message}" - ); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn persisted_session_state_rejects_empty_consumed_attempt_id() { - let mut persisted = persisted_session_state_fixture(); - persisted.consumed_attempt_ids = vec!["".to_string()]; - - let err = match SessionState::try_from(persisted) { - Ok(_) => panic!("expected decode rejection"), - Err(err) => err, - }; - expect_internal_error_contains(err, "persisted consumed attempt ID must be non-empty"); -} + expect_internal_error_contains(err, "persisted consumed attempt ID must be non-empty"); +} #[test] fn persisted_session_state_rejects_duplicate_consumed_attempt_id() { @@ -4902,331 +4780,6 @@ fn persisted_session_state_rejects_consumed_finalize_request_registry_over_limit ); } -#[test] -fn refresh_shares_allows_new_fingerprint_but_rejects_stale_retry() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_stale_retry"); - reset_for_tests(); - - let session_id = "session-refresh-stale".to_string(); - let req_a = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }; - let req_b = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }], - }; - - // First refresh A. - assert_eq!( - refresh_shares(req_a.clone()) - .expect("refresh A") - .refresh_epoch, - 1 - ); - // Idempotent replay of A (most recent) returns the cached result, no new epoch. - assert_eq!( - refresh_shares(req_a.clone()) - .expect("idempotent replay of A") - .refresh_epoch, - 1 - ); - // A genuinely new fingerprint B is a real subsequent periodic refresh. - assert_eq!( - refresh_shares(req_b.clone()) - .expect("refresh B") - .refresh_epoch, - 2 - ); - // Idempotent replay of B (now most recent) returns the cached result. - assert_eq!( - refresh_shares(req_b) - .expect("idempotent replay of B") - .refresh_epoch, - 2 - ); - // A stale retry of A (already accepted, no longer most recent) is rejected -- - // it must NOT re-derive old shares or bump the epoch forward. - let err = refresh_shares(req_a).expect_err("stale retry of A must be rejected"); - assert!(matches!(err, EngineError::SessionConflict { .. })); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_shares_rejects_legacy_fingerprint_with_empty_history() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_legacy_empty_history"); - reset_for_tests(); - - let session_id = "session-refresh-legacy-empty".to_string(); - let req_a = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }; - let req_b = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }], - }; - - refresh_shares(req_a.clone()).expect("refresh A"); - - // Simulate state written before refresh_history existed: the fingerprint and - // cached result are set, but refresh_history is empty. - { - let mut guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard.sessions.get_mut(&session_id).expect("session state"); - session.refresh_history.clear(); - assert!(session.refresh_request_fingerprint.is_some()); - assert!(session.refresh_result.is_some()); - persist_engine_state_to_storage(&guard).expect("persist legacy-shaped state"); - } - reload_state_from_storage_for_tests(); - - // Refresh B must synthesize a history record for A (no record to backfill onto) - // before overwriting the fingerprint, so a delayed retry of A is rejected. - refresh_shares(req_b).expect("refresh B"); - let err = refresh_shares(req_a).expect_err("stale retry of A must be rejected"); - assert!(matches!(err, EngineError::SessionConflict { .. })); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_shares_rejects_legacy_fingerprint_with_empty_history_and_no_result() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_legacy_empty_history_no_result"); - reset_for_tests(); - - let session_id = "session-refresh-legacy-empty-no-result".to_string(); - let req_a = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }; - let req_b = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }], - }; - - refresh_shares(req_a.clone()).expect("refresh A"); - - // Simulate a truncated/legacy blob that kept A's fingerprint but whose - // refresh_result deserialized to None and whose refresh_history is empty. - // The synthesize branch must still preserve A's fingerprint even without a - // cached result to derive the epoch/share_count from. - { - let mut guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard.sessions.get_mut(&session_id).expect("session state"); - session.refresh_history.clear(); - session.refresh_result = None; - assert!(session.refresh_request_fingerprint.is_some()); - persist_engine_state_to_storage(&guard).expect("persist legacy-shaped state"); - } - reload_state_from_storage_for_tests(); - - // Refresh B synthesizes a history record carrying A's fingerprint (falling - // back to a below-B epoch since there is no cached result) before - // overwriting the fingerprint, so a delayed retry of A is still rejected as - // stale instead of being re-executed as a new refresh. - assert_eq!(refresh_shares(req_b).expect("refresh B").refresh_epoch, 2); - let err = refresh_shares(req_a).expect_err("stale retry of A must be rejected"); - assert!(matches!(err, EngineError::SessionConflict { .. })); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_shares_rejects_legacy_pre_upgrade_fingerprint_after_new_refresh() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_legacy_fingerprint"); - reset_for_tests(); - - let session_id = "session-refresh-legacy".to_string(); - let req_a = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }; - let req_b = RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }], - }; - - // Accept refresh A. - refresh_shares(req_a.clone()).expect("refresh A"); - - // Simulate state written before RefreshHistoryRecord.request_fingerprint - // existed: A's history record deserializes with None, so A's fingerprint - // survives only in session.refresh_request_fingerprint. - { - let mut guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard.sessions.get_mut(&session_id).expect("session state"); - for record in session.refresh_history.iter_mut() { - record.request_fingerprint = None; - } - persist_engine_state_to_storage(&guard).expect("persist legacy-shaped state"); - } - reload_state_from_storage_for_tests(); - - // A new refresh B is accepted and overwrites refresh_request_fingerprint; the - // backfill must move A's fingerprint into history before that overwrite. - assert_eq!(refresh_shares(req_b).expect("refresh B").refresh_epoch, 2); - - // A delayed retry of the pre-upgrade refresh A must be rejected as stale, not - // re-executed as a new epoch. - let err = refresh_shares(req_a).expect_err("stale legacy retry of A must be rejected"); - assert!(matches!(err, EngineError::SessionConflict { .. })); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_count_backfills_from_history_on_legacy_load() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_count_legacy_load"); - reset_for_tests(); - - let session_id = "session-refresh-count-legacy".to_string(); - for hex in ["aaaa", "bbbb"] { - refresh_shares(RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: hex.to_string(), - }], - }) - .expect("refresh"); - } - - // Simulate state written before refresh_count existed: the field deserializes - // to 0 even though refresh_history already holds accepted refreshes. - { - let mut guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard.sessions.get_mut(&session_id).expect("session state"); - assert_eq!(session.refresh_count, 2); - assert_eq!(session.refresh_history.len(), 2); - session.refresh_count = 0; - persist_engine_state_to_storage(&guard).expect("persist legacy-shaped state"); - } - reload_state_from_storage_for_tests(); - - // On load, refresh_count is backfilled from history length, so cadence status - // reports the true total immediately -- not 0 until the next refresh. - let status = refresh_cadence_status(RefreshCadenceStatusRequest { - session_id: session_id.clone(), - }) - .expect("cadence status"); - assert_eq!(status.refresh_count, 2); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_cadence_status_count_survives_history_pruning() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_count_pruning"); - reset_for_tests(); - - let session_id = "session-refresh-count".to_string(); - for hex in ["aaaa", "bbbb", "cccc"] { - refresh_shares(RefreshSharesRequest { - session_id: session_id.clone(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: hex.to_string(), - }], - }) - .expect("refresh"); - } - - // Simulate the MAX_REFRESH_HISTORY prune (drop older records) without touching - // the monotonic refresh_count. - { - let mut guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard.sessions.get_mut(&session_id).expect("session state"); - assert_eq!(session.refresh_count, 3); - session.refresh_history.drain(0..2); - } - - let status = refresh_cadence_status(RefreshCadenceStatusRequest { - session_id: session_id.clone(), - }) - .expect("cadence status"); - // Reports the true total (3), not the pruned history window (1). - assert_eq!(status.refresh_count, 3); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - -#[test] -fn refresh_epoch_counter_persists_across_storage_reload() { - let _guard = lock_test_state(); - let state_path = configure_test_state_path("refresh_epoch_counter"); - reset_for_tests(); - - let first_result = refresh_shares(RefreshSharesRequest { - session_id: "session-persisted-refresh-1".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }) - .expect("first refresh"); - assert_eq!(first_result.refresh_epoch, 1); - - reload_state_from_storage_for_tests(); - - let second_result = refresh_shares(RefreshSharesRequest { - session_id: "session-persisted-refresh-2".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }], - }) - .expect("second refresh"); - assert_eq!(second_result.refresh_epoch, 2); - - reset_for_tests(); - cleanup_test_state_artifacts(&state_path); - clear_state_storage_policy_overrides(); -} - #[test] fn state_lock_path_is_bound_and_rejects_in_process_path_switch() { let _guard = lock_test_state(); @@ -5238,25 +4791,13 @@ fn state_lock_path_is_bound_and_rejects_in_process_path_switch() { cleanup_test_state_artifacts(&alternate_state_path); reset_for_tests(); - refresh_shares(RefreshSharesRequest { - session_id: "session-lock-path-initial".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }) - .expect("initial refresh"); + persist_state_for_key_provider_test("session-lock-path-initial") + .expect("initial state persist"); std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &alternate_state_path); - let err = refresh_shares(RefreshSharesRequest { - session_id: "session-lock-path-switch".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "bbbb".to_string(), - }], - }) - .expect_err("expected path switch rejection"); + let err = persist_state_for_key_provider_test("session-lock-path-switch") + .expect_err("expected path switch rejection"); let EngineError::Internal(message) = err else { panic!("unexpected error variant"); }; @@ -5308,16 +4849,6 @@ fn restart_reload_recovers_persisted_state_across_operation_types() { }; let build_result = build_taproot_tx(build_request.clone()).expect("build taproot tx"); - // Operation type 3: share refresh. - let refresh_request = RefreshSharesRequest { - session_id: "session-restart-refresh".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "abba".to_string(), - }], - }; - let refresh_result = refresh_shares(refresh_request.clone()).expect("refresh shares"); - simulate_process_restart_for_tests(); reload_state_from_storage_for_tests(); @@ -5325,7 +4856,6 @@ fn restart_reload_recovers_persisted_state_across_operation_types() { let guard = state().expect("engine state").lock().expect("engine lock"); assert!(guard.sessions.contains_key("session-restart-dkg")); assert!(guard.sessions.contains_key("session-restart-buildtx")); - assert!(guard.sessions.contains_key("session-restart-refresh")); } // The persisted DKG session survives the restart: a sibling seat @@ -5346,9 +4876,6 @@ fn restart_reload_recovers_persisted_state_across_operation_types() { let build_retry_result = build_taproot_tx(build_request).expect("retry build taproot tx"); assert_eq!(build_result, build_retry_result); - let refresh_retry_result = refresh_shares(refresh_request).expect("retry refresh shares"); - assert_eq!(refresh_result, refresh_retry_result); - // A brand-new operation on a fresh session works post-restart. let (new_public, new_key_packages) = sample_distributed_dkg_native_material(11); let new_session_result = @@ -5431,14 +4958,8 @@ fn persisted_state_file_uses_owner_only_permissions() { let state_path = configure_test_state_path("state_file_permissions"); reset_for_tests(); - refresh_shares(RefreshSharesRequest { - session_id: "session-state-file-permissions".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "aaaa".to_string(), - }], - }) - .expect("persist state via refresh"); + persist_state_for_key_provider_test("session-state-file-permissions") + .expect("persist signer state"); let mode = std::fs::metadata(&state_path) .expect("state file metadata") @@ -5906,7 +5427,7 @@ fn env_key_provider_is_rejected_in_production_profile() { TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, ); - let err = mutate_state_for_key_provider_test("session-production-rejects-env-provider") + let err = persist_state_for_key_provider_test("session-production-rejects-env-provider") .expect_err("production profile should reject env provider"); expect_internal_error_contains(err, "is not allowed in profile [production]"); @@ -5933,7 +5454,7 @@ fn production_profile_rejects_implicit_temp_state_path() { format!("printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), ); - let err = mutate_state_for_key_provider_test("session-production-rejects-implicit-state-path") + let err = persist_state_for_key_provider_test("session-production-rejects-implicit-state-path") .expect_err("production profile should reject implicit state path"); expect_internal_error_contains( err, @@ -5952,7 +5473,7 @@ fn unknown_state_key_provider_is_rejected() { std::env::set_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, "hsm"); - let err = mutate_state_for_key_provider_test("session-unknown-state-key-provider") + let err = persist_state_for_key_provider_test("session-unknown-state-key-provider") .expect_err("unsupported state key provider should fail closed"); expect_internal_error_contains(err, "unsupported state key provider"); @@ -5976,7 +5497,7 @@ fn command_key_provider_rejects_non_zero_exit() { std::env::set_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV, "exit 17"); let err = - mutate_state_for_key_provider_test("session-production-command-provider-non-zero-exit") + persist_state_for_key_provider_test("session-production-command-provider-non-zero-exit") .expect_err("non-zero command exit should fail closed"); expect_internal_error_contains(err, "exited with non-zero status"); @@ -6002,7 +5523,7 @@ fn command_key_provider_rejects_bad_output() { "printf 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\\n'", ); - let err = mutate_state_for_key_provider_test("session-production-command-provider-bad-output") + let err = persist_state_for_key_provider_test("session-production-command-provider-bad-output") .expect_err("bad command output should fail closed"); expect_internal_error_contains(err, "must be valid hex"); @@ -6032,7 +5553,7 @@ fn command_key_provider_drains_large_stderr_without_deadlock() { ), ); - mutate_state_for_key_provider_test("session-production-command-provider-large-stderr") + persist_state_for_key_provider_test("session-production-command-provider-large-stderr") .expect("large stderr from state key command should not deadlock"); reset_for_tests(); @@ -6058,7 +5579,7 @@ fn command_key_provider_times_out_fail_closed() { format!("sleep 2; printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), ); - let err = mutate_state_for_key_provider_test("session-production-command-provider-timeout") + let err = persist_state_for_key_provider_test("session-production-command-provider-timeout") .expect_err("state key command timeout should fail closed"); expect_internal_error_contains(err, "timed out"); @@ -6088,7 +5609,7 @@ fn command_key_provider_times_out_when_background_descendant_keeps_pipe_open() { let started_at = Instant::now(); let err = - mutate_state_for_key_provider_test("session-production-command-provider-background-pipe") + persist_state_for_key_provider_test("session-production-command-provider-background-pipe") .expect_err("state key command pipe timeout should fail closed"); assert!( started_at.elapsed() < Duration::from_secs(4), @@ -6118,7 +5639,7 @@ fn command_key_provider_survives_restart_with_stable_key() { format!("printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), ); - mutate_state_for_key_provider_test("session-production-command-provider") + persist_state_for_key_provider_test("session-production-command-provider") .expect("seed encrypted state with command provider"); simulate_process_restart_for_tests(); diff --git a/pkg/tbtc/signer/src/errors.rs b/pkg/tbtc/signer/src/errors.rs index a9006545e9..e82e333162 100644 --- a/pkg/tbtc/signer/src/errors.rs +++ b/pkg/tbtc/signer/src/errors.rs @@ -30,6 +30,10 @@ pub enum EngineError { reason_code: String, detail: String, }, + #[error( + "cryptographic share refresh is not supported for session {session_id}: a multi-round, zero-constant FROST refresh protocol is required" + )] + CryptographicRefreshNotSupported { session_id: String }, #[error("session conflict for {session_id}: repeated call must use identical payload")] SessionConflict { session_id: String }, #[error("session finalized for {session_id}: start_sign_round requires a new session_id")] @@ -100,6 +104,7 @@ impl EngineError { Self::SigningPolicyRejected { .. } => "signing_policy_rejected", Self::QuarantinePolicyRejected { .. } => "quarantine_policy_rejected", Self::LifecyclePolicyRejected { .. } => "lifecycle_policy_rejected", + Self::CryptographicRefreshNotSupported { .. } => "cryptographic_refresh_not_supported", Self::SessionConflict { .. } => "session_conflict", Self::SessionFinalized { .. } => "session_finalized", Self::SessionNotFound { .. } => "session_not_found", @@ -122,6 +127,7 @@ impl EngineError { Self::SigningPolicyRejected { .. } => "recoverable", Self::QuarantinePolicyRejected { .. } => "recoverable", Self::LifecyclePolicyRejected { .. } => "recoverable", + Self::CryptographicRefreshNotSupported { .. } => "terminal", Self::SessionConflict { .. } => "recoverable", Self::DkgNotReady { .. } => "recoverable", Self::SignRoundNotStarted { .. } => "recoverable", @@ -218,6 +224,17 @@ mod tests { EngineError::Internal("panic".to_string()).recovery_class(), "terminal" ); + let unsupported_refresh = EngineError::CryptographicRefreshNotSupported { + session_id: "session-refresh".to_string(), + }; + assert_eq!( + unsupported_refresh.code(), + "cryptographic_refresh_not_supported" + ); + assert_eq!(unsupported_refresh.recovery_class(), "terminal"); + assert!(unsupported_refresh + .to_string() + .contains("multi-round, zero-constant FROST refresh protocol")); } #[test] diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index 821b9db3c5..fdb7ded39d 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -37,16 +37,17 @@ const TBTC_SIGNER_VERSION: &str = "tbtc-signer/0.1.0-bootstrap"; // and results carry the ordered BIP-341 key-spend SIGHASH_DEFAULT messages. The // required request field is an incompatible wire-contract change, so bridges and // the signer library must move from major 2 to major 3 in lockstep. -const TBTC_SIGNER_ABI_MAJOR: u32 = 3; -// Minor 1 adds an optional, narrowly typed heartbeat intent to Interactive Open. -// ABI-3.0 callers remain valid because an absent intent preserves transaction-only -// signing-policy behavior. -// Minor 2 adds the optional heartbeat rate-limit config field and a dedicated -// heartbeat policy-rejection metric. Older callers safely omit/ignore both. -// Minor 3 adds optional canary-evidence configuration, including an independent -// policy-evidence sample minimum. Every field is optional and defaults fail -// closed; an absent policy minimum retains the interactive minimum. -const TBTC_SIGNER_ABI_MINOR: u32 = 3; +// Major 4: RefreshShares no longer returns synthetic replacement material for a +// valid request. It fails closed with a terminal +// cryptographic_refresh_not_supported error until a real multi-round protocol +// exists. Changing status_code from success to error and replacing the response +// JSON meaning is incompatible, so ABI-3 bridges must reject the library during +// negotiation rather than discovering the change at refresh time. +const TBTC_SIGNER_ABI_MAJOR: u32 = 4; +// Major bumps reset the additive minor version. ABI 3.1-3.3 introduced the typed +// heartbeat intent, its rate-limit configuration/metric, and optional canary +// evidence configuration; all remain present in ABI 4.0. +const TBTC_SIGNER_ABI_MINOR: u32 = 0; #[cfg(test)] use engine::TBTC_SIGNER_PROFILE_ENV; @@ -398,7 +399,7 @@ mod tests { DkgPart3Request, DkgPart3Result, DkgRound1Package, DkgRound2Package, ErrorResponse, FrostTbtcAbiVersionResult, PromoteCanaryRequest, QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult, - RefreshSharesRequest, RoastLivenessPolicyResult, RollbackCanaryRequest, ShareMaterial, + RefreshSharesRequest, RoastLivenessPolicyResult, RollbackCanaryRequest, SignerHardeningMetricsResult, TransactionResult, TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, }; @@ -759,14 +760,11 @@ mod tests { serde_json::from_slice(&payload).expect("abi version payload decode"); // The enforced FFI contract starts at 1.0; bump deliberately per the // TBTC_SIGNER_ABI_MAJOR / TBTC_SIGNER_ABI_MINOR rules. This test pins the - // current value so an accidental bump is caught. BuildTaprootTx now requires - // prevout scripts and returns BIP-341 SIGHASH_DEFAULT messages; the - // incompatible request shape is ABI 3. Optional typed heartbeat intent is - // the first backward-compatible minor addition; its independent rate-limit - // config and rejection metric are the second. Canary evidence configuration, - // including its independent policy-sample minimum, is the third. - assert_eq!(abi.abi_major, 3); - assert_eq!(abi.abi_minor, 3); + // current value so an accidental bump is caught. ABI 4 changes a valid + // RefreshShares call from a synthetic success response to a terminal error, + // forcing ABI-3 consumers to fail closed during compatibility negotiation. + assert_eq!(abi.abi_major, 4); + assert_eq!(abi.abi_minor, 0); } #[test] @@ -1364,70 +1362,30 @@ mod tests { } #[test] - fn refresh_shares_is_idempotent() { + fn refresh_shares_rejects_without_returning_synthetic_material() { let _guard = crate::engine::lock_test_state(); crate::engine::reset_for_tests(); let request = RefreshSharesRequest { session_id: "session-refresh".to_string(), - current_shares: vec![ - ShareMaterial { - identifier: 1, - encrypted_share_hex: "abcd".to_string(), - }, - ShareMaterial { - identifier: 2, - encrypted_share_hex: "ef01".to_string(), - }, - ], - }; - - let (status_first, payload_first) = call_ffi(&request, frost_tbtc_refresh_shares); - let (status_second, payload_second) = call_ffi(&request, frost_tbtc_refresh_shares); - - assert_eq!(status_first, 0); - assert_eq!(status_second, 0); - assert_eq!(payload_first, payload_second); - } - - #[test] - fn refresh_shares_uses_monotonic_epoch_counter() { - let _guard = crate::engine::lock_test_state(); - crate::engine::reset_for_tests(); - - let request_first = RefreshSharesRequest { - session_id: "session-refresh-epoch-1".to_string(), - current_shares: vec![ShareMaterial { + current_shares: vec![crate::api::ShareMaterial { identifier: 1, - encrypted_share_hex: "1111".to_string(), + encrypted_share_hex: "abcd".to_string(), }], }; - let request_second = RefreshSharesRequest { - session_id: "session-refresh-epoch-2".to_string(), - current_shares: vec![ShareMaterial { - identifier: 1, - encrypted_share_hex: "2222".to_string(), - }], - }; - - let (status_first, payload_first) = call_ffi(&request_first, frost_tbtc_refresh_shares); - let (status_first_retry, payload_first_retry) = - call_ffi(&request_first, frost_tbtc_refresh_shares); - let (status_second, payload_second) = call_ffi(&request_second, frost_tbtc_refresh_shares); - - assert_eq!(status_first, 0); - assert_eq!(status_first_retry, 0); - assert_eq!(payload_first, payload_first_retry); - assert_eq!(status_second, 0); - - let first_result: crate::api::RefreshSharesResult = - serde_json::from_slice(&payload_first).expect("first refresh payload decode"); - let second_result: crate::api::RefreshSharesResult = - serde_json::from_slice(&payload_second).expect("second refresh payload decode"); + let (status, payload) = call_ffi(&request, frost_tbtc_refresh_shares); + assert_eq!(status, 1); - assert_eq!(first_result.refresh_epoch, 1); - assert_eq!(second_result.refresh_epoch, 2); + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "cryptographic_refresh_not_supported"); + assert_eq!(error.recovery_class, "terminal"); + assert!(error + .message + .contains("cryptographic share refresh is not supported")); + assert!(error + .message + .contains("zero-constant FROST refresh protocol")); } #[test]