key_vault/vault/mod.rs
1//! The vault itself.
2//!
3//! In this phase [`KeyVault`] owns the configured fragmenter and the
4//! normalization toggle, and exposes `fragment` / `defragment` shortcuts so
5//! downstream crates can exercise the Layer 2 + Layer 3 + Layer 7 stack
6//! end-to-end. Key registration, naming, rotation, and recovery still arrive
7//! in Phase 0.9 — today the vault is a stateless helper around the
8//! fragmenter.
9//!
10//! ```
11//! use key_vault::{KeyVault, KeyVaultBuilder};
12//!
13//! // The builder follows the standard fluent pattern. None of the methods
14//! // perform I/O — construction is cheap and infallible.
15//! let _vault: KeyVault = KeyVaultBuilder::new().build();
16//! ```
17
18use alloc::borrow::Cow;
19use alloc::collections::VecDeque;
20use alloc::string::{String, ToString};
21use alloc::sync::Arc;
22use alloc::vec::Vec;
23use core::sync::atomic::{AtomicBool, Ordering};
24use core::time::Duration;
25use std::collections::HashMap;
26use std::sync::Mutex;
27use std::time::Instant;
28
29use crate::Result;
30use crate::codex::Codex;
31use crate::decoy::DecoyStrategy;
32use crate::error::Error;
33use crate::fetcher::RawKey;
34use crate::fragment::{FragmentStrategy, Fragments, StandardFragmenter};
35use crate::monitor::{AccessContext, FailureContext, SecurityMonitor, ThresholdContext};
36use crate::normalize::blake3_normalize;
37
38/// Default upper bound on failures per key before lockout. `0` means
39/// "never lock out" — i.e. the threshold is disabled. The default
40/// [`VaultConfig`] disables it so failures pass through to the monitor
41/// without triggering lockout unless the caller explicitly opts in.
42const DEFAULT_MAX_FAILURES: u32 = 0;
43
44/// Default window for the failure counter when no override is set.
45const DEFAULT_FAILURE_WINDOW: Duration = Duration::from_secs(60);
46
47/// Vault configuration.
48///
49/// Concrete fields are added in later phases as each layer comes online.
50/// Marked `#[non_exhaustive]` so new fields are additive.
51#[derive(Debug, Clone)]
52#[non_exhaustive]
53pub struct VaultConfig {
54 /// If `true`, raw key material is BLAKE3-normalized to 32 bytes before
55 /// fragmentation. Default is `true`.
56 pub key_normalization: bool,
57
58 /// Failures (per key) within the configured `failure_window`
59 /// required to trigger vault lockout. `0` disables threshold
60 /// lockout entirely — failures still flow to the configured monitor
61 /// but never lock the vault out. Default: `0` (disabled).
62 pub max_failures_before_lockout: u32,
63
64 /// Sliding window for the failure counter. Failures older than this
65 /// fall off the counter for a given key. Default: 60 seconds.
66 pub failure_window: Duration,
67}
68
69impl Default for VaultConfig {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl VaultConfig {
76 /// Default-on configuration with threshold lockout disabled.
77 #[must_use]
78 pub fn new() -> Self {
79 Self {
80 key_normalization: true,
81 max_failures_before_lockout: DEFAULT_MAX_FAILURES,
82 failure_window: DEFAULT_FAILURE_WINDOW,
83 }
84 }
85}
86
87/// In-memory key vault.
88///
89/// The vault is the entry point for everything `key-vault` does. Application
90/// code constructs one via [`KeyVaultBuilder`], hands it [`RawKey`] values
91/// to be fragmented, and (in later phases) receives
92/// [`KeyHandle`](crate::KeyHandle)s in return. The vault itself is cheap to
93/// clone (it is `Arc`-backed internally) and safe to share across threads.
94///
95/// In Phase 0.3 the vault exposes [`KeyVault::fragment`] and
96/// [`KeyVault::defragment`] convenience methods that route through the
97/// configured normalizer and [`StandardFragmenter`]. The full named-key
98/// registry arrives in Phase 0.9.
99#[derive(Clone)]
100pub struct KeyVault {
101 inner: Arc<VaultInner>,
102}
103
104struct VaultInner {
105 config: VaultConfig,
106 fragmenter: StandardFragmenter,
107 /// Optional Layer-5 codex. When set, every byte of normalized key
108 /// material passes through `codex.encode()` before being handed to
109 /// the fragmenter; `defragment` applies `codex.decode()` to recover.
110 codex: Option<Arc<dyn Codex>>,
111 /// Layer-8 security monitor. Defaults to a no-op
112 /// [`NoMonitor`](crate::NoMonitor) when no monitor is configured.
113 monitor: Arc<dyn SecurityMonitor>,
114 /// Per-key sliding-window failure tracker. Populated by
115 /// [`KeyVault::report_failure`]; consulted by the threshold-detection
116 /// logic to decide whether to trigger lockout.
117 failure_tracker: Mutex<HashMap<String, VecDeque<Instant>>>,
118 /// Set to `true` when the failure-tracker threshold has been crossed.
119 /// `fragment` / `defragment` refuse to operate while this is set;
120 /// `Error::LockedOut` is returned instead.
121 locked_out: AtomicBool,
122}
123
124impl KeyVault {
125 /// Returns `true` if the vault is in lock-out state.
126 ///
127 /// Lock-out is triggered by the threshold detector when
128 /// [`KeyVault::report_failure`] reports more failures than
129 /// [`VaultConfig::max_failures_before_lockout`] within
130 /// [`VaultConfig::failure_window`]. Once set, [`KeyVault::fragment`]
131 /// and [`KeyVault::defragment`] refuse to proceed and return
132 /// [`Error::LockedOut`](crate::Error::LockedOut). Use
133 /// [`KeyVault::clear_lockout`] to reset.
134 #[must_use]
135 pub fn is_locked_out(&self) -> bool {
136 self.inner.locked_out.load(Ordering::Acquire)
137 }
138
139 /// Clear the lockout flag.
140 ///
141 /// Use this after the operator has resolved the underlying cause —
142 /// e.g. a rotated credential, an investigated alert. Also clears the
143 /// failure tracker; subsequent failures start counting from zero.
144 pub fn clear_lockout(&self) {
145 self.inner.locked_out.store(false, Ordering::Release);
146 if let Ok(mut tracker) = self.inner.failure_tracker.lock() {
147 tracker.clear();
148 }
149 }
150
151 /// Report a key-access failure to the configured monitor and the
152 /// threshold detector.
153 ///
154 /// `key_name` identifies which key the failure pertains to (used for
155 /// per-key threshold tracking and in the monitor event). `note` is
156 /// an optional caller-supplied free-text label; pass `None` if you
157 /// don't have one. **Do not** include key bytes or other secrets in
158 /// the note — it is forwarded verbatim to every configured monitor.
159 ///
160 /// If the per-key failure count within
161 /// [`VaultConfig::failure_window`] reaches
162 /// [`VaultConfig::max_failures_before_lockout`], the vault transitions
163 /// to lock-out state and the monitor's `on_threshold_breach` callback
164 /// fires. A `max_failures` of `0` disables threshold lockout — only
165 /// the per-failure callback runs in that case.
166 pub fn report_failure(&self, key_name: &str, note: Option<&'static str>) {
167 let note = note.map_or(Cow::Borrowed(""), Cow::Borrowed);
168 let (count, oldest_in_window) = self.record_failure(key_name);
169 let window_elapsed = oldest_in_window.map(|t| t.elapsed()).unwrap_or_default();
170
171 // Always fire the per-failure callback first.
172 let ctx = FailureContext {
173 key_name: key_name.to_string(),
174 consecutive_failures: count,
175 window_elapsed,
176 note: note.clone(),
177 };
178 self.inner.monitor.on_decryption_failure(&ctx);
179
180 // Threshold check.
181 let threshold = self.inner.config.max_failures_before_lockout;
182 if threshold > 0 && count >= threshold {
183 // Only lock out once — subsequent calls keep firing
184 // on_decryption_failure but the lockout flag stays set.
185 let was_locked = self.inner.locked_out.swap(true, Ordering::AcqRel);
186 let breach = ThresholdContext {
187 key_name: key_name.to_string(),
188 failures_in_window: count,
189 window: self.inner.config.failure_window,
190 lockout_triggered: !was_locked,
191 };
192 self.inner.monitor.on_threshold_breach(&breach);
193 }
194 }
195
196 /// Report an anomalous (but successful) key access to the monitor.
197 ///
198 /// Useful for "this access pattern looks weird, but we're not going
199 /// to refuse it" cases — unusual time of day, geographic anomaly,
200 /// caller identity that hasn't been seen before. The monitor receives
201 /// an `AccessContext`; the vault state is unaffected.
202 pub fn report_anomalous_access(&self, key_name: &str, note: Option<&'static str>) {
203 let note = note.map_or(Cow::Borrowed(""), Cow::Borrowed);
204 let ctx = AccessContext {
205 key_name: key_name.to_string(),
206 note,
207 };
208 self.inner.monitor.on_anomalous_access(&ctx);
209 }
210
211 /// Append a failure timestamp for `key_name` and evict entries older
212 /// than the configured window. Returns the resulting count and the
213 /// oldest timestamp still in the window (if any).
214 fn record_failure(&self, key_name: &str) -> (u32, Option<Instant>) {
215 let now = Instant::now();
216 let window = self.inner.config.failure_window;
217 let Ok(mut tracker) = self.inner.failure_tracker.lock() else {
218 // Poisoned mutex — treat as a single isolated failure so
219 // monitoring still fires and we don't block legitimate
220 // operations. This branch is effectively unreachable in
221 // practice (the only writer here doesn't panic).
222 return (1, Some(now));
223 };
224 let entries = tracker.entry(key_name.to_string()).or_default();
225 // Evict expired.
226 while let Some(front) = entries.front() {
227 if now.saturating_duration_since(*front) > window {
228 let _ = entries.pop_front();
229 } else {
230 break;
231 }
232 }
233 entries.push_back(now);
234 let count = u32::try_from(entries.len()).unwrap_or(u32::MAX);
235 let oldest = entries.front().copied();
236 (count, oldest)
237 }
238
239 /// Snapshot of the vault's configuration.
240 #[must_use]
241 pub fn config(&self) -> &VaultConfig {
242 &self.inner.config
243 }
244
245 /// Fragment a raw key through the configured normalizer, codex, and
246 /// fragmenter.
247 ///
248 /// The returned [`Fragments`] is opaque; pass it back to
249 /// [`KeyVault::defragment`] to recover the (normalized + codex-encoded)
250 /// bytes inverse-transformed.
251 ///
252 /// # Pipeline
253 ///
254 /// ```text
255 /// key → blake3_normalize (optional) → codex.encode (optional) → fragmenter.fragment → Fragments
256 /// ```
257 ///
258 /// # Errors
259 ///
260 /// Returns whatever the underlying [`FragmentStrategy`] surfaces — in
261 /// practice an [`Error::Fragment`](crate::Error::Fragment) for a
262 /// zero-length input.
263 pub fn fragment(&self, key: &RawKey) -> Result<Fragments> {
264 if self.is_locked_out() {
265 return Err(Error::LockedOut);
266 }
267 let working = if self.inner.config.key_normalization {
268 blake3_normalize(key)
269 } else {
270 RawKey::new(key.as_bytes().to_vec())
271 };
272 let encoded = if let Some(codex) = &self.inner.codex {
273 codex_apply(codex.as_ref(), &working)
274 } else {
275 working
276 };
277 self.inner.fragmenter.fragment(&encoded)
278 }
279
280 /// Reassemble fragments produced by [`KeyVault::fragment`].
281 ///
282 /// Inverts the codex transformation (if configured) so the recovered
283 /// bytes are the normalized key (or the original raw key if
284 /// normalization is off). Defragmentation itself is delegated to the
285 /// configured [`FragmentStrategy`].
286 ///
287 /// # Errors
288 ///
289 /// Returns [`Error::Defragment`](crate::Error::Defragment) when the
290 /// supplied fragments do not match the configured fragmenter's layout.
291 pub fn defragment(&self, fragments: &Fragments) -> Result<RawKey> {
292 if self.is_locked_out() {
293 return Err(Error::LockedOut);
294 }
295 let encoded = self.inner.fragmenter.defragment(fragments)?;
296 if let Some(codex) = &self.inner.codex {
297 Ok(codex_apply(codex.as_ref(), &encoded))
298 } else {
299 Ok(encoded)
300 }
301 }
302}
303
304/// Apply a codex's transformation to every byte of a key.
305///
306/// Used both for encoding (pre-fragment) and decoding (post-defragment).
307/// For involution-based codices `decode == encode`; the function name
308/// reflects that — it's a single transformation pass either way.
309fn codex_apply(codex: &dyn Codex, key: &RawKey) -> RawKey {
310 let bytes: Vec<u8> = key.as_bytes().iter().map(|&b| codex.encode(b)).collect();
311 RawKey::new(bytes)
312}
313
314impl core::fmt::Debug for KeyVault {
315 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
316 f.debug_struct("KeyVault")
317 .field("locked_out", &self.is_locked_out())
318 .field("config", &self.inner.config)
319 .finish()
320 }
321}
322
323/// Fluent builder for [`KeyVault`].
324///
325/// The builder is the only way to construct a vault; the inherent
326/// `KeyVault::new` constructor is intentionally not provided so that future
327/// required configuration cannot be silently bypassed.
328#[derive(Clone)]
329pub struct KeyVaultBuilder {
330 config: VaultConfig,
331 fragmenter: StandardFragmenter,
332 codex: Option<Arc<dyn Codex>>,
333 monitor: Option<Arc<dyn SecurityMonitor>>,
334}
335
336impl Default for KeyVaultBuilder {
337 fn default() -> Self {
338 Self::new()
339 }
340}
341
342impl core::fmt::Debug for KeyVaultBuilder {
343 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
344 f.debug_struct("KeyVaultBuilder")
345 .field("config", &self.config)
346 .field("fragmenter", &self.fragmenter)
347 .field("codex", &self.codex.as_ref().map(|_| "<set>"))
348 .field("monitor", &self.monitor.as_ref().map(|_| "<set>"))
349 .finish()
350 }
351}
352
353impl KeyVaultBuilder {
354 /// Start a new builder with default configuration and a default-range
355 /// [`StandardFragmenter`].
356 #[must_use]
357 pub fn new() -> Self {
358 Self {
359 config: VaultConfig::new(),
360 fragmenter: StandardFragmenter::new(),
361 codex: None,
362 monitor: None,
363 }
364 }
365
366 /// Enable or disable BLAKE3 normalization of input key material.
367 ///
368 /// Default: `true`. Disabling normalization preserves the original byte
369 /// pattern of the key in storage, which can leak format cues (DER
370 /// envelopes, PEM markers, ASCII-armored data). Disable only when you
371 /// have a specific reason to preserve the original bytes.
372 #[must_use]
373 pub fn normalize_with_blake3(mut self, enabled: bool) -> Self {
374 self.config.key_normalization = enabled;
375 self
376 }
377
378 /// Customize the fragmenter chunk-size range.
379 ///
380 /// Defaults are documented on [`StandardFragmenter::new`]. `min` is
381 /// clamped to `>= 1` and `max` to `>= min`. Calling this replaces any
382 /// previously-configured chunk range and resets the decoy strategy to
383 /// `None`; configure decoy *after* this call.
384 #[must_use]
385 pub fn with_chunk_range(mut self, min: usize, max: usize) -> Self {
386 self.fragmenter = StandardFragmenter::with_chunk_range(min, max);
387 self
388 }
389
390 /// Attach a Layer-5 codex to the vault.
391 ///
392 /// When set, every byte of the (optionally BLAKE3-normalized) key
393 /// passes through `codex.encode()` before being handed to the
394 /// fragmenter; `defragment` applies `codex.decode()` to recover the
395 /// original bytes. For involution-based codices ([`StaticCodex`](crate::StaticCodex),
396 /// [`DynamicCodex`](crate::DynamicCodex), involution closures wrapped in
397 /// [`FnCodex`](crate::codex::FnCodex)) `decode == encode`, but the
398 /// vault calls them by name so non-involution codices would also
399 /// work in principle.
400 ///
401 /// The codex is held in an `Arc<dyn Codex>` so the same codex can be
402 /// shared across multiple vaults (rarely useful — usually each vault
403 /// wants its own [`DynamicCodex`](crate::DynamicCodex)).
404 ///
405 /// # Examples
406 ///
407 /// ```
408 /// use key_vault::{DynamicCodex, KeyVaultBuilder};
409 ///
410 /// let vault = KeyVaultBuilder::new()
411 /// .with_codex(DynamicCodex::new().unwrap())
412 /// .build();
413 /// // The vault now applies the codex transformation transparently
414 /// // on every fragment / defragment.
415 /// # let _ = vault;
416 /// ```
417 #[must_use]
418 pub fn with_codex<C>(mut self, codex: C) -> Self
419 where
420 C: Codex + 'static,
421 {
422 self.codex = Some(Arc::new(codex));
423 self
424 }
425
426 /// Attach a Layer-4 decoy strategy to the underlying fragmenter.
427 ///
428 /// When set, every `KeyVault::fragment` call also produces decoy chunks
429 /// from the strategy. Decoys are interleaved with real chunks via the
430 /// same Fisher-Yates shuffle and are skipped by `defragment`. See
431 /// [`StandardFragmenter::with_decoy`] for details on chunk-count and
432 /// size selection.
433 ///
434 /// Use [`SelfReferenceDecoy`](crate::SelfReferenceDecoy) for the
435 /// strongest statistical indistinguishability (recommended default);
436 /// [`KeyDerivedDecoy`](crate::KeyDerivedDecoy) for BLAKE3-XOF–derived
437 /// CSPRNG-like output;
438 /// [`RandomDecoy`](crate::RandomDecoy) for raw CSPRNG output.
439 #[must_use]
440 pub fn with_decoy<D>(mut self, decoy: D) -> Self
441 where
442 D: DecoyStrategy + 'static,
443 {
444 self.fragmenter = self.fragmenter.with_decoy(decoy);
445 self
446 }
447
448 /// Attach a Layer-8 security monitor.
449 ///
450 /// Replaces any previously-configured monitor. The monitor receives
451 /// every event the vault produces — failure callbacks via
452 /// [`KeyVault::report_failure`], anomaly callbacks via
453 /// [`KeyVault::report_anomalous_access`], and threshold-breach
454 /// callbacks when the failure tracker fires.
455 ///
456 /// Default is [`NoMonitor`](crate::NoMonitor) — events go nowhere
457 /// but threshold-driven lockout still works (lockout state is owned
458 /// by the vault, not the monitor).
459 #[must_use]
460 pub fn with_monitor<M>(mut self, monitor: M) -> Self
461 where
462 M: SecurityMonitor + 'static,
463 {
464 self.monitor = Some(Arc::new(monitor));
465 self
466 }
467
468 /// Configure the failure-threshold detector.
469 ///
470 /// When [`KeyVault::report_failure`] records `max` failures for the
471 /// same `key_name` within `window`, the vault transitions to
472 /// lock-out state and the monitor's `on_threshold_breach` fires.
473 ///
474 /// Pass `max = 0` to disable threshold lockout (the default). The
475 /// vault will still forward every failure to the monitor; it just
476 /// won't lock out on its own.
477 ///
478 /// `window` is the sliding-window size for the per-key failure
479 /// counter; failures older than this fall off and no longer count.
480 #[must_use]
481 pub fn with_failure_threshold(mut self, max: u32, window: Duration) -> Self {
482 self.config.max_failures_before_lockout = max;
483 self.config.failure_window = window;
484 self
485 }
486
487 /// Finalize and produce a [`KeyVault`].
488 ///
489 /// Infallible in this phase — later phases may move this to a
490 /// `Result`-returning shape if validation is added.
491 #[must_use]
492 pub fn build(self) -> KeyVault {
493 let monitor: Arc<dyn SecurityMonitor> = self
494 .monitor
495 .unwrap_or_else(|| Arc::new(crate::monitor::NoMonitor));
496 KeyVault {
497 inner: Arc::new(VaultInner {
498 config: self.config,
499 fragmenter: self.fragmenter,
500 codex: self.codex,
501 monitor,
502 failure_tracker: Mutex::new(HashMap::new()),
503 locked_out: AtomicBool::new(false),
504 }),
505 }
506 }
507}
508
509#[cfg(test)]
510#[allow(clippy::unwrap_used, clippy::expect_used)]
511mod tests {
512 use super::*;
513 use alloc::format;
514
515 #[test]
516 fn builder_defaults_to_normalization_on() {
517 let v = KeyVaultBuilder::new().build();
518 assert!(v.config().key_normalization);
519 }
520
521 #[test]
522 fn builder_can_disable_normalization() {
523 let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
524 assert!(!v.config().key_normalization);
525 }
526
527 #[test]
528 fn fresh_vault_is_not_locked_out() {
529 let v = KeyVaultBuilder::new().build();
530 assert!(!v.is_locked_out());
531 }
532
533 #[test]
534 fn debug_does_not_panic() {
535 let v = KeyVaultBuilder::new().build();
536 let _ = format!("{v:?}");
537 }
538
539 #[test]
540 fn fragment_defragment_roundtrip_with_normalization() {
541 let v = KeyVaultBuilder::new().build(); // normalization on
542 let raw = RawKey::new(b"hello world".to_vec());
543 let frags = v.fragment(&raw).unwrap();
544 let recovered = v.defragment(&frags).unwrap();
545 // With normalization on, the output is the BLAKE3 hash (32 bytes),
546 // not the original 11-byte input.
547 assert_eq!(recovered.len(), 32);
548 // It is deterministic — fragmenting the same input twice produces the
549 // same recovered bytes (the bytes themselves; layout still varies).
550 let frags2 = v.fragment(&raw).unwrap();
551 let recovered2 = v.defragment(&frags2).unwrap();
552 assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
553 }
554
555 #[test]
556 fn fragment_defragment_roundtrip_without_normalization() {
557 let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
558 let raw = RawKey::new((0u8..40).collect());
559 let frags = v.fragment(&raw).unwrap();
560 let recovered = v.defragment(&frags).unwrap();
561 assert_eq!(recovered.as_bytes(), raw.as_bytes());
562 }
563
564 #[test]
565 fn fragment_rejects_empty_key() {
566 let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
567 let err = v
568 .fragment(&RawKey::new(alloc::vec::Vec::new()))
569 .unwrap_err();
570 assert!(matches!(err, crate::Error::Fragment(_)));
571 }
572
573 #[test]
574 fn chunk_range_propagates_through_builder() {
575 let v = KeyVaultBuilder::new()
576 .normalize_with_blake3(false)
577 .with_chunk_range(4, 6)
578 .build();
579 let raw = RawKey::new((0u8..30).collect());
580 let frags = v.fragment(&raw).unwrap();
581
582 // After fragmentation, chunks have been Fisher-Yates shuffled, so the
583 // "remainder" chunk (which the size-sampling loop allows to fall below
584 // `min` when the total doesn't divide cleanly) can land at any index.
585 // We verify the post-shuffle invariants instead of indexing by order:
586 // 1. Every chunk fits in [1, max].
587 // 2. At most one chunk falls below `min` (the remainder slot).
588 // 3. Total bytes sum to the original length.
589 let chunks = frags.chunks();
590 let mut below_min = 0;
591 let mut total = 0usize;
592 for c in chunks {
593 assert!(
594 c.len() >= 1 && c.len() <= 6,
595 "chunk size {} not in [1,6]",
596 c.len()
597 );
598 if c.len() < 4 {
599 below_min += 1;
600 }
601 total += c.len();
602 }
603 assert!(
604 below_min <= 1,
605 "more than one chunk below min size: {below_min}"
606 );
607 assert_eq!(total, 30);
608 }
609
610 #[test]
611 fn fragment_with_random_decoy_roundtrips() {
612 let v = KeyVaultBuilder::new()
613 .normalize_with_blake3(false)
614 .with_decoy(crate::RandomDecoy)
615 .build();
616 let raw = RawKey::new((0u8..32).collect());
617 let frags = v.fragment(&raw).unwrap();
618 // Chunk count is real + decoy (roughly 2x the real count).
619 // Defragment must skip the decoys and return the original bytes.
620 let recovered = v.defragment(&frags).unwrap();
621 assert_eq!(recovered.as_bytes(), raw.as_bytes());
622 }
623
624 #[test]
625 fn fragment_with_self_reference_decoy_roundtrips() {
626 let v = KeyVaultBuilder::new()
627 .normalize_with_blake3(false)
628 .with_decoy(crate::SelfReferenceDecoy)
629 .build();
630 let raw = RawKey::new(b"some user-supplied key material".to_vec());
631 let frags = v.fragment(&raw).unwrap();
632 let recovered = v.defragment(&frags).unwrap();
633 assert_eq!(recovered.as_bytes(), raw.as_bytes());
634 }
635
636 #[test]
637 fn fragment_with_key_derived_decoy_roundtrips() {
638 let v = KeyVaultBuilder::new()
639 .normalize_with_blake3(false)
640 .with_decoy(crate::KeyDerivedDecoy)
641 .build();
642 let raw = RawKey::new((0u8..64).collect());
643 let frags = v.fragment(&raw).unwrap();
644 let recovered = v.defragment(&frags).unwrap();
645 assert_eq!(recovered.as_bytes(), raw.as_bytes());
646 }
647
648 #[test]
649 fn decoy_increases_chunk_count_relative_to_no_decoy() {
650 let no_decoy = KeyVaultBuilder::new()
651 .normalize_with_blake3(false)
652 .with_chunk_range(2, 4)
653 .build();
654 let with_decoy = KeyVaultBuilder::new()
655 .normalize_with_blake3(false)
656 .with_chunk_range(2, 4)
657 .with_decoy(crate::SelfReferenceDecoy)
658 .build();
659 let raw = RawKey::new((0u8..32).collect());
660
661 // The total chunk count is randomized per fragmentation, so average
662 // over a few runs to get a stable comparison. The decoy-enabled
663 // vault should average ~2x the chunks.
664 let mut no_decoy_total = 0usize;
665 let mut decoy_total = 0usize;
666 for _ in 0..8 {
667 no_decoy_total += no_decoy.fragment(&raw).unwrap().chunk_count();
668 decoy_total += with_decoy.fragment(&raw).unwrap().chunk_count();
669 }
670 // The decoy-enabled vault adds one decoy chunk per real chunk, so
671 // its total chunk count should be exactly twice the no-decoy count
672 // (modulo per-call sampling that affects the real-chunk count
673 // identically). Allow some slack for the random sampling variance.
674 assert!(
675 decoy_total > no_decoy_total,
676 "decoy vault produced {decoy_total} chunks vs no-decoy {no_decoy_total}"
677 );
678 }
679
680 #[test]
681 fn fragment_with_static_codex_roundtrips() {
682 use crate::StaticCodex;
683 let codex = StaticCodex::from_swaps(&[(b'A', b'#'), (b'0', b'%')]).unwrap();
684 let v = KeyVaultBuilder::new()
685 .normalize_with_blake3(false)
686 .with_codex(codex)
687 .build();
688 let raw = RawKey::new(b"A0A0A0A0".to_vec());
689 let frags = v.fragment(&raw).unwrap();
690 let recovered = v.defragment(&frags).unwrap();
691 // Codex round-trips: the recovered bytes are the original
692 // (pre-encode) bytes, not the encoded ones.
693 assert_eq!(recovered.as_bytes(), raw.as_bytes());
694 }
695
696 #[test]
697 fn fragment_with_dynamic_codex_roundtrips() {
698 use crate::DynamicCodex;
699 let v = KeyVaultBuilder::new()
700 .normalize_with_blake3(false)
701 .with_codex(DynamicCodex::new().unwrap())
702 .build();
703 let raw = RawKey::new((0u8..=255).collect());
704 let frags = v.fragment(&raw).unwrap();
705 let recovered = v.defragment(&frags).unwrap();
706 assert_eq!(recovered.as_bytes(), raw.as_bytes());
707 }
708
709 #[test]
710 fn fragment_with_codex_and_decoy_and_normalization_roundtrips() {
711 use crate::{DynamicCodex, SelfReferenceDecoy};
712 // All layers stacked: BLAKE3 normalize + DynamicCodex encode +
713 // StandardFragmenter w/ SelfReferenceDecoy. Must still round-trip.
714 let v = KeyVaultBuilder::new()
715 .normalize_with_blake3(true)
716 .with_codex(DynamicCodex::new().unwrap())
717 .with_decoy(SelfReferenceDecoy)
718 .build();
719 let raw = RawKey::new(b"my application key".to_vec());
720 let frags = v.fragment(&raw).unwrap();
721 let recovered = v.defragment(&frags).unwrap();
722 // With normalization on, recovered is 32 bytes (BLAKE3 hash).
723 // It must be deterministic given the same input.
724 assert_eq!(recovered.len(), 32);
725 let recovered2 = v.defragment(&v.fragment(&raw).unwrap()).unwrap();
726 assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
727 }
728
729 #[test]
730 fn codex_visibly_transforms_stored_bytes() {
731 // Without codex, the fragment chunks contain the original bytes
732 // somewhere among them. With a non-identity codex, the stored
733 // bytes should differ — we verify by checking that some chunk
734 // contains a transformed byte not in the original input.
735 use crate::StaticCodex;
736 let v = KeyVaultBuilder::new()
737 .normalize_with_blake3(false)
738 // Force every byte to swap with a distinct partner.
739 .with_codex(crate::DynamicCodex::new().unwrap())
740 .build();
741 let raw = RawKey::new(alloc::vec![0xaa; 8]);
742 let frags = v.fragment(&raw).unwrap();
743
744 // Walk chunks and confirm at least one byte is *not* 0xaa
745 // (the codex encoded 0xaa to something else).
746 let mut saw_non_aa = false;
747 for chunk in frags.chunks() {
748 for &b in chunk.as_bytes() {
749 if b != 0xaa {
750 saw_non_aa = true;
751 break;
752 }
753 }
754 if saw_non_aa {
755 break;
756 }
757 }
758 assert!(
759 saw_non_aa,
760 "codex did not transform 0xaa — stored bytes still all 0xaa",
761 );
762
763 // And defragment recovers the original 0xaa bytes.
764 let recovered = v.defragment(&frags).unwrap();
765 assert_eq!(recovered.as_bytes(), raw.as_bytes());
766 // Use the `_codex` import to keep the import non-dead.
767 let _ = StaticCodex::from_swaps(&[]).unwrap();
768 }
769
770 // ----- Layer 8: monitor + threshold tests -----
771
772 use core::sync::atomic::AtomicU32;
773
774 /// Helper monitor that counts each callback invocation.
775 struct CountingMonitor {
776 failures: AtomicU32,
777 anomalies: AtomicU32,
778 breaches: AtomicU32,
779 }
780
781 impl CountingMonitor {
782 fn new() -> Self {
783 Self {
784 failures: AtomicU32::new(0),
785 anomalies: AtomicU32::new(0),
786 breaches: AtomicU32::new(0),
787 }
788 }
789 }
790
791 impl SecurityMonitor for CountingMonitor {
792 fn on_decryption_failure(&self, _ctx: &FailureContext) {
793 let _ = self.failures.fetch_add(1, Ordering::SeqCst);
794 }
795 fn on_anomalous_access(&self, _ctx: &AccessContext) {
796 let _ = self.anomalies.fetch_add(1, Ordering::SeqCst);
797 }
798 fn on_threshold_breach(&self, _ctx: &ThresholdContext) {
799 let _ = self.breaches.fetch_add(1, Ordering::SeqCst);
800 }
801 }
802
803 #[test]
804 fn report_failure_fires_monitor() {
805 let monitor = Arc::new(CountingMonitor::new());
806 let v = KeyVaultBuilder::new()
807 .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
808 .build();
809 v.report_failure("k", None);
810 v.report_failure("k", Some("test note"));
811 assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
812 assert_eq!(monitor.breaches.load(Ordering::SeqCst), 0);
813 assert!(!v.is_locked_out());
814 }
815
816 #[test]
817 fn report_anomalous_access_fires_monitor() {
818 let monitor = Arc::new(CountingMonitor::new());
819 let v = KeyVaultBuilder::new()
820 .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
821 .build();
822 v.report_anomalous_access("k", None);
823 assert_eq!(monitor.anomalies.load(Ordering::SeqCst), 1);
824 assert!(!v.is_locked_out());
825 }
826
827 #[test]
828 fn threshold_lockout_fires_after_max_failures() {
829 let monitor = Arc::new(CountingMonitor::new());
830 let v = KeyVaultBuilder::new()
831 .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
832 .with_failure_threshold(3, Duration::from_secs(30))
833 .build();
834
835 v.report_failure("k", None);
836 assert!(!v.is_locked_out());
837 v.report_failure("k", None);
838 assert!(!v.is_locked_out());
839 v.report_failure("k", None);
840 // Three failures in the window → lockout.
841 assert!(v.is_locked_out());
842 assert_eq!(monitor.failures.load(Ordering::SeqCst), 3);
843 assert_eq!(monitor.breaches.load(Ordering::SeqCst), 1);
844
845 // Subsequent failures keep counting but only one breach event fires
846 // until clear_lockout resets the flag.
847 v.report_failure("k", None);
848 assert!(v.is_locked_out());
849 assert_eq!(monitor.failures.load(Ordering::SeqCst), 4);
850 // Breach event count grows but lockout_triggered is false now.
851 assert_eq!(monitor.breaches.load(Ordering::SeqCst), 2);
852 }
853
854 #[test]
855 fn fragment_refuses_when_locked_out() {
856 let v = KeyVaultBuilder::new()
857 .normalize_with_blake3(false)
858 .with_failure_threshold(1, Duration::from_secs(30))
859 .build();
860 v.report_failure("k", None);
861 assert!(v.is_locked_out());
862
863 let err = v
864 .fragment(&RawKey::new(alloc::vec![1u8, 2, 3, 4]))
865 .unwrap_err();
866 assert!(matches!(err, Error::LockedOut));
867 }
868
869 #[test]
870 fn defragment_refuses_when_locked_out() {
871 let v = KeyVaultBuilder::new()
872 .normalize_with_blake3(false)
873 .with_failure_threshold(2, Duration::from_secs(30))
874 .build();
875 // Produce a fragment before lockout.
876 let raw = RawKey::new(alloc::vec![1u8; 16]);
877 let frags = v.fragment(&raw).unwrap();
878 v.report_failure("k", None);
879 v.report_failure("k", None);
880 assert!(v.is_locked_out());
881
882 let err = v.defragment(&frags).unwrap_err();
883 assert!(matches!(err, Error::LockedOut));
884 }
885
886 #[test]
887 fn clear_lockout_resets_state() {
888 let v = KeyVaultBuilder::new()
889 .with_failure_threshold(1, Duration::from_secs(30))
890 .build();
891 v.report_failure("k", None);
892 assert!(v.is_locked_out());
893 v.clear_lockout();
894 assert!(!v.is_locked_out());
895 // Failure tracker also cleared — next single failure shouldn't lock
896 // again immediately (threshold is 1, so it WILL lock, but starting
897 // count is fresh — verifies tracker was cleared by counting
898 // monitor breaches).
899 // Actually with threshold=1 a single failure re-locks. So instead
900 // assert via tracker contents indirectly: a second `clear_lockout`
901 // call is a no-op.
902 v.clear_lockout();
903 assert!(!v.is_locked_out());
904 }
905
906 #[test]
907 fn per_key_failure_counts_are_independent() {
908 let monitor = Arc::new(CountingMonitor::new());
909 let v = KeyVaultBuilder::new()
910 .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
911 .with_failure_threshold(2, Duration::from_secs(30))
912 .build();
913 v.report_failure("alpha", None);
914 v.report_failure("beta", None);
915 // One failure each — neither hits the threshold.
916 assert!(!v.is_locked_out());
917 assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
918 v.report_failure("alpha", None);
919 // alpha now has 2 — triggers lockout.
920 assert!(v.is_locked_out());
921 }
922
923 #[test]
924 fn composite_monitor_chains_to_all_inner() {
925 use crate::CompositeMonitor;
926 let a = Arc::new(CountingMonitor::new());
927 let b = Arc::new(CountingMonitor::new());
928 let composite = CompositeMonitor::new(alloc::vec![
929 Arc::clone(&a) as Arc<dyn SecurityMonitor>,
930 Arc::clone(&b) as Arc<dyn SecurityMonitor>,
931 ]);
932 let v = KeyVaultBuilder::new()
933 .with_monitor(composite)
934 .with_failure_threshold(1, Duration::from_secs(30))
935 .build();
936 v.report_failure("k", None);
937 assert_eq!(a.failures.load(Ordering::SeqCst), 1);
938 assert_eq!(b.failures.load(Ordering::SeqCst), 1);
939 assert_eq!(a.breaches.load(Ordering::SeqCst), 1);
940 assert_eq!(b.breaches.load(Ordering::SeqCst), 1);
941 }
942}