enact_config/
encrypted_store.rs1use aes_gcm::{
7 aead::{Aead, AeadCore, KeyInit, OsRng},
8 Aes256Gcm, Key, Nonce,
9};
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::{
13 fs,
14 path::{Path, PathBuf},
15};
16use tracing::debug;
17
18use crate::secrets::SecretManager;
19
20const ENCRYPTION_KEY_NAME: &str = "enact.config.encryption_key";
21const NONCE_SIZE: usize = 12; pub struct EncryptedStore {
25 config_path: PathBuf,
26 secrets: SecretManager,
27}
28
29#[derive(Debug, Serialize, Deserialize)]
30struct EncryptedData {
31 nonce: Vec<u8>,
32 ciphertext: Vec<u8>,
33}
34
35impl EncryptedStore {
36 pub fn new(config_path: impl AsRef<Path>) -> Result<Self> {
41 let secrets = SecretManager::new();
43 Self::with_secrets(config_path, secrets)
44 }
45
46 pub fn with_secrets(config_path: impl AsRef<Path>, secrets: SecretManager) -> Result<Self> {
52 let config_path = config_path.as_ref().to_path_buf();
53
54 if let Some(parent) = config_path.parent() {
56 fs::create_dir_all(parent).context("Failed to create config directory")?;
57 }
58
59 Ok(Self {
60 config_path,
61 secrets,
62 })
63 }
64
65 fn get_encryption_key(&self) -> Result<Key<Aes256Gcm>> {
67 if let Some(key_str) = self.secrets.get(ENCRYPTION_KEY_NAME)? {
69 let key_bytes = hex::decode(&key_str).context("Failed to decode encryption key")?;
71 if key_bytes.len() == 32 {
72 return Ok(*Key::<Aes256Gcm>::from_slice(&key_bytes));
73 }
74 }
75
76 let key = Aes256Gcm::generate_key(&mut OsRng);
79 let key_hex = hex::encode(key.as_slice());
80
81 if self.secrets.set(ENCRYPTION_KEY_NAME, &key_hex).is_err() {
83 eprintln!("⚠️ WARNING: No encryption key found in environment.");
84 eprintln!(" Generated temporary key: {}", key_hex);
85 eprintln!(
86 " Set ENACT_CONFIG_ENCRYPTION_KEY={} in your .env file to persist configuration.",
87 key_hex
88 );
89 } else {
90 debug!("Generated and stored new encryption key (mock)");
91 }
92
93 debug!("Using generated encryption key");
94 Ok(key)
95 }
96
97 pub fn save(&self, config: &str) -> Result<()> {
102 let key = self.get_encryption_key()?;
103 let cipher = Aes256Gcm::new(&key);
104
105 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
107
108 let ciphertext = cipher
110 .encrypt(&nonce, config.as_bytes())
111 .map_err(|_| anyhow::anyhow!("Failed to encrypt configuration"))?;
112
113 let encrypted_data = EncryptedData {
115 nonce: nonce.to_vec(),
116 ciphertext,
117 };
118
119 let json = serde_json::to_string_pretty(&encrypted_data)
120 .context("Failed to serialize encrypted data")?;
121
122 fs::write(&self.config_path, json).context("Failed to write encrypted config file")?;
123
124 debug!("Saved encrypted configuration to {:?}", self.config_path);
125 Ok(())
126 }
127
128 pub fn load(&self) -> Result<Option<String>> {
135 if !self.config_path.exists() {
136 debug!("Config file does not exist: {:?}", self.config_path);
137 return Ok(None);
138 }
139
140 let json = fs::read_to_string(&self.config_path)
141 .context("Failed to read encrypted config file")?;
142
143 let encrypted_data: EncryptedData =
144 serde_json::from_str(&json).context("Failed to parse encrypted config file")?;
145
146 let key = self.get_encryption_key()?;
147 let cipher = Aes256Gcm::new(&key);
148
149 if encrypted_data.nonce.len() != NONCE_SIZE {
151 return Err(anyhow::anyhow!("Invalid nonce size"));
152 }
153 let nonce = Nonce::from_slice(&encrypted_data.nonce);
154
155 let plaintext = cipher
157 .decrypt(nonce, encrypted_data.ciphertext.as_ref())
158 .map_err(|_| {
159 anyhow::anyhow!(
160 "Failed to decrypt configuration - Check your ENACT_CONFIG_ENCRYPTION_KEY"
161 )
162 })?;
163
164 let config = String::from_utf8(plaintext)
165 .context("Failed to decode decrypted configuration as UTF-8")?;
166
167 debug!("Loaded encrypted configuration from {:?}", self.config_path);
168 Ok(Some(config))
169 }
170
171 pub fn config_path(&self) -> &Path {
173 &self.config_path
174 }
175}
176
177pub fn default_config_path() -> Result<PathBuf> {
179 Ok(crate::home::enact_home().join("config.encrypted"))
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use tempfile::TempDir;
186
187 #[test]
188 fn test_encrypted_store() {
189 let temp_dir = TempDir::new().unwrap();
190 let config_path = temp_dir.path().join("test_config.encrypted");
191 let secrets = SecretManager::new_mock();
193 let store = EncryptedStore::with_secrets(&config_path, secrets).unwrap();
194
195 let test_config = r#"{"test": "value", "number": 42}"#;
197 store.save(test_config).unwrap();
198
199 let loaded = store.load().unwrap();
200 assert_eq!(loaded, Some(test_config.to_string()));
201
202 let file_contents = fs::read_to_string(&config_path).unwrap();
204 assert!(!file_contents.contains("test"));
205 assert!(!file_contents.contains("value"));
206 }
207}