Skip to main content

enact_config/
encrypted_store.rs

1//! Encrypted File Storage - Secure storage for non-sensitive settings
2//!
3//! Stores configuration in an encrypted file using AES-256-GCM.
4//! The encryption key is derived from environment variables.
5
6use 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; // 96 bits for GCM
22
23/// Encrypted configuration store
24pub 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    /// Create a new encrypted store
37    ///
38    /// # Arguments
39    /// * `config_path` - Path to the encrypted config file
40    pub fn new(config_path: impl AsRef<Path>) -> Result<Self> {
41        // Always use SecretManager
42        let secrets = SecretManager::new();
43        Self::with_secrets(config_path, secrets)
44    }
45
46    /// Create a new encrypted store with a specific secret manager
47    ///
48    /// # Arguments
49    /// * `config_path` - Path to the encrypted config file
50    /// * `secrets` - Secret manager to use
51    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        // Ensure parent directory exists
55        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    /// Get or create the encryption key
66    fn get_encryption_key(&self) -> Result<Key<Aes256Gcm>> {
67        // Try to get existing key from environment
68        if let Some(key_str) = self.secrets.get(ENCRYPTION_KEY_NAME)? {
69            // Decode hex string to key
70            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        // Generate new key if not found
77        // Since we can't persist it to .env automatically, we must warn the user
78        let key = Aes256Gcm::generate_key(&mut OsRng);
79        let key_hex = hex::encode(key.as_slice());
80
81        // Try to store in secrets (works if mock store, fails/warns if .env)
82        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    /// Encrypt and store configuration
98    ///
99    /// # Arguments
100    /// * `config` - The configuration to store (as JSON string)
101    pub fn save(&self, config: &str) -> Result<()> {
102        let key = self.get_encryption_key()?;
103        let cipher = Aes256Gcm::new(&key);
104
105        // Generate nonce
106        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
107
108        // Encrypt
109        let ciphertext = cipher
110            .encrypt(&nonce, config.as_bytes())
111            .map_err(|_| anyhow::anyhow!("Failed to encrypt configuration"))?;
112
113        // Store encrypted data
114        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    /// Load and decrypt configuration
129    ///
130    /// # Returns
131    /// * `Ok(Some(config))` if the config exists and was decrypted successfully
132    /// * `Ok(None)` if the config file doesn't exist
133    /// * `Err` if there was an error reading or decrypting
134    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        // Reconstruct nonce
150        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        // Decrypt
156        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    /// Get the config file path
172    pub fn config_path(&self) -> &Path {
173        &self.config_path
174    }
175}
176
177/// Get the default config file path (under ENACT_HOME).
178pub 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        // Use mock secrets for test to avoid stderr clutter and ensure key storage
192        let secrets = SecretManager::new_mock();
193        let store = EncryptedStore::with_secrets(&config_path, secrets).unwrap();
194
195        // Test save and load
196        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        // Test that it's actually encrypted
203        let file_contents = fs::read_to_string(&config_path).unwrap();
204        assert!(!file_contents.contains("test"));
205        assert!(!file_contents.contains("value"));
206    }
207}