1use arkhe_forge_core::pii::{
31 compute_aad, AeadKind, DekId, DekMessageCounter, PiiError, PiiType, RotationTrigger,
32};
33use bytes::Bytes;
34use serde::{Deserialize, Serialize};
35use std::cell::Cell;
36use std::marker::PhantomData;
37use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub struct DekConfig {
48 pub replica_id: u32,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum DekLifecycle {
68 Ephemeral,
70 LongLived,
73}
74
75#[derive(Zeroize, ZeroizeOnDrop)]
110pub struct Dek {
111 material: [u8; 32],
112 #[zeroize(skip)]
115 counter: Cell<u64>,
116 #[zeroize(skip)]
119 replica_id: u32,
120 #[zeroize(skip)]
123 lifecycle: DekLifecycle,
124}
125
126impl Dek {
127 #[inline]
132 #[must_use]
133 pub fn from_bytes(material: [u8; 32]) -> Self {
134 Self::with_config(material, DekConfig::default())
135 }
136
137 #[inline]
142 #[must_use]
143 pub fn with_config(material: [u8; 32], config: DekConfig) -> Self {
144 Self {
145 material,
146 counter: Cell::new(0),
147 replica_id: config.replica_id,
148 lifecycle: DekLifecycle::Ephemeral,
149 }
150 }
151
152 #[inline]
158 #[must_use]
159 pub fn from_unwrapped(material: [u8; 32], config: DekConfig) -> Self {
160 Self {
161 material,
162 counter: Cell::new(0),
163 replica_id: config.replica_id,
164 lifecycle: DekLifecycle::LongLived,
165 }
166 }
167
168 #[inline]
170 #[must_use]
171 #[cfg_attr(not(feature = "tier-2-multi-kms"), allow(dead_code))]
172 pub fn lifecycle(&self) -> DekLifecycle {
173 self.lifecycle
174 }
175
176 pub fn try_from_slice(bytes: &[u8]) -> Result<Self, PiiError> {
187 if bytes.len() != 32 {
188 return Err(PiiError::InvalidKeyLength);
189 }
190 let mut material = [0u8; 32];
191 material.copy_from_slice(bytes);
192 Ok(Self {
193 material,
194 counter: Cell::new(0),
195 replica_id: 0,
196 lifecycle: DekLifecycle::Ephemeral,
197 })
198 }
199
200 #[inline]
204 #[must_use]
205 #[cfg_attr(
206 not(any(feature = "tier-1-kms", feature = "tier-2-multi-kms")),
207 allow(dead_code)
208 )]
209 pub(crate) fn as_bytes(&self) -> &[u8; 32] {
210 &self.material
211 }
212
213 #[cfg_attr(not(feature = "tier-2-multi-kms"), allow(dead_code))]
219 fn advance_counter(&self) -> Result<u64, PiiError> {
220 let n = self.counter.get();
221 if n == u64::MAX {
222 return Err(PiiError::DekExhausted);
223 }
224 self.counter.set(n.wrapping_add(1));
225 Ok(n)
226 }
227
228 #[cfg(all(test, feature = "tier-2-multi-kms"))]
231 pub(crate) fn set_counter_for_test(&self, n: u64) {
232 self.counter.set(n);
233 }
234
235 #[cfg(all(test, feature = "tier-2-multi-kms"))]
237 pub(crate) fn get_counter_for_test(&self) -> u64 {
238 self.counter.get()
239 }
240}
241
242#[cfg_attr(not(feature = "tier-2-multi-kms"), allow(dead_code))]
255#[inline]
256fn aes_gcm_nonce_from_counter(replica_id: u32, counter: u64) -> [u8; 12] {
257 let mut n = [0u8; 12];
258 n[0..4].copy_from_slice(&replica_id.to_be_bytes());
259 n[4..12].copy_from_slice(&counter.to_be_bytes());
260 n
261}
262
263impl core::fmt::Debug for Dek {
264 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
265 f.debug_struct("Dek").finish_non_exhaustive()
267 }
268}
269
270#[non_exhaustive]
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub enum NonceBytes {
279 X24([u8; 24]),
281 Short12([u8; 12]),
283}
284
285impl NonceBytes {
286 #[inline]
290 #[must_use]
291 pub fn expected_len(kind: AeadKind) -> usize {
292 match kind {
293 AeadKind::XChaCha20Poly1305 => 24,
294 AeadKind::Aes256Gcm | AeadKind::Aes256GcmSiv => 12,
295 _ => 0,
296 }
297 }
298
299 #[inline]
301 #[must_use]
302 pub fn as_slice(&self) -> &[u8] {
303 match self {
304 Self::X24(b) => b,
305 Self::Short12(b) => b,
306 }
307 }
308}
309
310#[derive(Debug, PartialEq, Eq)]
323pub struct EncryptedPii<T: PiiType> {
324 pub dek_id: DekId,
326 pub pii_code: u16,
329 pub aead_kind: AeadKind,
331 pub nonce: NonceBytes,
333 pub ciphertext: Bytes,
335 pub(crate) _marker: PhantomData<fn() -> T>,
336}
337
338impl<T: PiiType> Clone for EncryptedPii<T> {
341 fn clone(&self) -> Self {
342 Self {
343 dek_id: self.dek_id,
344 pii_code: self.pii_code,
345 aead_kind: self.aead_kind,
346 nonce: self.nonce.clone(),
347 ciphertext: self.ciphertext.clone(),
348 _marker: PhantomData,
349 }
350 }
351}
352
353#[derive(Serialize, Deserialize)]
356struct EncryptedPiiWire {
357 dek_id: DekId,
358 pii_code: u16,
359 aead_kind: AeadKind,
360 nonce: NonceBytes,
361 ciphertext: Bytes,
362}
363
364impl<T: PiiType> Serialize for EncryptedPii<T> {
365 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
366 EncryptedPiiWire {
367 dek_id: self.dek_id,
368 pii_code: self.pii_code,
369 aead_kind: self.aead_kind,
370 nonce: self.nonce.clone(),
371 ciphertext: self.ciphertext.clone(),
372 }
373 .serialize(serializer)
374 }
375}
376
377impl<'de, T: PiiType> Deserialize<'de> for EncryptedPii<T> {
378 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
379 let wire = EncryptedPiiWire::deserialize(deserializer)?;
380 Ok(Self {
381 dek_id: wire.dek_id,
382 pii_code: wire.pii_code,
383 aead_kind: wire.aead_kind,
384 nonce: wire.nonce,
385 ciphertext: wire.ciphertext,
386 _marker: PhantomData,
387 })
388 }
389}
390
391impl<T: PiiType> EncryptedPii<T> {
392 #[inline]
397 #[must_use]
398 pub fn new(dek_id: DekId, aead_kind: AeadKind, nonce: NonceBytes, ciphertext: Bytes) -> Self {
399 Self {
400 dek_id,
401 pii_code: T::PII_CODE,
402 aead_kind,
403 nonce,
404 ciphertext,
405 _marker: PhantomData,
406 }
407 }
408}
409
410#[derive(Debug)]
421pub struct CryptoCoordinator<N: NonceSource = OsNonceSource> {
422 manifest_cipher: AeadKind,
423 #[cfg_attr(not(feature = "tier-1-kms"), allow(dead_code))]
427 nonce_source: N,
428}
429
430pub trait NonceSource {
434 fn fill(&self, out: &mut [u8]);
439}
440
441#[derive(Debug, Default, Clone, Copy)]
447pub struct OsNonceSource;
448
449impl NonceSource for OsNonceSource {
450 #[cfg(feature = "tier-1-kms")]
451 fn fill(&self, out: &mut [u8]) {
452 use chacha20poly1305::aead::{rand_core::RngCore, OsRng};
453 OsRng.fill_bytes(out);
454 }
455
456 #[cfg(not(feature = "tier-1-kms"))]
457 fn fill(&self, out: &mut [u8]) {
458 for byte in out.iter_mut() {
461 *byte = 0;
462 }
463 }
464}
465
466impl<N: NonceSource> CryptoCoordinator<N> {
467 #[inline]
470 #[must_use]
471 pub fn new(manifest_cipher: AeadKind, nonce_source: N) -> Self {
472 Self {
473 manifest_cipher,
474 nonce_source,
475 }
476 }
477
478 #[inline]
480 #[must_use]
481 pub fn manifest_cipher(&self) -> AeadKind {
482 self.manifest_cipher
483 }
484
485 pub fn encrypt<T: PiiType>(
490 &self,
491 plaintext: &T,
492 dek: &Dek,
493 dek_id: DekId,
494 ) -> Result<EncryptedPii<T>, PiiError> {
495 let aad = compute_aad(&dek_id, T::PII_CODE, self.manifest_cipher);
496 let pt_bytes =
499 Zeroizing::new(postcard::to_stdvec(plaintext).map_err(|_| PiiError::EncryptFailed)?);
500 let (nonce, ciphertext) = self.encrypt_raw(dek, &aad, &pt_bytes)?;
501 Ok(EncryptedPii::new(
502 dek_id,
503 self.manifest_cipher,
504 nonce,
505 Bytes::from(ciphertext),
506 ))
507 }
508
509 pub fn decrypt<T: PiiType>(
513 &self,
514 envelope: &EncryptedPii<T>,
515 dek: &Dek,
516 ) -> Result<T, PiiError> {
517 if envelope.pii_code != T::PII_CODE {
518 return Err(PiiError::TypeMismatch);
519 }
520 if envelope.aead_kind != self.manifest_cipher {
521 return Err(PiiError::CipherDowngrade);
522 }
523 let aad = compute_aad(&envelope.dek_id, envelope.pii_code, envelope.aead_kind);
524 let pt = self.decrypt_raw(
525 dek,
526 envelope.aead_kind,
527 &envelope.nonce,
528 &aad,
529 &envelope.ciphertext,
530 )?;
531 postcard::from_bytes::<T>(&pt).map_err(|_| PiiError::DecodeFailed)
532 }
533
534 fn decrypt_raw_under<T: PiiType>(
541 &self,
542 dek: &Dek,
543 envelope: &EncryptedPii<T>,
544 ) -> Result<Zeroizing<Vec<u8>>, PiiError> {
545 let aad = compute_aad(&envelope.dek_id, envelope.pii_code, envelope.aead_kind);
546 self.decrypt_raw(
547 dek,
548 envelope.aead_kind,
549 &envelope.nonce,
550 &aad,
551 &envelope.ciphertext,
552 )
553 }
554
555 fn encrypt_raw(
556 &self,
557 dek: &Dek,
558 aad: &[u8; 19],
559 plaintext: &[u8],
560 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
561 match self.manifest_cipher {
562 AeadKind::XChaCha20Poly1305 => self.encrypt_xchacha(dek, aad, plaintext),
563 AeadKind::Aes256Gcm => self.encrypt_aes_gcm(dek, aad, plaintext),
564 AeadKind::Aes256GcmSiv => self.encrypt_aes_gcm_siv(dek, aad, plaintext),
565 _ => Err(PiiError::UnsupportedAead),
566 }
567 }
568
569 fn decrypt_raw(
572 &self,
573 dek: &Dek,
574 kind: AeadKind,
575 nonce: &NonceBytes,
576 aad: &[u8; 19],
577 ciphertext: &[u8],
578 ) -> Result<Zeroizing<Vec<u8>>, PiiError> {
579 match kind {
580 AeadKind::XChaCha20Poly1305 => self.decrypt_xchacha(dek, nonce, aad, ciphertext),
581 AeadKind::Aes256Gcm => self.decrypt_aes_gcm(dek, nonce, aad, ciphertext),
582 AeadKind::Aes256GcmSiv => self.decrypt_aes_gcm_siv(dek, nonce, aad, ciphertext),
583 _ => Err(PiiError::UnsupportedAead),
584 }
585 .map(Zeroizing::new)
586 }
587
588 #[cfg(feature = "tier-1-kms")]
591 fn encrypt_xchacha(
592 &self,
593 dek: &Dek,
594 aad: &[u8; 19],
595 plaintext: &[u8],
596 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
597 use chacha20poly1305::aead::{Aead, KeyInit, Payload};
598 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
599
600 let key = Key::from_slice(dek.as_bytes());
601 let cipher = XChaCha20Poly1305::new(key);
602 let mut nonce_buf = [0u8; 24];
603 self.nonce_source.fill(&mut nonce_buf);
604 let nonce = XNonce::from_slice(&nonce_buf);
605 let ciphertext = cipher
606 .encrypt(
607 nonce,
608 Payload {
609 msg: plaintext,
610 aad,
611 },
612 )
613 .map_err(|_| PiiError::EncryptFailed)?;
614 Ok((NonceBytes::X24(nonce_buf), ciphertext))
615 }
616
617 #[cfg(not(feature = "tier-1-kms"))]
618 fn encrypt_xchacha(
619 &self,
620 _dek: &Dek,
621 _aad: &[u8; 19],
622 _plaintext: &[u8],
623 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
624 Err(PiiError::TierTooLow)
625 }
626
627 #[cfg(feature = "tier-1-kms")]
628 fn decrypt_xchacha(
629 &self,
630 dek: &Dek,
631 nonce: &NonceBytes,
632 aad: &[u8; 19],
633 ciphertext: &[u8],
634 ) -> Result<Vec<u8>, PiiError> {
635 use chacha20poly1305::aead::{Aead, KeyInit, Payload};
636 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
637
638 let bytes_24 = match nonce {
639 NonceBytes::X24(b) => b,
640 NonceBytes::Short12(_) => return Err(PiiError::AadMismatch),
641 };
642 let key = Key::from_slice(dek.as_bytes());
643 let cipher = XChaCha20Poly1305::new(key);
644 let nonce = XNonce::from_slice(bytes_24);
645 cipher
646 .decrypt(
647 nonce,
648 Payload {
649 msg: ciphertext,
650 aad,
651 },
652 )
653 .map_err(|_| PiiError::AadMismatch)
654 }
655
656 #[cfg(not(feature = "tier-1-kms"))]
657 fn decrypt_xchacha(
658 &self,
659 _dek: &Dek,
660 _nonce: &NonceBytes,
661 _aad: &[u8; 19],
662 _ciphertext: &[u8],
663 ) -> Result<Vec<u8>, PiiError> {
664 Err(PiiError::TierTooLow)
665 }
666
667 #[cfg(feature = "tier-2-multi-kms")]
670 fn encrypt_aes_gcm(
671 &self,
672 dek: &Dek,
673 aad: &[u8; 19],
674 plaintext: &[u8],
675 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
676 use aes_gcm::aead::{Aead, KeyInit, Payload};
677 use aes_gcm::{Aes256Gcm, Key, Nonce};
678
679 if dek.lifecycle() == DekLifecycle::LongLived {
684 return Err(PiiError::NonceReuseRisk);
685 }
686
687 let counter = dek.advance_counter()?;
691 let nonce_buf = aes_gcm_nonce_from_counter(dek.replica_id, counter);
692 let key = Key::<Aes256Gcm>::from_slice(dek.as_bytes());
693 let cipher = Aes256Gcm::new(key);
694 let nonce = Nonce::from_slice(&nonce_buf);
695 let ciphertext = cipher
696 .encrypt(
697 nonce,
698 Payload {
699 msg: plaintext,
700 aad,
701 },
702 )
703 .map_err(|_| PiiError::EncryptFailed)?;
704 Ok((NonceBytes::Short12(nonce_buf), ciphertext))
705 }
706
707 #[cfg(not(feature = "tier-2-multi-kms"))]
708 fn encrypt_aes_gcm(
709 &self,
710 _dek: &Dek,
711 _aad: &[u8; 19],
712 _plaintext: &[u8],
713 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
714 Err(PiiError::UnsupportedAead)
715 }
716
717 #[cfg(feature = "tier-2-multi-kms")]
718 fn decrypt_aes_gcm(
719 &self,
720 dek: &Dek,
721 nonce: &NonceBytes,
722 aad: &[u8; 19],
723 ciphertext: &[u8],
724 ) -> Result<Vec<u8>, PiiError> {
725 use aes_gcm::aead::{Aead, KeyInit, Payload};
726 use aes_gcm::{Aes256Gcm, Key, Nonce};
727
728 let bytes_12 = match nonce {
729 NonceBytes::Short12(b) => b,
730 NonceBytes::X24(_) => return Err(PiiError::AadMismatch),
731 };
732 let key = Key::<Aes256Gcm>::from_slice(dek.as_bytes());
733 let cipher = Aes256Gcm::new(key);
734 let nonce = Nonce::from_slice(bytes_12);
735 cipher
736 .decrypt(
737 nonce,
738 Payload {
739 msg: ciphertext,
740 aad,
741 },
742 )
743 .map_err(|_| PiiError::AadMismatch)
744 }
745
746 #[cfg(not(feature = "tier-2-multi-kms"))]
747 fn decrypt_aes_gcm(
748 &self,
749 _dek: &Dek,
750 _nonce: &NonceBytes,
751 _aad: &[u8; 19],
752 _ciphertext: &[u8],
753 ) -> Result<Vec<u8>, PiiError> {
754 Err(PiiError::UnsupportedAead)
755 }
756
757 #[cfg(feature = "tier-2-multi-kms")]
760 fn encrypt_aes_gcm_siv(
761 &self,
762 dek: &Dek,
763 aad: &[u8; 19],
764 plaintext: &[u8],
765 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
766 use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
767 use aes_gcm_siv::{Aes256GcmSiv, Key, Nonce};
768
769 let counter = dek.advance_counter()?;
773 let nonce_buf = aes_gcm_nonce_from_counter(dek.replica_id, counter);
774 let key = Key::<Aes256GcmSiv>::from_slice(dek.as_bytes());
775 let cipher = Aes256GcmSiv::new(key);
776 let nonce = Nonce::from_slice(&nonce_buf);
777 let ciphertext = cipher
778 .encrypt(
779 nonce,
780 Payload {
781 msg: plaintext,
782 aad,
783 },
784 )
785 .map_err(|_| PiiError::EncryptFailed)?;
786 Ok((NonceBytes::Short12(nonce_buf), ciphertext))
787 }
788
789 #[cfg(not(feature = "tier-2-multi-kms"))]
790 fn encrypt_aes_gcm_siv(
791 &self,
792 _dek: &Dek,
793 _aad: &[u8; 19],
794 _plaintext: &[u8],
795 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
796 Err(PiiError::UnsupportedAead)
797 }
798
799 #[cfg(feature = "tier-2-multi-kms")]
800 fn decrypt_aes_gcm_siv(
801 &self,
802 dek: &Dek,
803 nonce: &NonceBytes,
804 aad: &[u8; 19],
805 ciphertext: &[u8],
806 ) -> Result<Vec<u8>, PiiError> {
807 use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
808 use aes_gcm_siv::{Aes256GcmSiv, Key, Nonce};
809
810 let bytes_12 = match nonce {
811 NonceBytes::Short12(b) => b,
812 NonceBytes::X24(_) => return Err(PiiError::AadMismatch),
813 };
814 let key = Key::<Aes256GcmSiv>::from_slice(dek.as_bytes());
815 let cipher = Aes256GcmSiv::new(key);
816 let nonce = Nonce::from_slice(bytes_12);
817 cipher
818 .decrypt(
819 nonce,
820 Payload {
821 msg: ciphertext,
822 aad,
823 },
824 )
825 .map_err(|_| PiiError::AadMismatch)
826 }
827
828 #[cfg(not(feature = "tier-2-multi-kms"))]
829 fn decrypt_aes_gcm_siv(
830 &self,
831 _dek: &Dek,
832 _nonce: &NonceBytes,
833 _aad: &[u8; 19],
834 _ciphertext: &[u8],
835 ) -> Result<Vec<u8>, PiiError> {
836 Err(PiiError::UnsupportedAead)
837 }
838}
839
840pub fn rotate_dek<T: PiiType>(
854 coordinator: &CryptoCoordinator<impl NonceSource>,
855 old_dek: &Dek,
856 new_dek: &Dek,
857 new_dek_id: DekId,
858 ciphertexts: &mut [EncryptedPii<T>],
859 counter: &mut DekMessageCounter,
860) -> Result<(), PiiError> {
861 let originals: Vec<EncryptedPii<T>> = ciphertexts.to_vec();
862 let counter_snapshot = counter.clone();
866 let aad = compute_aad(&new_dek_id, T::PII_CODE, coordinator.manifest_cipher);
867 for idx in 0..ciphertexts.len() {
870 let step = coordinator
871 .decrypt_raw_under(old_dek, &ciphertexts[idx])
872 .and_then(|plaintext_bytes| coordinator.encrypt_raw(new_dek, &aad, &plaintext_bytes));
873 match step {
874 Ok((nonce, new_ct)) => {
875 ciphertexts[idx] = EncryptedPii::new(
876 new_dek_id,
877 coordinator.manifest_cipher,
878 nonce,
879 Bytes::from(new_ct),
880 );
881 counter.record_message();
882 }
883 Err(err) => {
884 for (target, backup) in ciphertexts.iter_mut().zip(originals.iter()) {
885 *target = backup.clone();
886 }
887 *counter = counter_snapshot;
888 return Err(err);
889 }
890 }
891 }
892 Ok(())
893}
894
895#[inline]
897#[must_use]
898pub fn rotation_advice(counter: &DekMessageCounter) -> RotationTrigger {
899 counter.rotation_trigger()
900}
901
902#[cfg(test)]
905#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
906mod tests {
907 use super::*;
908 use arkhe_forge_core::pii::ActorHandle;
909
910 #[derive(Clone, Copy, Default)]
911 struct FixedNonce;
912
913 impl NonceSource for FixedNonce {
914 fn fill(&self, out: &mut [u8]) {
915 for (i, byte) in out.iter_mut().enumerate() {
916 *byte = (i & 0xFF) as u8;
917 }
918 }
919 }
920
921 fn make_dek(byte: u8) -> Dek {
922 Dek::from_bytes([byte; 32])
923 }
924
925 fn make_dek_id(byte: u8) -> DekId {
926 DekId([byte; 16])
927 }
928
929 #[test]
930 fn dek_from_bytes_exposes_material_via_crate_accessor() {
931 let d = make_dek(0x42);
932 assert_eq!(d.as_bytes(), &[0x42u8; 32]);
933 }
934
935 #[test]
936 fn dek_try_from_slice_rejects_short_key() {
937 let err = Dek::try_from_slice(&[0u8; 16]).unwrap_err();
938 assert!(matches!(err, PiiError::InvalidKeyLength));
939 }
940
941 #[test]
942 fn dek_try_from_slice_accepts_32_bytes() {
943 let key = [0x77u8; 32];
944 let dek = Dek::try_from_slice(&key).unwrap();
945 assert_eq!(dek.as_bytes(), &key);
946 }
947
948 #[test]
949 fn dek_debug_does_not_expose_material() {
950 let d = make_dek(0xAB);
951 let s = format!("{:?}", d);
952 assert!(!s.contains("AB"), "Debug output must not leak key bytes");
953 assert!(!s.contains("ab"));
954 }
955
956 #[test]
957 fn nonce_bytes_expected_len_matches_kind() {
958 assert_eq!(NonceBytes::expected_len(AeadKind::XChaCha20Poly1305), 24);
959 assert_eq!(NonceBytes::expected_len(AeadKind::Aes256Gcm), 12);
960 assert_eq!(NonceBytes::expected_len(AeadKind::Aes256GcmSiv), 12);
961 }
962
963 #[test]
964 fn encrypted_pii_wire_layout_roundtrips_through_postcard() {
965 let envelope = EncryptedPii::<ActorHandle>::new(
966 make_dek_id(0x11),
967 AeadKind::XChaCha20Poly1305,
968 NonceBytes::X24([0x22; 24]),
969 Bytes::from_static(&[0x33; 48]),
970 );
971 let bytes = postcard::to_stdvec(&envelope).unwrap();
972 let back: EncryptedPii<ActorHandle> = postcard::from_bytes(&bytes).unwrap();
973 assert_eq!(envelope, back);
974 assert_eq!(back.pii_code, ActorHandle::PII_CODE);
975 }
976
977 #[cfg(not(feature = "tier-1-kms"))]
980 #[test]
981 fn tier0_default_rejects_encryption() {
982 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
983 let err = coord
984 .encrypt::<ActorHandle>(
985 &ActorHandle(b"alice".to_vec()),
986 &make_dek(0x00),
987 make_dek_id(0x11),
988 )
989 .unwrap_err();
990 assert!(matches!(err, PiiError::TierTooLow));
991 }
992
993 #[cfg(feature = "tier-1-kms")]
996 #[test]
997 fn tier1_xchacha_encrypt_decrypt_roundtrip() {
998 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
999 let handle = ActorHandle(b"alice".to_vec());
1000 let dek = make_dek(0xA5);
1001 let dek_id = make_dek_id(0x11);
1002 let env = coord.encrypt(&handle, &dek, dek_id).unwrap();
1003 assert_eq!(env.aead_kind, AeadKind::XChaCha20Poly1305);
1004 assert_eq!(env.pii_code, ActorHandle::PII_CODE);
1005 let back: ActorHandle = coord.decrypt(&env, &dek).unwrap();
1006 assert_eq!(back, handle);
1007 }
1008
1009 #[cfg(feature = "tier-1-kms")]
1010 #[test]
1011 fn tier1_xchacha_aad_tamper_fails_tag() {
1012 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1013 let handle = ActorHandle(b"alice".to_vec());
1014 let dek = make_dek(0x01);
1015 let mut env = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1016 env.dek_id = make_dek_id(0x12);
1019 let err = coord.decrypt::<ActorHandle>(&env, &dek).unwrap_err();
1020 assert!(matches!(err, PiiError::AadMismatch));
1021 }
1022
1023 #[cfg(feature = "tier-1-kms")]
1024 #[test]
1025 fn tier1_ciphertext_tamper_fails_tag() {
1026 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1027 let handle = ActorHandle(b"alice".to_vec());
1028 let dek = make_dek(0x03);
1029 let env = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1030 let mut ct = env.ciphertext.to_vec();
1031 if let Some(first) = ct.first_mut() {
1032 *first ^= 0x01;
1033 }
1034 let tampered =
1035 EncryptedPii::<ActorHandle>::new(env.dek_id, env.aead_kind, env.nonce, Bytes::from(ct));
1036 let err = coord.decrypt::<ActorHandle>(&tampered, &dek).unwrap_err();
1037 assert!(matches!(err, PiiError::AadMismatch));
1038 }
1039
1040 #[cfg(feature = "tier-1-kms")]
1041 #[test]
1042 fn tier1_wrong_pii_code_rejected_as_type_mismatch() {
1043 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1044 let handle = ActorHandle(b"alice".to_vec());
1045 let dek = make_dek(0x07);
1046 let env = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1047 let wrong = EncryptedPii::<ActorHandle> {
1050 dek_id: env.dek_id,
1051 pii_code: arkhe_forge_core::pii::EntryBody::PII_CODE,
1052 aead_kind: env.aead_kind,
1053 nonce: env.nonce,
1054 ciphertext: env.ciphertext,
1055 _marker: PhantomData,
1056 };
1057 let err = coord.decrypt::<ActorHandle>(&wrong, &dek).unwrap_err();
1058 assert!(matches!(err, PiiError::TypeMismatch));
1059 }
1060
1061 #[cfg(feature = "tier-1-kms")]
1062 #[test]
1063 fn tier1_aead_downgrade_rejected_by_coordinator_manifest() {
1064 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1067 let env = EncryptedPii::<ActorHandle>::new(
1068 make_dek_id(0x11),
1069 AeadKind::Aes256Gcm,
1070 NonceBytes::Short12([0u8; 12]),
1071 Bytes::from_static(&[0u8; 48]),
1072 );
1073 let err = coord
1074 .decrypt::<ActorHandle>(&env, &make_dek(0x00))
1075 .unwrap_err();
1076 assert!(matches!(err, PiiError::CipherDowngrade));
1077 }
1078
1079 #[cfg(feature = "tier-1-kms")]
1080 #[test]
1081 fn tier1_aes_gcm_without_tier2_is_unsupported() {
1082 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1085 let handle = ActorHandle(b"alice".to_vec());
1086 let out = coord.encrypt(&handle, &make_dek(0x00), make_dek_id(0x11));
1087 #[cfg(feature = "tier-2-multi-kms")]
1088 assert!(out.is_ok());
1089 #[cfg(not(feature = "tier-2-multi-kms"))]
1090 assert!(matches!(out, Err(PiiError::UnsupportedAead)));
1091 }
1092
1093 #[cfg(feature = "tier-2-multi-kms")]
1094 #[test]
1095 fn tier2_aes_gcm_roundtrip() {
1096 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1097 let handle = ActorHandle(b"aes-user".to_vec());
1098 let dek = make_dek(0x5A);
1099 let env = coord.encrypt(&handle, &dek, make_dek_id(0x21)).unwrap();
1100 assert_eq!(env.aead_kind, AeadKind::Aes256Gcm);
1101 assert!(matches!(env.nonce, NonceBytes::Short12(_)));
1102 let back: ActorHandle = coord.decrypt(&env, &dek).unwrap();
1103 assert_eq!(back, handle);
1104 }
1105
1106 #[cfg(feature = "tier-2-multi-kms")]
1107 #[test]
1108 fn tier2_aes_gcm_siv_roundtrip() {
1109 let coord = CryptoCoordinator::new(AeadKind::Aes256GcmSiv, FixedNonce);
1110 let handle = ActorHandle(b"aes-siv-user".to_vec());
1111 let dek = make_dek(0x7B);
1112 let env = coord.encrypt(&handle, &dek, make_dek_id(0x22)).unwrap();
1113 assert_eq!(env.aead_kind, AeadKind::Aes256GcmSiv);
1114 let back: ActorHandle = coord.decrypt(&env, &dek).unwrap();
1115 assert_eq!(back, handle);
1116 }
1117
1118 #[cfg(feature = "tier-2-multi-kms")]
1123 #[test]
1124 fn long_lived_dek_rejected_under_plain_aes_gcm() {
1125 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1126 let dek = Dek::from_unwrapped([0x9C; 32], DekConfig::default());
1127 assert_eq!(dek.lifecycle(), DekLifecycle::LongLived);
1128 let handle = ActorHandle(b"long-lived".to_vec());
1129 let out = coord.encrypt(&handle, &dek, make_dek_id(0x31));
1130 assert!(
1131 matches!(out, Err(PiiError::NonceReuseRisk)),
1132 "long-lived DEK must be refused under plain AES-256-GCM",
1133 );
1134 }
1135
1136 #[cfg(feature = "tier-2-multi-kms")]
1143 #[test]
1144 fn long_lived_dek_under_siv_survives_counter_reset() {
1145 let coord = CryptoCoordinator::new(AeadKind::Aes256GcmSiv, FixedNonce);
1146 let handle = ActorHandle(b"siv-survivor".to_vec());
1147
1148 let dek1 = Dek::from_unwrapped([0xA1; 32], DekConfig::default());
1150 let env1 = coord.encrypt(&handle, &dek1, make_dek_id(0x41)).unwrap();
1151 assert_eq!(env1.aead_kind, AeadKind::Aes256GcmSiv);
1152
1153 let dek2 = Dek::from_unwrapped([0xA1; 32], DekConfig::default());
1155 let env2 = coord.encrypt(&handle, &dek2, make_dek_id(0x41)).unwrap();
1156
1157 let NonceBytes::Short12(n1) = &env1.nonce else {
1158 panic!("SIV returns Short12");
1159 };
1160 let NonceBytes::Short12(n2) = &env2.nonce else {
1161 panic!("SIV returns Short12");
1162 };
1163 assert_eq!(n1, n2, "counter reset reproduces the same nonce");
1164 assert_eq!(
1168 env1.ciphertext, env2.ciphertext,
1169 "SIV is deterministic; reuse is non-catastrophic",
1170 );
1171 assert_eq!(coord.decrypt::<ActorHandle>(&env1, &dek2).unwrap(), handle);
1172 assert_eq!(coord.decrypt::<ActorHandle>(&env2, &dek1).unwrap(), handle);
1173 }
1174
1175 #[cfg(feature = "tier-2-multi-kms")]
1176 #[test]
1177 fn aes_gcm_nonce_is_deterministic_counter() {
1178 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1182 let dek = make_dek(0x5A);
1183 let handle = ActorHandle(b"alice".to_vec());
1184
1185 let env1 = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1186 let env2 = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1187
1188 let NonceBytes::Short12(n1) = &env1.nonce else {
1189 panic!("AES-GCM always returns Short12");
1190 };
1191 let NonceBytes::Short12(n2) = &env2.nonce else {
1192 panic!("AES-GCM always returns Short12");
1193 };
1194 assert_eq!(&n1[0..4], &[0u8; 4], "invocation field zeros");
1195 assert_eq!(&n1[4..12], &0u64.to_be_bytes());
1196 assert_eq!(&n2[4..12], &1u64.to_be_bytes());
1197 assert_ne!(n1, n2);
1198
1199 assert_eq!(coord.decrypt::<ActorHandle>(&env1, &dek).unwrap(), handle);
1201 assert_eq!(coord.decrypt::<ActorHandle>(&env2, &dek).unwrap(), handle);
1202 }
1203
1204 #[cfg(feature = "tier-2-multi-kms")]
1205 #[test]
1206 fn aes_gcm_nonce_honours_dek_replica_id() {
1207 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1212 let dek_a = Dek::with_config([0xC3; 32], DekConfig { replica_id: 0 });
1213 let dek_b = Dek::with_config(
1214 [0xC3; 32],
1215 DekConfig {
1216 replica_id: 0xDEAD_BEEF,
1217 },
1218 );
1219 let handle = ActorHandle(b"alice".to_vec());
1220
1221 let env_a = coord.encrypt(&handle, &dek_a, make_dek_id(0x11)).unwrap();
1222 let env_b = coord.encrypt(&handle, &dek_b, make_dek_id(0x11)).unwrap();
1223
1224 let NonceBytes::Short12(na) = &env_a.nonce else {
1225 panic!("AES-GCM returns Short12");
1226 };
1227 let NonceBytes::Short12(nb) = &env_b.nonce else {
1228 panic!("AES-GCM returns Short12");
1229 };
1230 assert_eq!(&na[0..4], &0u32.to_be_bytes());
1231 assert_eq!(&nb[0..4], &0xDEAD_BEEFu32.to_be_bytes());
1232 assert_eq!(&na[4..12], &0u64.to_be_bytes());
1235 assert_eq!(&nb[4..12], &0u64.to_be_bytes());
1236 assert_ne!(na, nb);
1237 }
1238
1239 #[cfg(feature = "tier-2-multi-kms")]
1240 #[test]
1241 fn aes_gcm_siv_nonce_is_deterministic_counter() {
1242 let coord = CryptoCoordinator::new(AeadKind::Aes256GcmSiv, FixedNonce);
1245 let dek = make_dek(0x7B);
1246 let handle = ActorHandle(b"siv".to_vec());
1247
1248 let env1 = coord.encrypt(&handle, &dek, make_dek_id(0x22)).unwrap();
1249 let NonceBytes::Short12(n1) = &env1.nonce else {
1250 panic!("AES-GCM-SIV returns Short12");
1251 };
1252 assert_eq!(&n1[4..12], &0u64.to_be_bytes());
1253 assert_eq!(dek.get_counter_for_test(), 1);
1254 }
1255
1256 #[cfg(feature = "tier-2-multi-kms")]
1257 #[test]
1258 fn dek_counter_exhaustion_errors() {
1259 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1262 let dek = make_dek(0xA5);
1263 dek.set_counter_for_test(u64::MAX);
1264
1265 let handle = ActorHandle(b"alice".to_vec());
1266 let err = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap_err();
1267 assert!(matches!(err, PiiError::DekExhausted));
1268 assert_eq!(dek.get_counter_for_test(), u64::MAX);
1270 }
1271
1272 #[cfg(feature = "tier-2-multi-kms")]
1273 #[test]
1274 fn rotate_dek_starts_new_counter_from_zero() {
1275 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1279 let old = make_dek(0x10);
1280 let new = make_dek(0x20);
1281 let new_id = make_dek_id(0x02);
1282
1283 let plaintexts: Vec<ActorHandle> = (0..3u8).map(|i| ActorHandle(vec![i; 8])).collect();
1284 let mut envs: Vec<EncryptedPii<ActorHandle>> = plaintexts
1285 .iter()
1286 .map(|pt| coord.encrypt(pt, &old, make_dek_id(0x01)).unwrap())
1287 .collect();
1288 assert_eq!(old.get_counter_for_test(), 3);
1289 assert_eq!(new.get_counter_for_test(), 0);
1290
1291 let mut rotation_metric = DekMessageCounter::new(new_id);
1292 rotate_dek(&coord, &old, &new, new_id, &mut envs, &mut rotation_metric).unwrap();
1293
1294 assert_eq!(new.get_counter_for_test(), 3);
1295 assert_eq!(rotation_metric.count(), 3);
1296 for (i, env) in envs.iter().enumerate() {
1297 let NonceBytes::Short12(n) = &env.nonce else {
1298 panic!("AES-GCM returns Short12");
1299 };
1300 assert_eq!(
1301 &n[4..12],
1302 &(i as u64).to_be_bytes(),
1303 "counter values run 0,1,2 under new DEK"
1304 );
1305 }
1306 }
1307
1308 #[cfg(feature = "tier-1-kms")]
1309 #[test]
1310 fn dek_rotate_preserves_plaintext() {
1311 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1312 let old = make_dek(0x10);
1313 let new = make_dek(0x20);
1314 let new_id = make_dek_id(0x02);
1315 let plaintexts: Vec<ActorHandle> = (0..4u8).map(|i| ActorHandle(vec![i; 8])).collect();
1316 let mut envelopes: Vec<EncryptedPii<ActorHandle>> = plaintexts
1317 .iter()
1318 .map(|pt| coord.encrypt(pt, &old, make_dek_id(0x01)).unwrap())
1319 .collect();
1320 let mut counter = DekMessageCounter::new(make_dek_id(0x02));
1321 rotate_dek(&coord, &old, &new, new_id, &mut envelopes, &mut counter).unwrap();
1322 assert_eq!(counter.count(), 4);
1323 for (env, pt) in envelopes.iter().zip(plaintexts.iter()) {
1324 assert_eq!(env.dek_id, new_id);
1325 assert_eq!(&coord.decrypt::<ActorHandle>(env, &new).unwrap(), pt);
1326 }
1327 }
1328
1329 #[cfg(feature = "tier-1-kms")]
1330 #[test]
1331 fn dek_rotate_with_wrong_old_key_rolls_back() {
1332 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1333 let real_old = make_dek(0x10);
1334 let wrong_old = make_dek(0xFF);
1335 let new = make_dek(0x20);
1336 let original_envelope = coord
1337 .encrypt(
1338 &ActorHandle(b"alice".to_vec()),
1339 &real_old,
1340 make_dek_id(0x01),
1341 )
1342 .unwrap();
1343 let mut envelopes = vec![original_envelope.clone()];
1344 let mut counter = DekMessageCounter::new(make_dek_id(0x02));
1345 let err = rotate_dek(
1346 &coord,
1347 &wrong_old,
1348 &new,
1349 make_dek_id(0x02),
1350 &mut envelopes,
1351 &mut counter,
1352 )
1353 .unwrap_err();
1354 assert!(matches!(err, PiiError::AadMismatch));
1355 assert_eq!(envelopes[0], original_envelope);
1357 }
1358
1359 #[cfg(feature = "tier-1-kms")]
1366 #[test]
1367 fn dek_rotate_partial_failure_rolls_back_counter() {
1368 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1369 let old = make_dek(0x10);
1370 let new = make_dek(0x20);
1371 let new_id = make_dek_id(0x02);
1372
1373 let good = coord
1374 .encrypt(&ActorHandle(b"first".to_vec()), &old, make_dek_id(0x01))
1375 .unwrap();
1376 let mut corrupt_ct = good.ciphertext.to_vec();
1379 corrupt_ct[0] ^= 0xFF;
1380 let corrupt = EncryptedPii::<ActorHandle>::new(
1381 good.dek_id,
1382 good.aead_kind,
1383 good.nonce.clone(),
1384 Bytes::from(corrupt_ct),
1385 );
1386 let mut envelopes = vec![good.clone(), corrupt.clone()];
1387
1388 let mut counter = DekMessageCounter::new(new_id);
1391 counter.record_message();
1392 counter.record_message();
1393 let entry_count = counter.count();
1394 assert_eq!(entry_count, 2);
1395
1396 let err = rotate_dek(&coord, &old, &new, new_id, &mut envelopes, &mut counter).unwrap_err();
1397 assert!(matches!(err, PiiError::AadMismatch));
1398 assert_eq!(envelopes[0], good);
1400 assert_eq!(envelopes[1], corrupt);
1401 assert_eq!(counter.count(), entry_count);
1403 }
1404}