openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
use std::path::Path;

use sha2::{Digest, Sha256};

use crate::error::{OlError, ERR_HMAC_KEY_UNAVAILABLE};

const KEYRING_SERVICE: &str = "ai.openlatch.client";
const KEYRING_USER: &str = "hmac/kid-01";
const KEY_LENGTH: usize = 32;

pub struct HmacKeyStore {
    openlatch_dir: std::path::PathBuf,
}

impl HmacKeyStore {
    pub fn new(openlatch_dir: &Path) -> Self {
        Self {
            openlatch_dir: openlatch_dir.to_path_buf(),
        }
    }

    pub fn load_or_create(&self) -> Result<Vec<u8>, OlError> {
        let fallback_path = self.openlatch_dir.join("hmac.key");

        // The on-disk `hmac.key` is authoritative for reads.
        //
        // `openlatch-hook` resolves this key once per hook event and every
        // event is a separate process, so a keychain round-trip here is paid on
        // every event rather than once per session. On macOS that read is also
        // an ACL check against the calling binary's code identity — the source
        // of the repeated "openlatch wants to use your confidential information
        // stored in 'ai.openlatch.client'" dialogs. Neither cost is acceptable
        // on a path budgeted at <3ms.
        //
        // This is not a demotion to a weaker store: the creation path below has
        // always written the file even when the keychain store succeeded, so
        // whenever a keychain entry exists an identical file exists beside it.
        // The keychain stays the store of record — still written on creation,
        // and still read when the file is absent (e.g. `~/.openlatch` was
        // cleared but the keychain entry survived).
        //
        // Also subsumes the OPENLATCH_SKIP_KEYRING seam's read path: with the
        // file consulted first, a test run never reaches the keychain either.
        if fallback_path.exists() {
            return self.load_from_file(&fallback_path);
        }

        // Test/CI seam (OPENLATCH_SKIP_KEYRING): never touch the OS keychain.
        // Without this, every `cargo test` re-signs the test binary, so macOS
        // treats it as a new app and re-prompts on each run.
        if skip_keyring() {
            let key = generate_key();
            self.store_to_file(&fallback_path, &key)?;
            return Ok(key);
        }

        match self.load_from_keyring() {
            Ok(key) => {
                // Restore the file so later events stay off the keychain.
                // Non-fatal: a read-only dir costs performance, not correctness.
                if let Err(e) = self.store_to_file(&fallback_path, &key) {
                    tracing::debug!(error = %e, "cannot cache HMAC key to file");
                }
                return Ok(key);
            }
            Err(e) => {
                tracing::debug!(error = %e, "keyring unavailable for HMAC key, generating a new one");
            }
        }

        let key = generate_key();

        if let Err(e) = self.store_to_keyring(&key) {
            tracing::debug!(error = %e, "cannot store HMAC key in keyring, using file fallback");
        }

        self.store_to_file(&fallback_path, &key)?;
        Ok(key)
    }

    fn load_from_keyring(&self) -> Result<Vec<u8>, OlError> {
        let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("Cannot create keyring entry: {e}"),
            )
        })?;

        let secret = entry.get_password().map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("Cannot read HMAC key from keyring: {e}"),
            )
        })?;

        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
        use base64::Engine;
        let key = URL_SAFE_NO_PAD.decode(&secret).map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("HMAC key in keyring is not valid base64: {e}"),
            )
        })?;

        if key.len() != KEY_LENGTH {
            return Err(OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!(
                    "HMAC key in keyring has wrong length ({} bytes, expected {KEY_LENGTH})",
                    key.len()
                ),
            ));
        }

        Ok(key)
    }

    fn store_to_keyring(&self, key: &[u8]) -> Result<(), OlError> {
        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
        use base64::Engine;

        let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("Cannot create keyring entry: {e}"),
            )
        })?;

        let encoded = URL_SAFE_NO_PAD.encode(key);
        entry.set_password(&encoded).map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("Cannot store HMAC key in keyring: {e}"),
            )
        })?;

        Ok(())
    }

    fn load_from_file(&self, path: &Path) -> Result<Vec<u8>, OlError> {
        let content = std::fs::read(path).map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("Cannot read HMAC key file: {e}"),
            )
        })?;

        if content.len() != KEY_LENGTH {
            return Err(OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!(
                    "HMAC key file has wrong length ({} bytes, expected {KEY_LENGTH})",
                    content.len()
                ),
            ));
        }

        Ok(content)
    }

    fn store_to_file(&self, path: &Path, key: &[u8]) -> Result<(), OlError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    ERR_HMAC_KEY_UNAVAILABLE,
                    format!("Cannot create HMAC key directory: {e}"),
                )
            })?;
        }

        std::fs::write(path, key).map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("Cannot write HMAC key file: {e}"),
            )
        })?;

        crate::fs_secure::restrict_to_owner(path).map_err(|e| {
            OlError::new(
                ERR_HMAC_KEY_UNAVAILABLE,
                format!("Cannot set HMAC key file permissions: {e}"),
            )
        })?;

        Ok(())
    }
}

