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}