1pub trait EncryptionProvider:
33 Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe
34{
35 fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>>;
44
45 fn max_overhead(&self) -> u32;
52
53 fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>>;
60
61 fn encrypt_vec(&self, plaintext: Vec<u8>) -> crate::Result<Vec<u8>> {
71 self.encrypt(&plaintext)
72 }
73
74 fn decrypt_vec(&self, ciphertext: Vec<u8>) -> crate::Result<Vec<u8>> {
85 self.decrypt(&ciphertext)
86 }
87}
88
89#[cfg(feature = "encryption")]
109pub struct Aes256GcmProvider {
110 cipher: aes_gcm::Aes256Gcm,
111}
112
113#[cfg(feature = "encryption")]
114impl Aes256GcmProvider {
115 const NONCE_LEN: usize = 12;
117
118 const TAG_LEN: usize = 16;
120
121 pub const OVERHEAD: usize = Self::NONCE_LEN + Self::TAG_LEN;
123
124 #[must_use]
129 pub fn new(key: &[u8; 32]) -> Self {
130 use aes_gcm::KeyInit;
131
132 Self {
133 cipher: aes_gcm::Aes256Gcm::new(key.into()),
134 }
135 }
136
137 pub fn from_slice(key: &[u8]) -> crate::Result<Self> {
144 let key: &[u8; 32] = key
145 .try_into()
146 .map_err(|_| crate::Error::Encrypt("AES-256-GCM key must be exactly 32 bytes"))?;
147 Ok(Self::new(key))
148 }
149}
150
151#[cfg(feature = "encryption")]
158fn new_chacha_rng() -> rand_chacha::ChaCha20Rng {
159 use aes_gcm::aead::Generate;
160 use aes_gcm::aead::rand_core::SeedableRng;
161
162 let seed: [u8; 32] = <[u8; 32]>::generate();
166 rand_chacha::ChaCha20Rng::from_seed(seed)
167}
168
169#[cfg(feature = "encryption")]
175struct ForkAwareRng {
176 pid: std::cell::Cell<u32>,
177 rng: std::cell::RefCell<rand_chacha::ChaCha20Rng>,
178}
179
180#[cfg(feature = "encryption")]
181impl ForkAwareRng {
182 fn new() -> Self {
183 Self {
184 pid: std::cell::Cell::new(std::process::id()),
185 rng: std::cell::RefCell::new(new_chacha_rng()),
186 }
187 }
188
189 fn with_rng<R>(&self, f: impl FnOnce(&mut rand_chacha::ChaCha20Rng) -> R) -> R {
190 let mut rng_ref = self.rng.borrow_mut();
191 let current_pid = std::process::id();
192 if self.pid.get() != current_pid {
193 self.pid.set(current_pid);
195 *rng_ref = new_chacha_rng();
196 }
197
198 f(&mut rng_ref)
203 }
204}
205
206#[cfg(feature = "encryption")]
207thread_local! {
208 static THREAD_RNG: ForkAwareRng = ForkAwareRng::new();
211}
212
213#[cfg(feature = "encryption")]
222fn thread_local_rng<R>(f: impl FnOnce(&mut rand_chacha::ChaCha20Rng) -> R) -> R {
223 THREAD_RNG.with(|state| state.with_rng(f))
224}
225
226#[cfg(feature = "encryption")]
227impl EncryptionProvider for Aes256GcmProvider {
228 fn max_overhead(&self) -> u32 {
229 #[expect(clippy::cast_possible_truncation, reason = "OVERHEAD is 28")]
231 {
232 Self::OVERHEAD as u32
233 }
234 }
235
236 fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>> {
237 use aes_gcm::aead::{AeadInOut, Generate, Nonce};
240
241 let nonce = thread_local_rng(Nonce::<aes_gcm::Aes256Gcm>::generate_from_rng);
242
243 let mut buf = Vec::with_capacity(Self::NONCE_LEN + plaintext.len() + Self::TAG_LEN);
244 buf.extend_from_slice(&nonce);
245 buf.extend_from_slice(plaintext);
246
247 #[expect(
253 clippy::indexing_slicing,
254 reason = "buf length = NONCE_LEN + plaintext.len()"
255 )]
256 let tag = self
257 .cipher
258 .encrypt_inout_detached(&nonce, b"", (&mut buf[Self::NONCE_LEN..]).into())
259 .map_err(|_| crate::Error::Encrypt("AES-256-GCM encryption failed"))?;
260
261 buf.extend_from_slice(&tag);
262
263 Ok(buf)
264 }
265
266 fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>> {
267 use aes_gcm::aead::{AeadInOut, Nonce, Tag};
268
269 let min_len = Self::NONCE_LEN + Self::TAG_LEN;
270 if ciphertext.len() < min_len {
271 return Err(crate::Error::Decrypt(
272 "ciphertext too short for AES-256-GCM (need nonce + tag)",
273 ));
274 }
275
276 #[expect(clippy::indexing_slicing, reason = "length checked above")]
277 let nonce = Nonce::<aes_gcm::Aes256Gcm>::try_from(&ciphertext[..Self::NONCE_LEN])
278 .map_err(|_| crate::Error::Decrypt("AES-256-GCM nonce length mismatch"))?;
279
280 let tag_start = ciphertext.len() - Self::TAG_LEN;
282
283 #[expect(clippy::indexing_slicing, reason = "length checked above")]
284 let tag = Tag::<aes_gcm::Aes256Gcm>::try_from(&ciphertext[tag_start..])
285 .map_err(|_| crate::Error::Decrypt("AES-256-GCM tag length mismatch"))?;
286
287 #[expect(clippy::indexing_slicing, reason = "length checked above")]
288 let mut buf = ciphertext[Self::NONCE_LEN..tag_start].to_vec();
289
290 self.cipher
291 .decrypt_inout_detached(&nonce, b"", (&mut buf[..]).into(), &tag)
292 .map_err(|_| {
293 crate::Error::Decrypt("AES-256-GCM decryption failed (bad key or tampered data)")
294 })?;
295
296 Ok(buf)
297 }
298
299 fn encrypt_vec(&self, mut buf: Vec<u8>) -> crate::Result<Vec<u8>> {
300 use aes_gcm::aead::{AeadInOut, Generate, Nonce};
301
302 let nonce = thread_local_rng(Nonce::<aes_gcm::Aes256Gcm>::generate_from_rng);
303
304 let plaintext_len = buf.len();
307 buf.reserve(Self::NONCE_LEN + Self::TAG_LEN);
308 buf.resize(plaintext_len + Self::NONCE_LEN, 0);
309 buf.copy_within(..plaintext_len, Self::NONCE_LEN);
310 #[expect(
311 clippy::indexing_slicing,
312 reason = "buf was just resized to include NONCE_LEN"
313 )]
314 buf[..Self::NONCE_LEN].copy_from_slice(&nonce);
315
316 #[expect(
317 clippy::indexing_slicing,
318 reason = "buf length ≥ NONCE_LEN after resize + copy_within"
319 )]
320 let tag = self
321 .cipher
322 .encrypt_inout_detached(&nonce, b"", (&mut buf[Self::NONCE_LEN..]).into())
323 .map_err(|_| crate::Error::Encrypt("AES-256-GCM encryption failed"))?;
324
325 buf.extend_from_slice(&tag);
326
327 Ok(buf)
328 }
329
330 fn decrypt_vec(&self, mut buf: Vec<u8>) -> crate::Result<Vec<u8>> {
331 use aes_gcm::aead::{AeadInOut, Nonce, Tag};
332
333 let min_len = Self::NONCE_LEN + Self::TAG_LEN;
336 if buf.len() < min_len {
337 return Err(crate::Error::Decrypt(
338 "ciphertext too short for AES-256-GCM (need nonce + tag)",
339 ));
340 }
341
342 #[expect(clippy::indexing_slicing, reason = "length checked above")]
344 let nonce = Nonce::<aes_gcm::Aes256Gcm>::try_from(&buf[..Self::NONCE_LEN])
345 .map_err(|_| crate::Error::Decrypt("AES-256-GCM nonce length mismatch"))?;
346
347 let tag_start = buf.len() - Self::TAG_LEN;
348 #[expect(clippy::indexing_slicing, reason = "length checked above")]
349 let tag = Tag::<aes_gcm::Aes256Gcm>::try_from(&buf[tag_start..])
350 .map_err(|_| crate::Error::Decrypt("AES-256-GCM tag length mismatch"))?;
351
352 buf.copy_within(Self::NONCE_LEN..tag_start, 0);
355 buf.truncate(tag_start - Self::NONCE_LEN);
356
357 self.cipher
358 .decrypt_inout_detached(&nonce, b"", (&mut buf[..]).into(), &tag)
359 .map_err(|_| {
360 crate::Error::Decrypt("AES-256-GCM decryption failed (bad key or tampered data)")
361 })?;
362
363 Ok(buf)
364 }
365}
366
367#[cfg(test)]
368#[allow(
369 clippy::doc_markdown,
370 clippy::redundant_clone,
371 clippy::unnecessary_wraps,
372 clippy::redundant_closure_for_method_calls
373)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn encryption_provider_trait_is_object_safe() {
379 fn _assert_object_safe(_: &dyn EncryptionProvider) {}
381 }
382
383 struct XorProvider;
386
387 impl std::panic::UnwindSafe for XorProvider {}
388 impl std::panic::RefUnwindSafe for XorProvider {}
389
390 impl EncryptionProvider for XorProvider {
391 fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>> {
392 Ok(plaintext.iter().map(|b| b ^ 0xAA).collect())
393 }
394
395 fn max_overhead(&self) -> u32 {
396 0
397 }
398
399 fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>> {
400 Ok(ciphertext.iter().map(|b| b ^ 0xAA).collect())
401 }
402 }
403
404 #[test]
405 fn default_encrypt_vec_delegates_to_encrypt() -> crate::Result<()> {
406 let provider = XorProvider;
407 let plaintext = b"test default encrypt_vec";
408
409 let via_encrypt = provider.encrypt(plaintext)?;
410 let via_encrypt_vec = provider.encrypt_vec(plaintext.to_vec())?;
411 assert_eq!(via_encrypt, via_encrypt_vec);
412
413 let decrypted = provider.decrypt(&via_encrypt_vec)?;
414 assert_eq!(decrypted, plaintext);
415 Ok(())
416 }
417
418 #[test]
419 fn default_decrypt_vec_delegates_to_decrypt() -> crate::Result<()> {
420 let provider = XorProvider;
421 let plaintext = b"test default decrypt_vec";
422
423 let ciphertext = provider.encrypt(plaintext)?;
424
425 let via_decrypt = provider.decrypt(&ciphertext)?;
426 let via_decrypt_vec = provider.decrypt_vec(ciphertext)?;
427 assert_eq!(via_decrypt, via_decrypt_vec);
428 assert_eq!(via_decrypt_vec, plaintext);
429 Ok(())
430 }
431
432 #[cfg(feature = "encryption")]
433 mod aes256gcm {
434 use super::*;
435
436 fn test_key() -> [u8; 32] {
437 [0x42; 32]
438 }
439
440 #[test]
441 fn roundtrip_basic() -> crate::Result<()> {
442 let provider = Aes256GcmProvider::new(&test_key());
443 let plaintext = b"hello world, this is a block of data!";
444
445 let ciphertext = provider.encrypt(plaintext)?;
446 assert_ne!(&ciphertext[..], plaintext.as_slice());
447 assert_eq!(
448 ciphertext.len(),
449 Aes256GcmProvider::NONCE_LEN + plaintext.len() + Aes256GcmProvider::TAG_LEN,
450 );
451
452 let decrypted = provider.decrypt(&ciphertext)?;
453 assert_eq!(decrypted, plaintext);
454 Ok(())
455 }
456
457 #[test]
458 fn roundtrip_empty() -> crate::Result<()> {
459 let provider = Aes256GcmProvider::new(&test_key());
460 let plaintext = b"";
461
462 let ciphertext = provider.encrypt(plaintext)?;
463 let decrypted = provider.decrypt(&ciphertext)?;
464 assert_eq!(decrypted, plaintext);
465 Ok(())
466 }
467
468 #[test]
469 fn different_nonces_produce_different_ciphertexts() -> crate::Result<()> {
470 let provider = Aes256GcmProvider::new(&test_key());
471 let plaintext = b"deterministic input";
472
473 let ct1 = provider.encrypt(plaintext)?;
474 let ct2 = provider.encrypt(plaintext)?;
475 assert_ne!(
476 ct1, ct2,
477 "random nonces should produce different ciphertexts"
478 );
479
480 assert_eq!(provider.decrypt(&ct1)?, provider.decrypt(&ct2)?,);
482 Ok(())
483 }
484
485 #[test]
486 fn wrong_key_fails_decrypt() -> crate::Result<()> {
487 let provider1 = Aes256GcmProvider::new(&[0x01; 32]);
488 let provider2 = Aes256GcmProvider::new(&[0x02; 32]);
489
490 let ciphertext = provider1.encrypt(b"secret")?;
491 let result = provider2.decrypt(&ciphertext);
492 assert!(result.is_err());
493 Ok(())
494 }
495
496 #[test]
497 fn tampered_ciphertext_fails_decrypt() -> crate::Result<()> {
498 let provider = Aes256GcmProvider::new(&test_key());
499 let mut ciphertext = provider.encrypt(b"data")?;
500
501 let mid = Aes256GcmProvider::NONCE_LEN + 1;
503 if mid < ciphertext.len() {
504 #[expect(clippy::indexing_slicing)]
505 {
506 ciphertext[mid] ^= 0xFF;
507 }
508 }
509
510 let result = provider.decrypt(&ciphertext);
511 assert!(result.is_err());
512 Ok(())
513 }
514
515 #[test]
516 fn truncated_ciphertext_fails_decrypt() -> crate::Result<()> {
517 let provider = Aes256GcmProvider::new(&test_key());
518 let result = provider.decrypt(&[0u8; 10]); assert!(result.is_err());
520 Ok(())
521 }
522
523 #[test]
524 fn from_slice_rejects_wrong_length() {
525 assert!(Aes256GcmProvider::from_slice(&[0u8; 16]).is_err());
526 assert!(Aes256GcmProvider::from_slice(&[0u8; 31]).is_err());
527 assert!(Aes256GcmProvider::from_slice(&[0u8; 33]).is_err());
528 assert!(Aes256GcmProvider::from_slice(&[0u8; 32]).is_ok());
529 }
530
531 #[test]
532 fn roundtrip_large_payload() -> crate::Result<()> {
533 let provider = Aes256GcmProvider::new(&test_key());
534 let plaintext = vec![0xAB_u8; 64 * 1024]; let ciphertext = provider.encrypt(&plaintext)?;
537 let decrypted = provider.decrypt(&ciphertext)?;
538 assert_eq!(decrypted, plaintext);
539 Ok(())
540 }
541
542 #[test]
545 fn thread_local_rng_produces_unique_nonces() -> crate::Result<()> {
546 let provider = Aes256GcmProvider::new(&test_key());
547 let plaintext = b"nonce uniqueness test";
548
549 let mut nonces = std::collections::HashSet::new();
550 for _ in 0..1000 {
551 let ct = provider.encrypt(plaintext)?;
552
553 #[expect(clippy::indexing_slicing, reason = "ct always >= NONCE_LEN")]
554 #[expect(clippy::expect_used, reason = "test assertion")]
555 let nonce: [u8; Aes256GcmProvider::NONCE_LEN] = ct[..Aes256GcmProvider::NONCE_LEN]
556 .try_into()
557 .expect("nonce has expected length");
558
559 assert!(
560 nonces.insert(nonce),
561 "nonce collision detected — CSPRNG produced duplicate nonce"
562 );
563 }
564 Ok(())
565 }
566
567 const SENTINEL_WORD_POS: u128 = 0xDEAD_BEEF_u128;
578
579 #[test]
580 fn fork_aware_rng_reseeds_on_pid_change() {
581 let rng = ForkAwareRng::new();
582
583 let _ = rng.with_rng(aes_gcm::aead::rand_core::Rng::next_u64);
585 rng.rng.borrow_mut().set_word_pos(SENTINEL_WORD_POS);
586 assert_eq!(rng.rng.borrow().get_word_pos(), SENTINEL_WORD_POS);
587
588 let real_pid = std::process::id();
590 rng.pid.set(real_pid ^ 1);
591
592 let _ = rng.with_rng(aes_gcm::aead::rand_core::Rng::next_u64);
597
598 assert_eq!(
599 rng.pid.get(),
600 real_pid,
601 "PID should be restored to real process ID after reseed"
602 );
603
604 let post_word_pos = rng.rng.borrow().get_word_pos();
605 assert!(
606 post_word_pos < SENTINEL_WORD_POS,
607 "inner RNG was not replaced on reseed: post word_pos {post_word_pos:#x} \
608 should be a fresh-RNG value, not {SENTINEL_WORD_POS:#x}+ \
609 (would indicate fork-safety reseed is broken)"
610 );
611 }
612
613 #[test]
614 fn encrypt_vec_roundtrip() -> crate::Result<()> {
615 let provider = Aes256GcmProvider::new(&test_key());
616 let plaintext = b"block data for encrypt_vec test";
617
618 let ciphertext = provider.encrypt_vec(plaintext.to_vec())?;
619 assert_eq!(
620 ciphertext.len(),
621 Aes256GcmProvider::NONCE_LEN + plaintext.len() + Aes256GcmProvider::TAG_LEN,
622 );
623
624 let decrypted = provider.decrypt(&ciphertext)?;
626 assert_eq!(decrypted, plaintext);
627 Ok(())
628 }
629
630 #[test]
631 fn decrypt_vec_roundtrip() -> crate::Result<()> {
632 let provider = Aes256GcmProvider::new(&test_key());
633 let plaintext = b"block data for decrypt_vec test";
634
635 let ciphertext = provider.encrypt(plaintext)?;
637 let decrypted = provider.decrypt_vec(ciphertext)?;
638 assert_eq!(decrypted, plaintext);
639 Ok(())
640 }
641
642 #[test]
643 fn encrypt_vec_decrypt_vec_roundtrip() -> crate::Result<()> {
644 let provider = Aes256GcmProvider::new(&test_key());
645 let plaintext = vec![0xCD_u8; 16 * 1024]; let ciphertext = provider.encrypt_vec(plaintext.clone())?;
648 let decrypted = provider.decrypt_vec(ciphertext)?;
649 assert_eq!(decrypted, plaintext);
650 Ok(())
651 }
652
653 #[test]
654 fn encrypt_vec_empty() -> crate::Result<()> {
655 let provider = Aes256GcmProvider::new(&test_key());
656
657 let ciphertext = provider.encrypt_vec(vec![])?;
658 let decrypted = provider.decrypt_vec(ciphertext)?;
659 assert!(decrypted.is_empty());
660 Ok(())
661 }
662
663 #[test]
664 fn decrypt_vec_truncated_fails() -> crate::Result<()> {
665 let provider = Aes256GcmProvider::new(&test_key());
666 let result = provider.decrypt_vec(vec![0u8; 10]);
667 assert!(result.is_err());
668 Ok(())
669 }
670
671 #[test]
672 fn decrypt_vec_tampered_fails() -> crate::Result<()> {
673 let provider = Aes256GcmProvider::new(&test_key());
674 let mut ciphertext = provider.encrypt_vec(b"data".to_vec())?;
675
676 let mid = Aes256GcmProvider::NONCE_LEN + 1;
677 if mid < ciphertext.len() {
678 #[expect(clippy::indexing_slicing)]
679 {
680 ciphertext[mid] ^= 0xFF;
681 }
682 }
683
684 let result = provider.decrypt_vec(ciphertext);
685 assert!(result.is_err());
686 Ok(())
687 }
688 }
689}