diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs index c62d28c3..ca158f5a 100644 --- a/examples/test_heartbeat.rs +++ b/examples/test_heartbeat.rs @@ -28,7 +28,7 @@ async fn main() -> anyhow::Result<()> { println!("=== Heartbeat Integration Test ===\n"); // 1. Load config - let config = Config::from_env().map_err(|e| anyhow::anyhow!("Config: {}", e))?; + let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("Config: {}", e))?; println!("[1/6] Config loaded"); println!(" heartbeat.enabled = {}", config.heartbeat.enabled); println!( diff --git a/src/cli/config.rs b/src/cli/config.rs index d248fcaf..7754c925 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -71,7 +71,7 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { /// Bootstrap a DB connection for config commands. async fn connect_store() -> anyhow::Result { - let config = crate::config::Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?; + let config = crate::config::Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; let store = crate::history::Store::new(&config.database).await?; store.run_migrations().await?; Ok(store) diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index 311aedc1..4600e302 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -467,7 +467,7 @@ const DEFAULT_USER_ID: &str = "default"; /// Try to connect to the database store for DB-backed config. async fn connect_store() -> Option { - let config = Config::from_env().ok()?; + let config = Config::from_env().await.ok()?; let store = Store::new(&config.database).await.ok()?; store.run_migrations().await.ok()?; Some(store) @@ -496,7 +496,7 @@ async fn save_servers( /// Initialize and return the secrets store. async fn get_secrets_store() -> anyhow::Result> { - let config = Config::from_env()?; + let config = Config::from_env().await?; let master_key = config.secrets.master_key().ok_or_else(|| { anyhow::anyhow!( diff --git a/src/cli/status.rs b/src/cli/status.rs index 53940472..0db48c90 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -47,7 +47,7 @@ pub async fn run_status_command() -> anyhow::Result<()> { print!(" Secrets: "); let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None || std::env::var("SECRETS_MASTER_KEY").is_ok() - || crate::secrets::keychain::has_master_key(); + || crate::secrets::keychain::has_master_key().await; if secrets_configured { println!("configured ({:?})", settings.secrets_master_key_source); } else { diff --git a/src/cli/tool.rs b/src/cli/tool.rs index 9a86bfd7..316ffa2c 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -715,7 +715,7 @@ async fn auth_tool(name: String, dir: Option, user_id: String) -> anyho println!(); // Initialize secrets store - let config = Config::from_env()?; + let config = Config::from_env().await?; let master_key = config.secrets.master_key().ok_or_else(|| { anyhow::anyhow!( "SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env" diff --git a/src/config.rs b/src/config.rs index fd60caa4..a1f93a8d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,7 +53,7 @@ impl Config { } }; - Self::build(bootstrap, &db_settings) + Self::build(bootstrap, &db_settings).await } /// Load configuration from environment variables only (no database). @@ -61,15 +61,15 @@ impl Config { /// Used during early startup before the database is connected, /// and by CLI commands that don't have DB access. /// Falls back to legacy `settings.json` on disk if present. - pub fn from_env() -> Result { + pub async fn from_env() -> Result { let _ = dotenvy::dotenv(); let bootstrap = crate::bootstrap::BootstrapConfig::load(); let settings = Settings::load(); - Self::build(&bootstrap, &settings) + Self::build(&bootstrap, &settings).await } /// Build config from bootstrap + settings (shared by from_env and from_db). - fn build( + async fn build( bootstrap: &crate::bootstrap::BootstrapConfig, settings: &Settings, ) -> Result { @@ -82,7 +82,7 @@ impl Config { agent: AgentConfig::resolve(settings)?, safety: SafetyConfig::resolve()?, wasm: WasmConfig::resolve()?, - secrets: SecretsConfig::resolve(bootstrap)?, + secrets: SecretsConfig::resolve(bootstrap).await?, builder: BuilderModeConfig::resolve()?, heartbeat: HeartbeatConfig::resolve(settings)?, routines: RoutineConfig::resolve()?, @@ -604,27 +604,32 @@ impl std::fmt::Debug for SecretsConfig { } impl SecretsConfig { - fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { + async fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { use crate::settings::KeySource; let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? { (Some(SecretString::from(env_key)), KeySource::Env) } else { match bootstrap.secrets_master_key_source { - KeySource::Keychain => match crate::secrets::keychain::get_master_key() { - Ok(key_bytes) => { - let key_hex: String = - key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); - (Some(SecretString::from(key_hex)), KeySource::Keychain) - } - Err(_) => { - tracing::warn!( - "Secrets configured for keychain but key not found. \ + KeySource::Keychain => { + // Try to load from OS keychain (async on Linux) + match crate::secrets::keychain::get_master_key().await { + Ok(key_bytes) => { + let key_hex: String = + key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); + (Some(SecretString::from(key_hex)), KeySource::Keychain) + } + Err(_) => { + // Keychain configured but key not found + // This might happen if keychain was cleared + tracing::warn!( + "Secrets configured for keychain but key not found. \ Run 'ironclaw onboard' to reconfigure." - ); - (None, KeySource::None) + ); + (None, KeySource::None) + } } - }, + } KeySource::Env => { tracing::warn!( "Secrets configured for env var but SECRETS_MASTER_KEY not set." diff --git a/src/main.rs b/src/main.rs index 0247e163..de53c90a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,7 +84,7 @@ async fn main() -> anyhow::Result<()> { // Memory commands need database (and optionally embeddings) let _ = dotenvy::dotenv(); - let config = Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?; + let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; let store = ironclaw::history::Store::new(&config.database).await?; store.run_migrations().await?; @@ -240,7 +240,7 @@ async fn main() -> anyhow::Result<()> { // Enhanced first-run detection if !cli.no_onboard { - if let Some(reason) = check_onboard_needed() { + if let Some(reason) = check_onboard_needed().await { println!("Onboarding needed: {}", reason); println!(); let mut wizard = SetupWizard::new(); @@ -252,7 +252,7 @@ async fn main() -> anyhow::Result<()> { let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); // Load initial config from env + disk (before DB is available) - let mut config = match Config::from_env() { + let mut config = match Config::from_env().await { Ok(c) => c, Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => { eprintln!("Configuration error: Missing required setting '{}'", key); @@ -1043,7 +1043,7 @@ async fn main() -> anyhow::Result<()> { /// Check if onboarding is needed and return the reason. /// /// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise. -fn check_onboard_needed() -> Option<&'static str> { +async fn check_onboard_needed() -> Option<&'static str> { let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); // Database not configured (and not in env) @@ -1054,7 +1054,7 @@ fn check_onboard_needed() -> Option<&'static str> { // Secrets not configured (and not in env) if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None && std::env::var("SECRETS_MASTER_KEY").is_err() - && !ironclaw::secrets::keychain::has_master_key() + && !ironclaw::secrets::keychain::has_master_key().await { // Only require secrets setup if user hasn't explicitly disabled it // For now, we don't require it for first run diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs index e81efcd0..0ab810ba 100644 --- a/src/secrets/keychain.rs +++ b/src/secrets/keychain.rs @@ -52,7 +52,7 @@ mod platform { use super::*; /// Store the master key in the macOS Keychain. - pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { // Convert to hex for storage (keychain prefers strings) let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); @@ -61,7 +61,7 @@ mod platform { } /// Retrieve the master key from the macOS Keychain. - pub fn get_master_key() -> Result, SecretError> { + pub async fn get_master_key() -> Result, SecretError> { let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { SecretError::KeychainError(format!("Failed to get from keychain: {}", e)) })?; @@ -74,14 +74,14 @@ mod platform { } /// Delete the master key from the macOS Keychain. - pub fn delete_master_key() -> Result<(), SecretError> { + pub async fn delete_master_key() -> Result<(), SecretError> { delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { SecretError::KeychainError(format!("Failed to delete from keychain: {}", e)) }) } /// Check if a master key exists in the keychain. - pub fn has_master_key() -> bool { + pub async fn has_master_key() -> bool { get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok() } } @@ -97,163 +97,141 @@ mod platform { use super::*; /// Store the master key in the Linux secret service (GNOME Keyring, KWallet). - pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { - let rt = tokio::runtime::Handle::try_current() - .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; - - rt.block_on(async { - let ss = SecretService::connect(EncryptionType::Dh) - .await - .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) - })?; - - let collection = ss.get_default_collection().await.map_err(|e| { - SecretError::KeychainError(format!("Failed to get collection: {}", e)) + pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) })?; - // Unlock if needed - if collection.is_locked().await.unwrap_or(true) { - collection.unlock().await.map_err(|e| { - SecretError::KeychainError(format!("Failed to unlock collection: {}", e)) - })?; - } + let collection = ss.get_default_collection().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to get collection: {}", e)) + })?; - // Convert to hex for storage - let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + // Unlock if needed + if collection.is_locked().await.unwrap_or(true) { + collection.unlock().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to unlock collection: {}", e)) + })?; + } - collection - .create_item( - &format!("{} master key", SERVICE_NAME), - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - key_hex.as_bytes(), - true, // Replace if exists - "text/plain", - ) - .await - .map_err(|e| { - SecretError::KeychainError(format!("Failed to create secret: {}", e)) - })?; + // Convert to hex for storage + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); - Ok(()) - }) + collection + .create_item( + &format!("{} master key", SERVICE_NAME), + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + key_hex.as_bytes(), + true, // Replace if exists + "text/plain", + ) + .await + .map_err(|e| { + SecretError::KeychainError(format!("Failed to create secret: {}", e)) + })?; + + Ok(()) } /// Retrieve the master key from the Linux secret service. - pub fn get_master_key() -> Result, SecretError> { - let rt = tokio::runtime::Handle::try_current() - .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + pub async fn get_master_key() -> Result, SecretError> { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; - rt.block_on(async { - let ss = SecretService::connect(EncryptionType::Dh) + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + let item = items + .unlocked + .first() + .or(items.locked.first()) + .ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?; + + // Unlock if needed + if item.is_locked().await.unwrap_or(true) { + item.unlock() .await - .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) - })?; + .map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?; + } - let items = ss - .search_items( - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - ) - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + let secret = item + .get_secret() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?; - let item = items - .unlocked - .first() - .or(items.locked.first()) - .ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?; + let hex_str = String::from_utf8(secret) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?; - // Unlock if needed - if item.is_locked().await.unwrap_or(true) { - item.unlock() - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?; - } - - let secret = item - .get_secret() - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?; - - let hex_str = String::from_utf8(secret) - .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?; - - hex_to_bytes(&hex_str) - }) + hex_to_bytes(&hex_str) } /// Delete the master key from the Linux secret service. - pub fn delete_master_key() -> Result<(), SecretError> { - let rt = tokio::runtime::Handle::try_current() - .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + pub async fn delete_master_key() -> Result<(), SecretError> { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; - rt.block_on(async { - let ss = SecretService::connect(EncryptionType::Dh) + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + for item in items.unlocked.iter().chain(items.locked.iter()) { + item.delete() .await - .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) - })?; + .map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?; + } - let items = ss - .search_items( - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - ) - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; - - for item in items.unlocked.iter().chain(items.locked.iter()) { - item.delete() - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?; - } - - Ok(()) - }) + Ok(()) } /// Check if a master key exists in the secret service. - pub fn has_master_key() -> bool { - let rt = match tokio::runtime::Handle::try_current() { - Ok(rt) => rt, + pub async fn has_master_key() -> bool { + let ss = match SecretService::connect(EncryptionType::Dh).await { + Ok(ss) => ss, Err(_) => return false, }; - rt.block_on(async { - let ss = match SecretService::connect(EncryptionType::Dh).await { - Ok(ss) => ss, - Err(_) => return false, - }; + let items = match ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + { + Ok(items) => items, + Err(_) => return false, + }; - let items = match ss - .search_items( - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - ) - .await - { - Ok(items) => items, - Err(_) => return false, - }; - - !items.unlocked.is_empty() || !items.locked.is_empty() - }) + !items.unlocked.is_empty() || !items.locked.is_empty() } } @@ -265,25 +243,25 @@ mod platform { mod platform { use super::*; - pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { + pub async fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { Err(SecretError::KeychainError( "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), )) } - pub fn get_master_key() -> Result, SecretError> { + pub async fn get_master_key() -> Result, SecretError> { Err(SecretError::KeychainError( "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), )) } - pub fn delete_master_key() -> Result<(), SecretError> { + pub async fn delete_master_key() -> Result<(), SecretError> { Err(SecretError::KeychainError( "Keychain not supported on this platform".to_string(), )) } - pub fn has_master_key() -> bool { + pub async fn has_master_key() -> bool { false } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index adf76d25..db447369 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -266,7 +266,7 @@ impl SetupWizard { async fn step_security(&mut self) -> Result<(), SetupError> { // Check current configuration let env_key_exists = std::env::var("SECRETS_MASTER_KEY").is_ok(); - let keychain_key_exists = crate::secrets::keychain::has_master_key(); + let keychain_key_exists = crate::secrets::keychain::has_master_key().await; if env_key_exists { print_info("Secrets master key found in SECRETS_MASTER_KEY environment variable."); @@ -304,9 +304,11 @@ impl SetupWizard { print_info("Generating master key..."); let key = crate::secrets::keychain::generate_master_key(); - crate::secrets::keychain::store_master_key(&key).map_err(|e| { - SetupError::Config(format!("Failed to store in keychain: {}", e)) - })?; + crate::secrets::keychain::store_master_key(&key) + .await + .map_err(|e| { + SetupError::Config(format!("Failed to store in keychain: {}", e)) + })?; // Also create crypto instance let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); @@ -550,7 +552,7 @@ impl SetupWizard { // Try to load master key from keychain or env let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") { env_key - } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key() { + } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key().await { keychain_key.iter().map(|b| format!("{:02x}", b)).collect() } else { return Err(SetupError::Config(