openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! AES-256-GCM encrypted file credential store (per D-02, D-03, D-04).
//!
//! Used as fallback when OS keychain is unavailable (headless Linux).
//! Encryption key derived from agent_id + static salt via HKDF-SHA256.

use std::path::PathBuf;

use aes_gcm::{
    aead::{Aead, Generate, Nonce},
    Aes256Gcm, KeyInit,
};
use hkdf::Hkdf;
use secrecy::{ExposeSecret, SecretString};
use sha2::Sha256;

use crate::error::OlError;

use super::{CredentialStore, ERR_FILE_FALLBACK_ERROR};

const HKDF_SALT: &[u8] = b"openlatch-v1-cred-file-key";
const HKDF_INFO: &[u8] = b"openlatch-credentials-enc";

/// Derive a 32-byte AES-256 key from agent_id using HKDF-SHA256 (per D-03).
pub(crate) fn derive_key(agent_id: &str) -> [u8; 32] {
    let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), agent_id.as_bytes());
    let mut key = [0u8; 32];
    hk.expand(HKDF_INFO, &mut key)
        .expect("32 bytes is valid for HKDF-SHA256");
    key
}

/// Encrypt plaintext with AES-256-GCM. Returns `[12-byte nonce || ciphertext+tag]`.
pub(crate) fn encrypt_credential(plaintext: &[u8], agent_id: &str) -> Vec<u8> {
    let key = derive_key(agent_id);
    let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
    let nonce = Nonce::<Aes256Gcm>::generate();
    let ciphertext = cipher
        .encrypt(&nonce, plaintext)
        .expect("AES-GCM encrypt should not fail");
    let mut out = nonce.to_vec();
    out.extend_from_slice(&ciphertext);
    out
}

/// Decrypt data produced by `encrypt_credential`. Format: `[12-byte nonce || ciphertext+tag]`.
pub(crate) fn decrypt_credential(data: &[u8], agent_id: &str) -> Result<Vec<u8>, OlError> {
    let too_short = || {
        OlError::new(
            ERR_FILE_FALLBACK_ERROR,
            "Credentials file too short (expected at least 12 bytes for nonce)",
        )
    };
    let (nonce_bytes, ciphertext) = data.split_at_checked(12).ok_or_else(too_short)?;
    let key = derive_key(agent_id);
    let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
    let nonce = Nonce::<Aes256Gcm>::try_from(nonce_bytes).map_err(|_| too_short())?;
    cipher.decrypt(&nonce, ciphertext).map_err(|_| {
        OlError::new(
            ERR_FILE_FALLBACK_ERROR,
            "Credentials file decryption failed — agent_id may have changed or file is corrupt",
        )
        .with_suggestion(
            "Delete ~/.openlatch/credentials.enc and re-authenticate with 'openlatch auth login'.",
        )
    })
}

/// Encrypted file credential store at a given path (per D-02).
///
/// Uses AES-256-GCM with a key derived from agent_id via HKDF-SHA256 (per D-03, D-04).
pub struct FileCredentialStore {
    path: PathBuf,
    agent_id: String,
}

impl FileCredentialStore {
    pub fn new(path: PathBuf, agent_id: String) -> Self {
        Self { path, agent_id }
    }
}

