diff --git a/keep-cli/src/cli.rs b/keep-cli/src/cli.rs index 439a21e9..20b39ed1 100644 --- a/keep-cli/src/cli.rs +++ b/keep-cli/src/cli.rs @@ -203,6 +203,8 @@ pub(crate) enum WalletCommands { required = true )] recovery: Vec, + #[arg(long, help = "Session timeout in seconds (max 86400)")] + timeout: Option, }, } @@ -374,6 +376,16 @@ pub(crate) enum FrostNetworkCommands { )] count: u32, }, + HealthCheck { + #[arg(short, long)] + group: String, + #[arg(short, long)] + relay: Option, + #[arg(short, long)] + share: Option, + #[arg(long, default_value = "10", help = "Timeout in seconds")] + timeout: u64, + }, } #[derive(Subcommand)] diff --git a/keep-cli/src/commands/frost_network/mod.rs b/keep-cli/src/commands/frost_network/mod.rs index f77a7174..d470eb7e 100644 --- a/keep-cli/src/commands/frost_network/mod.rs +++ b/keep-cli/src/commands/frost_network/mod.rs @@ -70,7 +70,8 @@ pub fn cmd_frost_network_serve( .map_err(|e| KeepError::Frost(e.to_string()))?, ); - let npub = node.pubkey().to_bech32().unwrap_or_default(); + let pk = node.pubkey(); + let npub = pk.to_bech32().unwrap_or_else(|_| format!("{pk}")); out.field("Node pubkey", &npub); out.newline(); out.info("Listening for FROST messages... (Ctrl+C to stop)"); @@ -123,8 +124,12 @@ pub fn cmd_frost_network_serve( move || node.derive_account_xpub(&net) }) .await; - match derived { - Ok(Ok((xpub, fingerprint))) => { + let xpub_result = match derived { + Ok(inner) => inner, + Err(e) => Err(keep_frost_net::FrostNetError::Crypto(e.to_string())), + }; + match xpub_result { + Ok((xpub, fingerprint)) => { if let Err(e) = contribute_node .contribute_descriptor( session_id, @@ -137,9 +142,6 @@ pub fn cmd_frost_network_serve( tracing::error!(session, error = %e, "failed to contribute descriptor"); } } - Ok(Err(e)) => { - tracing::error!(session, error = %e, "failed to derive xpub for contribution"); - } Err(e) => { tracing::error!(session, error = %e, "failed to derive xpub for contribution"); } @@ -172,7 +174,7 @@ pub fn cmd_frost_network_serve( network, created_at: now, }; - let guard = keep.lock().unwrap_or_else(|e| e.into_inner()); + let guard = keep.lock().expect("keep mutex poisoned"); match guard.store_wallet_descriptor(&descriptor) { Ok(()) => { tracing::info!("wallet descriptor stored"); @@ -405,9 +407,10 @@ pub fn cmd_frost_network_sign( .map_err(|e| KeepError::Frost(e.to_string()))?; out.info("Starting FROST coordination node..."); + let pk = node.pubkey(); out.field( "Node pubkey", - &node.pubkey().to_bech32().unwrap_or_default(), + &pk.to_bech32().unwrap_or_else(|_| format!("{pk}")), ); out.newline(); @@ -473,3 +476,176 @@ pub fn cmd_frost_network_sign_event( "FROST network event signing".into(), )) } + +#[tracing::instrument(skip(out), fields(path = %path.display()))] +pub fn cmd_frost_network_health_check( + out: &Output, + path: &Path, + group: &str, + relay: &str, + share_index: Option, + timeout: u64, +) -> Result<()> { + const MAX_HEALTH_CHECK_TIMEOUT_SECS: u64 = 3600; + if timeout == 0 || timeout > MAX_HEALTH_CHECK_TIMEOUT_SECS { + return Err(KeepError::InvalidInput(format!( + "timeout must be between 1 and {MAX_HEALTH_CHECK_TIMEOUT_SECS} seconds" + ))); + } + debug!(group, relay, share = ?share_index, timeout, "health check"); + + let mut keep = Keep::open(path)?; + let password = get_password("Enter password")?; + + let spinner = out.spinner("Unlocking vault..."); + keep.unlock(password.expose_secret())?; + spinner.finish(); + + let group_pubkey = keep_core::keys::npub_to_bytes(group)?; + + let share = match share_index { + Some(idx) => keep.frost_get_share_by_index(&group_pubkey, idx)?, + None => keep.frost_get_share(&group_pubkey)?, + }; + + out.newline(); + out.header("Key Health Check"); + out.field("Group", group); + out.field("Relay", relay); + out.field("Timeout", &format!("{timeout}s")); + out.newline(); + + let rt = + tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; + + rt.block_on(async { + let node = std::sync::Arc::new( + keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) + .await + .map_err(|e| KeepError::Frost(e.to_string()))?, + ); + + node.announce() + .await + .map_err(|e| KeepError::Frost(e.to_string()))?; + + let node_handle = tokio::spawn({ + let node = node.clone(); + async move { + if let Err(e) = node.run().await { + tracing::error!(error = %e, "FROST node error"); + } + } + }); + + let spinner = out.spinner("Discovering peers..."); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + spinner.finish(); + + let online = node.online_peers(); + out.info(&format!("{online} peer(s) discovered")); + + if online == 0 { + node_handle.abort(); + out.newline(); + out.warn("No peers discovered. Run 'keep frost network serve' on other devices first."); + return Ok::<_, KeepError>(()); + } + + let spinner = out.spinner(&format!("Pinging peers (timeout: {timeout}s)...")); + let result = node + .health_check(std::time::Duration::from_secs(timeout)) + .await + .map_err(|e| KeepError::Frost(e.to_string()))?; + spinner.finish(); + node_handle.abort(); + + out.newline(); + out.header("Results"); + + if !result.responsive.is_empty() { + let shares: Vec = result.responsive.iter().map(|s| s.to_string()).collect(); + out.field("Responsive", &shares.join(", ")); + } + if !result.unresponsive.is_empty() { + let shares: Vec = result.unresponsive.iter().map(|s| s.to_string()).collect(); + out.field("Unresponsive", &shares.join(", ")); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + for (&idx, responsive) in result + .responsive + .iter() + .map(|i| (i, true)) + .chain(result.unresponsive.iter().map(|i| (i, false))) + { + let existing = keep.get_health_status(&group_pubkey, idx)?; + let created_at = existing.and_then(|s| s.created_at).unwrap_or(now); + let status = keep_core::wallet::KeyHealthStatus { + group_pubkey, + share_index: idx, + last_check_timestamp: now, + responsive, + created_at: Some(created_at), + }; + keep.store_health_status(&status)?; + } + + out.newline(); + out.success(&format!( + "{} responsive, {} unresponsive", + result.responsive.len(), + result.unresponsive.len() + )); + + let all_statuses = keep.list_health_statuses()?; + let group_statuses: Vec<_> = all_statuses + .iter() + .filter(|s| s.group_pubkey == group_pubkey) + .collect(); + if !group_statuses.is_empty() { + out.newline(); + out.header("Health History"); + for s in &group_statuses { + let age = now.saturating_sub(s.last_check_timestamp); + let status_str = if s.responsive { + "responsive" + } else { + "unresponsive" + }; + let staleness = if s.is_critical(now) { + " [CRITICAL]" + } else if s.is_stale(now) { + " [STALE]" + } else { + "" + }; + let age_display = format_duration_ago(age); + out.field( + &format!("Share {}", s.share_index), + &format!("{status_str} ({age_display}){staleness}"), + ); + } + } + + Ok::<_, KeepError>(()) + })?; + + Ok(()) +} + +fn format_duration_ago(secs: u64) -> String { + if secs < 60 { + format!("{secs}s ago") + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } +} diff --git a/keep-cli/src/commands/wallet.rs b/keep-cli/src/commands/wallet.rs index a529f974..7fdb4569 100644 --- a/keep-cli/src/commands/wallet.rs +++ b/keep-cli/src/commands/wallet.rs @@ -417,8 +417,18 @@ pub fn cmd_wallet_propose( relay: &str, share_index: Option, recovery: &[String], + timeout_secs: Option, ) -> Result<()> { - debug!(group, network, relay, share = ?share_index, "wallet propose"); + debug!(group, network, relay, share = ?share_index, timeout = ?timeout_secs, "wallet propose"); + + if let Some(t) = timeout_secs { + if t == 0 || t > keep_frost_net::DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS { + return Err(KeepError::InvalidInput(format!( + "timeout must be between 1 and {} seconds", + keep_frost_net::DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS + ))); + } + } if !keep_frost_net::VALID_NETWORKS.contains(&network) { return Err(KeepError::InvalidInput(format!( @@ -513,7 +523,7 @@ pub fn cmd_wallet_propose( let spinner = out.spinner("Sending descriptor proposal..."); let session_id = node - .request_descriptor(policy, network, &xpub, &fingerprint) + .request_descriptor_with_timeout(policy, network, &xpub, &fingerprint, timeout_secs) .await .map_err(|e| KeepError::Frost(e.to_string()))?; spinner.finish(); @@ -537,7 +547,8 @@ pub fn cmd_wallet_propose( let spinner = out.spinner(&format!( "Waiting for contributions (0/{remaining_contributions})..." )); - let timeout = Duration::from_secs(keep_frost_net::DESCRIPTOR_SESSION_TIMEOUT_SECS); + let effective_timeout = timeout_secs.unwrap_or(keep_frost_net::DESCRIPTOR_SESSION_TIMEOUT_SECS); + let timeout = Duration::from_secs(effective_timeout); let deadline = tokio::time::Instant::now() + timeout; let mut received = 0usize; @@ -604,8 +615,11 @@ pub fn cmd_wallet_propose( spinner.finish(); let spinner = out.spinner("Waiting for ACKs..."); - let ack_deadline = - tokio::time::Instant::now() + Duration::from_secs(keep_frost_net::DESCRIPTOR_ACK_TIMEOUT_SECS); + let default_ack = keep_frost_net::DESCRIPTOR_ACK_TIMEOUT_SECS; + let ack_timeout = timeout_secs + .map(|t| t.clamp(default_ack, default_ack * 4)) + .unwrap_or(default_ack); + let ack_deadline = tokio::time::Instant::now() + Duration::from_secs(ack_timeout); let mut external_descriptor = String::new(); let mut internal_descriptor = String::new(); diff --git a/keep-cli/src/main.rs b/keep-cli/src/main.rs index 2dd2f771..f8ca3e24 100644 --- a/keep-cli/src/main.rs +++ b/keep-cli/src/main.rs @@ -342,6 +342,17 @@ fn dispatch_frost_network( out, path, &group, relay, &hardware, count, ) } + FrostNetworkCommands::HealthCheck { + group, + relay, + share, + timeout, + } => { + let relay = relay.as_deref().unwrap_or(default_relay); + commands::frost_network::cmd_frost_network_health_check( + out, path, &group, relay, share, timeout, + ) + } } } @@ -447,10 +458,11 @@ fn dispatch_wallet( relay, share, recovery, + timeout, } => { let relay = relay.as_deref().unwrap_or_else(|| cfg.default_relay()); commands::wallet::cmd_wallet_propose( - out, path, &group, &network, relay, share, &recovery, + out, path, &group, &network, relay, share, &recovery, timeout, ) } } diff --git a/keep-core/src/backend.rs b/keep-core/src/backend.rs index b5cef177..ab53497f 100644 --- a/keep-core/src/backend.rs +++ b/keep-core/src/backend.rs @@ -23,6 +23,8 @@ pub const DESCRIPTORS_TABLE: &str = "wallet_descriptors"; pub const RELAY_CONFIGS_TABLE: &str = "relay_configs"; /// Table name for application configuration. pub const CONFIG_TABLE: &str = "config"; +/// Table name for key health status records. +pub const HEALTH_STATUS_TABLE: &str = "key_health_status"; /// Trait for pluggable storage backends. /// @@ -83,6 +85,8 @@ const DESCRIPTORS_TABLE_DEF: TableDefinition<&[u8], &[u8]> = const RELAY_CONFIGS_TABLE_DEF: TableDefinition<&[u8], &[u8]> = TableDefinition::new("relay_configs"); const CONFIG_TABLE_DEF: TableDefinition<&[u8], &[u8]> = TableDefinition::new("config"); +const HEALTH_STATUS_TABLE_DEF: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("key_health_status"); /// Redb-based storage backend (default). pub struct RedbBackend { @@ -148,6 +152,7 @@ impl RedbBackend { DESCRIPTORS_TABLE => Ok(DESCRIPTORS_TABLE_DEF), RELAY_CONFIGS_TABLE => Ok(RELAY_CONFIGS_TABLE_DEF), CONFIG_TABLE => Ok(CONFIG_TABLE_DEF), + HEALTH_STATUS_TABLE => Ok(HEALTH_STATUS_TABLE_DEF), _ => Err(StorageError::database(format!("unknown table: {name}")).into()), } } diff --git a/keep-core/src/lib.rs b/keep-core/src/lib.rs index 1592557e..35a5a28a 100644 --- a/keep-core/src/lib.rs +++ b/keep-core/src/lib.rs @@ -665,6 +665,43 @@ impl Keep { self.storage.delete_descriptor(group_pubkey) } + /// Store a key health status record. + pub fn store_health_status(&self, status: &wallet::KeyHealthStatus) -> Result<()> { + if !self.is_unlocked() { + return Err(KeepError::Locked); + } + self.storage.store_health_status(status) + } + + /// Get a key health status record. + pub fn get_health_status( + &self, + group_pubkey: &[u8; 32], + share_index: u16, + ) -> Result> { + if !self.is_unlocked() { + return Err(KeepError::Locked); + } + self.storage.get_health_status(group_pubkey, share_index) + } + + /// List all key health status records. + pub fn list_health_statuses(&self) -> Result> { + if !self.is_unlocked() { + return Err(KeepError::Locked); + } + self.storage.list_health_statuses() + } + + /// List health statuses that are stale (not checked within the threshold). + pub fn list_stale_health_statuses(&self, now: u64) -> Result> { + if !self.is_unlocked() { + return Err(KeepError::Locked); + } + let all = self.storage.list_health_statuses()?; + Ok(all.into_iter().filter(|s| s.is_stale(now)).collect()) + } + /// Store relay configuration for a FROST share. pub fn store_relay_config(&self, config: &RelayConfig) -> Result<()> { if !self.is_unlocked() { diff --git a/keep-core/src/storage.rs b/keep-core/src/storage.rs index daafce2c..9cd33509 100644 --- a/keep-core/src/storage.rs +++ b/keep-core/src/storage.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use tracing::{debug, trace}; use crate::backend::{ - RedbBackend, StorageBackend, CONFIG_TABLE, DESCRIPTORS_TABLE, KEYS_TABLE, RELAY_CONFIGS_TABLE, - SHARES_TABLE, + RedbBackend, StorageBackend, CONFIG_TABLE, DESCRIPTORS_TABLE, HEALTH_STATUS_TABLE, KEYS_TABLE, + RELAY_CONFIGS_TABLE, SHARES_TABLE, }; use crate::crypto::{self, Argon2Params, EncryptedData, SecretKey, SALT_SIZE}; use crate::error::{KeepError, Result, StorageError}; @@ -17,7 +17,7 @@ use crate::frost::StoredShare; use crate::keys::KeyRecord; use crate::rate_limit; use crate::relay::RelayConfig; -use crate::wallet::WalletDescriptor; +use crate::wallet::{KeyHealthStatus, WalletDescriptor}; use bincode::Options; @@ -245,6 +245,7 @@ impl Storage { backend.create_table(DESCRIPTORS_TABLE)?; backend.create_table(RELAY_CONFIGS_TABLE)?; backend.create_table(CONFIG_TABLE)?; + backend.create_table(HEALTH_STATUS_TABLE)?; Ok(Self { path: path.to_path_buf(), @@ -717,6 +718,84 @@ impl Storage { backend.put(CONFIG_TABLE, b"kill_switch", &encrypted_bytes)?; Ok(()) } + + /// Store a key health status record. + pub fn store_health_status(&self, status: &KeyHealthStatus) -> Result<()> { + debug!( + group = %hex::encode(status.group_pubkey), + share = status.share_index, + responsive = status.responsive, + "storing health status" + ); + let data_key = self.data_key.as_ref().ok_or(KeepError::Locked)?; + let backend = self.backend.as_ref().ok_or(KeepError::Locked)?; + + let key = health_status_key(&status.group_pubkey, status.share_index); + let serialized = serde_json::to_vec(status) + .map_err(|e| KeepError::Other(format!("json serialization failed: {e}")))?; + let encrypted = crypto::encrypt(&serialized, data_key)?; + backend.put(HEALTH_STATUS_TABLE, key.as_bytes(), &encrypted.to_bytes())?; + Ok(()) + } + + /// Get a key health status record. + pub fn get_health_status( + &self, + group_pubkey: &[u8; 32], + share_index: u16, + ) -> Result> { + let data_key = self.data_key.as_ref().ok_or(KeepError::Locked)?; + let backend = self.backend.as_ref().ok_or(KeepError::Locked)?; + + let key = health_status_key(group_pubkey, share_index); + let Some(encrypted_bytes) = backend.get(HEALTH_STATUS_TABLE, key.as_bytes())? else { + return Ok(None); + }; + + let encrypted = EncryptedData::from_bytes(&encrypted_bytes)?; + let decrypted = crypto::decrypt(&encrypted, data_key)?; + let decrypted_bytes = decrypted.as_slice()?; + let status: KeyHealthStatus = serde_json::from_slice(&decrypted_bytes) + .map_err(|e| KeepError::Other(format!("json deserialization failed: {e}")))?; + Ok(Some(status)) + } + + /// List all key health status records. + pub fn list_health_statuses(&self) -> Result> { + let data_key = self.data_key.as_ref().ok_or(KeepError::Locked)?; + let backend = self.backend.as_ref().ok_or(KeepError::Locked)?; + + let entries = backend.list(HEALTH_STATUS_TABLE)?; + let mut statuses = Vec::new(); + + for (_, encrypted_bytes) in entries { + let encrypted = EncryptedData::from_bytes(&encrypted_bytes)?; + let decrypted = crypto::decrypt(&encrypted, data_key)?; + let decrypted_bytes = decrypted.as_slice()?; + let status: KeyHealthStatus = serde_json::from_slice(&decrypted_bytes) + .map_err(|e| KeepError::Other(format!("json deserialization failed: {e}")))?; + statuses.push(status); + } + + Ok(statuses) + } + + /// Delete a key health status record. + pub fn delete_health_status(&self, group_pubkey: &[u8; 32], share_index: u16) -> Result<()> { + let backend = self.backend.as_ref().ok_or(KeepError::Locked)?; + let key = health_status_key(group_pubkey, share_index); + if backend.delete(HEALTH_STATUS_TABLE, key.as_bytes())? { + Ok(()) + } else { + Err(KeepError::KeyNotFound(format!( + "health status for share {share_index} not found" + ))) + } + } +} + +fn health_status_key(group_pubkey: &[u8; 32], share_index: u16) -> String { + format!("{}:{}", hex::encode(group_pubkey), share_index) } pub(crate) fn share_id(group_pubkey: &[u8; 32], identifier: u16) -> [u8; 32] { diff --git a/keep-core/src/wallet.rs b/keep-core/src/wallet.rs index d19fcbc4..a0fa12b7 100644 --- a/keep-core/src/wallet.rs +++ b/keep-core/src/wallet.rs @@ -5,6 +5,39 @@ use serde::{Deserialize, Serialize}; +/// Health status of a key share from a liveness check. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyHealthStatus { + /// The FROST group public key. + pub group_pubkey: [u8; 32], + /// The share index that was checked. + pub share_index: u16, + /// Unix timestamp of the last health check. + pub last_check_timestamp: u64, + /// Whether the share was responsive. + pub responsive: bool, + /// Unix timestamp when this record was first created (None for legacy records). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + +/// 24 hours - key not checked in this period is considered stale. +pub const KEY_HEALTH_STALE_THRESHOLD_SECS: u64 = 86400; +/// 7 days - key not checked in this period is critically stale. +pub const KEY_HEALTH_CRITICAL_THRESHOLD_SECS: u64 = 604800; + +impl KeyHealthStatus { + /// Returns true if the last check is older than the stale threshold (24h). + pub fn is_stale(&self, now: u64) -> bool { + now.saturating_sub(self.last_check_timestamp) >= KEY_HEALTH_STALE_THRESHOLD_SECS + } + + /// Returns true if the last check is older than the critical threshold (7d). + pub fn is_critical(&self, now: u64) -> bool { + now.saturating_sub(self.last_check_timestamp) >= KEY_HEALTH_CRITICAL_THRESHOLD_SECS + } +} + /// A finalized wallet descriptor associated with a FROST group. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct WalletDescriptor { diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index c0678ef8..ab747444 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -1355,9 +1355,10 @@ impl App { self.update_wallet_setup(&sid, |setup| { setup.phase = SetupPhase::Coordinating(progress); }); - } else if let Screen::Wallet(WalletScreen { setup: Some(s), .. }) = &mut self.screen - { - s.phase = SetupPhase::Coordinating(progress); + } else if matches!(progress, DescriptorProgress::Contributed) { + if let Screen::Wallet(WalletScreen { setup: Some(s), .. }) = &mut self.screen { + s.phase = SetupPhase::Coordinating(progress); + } } Task::none() } diff --git a/keep-frost-net/src/descriptor_session.rs b/keep-frost-net/src/descriptor_session.rs index 477c3b5a..5201e626 100644 --- a/keep-frost-net/src/descriptor_session.rs +++ b/keep-frost-net/src/descriptor_session.rs @@ -12,13 +12,30 @@ use sha2::{Digest, Sha256}; use crate::error::{FrostNetError, Result}; use crate::protocol::{ KeySlot, WalletPolicy, DESCRIPTOR_ACK_PHASE_TIMEOUT_SECS, DESCRIPTOR_CONTRIBUTION_TIMEOUT_SECS, - DESCRIPTOR_FINALIZE_TIMEOUT_SECS, DESCRIPTOR_SESSION_TIMEOUT_SECS, MAX_FINGERPRINT_LENGTH, - MAX_XPUB_LENGTH, + DESCRIPTOR_FINALIZE_TIMEOUT_SECS, DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS, + DESCRIPTOR_SESSION_TIMEOUT_SECS, MAX_FINGERPRINT_LENGTH, MAX_XPUB_LENGTH, }; const MAX_SESSIONS: usize = 64; const REAP_GRACE_SECS: u64 = 60; +fn validate_session_timeout(timeout: Duration) -> Result { + let max = Duration::from_secs(DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS); + if timeout.is_zero() { + return Err(FrostNetError::Session( + "Session timeout must be greater than zero".into(), + )); + } + if timeout > max { + return Err(FrostNetError::Session(format!( + "Session timeout {}s exceeds maximum {}s", + timeout.as_secs(), + max.as_secs() + ))); + } + Ok(timeout) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum DescriptorSessionState { Proposed, @@ -378,18 +395,18 @@ impl DescriptorSessionManager { } } - pub fn with_timeout(timeout: Duration) -> Self { - Self { + pub fn with_timeout(timeout: Duration) -> Result { + let validated = validate_session_timeout(timeout)?; + Ok(Self { sessions: HashMap::new(), - default_timeout: timeout, - } + default_timeout: validated, + }) } pub fn session_count(&self) -> usize { self.sessions.len() } - #[allow(clippy::too_many_arguments)] pub fn create_session( &mut self, session_id: [u8; 32], @@ -398,6 +415,28 @@ impl DescriptorSessionManager { network: String, expected_contributors: HashSet, expected_acks: HashSet, + ) -> Result<&mut DescriptorSession> { + self.create_session_with_timeout( + session_id, + group_pubkey, + policy, + network, + expected_contributors, + expected_acks, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn create_session_with_timeout( + &mut self, + session_id: [u8; 32], + group_pubkey: [u8; 32], + policy: WalletPolicy, + network: String, + expected_contributors: HashSet, + expected_acks: HashSet, + timeout: Option, ) -> Result<&mut DescriptorSession> { if let Some(existing) = self.sessions.get(&session_id) { if !existing.is_expired() { @@ -408,7 +447,7 @@ impl DescriptorSessionManager { self.sessions.remove(&session_id); } - let _ = self.cleanup_expired(); + self.cleanup_expired(); if self.sessions.len() >= MAX_SESSIONS { return Err(FrostNetError::Session( @@ -416,6 +455,10 @@ impl DescriptorSessionManager { )); } + let effective_timeout = timeout + .map(validate_session_timeout) + .transpose()? + .unwrap_or(self.default_timeout); let session = DescriptorSession::new( session_id, group_pubkey, @@ -423,13 +466,11 @@ impl DescriptorSessionManager { network, expected_contributors, expected_acks, - self.default_timeout, + effective_timeout, ); self.sessions.insert(session_id, session); - self.sessions - .get_mut(&session_id) - .ok_or_else(|| FrostNetError::Session("Failed to retrieve created session".into())) + Ok(self.sessions.get_mut(&session_id).unwrap()) } pub fn get_session(&self, session_id: &[u8; 32]) -> Option<&DescriptorSession> { @@ -1017,7 +1058,7 @@ mod tests { #[test] fn test_session_manager_cleanup_expired() { - let mut manager = DescriptorSessionManager::with_timeout(Duration::from_millis(1)); + let mut manager = DescriptorSessionManager::with_timeout(Duration::from_millis(1)).unwrap(); let policy = test_policy(); manager @@ -1196,7 +1237,7 @@ mod tests { #[test] fn test_cleanup_returns_phase_reasons() { - let mut manager = DescriptorSessionManager::with_timeout(Duration::from_secs(600)); + let mut manager = DescriptorSessionManager::with_timeout(Duration::from_secs(600)).unwrap(); let policy = test_policy(); { diff --git a/keep-frost-net/src/lib.rs b/keep-frost-net/src/lib.rs index 1557adb9..1c315d2b 100644 --- a/keep-frost-net/src/lib.rs +++ b/keep-frost-net/src/lib.rs @@ -84,7 +84,9 @@ pub use ecdh::{ }; pub use error::{FrostNetError, Result}; pub use event::KfpEventBuilder; -pub use node::{KfpNode, KfpNodeEvent, NoOpHooks, PeerPolicy, SessionInfo, SigningHooks}; +pub use node::{ + HealthCheckResult, KfpNode, KfpNodeEvent, NoOpHooks, PeerPolicy, SessionInfo, SigningHooks, +}; pub use nonce_store::{FileNonceStore, MemoryNonceStore, NonceStore}; pub use peer::{AttestationStatus, Peer, PeerManager, PeerStatus}; pub use protocol::{ @@ -94,7 +96,9 @@ pub use protocol::{ EnclaveAttestation, ErrorPayload, KeySlot, KfpMessage, PingPayload, PolicyTier, PongPayload, RefreshCompletePayload, RefreshRequestPayload, RefreshRound1Payload, RefreshRound2Payload, SignRequestPayload, SignatureCompletePayload, SignatureSharePayload, WalletPolicy, - XpubAnnouncePayload, DEFAULT_REPLAY_WINDOW_SECS, DESCRIPTOR_ACK_TIMEOUT_SECS, + XpubAnnouncePayload, DEFAULT_REPLAY_WINDOW_SECS, DESCRIPTOR_ACK_PHASE_TIMEOUT_SECS, + DESCRIPTOR_ACK_TIMEOUT_SECS, DESCRIPTOR_CONTRIBUTION_TIMEOUT_SECS, + DESCRIPTOR_FINALIZE_TIMEOUT_SECS, DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS, DESCRIPTOR_SESSION_TIMEOUT_SECS, KFP_EVENT_KIND, KFP_VERSION, MAX_CAPABILITIES, MAX_CAPABILITY_LENGTH, MAX_COMMITMENT_SIZE, MAX_DESCRIPTOR_LENGTH, MAX_ERROR_CODE_LENGTH, MAX_ERROR_MESSAGE_LENGTH, MAX_FINGERPRINT_LENGTH, MAX_KEYS_PER_TIER, MAX_MESSAGE_SIZE, diff --git a/keep-frost-net/src/node/descriptor.rs b/keep-frost-net/src/node/descriptor.rs index 03ef4c24..84d6525b 100644 --- a/keep-frost-net/src/node/descriptor.rs +++ b/keep-frost-net/src/node/descriptor.rs @@ -24,6 +24,18 @@ impl KfpNode { network: &str, own_xpub: &str, own_fingerprint: &str, + ) -> Result<[u8; 32]> { + self.request_descriptor_with_timeout(policy, network, own_xpub, own_fingerprint, None) + .await + } + + pub async fn request_descriptor_with_timeout( + &self, + policy: WalletPolicy, + network: &str, + own_xpub: &str, + own_fingerprint: &str, + timeout_secs: Option, ) -> Result<[u8; 32]> { if !VALID_NETWORKS.contains(&network) { return Err(FrostNetError::Session(format!( @@ -50,15 +62,18 @@ impl KfpNode { .collect() }; + let session_timeout = timeout_secs.map(std::time::Duration::from_secs); + { let mut sessions = self.descriptor_sessions.write(); - let session = sessions.create_session( + let session = sessions.create_session_with_timeout( session_id, self.group_pubkey, policy.clone(), network.to_string(), expected_contributors, expected_acks, + session_timeout, )?; session.set_initiator(self.keys.public_key()); @@ -74,7 +89,7 @@ impl KfpNode { } } - let payload = DescriptorProposePayload::new( + let mut payload = DescriptorProposePayload::new( session_id, self.group_pubkey, created_at, @@ -83,6 +98,9 @@ impl KfpNode { own_xpub, own_fingerprint, ); + if let Some(t) = timeout_secs { + payload = payload.with_timeout(t); + } let msg = KfpMessage::DescriptorPropose(payload); let json = msg.to_json()?; @@ -184,15 +202,28 @@ impl KfpNode { let our_index = self.share.metadata.identifier; let we_are_contributor = expected_contributors.contains(&our_index); + let propose_timeout = match payload.timeout_secs { + None => None, + Some(t) if t > 0 && t <= DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS => { + Some(std::time::Duration::from_secs(t)) + } + Some(t) => { + return Err(FrostNetError::Session(format!( + "Invalid proposal timeout {t}s, must be 1..={DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS}" + ))); + } + }; + let session_created = { let mut sessions = self.descriptor_sessions.write(); - match sessions.create_session( + match sessions.create_session_with_timeout( payload.session_id, self.group_pubkey, payload.policy.clone(), payload.network.clone(), expected_contributors, HashSet::new(), + propose_timeout, ) { Ok(session) => { session.set_initiator(sender); @@ -206,8 +237,8 @@ impl KfpNode { } true } - Err(_) => { - debug!("Descriptor session already exists, ignoring duplicate proposal"); + Err(e) => { + debug!("Descriptor session creation failed: {e}"); false } } diff --git a/keep-frost-net/src/node/mod.rs b/keep-frost-net/src/node/mod.rs index fc28b560..547b6145 100644 --- a/keep-frost-net/src/node/mod.rs +++ b/keep-frost-net/src/node/mod.rs @@ -109,6 +109,12 @@ impl SigningHooks for NoOpHooks { fn post_sign(&self, _session: &SessionInfo, _signature: &[u8; 64]) {} } +#[derive(Clone, Debug)] +pub struct HealthCheckResult { + pub responsive: Vec, + pub unresponsive: Vec, +} + /// Maximum age for announcement timestamps (5 minutes) pub(crate) const ANNOUNCE_MAX_AGE_SECS: u64 = 300; /// Maximum clock skew tolerance for future timestamps (30 seconds) @@ -183,6 +189,11 @@ pub enum KfpNodeEvent { share_index: u16, recovery_xpubs: Vec, }, + HealthCheckComplete { + group_pubkey: [u8; 32], + responsive: Vec, + unresponsive: Vec, + }, } impl std::fmt::Debug for KfpNodeEvent { @@ -283,6 +294,16 @@ impl std::fmt::Debug for KfpNodeEvent { .field("share_index", share_index) .field("xpub_count", &recovery_xpubs.len()) .finish(), + Self::HealthCheckComplete { + group_pubkey, + responsive, + unresponsive, + } => f + .debug_struct("HealthCheckComplete") + .field("group_pubkey", &hex::encode(group_pubkey)) + .field("responsive", responsive) + .field("unresponsive", unresponsive) + .finish(), } } } @@ -344,6 +365,11 @@ impl KfpNode { proxy: Option, session_timeout: Option, ) -> Result { + let descriptor_manager = match session_timeout { + Some(t) => DescriptorSessionManager::with_timeout(t)?, + None => DescriptorSessionManager::new(), + }; + for relay in &relays { let validate = if ALLOW_INTERNAL_HOSTS { validate_relay_url_allow_internal @@ -405,11 +431,6 @@ impl KfpNode { None => EcdhSessionManager::new(), }; - let descriptor_manager = match session_timeout { - Some(t) => DescriptorSessionManager::with_timeout(t), - None => DescriptorSessionManager::new(), - }; - let audit_hmac_key = derive_audit_hmac_key(&keys, &group_pubkey); let audit_log = Arc::new(SigningAuditLog::new(audit_hmac_key)); @@ -1044,6 +1065,42 @@ impl KfpNode { Ok(()) } + pub async fn health_check(&self, timeout: Duration) -> Result { + if timeout < Duration::from_secs(1) || timeout > Duration::from_secs(300) { + return Err(FrostNetError::Session(format!( + "Health check timeout must be between 1s and 300s, got {}s", + timeout.as_secs() + ))); + } + let peers_snapshot: Vec<(u16, PublicKey, std::time::Instant)> = self + .peers + .read() + .all_peers() + .iter() + .map(|p| (p.share_index, p.pubkey, p.last_seen)) + .collect(); + + let responsive = self.ping_peers_snapshot(&peers_snapshot, timeout).await?; + let unresponsive: Vec = peers_snapshot + .iter() + .map(|(idx, _, _)| *idx) + .filter(|idx| !responsive.contains(idx)) + .collect(); + + let result = HealthCheckResult { + responsive, + unresponsive, + }; + + let _ = self.event_tx.send(KfpNodeEvent::HealthCheckComplete { + group_pubkey: self.group_pubkey, + responsive: result.responsive.clone(), + unresponsive: result.unresponsive.clone(), + }); + + Ok(result) + } + pub async fn ping_peers(&self, timeout: Duration) -> Result> { let peers_snapshot: Vec<(u16, PublicKey, std::time::Instant)> = self .peers @@ -1053,13 +1110,38 @@ impl KfpNode { .map(|p| (p.share_index, p.pubkey, p.last_seen)) .collect(); + self.ping_peers_snapshot(&peers_snapshot, timeout).await + } + + async fn ping_peers_snapshot( + &self, + peers_snapshot: &[(u16, PublicKey, std::time::Instant)], + timeout: Duration, + ) -> Result> { if peers_snapshot.is_empty() { return Ok(Vec::new()); } - for (_, pubkey, _) in &peers_snapshot { - if let Ok(event) = KfpEventBuilder::ping(&self.keys, pubkey) { - let _ = self.client.send_event(&event).await; + for (share_index, pubkey, _) in peers_snapshot { + match KfpEventBuilder::ping(&self.keys, pubkey) { + Ok(event) => { + if let Err(e) = self.client.send_event(&event).await { + warn!( + peer = %pubkey, + share_index, + error = %e, + "Failed to send ping" + ); + } + } + Err(e) => { + warn!( + peer = %pubkey, + share_index, + error = %e, + "Failed to build ping event" + ); + } } } diff --git a/keep-frost-net/src/protocol.rs b/keep-frost-net/src/protocol.rs index 65ed40ec..a83bf7dc 100644 --- a/keep-frost-net/src/protocol.rs +++ b/keep-frost-net/src/protocol.rs @@ -25,6 +25,7 @@ pub const MIN_XPUB_LENGTH: usize = 111; pub const MAX_XPUB_LENGTH: usize = 256; pub const MAX_FINGERPRINT_LENGTH: usize = 8; pub const DESCRIPTOR_SESSION_TIMEOUT_SECS: u64 = 600; +pub const DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS: u64 = 86400; pub const DESCRIPTOR_ACK_TIMEOUT_SECS: u64 = 60; pub const DESCRIPTOR_CONTRIBUTION_TIMEOUT_SECS: u64 = 300; pub const DESCRIPTOR_FINALIZE_TIMEOUT_SECS: u64 = 120; @@ -272,6 +273,14 @@ impl KfpMessage { } } KfpMessage::DescriptorPropose(p) => { + if let Some(t) = p.timeout_secs { + if t == 0 { + return Err("timeout_secs must be greater than zero"); + } + if t > DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS { + return Err("timeout_secs exceeds maximum allowed value"); + } + } if !VALID_NETWORKS.contains(&p.network.as_str()) { return Err("Invalid network value"); } @@ -962,6 +971,8 @@ pub struct DescriptorProposePayload { pub policy: WalletPolicy, pub initiator_xpub: String, pub initiator_fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, } impl DescriptorProposePayload { @@ -982,9 +993,15 @@ impl DescriptorProposePayload { policy, initiator_xpub: initiator_xpub.to_string(), initiator_fingerprint: initiator_fingerprint.to_string(), + timeout_secs: None, } } + pub fn with_timeout(mut self, timeout_secs: u64) -> Self { + self.timeout_secs = Some(timeout_secs); + self + } + pub fn is_within_replay_window(&self, window_secs: u64) -> bool { within_replay_window(self.created_at, window_secs) } diff --git a/keep-mobile/src/error.rs b/keep-mobile/src/error.rs index c20cafe9..1f050055 100644 --- a/keep-mobile/src/error.rs +++ b/keep-mobile/src/error.rs @@ -74,6 +74,9 @@ pub enum KeepMobileError { #[error("Policy signature verification failed")] PolicySignatureInvalid, + #[error("Invalid input: {msg}")] + InvalidInput { msg: String }, + #[error("Certificate pin mismatch")] CertificatePinMismatch { hostname: String, diff --git a/keep-mobile/src/lib.rs b/keep-mobile/src/lib.rs index f99e408b..b5ab71a0 100644 --- a/keep-mobile/src/lib.rs +++ b/keep-mobile/src/lib.rs @@ -29,8 +29,8 @@ pub use psbt::{PsbtInfo, PsbtInputSighash, PsbtOutputInfo, PsbtParser}; pub use storage::{SecureStorage, ShareInfo, ShareMetadataInfo, StoredShareInfo}; pub use types::{ AnnouncedXpubInfo, DescriptorProposal, DkgConfig, DkgStatus, FrostGenerationResult, - GeneratedShareInfo, PeerInfo, PeerStatus, RecoveryTierConfig, SignRequest, SignRequestMetadata, - ThresholdConfig, WalletDescriptorInfo, + GeneratedShareInfo, KeyHealthStatusInfo, PeerInfo, PeerStatus, RecoveryTierConfig, SignRequest, + SignRequestMetadata, ThresholdConfig, WalletDescriptorInfo, }; use keep_core::frost::{ @@ -80,6 +80,8 @@ const TRUSTED_WARDENS_KEY: &str = "__keep_trusted_wardens_v1"; const CERT_PINS_STORAGE_KEY: &str = "__keep_cert_pins_v1"; const DESCRIPTOR_INDEX_KEY: &str = "__keep_descriptor_index_v1"; const DESCRIPTOR_KEY_PREFIX: &str = "__keep_descriptor_"; +const HEALTH_STATUS_INDEX_KEY: &str = "__keep_health_index_v1"; +const HEALTH_STATUS_KEY_PREFIX: &str = "__keep_health_"; const DESCRIPTOR_SESSION_TIMEOUT: Duration = Duration::from_secs(600); #[derive(uniffi::Record)] @@ -95,6 +97,15 @@ struct StoredShareData { pubkey_package_bytes: Vec, } +#[uniffi::export(with_foreign)] +pub trait HealthCallbacks: Send + Sync { + fn on_health_check_complete( + &self, + responsive: Vec, + unresponsive: Vec, + ) -> Result<(), KeepMobileError>; +} + #[uniffi::export(with_foreign)] pub trait DescriptorCallbacks: Send + Sync { fn on_proposed(&self, session_id: String) -> Result<(), KeepMobileError>; @@ -170,6 +181,7 @@ pub struct KeepMobile { policy: Arc>, velocity: Arc>, descriptor_callbacks: Arc>>>, + health_callbacks: Arc>>>, descriptor_networks: Arc>>, pending_contributions: Arc>>, } @@ -239,6 +251,7 @@ impl KeepMobile { policy, velocity, descriptor_callbacks: Arc::new(RwLock::new(None)), + health_callbacks: Arc::new(RwLock::new(None)), descriptor_networks: Arc::new(std::sync::Mutex::new(HashMap::new())), pending_contributions: Arc::new(std::sync::Mutex::new(HashMap::new())), }) @@ -709,38 +722,138 @@ impl KeepMobile { }); } + pub fn set_health_callbacks(&self, callbacks: Arc) { + self.runtime.block_on(async { + *self.health_callbacks.write().await = Some(callbacks); + }); + } + + pub fn health_check(&self, timeout_secs: u64) -> Result, KeepMobileError> { + if timeout_secs == 0 || timeout_secs > 300 { + return Err(KeepMobileError::InvalidInput { + msg: format!("timeout_secs must be between 1 and 300, got {timeout_secs}"), + }); + } + self.runtime.block_on(async { + let (group_pubkey, result) = { + let node_guard = self.node.read().await; + let node = node_guard.as_ref().ok_or(KeepMobileError::NotInitialized)?; + let gp = hex::encode(node.group_pubkey()); + let r = node + .health_check(Duration::from_secs(timeout_secs)) + .await + .map_err(|e| KeepMobileError::NetworkError { msg: e.to_string() })?; + (gp, r) + }; + + if let Some(cb) = self.health_callbacks.read().await.as_ref() { + if let Err(e) = cb.on_health_check_complete( + result.responsive.clone(), + result.unresponsive.clone(), + ) { + tracing::warn!("Health check callback failed: {e}"); + } + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + for (idx, responsive) in result + .responsive + .iter() + .map(|&idx| (idx, true)) + .chain(result.unresponsive.iter().map(|&idx| (idx, false))) + { + let existing_created_at = + persistence::existing_created_at(&self.storage, &group_pubkey, idx); + if let Err(e) = persistence::persist_health_status( + &self.storage, + &KeyHealthStatusInfo { + group_pubkey: group_pubkey.clone(), + share_index: idx, + last_check_timestamp: now, + responsive, + created_at: existing_created_at.unwrap_or(now), + is_stale: false, + is_critical: false, + }, + ) { + tracing::warn!( + group_pubkey = %group_pubkey, + share_index = %idx, + error = %e, + "Failed to persist health status" + ); + } + } + + Ok(result.responsive) + }) + } + + pub fn health_status_list(&self) -> Vec { + persistence::load_health_statuses(&self.storage) + } + pub fn wallet_descriptor_propose( &self, network: String, tiers: Vec, + ) -> Result { + self.wallet_descriptor_propose_with_timeout(network, tiers, None) + } + + pub fn wallet_descriptor_propose_with_timeout( + &self, + network: String, + tiers: Vec, + timeout_secs: Option, ) -> Result { if !keep_frost_net::VALID_NETWORKS.contains(&network.as_str()) { return Err(KeepMobileError::NetworkError { msg: format!("Invalid network: {network}"), }); } + if let Some(t) = timeout_secs { + if t == 0 || t > keep_frost_net::DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS { + return Err(KeepMobileError::InvalidInput { + msg: format!( + "timeout_secs must be between 1 and {}, got {t}", + keep_frost_net::DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS + ), + }); + } + } self.runtime.block_on(async { - let node_guard = self.node.read().await; - let node = node_guard.as_ref().ok_or(KeepMobileError::NotInitialized)?; + let session_id = { + let node_guard = self.node.read().await; + let node = node_guard.as_ref().ok_or(KeepMobileError::NotInitialized)?; - let share_info = self - .storage - .get_share_metadata() - .ok_or(KeepMobileError::NotInitialized)?; + let share_info = self + .storage + .get_share_metadata() + .ok_or(KeepMobileError::NotInitialized)?; - let policy = build_wallet_policy(&tiers, share_info.total_shares)?; + let policy = build_wallet_policy(&tiers, share_info.total_shares)?; - let (xpub, fingerprint) = node - .derive_account_xpub(&network) - .map_err(|e| KeepMobileError::FrostError { msg: e.to_string() })?; + let (xpub, fingerprint) = node + .derive_account_xpub(&network) + .map_err(|e| KeepMobileError::FrostError { msg: e.to_string() })?; - validate_xpub_network(&xpub, &network)?; + validate_xpub_network(&xpub, &network)?; - let session_id = node - .request_descriptor(policy, &network, &xpub, &fingerprint) + node.request_descriptor_with_timeout( + policy, + &network, + &xpub, + &fingerprint, + timeout_secs, + ) .await - .map_err(|e| KeepMobileError::NetworkError { msg: e.to_string() })?; + .map_err(|e| KeepMobileError::NetworkError { msg: e.to_string() })? + }; self.descriptor_networks .lock() diff --git a/keep-mobile/src/persistence.rs b/keep-mobile/src/persistence.rs index 2f07f46b..6d2a1990 100644 --- a/keep-mobile/src/persistence.rs +++ b/keep-mobile/src/persistence.rs @@ -9,11 +9,11 @@ use serde::{Deserialize, Serialize}; use crate::error::KeepMobileError; use crate::policy::{PolicyBundle, POLICY_PUBKEY_LEN}; use crate::storage::{SecureStorage, ShareMetadataInfo}; -use crate::types::WalletDescriptorInfo; +use crate::types::{KeyHealthStatusInfo, WalletDescriptorInfo}; use crate::velocity::VelocityTracker; use crate::{ - CERT_PINS_STORAGE_KEY, DESCRIPTOR_INDEX_KEY, DESCRIPTOR_KEY_PREFIX, POLICY_STORAGE_KEY, - TRUSTED_WARDENS_KEY, VELOCITY_STORAGE_KEY, + CERT_PINS_STORAGE_KEY, DESCRIPTOR_INDEX_KEY, DESCRIPTOR_KEY_PREFIX, HEALTH_STATUS_INDEX_KEY, + HEALTH_STATUS_KEY_PREFIX, POLICY_STORAGE_KEY, TRUSTED_WARDENS_KEY, VELOCITY_STORAGE_KEY, }; pub(crate) fn load_policy( @@ -314,3 +314,118 @@ pub(crate) fn delete_descriptor( } Ok(()) } + +#[derive(Serialize, Deserialize)] +struct StoredHealthStatus { + group_pubkey: String, + share_index: u16, + last_check_timestamp: u64, + responsive: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_at: Option, +} + +fn health_status_key(group_pubkey_hex: &str, share_index: u16) -> String { + format!("{HEALTH_STATUS_KEY_PREFIX}{group_pubkey_hex}_{share_index}") +} + +fn load_health_index(storage: &Arc) -> Vec { + match storage.load_share_by_key(HEALTH_STATUS_INDEX_KEY.into()) { + Ok(data) => serde_json::from_slice(&data).unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +fn persist_health_index( + storage: &Arc, + index: &[String], +) -> Result<(), KeepMobileError> { + let data = serde_json::to_vec(index).map_err(|e| KeepMobileError::StorageError { + msg: format!("Failed to serialize health index: {e}"), + })?; + storage.store_share_by_key( + HEALTH_STATUS_INDEX_KEY.into(), + data, + storage_metadata("health_index"), + ) +} + +pub(crate) fn existing_created_at( + storage: &Arc, + group_pubkey_hex: &str, + share_index: u16, +) -> Option { + load_stored_health_status(storage, group_pubkey_hex, share_index).and_then(|s| s.created_at) +} + +fn load_stored_health_status( + storage: &Arc, + group_pubkey_hex: &str, + share_index: u16, +) -> Option { + let key = health_status_key(group_pubkey_hex, share_index); + storage + .load_share_by_key(key) + .ok() + .and_then(|data| serde_json::from_slice(&data).ok()) +} + +pub(crate) fn persist_health_status( + storage: &Arc, + info: &KeyHealthStatusInfo, +) -> Result<(), KeepMobileError> { + let stored = StoredHealthStatus { + group_pubkey: info.group_pubkey.clone(), + share_index: info.share_index, + last_check_timestamp: info.last_check_timestamp, + responsive: info.responsive, + created_at: Some(info.created_at), + }; + let data = serde_json::to_vec(&stored).map_err(|e| KeepMobileError::StorageError { + msg: format!("Failed to serialize health status: {e}"), + })?; + let key = health_status_key(&info.group_pubkey, info.share_index); + storage.store_share_by_key(key.clone(), data, storage_metadata("health_status"))?; + + let mut index = load_health_index(storage); + if !index.contains(&key) { + index.push(key); + persist_health_index(storage, &index)?; + } + Ok(()) +} + +pub(crate) fn load_health_statuses(storage: &Arc) -> Vec { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let index = load_health_index(storage); + let mut results = Vec::new(); + let mut live_keys = Vec::with_capacity(index.len()); + for key in &index { + let Some(data) = storage.load_share_by_key(key.clone()).ok() else { + continue; + }; + let Some(stored) = serde_json::from_slice::(&data).ok() else { + continue; + }; + live_keys.push(key.clone()); + let stale_age = now + .checked_sub(stored.last_check_timestamp) + .unwrap_or(u64::MAX); + results.push(KeyHealthStatusInfo { + group_pubkey: stored.group_pubkey, + share_index: stored.share_index, + last_check_timestamp: stored.last_check_timestamp, + responsive: stored.responsive, + created_at: stored.created_at.unwrap_or(stored.last_check_timestamp), + is_stale: stale_age >= keep_core::wallet::KEY_HEALTH_STALE_THRESHOLD_SECS, + is_critical: stale_age >= keep_core::wallet::KEY_HEALTH_CRITICAL_THRESHOLD_SECS, + }); + } + if live_keys.len() < index.len() { + let _ = persist_health_index(storage, &live_keys); + } + results +} diff --git a/keep-mobile/src/types.rs b/keep-mobile/src/types.rs index 7e04788e..617eb39f 100644 --- a/keep-mobile/src/types.rs +++ b/keep-mobile/src/types.rs @@ -113,3 +113,14 @@ pub struct AnnouncedXpubInfo { pub fingerprint: String, pub label: Option, } + +#[derive(uniffi::Record, Clone, Debug)] +pub struct KeyHealthStatusInfo { + pub group_pubkey: String, + pub share_index: u16, + pub last_check_timestamp: u64, + pub responsive: bool, + pub created_at: u64, + pub is_stale: bool, + pub is_critical: bool, +}