use async_trait::async_trait;
use keyring::Entry;
use zeroize::Zeroizing;
use super::{SecretProvider, SecretValue};
use crate::ContextError;
const SERVICE_NAME: &str = "skill-engine-context";
pub struct KeychainProvider {
prefix: Option<String>,
}
impl KeychainProvider {
pub fn new() -> Self {
Self { prefix: None }
}
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: Some(prefix.into()),
}
}
fn build_key(&self, context_id: &str, key: &str) -> String {
match &self.prefix {
Some(p) => format!("{}/{}/{}/{}", p, SERVICE_NAME, context_id, key),
None => format!("{}/{}/{}", SERVICE_NAME, context_id, key),
}
}
fn get_entry(&self, context_id: &str, key: &str) -> Result<Entry, ContextError> {
let user = self.build_key(context_id, key);
Entry::new(SERVICE_NAME, &user).map_err(|e| {
ContextError::SecretProvider(format!("Failed to create keyring entry: {}", e))
})
}
}
impl Default for KeychainProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl SecretProvider for KeychainProvider {
async fn get_secret(
&self,
context_id: &str,
key: &str,
) -> Result<Option<SecretValue>, ContextError> {
let entry = self.get_entry(context_id, key)?;
match entry.get_password() {
Ok(password) => {
tracing::debug!(
context_id = context_id,
key = key,
"Retrieved secret from keychain"
);
Ok(Some(Zeroizing::new(password)))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
tracing::warn!(
context_id = context_id,
key = key,
error = %e,
"Failed to get secret from keychain"
);
Err(ContextError::SecretProvider(format!(
"Failed to get secret '{}' from keychain: {}",
key, e
)))
}
}
}
async fn set_secret(
&self,
context_id: &str,
key: &str,
value: &str,
) -> Result<(), ContextError> {
let entry = self.get_entry(context_id, key)?;
entry.set_password(value).map_err(|e| {
tracing::error!(
context_id = context_id,
key = key,
error = %e,
"Failed to set secret in keychain"
);
ContextError::SecretProvider(format!(
"Failed to set secret '{}' in keychain: {}",
key, e
))
})?;
tracing::info!(
context_id = context_id,
key = key,
"Stored secret in keychain"
);
Ok(())
}
async fn delete_secret(&self, context_id: &str, key: &str) -> Result<(), ContextError> {
let entry = self.get_entry(context_id, key)?;
match entry.delete_credential() {
Ok(()) => {
tracing::info!(
context_id = context_id,
key = key,
"Deleted secret from keychain"
);
Ok(())
}
Err(keyring::Error::NoEntry) => {
Ok(())
}
Err(e) => {
tracing::error!(
context_id = context_id,
key = key,
error = %e,
"Failed to delete secret from keychain"
);
Err(ContextError::SecretProvider(format!(
"Failed to delete secret '{}' from keychain: {}",
key, e
)))
}
}
}
async fn list_keys(&self, _context_id: &str) -> Result<Vec<String>, ContextError> {
tracing::warn!("Listing keys is not supported by the keychain provider");
Ok(Vec::new())
}
fn name(&self) -> &'static str {
"keychain"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "interacts with system keychain"]
async fn test_keychain_set_get_delete() {
let provider = KeychainProvider::new();
let context_id = "test-context";
let key = "test-secret-key";
let value = "super-secret-value";
provider.set_secret(context_id, key, value).await.unwrap();
let retrieved = provider.get_secret(context_id, key).await.unwrap();
assert!(retrieved.is_some());
assert_eq!(&*retrieved.unwrap(), value);
provider.delete_secret(context_id, key).await.unwrap();
let retrieved = provider.get_secret(context_id, key).await.unwrap();
assert!(retrieved.is_none());
}
#[tokio::test]
async fn test_keychain_get_nonexistent() {
let provider = KeychainProvider::new();
let result = provider
.get_secret("nonexistent-context", "nonexistent-key")
.await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_key_building() {
let provider = KeychainProvider::new();
let key = provider.build_key("my-context", "api-key");
assert_eq!(key, "skill-engine-context/my-context/api-key");
let provider_with_prefix = KeychainProvider::with_prefix("custom");
let key = provider_with_prefix.build_key("my-context", "api-key");
assert_eq!(key, "custom/skill-engine-context/my-context/api-key");
}
}