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};
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        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    /// Inverse of [`CryptoCoordinator::encrypt`]. Checks the wire PII
507    /// marker, refuses cipher downgrades, then verifies the AEAD tag
508    /// against a recomputed AAD.
509    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    /// Variant of [`CryptoCoordinator::decrypt`] that tolerates a
532    /// legacy `AeadKind` — used by the DEK rotation path so a slice of
533    /// ciphertexts written under an older manifest `pii_cipher` can be
534    /// migrated in place. Borrows the envelope directly (no per-element
535    /// clone); the manifest downgrade check is bypassed (equivalence
536    /// relation anchored to the envelope's own `aead_kind`).
537    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    // ----- XChaCha20-Poly1305 — tier-1-kms gated -----
583
584    #[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    // ----- AES-256-GCM — tier-2-multi-kms gated -----
662
663    #[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        // Nonce-reuse guard — a long-lived (KMS-unwrapped) DEK resets its
674        // counter to 0 on every reconstruction, so plain AES-256-GCM would
675        // reuse nonces under a fixed key (catastrophic). Reject; such DEKs
676        // must use the misuse-resistant SIV path.
677        if dek.lifecycle() == DekLifecycle::LongLived {
678            return Err(PiiError::NonceReuseRisk);
679        }
680
681        // Deterministic counter nonce (NIST SP 800-38D §8.2.1). Counter
682        // exhaustion is surfaced by `advance_counter` before any AEAD
683        // work starts, so a failed call never consumes a nonce value.
684        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    // ----- AES-256-GCM-SIV — tier-2-multi-kms gated -----
752
753    #[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        // Deterministic counter nonce — AES-GCM-SIV already tolerates
764        // nonce reuse but the counter construction removes the chance
765        // entirely under single-writer (L0 A2) semantics.
766        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
834// ===================== DEK rotation =====================
835
836/// Re-wrap every element of `ciphertexts` under `new_dek` using a fresh
837/// `new_dek_id`. Decrypts under `old_dek` first, re-encrypts under the
838/// new key material, and rolls the slice back if any element fails
839/// (atomic-per-call semantics).
840///
841/// Callers must hold a single-writer lock across the slice while this
842/// helper runs — the coordinator does not expose its own synchronisation.
843/// The update also ticks `counter` (by the slice length) so the operator
844/// can observe the per-DEK rotation metric. On a partial-failure rollback
845/// the `counter` is restored to its entry value alongside the ciphertexts,
846/// so the metric always matches the slice state.
847pub 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    // Snapshot the rotation metric so a rollback restores it in lockstep
857    // with the ciphertexts — otherwise the metric would over-count the
858    // elements re-encrypted before the failing one.
859    let counter_snapshot = counter.clone();
860    let aad = compute_aad(&new_dek_id, T::PII_CODE, coordinator.manifest_cipher);
861    // Index-based loop so the rollback can re-borrow the slice mutably
862    // without conflicting with a held element borrow.
863    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/// Helper — extract the rotation trigger for a post-rotation counter.
890#[inline]
891#[must_use]
892pub fn rotation_advice(counter: &DekMessageCounter) -> RotationTrigger {
893    counter.rotation_trigger()
894}
895
896// ===================== Tests =====================
897
898#[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    // --- Default (Tier-0) — encryption is rejected across the board. ---
972
973    #[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    // --- tier-1-kms — XChaCha20-Poly1305 round trip. ---
988
989    #[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        // Tamper with the dek_id on the envelope — AAD recompute changes,
1011        // tag verification must fail.
1012        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        // Overwrite pii_code with a different marker. decrypt::<ActorHandle>
1042        // spots the mismatch without touching AEAD.
1043        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        // Coordinator is pinned to XChaCha20-Poly1305; envelope is written
1059        // with AES-GCM. decrypt must refuse.
1060        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        // Coordinator is pinned to AES-GCM; under tier-1 only, encrypt
1077        // must surface UnsupportedAead.
1078        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    /// #3 regression — a long-lived (KMS-unwrapped) DEK MUST be refused on
1113    /// the plain AES-256-GCM path. The deterministic counter resets to 0 on
1114    /// every reconstruction, so plain GCM would reuse nonces under a fixed
1115    /// key. The guard rejects with `NonceReuseRisk`.
1116    #[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    /// #3 regression — under SIV, a long-lived DEK reconstructed twice (each
1131    /// reconstruction resets the counter to 0, so both encrypts use the SAME
1132    /// nonce) does NOT catastrophically leak: SIV is nonce-misuse-resistant,
1133    /// so identical (key, nonce, aad, plaintext) yields identical ciphertext
1134    /// (deterministic, safe) and each round-trips correctly. This is the
1135    /// smallest cure that keeps deterministic replay intact.
1136    #[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        // First process incarnation.
1143        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        // Second incarnation — same material, counter reset to 0 → SAME nonce.
1148        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        // SIV determinism: same (key, nonce, aad, plaintext) → same ciphertext.
1159        // Under plain GCM this nonce reuse would be catastrophic; under SIV it
1160        // is merely a deterministic re-encryption that still round-trips.
1161        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        // First two encrypts under the same DEK must produce nonces 0
1173        // and 1 in the 8-byte big-endian counter tail; the 4-byte
1174        // invocation field is zero for the single-writer deployment.
1175        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        // Round-trip still holds under the new construction.
1194        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        // Federation path — a non-zero `replica_id` must appear as the
1202        // 4-byte invocation field prefix of the nonce. Two DEKs with
1203        // identical material but distinct replica ids produce
1204        // disjoint nonce spaces (0-counter slot differs).
1205        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        // Counter portion is the same (both at position 0) but the
1227        // full nonce differs because of the replica id prefix.
1228        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        // SIV path also consumes the DEK counter — defence-in-depth
1237        // despite AES-GCM-SIV's own nonce-reuse resistance.
1238        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        // Counter at u64::MAX rejects further encryption — no nonce is
1254        // consumed, operator must rotate.
1255        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        // Counter unchanged — failed call did not advance.
1263        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        // `rotate_dek` decrypts under the old DEK (no counter use) and
1270        // re-encrypts under the new DEK (counter advances 0..N). After
1271        // rotating N elements the new DEK's counter reflects exactly N.
1272        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        // Slice rolled back — original envelope intact.
1350        assert_eq!(envelopes[0], original_envelope);
1351    }
1352
1353    /// #9 regression — a partial-failure rollback must restore the
1354    /// `DekMessageCounter` to its entry value, not leave it counting the
1355    /// elements re-encrypted before the failing one. Element 0 rotates
1356    /// (counter would tick to 1), element 1 has a corrupted ciphertext so
1357    /// its decrypt fails; the rollback must restore both the slice AND the
1358    /// counter so the metric matches the rolled-back (untouched) state.
1359    #[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        // Corrupt a copy so its decrypt under `old` fails the tag check —
1371        // forces a rollback after element 0 has already been processed.
1372        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        // Pre-tick the counter so we can prove the snapshot is the *entry*
1383        // value, not a fresh zero.
1384        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        // Ciphertexts rolled back.
1393        assert_eq!(envelopes[0], good);
1394        assert_eq!(envelopes[1], corrupt);
1395        // Counter restored to its entry value — element 0's tick is undone.
1396        assert_eq!(counter.count(), entry_count);
1397    }
1398}