openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! OS keychain credential store via the `keyring` crate (per D-01).
//!
//! All keyring calls are wrapped in `tokio::task::spawn_blocking` to prevent
//! async runtime deadlock on Linux (per D-07, CRED-06).

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";

/// Test-only seam: when `OPENLATCH_SKIP_KEYRING` is set to a truthy value,
/// the keyring store behaves as if the OS keychain is unavailable. Used by the
/// E2E credentials suite so tests can exercise the `OPENLATCH_API_KEY` env-var
/// fallback path on a developer machine whose real keyring already holds a
/// credential. Truthy values: any non-empty string other than `0` / `false` /
/// `no` / `off` (case-insensitive).
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.")
}

/// OS keychain credential store.
///
/// Uses `keyring` crate v3.6.x with platform-native backends:
/// - macOS: Keychain
/// - Windows: Credential Manager
/// - Linux: Secret Service (GNOME Keyring / KWallet)
///
/// All blocking operations MUST be called via `spawn_blocking` in async contexts (per D-07).
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()
    }
}

/// Map a keyring error to an OlError with appropriate code.
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 {
    /// Store API key in OS keychain. Wrapped in spawn_blocking for async safety (per D-07).
    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}"),
            )
        })?
    }

    /// Retrieve API key from OS keychain. Wrapped in spawn_blocking (per D-07).
    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}"),
            )
        })?
    }

    /// Delete API key from OS keychain. Wrapped in spawn_blocking (per D-07).
    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(()), // Already gone, no-op
                Err(e) => Err(map_keyring_error(e)),
            }
        })
        .await
        .map_err(|e| {
            OlError::new(
                ERR_KEYCHAIN_UNAVAILABLE,
                format!("Keychain task panicked: {e}"),
            )
        })?
    }
}

/// Synchronous `CredentialStore` impl for `KeyringCredentialStore`.
///
/// These methods block the current thread. They exist to satisfy the `CredentialStore`
/// trait which is sync. Callers in async context MUST use the `_async` methods directly,
/// or wrap these calls in `spawn_blocking` at the call site (per D-07, CRED-06).
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] // Mutates a process-wide env var — not safe to run in parallel.
              // Run with: cargo test test_keyring_skip_env -- --ignored --test-threads=1
    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] // Requires real OS keychain — fails on headless CI without Secret Service
    async fn test_keyring_async_methods_compile_and_run_in_tokio_context() {
        // Verify the async methods can be called without panicking in a tokio runtime.
        // The keychain may not have a credential, so we only check that the call completes.
        let store = KeyringCredentialStore::new();
        // delete_async is idempotent (no-op when no entry exists), so it's safe to call.
        // This verifies spawn_blocking works correctly in the tokio context.
        let result = store.delete_async().await;
        assert!(
            result.is_ok(),
            "delete_async should succeed even when no entry exists"
        );
    }

    #[tokio::test]
    #[ignore] // Requires real OS keychain — run manually with: cargo test keyring -- --ignored
    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());
    }
}