//! Secure credential storage using OS keychain. //! //! Uses the system's secure credential storage: //! - macOS: Keychain //! - Windows: Credential Manager //! - Linux: Secret Service (via D-Bus) //! //! Credentials are stored with a service name of "goingson" and //! a unique key per account based on the account ID. use keyring::Entry; use serde::{Deserialize, Serialize}; use tracing::{debug, error, info, warn}; use uuid::Uuid; const SERVICE_NAME: &str = "goingson"; /// Credentials stored in the keychain for an OAuth account. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthCredentials { pub access_token: String, pub refresh_token: Option, } /// Credentials stored in the keychain for a password-based email account. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PasswordCredentials { pub password: String, } /// Credentials stored in the keychain for a SyncKit sync session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncTokenCredentials { pub token: String, pub user_id: Uuid, pub app_id: Uuid, } /// Secure credential storage manager. pub struct CredentialStore; impl CredentialStore { /// Generate the keychain key for an account's OAuth tokens. fn oauth_key(account_id: Uuid) -> String { format!("oauth:{}", account_id) } /// Generate the keychain key for an account's password. fn password_key(account_id: Uuid) -> String { format!("password:{}", account_id) } /// Store OAuth credentials for an account. /// /// # Arguments /// * `account_id` - The email account UUID /// * `credentials` - The OAuth tokens to store /// /// # Returns /// Ok(()) on success, Err with message on failure pub fn store_oauth(account_id: Uuid, credentials: &OAuthCredentials) -> Result<(), String> { let key = Self::oauth_key(account_id); let entry = Entry::new(SERVICE_NAME, &key) .map_err(|e| format!("Failed to create keychain entry: {}", e))?; let json = serde_json::to_string(credentials) .map_err(|e| format!("Failed to serialize credentials: {}", e))?; entry .set_password(&json) .map_err(|e| format!("Failed to store in keychain: {}", e))?; debug!("Stored OAuth credentials for account {}", account_id); Ok(()) } /// Retrieve OAuth credentials for an account. /// /// # Arguments /// * `account_id` - The email account UUID /// /// # Returns /// Some(credentials) if found, None if not in keychain pub fn get_oauth(account_id: Uuid) -> Option { let key = Self::oauth_key(account_id); let entry = match Entry::new(SERVICE_NAME, &key) { Ok(e) => e, Err(e) => { warn!("Failed to create keychain entry for read: {}", e); return None; } }; match entry.get_password() { Ok(json) => match serde_json::from_str(&json) { Ok(creds) => { debug!("Retrieved OAuth credentials for account {}", account_id); Some(creds) } Err(e) => { error!("Failed to deserialize credentials: {}", e); None } }, Err(keyring::Error::NoEntry) => { debug!("No OAuth credentials found for account {}", account_id); None } Err(e) => { warn!("Failed to retrieve from keychain: {}", e); None } } } /// Update OAuth tokens for an account (typically after refresh). /// /// # Arguments /// * `account_id` - The email account UUID /// * `access_token` - New access token /// * `refresh_token` - New refresh token (if provided by the OAuth server) pub fn update_oauth_tokens( account_id: Uuid, access_token: &str, refresh_token: Option<&str>, ) -> Result<(), String> { // Get existing credentials to preserve refresh token if not updated let existing = Self::get_oauth(account_id); let refresh = refresh_token .map(String::from) .or_else(|| existing.and_then(|c| c.refresh_token)); let credentials = OAuthCredentials { access_token: access_token.to_string(), refresh_token: refresh, }; Self::store_oauth(account_id, &credentials) } /// Delete OAuth credentials for an account. /// /// # Arguments /// * `account_id` - The email account UUID pub fn delete_oauth(account_id: Uuid) -> Result<(), String> { let key = Self::oauth_key(account_id); let entry = Entry::new(SERVICE_NAME, &key) .map_err(|e| format!("Failed to create keychain entry: {}", e))?; match entry.delete_credential() { Ok(()) => { debug!("Deleted OAuth credentials for account {}", account_id); Ok(()) } Err(keyring::Error::NoEntry) => { debug!("No OAuth credentials to delete for account {}", account_id); Ok(()) } Err(e) => Err(format!("Failed to delete from keychain: {}", e)), } } /// Store password credentials for an account. /// /// # Arguments /// * `account_id` - The email account UUID /// * `password` - The password to store pub fn store_password(account_id: Uuid, password: &str) -> Result<(), String> { let key = Self::password_key(account_id); let entry = Entry::new(SERVICE_NAME, &key) .map_err(|e| format!("Failed to create keychain entry: {}", e))?; let credentials = PasswordCredentials { password: password.to_string(), }; let json = serde_json::to_string(&credentials) .map_err(|e| format!("Failed to serialize credentials: {}", e))?; entry .set_password(&json) .map_err(|e| format!("Failed to store in keychain: {}", e))?; debug!("Stored password credentials for account {}", account_id); Ok(()) } /// Retrieve password credentials for an account. /// /// # Arguments /// * `account_id` - The email account UUID /// /// # Returns /// Some(password) if found, None if not in keychain pub fn get_password(account_id: Uuid) -> Option { let key = Self::password_key(account_id); let entry = match Entry::new(SERVICE_NAME, &key) { Ok(e) => e, Err(e) => { warn!("Failed to create keychain entry for read: {}", e); return None; } }; match entry.get_password() { Ok(json) => match serde_json::from_str::(&json) { Ok(creds) => { debug!("Retrieved password for account {}", account_id); Some(creds.password) } Err(e) => { error!("Failed to deserialize password: {}", e); None } }, Err(keyring::Error::NoEntry) => { debug!("No password found for account {}", account_id); None } Err(e) => { warn!("Failed to retrieve from keychain: {}", e); None } } } /// Delete password credentials for an account. /// /// # Arguments /// * `account_id` - The email account UUID pub fn delete_password(account_id: Uuid) -> Result<(), String> { let key = Self::password_key(account_id); let entry = Entry::new(SERVICE_NAME, &key) .map_err(|e| format!("Failed to create keychain entry: {}", e))?; match entry.delete_credential() { Ok(()) => { debug!("Deleted password for account {}", account_id); Ok(()) } Err(keyring::Error::NoEntry) => { debug!("No password to delete for account {}", account_id); Ok(()) } Err(e) => Err(format!("Failed to delete from keychain: {}", e)), } } // ============ Sync Token Methods ============ /// Fixed keychain key for the SyncKit sync token (single-user, single sync account). fn sync_key() -> String { "sync:token".to_string() } /// Store SyncKit sync token and session info in the keychain. pub fn store_sync_token(token: &str, user_id: Uuid, app_id: Uuid) -> Result<(), String> { let key = Self::sync_key(); let entry = Entry::new(SERVICE_NAME, &key) .map_err(|e| format!("Failed to create keychain entry: {}", e))?; let creds = SyncTokenCredentials { token: token.to_string(), user_id, app_id, }; let json = serde_json::to_string(&creds) .map_err(|e| format!("Failed to serialize sync credentials: {}", e))?; entry .set_password(&json) .map_err(|e| format!("Failed to store sync token in keychain: {} ({:?})", e, e))?; info!("Stored sync token in keychain for user {}", user_id); Ok(()) } /// Retrieve SyncKit sync token and session info from the keychain. pub fn get_sync_token() -> Option { let key = Self::sync_key(); let entry = match Entry::new(SERVICE_NAME, &key) { Ok(e) => e, Err(e) => { warn!("Failed to create keychain entry for sync token read: {}", e); return None; } }; match entry.get_password() { Ok(json) => match serde_json::from_str(&json) { Ok(creds) => { debug!("Retrieved sync token from keychain"); Some(creds) } Err(e) => { error!("Failed to deserialize sync token: {}", e); None } }, Err(keyring::Error::NoEntry) => { info!("No sync token in keychain (NoEntry)"); None } Err(e) => { warn!("Keychain sync token retrieval failed: {} ({:?})", e, e); None } } } /// Delete SyncKit sync token from the keychain. pub fn delete_sync_token() -> Result<(), String> { let key = Self::sync_key(); let entry = Entry::new(SERVICE_NAME, &key) .map_err(|e| format!("Failed to create keychain entry: {}", e))?; match entry.delete_credential() { Ok(()) => { debug!("Deleted sync token from keychain"); Ok(()) } Err(keyring::Error::NoEntry) => { debug!("No sync token to delete"); Ok(()) } Err(e) => Err(format!("Failed to delete sync token from keychain: {}", e)), } } // ============ Sync API Key Methods ============ /// Fixed keychain key for the sync API key. fn sync_api_key() -> String { "sync:api_key".to_string() } /// Store a sync API key in the keychain. pub fn store_sync_api_key(api_key: &str) -> Result<(), String> { let key = Self::sync_api_key(); let entry = Entry::new(SERVICE_NAME, &key) .map_err(|e| format!("Failed to create keychain entry: {}", e))?; entry .set_password(api_key) .map_err(|e| format!("Failed to store sync API key in keychain: {}", e))?; debug!("Stored sync API key in keychain"); Ok(()) } /// Retrieve the sync API key from the keychain. pub fn get_sync_api_key() -> Option { let key = Self::sync_api_key(); let entry = match Entry::new(SERVICE_NAME, &key) { Ok(e) => e, Err(e) => { warn!("Failed to create keychain entry for sync API key read: {}", e); return None; } }; match entry.get_password() { Ok(api_key) => { debug!("Retrieved sync API key from keychain"); Some(api_key) } Err(keyring::Error::NoEntry) => None, Err(e) => { warn!("Failed to retrieve sync API key from keychain: {}", e); None } } } /// Migrate credentials from database to keychain. /// /// Call this on startup to move any plaintext credentials /// from SQLite to the secure keychain. /// /// # Arguments /// * `account_id` - The email account UUID /// * `oauth_access` - Access token from database (if any) /// * `oauth_refresh` - Refresh token from database (if any) /// * `password` - Password from database (if any) /// /// # Returns /// true if any credentials were migrated pub fn migrate_from_database( account_id: Uuid, oauth_access: Option<&str>, oauth_refresh: Option<&str>, password: Option<&str>, ) -> bool { let mut migrated = false; // Migrate OAuth tokens if let Some(access) = oauth_access { if !access.is_empty() && Self::get_oauth(account_id).is_none() { let creds = OAuthCredentials { access_token: access.to_string(), refresh_token: oauth_refresh.map(String::from), }; if Self::store_oauth(account_id, &creds).is_ok() { debug!("Migrated OAuth credentials for account {}", account_id); migrated = true; } } } // Migrate password if let Some(pwd) = password { if !pwd.is_empty() && Self::get_password(account_id).is_none() && Self::store_password(account_id, pwd).is_ok() { debug!("Migrated password for account {}", account_id); migrated = true; } } migrated } } #[cfg(test)] mod tests { use super::*; // Note: These tests interact with the real system keychain. // They use a test-specific UUID to avoid conflicts. #[test] #[ignore] // Requires macOS keychain — fails on Linux CI fn test_oauth_roundtrip() { let test_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); // Clean up any existing test data let _ = CredentialStore::delete_oauth(test_id); // Store credentials let creds = OAuthCredentials { access_token: "test_access_token".to_string(), refresh_token: Some("test_refresh_token".to_string()), }; CredentialStore::store_oauth(test_id, &creds).expect("Failed to store"); // Retrieve and verify let retrieved = CredentialStore::get_oauth(test_id).expect("Failed to retrieve"); assert_eq!(retrieved.access_token, "test_access_token"); assert_eq!( retrieved.refresh_token, Some("test_refresh_token".to_string()) ); // Clean up CredentialStore::delete_oauth(test_id).expect("Failed to delete"); assert!(CredentialStore::get_oauth(test_id).is_none()); } #[test] #[ignore] // Requires macOS keychain — fails on Linux CI fn test_password_roundtrip() { let test_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); // Clean up any existing test data let _ = CredentialStore::delete_password(test_id); // Store password CredentialStore::store_password(test_id, "test_password").expect("Failed to store"); // Retrieve and verify let retrieved = CredentialStore::get_password(test_id).expect("Failed to retrieve"); assert_eq!(retrieved, "test_password"); // Clean up CredentialStore::delete_password(test_id).expect("Failed to delete"); assert!(CredentialStore::get_password(test_id).is_none()); } }