/// Test/CI seam mirroring `auth::keyring`'s `OPENLATCH_SKIP_KEYRING`. When set to
/// a truthy value, the HMAC key path skips the OS keychain and uses the
/// `hmac.key` file, so `cargo test` never triggers a macOS keychain prompt.
/// Truthy = any non-empty value other than `0`/`false`/`no`/`off` (case-insensitive).
fn skip_keyring() -> bool {
    match std::env::var("OPENLATCH_SKIP_KEYRING") {
        Ok(v) => {
            let v = v.trim().to_ascii_lowercase();
            !matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
        }
        Err(_) => false,
    }
}

fn generate_key() -> Vec<u8> {
    let a = uuid::Uuid::new_v4();
    let b = uuid::Uuid::new_v4();
    let mut key = Vec::with_capacity(KEY_LENGTH);
    key.extend_from_slice(a.as_bytes());
    key.extend_from_slice(b.as_bytes());
    key
}

pub fn key_fingerprint(key: &[u8]) -> String {
    let hash = Sha256::digest(key);
    hex::encode(&hash[..16])
}

pub(crate) mod hex {
    pub fn encode(bytes: &[u8]) -> String {
        let mut s = String::with_capacity(bytes.len() * 2);
        for b in bytes {
            s.push_str(&format!("{b:02x}"));
        }
        s
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generated_key_is_32_bytes() {
        let key = generate_key();
        assert_eq!(key.len(), KEY_LENGTH);
    }

    #[test]
    fn key_fingerprint_is_32_hex_chars() {
        let key = vec![0x42; 32];
        let fp = key_fingerprint(&key);
        assert_eq!(fp.len(), 32);
        assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn different_keys_different_fingerprints() {
        let fp1 = key_fingerprint(&[0x42; 32]);
        let fp2 = key_fingerprint(&[0x43; 32]);
        assert_ne!(fp1, fp2);
    }

    #[test]
    fn file_fallback_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let store = HmacKeyStore::new(dir.path());
        let path = dir.path().join("hmac.key");

        let key = generate_key();
        store.store_to_file(&path, &key).unwrap();
        let loaded = store.load_from_file(&path).unwrap();
        assert_eq!(key, loaded);
    }

    #[test]
    #[cfg(unix)]
    fn file_fallback_mode_0600() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().unwrap();
        let store = HmacKeyStore::new(dir.path());
        let path = dir.path().join("hmac.key");

        let key = generate_key();
        store.store_to_file(&path, &key).unwrap();
        let meta = std::fs::metadata(&path).unwrap();
        assert_eq!(meta.permissions().mode() & 0o777, 0o600);
    }

    #[test]
    fn rejects_wrong_length_file() {
        let dir = tempfile::tempdir().unwrap();
        let store = HmacKeyStore::new(dir.path());
        let path = dir.path().join("hmac.key");

        std::fs::write(&path, [0u8; 16]).unwrap();
        let err = store.load_from_file(&path).unwrap_err();
        assert_eq!(err.code, "OL-1900");
    }

    #[test]
    fn existing_file_is_used_without_consulting_the_keychain() {
        // Deliberately does NOT set OPENLATCH_SKIP_KEYRING: the point is that an
        // existing hmac.key short-circuits the keychain even with it enabled.
        // `openlatch-hook` runs this once per hook event in a fresh process, so
        // a keychain read here is paid per event and, on macOS, raises an
        // authorization dialog until the ACL grant sticks.
        //
        // That this test never prompts on a developer Mac is the assertion.
        let dir = tempfile::tempdir().unwrap();
        let store = HmacKeyStore::new(dir.path());

        let seeded = generate_key();
        store
            .store_to_file(&dir.path().join("hmac.key"), &seeded)
            .unwrap();

        let loaded = store.load_or_create().unwrap();
        assert_eq!(
            seeded, loaded,
            "load_or_create must return the on-disk key verbatim, not a \
             keychain value or a freshly generated one"
        );
    }

    #[test]
    #[ignore] // Mutates the process-wide OPENLATCH_SKIP_KEYRING env var — not
              // parallel-safe. Run with: cargo test skip_keyring -- --ignored --test-threads=1
    fn skip_keyring_uses_file_without_touching_os_keychain() {
        let dir = tempfile::tempdir().unwrap();
        let store = HmacKeyStore::new(dir.path());
        std::env::set_var("OPENLATCH_SKIP_KEYRING", "1");

        // First call creates the key in the file (never consults the keychain).
        let k1 = store.load_or_create().unwrap();
        assert_eq!(k1.len(), KEY_LENGTH);
        assert!(dir.path().join("hmac.key").exists());

        // Second call loads the same key back from the file.
        let k2 = store.load_or_create().unwrap();
        assert_eq!(k1, k2);

        std::env::remove_var("OPENLATCH_SKIP_KEYRING");
    }
}