use secrecy::{ExposeSecret, SecretString};
use crate::error::OlError;
use super::{
CredentialStore, ERR_KEYCHAIN_PERMISSION, ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS,
};
const SERVICE_NAME: &str = "openlatch";
const USERNAME: &str = "api-key";
const SKIP_KEYRING_ENV: &str = "OPENLATCH_SKIP_KEYRING";
fn keyring_disabled_by_env() -> bool {
match std::env::var(SKIP_KEYRING_ENV) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
!matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
}
Err(_) => false,
}
}
fn skipped_no_entry_error() -> OlError {
OlError::new(
ERR_NO_CREDENTIALS,
"OS keychain disabled via OPENLATCH_SKIP_KEYRING",
)
.with_suggestion("Unset OPENLATCH_SKIP_KEYRING to use the OS keychain.")
}
fn skipped_unavailable_error() -> OlError {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
"OS keychain disabled via OPENLATCH_SKIP_KEYRING",
)
.with_suggestion("Unset OPENLATCH_SKIP_KEYRING to use the OS keychain.")
}
pub struct KeyringCredentialStore {
service: String,
username: String,
}
impl KeyringCredentialStore {
pub fn new() -> Self {
Self {
service: SERVICE_NAME.to_string(),
username: USERNAME.to_string(),
}
}
}
impl Default for KeyringCredentialStore {
fn default() -> Self {
Self::new()
}
}
fn map_keyring_error(e: keyring::Error) -> OlError {
match e {
keyring::Error::NoEntry => {
OlError::new(ERR_NO_CREDENTIALS, "No API key found in OS keychain")
.with_suggestion("Run 'openlatch auth login' to authenticate.")
}
keyring::Error::NoStorageAccess(_) | keyring::Error::PlatformFailure(_) => OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("OS keychain is not available: {e}"),
)
.with_suggestion(crate::error::keychain_suggestion()),
keyring::Error::Ambiguous(_) => OlError::new(
ERR_KEYCHAIN_PERMISSION,
format!("OS keychain access denied: {e}"),
)
.with_suggestion(crate::error::keychain_suggestion()),
other => OlError::new(ERR_KEYCHAIN_UNAVAILABLE, format!("Keychain error: {other}"))
.with_suggestion(crate::error::keychain_suggestion()),
}
}
impl KeyringCredentialStore {
pub async fn store_async(&self, key: SecretString) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
let service = self.service.clone();
let username = self.username.clone();
let secret_val = key.expose_secret().to_string();
tokio::task::spawn_blocking(move || {
let entry = keyring::Entry::new(&service, &username).map_err(map_keyring_error)?;
entry.set_password(&secret_val).map_err(map_keyring_error)
})
.await
.map_err(|e| {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("Keychain task panicked: {e}"),
)
})?
}
pub async fn retrieve_async(&self) -> Result<SecretString, OlError> {
if keyring_disabled_by_env() {
return Err(skipped_no_entry_error());
}
let service = self.service.clone();
let username = self.username.clone();
tokio::task::spawn_blocking(move || {
let entry = keyring::Entry::new(&service, &username).map_err(map_keyring_error)?;
let password = entry.get_password().map_err(map_keyring_error)?;
Ok(SecretString::from(password))
})
.await
.map_err(|e| {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("Keychain task panicked: {e}"),
)
})?
}
pub async fn delete_async(&self) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
let service = self.service.clone();
let username = self.username.clone();
tokio::task::spawn_blocking(move || {
let entry = keyring::Entry::new(&service, &username).map_err(map_keyring_error)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(map_keyring_error(e)),
}
})
.await
.map_err(|e| {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("Keychain task panicked: {e}"),
)
})?
}
}
impl CredentialStore for KeyringCredentialStore {
fn store(&self, key: SecretString) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
let entry =
keyring::Entry::new(&self.service, &self.username).map_err(map_keyring_error)?;
entry
.set_password(key.expose_secret())
.map_err(map_keyring_error)
}
fn retrieve(&self) -> Result<SecretString, OlError> {
if keyring_disabled_by_env() {
return Err(skipped_no_entry_error());
}
let entry =
keyring::Entry::new(&self.service, &self.username).map_err(map_keyring_error)?;
let password = entry.get_password().map_err(map_keyring_error)?;
Ok(SecretString::from(password))
}
fn delete(&self) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
let entry =
keyring::Entry::new(&self.service, &self.username).map_err(map_keyring_error)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(map_keyring_error(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS};
#[test]
fn test_keyring_credential_store_new_creates_instance() {
let store = KeyringCredentialStore::new();
assert_eq!(store.service, "openlatch");
assert_eq!(store.username, "api-key");
}
#[test]
fn test_keyring_default_creates_instance_with_correct_fields() {
let store = KeyringCredentialStore::default();
assert_eq!(store.service, "openlatch");
assert_eq!(store.username, "api-key");
}
#[test]
fn test_map_keyring_error_no_entry_maps_to_ol_1600() {
let err = map_keyring_error(keyring::Error::NoEntry);
assert_eq!(err.code, ERR_NO_CREDENTIALS);
assert!(err.suggestion.is_some());
}
#[test]
fn test_map_keyring_error_platform_failure_maps_to_ol_1602() {
let boxed: Box<dyn std::error::Error + Send + Sync> = "test failure".to_string().into();
let err = map_keyring_error(keyring::Error::PlatformFailure(boxed));
assert_eq!(err.code, ERR_KEYCHAIN_UNAVAILABLE);
assert!(err.suggestion.is_some());
}
#[test]
fn test_map_keyring_error_no_storage_access_maps_to_ol_1602() {
let boxed: Box<dyn std::error::Error + Send + Sync> = "no access".to_string().into();
let err = map_keyring_error(keyring::Error::NoStorageAccess(boxed));
assert_eq!(err.code, ERR_KEYCHAIN_UNAVAILABLE);
}
#[test]
#[ignore] fn test_keyring_skip_env_disables_retrieve() {
let key = "OPENLATCH_SKIP_KEYRING";
std::env::remove_var(key);
assert!(!keyring_disabled_by_env());
for truthy in ["1", "true", "TRUE", "yes", "on"] {
std::env::set_var(key, truthy);
assert!(keyring_disabled_by_env(), "{truthy:?} should be truthy");
}
for falsy in ["", "0", "false", "no", "off"] {
std::env::set_var(key, falsy);
assert!(!keyring_disabled_by_env(), "{falsy:?} should be falsy");
}
std::env::set_var(key, "1");
let result = KeyringCredentialStore::new().retrieve();
std::env::remove_var(key);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ERR_NO_CREDENTIALS);
}
#[tokio::test]
#[ignore] async fn test_keyring_async_methods_compile_and_run_in_tokio_context() {
let store = KeyringCredentialStore::new();
let result = store.delete_async().await;
assert!(
result.is_ok(),
"delete_async should succeed even when no entry exists"
);
}
#[tokio::test]
#[ignore] async fn test_keyring_store_retrieve_delete_round_trip() {
let store = KeyringCredentialStore::new();
let key = SecretString::from("test-api-key-12345".to_string());
store.store_async(key).await.unwrap();
let retrieved = store.retrieve_async().await.unwrap();
use secrecy::ExposeSecret;
assert_eq!(retrieved.expose_secret(), "test-api-key-12345");
store.delete_async().await.unwrap();
assert!(store.retrieve_async().await.is_err());
}
}