//! Credential storage with OS keyring and encrypted file fallback. //! //! Passwords are stored via the OS-native credential store when available //! (macOS Keychain, Linux Secret Service, Windows Credential Manager). In //! environments without a keyring backend (Docker, headless servers), an //! encrypted file in the config directory is used instead. //! //! # Encrypted file backend — threat model //! //! The encrypted file backend prevents casual exposure — passwords won't //! appear in `grep`, `cat`, or accidental backups. However, the encryption //! key resides in the same directory as the ciphertext. An attacker with //! filesystem access to the config volume can decrypt the password. For //! stronger protection, use `--password-command` with an external secret //! manager or the OS keyring on native platforms. use std::path::{Path, PathBuf}; use aes_gcm::aead::{Aead, KeyInit, OsRng}; use aes_gcm::{AeadCore, Aes256Gcm}; use anyhow::{Context, Result}; use secrecy::SecretString; /// Service name used for keyring entries. const KEYRING_SERVICE: &str = "kei"; #[derive(Debug)] enum DeleteOutcome { Deleted, NotFound, Failed(anyhow::Error), } /// Backend that accepted a stored credential. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CredentialBackend { Keyring, EncryptedFile, } impl CredentialBackend { pub(crate) fn as_str(self) -> &'static str { match self { Self::Keyring => "keyring", Self::EncryptedFile => "encrypted-file", } } } /// Credential store that tries the OS keyring first, falling back to an /// AES-256-GCM encrypted file in the config directory. #[derive(Debug)] pub(crate) struct CredentialStore { username: String, config_dir: PathBuf, realm: String, } impl CredentialStore { pub(crate) fn new(username: &str, config_dir: &Path, realm: &str) -> Self { Self { username: username.to_string(), config_dir: config_dir.to_path_buf(), realm: realm.to_string(), } } /// Store a password. Tries keyring first, falls back to encrypted file. pub(crate) fn store(&self, password: &str) -> Result { match self.keyring_store(password) { Ok(()) => { tracing::debug!(backend = "keyring", "Credential stored"); Ok(CredentialBackend::Keyring) } Err(e) => { tracing::warn!( error = %e, "Keyring unavailable; falling back to encrypted file in config directory" ); self.file_store(password)?; tracing::debug!(backend = "encrypted-file", "Credential stored"); Ok(CredentialBackend::EncryptedFile) } } } /// Retrieve a stored password. Tries keyring first, falls back to encrypted file. pub(crate) fn retrieve(&self) -> Result> { match self.keyring_retrieve() { Ok(Some(pw)) => { tracing::debug!(backend = "keyring", "Credential retrieved"); return Ok(Some(pw)); } Ok(None) => {} Err(e) => { tracing::warn!( error = %e, "Keyring unavailable; trying encrypted file in config directory" ); } } let result = self.file_retrieve()?; if result.is_some() { tracing::debug!(backend = "encrypted-file", "Credential retrieved"); } Ok(result) } /// Delete stored credentials from all backends. pub(crate) fn delete(&self) -> Result<()> { finish_delete( &self.username, self.keyring_delete_outcome(), self.file_delete_outcome(), ) } /// Check whether a credential exists in any backend (keyring or file). pub(crate) fn has_credential(&self) -> bool { if self.keyring_retrieve().ok().flatten().is_some() { return true; } self.credential_file_path().exists() } /// Return the name of the currently active backend. pub(crate) fn backend_name(&self) -> &'static str { if self .keyring_entry() .and_then(|e| e.get_password().map_err(Into::into)) .is_ok() { return "keyring"; } if self.credential_file_path().exists() { "encrypted-file" } else { "none" } } // ── Keyring backend ──────────────────���───────────────────────── fn keyring_entry(&self) -> Result { keyring::Entry::new(KEYRING_SERVICE, &self.username) .context("Could not open the OS keyring entry") } fn keyring_store(&self, password: &str) -> Result<()> { let entry = self.keyring_entry()?; entry .set_password(password) .context("Could not store password in the OS keyring") } fn keyring_retrieve(&self) -> Result> { let entry = self.keyring_entry()?; match entry.get_password() { Ok(pw) => Ok(Some(SecretString::from(pw))), Err(keyring::Error::NoEntry) => Ok(None), Err(e) => { Err(anyhow::anyhow!(e).context("Could not read password from the OS keyring")) } } } fn keyring_delete_outcome(&self) -> DeleteOutcome { let entry = match self.keyring_entry() { Ok(entry) => entry, Err(e) => return DeleteOutcome::Failed(e), }; match entry.delete_credential() { Ok(()) => DeleteOutcome::Deleted, Err(e) => keyring_delete_error_outcome(e), } } // ── Encrypted file backend ───────────────────────────────────── fn key_file_path(&self) -> PathBuf { self.config_dir.join(".kei-state") } fn credential_file_path(&self) -> PathBuf { let sanitized = crate::account::namespace(&self.username, &self.realm); self.config_dir.join(format!("{sanitized}.credential")) } /// Load or generate the AES-256 key. fn load_or_create_key(&self) -> Result<[u8; 32]> { let key_path = self.key_file_path(); if key_path.exists() { let data = std::fs::read(&key_path).with_context(|| { format!("Could not read credential key file {}", key_path.display()) })?; anyhow::ensure!( data.len() == 32, "Credential key file is corrupt: expected 32 bytes, got {}.", data.len() ); let mut key = [0u8; 32]; key.copy_from_slice(&data); Ok(key) } else { let key: [u8; 32] = rand::random(); atomic_write_sync(&key_path, &key)?; Ok(key) } } fn file_store(&self, password: &str) -> Result<()> { std::fs::create_dir_all(&self.config_dir).with_context(|| { format!( "Could not create config directory {}", self.config_dir.display() ) })?; let key_bytes = self.load_or_create_key()?; let cipher = Aes256Gcm::new_from_slice(&key_bytes) .context("Could not initialize credential encryption")?; let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, password.as_bytes()) .map_err(|e| anyhow::anyhow!("Could not encrypt credential: {e}"))?; // File format: 12-byte nonce ‖ ciphertext let mut data = Vec::with_capacity(12 + ciphertext.len()); data.extend_from_slice(&nonce); data.extend_from_slice(&ciphertext); atomic_write_sync(&self.credential_file_path(), &data) } fn file_retrieve(&self) -> Result> { let cred_path = self.credential_file_path(); if !cred_path.exists() { return Ok(None); } let key_path = self.key_file_path(); anyhow::ensure!( key_path.exists(), "Credential file exists but key file is missing: {}", key_path.display() ); let key_bytes = self.load_or_create_key()?; let data = std::fs::read(&cred_path) .with_context(|| format!("Could not read credential file {}", cred_path.display()))?; anyhow::ensure!( data.len() > 12, "Credential file is corrupt or incomplete: {}", cred_path.display() ); let (nonce_bytes, ciphertext) = data.split_at(12); let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); let cipher = Aes256Gcm::new_from_slice(&key_bytes) .context("Could not initialize credential decryption")?; let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_e| { anyhow::anyhow!("Could not decrypt stored credential. The key may be wrong or the credential file may be corrupt.") })?; let password = String::from_utf8(plaintext).context("Stored credential is not valid UTF-8")?; Ok(Some(SecretString::from(password))) } #[cfg(test)] fn file_delete(&self) -> Result<()> { match self.file_delete_outcome() { DeleteOutcome::Deleted => Ok(()), DeleteOutcome::NotFound => { anyhow::bail!( "No credential file found at {}", self.credential_file_path().display() ) } DeleteOutcome::Failed(e) => Err(e), } } fn file_delete_outcome(&self) -> DeleteOutcome { let cred_path = self.credential_file_path(); if !cred_path.exists() { return DeleteOutcome::NotFound; } match std::fs::remove_file(&cred_path) { Ok(()) => DeleteOutcome::Deleted, Err(e) if e.kind() == std::io::ErrorKind::NotFound => DeleteOutcome::NotFound, Err(e) => DeleteOutcome::Failed(anyhow::anyhow!(e).context(format!( "Could not delete credential file {}", cred_path.display() ))), } } } fn keyring_delete_error_outcome(error: keyring::Error) -> DeleteOutcome { match error { keyring::Error::NoEntry => DeleteOutcome::NotFound, keyring::Error::PlatformFailure(e) if is_keyring_platform_unavailable(e.as_ref()) => { tracing::debug!( error = %e, "Keyring unavailable during credential deletion; treating it as absent" ); DeleteOutcome::NotFound } e => DeleteOutcome::Failed( anyhow::anyhow!(e).context("Could not delete the OS keyring credential"), ), } } fn is_keyring_platform_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool { let message = error.to_string(); message.contains("org.freedesktop.secrets") || message.contains("The name is not activatable") || (message.contains("Failed to connect") && message.contains("/run/user/") && message.contains("/bus")) } fn finish_delete( username: &str, keyring: DeleteOutcome, encrypted_file: DeleteOutcome, ) -> Result<()> { let mut deleted = Vec::new(); let mut failed = Vec::new(); match keyring { DeleteOutcome::Deleted => deleted.push("keyring"), DeleteOutcome::NotFound => {} DeleteOutcome::Failed(e) => failed.push(("keyring", e)), } match encrypted_file { DeleteOutcome::Deleted => deleted.push("encrypted-file"), DeleteOutcome::NotFound => {} DeleteOutcome::Failed(e) => failed.push(("encrypted-file", e)), } if failed.is_empty() { if deleted.is_empty() { anyhow::bail!("No stored credential was found for {username}."); } return Ok(()); } let failed_names = failed .iter() .map(|(name, _)| *name) .collect::>() .join(", "); let details = failed .into_iter() .map(|(name, err)| format!("{name}: {err:#}")) .collect::>() .join("; "); if deleted.is_empty() { anyhow::bail!("Could not delete credential from {failed_names}: {details}"); } anyhow::bail!( "Credential was deleted from {}, but kei could not delete it from {failed_names}: {details}", deleted.join(", ") ); } /// Atomically write data to a file with 0o600 permissions (synchronous). /// /// The temp file is fsynced before the rename and the parent directory is /// fsynced afterwards (Unix only) so a power loss between the rename /// returning and the kernel committing data + directory blocks can't leave /// the credential file pointing at uninitialised content. A corrupt /// credential file forces the user to re-enter their password (loud /// recovery), which is acceptable; an empty-or-garbage credential file /// silently failing decrypt is the failure mode this avoids. fn atomic_write_sync(path: &Path, data: &[u8]) -> Result<()> { use std::io::Write; let mut tmp_name = path.file_name().unwrap_or_default().to_os_string(); tmp_name.push(".tmp"); let tmp = path.with_file_name(tmp_name); { let mut f = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&tmp) .with_context(|| format!("Failed to open temp file: {}", tmp.display()))?; f.write_all(data) .with_context(|| format!("Failed to write temp file: {}", tmp.display()))?; f.sync_all() .with_context(|| format!("Failed to fsync temp file: {}", tmp.display()))?; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; } std::fs::rename(&tmp, path) .with_context(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?; if let Err(e) = crate::fs_util::fsync_parent_dir(path) { tracing::warn!( path = %path.display(), error = %e, "fsync of parent directory failed after atomic_write_sync" ); } Ok(()) } #[cfg(test)] mod tests { use super::*; use secrecy::ExposeSecret; /// Each test gets its own temp directory to avoid parallel test /// interference with the shared `.kei-state` key file. fn test_dir(_name: &str) -> (tempfile::TempDir, PathBuf) { let td = tempfile::tempdir().unwrap(); let path = td.path().to_path_buf(); (td, path) } #[test] fn encrypted_namespace_does_not_read_copy_or_delete_colliding_legacy_credentials() { let directory = tempfile::tempdir().unwrap(); let a = "first.last@example.invalid"; let b = "firstlast@example.invalid"; let legacy = directory.path().join("firstlastexampleinvalid.credential"); std::fs::write(&legacy, b"synthetic legacy ciphertext").unwrap(); let store = CredentialStore::new(a, directory.path(), "com"); let other = CredentialStore::new(b, directory.path(), "com"); let china = CredentialStore::new(a, directory.path(), "cn"); assert!(store.file_retrieve().unwrap().is_none()); assert_ne!(store.credential_file_path(), other.credential_file_path()); assert_ne!(store.credential_file_path(), china.credential_file_path()); store.file_store("synthetic new password").unwrap(); assert!(other.file_retrieve().unwrap().is_none()); assert!(china.file_retrieve().unwrap().is_none()); store.file_delete_outcome(); assert_eq!( std::fs::read(legacy).unwrap(), b"synthetic legacy ciphertext" ); } #[test] fn encrypted_file_store_retrieve_cycle() { let (_td, dir) = test_dir("store_retrieve"); let store = CredentialStore::new("user@example.com", &dir, "com"); store.file_store("super_secret_pw").unwrap(); let retrieved = store.file_retrieve().unwrap().unwrap(); assert_eq!(retrieved.expose_secret(), "super_secret_pw"); } #[test] fn encrypted_file_missing_returns_none() { let (_td, dir) = test_dir("missing"); let store = CredentialStore::new("user@example.com", &dir, "com"); let result = store.file_retrieve().unwrap(); assert!(result.is_none()); } #[test] fn encrypted_file_delete() { let (_td, dir) = test_dir("delete"); let store = CredentialStore::new("user@example.com", &dir, "com"); store.file_store("to_be_deleted").unwrap(); assert!(store.credential_file_path().exists()); store.file_delete().unwrap(); assert!(!store.credential_file_path().exists()); // Key file intentionally preserved assert!(store.key_file_path().exists()); } #[test] fn delete_outcome_file_only_success_reports_backend_state() { let cases = [ ( "only encrypted-file deleted", DeleteOutcome::NotFound, DeleteOutcome::Deleted, ), ( "only keyring deleted", DeleteOutcome::Deleted, DeleteOutcome::NotFound, ), ]; for (label, keyring, file) in cases { finish_delete("user@example.com", keyring, file) .unwrap_or_else(|err| panic!("{label} must be treated as success: {err}")); } let partial = finish_delete( "user@example.com", DeleteOutcome::Failed(anyhow::anyhow!("keyring locked")), DeleteOutcome::Deleted, ) .unwrap_err() .to_string(); assert!( partial.contains("deleted from encrypted-file") && partial.contains("could not delete it from keyring"), "partial delete must preserve which backend succeeded and which failed: {partial}" ); let missing = finish_delete( "user@example.com", DeleteOutcome::NotFound, DeleteOutcome::NotFound, ) .unwrap_err() .to_string(); assert!( missing.contains("No stored credential was found"), "two missing backends must not be treated as file-only success: {missing}" ); } #[test] fn delete_outcome_both_missing_reports_no_stored_credential() { let err = finish_delete( "user@example.com", DeleteOutcome::NotFound, DeleteOutcome::NotFound, ) .unwrap_err(); assert!( err.to_string().contains("No stored credential was found"), "expected no-credential error, got: {err}" ); } #[test] fn delete_outcome_deleted_plus_failure_reports_partial_backend() { let err = finish_delete( "user@example.com", DeleteOutcome::Deleted, DeleteOutcome::Failed(anyhow::anyhow!("permission denied")), ) .unwrap_err(); let msg = err.to_string(); assert!( msg.contains("could not delete it from encrypted-file"), "partial failure must name the failed backend: {msg}" ); } #[test] fn delete_outcome_failure_without_delete_names_backend() { let err = finish_delete( "user@example.com", DeleteOutcome::Failed(anyhow::anyhow!("keyring locked")), DeleteOutcome::NotFound, ) .unwrap_err(); let msg = err.to_string(); assert!( msg.contains("keyring") && msg.contains("keyring locked"), "failed deletion must name backend and source error: {msg}" ); } #[test] fn unavailable_secret_service_delete_is_not_found() { for message in [ "DBus error: The name org.freedesktop.secrets was not provided by any .service files", "DBus error: The name is not activatable", ] { let outcome = keyring_delete_error_outcome(keyring::Error::PlatformFailure(Box::new( std::io::Error::other(message), ))); assert!( matches!(outcome, DeleteOutcome::NotFound), "headless Secret Service delete should behave like an absent keyring" ); } } #[test] fn inaccessible_keyring_delete_still_fails() { let outcome = keyring_delete_error_outcome(keyring::Error::NoStorageAccess(Box::new( std::io::Error::other("keyring locked"), ))); assert!( matches!(outcome, DeleteOutcome::Failed(_)), "locked keyrings should not be treated as absent" ); } #[test] fn encrypted_file_corrupt_data() { let (_td, dir) = test_dir("corrupt"); let store = CredentialStore::new("user@example.com", &dir, "com"); store.file_store("valid").unwrap(); // Overwrite credential with garbage (too short) std::fs::write(store.credential_file_path(), b"short").unwrap(); let err = store.file_retrieve().unwrap_err(); assert!(err.to_string().contains("corrupt"), "{err}"); } #[test] fn encrypted_file_wrong_key() { let (_td, dir) = test_dir("wrong_key"); let store = CredentialStore::new("user@example.com", &dir, "com"); store.file_store("secret").unwrap(); // Overwrite key with different random key let bad_key: [u8; 32] = rand::random(); std::fs::write(store.key_file_path(), bad_key).unwrap(); let err = store.file_retrieve().unwrap_err(); assert!( err.to_string().contains("decrypt"), "Expected decryption error, got: {err}" ); } #[test] fn encrypted_file_rejects_valid_decrypt_invalid_utf8() { let (_td, dir) = test_dir("invalid_utf8"); let store = CredentialStore::new("user@example.com", &dir, "com"); std::fs::create_dir_all(&dir).unwrap(); let key_bytes = store.load_or_create_key().unwrap(); let cipher = Aes256Gcm::new_from_slice(&key_bytes).unwrap(); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher.encrypt(&nonce, [0xff, 0xfe].as_slice()).unwrap(); let mut data = Vec::with_capacity(12 + ciphertext.len()); data.extend_from_slice(&nonce); data.extend_from_slice(&ciphertext); atomic_write_sync(&store.credential_file_path(), &data).unwrap(); let err = store.file_retrieve().unwrap_err(); assert!( err.to_string().contains("valid UTF-8"), "invalid UTF-8 plaintext must be rejected loudly, got: {err}" ); } #[test] fn encrypted_file_key_generation() { let (_td, dir) = test_dir("keygen"); let store = CredentialStore::new("user@example.com", &dir, "com"); assert!(!store.key_file_path().exists()); store.file_store("pw").unwrap(); assert!(store.key_file_path().exists()); let key = std::fs::read(store.key_file_path()).unwrap(); assert_eq!(key.len(), 32); } #[test] fn legacy_credential_key_is_not_auto_renamed() { let (_td, dir) = test_dir("legacy_key"); let legacy_path = dir.join(".credential-key"); std::fs::write(&legacy_path, [7u8; 32]).unwrap(); let store = CredentialStore::new("user@example.com", &dir, "com"); let key_path = store.key_file_path(); assert_eq!(key_path, dir.join(".kei-state")); assert!( legacy_path.exists(), "legacy .credential-key must be left for manual user migration" ); assert!( !key_path.exists(), ".kei-state must not be created or renamed by path lookup" ); store.load_or_create_key().unwrap(); assert!( legacy_path.exists(), "creating .kei-state must not consume legacy .credential-key" ); assert!(key_path.exists()); assert_ne!( std::fs::read(key_path).unwrap(), vec![7u8; 32], ".kei-state must not reuse the legacy key bytes automatically" ); } #[test] fn has_credential_with_file() { let (_td, dir) = test_dir("has_cred"); let store = CredentialStore::new("user@example.com", &dir, "com"); assert!(!store.credential_file_path().exists()); store.file_store("pw").unwrap(); assert!(store.has_credential()); } #[test] fn public_api_store_retrieve_round_trips() { let (_td, dir) = test_dir("pub_rt"); let store = CredentialStore::new("pub-rt@kei-test.invalid", &dir, "com"); store.store("public_api_password").unwrap(); let retrieved = store.retrieve().unwrap().unwrap(); assert_eq!(retrieved.expose_secret(), "public_api_password"); let _ = store.delete(); } #[test] fn public_api_delete_clears_credential() { let (_td, dir) = test_dir("pub_delete"); let store = CredentialStore::new("pub-del@kei-test.invalid", &dir, "com"); store.store("to_delete").unwrap(); assert!(store.retrieve().unwrap().is_some()); let delete_result = store.delete(); if let Err(err) = delete_result { assert!( err.to_string().contains("partially failed") && err.to_string().contains("keyring"), "headless keyring failures should surface as partial deletion, got: {err}" ); assert!( !store.credential_file_path().exists(), "partial delete should still remove the encrypted-file credential" ); } assert!(store.retrieve().unwrap().is_none()); } #[test] fn public_api_retrieve_empty_returns_none() { let (_td, dir) = test_dir("pub_empty"); let store = CredentialStore::new("pub-empty@kei-test.invalid", &dir, "com"); let result = store.retrieve().unwrap(); assert!(result.is_none()); } #[test] fn atomic_write_sync_permissions() { let (_td, dir) = test_dir("atomic_perms"); let path = dir.join("test_atomic.bin"); atomic_write_sync(&path, b"test data").unwrap(); #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let mode = std::fs::metadata(&path).unwrap().mode(); assert_eq!(mode & 0o777, 0o600, "Expected 0o600, got {mode:o}"); } } }