Skip to main content

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}