openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! 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>;

    /// Drop any process-local cache of the stored credential, so the next
    /// [`retrieve`](Self::retrieve) re-reads the backing store instead of
    /// returning a memoized answer.
    ///
    /// Default no-op: most implementations never cache. [`KeyringCredentialStore`]
    /// overrides this — its `retrieve()` is memoized for the process lifetime
    /// (see `READ_MEMO` in `keyring.rs`) to avoid a macOS keychain authorization
    /// dialog per read, and `store`/`delete` only invalidate that memo when they
    /// run in the SAME process. A daemon's resident memo is invisible to an
    /// `openlatch system auth login` run in a different process, so without this
    /// hook a running daemon would never observe a rotated credential short of a
    /// restart. Callers invoke it when told a credential changed out of band:
    /// the `/admin/auth/refresh` route (a login just happened) and the cloud
    /// worker's 401/403 latch (the current key was just proven wrong).
    fn invalidate(&self) {}
}

// 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.
/// Store `key`, falling back to the encrypted file when the OS keychain cannot
/// take it.
///
/// **The mirror of [`retrieve_credential`], and it was missing.** Lookup has
/// consulted the file store since it existed; storing went to the keychain
/// alone and failed the whole flow when that was unavailable — so on any host
/// without a usable login keychain, `openlatch init` could not complete at all.
/// A sandbox with a redirected `HOME` is exactly such a host: the macOS login
/// keychain lives under `$HOME/Library/Keychains`, and moving `HOME` is what
/// puts an agent's own config inside the sandbox.
///
/// The fallback is the SAME store lookup already trusts, so a credential
/// written here is found again by step 3 of `retrieve_credential`. Both failing
/// is still an error: silently storing nothing would leave a host that believes
/// it is authenticated and is not.
pub fn store_credential(
    primary: &dyn CredentialStore,
    fallback: &dyn CredentialStore,
    key: SecretString,
) -> Result<(), OlError> {
    let Err(primary_err) = primary.store(key.clone()) else {
        return Ok(());
    };
    fallback.store(key).map_err(|fallback_err| {
        // BOTH messages. "keychain unavailable" alone sends the reader to the
        // keychain, which is not where this host is going to keep its
        // credential; the file error is the one they can act on.
        OlError::new(
            fallback_err.code,
            format!(
                "could not store the credential in the OS keychain ({}) and the \
                 encrypted file fallback also failed: {}",
                primary_err.message, fallback_err.message
            ),
        )
    })
}

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 system 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 {
    /// Through [`store_credential`], so this type's `store` and `retrieve` use
    /// the SAME two tiers. Delegating to the primary alone made a host whose
    /// keychain was unavailable fail to store while still being able to read —
    /// an asymmetry that reads as "the fallback is for lookup only", which it
    /// never was.
    fn store(&self, key: SecretString) -> Result<(), OlError> {
        store_credential(self.primary.as_ref(), self.fallback.as_ref(), 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()
    }

    /// Both tiers: only the keyring memoizes today, but a future fallback
    /// cache should not have to remember to wire this in separately.
    fn invalidate(&self) {
        self.primary.invalidate();
        self.fallback.invalidate();
    }
}

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

    /// A store that cannot take anything — a host with no usable OS keychain.
    struct UnavailableStore;

    impl CredentialStore for UnavailableStore {
        fn store(&self, _key: SecretString) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "OS keychain is not available"))
        }
        fn retrieve(&self) -> Result<SecretString, OlError> {
            Err(OlError::new("OL-TEST", "OS keychain is not available"))
        }
        fn delete(&self) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "OS keychain is not available"))
        }
    }

    /// **A host with no keychain can still authenticate.**
    ///
    /// Storing went to the keychain alone, so `init` failed outright wherever
    /// one was unavailable — including any sandbox whose `HOME` is redirected
    /// away from `~/Library/Keychains`, which is what puts an agent's config
    /// inside the sandbox in the first place.
    #[test]
    fn a_credential_falls_back_to_the_file_when_the_keychain_cannot_take_it() {
        let fallback = InMemoryCredentialStore::new();
        store_credential(
            &UnavailableStore,
            &fallback,
            SecretString::from("k-123".to_string()),
        )
        .expect("an unavailable keychain must not fail the whole flow");

        // And the tier lookup already consults finds it again.
        assert_eq!(
            retrieve_credential(&UnavailableStore, &fallback)
                .expect("stored")
                .expose_secret(),
            "k-123"
        );
    }

    /// Both failing is still an error — a host that believes it is
    /// authenticated and is not is worse than one that says so.
    #[test]
    fn both_stores_failing_is_reported() {
        let err = store_credential(
            &UnavailableStore,
            &UnavailableStore,
            SecretString::from("k".to_string()),
        )
        .expect_err("both tiers failed");
        assert!(
            err.message.contains("keychain") && err.message.contains("fallback also failed"),
            "the message must name both tiers: {}",
            err.message
        );
    }

    #[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);
    }
}