Skip to main content

max / balanced_breakfast

12.7 KB · 353 lines History Blame Raw
1 //! Encryption helpers for plugin secrets at rest.
2 //!
3 //! Secret-type config fields are encrypted using AES-256-GCM before storage.
4 //! The encrypted format is: `bb_enc:v1:<base64(nonce[12] || ciphertext || tag[16])>`
5 //!
6 //! Backward compatibility: `decrypt_field()` checks for the `bb_enc:v1:` prefix.
7 //! If absent, returns the value as-is (plaintext passthrough). This allows existing
8 //! configs to work without migration.
9
10 use aes_gcm::aead::{Aead, OsRng};
11 use aes_gcm::{AeadCore, Aes256Gcm, KeyInit};
12 use base64::engine::general_purpose::STANDARD as BASE64;
13 use base64::Engine;
14 use bb_interface::{ConfigFieldType, ConfigSchema};
15 use rand::RngCore;
16 use std::path::Path;
17 use zeroize::Zeroizing;
18
19 const PREFIX: &str = "bb_enc:v1:";
20
21 /// A 256-bit encryption key that is zeroed from memory on drop.
22 pub type EncryptionKey = Zeroizing<[u8; 32]>;
23
24 /// Generate a random 256-bit encryption key.
25 fn generate_key() -> EncryptionKey {
26 let mut key = Zeroizing::new([0u8; 32]);
27 rand::thread_rng().fill_bytes(key.as_mut());
28 key
29 }
30
31 /// Load an encryption key from a file, or generate and save one if it doesn't exist.
32 ///
33 /// Uses `create_new(true)` to atomically create the file, preventing a TOCTOU race
34 /// where two processes could both generate different keys and one overwrites the other.
35 fn load_or_create_key(path: &Path) -> Result<EncryptionKey, String> {
36 use std::io::Write;
37
38 // Try to create the file exclusively first (atomic check-and-create).
39 #[cfg(unix)]
40 let open_result = {
41 use std::os::unix::fs::OpenOptionsExt;
42 std::fs::OpenOptions::new()
43 .write(true)
44 .create_new(true)
45 .mode(0o600)
46 .open(path)
47 };
48 #[cfg(not(unix))]
49 let open_result = std::fs::OpenOptions::new()
50 .write(true)
51 .create_new(true)
52 .open(path);
53
54 match open_result {
55 Ok(mut file) => {
56 let key = generate_key();
57 file.write_all(&*key)
58 .map_err(|e| format!("Failed to write encryption key: {e}"))?;
59 Ok(key)
60 }
61 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
62 // File already exists — read it.
63 let data =
64 std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?;
65 if data.len() != 32 {
66 return Err(format!(
67 "Encryption key file has wrong size: {} bytes (expected 32)",
68 data.len()
69 ));
70 }
71 let mut key = Zeroizing::new([0u8; 32]);
72 key.copy_from_slice(&data);
73 Ok(key)
74 }
75 Err(e) => Err(format!("Failed to create encryption key file: {e}")),
76 }
77 }
78
79 const KEYCHAIN_SERVICE: &str = "balanced-breakfast";
80 const KEYCHAIN_KEY: &str = "encryption:master";
81
82 #[tracing::instrument(skip_all)]
83 /// Load an encryption key from the OS keychain, falling back to file-based storage
84 pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKey, String> {
85 // Try keychain first
86 if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_KEY) {
87 match entry.get_password() {
88 Ok(b64) => {
89 let bytes = BASE64.decode(&b64).map_err(|e| format!("Keychain key decode failed: {e}"))?;
90 if bytes.len() != 32 {
91 return Err(format!("Keychain key wrong size: {} (expected 32)", bytes.len()));
92 }
93 let mut key = Zeroizing::new([0u8; 32]);
94 key.copy_from_slice(&bytes);
95 return Ok(key);
96 }
97 Err(keyring::Error::NoEntry) => {
98 // No key in keychain — check if file exists to migrate
99 if file_path.exists() {
100 let key = load_or_create_key(file_path)?;
101 // Migrate to keychain
102 let b64 = BASE64.encode(&*key);
103 if entry.set_password(&b64).is_ok() {
104 // Read back and verify before deleting the file fallback
105 if let Ok(readback) = entry.get_password() {
106 if readback == b64 {
107 if let Err(e) = std::fs::remove_file(file_path) {
108 tracing::warn!(error = %e, path = %file_path.display(), "Failed to delete encryption key file after keychain migration");
109 }
110 tracing::info!("Migrated encryption key from file to keychain");
111 } else {
112 tracing::warn!("Keychain read-back mismatch, keeping file fallback");
113 }
114 } else {
115 tracing::warn!("Keychain read-back failed, keeping file fallback");
116 }
117 }
118 return Ok(key);
119 }
120 // No file either — generate new key and store in keychain
121 let key = generate_key();
122 let b64 = BASE64.encode(&*key);
123 if entry.set_password(&b64).is_ok() {
124 return Ok(key);
125 }
126 // Keychain write failed — fall back to file
127 tracing::warn!("Keychain write failed, falling back to file-based key");
128 }
129 Err(_) => {
130 // Keychain read error — fall back to file
131 tracing::warn!("Keychain unavailable, falling back to file-based key");
132 }
133 }
134 }
135
136 // Fallback: use file-based key storage
137 load_or_create_key(file_path)
138 }
139
140 #[tracing::instrument(skip_all)]
141 /// Encrypt a plaintext string field using AES-256-GCM
142 pub fn encrypt_field(plaintext: &str, key: &EncryptionKey) -> Result<String, String> {
143 let cipher = Aes256Gcm::new((&**key).into());
144 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
145 let ciphertext = cipher
146 .encrypt(&nonce, plaintext.as_bytes())
147 .map_err(|e| format!("Encryption failed: {e}"))?;
148
149 // nonce (12 bytes) || ciphertext+tag
150 let mut payload = Vec::with_capacity(12 + ciphertext.len());
151 payload.extend_from_slice(&nonce);
152 payload.extend_from_slice(&ciphertext);
153
154 Ok(format!("{}{}", PREFIX, BASE64.encode(&payload)))
155 }
156
157 #[tracing::instrument(skip_all)]
158 /// Decrypt a field value, passing through plaintext values without the `bb_enc:v1:` prefix
159 pub fn decrypt_field(value: &str, key: &EncryptionKey) -> Result<String, String> {
160 let Some(encoded) = value.strip_prefix(PREFIX) else {
161 return Ok(value.to_string());
162 };
163
164 let payload = BASE64
165 .decode(encoded)
166 .map_err(|e| format!("Base64 decode failed: {e}"))?;
167
168 if payload.len() < 12 {
169 return Err("Encrypted payload too short".to_string());
170 }
171
172 let (nonce_bytes, ciphertext) = payload.split_at(12);
173 let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
174 let cipher = Aes256Gcm::new((&**key).into());
175
176 let plaintext = cipher
177 .decrypt(nonce, ciphertext)
178 .map_err(|e| format!("Decryption failed: {e}"))?;
179
180 String::from_utf8(plaintext).map_err(|e| format!("Decrypted data is not valid UTF-8: {e}"))
181 }
182
183 #[tracing::instrument(skip_all)]
184 /// Encrypt Secret-type fields in a config JSON object in-place
185 pub fn encrypt_config_secrets(
186 config: &mut serde_json::Value,
187 schema: &ConfigSchema,
188 key: &EncryptionKey,
189 ) {
190 let Some(obj) = config.as_object_mut() else {
191 return;
192 };
193 for field in &schema.fields {
194 if field.field_type != ConfigFieldType::Secret {
195 continue;
196 }
197 if let Some(serde_json::Value::String(val)) = obj.get(&field.key) {
198 if !val.is_empty() && !val.starts_with(PREFIX) {
199 match encrypt_field(val, key) {
200 Ok(encrypted) => {
201 obj.insert(field.key.clone(), serde_json::Value::String(encrypted));
202 }
203 Err(e) => {
204 tracing::warn!(field = %field.key, error = %e, "Failed to encrypt secret, plaintext retained");
205 }
206 }
207 }
208 }
209 }
210 }
211
212 #[tracing::instrument(skip_all)]
213 /// Decrypt Secret-type fields in a config JSON object in-place
214 pub fn decrypt_config_secrets(
215 config: &mut serde_json::Value,
216 schema: &ConfigSchema,
217 key: &EncryptionKey,
218 ) {
219 let Some(obj) = config.as_object_mut() else {
220 return;
221 };
222 for field in &schema.fields {
223 if field.field_type != ConfigFieldType::Secret {
224 continue;
225 }
226 if let Some(serde_json::Value::String(val)) = obj.get(&field.key) {
227 if val.starts_with(PREFIX) {
228 match decrypt_field(val, key) {
229 Ok(decrypted) => {
230 obj.insert(field.key.clone(), serde_json::Value::String(decrypted));
231 }
232 Err(e) => {
233 tracing::error!(field = %field.key, error = %e, "Failed to decrypt secret, clearing field to prevent ciphertext leakage. Feed may need re-configuration.");
234 obj.insert(field.key.clone(), serde_json::Value::String(String::new()));
235 }
236 }
237 }
238 }
239 }
240 }
241
242 #[cfg(test)]
243 mod tests {
244 use super::*;
245 use bb_interface::ConfigField;
246
247 #[test]
248 fn roundtrip_encrypt_decrypt() {
249 let key = generate_key();
250 let plaintext = "my-secret-api-key-12345";
251 let encrypted = encrypt_field(plaintext, &key).unwrap();
252 assert!(encrypted.starts_with(PREFIX));
253 assert_ne!(encrypted, plaintext);
254
255 let decrypted = decrypt_field(&encrypted, &key).unwrap();
256 assert_eq!(decrypted, plaintext);
257 }
258
259 #[test]
260 fn passthrough_non_prefixed() {
261 let key = generate_key();
262 let plaintext = "just-a-regular-value";
263 let result = decrypt_field(plaintext, &key).unwrap();
264 assert_eq!(result, plaintext);
265 }
266
267 #[test]
268 fn different_nonces_per_call() {
269 let key = generate_key();
270 let plaintext = "same-input";
271 let a = encrypt_field(plaintext, &key).unwrap();
272 let b = encrypt_field(plaintext, &key).unwrap();
273 // Different nonces should produce different ciphertexts
274 assert_ne!(a, b);
275 // Both should decrypt to the same value
276 assert_eq!(decrypt_field(&a, &key).unwrap(), plaintext);
277 assert_eq!(decrypt_field(&b, &key).unwrap(), plaintext);
278 }
279
280 #[test]
281 fn wrong_key_fails() {
282 let key1 = generate_key();
283 let key2 = generate_key();
284 let encrypted = encrypt_field("secret", &key1).unwrap();
285 assert!(decrypt_field(&encrypted, &key2).is_err());
286 }
287
288 #[test]
289 fn encrypt_config_secrets_only_secrets() {
290 let key = generate_key();
291 let schema = ConfigSchema {
292 description: "test".to_string(),
293 fields: vec![
294 ConfigField {
295 key: "url".to_string(),
296 label: "URL".to_string(),
297 description: None,
298 field_type: ConfigFieldType::Url,
299 required: true,
300 default: None,
301 options: vec![],
302 placeholder: None,
303 },
304 ConfigField {
305 key: "api_key".to_string(),
306 label: "API Key".to_string(),
307 description: None,
308 field_type: ConfigFieldType::Secret,
309 required: true,
310 default: None,
311 options: vec![],
312 placeholder: None,
313 },
314 ],
315 };
316
317 let mut config = serde_json::json!({
318 "url": "https://example.com/feed",
319 "api_key": "sk-12345"
320 });
321
322 encrypt_config_secrets(&mut config, &schema, &key);
323
324 // URL should be unchanged
325 assert_eq!(config["url"], "https://example.com/feed");
326 // Secret should be encrypted
327 let encrypted = config["api_key"].as_str().unwrap();
328 assert!(encrypted.starts_with(PREFIX));
329
330 // Decrypt and verify
331 decrypt_config_secrets(&mut config, &schema, &key);
332 assert_eq!(config["api_key"], "sk-12345");
333 }
334
335 #[test]
336 fn load_or_create_key_creates_and_reloads() {
337 let dir = std::env::temp_dir().join(format!("bb_test_{}", std::process::id()));
338 std::fs::create_dir_all(&dir).unwrap();
339 let path = dir.join("test.key");
340
341 // First call creates the key
342 let key1 = load_or_create_key(&path).unwrap();
343 assert!(path.exists());
344
345 // Second call loads the same key
346 let key2 = load_or_create_key(&path).unwrap();
347 assert_eq!(key1, key2);
348
349 // Cleanup
350 let _ = std::fs::remove_dir_all(&dir);
351 }
352 }
353