Skip to main content

arkhe_forge_core/
pii.rs

1//! PII wire format + AEAD AAD helper + DEK message counter.
2//!
3//! Surface: AAD 19-byte composition for AEAD tag binding, per-DEK message
4//! counter, `compute_body_hash` helper for L2 pre-compute, the
5//! sealed [`PiiType`] trait plus its four canonical marker types
6//! (`ActorHandle`, `EntryBody`, `ActivityExtraBytes`, `AuthCredentialSecret`),
7//! and the [`ShellPiiType`] wrapper carrying shell-registered markers on the
8//! `0x0100..=0xFFFF` range (manifest-hooked, runtime-dispatched).
9//! [`UserSalt`] is the typed per-user anchor fed into [`compute_body_hash`].
10
11use blake3::Hasher;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use zeroize::{Zeroize, ZeroizeOnDrop};
15
16/// DEK identifier — 16-byte reference to an HSM/KMS key. The runtime never
17/// holds plaintext key material.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct DekId(pub [u8; 16]);
21
22/// AEAD algorithm family — serialized as a single discriminant byte (spec
23/// AEAD policy hook).
24///
25/// `XChaCha20Poly1305` is the runtime default — misuse-resistant with a
26/// 192-bit nonce.
27#[non_exhaustive]
28#[repr(u8)]
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum AeadKind {
31    /// 192-bit nonce + Poly1305 tag — misuse-resistant, default.
32    XChaCha20Poly1305 = 0,
33    /// AES-256-GCM — hardware-accelerated; requires deterministic counter nonce.
34    Aes256Gcm = 1,
35    /// AES-256-GCM-SIV (RFC 8452) — nonce-reuse resistance plus hardware acceleration.
36    Aes256GcmSiv = 2,
37}
38
39/// Runtime-canonical `PII_CODE` reservations (`0x0001..=0x00FF`) — spec
40/// Each constant is the wire tag for the corresponding PII
41/// family; shell-scoped codes (`0x0100..=0xFFFF`) are layered on top.
42pub mod pii_code {
43    /// `ActorProfile.handle` — shell-scoped identifier.
44    pub const ACTOR_HANDLE: u16 = 0x0001;
45    /// `EntryBody.body_plaintext` — user content.
46    pub const ENTRY_BODY: u16 = 0x0002;
47    /// `ActivityRecord.extra_bytes` — shell discretion; per-user encryption recommended.
48    pub const ACTIVITY_EXTRA_BYTES: u16 = 0x0003;
49    /// AuthCredential auxiliary secret — stored separately from the KDF salt.
50    pub const AUTH_CREDENTIAL_SECRET: u16 = 0x0004;
51}
52
53// ===================== ShellPiiType =====================
54
55/// Audit / observer sensitivity level for a shell-registered PII type —
56/// Higher levels trigger stricter logging policies
57/// and can bind the type to specific AEAD / Tier requirements.
58#[non_exhaustive]
59#[repr(u8)]
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub enum Sensitivity {
62    /// Analytics-friendly identifier — still encrypted at rest but
63    /// appears in operator dashboards under coarse pseudonymisation.
64    Low = 0,
65    /// Standard PII (e.g., free-form handles, message bodies).
66    Medium = 1,
67    /// Credential-adjacent or regulator-scrutinised material — Tier-2
68    /// compliance is expected; audit trail keeps HSM attestations.
69    High = 2,
70}
71
72/// Shell-registered PII marker living in the `0x0100..=0xFFFF` range
73/// Canonical `0x0001..=0x00FF` codes are reserved
74/// for the sealed [`PiiType`] trait; shell-scoped types flow through
75/// this runtime-dispatched wrapper so manifests can declare additional
76/// PII families without editing the runtime crate.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ShellPiiType {
79    /// Wire tag — must be in the shell-scoped band `0x0100..=0xFFFF`.
80    pii_code: u16,
81    /// Operator / audit sensitivity level.
82    sensitivity: Sensitivity,
83    /// AEAD family the shell has pinned for this PII type. Must match
84    /// the shell manifest `[audit.pii_cipher]` at decrypt time (see
85    /// cipher-downgrade gate).
86    aead_kind: AeadKind,
87}
88
89/// Shell-scoped wire range — `0x0100..=0xFFFF`.
90pub const SHELL_PII_CODE_RANGE: core::ops::RangeInclusive<u16> = 0x0100..=0xFFFF;
91
92impl ShellPiiType {
93    /// Construct a shell PII marker. Returns [`PiiError::ShellPiiCodeOutOfRange`]
94    /// if `pii_code` is outside `0x0100..=0xFFFF`.
95    pub fn new(
96        pii_code: u16,
97        sensitivity: Sensitivity,
98        aead_kind: AeadKind,
99    ) -> Result<Self, PiiError> {
100        if !SHELL_PII_CODE_RANGE.contains(&pii_code) {
101            return Err(PiiError::ShellPiiCodeOutOfRange);
102        }
103        Ok(Self {
104            pii_code,
105            sensitivity,
106            aead_kind,
107        })
108    }
109
110    /// Wire tag in the shell-scoped range.
111    #[inline]
112    #[must_use]
113    pub fn pii_code(&self) -> u16 {
114        self.pii_code
115    }
116
117    /// Audit sensitivity level.
118    #[inline]
119    #[must_use]
120    pub fn sensitivity(&self) -> Sensitivity {
121        self.sensitivity
122    }
123
124    /// Shell-pinned AEAD for this marker.
125    #[inline]
126    #[must_use]
127    pub fn aead_kind(&self) -> AeadKind {
128        self.aead_kind
129    }
130}
131
132/// Runtime-local registry of shell-scoped PII markers. Populated from
133/// the shell manifest at load time; subsequent encrypt / decrypt paths
134/// look up the marker by its wire `pii_code` rather than carrying a
135/// generic type parameter.
136///
137/// Duplicate registrations and out-of-range codes are refused; the
138/// registry is otherwise append-only (sunset flow mirrors the shell
139/// sunset policy and is out of scope for this module).
140#[derive(Debug, Default)]
141pub struct ShellPiiRegistry {
142    entries: BTreeMap<u16, ShellPiiType>,
143}
144
145impl ShellPiiRegistry {
146    /// Empty registry.
147    #[inline]
148    #[must_use]
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Register a shell PII marker. Rejects if `pii_code` is outside
154    /// `0x0100..=0xFFFF` or has already been registered.
155    pub fn register(&mut self, entry: ShellPiiType) -> Result<(), PiiError> {
156        if !SHELL_PII_CODE_RANGE.contains(&entry.pii_code) {
157            return Err(PiiError::ShellPiiCodeOutOfRange);
158        }
159        if self.entries.contains_key(&entry.pii_code) {
160            return Err(PiiError::ShellPiiAlreadyRegistered);
161        }
162        self.entries.insert(entry.pii_code, entry);
163        Ok(())
164    }
165
166    /// Look up a registered marker by its wire tag. Returns `None`
167    /// when the code is unregistered — callers treat that as a shell
168    /// drift and refuse the operation.
169    #[inline]
170    #[must_use]
171    pub fn get(&self, pii_code: u16) -> Option<&ShellPiiType> {
172        self.entries.get(&pii_code)
173    }
174
175    /// Number of registered markers.
176    #[inline]
177    #[must_use]
178    pub fn len(&self) -> usize {
179        self.entries.len()
180    }
181
182    /// Whether the registry is empty.
183    #[inline]
184    #[must_use]
185    pub fn is_empty(&self) -> bool {
186        self.entries.is_empty()
187    }
188}
189
190/// AEAD AAD (Additional Authenticated Data) — **exactly 19 bytes**.
191///
192/// Layout: `dek_id (16) || pii_code.to_be_bytes() (2) || aead_kind as u8 (1)` = 19 B.
193///
194/// Wrap-field tampering causes AEAD tag verification failure → `PiiError::AadMismatch`.
195/// This helper is reused for recomputation on both encrypt and decrypt paths.
196#[must_use]
197pub fn compute_aad(dek_id: &DekId, pii_code: u16, aead_kind: AeadKind) -> [u8; 19] {
198    let mut aad = [0u8; 19];
199    aad[..16].copy_from_slice(&dek_id.0);
200    aad[16..18].copy_from_slice(&pii_code.to_be_bytes());
201    aad[18] = aead_kind as u8;
202    aad
203}
204
205/// Per-user 128-bit salt held by the HSM. Shredding
206/// the salt renders every `body_hash` derived from it pre-image-unsafe
207/// — the crypto-erasure pairing for content hashes.
208///
209/// The buffer is zeroised on drop so in-process handling after an HSM
210/// fetch does not leak material into memory dumps. `UserSalt` is
211/// intentionally not `Clone` to keep a single owner per fetch; callers
212/// borrow or re-fetch from the HSM.
213///
214/// Production HSM backends supply `UserSalt` via the
215/// `KmsBackend::fetch_user_salt(user_id) -> Result<UserSalt, _>` hook
216/// — that path keeps the user erasure
217/// semantics aligned (DEK + salt drop together under a single
218/// `delete_user(user_id)` call).
219#[derive(Zeroize, ZeroizeOnDrop)]
220pub struct UserSalt([u8; 16]);
221
222impl UserSalt {
223    /// Construct from a 16-byte buffer — normally produced by an HSM
224    /// `fetch_user_salt` call. Callers remain responsible for wiping
225    /// their own buffer after handing it off.
226    #[inline]
227    #[must_use]
228    pub fn from_bytes(bytes: [u8; 16]) -> Self {
229        Self(bytes)
230    }
231
232    /// Borrow the salt material — used by [`compute_body_hash`]. The
233    /// returned reference is never persisted; callers feed it straight
234    /// into BLAKE3.
235    #[inline]
236    #[must_use]
237    pub fn as_bytes(&self) -> &[u8; 16] {
238        &self.0
239    }
240}
241
242impl core::fmt::Debug for UserSalt {
243    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
244        f.debug_struct("UserSalt").finish_non_exhaustive()
245    }
246}
247
248/// `body_hash = BLAKE3(body || user_salt || entry_nonce)`.
249///
250/// `user_salt` is held per-user by the HSM (shred → all entries unhashable).
251/// `entry_nonce` is a per-record 128-bit plaintext field. The L2 pre-compute
252/// path calls this helper; L1 simply stores the finalized `body_hash` (A11
253/// pure succession).
254#[must_use]
255pub fn compute_body_hash(body: &[u8], user_salt: &UserSalt, entry_nonce: &[u8; 16]) -> [u8; 32] {
256    let mut h = Hasher::new();
257    h.update(body);
258    h.update(user_salt.as_bytes());
259    h.update(entry_nonce);
260    *h.finalize().as_bytes()
261}
262
263/// Per-DEK message-count metric.
264///
265/// Warn at 2^30 messages, force rotation before 2^32 (well clear of the
266/// birthday bound). Observed backends plug into
267/// `arkhe_runtime_dek_message_count{user_id}`.
268#[derive(Debug, Clone)]
269pub struct DekMessageCounter {
270    dek_id: DekId,
271    count: u64,
272}
273
274/// DEK rotation trigger state.
275#[non_exhaustive]
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum RotationTrigger {
278    /// Healthy — threshold not yet reached.
279    Healthy,
280    /// 2^30 warn threshold reached — operator warning.
281    WarnApproachingLimit,
282    /// 2^32 - cooldown — immediate rotation required (force).
283    MustRotate,
284}
285
286impl DekMessageCounter {
287    /// New counter — keyed by `dek_id`.
288    pub fn new(dek_id: DekId) -> Self {
289        Self { dek_id, count: 0 }
290    }
291
292    /// DEK id.
293    #[must_use]
294    pub fn dek_id(&self) -> DekId {
295        self.dek_id
296    }
297
298    /// Current count.
299    #[must_use]
300    pub fn count(&self) -> u64 {
301        self.count
302    }
303
304    /// Called on successful encrypt — count += 1.
305    pub fn record_message(&mut self) {
306        self.count = self.count.saturating_add(1);
307    }
308
309    /// Rotation trigger decision. 2^30 warn / 2^31 force.
310    ///
311    /// Force threshold set at 2^31 — clear margin before the 2^32 birthday
312    /// bound (~50% earlier trigger).
313    #[must_use]
314    pub fn rotation_trigger(&self) -> RotationTrigger {
315        const WARN_THRESHOLD: u64 = 1u64 << 30;
316        const FORCE_THRESHOLD: u64 = 1u64 << 31;
317        if self.count >= FORCE_THRESHOLD {
318            RotationTrigger::MustRotate
319        } else if self.count >= WARN_THRESHOLD {
320            RotationTrigger::WarnApproachingLimit
321        } else {
322            RotationTrigger::Healthy
323        }
324    }
325}
326
327// ===================== PiiType sealed trait =====================
328
329/// Internal seal — [`PiiType`] impls may come only from this module.
330mod pii_seal {
331    /// Sealed marker keeping shell code from implementing [`super::PiiType`].
332    pub trait Sealed {}
333}
334
335/// Runtime-canonical PII marker — identifies a wire-tagged PII family
336/// The trait is **sealed** — only the four canonical
337/// marker types in this module implement it. Shell-scoped PII uses the
338/// separate `ShellPiiType` channel with manifest registration.
339///
340/// Implementors carry a single `const PII_CODE: u16` pin in the runtime
341/// canonical range `0x0001..=0x00FF`; the 2-byte field is folded into the
342/// AEAD AAD so a ciphertext cannot be decrypted under a mismatched PII
343/// marker (type-confused-deputy mitigation).
344pub trait PiiType: pii_seal::Sealed + Serialize + for<'de> Deserialize<'de> {
345    /// Wire-tag constant — fixed at the module boundary so canonical
346    /// `0x0001..=0x00FF` codes are stable across releases.
347    const PII_CODE: u16;
348}
349
350/// `ActorProfile.handle` marker — `PII_CODE = 0x0001`.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct ActorHandle(pub Vec<u8>);
353impl pii_seal::Sealed for ActorHandle {}
354impl PiiType for ActorHandle {
355    const PII_CODE: u16 = pii_code::ACTOR_HANDLE;
356}
357
358/// `EntryBody.body_plaintext` marker — `PII_CODE = 0x0002`.
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360pub struct EntryBody(pub Vec<u8>);
361impl pii_seal::Sealed for EntryBody {}
362impl PiiType for EntryBody {
363    const PII_CODE: u16 = pii_code::ENTRY_BODY;
364}
365
366/// `ActivityRecord.extra_bytes` marker — `PII_CODE = 0x0003`. Shell
367/// discretion; per-user encrypt recommended.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct ActivityExtraBytes(pub Vec<u8>);
370impl pii_seal::Sealed for ActivityExtraBytes {}
371impl PiiType for ActivityExtraBytes {
372    const PII_CODE: u16 = pii_code::ACTIVITY_EXTRA_BYTES;
373}
374
375/// Supplementary credential secret marker — `PII_CODE = 0x0004`. The
376/// primary `AuthCredential` KDF salt is already per-credential random; this
377/// marker covers auxiliary secret storage kept encrypted alongside the
378/// credential (PII_CODE table).
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct AuthCredentialSecret(pub Vec<u8>);
381impl pii_seal::Sealed for AuthCredentialSecret {}
382impl PiiType for AuthCredentialSecret {
383    const PII_CODE: u16 = pii_code::AUTH_CREDENTIAL_SECRET;
384}
385
386// ===================== PiiError =====================
387
388/// Crypto-erasure + PII handling failure taxonomy.
389///
390/// The display strings are intentionally brief — public error surfaces
391/// stay opaque; operator-facing detail is logged separately.
392#[non_exhaustive]
393#[derive(Debug, thiserror::Error)]
394pub enum PiiError {
395    /// Current feature set rejects encryption — the caller is running at
396    /// Tier-0 (default) with no KMS backend wired.
397    #[error("compliance tier too low for encryption")]
398    TierTooLow,
399
400    /// Wire `pii_code` did not match the expected `T::PII_CODE` — the
401    /// ciphertext was presented for decryption under the wrong marker
402    /// type (type-confused-deputy mitigation).
403    #[error("PII type marker mismatch")]
404    TypeMismatch,
405
406    /// The record's `aead_kind` did not match the manifest policy
407    /// (`[audit.pii_cipher]`). Downgrade attempts are refused at the
408    /// decryption boundary.
409    #[error("AEAD cipher downgrade rejected")]
410    CipherDowngrade,
411
412    /// AEAD tag verification failed — the AAD was tampered with, the DEK
413    /// is wrong, or the ciphertext is corrupt.
414    #[error("AEAD tag verification failed")]
415    AadMismatch,
416
417    /// The plaintext decoded correctly at the AEAD layer but failed
418    /// postcard decoding into `T`. Usually a schema_version drift.
419    #[error("payload decode failed")]
420    DecodeFailed,
421
422    /// `Dek` buffer was not the expected 32 bytes — construction-time
423    /// guard (`Dek::from_bytes` never hands out a short key).
424    #[error("DEK must be exactly 32 bytes")]
425    InvalidKeyLength,
426
427    /// AEAD encryption primitive reported an internal error. Opaque on
428    /// purpose; operator log holds the crate-level detail.
429    #[error("AEAD encrypt failed")]
430    EncryptFailed,
431
432    /// The coordinator was invoked against an unsupported `AeadKind`
433    /// under the current feature set — e.g. AES-GCM under `tier-1-kms`
434    /// alone.
435    #[error("AEAD kind unsupported for current feature set")]
436    UnsupportedAead,
437
438    /// Per-DEK monotonic counter reached `u64::MAX` — the AES-GCM /
439    /// AES-GCM-SIV paths cannot issue another deterministic nonce
440    /// without risking reuse. Operator must rotate the DEK before
441    /// further encryption.
442    #[error("DEK nonce counter exhausted; rotation required")]
443    DekExhausted,
444
445    /// Shell-scoped PII marker declared a `pii_code` outside the
446    /// reserved `0x0100..=0xFFFF` range. Canonical
447    /// codes (`0x0001..=0x00FF`) are owned by the sealed [`PiiType`]
448    /// trait and cannot be registered through the shell channel.
449    #[error("shell PII code outside the 0x0100..=0xFFFF range")]
450    ShellPiiCodeOutOfRange,
451
452    /// Shell manifest tried to register a `pii_code` that another
453    /// entry in the same registry already owns. Registration is
454    /// append-only; removals flow through the shell sunset
455    /// policy.
456    #[error("shell PII code already registered")]
457    ShellPiiAlreadyRegistered,
458}
459
460#[cfg(test)]
461#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn aad_is_exactly_19_bytes_and_deterministic() {
467        let dek_id = DekId([0xAB; 16]);
468        let aad1 = compute_aad(&dek_id, pii_code::ACTOR_HANDLE, AeadKind::XChaCha20Poly1305);
469        let aad2 = compute_aad(&dek_id, pii_code::ACTOR_HANDLE, AeadKind::XChaCha20Poly1305);
470        assert_eq!(aad1.len(), 19);
471        assert_eq!(aad1, aad2);
472    }
473
474    #[test]
475    fn aad_differs_on_pii_code_change() {
476        let dek_id = DekId([0u8; 16]);
477        let a = compute_aad(&dek_id, pii_code::ACTOR_HANDLE, AeadKind::XChaCha20Poly1305);
478        let b = compute_aad(&dek_id, pii_code::ENTRY_BODY, AeadKind::XChaCha20Poly1305);
479        assert_ne!(a, b);
480    }
481
482    #[test]
483    fn aad_differs_on_aead_kind_change() {
484        let dek_id = DekId([0u8; 16]);
485        let a = compute_aad(&dek_id, pii_code::ACTOR_HANDLE, AeadKind::XChaCha20Poly1305);
486        let b = compute_aad(&dek_id, pii_code::ACTOR_HANDLE, AeadKind::Aes256Gcm);
487        assert_ne!(a, b);
488    }
489
490    #[test]
491    fn aad_layout_dek_first_then_code_then_kind() {
492        let dek_id = DekId([0x11; 16]);
493        let aad = compute_aad(&dek_id, 0xABCD, AeadKind::Aes256GcmSiv);
494        assert_eq!(&aad[..16], &[0x11; 16]);
495        assert_eq!(&aad[16..18], &[0xAB, 0xCD]); // big-endian
496        assert_eq!(aad[18], AeadKind::Aes256GcmSiv as u8);
497    }
498
499    #[test]
500    fn body_hash_deterministic_and_32_bytes() {
501        let body = b"hello world";
502        let salt = UserSalt::from_bytes([0x01; 16]);
503        let nonce = [0x02; 16];
504        let h1 = compute_body_hash(body, &salt, &nonce);
505        let h2 = compute_body_hash(body, &salt, &nonce);
506        assert_eq!(h1, h2);
507        assert_eq!(h1.len(), 32);
508    }
509
510    #[test]
511    fn body_hash_differs_on_salt_change() {
512        let body = b"x";
513        let s1 = UserSalt::from_bytes([0x01; 16]);
514        let s2 = UserSalt::from_bytes([0x02; 16]);
515        let nonce = [0u8; 16];
516        assert_ne!(
517            compute_body_hash(body, &s1, &nonce),
518            compute_body_hash(body, &s2, &nonce)
519        );
520    }
521
522    #[test]
523    fn user_salt_debug_does_not_leak_material() {
524        let salt = UserSalt::from_bytes([0xBB; 16]);
525        let s = format!("{:?}", salt);
526        assert!(!s.contains("BB"));
527        assert!(!s.contains("bb"));
528    }
529
530    #[test]
531    fn shell_pii_type_rejects_canonical_code() {
532        // 0x0050 is inside the canonical band 0x0001..=0x00FF.
533        let err = ShellPiiType::new(0x0050, Sensitivity::Medium, AeadKind::XChaCha20Poly1305)
534            .unwrap_err();
535        assert!(matches!(err, PiiError::ShellPiiCodeOutOfRange));
536    }
537
538    #[test]
539    fn shell_pii_type_accepts_shell_code() {
540        let t = ShellPiiType::new(0x0200, Sensitivity::High, AeadKind::Aes256Gcm).unwrap();
541        assert_eq!(t.pii_code(), 0x0200);
542        assert_eq!(t.sensitivity(), Sensitivity::High);
543        assert_eq!(t.aead_kind(), AeadKind::Aes256Gcm);
544    }
545
546    #[test]
547    fn shell_pii_registry_rejects_duplicate() {
548        let mut reg = ShellPiiRegistry::new();
549        let a = ShellPiiType::new(0x0300, Sensitivity::Low, AeadKind::XChaCha20Poly1305).unwrap();
550        reg.register(a).unwrap();
551        let dup = ShellPiiType::new(0x0300, Sensitivity::Medium, AeadKind::Aes256Gcm).unwrap();
552        let err = reg.register(dup).unwrap_err();
553        assert!(matches!(err, PiiError::ShellPiiAlreadyRegistered));
554        assert_eq!(reg.len(), 1);
555    }
556
557    #[test]
558    fn shell_pii_registry_lookup_returns_entry() {
559        let mut reg = ShellPiiRegistry::new();
560        assert!(reg.is_empty());
561        let entry = ShellPiiType::new(0x0400, Sensitivity::Medium, AeadKind::Aes256GcmSiv).unwrap();
562        reg.register(entry).unwrap();
563        let found = reg.get(0x0400).expect("registered marker");
564        assert_eq!(found.aead_kind(), AeadKind::Aes256GcmSiv);
565        assert!(reg.get(0x0401).is_none());
566    }
567
568    #[test]
569    fn shell_pii_registry_rejects_out_of_range() {
570        let mut reg = ShellPiiRegistry::new();
571        // Manifest carrying a 0x00FF code must be refused even if it
572        // somehow passes `ShellPiiType::new` in a hostile build.
573        let leaked = ShellPiiType {
574            pii_code: 0x00FF,
575            sensitivity: Sensitivity::Medium,
576            aead_kind: AeadKind::XChaCha20Poly1305,
577        };
578        let err = reg.register(leaked).unwrap_err();
579        assert!(matches!(err, PiiError::ShellPiiCodeOutOfRange));
580    }
581
582    #[test]
583    fn rotation_trigger_transitions() {
584        let dek_id = DekId([0u8; 16]);
585        let mut c = DekMessageCounter::new(dek_id);
586        assert_eq!(c.rotation_trigger(), RotationTrigger::Healthy);
587
588        // Warn threshold: 2^30.
589        c.count = 1u64 << 30;
590        assert_eq!(c.rotation_trigger(), RotationTrigger::WarnApproachingLimit);
591
592        // Force threshold: 2^31.
593        c.count = 1u64 << 31;
594        assert_eq!(c.rotation_trigger(), RotationTrigger::MustRotate);
595    }
596
597    #[test]
598    fn counter_saturates_at_u64_max() {
599        let dek_id = DekId([0u8; 16]);
600        let mut c = DekMessageCounter::new(dek_id);
601        c.count = u64::MAX;
602        c.record_message();
603        assert_eq!(c.count(), u64::MAX); // saturating_add
604    }
605}