use crate::error::{FnoxError, Result};
use async_trait::async_trait;
use keyring_core::Entry;
use std::collections::HashMap;
pub fn env_dependencies() -> &'static [&'static str] {
&[]
}
pub struct KeychainProvider {
service: String,
prefix: Option<String>,
}
impl KeychainProvider {
pub fn new(service: String, prefix: Option<String>) -> Result<Self> {
Ok(Self { service, prefix })
}
fn build_key_name(&self, key: &str) -> String {
match &self.prefix {
Some(prefix) => format!("{}{}", prefix, key),
None => key.to_string(),
}
}
fn create_entry(&self, key: &str) -> Result<Entry> {
crate::keyring_store::init();
let full_key = self.build_key_name(key);
Entry::new(&self.service, &full_key).map_err(|e| match e {
keyring_core::Error::NoDefaultStore => FnoxError::ProviderAuthFailed {
provider: "Keychain".to_string(),
details: e.to_string(),
hint: platform_backend_hint().to_string(),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
},
_ => FnoxError::ProviderApiError {
provider: "Keychain".to_string(),
details: format!(
"Failed to create entry for service '{}', key '{}': {}",
self.service, full_key, e
),
hint: "Check that the keychain is accessible".to_string(),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
},
})
}
pub async fn put_secret(&self, key: &str, value: &str) -> Result<()> {
let entry = self.create_entry(key)?;
let full_key = self.build_key_name(key);
let service = self.service.clone();
tracing::debug!(
"Storing secret '{}' in OS keychain (service: '{}', key: '{}')",
full_key,
self.service,
full_key
);
let value = value.to_string();
let set_full_key = full_key.clone();
let set_service = service.clone();
spawn_keychain_blocking(move || entry.set_password(&value))
.await?
.map_err(|e| FnoxError::ProviderApiError {
provider: "Keychain".to_string(),
details: format!(
"Failed to store secret '{}' (service: '{}'): {}",
set_full_key, set_service, e
),
hint: "Check that the keychain is accessible and writable".to_string(),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
})?;
tracing::debug!(
"Successfully stored secret '{}' in OS keychain (service: '{}')",
full_key,
self.service
);
Ok(())
}
}
#[async_trait]
impl crate::providers::Provider for KeychainProvider {
fn capabilities(&self) -> Vec<crate::providers::ProviderCapability> {
vec![crate::providers::ProviderCapability::RemoteStorage]
}
async fn get_secret(&self, value: &str) -> Result<String> {
let entry = self.create_entry(value)?;
let full_key = self.build_key_name(value);
let service = self.service.clone();
tracing::debug!(
"Getting secret '{}' from OS keychain (service: '{}')",
full_key,
service
);
spawn_keychain_blocking(move || entry.get_password())
.await?
.map_err(|e| match e {
keyring_core::Error::NoEntry => FnoxError::ProviderSecretNotFound {
provider: "Keychain".to_string(),
secret: full_key.clone(),
hint: format!(
"Check that the secret exists in the keychain (service: '{}')",
service
),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
},
keyring_core::Error::NoStorageAccess(_) => FnoxError::ProviderAuthFailed {
provider: "Keychain".to_string(),
details: e.to_string(),
hint: "Check that the keychain is unlocked and accessible".to_string(),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
},
_ => FnoxError::ProviderApiError {
provider: "Keychain".to_string(),
details: e.to_string(),
hint: format!(
"Failed to get secret from keychain (service: '{}')",
service
),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
},
})
}
async fn get_secrets_batch(
&self,
secrets: &[(String, String)],
) -> HashMap<String, Result<String>> {
let mut results = HashMap::with_capacity(secrets.len());
for (key, value) in secrets {
results.insert(key.clone(), self.get_secret(value).await);
}
results
}
async fn test_connection(&self) -> Result<()> {
let test_key = "__fnox_test__";
let entry = self.create_entry(test_key)?;
let service = self.service.clone();
spawn_keychain_blocking(move || {
entry.set_password("test")?;
entry.get_password()?;
let _ = entry.delete_credential();
Ok(())
})
.await?
.map_err(|e: keyring_core::Error| FnoxError::ProviderAuthFailed {
provider: "Keychain".to_string(),
details: format!("Failed to access keychain (service: '{service}'): {e}"),
hint: "Check that you have permission to access the OS keychain".to_string(),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
})
}
async fn put_secret(&self, key: &str, value: &str) -> Result<String> {
self.put_secret(key, value).await?;
Ok(key.to_string())
}
}
async fn spawn_keychain_blocking<F, T>(f: F) -> Result<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|e| FnoxError::ProviderApiError {
provider: "Keychain".to_string(),
details: format!("Keychain task failed to complete: {e}"),
hint: "This is a bug; please report it".to_string(),
url: "https://fnox.jdx.dev/providers/keychain".to_string(),
})
}
fn platform_backend_hint() -> &'static str {
#[cfg(target_os = "linux")]
{
"No OS keyring backend is available. Start a Secret Service provider \
(e.g. gnome-keyring or KeePassXC) and make sure DBUS_SESSION_BUS_ADDRESS \
is set."
}
#[cfg(target_os = "macos")]
{
"Failed to initialize the macOS Keychain backend."
}
#[cfg(target_os = "windows")]
{
"Failed to initialize the Windows Credential Manager backend."
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
"The keychain provider is not supported on this platform."
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::Provider;
#[tokio::test]
async fn test_keychain_set_and_get() {
let provider = KeychainProvider::new("fnox-unit-test".to_string(), None).unwrap();
let result = provider.put_secret("test_key", "test_value").await;
assert!(result.is_ok(), "Failed to set secret: {:?}", result.err());
let result = provider.get_secret("test_key").await;
assert!(result.is_ok(), "Failed to get secret: {:?}", result.err());
assert_eq!(result.unwrap(), "test_value");
let entry = provider.create_entry("test_key").unwrap();
let _ = entry.delete_credential();
}
}