enact-config 0.0.2

Unified configuration management for Enact - secure storage with keychain and encrypted files
Documentation
//! Encrypted File Storage - Secure storage for non-sensitive settings
//!
//! Stores configuration in an encrypted file using AES-256-GCM.
//! The encryption key is derived from environment variables.

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; // 96 bits for GCM

/// Encrypted configuration store
pub struct EncryptedStore {
    config_path: PathBuf,
    secrets: SecretManager,
}

#[derive(Debug, Serialize, Deserialize)]
struct EncryptedData {
    nonce: Vec<u8>,
    ciphertext: Vec<u8>,
}

impl EncryptedStore {
    /// Create a new encrypted store
    ///
    /// # Arguments
    /// * `config_path` - Path to the encrypted config file
    pub fn new(config_path: impl AsRef<Path>) -> Result<Self> {
        // Always use SecretManager
        let secrets = SecretManager::new();
        Self::with_secrets(config_path, secrets)
    }

    /// Create a new encrypted store with a specific secret manager
    ///
    /// # Arguments
    /// * `config_path` - Path to the encrypted config file
    /// * `secrets` - Secret manager to use
    pub fn with_secrets(config_path: impl AsRef<Path>, secrets: SecretManager) -> Result<Self> {
        let config_path = config_path.as_ref().to_path_buf();

        // Ensure parent directory exists
        if let Some(parent) = config_path.parent() {
            fs::create_dir_all(parent).context("Failed to create config directory")?;
        }

        Ok(Self {
            config_path,
            secrets,
        })
    }

    /// Get or create the encryption key
    fn get_encryption_key(&self) -> Result<Key<Aes256Gcm>> {
        // Try to get existing key from environment
        if let Some(key_str) = self.secrets.get(ENCRYPTION_KEY_NAME)? {
            // Decode hex string to key
            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));
            }
        }

        // Generate new key if not found
        // Since we can't persist it to .env automatically, we must warn the user
        let key = Aes256Gcm::generate_key(&mut OsRng);
        let key_hex = hex::encode(key.as_slice());

        // Try to store in secrets (works if mock store, fails/warns if .env)
        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)
    }

    /// Encrypt and store configuration
    ///
    /// # Arguments
    /// * `config` - The configuration to store (as JSON string)
    pub fn save(&self, config: &str) -> Result<()> {
        let key = self.get_encryption_key()?;
        let cipher = Aes256Gcm::new(&key);

        // Generate nonce
        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);

        // Encrypt
        let ciphertext = cipher
            .encrypt(&nonce, config.as_bytes())
            .map_err(|_| anyhow::anyhow!("Failed to encrypt configuration"))?;

        // Store encrypted data
        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(())
    }

    /// Load and decrypt configuration
    ///
    /// # Returns
    /// * `Ok(Some(config))` if the config exists and was decrypted successfully
    /// * `Ok(None)` if the config file doesn't exist
    /// * `Err` if there was an error reading or decrypting
    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);

        // Reconstruct nonce
        if encrypted_data.nonce.len() != NONCE_SIZE {
            return Err(anyhow::anyhow!("Invalid nonce size"));
        }
        let nonce = Nonce::from_slice(&encrypted_data.nonce);

        // Decrypt
        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))
    }

    /// Get the config file path
    pub fn config_path(&self) -> &Path {
        &self.config_path
    }
}

/// Get the default config file path (under ENACT_HOME).
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");
        // Use mock secrets for test to avoid stderr clutter and ensure key storage
        let secrets = SecretManager::new_mock();
        let store = EncryptedStore::with_secrets(&config_path, secrets).unwrap();

        // Test save and load
        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()));

        // Test that it's actually encrypted
        let file_contents = fs::read_to_string(&config_path).unwrap();
        assert!(!file_contents.contains("test"));
        assert!(!file_contents.contains("value"));
    }
}