// Encrypted secrets management for containers // Stores secrets encrypted with AES-256-GCM and injects them at runtime use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; use anyhow::{Context, Result}; use rand::RngCore; use std::path::PathBuf; use tokio::fs; use uuid::Uuid; /// Prefix to identify encrypted files (magic bytes) const ENCRYPTED_MAGIC: &[u8] = b"ARCHI_ENC1"; pub struct SecretsManager { secrets_dir: PathBuf, cipher: Aes256Gcm, } impl SecretsManager { /// Create a new SecretsManager with a 32-byte encryption key. /// In production, derive this key from the user's password via Argon2. pub fn new(secrets_dir: PathBuf, encryption_key: Vec) -> Result { anyhow::ensure!( encryption_key.len() == 32, "Encryption key must be exactly 32 bytes (256 bits), got {}", encryption_key.len() ); let cipher = Aes256Gcm::new_from_slice(&encryption_key) .map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?; Ok(Self { secrets_dir, cipher, }) } /// Encrypt a plaintext value using AES-256-GCM. /// Returns: MAGIC (10 bytes) + nonce (12 bytes) + ciphertext (variable) fn encrypt(&self, plaintext: &[u8]) -> Result> { let mut nonce_bytes = [0u8; 12]; rand::thread_rng().fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = self .cipher .encrypt(nonce, plaintext) .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + 12 + ciphertext.len()); output.extend_from_slice(ENCRYPTED_MAGIC); output.extend_from_slice(&nonce_bytes); output.extend_from_slice(&ciphertext); Ok(output) } /// Decrypt a previously encrypted value. fn decrypt(&self, data: &[u8]) -> Result> { let magic_len = ENCRYPTED_MAGIC.len(); anyhow::ensure!( data.len() > magic_len + 12, "Encrypted data too short" ); anyhow::ensure!( &data[..magic_len] == ENCRYPTED_MAGIC, "Invalid encrypted data (bad magic bytes)" ); let nonce = Nonce::from_slice(&data[magic_len..magic_len + 12]); let ciphertext = &data[magic_len + 12..]; self.cipher .decrypt(nonce, ciphertext) .map_err(|e| anyhow::anyhow!("Decryption failed (wrong key or corrupted data): {}", e)) } /// Store a secret for an app (encrypted at rest) pub async fn store_secret( &self, app_id: &str, _key: &str, value: &str, ) -> Result { let secret_id = Uuid::new_v4().to_string(); let secret_path = self .secrets_dir .join(app_id) .join(format!("{}.secret", secret_id)); fs::create_dir_all(secret_path.parent().unwrap()).await?; let encrypted = self .encrypt(value.as_bytes()) .context("Failed to encrypt secret")?; fs::write(&secret_path, &encrypted) .await .context("Failed to write secret")?; // Set restrictive permissions #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&secret_path).await?.permissions(); perms.set_mode(0o600); fs::set_permissions(&secret_path, perms).await?; } Ok(secret_id) } /// Read and decrypt a secret value pub async fn read_secret(&self, app_id: &str, secret_id: &str) -> Result { let secret_path = self .secrets_dir .join(app_id) .join(format!("{}.secret", secret_id)); let data = fs::read(&secret_path) .await .context("Failed to read secret file")?; // Support reading legacy plaintext secrets (no magic prefix) if data.len() < ENCRYPTED_MAGIC.len() || &data[..ENCRYPTED_MAGIC.len()] != ENCRYPTED_MAGIC { return String::from_utf8(data) .context("Legacy secret is not valid UTF-8"); } let plaintext = self.decrypt(&data)?; String::from_utf8(plaintext).context("Decrypted secret is not valid UTF-8") } /// Retrieve a secret (returns the secret ID path for volume mounting) pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf { self.secrets_dir .join(app_id) .join(format!("{}.secret", secret_id)) } /// List secrets for an app pub async fn list_secrets(&self, app_id: &str) -> Result> { let app_secrets_dir = self.secrets_dir.join(app_id); if !app_secrets_dir.exists() { return Ok(vec![]); } let mut secrets = Vec::new(); let mut entries = fs::read_dir(&app_secrets_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("secret") { if let Some(secret_id) = path .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_string()) { secrets.push(secret_id); } } } Ok(secrets) } /// Delete a secret pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> { let secret_path = self .secrets_dir .join(app_id) .join(format!("{}.secret", secret_id)); if secret_path.exists() { fs::remove_file(&secret_path).await?; } Ok(()) } } #[cfg(test)] mod tests { use super::*; fn test_key() -> Vec { vec![0x42; 32] // 32-byte test key } #[tokio::test] async fn test_encrypt_decrypt_roundtrip() { let dir = tempfile::tempdir().unwrap(); let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap(); let secret_id = mgr .store_secret("test-app", "db-password", "supersecret123") .await .unwrap(); let decrypted = mgr.read_secret("test-app", &secret_id).await.unwrap(); assert_eq!(decrypted, "supersecret123"); } #[tokio::test] async fn test_wrong_key_fails() { let dir = tempfile::tempdir().unwrap(); let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap(); let secret_id = mgr .store_secret("test-app", "key", "secret") .await .unwrap(); let wrong_key = vec![0x99; 32]; let mgr2 = SecretsManager::new(dir.path().to_path_buf(), wrong_key).unwrap(); assert!(mgr2.read_secret("test-app", &secret_id).await.is_err()); } #[tokio::test] async fn test_list_and_delete() { let dir = tempfile::tempdir().unwrap(); let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap(); let id1 = mgr.store_secret("app1", "k1", "v1").await.unwrap(); let _id2 = mgr.store_secret("app1", "k2", "v2").await.unwrap(); let list = mgr.list_secrets("app1").await.unwrap(); assert_eq!(list.len(), 2); mgr.delete_secret("app1", &id1).await.unwrap(); let list = mgr.list_secrets("app1").await.unwrap(); assert_eq!(list.len(), 1); } #[test] fn test_invalid_key_length() { let dir = tempfile::tempdir().unwrap(); assert!(SecretsManager::new(dir.path().to_path_buf(), vec![0; 16]).is_err()); } #[tokio::test] async fn test_file_is_encrypted_on_disk() { let dir = tempfile::tempdir().unwrap(); let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap(); let secret_id = mgr .store_secret("test-app", "key", "my_secret_value") .await .unwrap(); let path = mgr.get_secret_path("test-app", &secret_id); let raw = std::fs::read(&path).unwrap(); // File must NOT contain plaintext assert!(!raw.windows(15).any(|w| w == b"my_secret_value")); // File must start with our magic prefix assert_eq!(&raw[..ENCRYPTED_MAGIC.len()], ENCRYPTED_MAGIC); } }