//! Encryption helpers for plugin secrets at rest. //! //! Secret-type config fields are encrypted using AES-256-GCM before storage. //! The encrypted format is: `bb_enc:v1:` //! //! Backward compatibility: `decrypt_field()` checks for the `bb_enc:v1:` prefix. //! If absent, returns the value as-is (plaintext passthrough). This allows existing //! configs to work without migration. use aes_gcm::aead::{Aead, OsRng}; use aes_gcm::{AeadCore, Aes256Gcm, KeyInit}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use bb_interface::{ConfigFieldType, ConfigSchema}; use rand::RngCore; use std::path::Path; use zeroize::Zeroizing; const PREFIX: &str = "bb_enc:v1:"; /// A 256-bit encryption key that is zeroed from memory on drop. pub type EncryptionKey = Zeroizing<[u8; 32]>; /// Generate a random 256-bit encryption key. fn generate_key() -> EncryptionKey { let mut key = Zeroizing::new([0u8; 32]); rand::thread_rng().fill_bytes(key.as_mut()); key } /// Load an encryption key from a file, or generate and save one if it doesn't exist. /// /// Uses `create_new(true)` to atomically create the file, preventing a TOCTOU race /// where two processes could both generate different keys and one overwrites the other. fn load_or_create_key(path: &Path) -> Result { use std::io::Write; // Try to create the file exclusively first (atomic check-and-create). #[cfg(unix)] let open_result = { use std::os::unix::fs::OpenOptionsExt; std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(path) }; #[cfg(not(unix))] let open_result = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path); match open_result { Ok(mut file) => { let key = generate_key(); file.write_all(&*key) .map_err(|e| format!("Failed to write encryption key: {e}"))?; Ok(key) } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { // File already exists — read it. let data = std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?; if data.len() != 32 { return Err(format!( "Encryption key file has wrong size: {} bytes (expected 32)", data.len() )); } let mut key = Zeroizing::new([0u8; 32]); key.copy_from_slice(&data); Ok(key) } Err(e) => Err(format!("Failed to create encryption key file: {e}")), } } const KEYCHAIN_SERVICE: &str = "balanced-breakfast"; const KEYCHAIN_KEY: &str = "encryption:master"; #[tracing::instrument(skip_all)] /// Load an encryption key from the OS keychain, falling back to file-based storage pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result { // Try keychain first if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_KEY) { match entry.get_password() { Ok(b64) => { let bytes = BASE64.decode(&b64).map_err(|e| format!("Keychain key decode failed: {e}"))?; if bytes.len() != 32 { return Err(format!("Keychain key wrong size: {} (expected 32)", bytes.len())); } let mut key = Zeroizing::new([0u8; 32]); key.copy_from_slice(&bytes); return Ok(key); } Err(keyring::Error::NoEntry) => { // No key in keychain — check if file exists to migrate if file_path.exists() { let key = load_or_create_key(file_path)?; // Migrate to keychain let b64 = BASE64.encode(&*key); if entry.set_password(&b64).is_ok() { // Read back and verify before deleting the file fallback if let Ok(readback) = entry.get_password() { if readback == b64 { if let Err(e) = std::fs::remove_file(file_path) { tracing::warn!(error = %e, path = %file_path.display(), "Failed to delete encryption key file after keychain migration"); } tracing::info!("Migrated encryption key from file to keychain"); } else { tracing::warn!("Keychain read-back mismatch, keeping file fallback"); } } else { tracing::warn!("Keychain read-back failed, keeping file fallback"); } } return Ok(key); } // No file either — generate new key and store in keychain let key = generate_key(); let b64 = BASE64.encode(&*key); if entry.set_password(&b64).is_ok() { return Ok(key); } // Keychain write failed — fall back to file tracing::warn!("Keychain write failed, falling back to file-based key"); } Err(_) => { // Keychain read error — fall back to file tracing::warn!("Keychain unavailable, falling back to file-based key"); } } } // Fallback: use file-based key storage load_or_create_key(file_path) } #[tracing::instrument(skip_all)] /// Encrypt a plaintext string field using AES-256-GCM pub fn encrypt_field(plaintext: &str, key: &EncryptionKey) -> Result { let cipher = Aes256Gcm::new((&**key).into()); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) .map_err(|e| format!("Encryption failed: {e}"))?; // nonce (12 bytes) || ciphertext+tag let mut payload = Vec::with_capacity(12 + ciphertext.len()); payload.extend_from_slice(&nonce); payload.extend_from_slice(&ciphertext); Ok(format!("{}{}", PREFIX, BASE64.encode(&payload))) } #[tracing::instrument(skip_all)] /// Decrypt a field value, passing through plaintext values without the `bb_enc:v1:` prefix pub fn decrypt_field(value: &str, key: &EncryptionKey) -> Result { let Some(encoded) = value.strip_prefix(PREFIX) else { return Ok(value.to_string()); }; let payload = BASE64 .decode(encoded) .map_err(|e| format!("Base64 decode failed: {e}"))?; if payload.len() < 12 { return Err("Encrypted payload too short".to_string()); } let (nonce_bytes, ciphertext) = payload.split_at(12); let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); let cipher = Aes256Gcm::new((&**key).into()); let plaintext = cipher .decrypt(nonce, ciphertext) .map_err(|e| format!("Decryption failed: {e}"))?; String::from_utf8(plaintext).map_err(|e| format!("Decrypted data is not valid UTF-8: {e}")) } #[tracing::instrument(skip_all)] /// Encrypt Secret-type fields in a config JSON object in-place pub fn encrypt_config_secrets( config: &mut serde_json::Value, schema: &ConfigSchema, key: &EncryptionKey, ) { let Some(obj) = config.as_object_mut() else { return; }; for field in &schema.fields { if field.field_type != ConfigFieldType::Secret { continue; } if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { if !val.is_empty() && !val.starts_with(PREFIX) { match encrypt_field(val, key) { Ok(encrypted) => { obj.insert(field.key.clone(), serde_json::Value::String(encrypted)); } Err(e) => { tracing::warn!(field = %field.key, error = %e, "Failed to encrypt secret, plaintext retained"); } } } } } } #[tracing::instrument(skip_all)] /// Decrypt Secret-type fields in a config JSON object in-place pub fn decrypt_config_secrets( config: &mut serde_json::Value, schema: &ConfigSchema, key: &EncryptionKey, ) { let Some(obj) = config.as_object_mut() else { return; }; for field in &schema.fields { if field.field_type != ConfigFieldType::Secret { continue; } if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { if val.starts_with(PREFIX) { match decrypt_field(val, key) { Ok(decrypted) => { obj.insert(field.key.clone(), serde_json::Value::String(decrypted)); } Err(e) => { tracing::error!(field = %field.key, error = %e, "Failed to decrypt secret, clearing field to prevent ciphertext leakage. Feed may need re-configuration."); obj.insert(field.key.clone(), serde_json::Value::String(String::new())); } } } } } } #[cfg(test)] mod tests { use super::*; use bb_interface::ConfigField; #[test] fn roundtrip_encrypt_decrypt() { let key = generate_key(); let plaintext = "my-secret-api-key-12345"; let encrypted = encrypt_field(plaintext, &key).unwrap(); assert!(encrypted.starts_with(PREFIX)); assert_ne!(encrypted, plaintext); let decrypted = decrypt_field(&encrypted, &key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn passthrough_non_prefixed() { let key = generate_key(); let plaintext = "just-a-regular-value"; let result = decrypt_field(plaintext, &key).unwrap(); assert_eq!(result, plaintext); } #[test] fn different_nonces_per_call() { let key = generate_key(); let plaintext = "same-input"; let a = encrypt_field(plaintext, &key).unwrap(); let b = encrypt_field(plaintext, &key).unwrap(); // Different nonces should produce different ciphertexts assert_ne!(a, b); // Both should decrypt to the same value assert_eq!(decrypt_field(&a, &key).unwrap(), plaintext); assert_eq!(decrypt_field(&b, &key).unwrap(), plaintext); } #[test] fn wrong_key_fails() { let key1 = generate_key(); let key2 = generate_key(); let encrypted = encrypt_field("secret", &key1).unwrap(); assert!(decrypt_field(&encrypted, &key2).is_err()); } #[test] fn encrypt_config_secrets_only_secrets() { let key = generate_key(); let schema = ConfigSchema { description: "test".to_string(), fields: vec![ ConfigField { key: "url".to_string(), label: "URL".to_string(), description: None, field_type: ConfigFieldType::Url, required: true, default: None, options: vec![], placeholder: None, }, ConfigField { key: "api_key".to_string(), label: "API Key".to_string(), description: None, field_type: ConfigFieldType::Secret, required: true, default: None, options: vec![], placeholder: None, }, ], }; let mut config = serde_json::json!({ "url": "https://example.com/feed", "api_key": "sk-12345" }); encrypt_config_secrets(&mut config, &schema, &key); // URL should be unchanged assert_eq!(config["url"], "https://example.com/feed"); // Secret should be encrypted let encrypted = config["api_key"].as_str().unwrap(); assert!(encrypted.starts_with(PREFIX)); // Decrypt and verify decrypt_config_secrets(&mut config, &schema, &key); assert_eq!(config["api_key"], "sk-12345"); } #[test] fn load_or_create_key_creates_and_reloads() { let dir = std::env::temp_dir().join(format!("bb_test_{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("test.key"); // First call creates the key let key1 = load_or_create_key(&path).unwrap(); assert!(path.exists()); // Second call loads the same key let key2 = load_or_create_key(&path).unwrap(); assert_eq!(key1, key2); // Cleanup let _ = std::fs::remove_dir_all(&dir); } }