diff --git a/pkg/tbtc/signer/benches/phase5_roast.rs b/pkg/tbtc/signer/benches/phase5_roast.rs index 68255da742..af6e93e390 100644 --- a/pkg/tbtc/signer/benches/phase5_roast.rs +++ b/pkg/tbtc/signer/benches/phase5_roast.rs @@ -282,6 +282,18 @@ fn ensure_benchmark_environment() { std::env::set_var("TBTC_SIGNER_MAX_SESSIONS", "200000"); std::env::set_var("TBTC_SIGNER_ALLOW_BOOTSTRAP", "true"); std::env::set_var("TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK", "true"); + // The signer treats a missing profile as production, and the default + // `env` state-key provider requires an encryption key for persistence. + // Seed both (and pin the provider) so the README-documented + // `cargo bench --features bench-restart-hook --bench phase5_roast` + // runs in a clean shell without any pre-set TBTC_SIGNER_* variables; + // otherwise the first RunDkg persist fails. + std::env::set_var("TBTC_SIGNER_PROFILE", "development"); + std::env::set_var("TBTC_SIGNER_STATE_KEY_PROVIDER", "env"); + std::env::set_var( + "TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX", + "0c9258935f0a30c065befcd746cb1564e9f3c91936c0f0f1c78853fa2d6713dc", + ); }); } diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index 7e59bf32ff..0336247e70 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct DkgParticipant { @@ -129,16 +130,31 @@ pub struct NewSigningPackageResult { pub signing_package_hex: String, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Clone, Deserialize, PartialEq, Eq, Serialize)] pub struct SignShareRequest { pub signing_package_hex: String, /// Secret one-time nonces returned by `GenerateNoncesAndCommitmentsResult`. /// /// This stateless endpoint cannot remember consumed nonces across FFI /// calls. The caller is cryptographically responsible for single use. - pub nonces_hex: String, + /// Wrapped in `Zeroizing` so the deserialized secret is wiped from the heap + /// on drop rather than lingering in freed memory after the share is produced. + pub nonces_hex: Zeroizing, pub key_package_identifier: String, - pub key_package_hex: String, + /// Secret private key-package material; `Zeroizing` so it is wiped on drop. + pub key_package_hex: Zeroizing, +} + +// Custom Debug redacts the secret fields (the derive would print them verbatim). +impl std::fmt::Debug for SignShareRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SignShareRequest") + .field("signing_package_hex", &self.signing_package_hex) + .field("nonces_hex", &"") + .field("key_package_identifier", &self.key_package_identifier) + .field("key_package_hex", &"") + .finish() + } } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -799,6 +815,8 @@ pub struct InitSignerConfigRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub state_corrupt_backup_limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub permit_plaintext_state_rollback: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_sessions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_live_interactive_sessions: Option, diff --git a/pkg/tbtc/signer/src/engine/config.rs b/pkg/tbtc/signer/src/engine/config.rs index 79737296d9..65e6ac87ba 100644 --- a/pkg/tbtc/signer/src/engine/config.rs +++ b/pkg/tbtc/signer/src/engine/config.rs @@ -123,6 +123,9 @@ pub(crate) const TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV: &str = pub(crate) const TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV: &str = "TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL"; +pub(crate) const TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV: &str = + "TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK"; + pub(crate) const TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV: &str = "TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES"; diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs index d20b6f42c0..36d193c2ef 100644 --- a/pkg/tbtc/signer/src/engine/init_config.rs +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -241,6 +241,14 @@ pub(crate) fn config_values_from_request( TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, request.enforce_signing_policy_firewall, ); + // Make the emergency plaintext-state rollback opt-in reachable for hosts that + // configure via init-time config (where signer_env_var reads the installed + // config, not the process environment), not just raw env. + insert_bool( + &mut values, + TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, + request.permit_plaintext_state_rollback, + ); insert_bool( &mut values, TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, diff --git a/pkg/tbtc/signer/src/engine/lifecycle.rs b/pkg/tbtc/signer/src/engine/lifecycle.rs index 85083fba6d..6be554fea0 100644 --- a/pkg/tbtc/signer/src/engine/lifecycle.rs +++ b/pkg/tbtc/signer/src/engine/lifecycle.rs @@ -2,6 +2,12 @@ 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; + pub(crate) fn canary_max_start_sign_round_p95_ms() -> u64 { signer_env_var(TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV) .and_then(|value| value.trim().parse::().ok()) @@ -103,7 +109,7 @@ pub fn refresh_cadence_status( Ok(RefreshCadenceStatusResult { session_id: request.session_id, - refresh_count: session.refresh_history.len() as u64, + refresh_count: session.refresh_count, last_refresh_epoch: last_refresh_record .map(|record| record.refresh_epoch) .unwrap_or(0), @@ -393,15 +399,27 @@ pub fn refresh_shares(request: RefreshSharesRequest) -> Result Result= 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); + } persist_engine_state_to_storage(&guard)?; record_hardening_telemetry(|telemetry| { telemetry.refresh_shares_success_total = diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index 667521df74..bfc352591b 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -51,6 +51,8 @@ pub(crate) struct PersistedSessionState { pub(crate) refresh_result: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) refresh_history: Vec, + #[serde(default)] + pub(crate) refresh_count: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) emergency_rekey_event: Option, // Phase 7.1 interactive consumption markers - the ONLY durable @@ -882,6 +884,25 @@ pub(crate) fn decode_encrypted_state_envelope( .map_err(|e| EngineError::Internal(format!("failed to decode decrypted signer state: {e}"))) } +/// Whether the legacy unencrypted plaintext state path may be accepted. +/// +/// Plaintext state is UNAUTHENTICATED, so accepting it would let anyone who can +/// write the state file forge it (cleared replay markers, attacker key material) +/// without holding the state-encryption key. Per the secret-material hardening +/// plan this is an emergency-rollback-only path. The load-bearing guard is the +/// runtime non-production check (a production profile NEVER accepts plaintext, +/// regardless of build); it is additionally gated off in optimized builds via +/// `debug_assertions` and behind an explicit opt-in env flag. (Note: a release +/// build compiled with `debug-assertions = on` would still require both the +/// non-production profile and the opt-in flag.) +fn legacy_plaintext_state_permitted() -> bool { + cfg!(debug_assertions) + && !signer_profile_is_production() + && signer_env_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + pub(crate) fn decode_persisted_state_storage_format( bytes: &[u8], ) -> Result { @@ -895,6 +916,21 @@ pub(crate) fn decode_persisted_state_storage_format( }); } + // The bytes are not an encrypted envelope. Only fall back to the legacy + // UNAUTHENTICATED plaintext format on the gated emergency-rollback path; + // otherwise refuse, so an attacker who can write the state file cannot + // bypass the AEAD envelope (forged replay markers / key material) without + // the state-encryption key. + if !legacy_plaintext_state_permitted() { + return Err(EngineError::Internal( + "refusing to load unauthenticated plaintext signer state; an \ + encrypted state envelope is required (legacy plaintext is an \ + emergency-rollback-only path, disabled in production and release \ + builds)" + .to_string(), + )); + } + let persisted = serde_json::from_slice::(bytes).map_err(|e| { EngineError::Internal(format!("failed to decode signer state file payload: {e}")) })?; @@ -1442,6 +1478,13 @@ 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. + refresh_count: persisted + .refresh_count + .max(persisted.refresh_history.len() as u64), refresh_history: persisted.refresh_history, emergency_rekey_event: persisted.emergency_rekey_event, // Live interactive state never restores: nonces are gone by @@ -1588,6 +1631,7 @@ impl TryFrom<&SessionState> for PersistedSessionState { refresh_request_fingerprint: session_state.refresh_request_fingerprint.clone(), refresh_result: session_state.refresh_result.clone(), refresh_history: session_state.refresh_history.clone(), + refresh_count: session_state.refresh_count, emergency_rekey_event: session_state.emergency_rekey_event.clone(), consumed_interactive_attempt_markers, aggregated_interactive_attempt_markers, diff --git a/pkg/tbtc/signer/src/engine/signing.rs b/pkg/tbtc/signer/src/engine/signing.rs index 631fac28c1..67d053497a 100644 --- a/pkg/tbtc/signer/src/engine/signing.rs +++ b/pkg/tbtc/signer/src/engine/signing.rs @@ -23,6 +23,18 @@ pub fn start_sign_round(mut request: StartSignRoundRequest) -> Result Result = + if original_message_hex != request.message_hex { + let mut mixed_case_request = request.clone(); + mixed_case_request.message_hex = original_message_hex; + vec![ + start_sign_round_request_fingerprint(&mixed_case_request, 0)?, + start_sign_round_request_fingerprint( + &mixed_case_request, + mixed_case_request.member_identifier, + )?, + start_sign_round_request_fingerprint_including_transition_evidence( + &mixed_case_request, + 0, + )?, + start_sign_round_request_fingerprint_including_transition_evidence( + &mixed_case_request, + mixed_case_request.member_identifier, + )?, + ] + } else { + Vec::new() + }; let mut guard = state()? .lock() .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; @@ -175,7 +216,10 @@ pub fn start_sign_round(mut request: StartSignRoundRequest) -> Result Result, 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. + pub(crate) refresh_count: u64, pub(crate) emergency_rekey_event: Option, // Multi-seat: a process-global engine may hold several LOCAL members (seats) // signing the same session concurrently, each on its own attempt timeline. @@ -141,6 +145,11 @@ 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) request_fingerprint: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 40d4b13780..2f0522ee47 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -483,9 +483,10 @@ fn dkg_part3_normalizes_odd_y_group_key_and_secret_shares() { signing_package_hex: signing_package.signing_package_hex.clone(), nonces_hex: nonces_by_participant .remove(&id) - .expect("participant nonces"), + .expect("participant nonces") + .into(), key_package_identifier: part3_results[&id].key_package.identifier.clone(), - key_package_hex: part3_results[&id].key_package.data_hex.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone().into(), }) .expect("sign share"); signature_shares.push(result.signature_share); @@ -700,6 +701,7 @@ fn persisted_session_state_fixture() -> PersistedSessionState { refresh_request_fingerprint: None, refresh_result: None, refresh_history: vec![], + refresh_count: 0, emergency_rekey_event: None, consumed_interactive_attempt_markers: vec![], aggregated_interactive_attempt_markers: vec![], @@ -7555,6 +7557,96 @@ fn sign_round_and_finalize_idempotency_persist_across_storage_reload() { clear_state_storage_policy_overrides(); } +#[test] +fn start_sign_round_accepts_persisted_legacy_mixed_case_message_fingerprint() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_legacy_mixed_case_message_fingerprint"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-legacy-mixed-case-message".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + // Caller sends non-lowercase message_hex, as a pre-canonicalization build + // would have. hex::decode accepts it; start_sign_round lowercases it + // internally before fingerprinting. + let start_request = StartSignRoundRequest { + session_id: "session-legacy-mixed-case-message".to_string(), + member_identifier: 1, + message_hex: "BADDCAFE".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let first_round_state = start_sign_round(start_request.clone()).expect("start sign round"); + + // The canonical fingerprint the current build stores is computed over the + // lowercased message_hex; the fingerprint a pre-canonicalization build + // persisted is computed over the original mixed casing. They must differ. + let mut lowercase_request = start_request.clone(); + lowercase_request.message_hex = start_request.message_hex.to_ascii_lowercase(); + let canonical_fingerprint = + start_sign_round_request_fingerprint(&lowercase_request, 0).expect("canonical fingerprint"); + let legacy_mixed_case_fingerprint = + start_sign_round_request_fingerprint(&start_request, 0).expect("mixed-case fingerprint"); + assert_ne!(canonical_fingerprint, legacy_mixed_case_fingerprint); + + // Simulate the persisted state of a pre-canonicalization build: the active + // round's fingerprint was stored over the original mixed casing. + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut(&start_request.session_id) + .expect("session state"); + assert_eq!( + session.sign_request_fingerprint.as_deref(), + Some(canonical_fingerprint.as_str()) + ); + session.sign_request_fingerprint = Some(legacy_mixed_case_fingerprint.clone()); + persist_engine_state_to_storage(&guard).expect("persist legacy mixed-case fingerprint"); + } + + // The same retry (still mixed casing) must reuse the cached round instead of + // failing SessionConflict. + reload_state_from_storage_for_tests(); + let retry_round_state = + start_sign_round(start_request.clone()).expect("legacy mixed-case fingerprint retry"); + assert_eq!(first_round_state, retry_round_state); + + // Reuse migrates the stored fingerprint to the canonical lowercase form. + reload_state_from_storage_for_tests(); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + assert_eq!( + session.sign_request_fingerprint.as_deref(), + Some(canonical_fingerprint.as_str()) + ); + } + + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + #[test] fn start_sign_round_accepts_persisted_legacy_member_bound_fingerprint() { let _guard = lock_test_state(); @@ -9436,6 +9528,298 @@ fn finalize_sign_round_rejects_materially_different_retry_after_canonicalization assert!(matches!(err, EngineError::SessionConflict { .. })); } +#[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(); @@ -10099,6 +10483,10 @@ fn truncated_state_file_quarantines_and_resets_when_enabled() { clear_state_storage_policy_overrides(); } +// The plaintext-acceptance path is debug-only (legacy_plaintext_state_permitted +// gates on cfg!(debug_assertions)), so this rollback-path test is too; in a +// release build the bytes are always refused before schema validation is reached. +#[cfg(debug_assertions)] #[test] fn schema_mismatch_state_file_fails_closed_by_default() { let _guard = lock_test_state(); @@ -10121,10 +10509,14 @@ fn schema_mismatch_state_file_fails_closed_by_default() { let persisted_bytes = serde_json::to_vec(&persisted).expect("encode mismatched schema"); std::fs::write(&state_path, &persisted_bytes).expect("write mismatched schema state file"); + // Schema validation runs only after the plaintext gate, so opt into the + // legacy plaintext rollback path (development profile + flag) to reach it. + std::env::set_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, "true"); let err = match load_engine_state_from_storage() { Ok(_) => panic!("expected schema mismatch failure"), Err(err) => err, }; + std::env::remove_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV); assert!(matches!(err, EngineError::Internal(_))); let err_message = err.to_string(); @@ -10138,6 +10530,7 @@ fn schema_mismatch_state_file_fails_closed_by_default() { clear_state_storage_policy_overrides(); } +#[cfg(debug_assertions)] // plaintext rollback path is debug-only; see legacy_plaintext_state_permitted #[test] fn schema_mismatch_state_file_quarantines_and_resets_when_enabled() { let _guard = lock_test_state(); @@ -10165,7 +10558,12 @@ fn schema_mismatch_state_file_quarantines_and_resets_when_enabled() { let persisted_bytes = serde_json::to_vec(&persisted).expect("encode mismatched schema"); std::fs::write(&state_path, &persisted_bytes).expect("write mismatched schema state file"); + // Reach schema validation (the test's intent) by opting into the plaintext + // rollback path; otherwise the plaintext gate refuses the bytes before the + // schema check and the quarantine-and-reset would fire for the wrong reason. + std::env::set_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, "true"); let loaded = load_engine_state_from_storage().expect("recover from schema mismatch state"); + std::env::remove_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV); assert!(loaded.sessions.is_empty()); assert_eq!(loaded.refresh_epoch_counter, 0); assert!(!state_path.exists()); @@ -10260,6 +10658,7 @@ fn persisted_state_is_encrypted_envelope() { clear_state_storage_policy_overrides(); } +#[cfg(debug_assertions)] // plaintext rollback path is debug-only; see legacy_plaintext_state_permitted #[test] fn legacy_plaintext_state_migrates_to_encrypted_envelope_on_load() { let _guard = lock_test_state(); @@ -10282,7 +10681,18 @@ fn legacy_plaintext_state_migrates_to_encrypted_envelope_on_load() { let plaintext_bytes = serde_json::to_vec(&plaintext_state).expect("encode plaintext state"); std::fs::write(&state_path, &plaintext_bytes).expect("write plaintext state file"); + // Without the opt-in rollback flag the unauthenticated plaintext is refused + // (fail-closed), even in a non-production profile. + assert!( + load_engine_state_from_storage().is_err(), + "plaintext signer state must be rejected without the rollback opt-in" + ); + + // Plaintext load is an opt-in emergency-rollback path: development profile + // (selected by reset_for_tests) + this flag. + std::env::set_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, "true"); let loaded = load_engine_state_from_storage().expect("load and migrate legacy plaintext"); + std::env::remove_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV); assert_eq!(loaded.sessions.len(), 1); assert_eq!(loaded.refresh_epoch_counter, 7); @@ -10990,6 +11400,7 @@ fn init_signer_config_canonicalizes_list_and_bool_encodings() { enable_auto_quarantine: Some(false), auto_quarantine_dao_allowlist_identifiers: Some(vec![3, 1, 2, 2]), policy_allowed_script_classes: Some(vec!["P2TR".to_string(), "p2wpkh".to_string()]), + permit_plaintext_state_rollback: Some(true), ..InitSignerConfigRequest::default() }) .expect("convert request"); @@ -11014,6 +11425,14 @@ fn init_signer_config_canonicalizes_list_and_bool_encodings() { .map(String::as_str), Some("P2TR,p2wpkh") ); + // The plaintext rollback opt-in is reachable via init-time config, not only + // the process environment. + assert_eq!( + values + .get(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV) + .map(String::as_str), + Some("true") + ); let empty_list = config_values_from_request(&InitSignerConfigRequest { admission_required_identifiers: Some(Vec::new()), @@ -11536,9 +11955,9 @@ fn interactive_session_full_round_trip_aggregates_bip340() { let member2_share = sign_share(SignShareRequest { signing_package_hex: signing_package_hex.clone(), - nonces_hex: member2.nonces_hex, + nonces_hex: member2.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("member 2 stateless share"); @@ -12152,16 +12571,16 @@ fn interactive_aggregate_cleanup_is_message_bound() { ); let share1_b = sign_share(SignShareRequest { signing_package_hex: package_b.clone(), - nonces_hex: m1_b.nonces_hex, + nonces_hex: m1_b.nonces_hex.into(), key_package_identifier: key_packages[&1].identifier.clone(), - key_package_hex: key_packages[&1].data_hex.clone(), + key_package_hex: key_packages[&1].data_hex.clone().into(), }) .expect("stateless share 1 over B"); let share2_b = sign_share(SignShareRequest { signing_package_hex: package_b.clone(), - nonces_hex: m2_b.nonces_hex, + nonces_hex: m2_b.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("stateless share 2 over B"); interactive_aggregate(InteractiveAggregateRequest { @@ -13576,9 +13995,9 @@ fn interactive_aggregate_produces_and_self_verifies_bip340() { .expect("round 2 share"); let member2_share = sign_share(SignShareRequest { signing_package_hex: signing_package_hex.clone(), - nonces_hex: member2.nonces_hex, + nonces_hex: member2.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("member 2 share"); @@ -13662,9 +14081,9 @@ fn interactive_aggregate_rejects_repeat_aggregate_of_completed_attempt() { .expect("round 2 share"); let member2_share = sign_share(SignShareRequest { signing_package_hex: signing_package_hex.clone(), - nonces_hex: member2.nonces_hex, + nonces_hex: member2.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("member 2 share"); @@ -13771,9 +14190,9 @@ fn interactive_aggregate_completion_marker_survives_process_restart() { .expect("round 2 share"); let member2_share = sign_share(SignShareRequest { signing_package_hex: signing_package_hex.clone(), - nonces_hex: member2.nonces_hex, + nonces_hex: member2.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("member 2 share"); @@ -13878,9 +14297,9 @@ fn interactive_aggregate_rejects_invalid_share_fail_closed() { ); let bogus_share = sign_share(SignShareRequest { signing_package_hex: other_package, - nonces_hex: bogus_member2.nonces_hex, + nonces_hex: bogus_member2.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("bogus member 2 share"); @@ -13985,9 +14404,9 @@ fn interactive_aggregate_names_all_invalid_share_culprits() { ); let bogus1_share = sign_share(SignShareRequest { signing_package_hex: bogus1_package, - nonces_hex: bogus1.nonces_hex, + nonces_hex: bogus1.nonces_hex.into(), key_package_identifier: key_packages[&1].identifier.clone(), - key_package_hex: key_packages[&1].data_hex.clone(), + key_package_hex: key_packages[&1].data_hex.clone().into(), }) .expect("bogus member 1 share"); let bogus2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { @@ -14010,9 +14429,9 @@ fn interactive_aggregate_names_all_invalid_share_culprits() { ); let bogus2_share = sign_share(SignShareRequest { signing_package_hex: bogus2_package, - nonces_hex: bogus2.nonces_hex, + nonces_hex: bogus2.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("bogus member 2 share"); @@ -14105,9 +14524,9 @@ fn interactive_aggregate_sweeps_expired_sessions() { ); let parseable_share = sign_share(SignShareRequest { signing_package_hex: parseable_package.clone(), - nonces_hex: member1.nonces_hex, + nonces_hex: member1.nonces_hex.into(), key_package_identifier: key_packages[&1].identifier.clone(), - key_package_hex: key_packages[&1].data_hex.clone(), + key_package_hex: key_packages[&1].data_hex.clone().into(), }) .expect("member 1 share"); @@ -14401,9 +14820,9 @@ fn verify_signature_share_verdicts_match_aggregate_and_handle_edges() { .expect("round 2 share"); let member2_valid = sign_share(SignShareRequest { signing_package_hex: signing_package_hex.clone(), - nonces_hex: member2_nonces, + nonces_hex: member2_nonces.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("member 2 valid share"); @@ -14429,9 +14848,9 @@ fn verify_signature_share_verdicts_match_aggregate_and_handle_edges() { ); let bogus_share = sign_share(SignShareRequest { signing_package_hex: other_package, - nonces_hex: bogus_member2.nonces_hex, + nonces_hex: bogus_member2.nonces_hex.into(), key_package_identifier: key_packages[&2].identifier.clone(), - key_package_hex: key_packages[&2].data_hex.clone(), + key_package_hex: key_packages[&2].data_hex.clone().into(), }) .expect("bogus member 2 share"); diff --git a/pkg/tbtc/signer/src/ffi.rs b/pkg/tbtc/signer/src/ffi.rs index 4715f05dce..ac9567d078 100644 --- a/pkg/tbtc/signer/src/ffi.rs +++ b/pkg/tbtc/signer/src/ffi.rs @@ -2,6 +2,8 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use serde::de::DeserializeOwned; +use zeroize::Zeroize; + use crate::api::ErrorResponse; use crate::errors::EngineError; @@ -43,10 +45,42 @@ pub fn serialize_response(response: &T) -> Result, .map_err(|e| EngineError::Internal(format!("failed to encode response: {e}"))) } +// Install a panic hook that redacts the panic payload outside the development +// profile. `catch_unwind` does not suppress Rust's default hook, so a panic +// carrying a path / config value / secret would otherwise print verbatim to +// stderr before it is converted to a redacted ErrorResponse. Installed once. +fn install_redacting_panic_hook() { + static INSTALLED: std::sync::Once = std::sync::Once::new(); + INSTALLED.call_once(|| { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let development_profile = + crate::engine::signer_env_var(crate::engine::TBTC_SIGNER_PROFILE_ENV) + .map(|raw| { + raw.trim() + .eq_ignore_ascii_case(crate::engine::TBTC_SIGNER_PROFILE_DEVELOPMENT) + }) + .unwrap_or(false); + if development_profile { + default_hook(info); + } else if let Some(location) = info.location() { + eprintln!( + "panic at {}:{} (payload redacted)", + location.file(), + location.line() + ); + } else { + eprintln!("panic (payload redacted)"); + } + })); + }); +} + pub fn ffi_entry(f: F) -> TbtcSignerResult where F: FnOnce() -> Result, EngineError>, { + install_redacting_panic_hook(); match catch_unwind(AssertUnwindSafe(f)) { Ok(Ok(bytes)) => success_from_serialized(bytes), Ok(Err(err)) => error_result(err), @@ -88,7 +122,13 @@ pub fn free_buffer(ptr: *mut u8, len: usize) { } unsafe { - drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len))); + let mut buffer = Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)); + // Wipe any plaintext secret material (e.g. FROST nonces, DKG/key-package + // bytes) before deallocation rather than trusting every FFI caller to do + // it correctly. Leaking a nonce after a share is produced can expose the + // signing share. `zeroize` is a volatile wipe the optimizer cannot elide. + buffer.zeroize(); + drop(buffer); } } @@ -138,7 +178,7 @@ fn request_bytes<'a>(ptr: *const u8, len: usize) -> Result<&'a [u8], EngineError unsafe { Ok(std::slice::from_raw_parts(ptr, len)) } } -fn to_ffi_buffer(bytes: Vec) -> TbtcBuffer { +fn to_ffi_buffer(mut bytes: Vec) -> TbtcBuffer { let len = bytes.len(); if len == 0 { return TbtcBuffer { @@ -147,7 +187,13 @@ fn to_ffi_buffer(bytes: Vec) -> TbtcBuffer { }; } - let boxed = bytes.into_boxed_slice(); + // Copy into an exact-capacity boxed slice, then wipe the source Vec. + // `bytes.into_boxed_slice()` shrink-to-fits and reallocates when capacity > len + // (serde_json::to_vec over-allocates secret-bearing JSON), which would free the + // original secret buffer WITHOUT zeroizing it. free_buffer wipes the boxed + // slice on free; we wipe the source here so no un-zeroized copy survives. + let boxed: Box<[u8]> = bytes.as_slice().into(); + bytes.zeroize(); let ptr = Box::into_raw(boxed) as *mut u8; TbtcBuffer { ptr, len } diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index 170f948085..4bae4def89 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -1063,9 +1063,9 @@ mod tests { for id in signing_participants { let request = SignShareRequest { signing_package_hex: signing_package.signing_package_hex.clone(), - nonces_hex: nonces_by_participant[&id].clone(), + nonces_hex: nonces_by_participant[&id].clone().into(), key_package_identifier: part3_results[&id].key_package.identifier.clone(), - key_package_hex: part3_results[&id].key_package.data_hex.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone().into(), }; let (status, payload) = call_ffi(&request, frost_tbtc_sign_share); assert_eq!(status, 0);