impl CredentialStore for FileCredentialStore {
    fn store(&self, key: SecretString) -> Result<(), OlError> {
        let encrypted = encrypt_credential(key.expose_secret().as_bytes(), &self.agent_id);
        // Ensure parent directory exists
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    ERR_FILE_FALLBACK_ERROR,
                    format!("Cannot create directory: {e}"),
                )
            })?;
        }
        std::fs::write(&self.path, &encrypted).map_err(|e| {
            OlError::new(
                ERR_FILE_FALLBACK_ERROR,
                format!("Cannot write credentials file: {e}"),
            )
        })?;
        // Owner-only, on every platform (per T-03-06 for the Unix half).
        crate::fs_secure::restrict_to_owner(&self.path).map_err(|e| {
            OlError::new(
                ERR_FILE_FALLBACK_ERROR,
                format!("Cannot set file permissions: {e}"),
            )
        })?;
        Ok(())
    }

    fn retrieve(&self) -> Result<SecretString, OlError> {
        let data = std::fs::read(&self.path).map_err(|e| {
            OlError::new(
                ERR_FILE_FALLBACK_ERROR,
                format!("Cannot read credentials file: {e}"),
            )
        })?;
        let plaintext = decrypt_credential(&data, &self.agent_id)?;
        let key_str = String::from_utf8(plaintext).map_err(|_| {
            OlError::new(
                ERR_FILE_FALLBACK_ERROR,
                "Decrypted credential is not valid UTF-8",
            )
        })?;
        Ok(SecretString::from(key_str))
    }

    fn delete(&self) -> Result<(), OlError> {
        if self.path.exists() {
            std::fs::remove_file(&self.path).map_err(|e| {
                OlError::new(
                    ERR_FILE_FALLBACK_ERROR,
                    format!("Cannot delete credentials file: {e}"),
                )
            })?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use secrecy::ExposeSecret;
    use tempfile::tempdir;

    #[test]
    fn test_derive_key_produces_32_bytes() {
        let key = derive_key("agt_abc123");
        assert_eq!(key.len(), 32);
    }

    #[test]
    fn test_derive_key_is_deterministic() {
        let key1 = derive_key("agt_abc123");
        let key2 = derive_key("agt_abc123");
        assert_eq!(key1, key2);
    }

    #[test]
    fn test_derive_key_different_inputs_produce_different_keys() {
        let key1 = derive_key("agt_abc123");
        let key2 = derive_key("agt_xyz789");
        assert_ne!(key1, key2);
    }

    #[test]
    fn test_encrypt_then_decrypt_round_trips() {
        let agent_id = "agt_test_round_trip";
        let plaintext = b"my-secret-api-key";
        let encrypted = encrypt_credential(plaintext, agent_id);
        let decrypted = decrypt_credential(&encrypted, agent_id).unwrap();
        assert_eq!(decrypted, plaintext);
    }

    #[test]
    fn test_decrypt_with_wrong_agent_id_returns_err() {
        let plaintext = b"my-secret-api-key";
        let encrypted = encrypt_credential(plaintext, "agt_correct");
        let result = decrypt_credential(&encrypted, "agt_wrong");
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().code, ERR_FILE_FALLBACK_ERROR);
    }

    /// Golden vector: the on-disk credential format is a compatibility
    /// contract, not an implementation detail. A user who authenticated on an
    /// older build must still be able to read `~/.openlatch/credentials.enc`
    /// after upgrading — a silent format change would lock them out with a
    /// decryption failure they cannot act on.
    ///
    /// The round-trip tests above only prove self-consistency: they would pass
    /// just as happily if a crate bump changed the nonce size, the tag
    /// position, or the key derivation. This blob was produced by an
    /// implementation *outside* this dependency tree (Python `cryptography`,
    /// HKDF-SHA256 + AES-256-GCM with nonce `00..0b`), so it pins the format to
    /// the standard rather than to whatever the current `aes-gcm` happens to
    /// emit. It caught nothing on the 0.10 → 0.11 bump — the format was
    /// unchanged — and exists so the next major bump cannot change it quietly.
    #[test]
    fn test_decrypts_credential_written_by_an_older_build() {
        const GOLDEN: &str = "000102030405060708090a0bcd0a146c3f15cd9ec355b76\
                              02f129cf7ae5171bc9431078840d87c40f1b265a029";
        let blob: Vec<u8> = GOLDEN
            .as_bytes()
            .chunks(2)
            .map(|pair| {
                u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16)
                    .expect("golden vector is valid hex")
            })
            .collect();

        let plaintext = decrypt_credential(&blob, "agt_compat_vector")
            .expect("credential written by an older build must still decrypt");
        assert_eq!(plaintext, b"my-secret-api-key");
    }

    #[test]
    fn test_decrypt_truncated_data_returns_err() {
        // Data shorter than 12 bytes (nonce size) should fail immediately
        let short_data = &[0u8; 5];
        let result = decrypt_credential(short_data, "agt_any");
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().code, ERR_FILE_FALLBACK_ERROR);
    }

    #[test]
    fn test_file_credential_store_store_then_retrieve_round_trips() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("credentials.enc");
        let store = FileCredentialStore::new(path, "agt_test_install".to_string());

        store
            .store(SecretString::from("test-api-key-12345".to_string()))
            .unwrap();
        let retrieved = store.retrieve().unwrap();
        assert_eq!(retrieved.expose_secret(), "test-api-key-12345");
    }

    #[test]
    fn test_file_credential_store_delete_removes_file() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("credentials.enc");
        let store = FileCredentialStore::new(path.clone(), "agt_test_install".to_string());

        store
            .store(SecretString::from("test-key".to_string()))
            .unwrap();
        assert!(path.exists());

        store.delete().unwrap();
        assert!(!path.exists());
    }

    #[test]
    fn test_file_credential_store_delete_is_noop_when_missing() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("nonexistent.enc");
        let store = FileCredentialStore::new(path, "agt_test_install".to_string());

        // Should not return an error if the file doesn't exist
        assert!(store.delete().is_ok());
    }
}