Skip to main content

arkhe_forge_platform/
crypto.rs

1//! Crypto-erasure coordinator — Tier-1+ AEAD envelope encryption.
2//!
3//! Provides:
4//!
5//! * [`Dek`] — 32-byte key material with zeroise-on-drop semantics. The
6//!   runtime never handles wrapped key material; that lives in the
7//!   HSM/KMS backend.
8//! * [`EncryptedPii`] — generic wrapper over an opaque ciphertext + tag +
9//!   nonce + `DekId` + `AeadKind`. The wire tag binds the PII marker
10//!   (`T::PII_CODE`) via AAD, defeating the type-confused-deputy path.
11//! * [`CryptoCoordinator`] — stateful entry-point that dispatches
12//!   `encrypt` / `decrypt` by the shell manifest's declared `AeadKind`
13//!   and compliance tier. Under the default (Tier-0) feature set the
14//!   coordinator refuses encryption with [`PiiError::TierTooLow`].
15//! * [`rotate_dek`] — slice-level DEK rotation helper. Callers must hold
16//!   a single-writer lock; the helper is atomic per-element and rolls
17//!   the whole slice back on the first failure.
18//!
19//! Feature matrix:
20//!
21//! | Feature                | `XChaCha20-Poly1305` | `AES-256-GCM` | `AES-256-GCM-SIV` |
22//! |------------------------|----------------------|---------------|-------------------|
23//! | *(default — Tier-0)*   | rejected             | rejected      | rejected          |
24//! | `tier-1-kms`           | ✓                    | rejected      | rejected          |
25//! | `tier-2-multi-kms`     | ✓                    | ✓             | ✓                 |
26//!
27//! The coordinator's public surface is stable. HSM / KMS wrap-unwrap
28//! integration and the Sigstore transparency anchor route through `hf2_kms`.
29
30use 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// ===================== Dek =====================
40
41/// Construction-time configuration for a [`Dek`]. Single-writer
42/// deployments use the default (all fields zero); federation builds
43/// populate `replica_id` from the per-instance manifest anchor so two
44/// regions sharing the same DEK material cannot collide their
45/// deterministic nonces (the F6 invocation-field reservation).
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub struct DekConfig {
48    /// 4-byte invocation field for the AES-GCM(-SIV) nonce. Must be
49    /// instance-pinned immutable (L0 A11 pure compute); Runtime
50    /// reconfig **must not** mutate this value without a full
51    /// RuntimeBootstrap re-emit and DEK rotation cycle.
52    pub replica_id: u32,
53}
54
55/// DEK lifetime relative to the process. The distinction is a nonce-reuse
56/// safety boundary for the deterministic counter nonce:
57///
58/// * [`DekLifecycle::Ephemeral`] — the `Dek` lives for at most the current
59///   process; the in-memory counter is authoritative for its whole life, so
60///   the deterministic counter never repeats and plain AES-256-GCM is safe.
61/// * [`DekLifecycle::LongLived`] — the `Dek` was reconstructed from durable
62///   wrapped material (a KMS `unwrap_dek`) and may outlive the process. The
63///   counter resets to `0` on every reconstruction, so plain AES-256-GCM
64///   would reuse nonces under a fixed key (catastrophic). Such DEKs are
65///   admitted only under the nonce-misuse-resistant `Aes256GcmSiv`.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum DekLifecycle {
68    /// Process-bound; counter is authoritative for the key's whole life.
69    Ephemeral,
70    /// Reconstructed from durable wrapped material; counter resets on each
71    /// reconstruction, so plain AES-256-GCM is unsafe (must use SIV).
72    LongLived,
73}
74
75/// Per-user 32-byte DEK material. The byte buffer is
76/// wiped on `Drop` via the `zeroize` crate; callers obtain a `Dek` from
77/// an HSM unwrap — the runtime never derives key material directly
78/// (envelope encryption).
79///
80/// A per-DEK monotonic counter drives the deterministic 96-bit nonce
81/// for AES-GCM / AES-GCM-SIV under the NIST SP 800-38D §8.2.1
82/// construction (4-byte invocation field = [`DekConfig::replica_id`] +
83/// 8-byte counter, big-endian). XChaCha20-Poly1305 uses a 192-bit
84/// random nonce and leaves the counter alone. Counter exhaustion at
85/// `u64::MAX` surfaces [`PiiError::DekExhausted`] so the operator
86/// rotates the DEK before any nonce reuse.
87///
88/// `replica_id` is held by the DEK itself and is **immutable after
89/// construction** — changing it post-hoc would violate L0 A11 pure
90/// compute (replay determinism depends on stable nonce bytes). Default
91/// single-writer deployments use `replica_id = 0`; federation builds
92/// populate a per-replica id via [`Dek::with_config`].
93///
94/// `Dek` is intentionally **not** `Clone` — two copies of the same key
95/// material with independent counters would collide their nonces under
96/// AES-GCM (catastrophic integrity loss). Callers must hold a single
97/// owner per key; rotation yields a fresh `Dek` via [`Dek::from_bytes`]
98/// whose counter starts at `0`.
99///
100/// The interior `Cell<u64>` counter makes `Dek` implicitly `!Sync`, so
101/// the compiler refuses naive cross-thread sharing. If a deployment
102/// genuinely needs to share a DEK across task boundaries (e.g., an
103/// L2 async runtime feeding multiple encrypt handlers), wrap it in
104/// `Arc<Mutex<Dek>>` or `Arc<parking_lot::Mutex<Dek>>` at the caller
105/// site — the mutex guards the counter advance and keeps the
106/// deterministic-nonce invariant intact. Single-writer deployments
107/// (L0 A2 single-thread) hold a plain owned `Dek` without
108/// synchronisation.
109#[derive(Zeroize, ZeroizeOnDrop)]
110pub struct Dek {
111    material: [u8; 32],
112    /// Monotonic message counter used by the AES-GCM(-SIV) nonce
113    /// construction. Not sensitive; skipped by zeroize.
114    #[zeroize(skip)]
115    counter: Cell<u64>,
116    /// Invocation field for the nonce construction. Immutable after
117    /// construction — see [`DekConfig::replica_id`].
118    #[zeroize(skip)]
119    replica_id: u32,
120    /// Lifetime classification — gates the AES-256-GCM nonce-reuse guard.
121    /// Not sensitive; skipped by zeroize.
122    #[zeroize(skip)]
123    lifecycle: DekLifecycle,
124}
125
126impl Dek {
127    /// Construct from a 32-byte buffer using default configuration
128    /// (`replica_id = 0`, single-writer). The input is copied; callers
129    /// remain responsible for wiping their own buffer. Counter starts
130    /// at `0`.
131    #[inline]
132    #[must_use]
133    pub fn from_bytes(material: [u8; 32]) -> Self {
134        Self::with_config(material, DekConfig::default())
135    }
136
137    /// Construct from a 32-byte buffer with an explicit [`DekConfig`].
138    /// Federation path consumes this entry point with a non-zero
139    /// `replica_id`; single-writer deployments use [`Dek::from_bytes`].
140    /// Lifecycle is [`DekLifecycle::Ephemeral`] — process-bound.
141    #[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    /// Construct a [`DekLifecycle::LongLived`] DEK from durable wrapped
153    /// material — the KMS `unwrap_dek` entry point. Because the counter
154    /// resets to `0` on every reconstruction, a long-lived DEK is admitted
155    /// only under `AeadKind::Aes256GcmSiv`; the AES-256-GCM encrypt path
156    /// rejects it with [`PiiError::NonceReuseRisk`].
157    #[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    /// Lifetime classification — see [`DekLifecycle`].
169    #[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    /// Construct from a byte slice. Rejects the call with
177    /// [`PiiError::InvalidKeyLength`] when `bytes.len() != 32`.
178    /// Counter starts at `0` with default [`DekConfig`]; lifecycle is
179    /// [`DekLifecycle::Ephemeral`].
180    ///
181    /// The length check is a single `usize` compare against the
182    /// constant `32` — no byte-by-byte value comparison happens on
183    /// the reject path, so there is no timing side-channel the
184    /// `subtle` crate would mitigate. `copy_from_slice` runs in
185    /// time dependent on the buffer length, not its contents.
186    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    /// Borrow the underlying 32-byte key. Intentionally crate-visible so
201    /// downstream crypto primitives can feed the AEAD `Key` type without
202    /// leaking the buffer through the public surface.
203    #[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    /// Return the current counter value and advance to the next. Used
214    /// by the AES-GCM(-SIV) encrypt paths to produce deterministic
215    /// nonces. Returns [`PiiError::DekExhausted`] when the counter has
216    /// already reached `u64::MAX` — a rotation is required before any
217    /// further encryption.
218    #[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    /// Test-only accessor — set the counter to an arbitrary value to
229    /// exercise exhaustion and rotation paths.
230    #[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    /// Test-only accessor — read the current counter.
236    #[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/// NIST SP 800-38D §8.2.1 deterministic nonce: 4-byte invocation
243/// field (`replica_id`) + 8-byte big-endian counter. The 96-bit
244/// layout is the native nonce size for both AES-GCM and AES-GCM-SIV.
245///
246/// `replica_id = 0` is the single-writer default. Federation builds
247/// supply a per-replica id via [`DekConfig::replica_id`] so two
248/// regions sharing the same DEK material cannot collide their
249/// counters. The value is captured at `Dek` construction and is
250/// immutable for the lifetime of the key (L0 A11 pure compute);
251/// changing it later would surface in the WAL as different ciphertext
252/// bytes and therefore must ship behind a manifest-anchored policy
253/// event when the federation activation lands.
254#[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        // Never print key material.
266        f.debug_struct("Dek").finish_non_exhaustive()
267    }
268}
269
270// ===================== EncryptedPii =====================
271
272/// Per-AEAD-kind nonce carrier — XChaCha20-Poly1305 uses 24 bytes, the
273/// AES-256-GCM family uses 12. A single variant enum keeps the on-wire
274/// layout round-trip stable under postcard. Variants use postcard's
275/// default untagged discriminant (enum index byte).
276#[non_exhaustive]
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub enum NonceBytes {
279    /// 24-byte nonce — `XChaCha20-Poly1305`.
280    X24([u8; 24]),
281    /// 12-byte nonce — `AES-256-GCM` / `AES-256-GCM-SIV`.
282    Short12([u8; 12]),
283}
284
285impl NonceBytes {
286    /// Expected length for the given `AeadKind`. Forward-compat unknown
287    /// variants map to `0` so callers can catch the mismatch as
288    /// [`PiiError::UnsupportedAead`].
289    #[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    /// Returns the nonce bytes as a slice regardless of variant.
300    #[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/// Per-PII-marker ciphertext envelope.
311///
312/// The wire shape is
313/// `(dek_id, pii_code, aead_kind, nonce, ciphertext_with_tag)` —
314/// every input to the AEAD AAD is mirrored on the envelope so the
315/// receiver can recompute the 19-byte AAD exactly. `ciphertext`
316/// includes the 16-byte Poly1305 / GCM tag appended by the AEAD
317/// primitive.
318///
319/// The generic parameter `T` is a *phantom* — the wire layout is purely
320/// data-bearing, and a manual (de)serialize impl threads around the
321/// `PhantomData` so postcard can round-trip the struct.
322#[derive(Debug, PartialEq, Eq)]
323pub struct EncryptedPii<T: PiiType> {
324    /// HSM/KMS key reference.
325    pub dek_id: DekId,
326    /// Wire tag. Validated against `T::PII_CODE` at
327    /// decrypt time.
328    pub pii_code: u16,
329    /// AEAD family used for the ciphertext.
330    pub aead_kind: AeadKind,
331    /// Nonce — length varies per AEAD kind.
332    pub nonce: NonceBytes,
333    /// Ciphertext with the 16-byte AEAD tag appended.
334    pub ciphertext: Bytes,
335    pub(crate) _marker: PhantomData<fn() -> T>,
336}
337
338// Manual Clone — `T` carries no data on the envelope (the PhantomData is
339// `fn() -> T` for variance), so cloning never calls into `T::clone`.
340impl<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/// Data-only mirror of [`EncryptedPii`] — used as the serde surface so
354/// the generic phantom does not reach the wire.
355#[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    /// Construct from components — intended for deserialization paths /
393    /// tests that need to reassemble a postcard-decoded envelope into
394    /// its typed form. Encryption path callers use
395    /// [`CryptoCoordinator::encrypt`].
396    #[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// ===================== CryptoCoordinator =====================
411
412/// Tier-1+ AEAD coordinator.
413///
414/// The coordinator carries the shell-declared `AeadKind` (from
415/// `[audit.pii_cipher]`) plus an opaque `nonce_source` the caller
416/// supplies so tests and production paths can share the same
417/// dispatcher. Tier-0 (default feature set) consumers may still
418/// instantiate a coordinator — every mutating call returns
419/// [`PiiError::TierTooLow`].
420#[derive(Debug)]
421pub struct CryptoCoordinator<N: NonceSource = OsNonceSource> {
422    manifest_cipher: AeadKind,
423    // Only consumed by the XChaCha20-Poly1305 (tier-1-kms) path — the
424    // AES-GCM(-SIV) paths use a DEK-local deterministic counter (NIST
425    // SP 800-38D §8.2.1) rather than the nonce source.
426    #[cfg_attr(not(feature = "tier-1-kms"), allow(dead_code))]
427    nonce_source: N,
428}
429
430/// Per-kind nonce generator. Production uses [`OsNonceSource`] (the
431/// `getrandom` syscall wrapper baked into the AEAD crates). Tests plug
432/// in a fixed value for bit-identical fixtures.
433pub trait NonceSource {
434    /// Fill `out` with `len` fresh nonce bytes. `len` matches
435    /// [`NonceBytes::expected_len`] for the active AEAD kind; panics /
436    /// short writes are illegal — return an error via the caller's
437    /// wrapper if needed.
438    fn fill(&self, out: &mut [u8]);
439}
440
441/// OS-backed nonce source. On `tier-1-kms` / `tier-2-multi-kms` this
442/// pulls from the underlying AEAD crate's default RNG hook (itself
443/// `getrandom`-backed). On the default feature set it is still
444/// constructible — encryption paths reject before the nonce source is
445/// consulted.
446#[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        // No crypto feature active — zero-fill. Encrypt paths reject
459        // before the nonce is read, so the value is never observed.
460        for byte in out.iter_mut() {
461            *byte = 0;
462        }
463    }
464}
465
466impl<N: NonceSource> CryptoCoordinator<N> {
467    /// Construct a coordinator that enforces `manifest_cipher` at the
468    /// encrypt / decrypt boundary.
469    #[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    /// Borrow the manifest-declared cipher.
479    #[inline]
480    #[must_use]
481    pub fn manifest_cipher(&self) -> AeadKind {
482        self.manifest_cipher
483    }
484
485    /// AEAD-encrypt a PII payload under the given DEK. The 19-byte AAD
486    /// is computed from `(dek_id, T::PII_CODE, manifest_cipher)` so a
487    /// downstream decrypt call with a tampered envelope fails the tag
488    /// check.
489    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        // Zeroizing: the serialized plaintext is PII — scrub the heap
497        // buffer when it drops rather than leaving residue on freed pages.
498        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    /// Inverse of [`CryptoCoordinator::encrypt`]. Checks the wire PII
510    /// marker, refuses cipher downgrades, then verifies the AEAD tag
511    /// against a recomputed AAD.
512    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    /// Variant of [`CryptoCoordinator::decrypt`] that tolerates a
535    /// legacy `AeadKind` — used by the DEK rotation path so a slice of
536    /// ciphertexts written under an older manifest `pii_cipher` can be
537    /// migrated in place. Borrows the envelope directly (no per-element
538    /// clone); the manifest downgrade check is bypassed (equivalence
539    /// relation anchored to the envelope's own `aead_kind`).
540    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    /// Decrypted output is PII plaintext — returned in a [`Zeroizing`]
570    /// wrapper so every internal consumer scrubs the heap buffer on drop.
571    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    // ----- XChaCha20-Poly1305 — tier-1-kms gated -----
589
590    #[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    // ----- AES-256-GCM — tier-2-multi-kms gated -----
668
669    #[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        // Nonce-reuse guard — a long-lived (KMS-unwrapped) DEK resets its
680        // counter to 0 on every reconstruction, so plain AES-256-GCM would
681        // reuse nonces under a fixed key (catastrophic). Reject; such DEKs
682        // must use the misuse-resistant SIV path.
683        if dek.lifecycle() == DekLifecycle::LongLived {
684            return Err(PiiError::NonceReuseRisk);
685        }
686
687        // Deterministic counter nonce (NIST SP 800-38D §8.2.1). Counter
688        // exhaustion is surfaced by `advance_counter` before any AEAD
689        // work starts, so a failed call never consumes a nonce value.
690        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    // ----- AES-256-GCM-SIV — tier-2-multi-kms gated -----
758
759    #[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        // Deterministic counter nonce — AES-GCM-SIV already tolerates
770        // nonce reuse but the counter construction removes the chance
771        // entirely under single-writer (L0 A2) semantics.
772        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
840// ===================== DEK rotation =====================
841
842/// Re-wrap every element of `ciphertexts` under `new_dek` using a fresh
843/// `new_dek_id`. Decrypts under `old_dek` first, re-encrypts under the
844/// new key material, and rolls the slice back if any element fails
845/// (atomic-per-call semantics).
846///
847/// Callers must hold a single-writer lock across the slice while this
848/// helper runs — the coordinator does not expose its own synchronisation.
849/// The update also ticks `counter` (by the slice length) so the operator
850/// can observe the per-DEK rotation metric. On a partial-failure rollback
851/// the `counter` is restored to its entry value alongside the ciphertexts,
852/// so the metric always matches the slice state.
853pub 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    // Snapshot the rotation metric so a rollback restores it in lockstep
863    // with the ciphertexts — otherwise the metric would over-count the
864    // elements re-encrypted before the failing one.
865    let counter_snapshot = counter.clone();
866    let aad = compute_aad(&new_dek_id, T::PII_CODE, coordinator.manifest_cipher);
867    // Index-based loop so the rollback can re-borrow the slice mutably
868    // without conflicting with a held element borrow.
869    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/// Helper — extract the rotation trigger for a post-rotation counter.
896#[inline]
897#[must_use]
898pub fn rotation_advice(counter: &DekMessageCounter) -> RotationTrigger {
899    counter.rotation_trigger()
900}
901
902// ===================== Tests =====================
903
904#[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    // --- Default (Tier-0) — encryption is rejected across the board. ---
978
979    #[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    // --- tier-1-kms — XChaCha20-Poly1305 round trip. ---
994
995    #[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        // Tamper with the dek_id on the envelope — AAD recompute changes,
1017        // tag verification must fail.
1018        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        // Overwrite pii_code with a different marker. decrypt::<ActorHandle>
1048        // spots the mismatch without touching AEAD.
1049        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        // Coordinator is pinned to XChaCha20-Poly1305; envelope is written
1065        // with AES-GCM. decrypt must refuse.
1066        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        // Coordinator is pinned to AES-GCM; under tier-1 only, encrypt
1083        // must surface UnsupportedAead.
1084        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    /// #3 regression — a long-lived (KMS-unwrapped) DEK MUST be refused on
1119    /// the plain AES-256-GCM path. The deterministic counter resets to 0 on
1120    /// every reconstruction, so plain GCM would reuse nonces under a fixed
1121    /// key. The guard rejects with `NonceReuseRisk`.
1122    #[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    /// #3 regression — under SIV, a long-lived DEK reconstructed twice (each
1137    /// reconstruction resets the counter to 0, so both encrypts use the SAME
1138    /// nonce) does NOT catastrophically leak: SIV is nonce-misuse-resistant,
1139    /// so identical (key, nonce, aad, plaintext) yields identical ciphertext
1140    /// (deterministic, safe) and each round-trips correctly. This is the
1141    /// smallest cure that keeps deterministic replay intact.
1142    #[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        // First process incarnation.
1149        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        // Second incarnation — same material, counter reset to 0 → SAME nonce.
1154        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        // SIV determinism: same (key, nonce, aad, plaintext) → same ciphertext.
1165        // Under plain GCM this nonce reuse would be catastrophic; under SIV it
1166        // is merely a deterministic re-encryption that still round-trips.
1167        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        // First two encrypts under the same DEK must produce nonces 0
1179        // and 1 in the 8-byte big-endian counter tail; the 4-byte
1180        // invocation field is zero for the single-writer deployment.
1181        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        // Round-trip still holds under the new construction.
1200        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        // Federation path — a non-zero `replica_id` must appear as the
1208        // 4-byte invocation field prefix of the nonce. Two DEKs with
1209        // identical material but distinct replica ids produce
1210        // disjoint nonce spaces (0-counter slot differs).
1211        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        // Counter portion is the same (both at position 0) but the
1233        // full nonce differs because of the replica id prefix.
1234        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        // SIV path also consumes the DEK counter — defence-in-depth
1243        // despite AES-GCM-SIV's own nonce-reuse resistance.
1244        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        // Counter at u64::MAX rejects further encryption — no nonce is
1260        // consumed, operator must rotate.
1261        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        // Counter unchanged — failed call did not advance.
1269        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        // `rotate_dek` decrypts under the old DEK (no counter use) and
1276        // re-encrypts under the new DEK (counter advances 0..N). After
1277        // rotating N elements the new DEK's counter reflects exactly N.
1278        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        // Slice rolled back — original envelope intact.
1356        assert_eq!(envelopes[0], original_envelope);
1357    }
1358
1359    /// #9 regression — a partial-failure rollback must restore the
1360    /// `DekMessageCounter` to its entry value, not leave it counting the
1361    /// elements re-encrypted before the failing one. Element 0 rotates
1362    /// (counter would tick to 1), element 1 has a corrupted ciphertext so
1363    /// its decrypt fails; the rollback must restore both the slice AND the
1364    /// counter so the metric matches the rolled-back (untouched) state.
1365    #[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        // Corrupt a copy so its decrypt under `old` fails the tag check —
1377        // forces a rollback after element 0 has already been processed.
1378        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        // Pre-tick the counter so we can prove the snapshot is the *entry*
1389        // value, not a fresh zero.
1390        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        // Ciphertexts rolled back.
1399        assert_eq!(envelopes[0], good);
1400        assert_eq!(envelopes[1], corrupt);
1401        // Counter restored to its entry value — element 0's tick is undone.
1402        assert_eq!(counter.count(), entry_count);
1403    }
1404}