openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Credential storage for OpenLatch API keys.
//!
//! Provides a [`CredentialStore`] trait with three implementations:
//! - [`KeyringCredentialStore`] — OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
//! - [`FileCredentialStore`] — AES-256-GCM encrypted file fallback for headless environments
//! - [`InMemoryCredentialStore`] — In-memory store for testing
//!
//! The fallback chain (per D-06, revised): OPENLATCH_API_KEY env var -> OS keychain -> encrypted file.

pub mod file;
pub mod keyring;
pub mod memory;

use secrecy::SecretString;

use crate::error::OlError;

/// Credential storage abstraction (per D-05).
///
/// All implementations must be Send + Sync for use in async contexts.
pub trait CredentialStore: Send + Sync {
    /// Store an API key. Overwrites any existing key.
    fn store(&self, key: SecretString) -> Result<(), OlError>;

    /// Retrieve the stored API key.
    fn retrieve(&self) -> Result<SecretString, OlError>;

    /// Delete the stored API key. No-op if no key exists.
    fn delete(&self) -> Result<(), OlError>;
}

// Re-export error constants from crate::error for use within this leaf module.
pub(crate) use crate::error::{
    ERR_FILE_FALLBACK_ERROR, ERR_KEYCHAIN_PERMISSION, ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS,
};

/// Retrieve a credential using the fallback chain (per D-06, revised):
/// 1. Check OPENLATCH_API_KEY env var
/// 2. Try the primary store (OS keychain)
/// 3. Try the fallback store (encrypted file)
///
/// Returns the first successful result, or an error if all fail.
///
/// # Why the env var is checked first
///
/// D-06 originally probed the keychain before the env var, which made
/// `OPENLATCH_API_KEY` an "override" that could not actually override
/// anything: whenever the keychain held a key, the env var was unreachable,
/// so a stale keychain entry could not be worked around without deleting it.
///
/// The ordering also cost a keychain hit on every lookup even when the caller
/// had already supplied a credential explicitly. On macOS a keychain read from
/// a binary that did not create the item raises a blocking authorization
/// dialog, so headless callers that set the env var precisely to avoid the
/// keychain (launchd/systemd units, containers, CI) were prompted anyway.
///
/// An explicitly-provided credential is a deliberate act by the caller and now
/// wins over both stores.
pub fn retrieve_credential(
    primary: &dyn CredentialStore,
    fallback: &dyn CredentialStore,
) -> Result<SecretString, OlError> {
    // Step 1: env var override (per CRED-02)
    if let Ok(val) = std::env::var("OPENLATCH_API_KEY") {
        if !val.is_empty() {
            return Ok(SecretString::from(val));
        }
    }

    // Step 2: primary store (keyring)
    if let Ok(key) = primary.retrieve() {
        return Ok(key);
    }

    // Step 3: fallback store (encrypted file)
    if let Ok(key) = fallback.retrieve() {
        return Ok(key);
    }

    Err(OlError::new(
        ERR_NO_CREDENTIALS,
        "No API key found in keychain, OPENLATCH_API_KEY env var, or encrypted file",
    )
    .with_suggestion("Run 'openlatch auth login' to authenticate, or set OPENLATCH_API_KEY."))
}

pub use self::file::FileCredentialStore;
pub use self::keyring::KeyringCredentialStore;
pub use self::memory::InMemoryCredentialStore;

/// `CredentialStore` that composes the full fallback chain (keyring -> env -> file)
/// behind a single `retrieve()` call, so the daemon can hand one store to the
/// cloud worker without caring which source holds the key.
///
/// `store()` and `delete()` target the primary (keyring) store only — mutations
/// are the `auth` command's job, not the daemon's.
pub struct FallbackCredentialStore {
    primary: Box<dyn CredentialStore>,
    fallback: Box<dyn CredentialStore>,
}

impl FallbackCredentialStore {
    pub fn new(primary: Box<dyn CredentialStore>, fallback: Box<dyn CredentialStore>) -> Self {
        Self { primary, fallback }
    }
}

impl CredentialStore for FallbackCredentialStore {
    fn store(&self, key: SecretString) -> Result<(), OlError> {
        self.primary.store(key)
    }

    fn retrieve(&self) -> Result<SecretString, OlError> {
        retrieve_credential(self.primary.as_ref(), self.fallback.as_ref())
    }

    fn delete(&self) -> Result<(), OlError> {
        self.primary.delete()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::auth::memory::InMemoryCredentialStore;
    use secrecy::ExposeSecret;

    #[test]
    fn test_retrieve_credential_uses_primary_first() {
        let primary = InMemoryCredentialStore::new();
        primary
            .store(SecretString::from("primary-key".to_string()))
            .unwrap();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        let result = retrieve_credential(&primary, &fallback).unwrap();
        assert_eq!(result.expose_secret(), "primary-key");
    }

    #[test]
    #[ignore] // Mutates the process-wide OPENLATCH_API_KEY env var — not
              // parallel-safe. Run with:
              // cargo test retrieve_credential_env -- --ignored --test-threads=1
    fn test_env_var_beats_both_stores() {
        // The env var is an override: it must win even when both stores hold a
        // key. Before the D-06 revision the keychain was probed first, so a
        // stale entry could not be overridden and headless callers that set
        // this var to avoid the keychain were prompted anyway.
        let primary = InMemoryCredentialStore::new();
        primary
            .store(SecretString::from("primary-key".to_string()))
            .unwrap();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        std::env::set_var("OPENLATCH_API_KEY", "env-key");
        let result = retrieve_credential(&primary, &fallback).unwrap();
        std::env::remove_var("OPENLATCH_API_KEY");

        assert_eq!(result.expose_secret(), "env-key");
    }

    #[test]
    #[ignore] // Mutates the process-wide OPENLATCH_API_KEY env var — see above.
    fn test_empty_env_var_falls_through_to_stores() {
        let primary = InMemoryCredentialStore::new();
        primary
            .store(SecretString::from("primary-key".to_string()))
            .unwrap();
        let fallback = InMemoryCredentialStore::new();

        std::env::set_var("OPENLATCH_API_KEY", "");
        let result = retrieve_credential(&primary, &fallback).unwrap();
        std::env::remove_var("OPENLATCH_API_KEY");

        assert_eq!(result.expose_secret(), "primary-key");
    }

    #[test]
    fn test_retrieve_credential_falls_through_to_fallback_when_primary_empty() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        let result = retrieve_credential(&primary, &fallback).unwrap();
        assert_eq!(result.expose_secret(), "fallback-key");
    }

    #[test]
    fn test_retrieve_credential_returns_err_when_all_empty() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();

        let result = retrieve_credential(&primary, &fallback);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert_eq!(err.code, ERR_NO_CREDENTIALS);
    }

    #[test]
    fn test_secret_string_debug_does_not_leak_value() {
        let secret = SecretString::from("my-api-key".to_string());
        let debug_output = format!("{:?}", secret);
        assert!(
            !debug_output.contains("my-api-key"),
            "SecretString Debug output must not contain the actual secret: {debug_output}"
        );
    }

    #[test]
    fn test_retrieve_credential_env_var_absent_falls_through() {
        // With no env var set, all three sources are empty → OL-1600.
        // The positive env-var cases are covered by the two `#[ignore]`d tests
        // above, which mutate the process-wide var and must run single-threaded.
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();

        let result = retrieve_credential(&primary, &fallback);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().code, ERR_NO_CREDENTIALS);
    }
}