use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{
fs,
path::{Path, PathBuf},
};
use tracing::debug;
use crate::secrets::SecretManager;
const ENCRYPTION_KEY_NAME: &str = "enact.config.encryption_key";
const NONCE_SIZE: usize = 12;
pub struct EncryptedStore {
config_path: PathBuf,
secrets: SecretManager,
}
#[derive(Debug, Serialize, Deserialize)]
struct EncryptedData {
nonce: Vec<u8>,
ciphertext: Vec<u8>,
}
impl EncryptedStore {
pub fn new(config_path: impl AsRef<Path>) -> Result<Self> {
let secrets = SecretManager::new();
Self::with_secrets(config_path, secrets)
}
pub fn with_secrets(config_path: impl AsRef<Path>, secrets: SecretManager) -> Result<Self> {
let config_path = config_path.as_ref().to_path_buf();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).context("Failed to create config directory")?;
}
Ok(Self {
config_path,
secrets,
})
}
fn get_encryption_key(&self) -> Result<Key<Aes256Gcm>> {
if let Some(key_str) = self.secrets.get(ENCRYPTION_KEY_NAME)? {
let key_bytes = hex::decode(&key_str).context("Failed to decode encryption key")?;
if key_bytes.len() == 32 {
return Ok(*Key::<Aes256Gcm>::from_slice(&key_bytes));
}
}
let key = Aes256Gcm::generate_key(&mut OsRng);
let key_hex = hex::encode(key.as_slice());
if self.secrets.set(ENCRYPTION_KEY_NAME, &key_hex).is_err() {
eprintln!("⚠️ WARNING: No encryption key found in environment.");
eprintln!(" Generated temporary key: {}", key_hex);
eprintln!(
" Set ENACT_CONFIG_ENCRYPTION_KEY={} in your .env file to persist configuration.",
key_hex
);
} else {
debug!("Generated and stored new encryption key (mock)");
}
debug!("Using generated encryption key");
Ok(key)
}
pub fn save(&self, config: &str) -> Result<()> {
let key = self.get_encryption_key()?;
let cipher = Aes256Gcm::new(&key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, config.as_bytes())
.map_err(|_| anyhow::anyhow!("Failed to encrypt configuration"))?;
let encrypted_data = EncryptedData {
nonce: nonce.to_vec(),
ciphertext,
};
let json = serde_json::to_string_pretty(&encrypted_data)
.context("Failed to serialize encrypted data")?;
fs::write(&self.config_path, json).context("Failed to write encrypted config file")?;
debug!("Saved encrypted configuration to {:?}", self.config_path);
Ok(())
}
pub fn load(&self) -> Result<Option<String>> {
if !self.config_path.exists() {
debug!("Config file does not exist: {:?}", self.config_path);
return Ok(None);
}
let json = fs::read_to_string(&self.config_path)
.context("Failed to read encrypted config file")?;
let encrypted_data: EncryptedData =
serde_json::from_str(&json).context("Failed to parse encrypted config file")?;
let key = self.get_encryption_key()?;
let cipher = Aes256Gcm::new(&key);
if encrypted_data.nonce.len() != NONCE_SIZE {
return Err(anyhow::anyhow!("Invalid nonce size"));
}
let nonce = Nonce::from_slice(&encrypted_data.nonce);
let plaintext = cipher
.decrypt(nonce, encrypted_data.ciphertext.as_ref())
.map_err(|_| {
anyhow::anyhow!(
"Failed to decrypt configuration - Check your ENACT_CONFIG_ENCRYPTION_KEY"
)
})?;
let config = String::from_utf8(plaintext)
.context("Failed to decode decrypted configuration as UTF-8")?;
debug!("Loaded encrypted configuration from {:?}", self.config_path);
Ok(Some(config))
}
pub fn config_path(&self) -> &Path {
&self.config_path
}
}
pub fn default_config_path() -> Result<PathBuf> {
Ok(crate::home::enact_home().join("config.encrypted"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_encrypted_store() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.encrypted");
let secrets = SecretManager::new_mock();
let store = EncryptedStore::with_secrets(&config_path, secrets).unwrap();
let test_config = r#"{"test": "value", "number": 42}"#;
store.save(test_config).unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded, Some(test_config.to_string()));
let file_contents = fs::read_to_string(&config_path).unwrap();
assert!(!file_contents.contains("test"));
assert!(!file_contents.contains("value"));
}
}