use tracing::{debug, error, info};
use crate::core::domain::identity::{Identity, IdentitySource};
use crate::error::{Result, StoreError};
use age::x25519;
pub struct Keychain {
service: String,
}
impl Keychain {
const SERVICE_NAME: &'static str = "com.usemantle.dugout";
pub fn new() -> Result<Self> {
Ok(Self {
service: Self::SERVICE_NAME.to_string(),
})
}
pub fn store_identity(&self, account: &str, secret: &str, force: bool) -> Result<()> {
use security_framework::passwords::set_generic_password;
info!(
account = %account,
service = %self.service,
"Storing identity in macOS Keychain"
);
if !force && self.keychain_has_key(account) {
error!(account = %account, "Identity already exists in Keychain");
return Err(StoreError::KeychainError(format!(
"Identity '{}' already exists in Keychain. Use --force to overwrite.",
account
))
.into());
}
if force {
info!(account = %account, "Deleting existing Keychain entry (--force)");
let _ = self.delete_identity(account);
}
match set_generic_password(&self.service, account, secret.as_bytes()) {
Ok(_) => {
info!(
account = %account,
"✓ Identity stored in Keychain"
);
Ok(())
}
Err(e) => {
error!(
account = %account,
error = %e,
"Failed to store identity in Keychain"
);
let error_code = e.code();
if error_code == -128 {
Err(StoreError::KeychainAccessDenied.into())
} else {
Err(StoreError::KeychainError(format!(
"Keychain storage failed: {}. Set DUGOUT_NO_KEYCHAIN=1 to store locally instead.",
e
)).into())
}
}
}
}
pub(crate) fn load_from_keychain(&self, account: &str) -> Result<Identity> {
info!(account = %account, service = %self.service, "Loading identity from macOS Keychain");
use security_framework::passwords::get_generic_password;
match get_generic_password(&self.service, account) {
Ok(password_bytes) => {
let secret_str = String::from_utf8(password_bytes.to_vec()).map_err(|e| {
error!(error = %e, "Invalid UTF-8 in Keychain data");
StoreError::InvalidFormat(format!("Invalid UTF-8 in Keychain data: {}", e))
})?;
let inner: x25519::Identity = secret_str.trim().parse().map_err(|e: &str| {
error!(error = %e, "Invalid age identity format in Keychain");
StoreError::InvalidFormat(e.to_string())
})?;
info!(account = %account, "✓ Loaded identity from Keychain");
Ok(Identity::from_parts(
inner,
IdentitySource::Keychain {
account: account.to_string(),
},
))
}
Err(e) => {
let error_code = e.code();
error!(
account = %account,
error_code = error_code,
error = %e,
"Failed to load identity from Keychain"
);
if error_code == -128 {
Err(StoreError::KeychainAccessDenied.into())
} else if error_code == -25300 {
Err(StoreError::NoPrivateKey(format!("keychain:{}", account)).into())
} else {
Err(StoreError::KeychainError(format!("Keychain error: {}", e)).into())
}
}
}
}
pub fn delete_identity(&self, account: &str) -> Result<()> {
info!(account = %account, service = %self.service, "Deleting identity from Keychain");
use security_framework::passwords::delete_generic_password;
match delete_generic_password(&self.service, account) {
Ok(_) => {
info!(account = %account, "✓ Deleted identity from Keychain");
Ok(())
}
Err(e) => {
let error_code = e.code();
if error_code == -25300 {
info!(account = %account, "Identity not found in Keychain (already deleted)");
Ok(())
} else if error_code == -128 {
error!(account = %account, "User cancelled Keychain access");
Err(StoreError::KeychainAccessDenied.into())
} else {
error!(account = %account, error = %e, "Failed to delete from Keychain");
Err(
StoreError::KeychainError(format!("Failed to delete from Keychain: {}", e))
.into(),
)
}
}
}
}
fn keychain_has_key(&self, account: &str) -> bool {
use security_framework::passwords::get_generic_password;
match get_generic_password(&self.service, account) {
Ok(_) => {
debug!(account = %account, "identity exists in Keychain");
true
}
Err(e) => {
if e.code() == -25300 {
debug!(account = %account, "identity not found in Keychain");
false
} else {
debug!(account = %account, error_code = e.code(), "error checking Keychain");
false
}
}
}
}
}
impl super::Store for Keychain {
fn generate_keypair(&self, project_id: &str) -> Result<String> {
info!(
project_id = %project_id,
backend = "Keychain",
"Generating keypair with Keychain backend"
);
let inner = x25519::Identity::generate();
let public_key = inner.to_public().to_string();
use age::secrecy::ExposeSecret;
let secret = inner.to_string();
self.store_identity(project_id, secret.expose_secret(), false)?;
info!(
project_id = %project_id,
backend = "Keychain",
"✓ Identity generated and stored in Keychain"
);
Ok(public_key)
}
fn load_identity(&self, project_id: &str) -> Result<Identity> {
info!(
project_id = %project_id,
backend = "Keychain",
"Loading identity from Keychain"
);
self.load_from_keychain(project_id)
}
fn has_key(&self, project_id: &str) -> bool {
self.keychain_has_key(project_id)
}
}
#[cfg(test)]
mod tests {
use super::super::Store;
use super::*;
#[test]
fn test_keychain_backend_creation() {
let keychain = Keychain::new().unwrap();
assert_eq!(keychain.service, Keychain::SERVICE_NAME);
}
#[test]
fn test_keychain_storage() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project_id = "test-keychain-storage";
std::env::set_var("DUGOUT_HOME", tmp.path());
let keychain = Keychain::new().unwrap();
let pubkey = keychain.generate_keypair(project_id).unwrap();
assert!(pubkey.starts_with("age1"));
let identity_result = keychain.load_identity(project_id);
match identity_result {
Ok(identity) => {
assert_eq!(identity.public_key(), pubkey);
}
Err(_) => {
let key_dir = Identity::project_dir(project_id).unwrap();
if key_dir.join("identity.key").exists() {
let identity = Identity::load(&key_dir).unwrap();
assert_eq!(identity.public_key(), pubkey);
}
}
}
let _ = keychain.delete_identity(project_id);
std::env::remove_var("DUGOUT_HOME");
}
}