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};
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 = postcard::to_stdvec(plaintext).map_err(|_| PiiError::EncryptFailed)?;
497 let (nonce, ciphertext) = self.encrypt_raw(dek, &aad, &pt_bytes)?;
498 Ok(EncryptedPii::new(
499 dek_id,
500 self.manifest_cipher,
501 nonce,
502 Bytes::from(ciphertext),
503 ))
504 }
505
506 pub fn decrypt<T: PiiType>(
510 &self,
511 envelope: &EncryptedPii<T>,
512 dek: &Dek,
513 ) -> Result<T, PiiError> {
514 if envelope.pii_code != T::PII_CODE {
515 return Err(PiiError::TypeMismatch);
516 }
517 if envelope.aead_kind != self.manifest_cipher {
518 return Err(PiiError::CipherDowngrade);
519 }
520 let aad = compute_aad(&envelope.dek_id, envelope.pii_code, envelope.aead_kind);
521 let pt = self.decrypt_raw(
522 dek,
523 envelope.aead_kind,
524 &envelope.nonce,
525 &aad,
526 &envelope.ciphertext,
527 )?;
528 postcard::from_bytes::<T>(&pt).map_err(|_| PiiError::DecodeFailed)
529 }
530
531 fn decrypt_raw_under<T: PiiType>(
538 &self,
539 dek: &Dek,
540 envelope: &EncryptedPii<T>,
541 ) -> Result<Vec<u8>, PiiError> {
542 let aad = compute_aad(&envelope.dek_id, envelope.pii_code, envelope.aead_kind);
543 self.decrypt_raw(
544 dek,
545 envelope.aead_kind,
546 &envelope.nonce,
547 &aad,
548 &envelope.ciphertext,
549 )
550 }
551
552 fn encrypt_raw(
553 &self,
554 dek: &Dek,
555 aad: &[u8; 19],
556 plaintext: &[u8],
557 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
558 match self.manifest_cipher {
559 AeadKind::XChaCha20Poly1305 => self.encrypt_xchacha(dek, aad, plaintext),
560 AeadKind::Aes256Gcm => self.encrypt_aes_gcm(dek, aad, plaintext),
561 AeadKind::Aes256GcmSiv => self.encrypt_aes_gcm_siv(dek, aad, plaintext),
562 _ => Err(PiiError::UnsupportedAead),
563 }
564 }
565
566 fn decrypt_raw(
567 &self,
568 dek: &Dek,
569 kind: AeadKind,
570 nonce: &NonceBytes,
571 aad: &[u8; 19],
572 ciphertext: &[u8],
573 ) -> Result<Vec<u8>, PiiError> {
574 match kind {
575 AeadKind::XChaCha20Poly1305 => self.decrypt_xchacha(dek, nonce, aad, ciphertext),
576 AeadKind::Aes256Gcm => self.decrypt_aes_gcm(dek, nonce, aad, ciphertext),
577 AeadKind::Aes256GcmSiv => self.decrypt_aes_gcm_siv(dek, nonce, aad, ciphertext),
578 _ => Err(PiiError::UnsupportedAead),
579 }
580 }
581
582 #[cfg(feature = "tier-1-kms")]
585 fn encrypt_xchacha(
586 &self,
587 dek: &Dek,
588 aad: &[u8; 19],
589 plaintext: &[u8],
590 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
591 use chacha20poly1305::aead::{Aead, KeyInit, Payload};
592 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
593
594 let key = Key::from_slice(dek.as_bytes());
595 let cipher = XChaCha20Poly1305::new(key);
596 let mut nonce_buf = [0u8; 24];
597 self.nonce_source.fill(&mut nonce_buf);
598 let nonce = XNonce::from_slice(&nonce_buf);
599 let ciphertext = cipher
600 .encrypt(
601 nonce,
602 Payload {
603 msg: plaintext,
604 aad,
605 },
606 )
607 .map_err(|_| PiiError::EncryptFailed)?;
608 Ok((NonceBytes::X24(nonce_buf), ciphertext))
609 }
610
611 #[cfg(not(feature = "tier-1-kms"))]
612 fn encrypt_xchacha(
613 &self,
614 _dek: &Dek,
615 _aad: &[u8; 19],
616 _plaintext: &[u8],
617 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
618 Err(PiiError::TierTooLow)
619 }
620
621 #[cfg(feature = "tier-1-kms")]
622 fn decrypt_xchacha(
623 &self,
624 dek: &Dek,
625 nonce: &NonceBytes,
626 aad: &[u8; 19],
627 ciphertext: &[u8],
628 ) -> Result<Vec<u8>, PiiError> {
629 use chacha20poly1305::aead::{Aead, KeyInit, Payload};
630 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
631
632 let bytes_24 = match nonce {
633 NonceBytes::X24(b) => b,
634 NonceBytes::Short12(_) => return Err(PiiError::AadMismatch),
635 };
636 let key = Key::from_slice(dek.as_bytes());
637 let cipher = XChaCha20Poly1305::new(key);
638 let nonce = XNonce::from_slice(bytes_24);
639 cipher
640 .decrypt(
641 nonce,
642 Payload {
643 msg: ciphertext,
644 aad,
645 },
646 )
647 .map_err(|_| PiiError::AadMismatch)
648 }
649
650 #[cfg(not(feature = "tier-1-kms"))]
651 fn decrypt_xchacha(
652 &self,
653 _dek: &Dek,
654 _nonce: &NonceBytes,
655 _aad: &[u8; 19],
656 _ciphertext: &[u8],
657 ) -> Result<Vec<u8>, PiiError> {
658 Err(PiiError::TierTooLow)
659 }
660
661 #[cfg(feature = "tier-2-multi-kms")]
664 fn encrypt_aes_gcm(
665 &self,
666 dek: &Dek,
667 aad: &[u8; 19],
668 plaintext: &[u8],
669 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
670 use aes_gcm::aead::{Aead, KeyInit, Payload};
671 use aes_gcm::{Aes256Gcm, Key, Nonce};
672
673 if dek.lifecycle() == DekLifecycle::LongLived {
678 return Err(PiiError::NonceReuseRisk);
679 }
680
681 let counter = dek.advance_counter()?;
685 let nonce_buf = aes_gcm_nonce_from_counter(dek.replica_id, counter);
686 let key = Key::<Aes256Gcm>::from_slice(dek.as_bytes());
687 let cipher = Aes256Gcm::new(key);
688 let nonce = Nonce::from_slice(&nonce_buf);
689 let ciphertext = cipher
690 .encrypt(
691 nonce,
692 Payload {
693 msg: plaintext,
694 aad,
695 },
696 )
697 .map_err(|_| PiiError::EncryptFailed)?;
698 Ok((NonceBytes::Short12(nonce_buf), ciphertext))
699 }
700
701 #[cfg(not(feature = "tier-2-multi-kms"))]
702 fn encrypt_aes_gcm(
703 &self,
704 _dek: &Dek,
705 _aad: &[u8; 19],
706 _plaintext: &[u8],
707 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
708 Err(PiiError::UnsupportedAead)
709 }
710
711 #[cfg(feature = "tier-2-multi-kms")]
712 fn decrypt_aes_gcm(
713 &self,
714 dek: &Dek,
715 nonce: &NonceBytes,
716 aad: &[u8; 19],
717 ciphertext: &[u8],
718 ) -> Result<Vec<u8>, PiiError> {
719 use aes_gcm::aead::{Aead, KeyInit, Payload};
720 use aes_gcm::{Aes256Gcm, Key, Nonce};
721
722 let bytes_12 = match nonce {
723 NonceBytes::Short12(b) => b,
724 NonceBytes::X24(_) => return Err(PiiError::AadMismatch),
725 };
726 let key = Key::<Aes256Gcm>::from_slice(dek.as_bytes());
727 let cipher = Aes256Gcm::new(key);
728 let nonce = Nonce::from_slice(bytes_12);
729 cipher
730 .decrypt(
731 nonce,
732 Payload {
733 msg: ciphertext,
734 aad,
735 },
736 )
737 .map_err(|_| PiiError::AadMismatch)
738 }
739
740 #[cfg(not(feature = "tier-2-multi-kms"))]
741 fn decrypt_aes_gcm(
742 &self,
743 _dek: &Dek,
744 _nonce: &NonceBytes,
745 _aad: &[u8; 19],
746 _ciphertext: &[u8],
747 ) -> Result<Vec<u8>, PiiError> {
748 Err(PiiError::UnsupportedAead)
749 }
750
751 #[cfg(feature = "tier-2-multi-kms")]
754 fn encrypt_aes_gcm_siv(
755 &self,
756 dek: &Dek,
757 aad: &[u8; 19],
758 plaintext: &[u8],
759 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
760 use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
761 use aes_gcm_siv::{Aes256GcmSiv, Key, Nonce};
762
763 let counter = dek.advance_counter()?;
767 let nonce_buf = aes_gcm_nonce_from_counter(dek.replica_id, counter);
768 let key = Key::<Aes256GcmSiv>::from_slice(dek.as_bytes());
769 let cipher = Aes256GcmSiv::new(key);
770 let nonce = Nonce::from_slice(&nonce_buf);
771 let ciphertext = cipher
772 .encrypt(
773 nonce,
774 Payload {
775 msg: plaintext,
776 aad,
777 },
778 )
779 .map_err(|_| PiiError::EncryptFailed)?;
780 Ok((NonceBytes::Short12(nonce_buf), ciphertext))
781 }
782
783 #[cfg(not(feature = "tier-2-multi-kms"))]
784 fn encrypt_aes_gcm_siv(
785 &self,
786 _dek: &Dek,
787 _aad: &[u8; 19],
788 _plaintext: &[u8],
789 ) -> Result<(NonceBytes, Vec<u8>), PiiError> {
790 Err(PiiError::UnsupportedAead)
791 }
792
793 #[cfg(feature = "tier-2-multi-kms")]
794 fn decrypt_aes_gcm_siv(
795 &self,
796 dek: &Dek,
797 nonce: &NonceBytes,
798 aad: &[u8; 19],
799 ciphertext: &[u8],
800 ) -> Result<Vec<u8>, PiiError> {
801 use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
802 use aes_gcm_siv::{Aes256GcmSiv, Key, Nonce};
803
804 let bytes_12 = match nonce {
805 NonceBytes::Short12(b) => b,
806 NonceBytes::X24(_) => return Err(PiiError::AadMismatch),
807 };
808 let key = Key::<Aes256GcmSiv>::from_slice(dek.as_bytes());
809 let cipher = Aes256GcmSiv::new(key);
810 let nonce = Nonce::from_slice(bytes_12);
811 cipher
812 .decrypt(
813 nonce,
814 Payload {
815 msg: ciphertext,
816 aad,
817 },
818 )
819 .map_err(|_| PiiError::AadMismatch)
820 }
821
822 #[cfg(not(feature = "tier-2-multi-kms"))]
823 fn decrypt_aes_gcm_siv(
824 &self,
825 _dek: &Dek,
826 _nonce: &NonceBytes,
827 _aad: &[u8; 19],
828 _ciphertext: &[u8],
829 ) -> Result<Vec<u8>, PiiError> {
830 Err(PiiError::UnsupportedAead)
831 }
832}
833
834pub fn rotate_dek<T: PiiType>(
848 coordinator: &CryptoCoordinator<impl NonceSource>,
849 old_dek: &Dek,
850 new_dek: &Dek,
851 new_dek_id: DekId,
852 ciphertexts: &mut [EncryptedPii<T>],
853 counter: &mut DekMessageCounter,
854) -> Result<(), PiiError> {
855 let originals: Vec<EncryptedPii<T>> = ciphertexts.to_vec();
856 let counter_snapshot = counter.clone();
860 let aad = compute_aad(&new_dek_id, T::PII_CODE, coordinator.manifest_cipher);
861 for idx in 0..ciphertexts.len() {
864 let step = coordinator
865 .decrypt_raw_under(old_dek, &ciphertexts[idx])
866 .and_then(|plaintext_bytes| coordinator.encrypt_raw(new_dek, &aad, &plaintext_bytes));
867 match step {
868 Ok((nonce, new_ct)) => {
869 ciphertexts[idx] = EncryptedPii::new(
870 new_dek_id,
871 coordinator.manifest_cipher,
872 nonce,
873 Bytes::from(new_ct),
874 );
875 counter.record_message();
876 }
877 Err(err) => {
878 for (target, backup) in ciphertexts.iter_mut().zip(originals.iter()) {
879 *target = backup.clone();
880 }
881 *counter = counter_snapshot;
882 return Err(err);
883 }
884 }
885 }
886 Ok(())
887}
888
889#[inline]
891#[must_use]
892pub fn rotation_advice(counter: &DekMessageCounter) -> RotationTrigger {
893 counter.rotation_trigger()
894}
895
896#[cfg(test)]
899#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
900mod tests {
901 use super::*;
902 use arkhe_forge_core::pii::ActorHandle;
903
904 #[derive(Clone, Copy, Default)]
905 struct FixedNonce;
906
907 impl NonceSource for FixedNonce {
908 fn fill(&self, out: &mut [u8]) {
909 for (i, byte) in out.iter_mut().enumerate() {
910 *byte = (i & 0xFF) as u8;
911 }
912 }
913 }
914
915 fn make_dek(byte: u8) -> Dek {
916 Dek::from_bytes([byte; 32])
917 }
918
919 fn make_dek_id(byte: u8) -> DekId {
920 DekId([byte; 16])
921 }
922
923 #[test]
924 fn dek_from_bytes_exposes_material_via_crate_accessor() {
925 let d = make_dek(0x42);
926 assert_eq!(d.as_bytes(), &[0x42u8; 32]);
927 }
928
929 #[test]
930 fn dek_try_from_slice_rejects_short_key() {
931 let err = Dek::try_from_slice(&[0u8; 16]).unwrap_err();
932 assert!(matches!(err, PiiError::InvalidKeyLength));
933 }
934
935 #[test]
936 fn dek_try_from_slice_accepts_32_bytes() {
937 let key = [0x77u8; 32];
938 let dek = Dek::try_from_slice(&key).unwrap();
939 assert_eq!(dek.as_bytes(), &key);
940 }
941
942 #[test]
943 fn dek_debug_does_not_expose_material() {
944 let d = make_dek(0xAB);
945 let s = format!("{:?}", d);
946 assert!(!s.contains("AB"), "Debug output must not leak key bytes");
947 assert!(!s.contains("ab"));
948 }
949
950 #[test]
951 fn nonce_bytes_expected_len_matches_kind() {
952 assert_eq!(NonceBytes::expected_len(AeadKind::XChaCha20Poly1305), 24);
953 assert_eq!(NonceBytes::expected_len(AeadKind::Aes256Gcm), 12);
954 assert_eq!(NonceBytes::expected_len(AeadKind::Aes256GcmSiv), 12);
955 }
956
957 #[test]
958 fn encrypted_pii_wire_layout_roundtrips_through_postcard() {
959 let envelope = EncryptedPii::<ActorHandle>::new(
960 make_dek_id(0x11),
961 AeadKind::XChaCha20Poly1305,
962 NonceBytes::X24([0x22; 24]),
963 Bytes::from_static(&[0x33; 48]),
964 );
965 let bytes = postcard::to_stdvec(&envelope).unwrap();
966 let back: EncryptedPii<ActorHandle> = postcard::from_bytes(&bytes).unwrap();
967 assert_eq!(envelope, back);
968 assert_eq!(back.pii_code, ActorHandle::PII_CODE);
969 }
970
971 #[cfg(not(feature = "tier-1-kms"))]
974 #[test]
975 fn tier0_default_rejects_encryption() {
976 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
977 let err = coord
978 .encrypt::<ActorHandle>(
979 &ActorHandle(b"alice".to_vec()),
980 &make_dek(0x00),
981 make_dek_id(0x11),
982 )
983 .unwrap_err();
984 assert!(matches!(err, PiiError::TierTooLow));
985 }
986
987 #[cfg(feature = "tier-1-kms")]
990 #[test]
991 fn tier1_xchacha_encrypt_decrypt_roundtrip() {
992 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
993 let handle = ActorHandle(b"alice".to_vec());
994 let dek = make_dek(0xA5);
995 let dek_id = make_dek_id(0x11);
996 let env = coord.encrypt(&handle, &dek, dek_id).unwrap();
997 assert_eq!(env.aead_kind, AeadKind::XChaCha20Poly1305);
998 assert_eq!(env.pii_code, ActorHandle::PII_CODE);
999 let back: ActorHandle = coord.decrypt(&env, &dek).unwrap();
1000 assert_eq!(back, handle);
1001 }
1002
1003 #[cfg(feature = "tier-1-kms")]
1004 #[test]
1005 fn tier1_xchacha_aad_tamper_fails_tag() {
1006 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1007 let handle = ActorHandle(b"alice".to_vec());
1008 let dek = make_dek(0x01);
1009 let mut env = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1010 env.dek_id = make_dek_id(0x12);
1013 let err = coord.decrypt::<ActorHandle>(&env, &dek).unwrap_err();
1014 assert!(matches!(err, PiiError::AadMismatch));
1015 }
1016
1017 #[cfg(feature = "tier-1-kms")]
1018 #[test]
1019 fn tier1_ciphertext_tamper_fails_tag() {
1020 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1021 let handle = ActorHandle(b"alice".to_vec());
1022 let dek = make_dek(0x03);
1023 let env = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1024 let mut ct = env.ciphertext.to_vec();
1025 if let Some(first) = ct.first_mut() {
1026 *first ^= 0x01;
1027 }
1028 let tampered =
1029 EncryptedPii::<ActorHandle>::new(env.dek_id, env.aead_kind, env.nonce, Bytes::from(ct));
1030 let err = coord.decrypt::<ActorHandle>(&tampered, &dek).unwrap_err();
1031 assert!(matches!(err, PiiError::AadMismatch));
1032 }
1033
1034 #[cfg(feature = "tier-1-kms")]
1035 #[test]
1036 fn tier1_wrong_pii_code_rejected_as_type_mismatch() {
1037 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1038 let handle = ActorHandle(b"alice".to_vec());
1039 let dek = make_dek(0x07);
1040 let env = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1041 let wrong = EncryptedPii::<ActorHandle> {
1044 dek_id: env.dek_id,
1045 pii_code: arkhe_forge_core::pii::EntryBody::PII_CODE,
1046 aead_kind: env.aead_kind,
1047 nonce: env.nonce,
1048 ciphertext: env.ciphertext,
1049 _marker: PhantomData,
1050 };
1051 let err = coord.decrypt::<ActorHandle>(&wrong, &dek).unwrap_err();
1052 assert!(matches!(err, PiiError::TypeMismatch));
1053 }
1054
1055 #[cfg(feature = "tier-1-kms")]
1056 #[test]
1057 fn tier1_aead_downgrade_rejected_by_coordinator_manifest() {
1058 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1061 let env = EncryptedPii::<ActorHandle>::new(
1062 make_dek_id(0x11),
1063 AeadKind::Aes256Gcm,
1064 NonceBytes::Short12([0u8; 12]),
1065 Bytes::from_static(&[0u8; 48]),
1066 );
1067 let err = coord
1068 .decrypt::<ActorHandle>(&env, &make_dek(0x00))
1069 .unwrap_err();
1070 assert!(matches!(err, PiiError::CipherDowngrade));
1071 }
1072
1073 #[cfg(feature = "tier-1-kms")]
1074 #[test]
1075 fn tier1_aes_gcm_without_tier2_is_unsupported() {
1076 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1079 let handle = ActorHandle(b"alice".to_vec());
1080 let out = coord.encrypt(&handle, &make_dek(0x00), make_dek_id(0x11));
1081 #[cfg(feature = "tier-2-multi-kms")]
1082 assert!(out.is_ok());
1083 #[cfg(not(feature = "tier-2-multi-kms"))]
1084 assert!(matches!(out, Err(PiiError::UnsupportedAead)));
1085 }
1086
1087 #[cfg(feature = "tier-2-multi-kms")]
1088 #[test]
1089 fn tier2_aes_gcm_roundtrip() {
1090 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1091 let handle = ActorHandle(b"aes-user".to_vec());
1092 let dek = make_dek(0x5A);
1093 let env = coord.encrypt(&handle, &dek, make_dek_id(0x21)).unwrap();
1094 assert_eq!(env.aead_kind, AeadKind::Aes256Gcm);
1095 assert!(matches!(env.nonce, NonceBytes::Short12(_)));
1096 let back: ActorHandle = coord.decrypt(&env, &dek).unwrap();
1097 assert_eq!(back, handle);
1098 }
1099
1100 #[cfg(feature = "tier-2-multi-kms")]
1101 #[test]
1102 fn tier2_aes_gcm_siv_roundtrip() {
1103 let coord = CryptoCoordinator::new(AeadKind::Aes256GcmSiv, FixedNonce);
1104 let handle = ActorHandle(b"aes-siv-user".to_vec());
1105 let dek = make_dek(0x7B);
1106 let env = coord.encrypt(&handle, &dek, make_dek_id(0x22)).unwrap();
1107 assert_eq!(env.aead_kind, AeadKind::Aes256GcmSiv);
1108 let back: ActorHandle = coord.decrypt(&env, &dek).unwrap();
1109 assert_eq!(back, handle);
1110 }
1111
1112 #[cfg(feature = "tier-2-multi-kms")]
1117 #[test]
1118 fn long_lived_dek_rejected_under_plain_aes_gcm() {
1119 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1120 let dek = Dek::from_unwrapped([0x9C; 32], DekConfig::default());
1121 assert_eq!(dek.lifecycle(), DekLifecycle::LongLived);
1122 let handle = ActorHandle(b"long-lived".to_vec());
1123 let out = coord.encrypt(&handle, &dek, make_dek_id(0x31));
1124 assert!(
1125 matches!(out, Err(PiiError::NonceReuseRisk)),
1126 "long-lived DEK must be refused under plain AES-256-GCM",
1127 );
1128 }
1129
1130 #[cfg(feature = "tier-2-multi-kms")]
1137 #[test]
1138 fn long_lived_dek_under_siv_survives_counter_reset() {
1139 let coord = CryptoCoordinator::new(AeadKind::Aes256GcmSiv, FixedNonce);
1140 let handle = ActorHandle(b"siv-survivor".to_vec());
1141
1142 let dek1 = Dek::from_unwrapped([0xA1; 32], DekConfig::default());
1144 let env1 = coord.encrypt(&handle, &dek1, make_dek_id(0x41)).unwrap();
1145 assert_eq!(env1.aead_kind, AeadKind::Aes256GcmSiv);
1146
1147 let dek2 = Dek::from_unwrapped([0xA1; 32], DekConfig::default());
1149 let env2 = coord.encrypt(&handle, &dek2, make_dek_id(0x41)).unwrap();
1150
1151 let NonceBytes::Short12(n1) = &env1.nonce else {
1152 panic!("SIV returns Short12");
1153 };
1154 let NonceBytes::Short12(n2) = &env2.nonce else {
1155 panic!("SIV returns Short12");
1156 };
1157 assert_eq!(n1, n2, "counter reset reproduces the same nonce");
1158 assert_eq!(
1162 env1.ciphertext, env2.ciphertext,
1163 "SIV is deterministic; reuse is non-catastrophic",
1164 );
1165 assert_eq!(coord.decrypt::<ActorHandle>(&env1, &dek2).unwrap(), handle);
1166 assert_eq!(coord.decrypt::<ActorHandle>(&env2, &dek1).unwrap(), handle);
1167 }
1168
1169 #[cfg(feature = "tier-2-multi-kms")]
1170 #[test]
1171 fn aes_gcm_nonce_is_deterministic_counter() {
1172 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1176 let dek = make_dek(0x5A);
1177 let handle = ActorHandle(b"alice".to_vec());
1178
1179 let env1 = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1180 let env2 = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap();
1181
1182 let NonceBytes::Short12(n1) = &env1.nonce else {
1183 panic!("AES-GCM always returns Short12");
1184 };
1185 let NonceBytes::Short12(n2) = &env2.nonce else {
1186 panic!("AES-GCM always returns Short12");
1187 };
1188 assert_eq!(&n1[0..4], &[0u8; 4], "invocation field zeros");
1189 assert_eq!(&n1[4..12], &0u64.to_be_bytes());
1190 assert_eq!(&n2[4..12], &1u64.to_be_bytes());
1191 assert_ne!(n1, n2);
1192
1193 assert_eq!(coord.decrypt::<ActorHandle>(&env1, &dek).unwrap(), handle);
1195 assert_eq!(coord.decrypt::<ActorHandle>(&env2, &dek).unwrap(), handle);
1196 }
1197
1198 #[cfg(feature = "tier-2-multi-kms")]
1199 #[test]
1200 fn aes_gcm_nonce_honours_dek_replica_id() {
1201 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1206 let dek_a = Dek::with_config([0xC3; 32], DekConfig { replica_id: 0 });
1207 let dek_b = Dek::with_config(
1208 [0xC3; 32],
1209 DekConfig {
1210 replica_id: 0xDEAD_BEEF,
1211 },
1212 );
1213 let handle = ActorHandle(b"alice".to_vec());
1214
1215 let env_a = coord.encrypt(&handle, &dek_a, make_dek_id(0x11)).unwrap();
1216 let env_b = coord.encrypt(&handle, &dek_b, make_dek_id(0x11)).unwrap();
1217
1218 let NonceBytes::Short12(na) = &env_a.nonce else {
1219 panic!("AES-GCM returns Short12");
1220 };
1221 let NonceBytes::Short12(nb) = &env_b.nonce else {
1222 panic!("AES-GCM returns Short12");
1223 };
1224 assert_eq!(&na[0..4], &0u32.to_be_bytes());
1225 assert_eq!(&nb[0..4], &0xDEAD_BEEFu32.to_be_bytes());
1226 assert_eq!(&na[4..12], &0u64.to_be_bytes());
1229 assert_eq!(&nb[4..12], &0u64.to_be_bytes());
1230 assert_ne!(na, nb);
1231 }
1232
1233 #[cfg(feature = "tier-2-multi-kms")]
1234 #[test]
1235 fn aes_gcm_siv_nonce_is_deterministic_counter() {
1236 let coord = CryptoCoordinator::new(AeadKind::Aes256GcmSiv, FixedNonce);
1239 let dek = make_dek(0x7B);
1240 let handle = ActorHandle(b"siv".to_vec());
1241
1242 let env1 = coord.encrypt(&handle, &dek, make_dek_id(0x22)).unwrap();
1243 let NonceBytes::Short12(n1) = &env1.nonce else {
1244 panic!("AES-GCM-SIV returns Short12");
1245 };
1246 assert_eq!(&n1[4..12], &0u64.to_be_bytes());
1247 assert_eq!(dek.get_counter_for_test(), 1);
1248 }
1249
1250 #[cfg(feature = "tier-2-multi-kms")]
1251 #[test]
1252 fn dek_counter_exhaustion_errors() {
1253 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1256 let dek = make_dek(0xA5);
1257 dek.set_counter_for_test(u64::MAX);
1258
1259 let handle = ActorHandle(b"alice".to_vec());
1260 let err = coord.encrypt(&handle, &dek, make_dek_id(0x11)).unwrap_err();
1261 assert!(matches!(err, PiiError::DekExhausted));
1262 assert_eq!(dek.get_counter_for_test(), u64::MAX);
1264 }
1265
1266 #[cfg(feature = "tier-2-multi-kms")]
1267 #[test]
1268 fn rotate_dek_starts_new_counter_from_zero() {
1269 let coord = CryptoCoordinator::new(AeadKind::Aes256Gcm, FixedNonce);
1273 let old = make_dek(0x10);
1274 let new = make_dek(0x20);
1275 let new_id = make_dek_id(0x02);
1276
1277 let plaintexts: Vec<ActorHandle> = (0..3u8).map(|i| ActorHandle(vec![i; 8])).collect();
1278 let mut envs: Vec<EncryptedPii<ActorHandle>> = plaintexts
1279 .iter()
1280 .map(|pt| coord.encrypt(pt, &old, make_dek_id(0x01)).unwrap())
1281 .collect();
1282 assert_eq!(old.get_counter_for_test(), 3);
1283 assert_eq!(new.get_counter_for_test(), 0);
1284
1285 let mut rotation_metric = DekMessageCounter::new(new_id);
1286 rotate_dek(&coord, &old, &new, new_id, &mut envs, &mut rotation_metric).unwrap();
1287
1288 assert_eq!(new.get_counter_for_test(), 3);
1289 assert_eq!(rotation_metric.count(), 3);
1290 for (i, env) in envs.iter().enumerate() {
1291 let NonceBytes::Short12(n) = &env.nonce else {
1292 panic!("AES-GCM returns Short12");
1293 };
1294 assert_eq!(
1295 &n[4..12],
1296 &(i as u64).to_be_bytes(),
1297 "counter values run 0,1,2 under new DEK"
1298 );
1299 }
1300 }
1301
1302 #[cfg(feature = "tier-1-kms")]
1303 #[test]
1304 fn dek_rotate_preserves_plaintext() {
1305 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1306 let old = make_dek(0x10);
1307 let new = make_dek(0x20);
1308 let new_id = make_dek_id(0x02);
1309 let plaintexts: Vec<ActorHandle> = (0..4u8).map(|i| ActorHandle(vec![i; 8])).collect();
1310 let mut envelopes: Vec<EncryptedPii<ActorHandle>> = plaintexts
1311 .iter()
1312 .map(|pt| coord.encrypt(pt, &old, make_dek_id(0x01)).unwrap())
1313 .collect();
1314 let mut counter = DekMessageCounter::new(make_dek_id(0x02));
1315 rotate_dek(&coord, &old, &new, new_id, &mut envelopes, &mut counter).unwrap();
1316 assert_eq!(counter.count(), 4);
1317 for (env, pt) in envelopes.iter().zip(plaintexts.iter()) {
1318 assert_eq!(env.dek_id, new_id);
1319 assert_eq!(&coord.decrypt::<ActorHandle>(env, &new).unwrap(), pt);
1320 }
1321 }
1322
1323 #[cfg(feature = "tier-1-kms")]
1324 #[test]
1325 fn dek_rotate_with_wrong_old_key_rolls_back() {
1326 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1327 let real_old = make_dek(0x10);
1328 let wrong_old = make_dek(0xFF);
1329 let new = make_dek(0x20);
1330 let original_envelope = coord
1331 .encrypt(
1332 &ActorHandle(b"alice".to_vec()),
1333 &real_old,
1334 make_dek_id(0x01),
1335 )
1336 .unwrap();
1337 let mut envelopes = vec![original_envelope.clone()];
1338 let mut counter = DekMessageCounter::new(make_dek_id(0x02));
1339 let err = rotate_dek(
1340 &coord,
1341 &wrong_old,
1342 &new,
1343 make_dek_id(0x02),
1344 &mut envelopes,
1345 &mut counter,
1346 )
1347 .unwrap_err();
1348 assert!(matches!(err, PiiError::AadMismatch));
1349 assert_eq!(envelopes[0], original_envelope);
1351 }
1352
1353 #[cfg(feature = "tier-1-kms")]
1360 #[test]
1361 fn dek_rotate_partial_failure_rolls_back_counter() {
1362 let coord = CryptoCoordinator::new(AeadKind::XChaCha20Poly1305, FixedNonce);
1363 let old = make_dek(0x10);
1364 let new = make_dek(0x20);
1365 let new_id = make_dek_id(0x02);
1366
1367 let good = coord
1368 .encrypt(&ActorHandle(b"first".to_vec()), &old, make_dek_id(0x01))
1369 .unwrap();
1370 let mut corrupt_ct = good.ciphertext.to_vec();
1373 corrupt_ct[0] ^= 0xFF;
1374 let corrupt = EncryptedPii::<ActorHandle>::new(
1375 good.dek_id,
1376 good.aead_kind,
1377 good.nonce.clone(),
1378 Bytes::from(corrupt_ct),
1379 );
1380 let mut envelopes = vec![good.clone(), corrupt.clone()];
1381
1382 let mut counter = DekMessageCounter::new(new_id);
1385 counter.record_message();
1386 counter.record_message();
1387 let entry_count = counter.count();
1388 assert_eq!(entry_count, 2);
1389
1390 let err = rotate_dek(&coord, &old, &new, new_id, &mut envelopes, &mut counter).unwrap_err();
1391 assert!(matches!(err, PiiError::AadMismatch));
1392 assert_eq!(envelopes[0], good);
1394 assert_eq!(envelopes[1], corrupt);
1395 assert_eq!(counter.count(), entry_count);
1397 }
1398}