1use std::sync::Arc;
39
40use aes_gcm::aead::{Aead, KeyInit};
41use aes_gcm::{Aes256Gcm, Nonce as AesNonce};
42use anyhow::{Result, anyhow};
43use chacha20poly1305::{ChaCha20Poly1305, Nonce as ChaChaNonce};
44use hkdf::Hkdf;
45use pbkdf2::pbkdf2_hmac;
46use serde::{Deserialize, Serialize};
47use sha2::Sha256;
48use zeroize::Zeroizing;
49
50const ENVELOPE_VERSION: u32 = 3;
52
53pub const MIN_KDF_ITERATIONS: u32 = 600_000;
56
57const AEAD_NONCE_LEN: usize = 12;
59const AEAD_KEY_LEN: usize = 32;
61const PER_BLOB_SALT_LEN: usize = 16;
63pub const MASTER_SALT_LEN: usize = 16;
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct EncryptionConfig {
68 pub enabled: bool,
69 pub algorithm: String,
70 pub kdf_iterations: u32,
71 #[serde(default, skip_serializing_if = "String::is_empty")]
77 pub master_salt: String,
78}
79
80impl Default for EncryptionConfig {
81 fn default() -> Self {
82 Self {
83 enabled: false,
84 algorithm: "aes-256-gcm".to_string(),
85 kdf_iterations: MIN_KDF_ITERATIONS,
86 master_salt: String::new(),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct EncryptedData {
93 pub version: u32,
94 pub algorithm: String,
95 pub salt: Vec<u8>,
96 pub nonce: Vec<u8>,
97 pub ciphertext: Vec<u8>,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101enum Algorithm {
102 Aes256Gcm,
103 ChaCha20Poly1305,
104}
105
106impl Algorithm {
107 fn parse(name: &str) -> Result<Self> {
108 match name.to_ascii_lowercase().as_str() {
109 "aes-256-gcm" => Ok(Self::Aes256Gcm),
110 "chacha20-poly1305" => Ok(Self::ChaCha20Poly1305),
111 other => Err(anyhow!(
112 "unsupported encryption algorithm '{}'; expected aes-256-gcm or chacha20-poly1305",
113 other
114 )),
115 }
116 }
117
118 fn name(self) -> &'static str {
119 match self {
120 Self::Aes256Gcm => "aes-256-gcm",
121 Self::ChaCha20Poly1305 => "chacha20-poly1305",
122 }
123 }
124}
125
126#[derive(Clone)]
131pub struct EncryptionRuntime {
132 inner: Arc<RuntimeInner>,
133}
134
135struct RuntimeInner {
136 algorithm: Algorithm,
137 master_key: Zeroizing<Vec<u8>>,
138}
139
140impl std::fmt::Debug for EncryptionRuntime {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.debug_struct("EncryptionRuntime")
143 .field("algorithm", &self.inner.algorithm.name())
144 .field("master_key", &"<redacted>")
145 .finish()
146 }
147}
148
149impl EncryptionRuntime {
150 pub fn from_config(config: EncryptionConfig, password: String) -> Result<Self> {
156 if !config.enabled {
157 return Err(anyhow!(
158 "encryption runtime requires enabled encryption config"
159 ));
160 }
161 validate_config(&config)?;
162 let password = Zeroizing::new(password);
163 if password.is_empty() {
164 return Err(anyhow!("encryption password cannot be empty"));
165 }
166 let master_salt = hex::decode(&config.master_salt)
167 .map_err(|_| anyhow!("master_salt is not valid hex"))?;
168 if master_salt.len() != MASTER_SALT_LEN {
169 return Err(anyhow!(
170 "master_salt must be {} bytes (got {})",
171 MASTER_SALT_LEN,
172 master_salt.len()
173 ));
174 }
175 let algorithm = Algorithm::parse(&config.algorithm)?;
176
177 let mut master_key = Zeroizing::new(vec![0u8; AEAD_KEY_LEN]);
178 pbkdf2_hmac::<Sha256>(
179 password.as_bytes(),
180 &master_salt,
181 config.kdf_iterations,
182 &mut master_key,
183 );
184
185 Ok(Self {
186 inner: Arc::new(RuntimeInner {
187 algorithm,
188 master_key,
189 }),
190 })
191 }
192
193 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
195 let salt = utils::random_bytes(PER_BLOB_SALT_LEN)?;
196 let nonce = utils::random_bytes(AEAD_NONCE_LEN)?;
197 let per_blob_key = derive_per_blob_key(
198 &self.inner.master_key,
199 &salt,
200 self.inner.algorithm.name(),
201 )?;
202 let ciphertext =
203 aead_encrypt(self.inner.algorithm, per_blob_key.as_slice(), &nonce, plaintext)?;
204
205 let envelope = EncryptedData {
206 version: ENVELOPE_VERSION,
207 algorithm: self.inner.algorithm.name().to_string(),
208 salt,
209 nonce,
210 ciphertext,
211 };
212 crate::canonical::to_cbor(&envelope)
213 }
214
215 pub fn decrypt(&self, bytes: &[u8]) -> Result<Vec<u8>> {
217 let envelope: EncryptedData = crate::canonical::from_cbor(bytes)
218 .map_err(|e| anyhow!("invalid encryption envelope: {e}"))?;
219 if envelope.version != ENVELOPE_VERSION {
220 return Err(anyhow!(
221 "unsupported envelope version {}; this build only reads v{}",
222 envelope.version,
223 ENVELOPE_VERSION
224 ));
225 }
226 let algorithm = Algorithm::parse(&envelope.algorithm)?;
227 if algorithm != self.inner.algorithm {
228 return Err(anyhow!(
229 "algorithm mismatch: runtime={} envelope={}",
230 self.inner.algorithm.name(),
231 envelope.algorithm
232 ));
233 }
234 if envelope.nonce.len() != AEAD_NONCE_LEN {
235 return Err(anyhow!(
236 "invalid nonce size: expected {}, got {}",
237 AEAD_NONCE_LEN,
238 envelope.nonce.len()
239 ));
240 }
241 let per_blob_key = derive_per_blob_key(
242 &self.inner.master_key,
243 &envelope.salt,
244 self.inner.algorithm.name(),
245 )?;
246 aead_decrypt(
247 self.inner.algorithm,
248 per_blob_key.as_slice(),
249 &envelope.nonce,
250 &envelope.ciphertext,
251 )
252 }
253
254 pub fn algorithm(&self) -> &'static str {
255 self.inner.algorithm.name()
256 }
257
258 pub fn is_enabled(&self) -> bool {
259 true
260 }
261}
262
263pub fn validate_config(config: &EncryptionConfig) -> Result<()> {
265 if !config.enabled {
266 return Err(anyhow!("encryption is disabled in config"));
267 }
268 Algorithm::parse(&config.algorithm)?;
269 if config.kdf_iterations < MIN_KDF_ITERATIONS {
270 return Err(anyhow!(
271 "kdf_iterations must be at least {} (OWASP 2024 minimum for PBKDF2-HMAC-SHA256)",
272 MIN_KDF_ITERATIONS
273 ));
274 }
275 if config.master_salt.is_empty() {
276 return Err(anyhow!(
277 "master_salt is missing from config; call ensure_master_salt before runtime construction"
278 ));
279 }
280 Ok(())
281}
282
283pub fn ensure_master_salt(config: &mut EncryptionConfig) -> Result<bool> {
286 if !config.master_salt.is_empty() {
287 return Ok(false);
288 }
289 let salt = utils::random_bytes(MASTER_SALT_LEN)?;
290 config.master_salt = hex::encode(&salt);
291 Ok(true)
292}
293
294fn derive_per_blob_key(
295 master_key: &[u8],
296 salt: &[u8],
297 algorithm_name: &str,
298) -> Result<Zeroizing<[u8; AEAD_KEY_LEN]>> {
299 let hk = Hkdf::<Sha256>::new(Some(salt), master_key);
300 let mut key = Zeroizing::new([0u8; AEAD_KEY_LEN]);
301 hk.expand(algorithm_name.as_bytes(), key.as_mut())
302 .map_err(|e| anyhow!("HKDF expand failed: {e}"))?;
303 Ok(key)
304}
305
306fn aead_encrypt(
307 algorithm: Algorithm,
308 key: &[u8],
309 nonce: &[u8],
310 plaintext: &[u8],
311) -> Result<Vec<u8>> {
312 match algorithm {
313 Algorithm::Aes256Gcm => {
314 let cipher = Aes256Gcm::new_from_slice(key)
315 .map_err(|_| anyhow!("invalid AES-256-GCM key size"))?;
316 cipher
317 .encrypt(AesNonce::from_slice(nonce), plaintext)
318 .map_err(|e| anyhow!("AES-256-GCM encryption failed: {e}"))
319 }
320 Algorithm::ChaCha20Poly1305 => {
321 let cipher = ChaCha20Poly1305::new_from_slice(key)
322 .map_err(|_| anyhow!("invalid ChaCha20-Poly1305 key size"))?;
323 cipher
324 .encrypt(ChaChaNonce::from_slice(nonce), plaintext)
325 .map_err(|e| anyhow!("ChaCha20-Poly1305 encryption failed: {e}"))
326 }
327 }
328}
329
330fn aead_decrypt(
331 algorithm: Algorithm,
332 key: &[u8],
333 nonce: &[u8],
334 ciphertext: &[u8],
335) -> Result<Vec<u8>> {
336 match algorithm {
337 Algorithm::Aes256Gcm => {
338 let cipher = Aes256Gcm::new_from_slice(key)
339 .map_err(|_| anyhow!("invalid AES-256-GCM key size"))?;
340 cipher
341 .decrypt(AesNonce::from_slice(nonce), ciphertext)
342 .map_err(|_| {
343 anyhow!("AES-256-GCM authentication failed (wrong password or tampered data)")
344 })
345 }
346 Algorithm::ChaCha20Poly1305 => {
347 let cipher = ChaCha20Poly1305::new_from_slice(key)
348 .map_err(|_| anyhow!("invalid ChaCha20-Poly1305 key size"))?;
349 cipher
350 .decrypt(ChaChaNonce::from_slice(nonce), ciphertext)
351 .map_err(|_| {
352 anyhow!(
353 "ChaCha20-Poly1305 authentication failed (wrong password or tampered data)"
354 )
355 })
356 }
357 }
358}
359
360pub mod utils {
361 use super::*;
362
363 pub fn random_bytes(len: usize) -> Result<Vec<u8>> {
364 if len == 0 {
365 return Ok(Vec::new());
366 }
367 let mut output = vec![0u8; len];
368 getrandom::getrandom(&mut output)
369 .map_err(|e| anyhow!("secure random generation failed: {e}"))?;
370 Ok(output)
371 }
372}
373
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 fn enabled_aes_config() -> EncryptionConfig {
380 let mut c = EncryptionConfig {
381 enabled: true,
382 algorithm: "aes-256-gcm".into(),
383 ..EncryptionConfig::default()
384 };
385 ensure_master_salt(&mut c).unwrap();
386 c
387 }
388
389 fn enabled_chacha_config() -> EncryptionConfig {
390 let mut c = EncryptionConfig {
391 enabled: true,
392 algorithm: "chacha20-poly1305".into(),
393 ..EncryptionConfig::default()
394 };
395 ensure_master_salt(&mut c).unwrap();
396 c
397 }
398
399 #[test]
400 fn defaults_are_safe() {
401 let c = EncryptionConfig::default();
402 assert!(!c.enabled);
403 assert_eq!(c.algorithm, "aes-256-gcm");
404 assert_eq!(c.kdf_iterations, MIN_KDF_ITERATIONS);
405 }
406
407 #[test]
408 fn ensure_master_salt_only_generates_once() {
409 let mut c = EncryptionConfig {
410 enabled: true,
411 algorithm: "aes-256-gcm".into(),
412 ..EncryptionConfig::default()
413 };
414 assert!(ensure_master_salt(&mut c).unwrap());
415 let salt = c.master_salt.clone();
416 assert!(!ensure_master_salt(&mut c).unwrap());
417 assert_eq!(c.master_salt, salt);
418 }
419
420 #[test]
421 fn config_below_min_iterations_rejected() {
422 let mut c = enabled_aes_config();
423 c.kdf_iterations = 100_000;
424 let err = validate_config(&c).unwrap_err();
425 assert!(err.to_string().contains("kdf_iterations"));
426 }
427
428 #[test]
429 fn config_without_master_salt_rejected() {
430 let c = EncryptionConfig {
431 enabled: true,
432 algorithm: "aes-256-gcm".into(),
433 ..EncryptionConfig::default()
434 };
435 let err = validate_config(&c).unwrap_err();
436 assert!(err.to_string().contains("master_salt"));
437 }
438
439 #[test]
440 fn aes_runtime_roundtrip() {
441 let runtime =
442 EncryptionRuntime::from_config(enabled_aes_config(), "strong-password".into())
443 .unwrap();
444 let plaintext = b"runtime payload";
445 let envelope = runtime.encrypt(plaintext).unwrap();
446 let decrypted = runtime.decrypt(&envelope).unwrap();
447 assert_eq!(plaintext, &decrypted[..]);
448 assert_eq!(runtime.algorithm(), "aes-256-gcm");
449 }
450
451 #[test]
452 fn chacha_runtime_roundtrip() {
453 let runtime =
454 EncryptionRuntime::from_config(enabled_chacha_config(), "strong-password".into())
455 .unwrap();
456 let plaintext = b"runtime payload";
457 let envelope = runtime.encrypt(plaintext).unwrap();
458 let decrypted = runtime.decrypt(&envelope).unwrap();
459 assert_eq!(plaintext, &decrypted[..]);
460 assert_eq!(runtime.algorithm(), "chacha20-poly1305");
461 }
462
463 #[test]
464 fn wrong_password_fails() {
465 let cfg = enabled_aes_config();
466 let runtime = EncryptionRuntime::from_config(cfg.clone(), "right".into()).unwrap();
467 let envelope = runtime.encrypt(b"x").unwrap();
468
469 let other = EncryptionRuntime::from_config(cfg, "wrong".into()).unwrap();
470 assert!(other.decrypt(&envelope).is_err());
471 }
472
473 #[test]
477 fn same_plaintext_different_ciphertext() {
478 let runtime =
479 EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
480 let a = runtime.encrypt(b"same").unwrap();
481 let b = runtime.encrypt(b"same").unwrap();
482 assert_ne!(a, b);
483 }
484
485 #[test]
489 fn per_op_cost_is_fast() {
490 let runtime =
491 EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
492 let start = std::time::Instant::now();
493 for i in 0..100 {
494 let payload = format!("payload-{i}");
495 let _ = runtime.encrypt(payload.as_bytes()).unwrap();
496 }
497 let elapsed = start.elapsed();
498 assert!(
499 elapsed < std::time::Duration::from_secs(1),
500 "100 encrypts took {:?}; per-op derivation is likely regressed",
501 elapsed
502 );
503 }
504
505 #[test]
506 fn older_envelope_versions_are_rejected() {
507 let runtime =
510 EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
511 let older = EncryptedData {
512 version: ENVELOPE_VERSION - 1,
513 algorithm: "aes-256-gcm".into(),
514 salt: vec![0u8; PER_BLOB_SALT_LEN],
515 nonce: vec![0u8; AEAD_NONCE_LEN],
516 ciphertext: vec![0xde, 0xad, 0xbe, 0xef],
517 };
518 let bytes = crate::canonical::to_cbor(&older).unwrap();
519 let err = runtime.decrypt(&bytes).unwrap_err();
520 assert!(err.to_string().contains("unsupported envelope version"));
521 }
522
523 #[test]
524 fn legacy_json_envelope_is_rejected() {
525 let json = br#"{"version":2,"algorithm":"aes-256-gcm","salt":"","nonce":"","ciphertext":""}"#;
527 let runtime =
528 EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
529 let err = runtime.decrypt(json).unwrap_err();
530 assert!(err.to_string().contains("invalid encryption envelope"));
531 }
532
533 #[test]
534 fn algorithm_mismatch_between_runtime_and_envelope_fails() {
535 let aes = EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
536 let envelope = aes.encrypt(b"x").unwrap();
537 let chacha =
538 EncryptionRuntime::from_config(enabled_chacha_config(), "pw".into()).unwrap();
539 let err = chacha.decrypt(&envelope).unwrap_err();
540 assert!(err.to_string().contains("algorithm mismatch"));
541 }
542
543 #[test]
544 fn empty_password_rejected() {
545 let err = EncryptionRuntime::from_config(enabled_aes_config(), "".into()).unwrap_err();
546 assert!(err.to_string().contains("password"));
547 }
548}