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";
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
}
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
}
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'.",
)
})
}
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);
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}"),
)
})?;
#[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() {
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());
assert!(store.delete().is_ok());
}
}