openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! 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 std::sync::{Mutex, OnceLock};

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,
    }
}

/// Process-wide memo of the first keychain read.
///
/// macOS binds a keychain item's ACL to the code identity of the binary that
/// created it, so any read from a different binary — including a rebuilt one,
/// since unsigned/ad-hoc builds get a fresh identity every compile — raises a
/// blocking authorization dialog. The dialogs are per *read*, not per process:
/// a single `openlatch status` resolves the credential twice (the
/// cloud-configured check and the live cloud probe), `doctor` adds more, and
/// the daemon's policy poller and cloud worker re-resolve on their loops.
///
/// Nothing outside this store mutates the entry during a process's lifetime,
/// so the first answer is reused for the rest of it. Failures are memoized
/// too — a denied dialog must not re-prompt on the next call. `store` and
/// `delete` invalidate the memo.
static READ_MEMO: OnceLock<Mutex<Option<Result<String, OlError>>>> = OnceLock::new();

fn read_memo() -> &'static Mutex<Option<Result<String, OlError>>> {
    READ_MEMO.get_or_init(|| Mutex::new(None))
}

/// Drop the memoized read so the next retrieve sees the keychain's new state.
fn invalidate_read_memo() {
    if let Ok(mut slot) = read_memo().lock() {
        *slot = None;
    }
}

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 {
    /// One unmemoized read of the backing keychain entry.
    ///
    /// Returns the raw secret rather than a `SecretString` so the result can be
    /// memoized: `SecretString` is `SecretBox<str>`, which is not `Clone`.
    fn read_entry(&self) -> Result<String, OlError> {
        let entry =
            keyring::Entry::new(&self.service, &self.username).map_err(map_keyring_error)?;
        entry.get_password().map_err(map_keyring_error)
    }

    /// 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());
        }
        invalidate_read_memo();
        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());
        }
        // Delegates to the sync impl so both entry points share one memo
        // (and therefore raise at most one macOS authorization dialog).
        let store = Self {
            service: self.service.clone(),
            username: self.username.clone(),
        };
        tokio::task::spawn_blocking(move || CredentialStore::retrieve(&store))
            .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());
        }
        invalidate_read_memo();
        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());
        }
        invalidate_read_memo();
        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)
    }

    /// Retrieve the API key, reading the OS keychain at most once per process.
    ///
    /// See [`READ_MEMO`] for why repeat reads are worth avoiding.
    fn retrieve(&self) -> Result<SecretString, OlError> {
        if keyring_disabled_by_env() {
            return Err(skipped_no_entry_error());
        }

        if let Ok(slot) = read_memo().lock() {
            if let Some(memoized) = slot.as_ref() {
                return memoized.clone().map(SecretString::from);
            }
        }

        let result = self.read_entry();

        if let Ok(mut slot) = read_memo().lock() {
            *slot = Some(result.clone());
        }

        result.map(SecretString::from)
    }

    fn delete(&self) -> Result<(), OlError> {
        if keyring_disabled_by_env() {
            return Err(skipped_unavailable_error());
        }
        invalidate_read_memo();
        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());
    }
}