openlatch-client 0.1.5

The open-source security layer for AI agents — client forwarder
//! 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, AeadCore, OsRng},
    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 = Aes256Gcm::generate_nonce(&mut OsRng);
    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> {
    if data.len() < 12 {
        return Err(OlError::new(
            ERR_FILE_FALLBACK_ERROR,
            "Credentials file too short (expected at least 12 bytes for nonce)",
        ));
    }
    let (nonce_bytes, ciphertext) = data.split_at(12);
    let key = derive_key(agent_id);
    let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
    let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
    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}"),
            )
        })?;
        // Set file permissions to 0600 on Unix (per T-03-06)
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = std::fs::Permissions::from_mode(0o600);
            std::fs::set_permissions(&self.path, perms).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);
    }

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