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, SystemTime, UNIX_EPOCH};
28
29use arc_swap::ArcSwap;
30use subtle::ConstantTimeEq;
31
32use crate::Result;
33use crate::codex::Codex;
34use crate::decoy::DecoyStrategy;
35use crate::error::Error;
36use crate::fetcher::RawKey;
37use crate::fragment::{FragmentStrategy, Fragments, StandardFragmenter};
38use crate::handle::{KeyHandle, KeyId};
39use crate::metadata::KeyMetadata;
40use crate::monitor::{AccessContext, FailureContext, SecurityMonitor, ThresholdContext};
41use crate::normalize::blake3_normalize;
42
43/// Default upper bound on failures per key before lockout. `0` means
44/// "never lock out" — i.e. the threshold is disabled. The default
45/// [`VaultConfig`] disables it so failures pass through to the monitor
46/// without triggering lockout unless the caller explicitly opts in.
47const DEFAULT_MAX_FAILURES: u32 = 0;
48
49/// Default window for the failure counter when no override is set.
50const DEFAULT_FAILURE_WINDOW: Duration = Duration::from_secs(60);
51
52/// Vault configuration.
53///
54/// Concrete fields are added in later phases as each layer comes online.
55/// Marked `#[non_exhaustive]` so new fields are additive.
56#[derive(Debug, Clone)]
57#[non_exhaustive]
58pub struct VaultConfig {
59    /// If `true`, raw key material is BLAKE3-normalized to 32 bytes before
60    /// fragmentation. Default is `true`.
61    pub key_normalization: bool,
62
63    /// Failures (per key) within the configured `failure_window`
64    /// required to trigger vault lockout. `0` disables threshold
65    /// lockout entirely — failures still flow to the configured monitor
66    /// but never lock the vault out. Default: `0` (disabled).
67    pub max_failures_before_lockout: u32,
68
69    /// Sliding window for the failure counter. Failures older than this
70    /// fall off the counter for a given key. Default: 60 seconds.
71    pub failure_window: Duration,
72}
73
74impl Default for VaultConfig {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl VaultConfig {
81    /// Default-on configuration with threshold lockout disabled.
82    #[must_use]
83    pub fn new() -> Self {
84        Self {
85            key_normalization: true,
86            max_failures_before_lockout: DEFAULT_MAX_FAILURES,
87            failure_window: DEFAULT_FAILURE_WINDOW,
88        }
89    }
90}
91
92/// In-memory key vault.
93///
94/// The vault is the entry point for everything `key-vault` does. Application
95/// code constructs one via [`KeyVaultBuilder`], hands it [`RawKey`] values
96/// to be fragmented, and (in later phases) receives
97/// [`KeyHandle`](crate::KeyHandle)s in return. The vault itself is cheap to
98/// clone (it is `Arc`-backed internally) and safe to share across threads.
99///
100/// In Phase 0.3 the vault exposes [`KeyVault::fragment`] and
101/// [`KeyVault::defragment`] convenience methods that route through the
102/// configured normalizer and [`StandardFragmenter`]. The full named-key
103/// registry arrives in Phase 0.9.
104#[derive(Clone)]
105pub struct KeyVault {
106    inner: Arc<VaultInner>,
107}
108
109/// Entry in the vault's named-key registry. Holds the fragmented
110/// representation of a key, its name (for audit / threshold tracking),
111/// and non-secret metadata.
112///
113/// Crate-internal. Outside callers see only [`KeyHandle`] which indexes
114/// into this map.
115///
116/// `Clone` is required so the registry's `HashMap` can be cloned during
117/// `ArcSwap::rcu` updates. Cloning an entry copies the name and
118/// metadata (cheap) and bumps the `Arc<Fragments>` refcount — the
119/// underlying `Fragments` storage (`LockedBytes` chunks) is not
120/// duplicated.
121#[derive(Clone)]
122struct KeyEntry {
123    name: String,
124    /// `Fragments` is not `Clone`, so the registry stores `Arc<Fragments>`.
125    /// Rotation produces a new `Arc<Fragments>` and atomically swaps the
126    /// old one out via [`ArcSwap`]; concurrent readers see either the old
127    /// or the new value (never a torn read).
128    fragments: Arc<Fragments>,
129    metadata: KeyMetadata,
130}
131
132struct VaultInner {
133    config: VaultConfig,
134    fragmenter: StandardFragmenter,
135    /// Optional Layer-5 codex. When set, every byte of normalized key
136    /// material passes through `codex.encode()` before being handed to
137    /// the fragmenter; `defragment` applies `codex.decode()` to recover.
138    codex: Option<Arc<dyn Codex>>,
139    /// Layer-8 security monitor. Defaults to a no-op
140    /// [`NoMonitor`](crate::NoMonitor) when no monitor is configured.
141    monitor: Arc<dyn SecurityMonitor>,
142    /// Named-key registry. Lock-free reads via [`ArcSwap`]; writes
143    /// (register, unregister, rotate) build a new `HashMap` and swap
144    /// it in atomically.
145    keys: ArcSwap<HashMap<KeyId, KeyEntry>>,
146    /// Per-key sliding-window failure tracker. Populated by
147    /// [`KeyVault::report_failure`]; consulted by the threshold-detection
148    /// logic to decide whether to trigger lockout.
149    failure_tracker: Mutex<HashMap<String, VecDeque<Instant>>>,
150    /// Set to `true` when the failure-tracker threshold has been crossed.
151    /// `fragment` / `defragment` refuse to operate while this is set;
152    /// `Error::LockedOut` is returned instead.
153    locked_out: AtomicBool,
154    /// Optional master-key credential. Stored as the BLAKE3 hash of the
155    /// supplied master bytes — the plaintext is dropped (and zeroed via
156    /// `RawKey::Drop`) immediately after registration. Used by
157    /// [`KeyVault::unlock_with_master`] as an emergency unlock.
158    master_hash: Option<[u8; 32]>,
159}
160
161impl KeyVault {
162    /// Returns `true` if the vault is in lock-out state.
163    ///
164    /// Lock-out is triggered by the threshold detector when
165    /// [`KeyVault::report_failure`] reports more failures than
166    /// [`VaultConfig::max_failures_before_lockout`] within
167    /// [`VaultConfig::failure_window`]. Once set, [`KeyVault::fragment`]
168    /// and [`KeyVault::defragment`] refuse to proceed and return
169    /// [`Error::LockedOut`](crate::Error::LockedOut). Use
170    /// [`KeyVault::clear_lockout`] to reset.
171    #[must_use]
172    pub fn is_locked_out(&self) -> bool {
173        self.inner.locked_out.load(Ordering::Acquire)
174    }
175
176    /// Clear the lockout flag.
177    ///
178    /// Use this after the operator has resolved the underlying cause —
179    /// e.g. a rotated credential, an investigated alert. Also clears the
180    /// failure tracker; subsequent failures start counting from zero.
181    pub fn clear_lockout(&self) {
182        self.inner.locked_out.store(false, Ordering::Release);
183        if let Ok(mut tracker) = self.inner.failure_tracker.lock() {
184            tracker.clear();
185        }
186    }
187
188    /// Report a key-access failure to the configured monitor and the
189    /// threshold detector.
190    ///
191    /// `key_name` identifies which key the failure pertains to (used for
192    /// per-key threshold tracking and in the monitor event). `note` is
193    /// an optional caller-supplied free-text label; pass `None` if you
194    /// don't have one. **Do not** include key bytes or other secrets in
195    /// the note — it is forwarded verbatim to every configured monitor.
196    ///
197    /// If the per-key failure count within
198    /// [`VaultConfig::failure_window`] reaches
199    /// [`VaultConfig::max_failures_before_lockout`], the vault transitions
200    /// to lock-out state and the monitor's `on_threshold_breach` callback
201    /// fires. A `max_failures` of `0` disables threshold lockout — only
202    /// the per-failure callback runs in that case.
203    pub fn report_failure(&self, key_name: &str, note: Option<&'static str>) {
204        let note = note.map_or(Cow::Borrowed(""), Cow::Borrowed);
205        let (count, oldest_in_window) = self.record_failure(key_name);
206        let window_elapsed = oldest_in_window.map(|t| t.elapsed()).unwrap_or_default();
207
208        // Always fire the per-failure callback first.
209        let ctx = FailureContext {
210            key_name: key_name.to_string(),
211            consecutive_failures: count,
212            window_elapsed,
213            note: note.clone(),
214        };
215        self.inner.monitor.on_decryption_failure(&ctx);
216
217        // Threshold check.
218        let threshold = self.inner.config.max_failures_before_lockout;
219        if threshold > 0 && count >= threshold {
220            // Only lock out once — subsequent calls keep firing
221            // on_decryption_failure but the lockout flag stays set.
222            let was_locked = self.inner.locked_out.swap(true, Ordering::AcqRel);
223            let breach = ThresholdContext {
224                key_name: key_name.to_string(),
225                failures_in_window: count,
226                window: self.inner.config.failure_window,
227                lockout_triggered: !was_locked,
228            };
229            self.inner.monitor.on_threshold_breach(&breach);
230        }
231    }
232
233    /// Report an anomalous (but successful) key access to the monitor.
234    ///
235    /// Useful for "this access pattern looks weird, but we're not going
236    /// to refuse it" cases — unusual time of day, geographic anomaly,
237    /// caller identity that hasn't been seen before. The monitor receives
238    /// an `AccessContext`; the vault state is unaffected.
239    pub fn report_anomalous_access(&self, key_name: &str, note: Option<&'static str>) {
240        let note = note.map_or(Cow::Borrowed(""), Cow::Borrowed);
241        let ctx = AccessContext {
242            key_name: key_name.to_string(),
243            note,
244        };
245        self.inner.monitor.on_anomalous_access(&ctx);
246    }
247
248    /// Append a failure timestamp for `key_name` and evict entries older
249    /// than the configured window. Returns the resulting count and the
250    /// oldest timestamp still in the window (if any).
251    fn record_failure(&self, key_name: &str) -> (u32, Option<Instant>) {
252        let now = Instant::now();
253        let window = self.inner.config.failure_window;
254        let Ok(mut tracker) = self.inner.failure_tracker.lock() else {
255            // Poisoned mutex — treat as a single isolated failure so
256            // monitoring still fires and we don't block legitimate
257            // operations. This branch is effectively unreachable in
258            // practice (the only writer here doesn't panic).
259            return (1, Some(now));
260        };
261        let entries = tracker.entry(key_name.to_string()).or_default();
262        // Evict expired.
263        while let Some(front) = entries.front() {
264            if now.saturating_duration_since(*front) > window {
265                let _ = entries.pop_front();
266            } else {
267                break;
268            }
269        }
270        entries.push_back(now);
271        let count = u32::try_from(entries.len()).unwrap_or(u32::MAX);
272        let oldest = entries.front().copied();
273        (count, oldest)
274    }
275
276    /// Snapshot of the vault's configuration.
277    #[must_use]
278    pub fn config(&self) -> &VaultConfig {
279        &self.inner.config
280    }
281
282    /// Fragment a raw key through the configured normalizer, codex, and
283    /// fragmenter.
284    ///
285    /// The returned [`Fragments`] is opaque; pass it back to
286    /// [`KeyVault::defragment`] to recover the (normalized + codex-encoded)
287    /// bytes inverse-transformed.
288    ///
289    /// # Pipeline
290    ///
291    /// ```text
292    /// key → blake3_normalize (optional) → codex.encode (optional) → fragmenter.fragment → Fragments
293    /// ```
294    ///
295    /// # Errors
296    ///
297    /// Returns whatever the underlying [`FragmentStrategy`] surfaces — in
298    /// practice an [`Error::Fragment`](crate::Error::Fragment) for a
299    /// zero-length input.
300    pub fn fragment(&self, key: &RawKey) -> Result<Fragments> {
301        if self.is_locked_out() {
302            return Err(Error::LockedOut);
303        }
304        let working = if self.inner.config.key_normalization {
305            blake3_normalize(key)
306        } else {
307            RawKey::new(key.as_bytes().to_vec())
308        };
309        let encoded = if let Some(codex) = &self.inner.codex {
310            codex_apply(codex.as_ref(), &working)
311        } else {
312            working
313        };
314        self.inner.fragmenter.fragment(&encoded)
315    }
316
317    /// Reassemble fragments produced by [`KeyVault::fragment`].
318    ///
319    /// Inverts the codex transformation (if configured) so the recovered
320    /// bytes are the normalized key (or the original raw key if
321    /// normalization is off). Defragmentation itself is delegated to the
322    /// configured [`FragmentStrategy`].
323    ///
324    /// # Errors
325    ///
326    /// Returns [`Error::Defragment`](crate::Error::Defragment) when the
327    /// supplied fragments do not match the configured fragmenter's layout.
328    pub fn defragment(&self, fragments: &Fragments) -> Result<RawKey> {
329        if self.is_locked_out() {
330            return Err(Error::LockedOut);
331        }
332        let encoded = self.inner.fragmenter.defragment(fragments)?;
333        if let Some(codex) = &self.inner.codex {
334            Ok(codex_apply(codex.as_ref(), &encoded))
335        } else {
336            Ok(encoded)
337        }
338    }
339
340    // ----- Named-key registry (Phase 0.9) -----
341
342    /// Register a key under a name and return an opaque [`KeyHandle`].
343    ///
344    /// The key bytes are run through the configured normalizer + codex
345    /// pipeline, fragmented, and inserted into the named registry. The
346    /// returned handle is the only way to refer to the key from outside
347    /// the crate; the underlying numeric id is not exposed.
348    ///
349    /// # Errors
350    ///
351    /// - [`Error::LockedOut`](crate::Error::LockedOut) if the vault is
352    ///   currently locked out (threshold-driven).
353    /// - [`Error::InvalidConfig`](crate::Error::InvalidConfig) if a key
354    ///   with the same name is already registered.
355    /// - Whatever the configured fragmenter surfaces (typically
356    ///   [`Error::Fragment`](crate::Error::Fragment) for empty input).
357    // Intentionally take `key` by value: the function consumes the
358    // caller's `RawKey` so its `Drop` impl zeroes the original buffer
359    // as soon as we've fragmented (and copied) the bytes into
360    // mlock'd storage.
361    #[allow(clippy::needless_pass_by_value)]
362    pub fn register(&self, name: impl Into<String>, key: RawKey) -> Result<KeyHandle> {
363        if self.is_locked_out() {
364            return Err(Error::LockedOut);
365        }
366        let name: String = name.into();
367
368        // Reject duplicate names early so callers get a clear error
369        // before paying the fragmentation cost.
370        let snapshot = self.inner.keys.load();
371        if snapshot.values().any(|e| e.name == name) {
372            return Err(Error::InvalidConfig(format!(
373                "key name {name:?} is already registered"
374            )));
375        }
376        drop(snapshot);
377
378        let key_len = key.len();
379        let fragments = self.fragment(&key)?;
380        let handle = KeyHandle::allocate();
381        let now = SystemTime::now()
382            .duration_since(UNIX_EPOCH)
383            .unwrap_or_default();
384        let metadata = KeyMetadata::new(now, key_len, None);
385
386        let entry = KeyEntry {
387            name,
388            fragments: Arc::new(fragments),
389            metadata,
390        };
391
392        // Atomic insert: build a new map containing the entry and swap.
393        let _previous = self.inner.keys.rcu(|current| {
394            let mut new_map = (**current).clone();
395            let _ = new_map.insert(
396                handle.id(),
397                KeyEntry {
398                    name: entry.name.clone(),
399                    fragments: Arc::clone(&entry.fragments),
400                    metadata: entry.metadata.clone(),
401                },
402            );
403            new_map
404        });
405        Ok(handle)
406    }
407
408    /// Remove a registered key from the registry. The key's `Fragments`
409    /// (and their `LockedBytes` chunks) drop and zeroize when the last
410    /// reference goes away.
411    ///
412    /// # Errors
413    ///
414    /// Returns [`Error::KeyNotFound`](crate::Error::KeyNotFound) if no
415    /// key is registered under the given handle.
416    pub fn unregister(&self, handle: KeyHandle) -> Result<()> {
417        let mut removed = false;
418        let _previous = self.inner.keys.rcu(|current| {
419            let mut new_map = (**current).clone();
420            removed = new_map.remove(&handle.id()).is_some();
421            new_map
422        });
423        if removed {
424            Ok(())
425        } else {
426            Err(Error::KeyNotFound)
427        }
428    }
429
430    /// Briefly access the recovered key material inside a callback.
431    ///
432    /// The vault defragments the named key into a temporary [`RawKey`],
433    /// applies the codex decode if configured, and passes the bytes to
434    /// the user-supplied closure. When the closure returns, the
435    /// `RawKey` drops and its bytes are volatile-zeroed.
436    ///
437    /// **The byte slice handed to the closure does not outlive the
438    /// call.** Do not stash it in a longer-lived structure; do your
439    /// cryptographic operation, return, and let the vault scrub the
440    /// buffer.
441    ///
442    /// # Errors
443    ///
444    /// - [`Error::LockedOut`](crate::Error::LockedOut) if the vault is
445    ///   currently locked out.
446    /// - [`Error::KeyNotFound`](crate::Error::KeyNotFound) if no key is
447    ///   registered under the given handle.
448    /// - [`Error::Defragment`](crate::Error::Defragment) on internal
449    ///   inconsistency.
450    pub fn with_key<F, T>(&self, handle: KeyHandle, f: F) -> Result<T>
451    where
452        F: FnOnce(&[u8]) -> T,
453    {
454        if self.is_locked_out() {
455            return Err(Error::LockedOut);
456        }
457        let snapshot = self.inner.keys.load();
458        let entry = snapshot.get(&handle.id()).ok_or(Error::KeyNotFound)?;
459        let fragments = Arc::clone(&entry.fragments);
460        // Drop the snapshot so we don't hold the Arc across the
461        // potentially-slow defragment + user-callback path.
462        drop(snapshot);
463
464        let encoded = self.inner.fragmenter.defragment(&fragments)?;
465        let raw = if let Some(codex) = &self.inner.codex {
466            codex_apply(codex.as_ref(), &encoded)
467        } else {
468            encoded
469        };
470        // `raw` zeroes its bytes on drop at the end of this scope.
471        let result = f(raw.as_bytes());
472        Ok(result)
473    }
474
475    /// Rotate a registered key to new material.
476    ///
477    /// The new key is fragmented and atomically swapped into the
478    /// registry slot. Concurrent [`KeyVault::with_key`] callers see
479    /// either the old or the new fragmentation (never a torn read);
480    /// the old `Fragments` drops once all in-flight readers release
481    /// their `Arc` clones.
482    ///
483    /// The metadata is updated to record the new key length and a fresh
484    /// registration timestamp.
485    ///
486    /// # Errors
487    ///
488    /// - [`Error::LockedOut`](crate::Error::LockedOut)
489    /// - [`Error::KeyNotFound`](crate::Error::KeyNotFound)
490    /// - Fragmenter errors for the new key.
491    // Take `new_key` by value so its `Drop` zeroes the original buffer
492    // after we've fragmented and copied the bytes into mlock'd storage.
493    #[allow(clippy::needless_pass_by_value)]
494    pub fn rotate(&self, handle: KeyHandle, new_key: RawKey) -> Result<()> {
495        if self.is_locked_out() {
496            return Err(Error::LockedOut);
497        }
498
499        // Verify the key exists first so we don't pay the fragmentation
500        // cost on a missing handle.
501        {
502            let snapshot = self.inner.keys.load();
503            if !snapshot.contains_key(&handle.id()) {
504                return Err(Error::KeyNotFound);
505            }
506        }
507
508        let new_len = new_key.len();
509        let new_fragments = Arc::new(self.fragment(&new_key)?);
510        let now = SystemTime::now()
511            .duration_since(UNIX_EPOCH)
512            .unwrap_or_default();
513        let new_metadata = KeyMetadata::new(now, new_len, None);
514
515        let mut found = false;
516        let _previous = self.inner.keys.rcu(|current| {
517            let mut new_map = (**current).clone();
518            if let Some(entry) = new_map.get_mut(&handle.id()) {
519                entry.fragments = Arc::clone(&new_fragments);
520                entry.metadata = new_metadata.clone();
521                found = true;
522            }
523            new_map
524        });
525        if found {
526            Ok(())
527        } else {
528            // Race: handle was unregistered between the check and the
529            // RCU update. Treat as not-found so the caller can react.
530            Err(Error::KeyNotFound)
531        }
532    }
533
534    /// `true` if a key is registered under the given handle.
535    #[must_use]
536    pub fn contains(&self, handle: KeyHandle) -> bool {
537        self.inner.keys.load().contains_key(&handle.id())
538    }
539
540    /// Clone the [`KeyMetadata`] for the given handle.
541    ///
542    /// Returns `None` if the handle is not registered. Metadata is a
543    /// non-secret descriptor (length, registration time, algorithm
544    /// hint) — safe to log and pass around.
545    #[must_use]
546    pub fn metadata(&self, handle: KeyHandle) -> Option<KeyMetadata> {
547        self.inner
548            .keys
549            .load()
550            .get(&handle.id())
551            .map(|e| e.metadata.clone())
552    }
553
554    /// Find the handle registered under `name`, if any.
555    #[must_use]
556    pub fn handle_for_name(&self, name: &str) -> Option<KeyHandle> {
557        self.inner
558            .keys
559            .load()
560            .iter()
561            .find_map(|(id, entry)| (entry.name == name).then(|| KeyHandle::from_id(*id)))
562    }
563
564    /// Number of keys currently registered.
565    #[must_use]
566    pub fn key_count(&self) -> usize {
567        self.inner.keys.load().len()
568    }
569
570    // ----- Master-key emergency unlock (Phase 0.9) -----
571
572    /// Attempt to clear the lockout flag using a master credential.
573    ///
574    /// If the vault has a master key registered (via
575    /// [`KeyVaultBuilder::with_master_key`]) and the supplied bytes
576    /// match the stored BLAKE3 digest in constant time, the lockout is
577    /// cleared and the failure tracker is reset.
578    ///
579    /// On mismatch, the failure is reported to the monitor under the
580    /// reserved key name `"<master>"` and the lockout (if any) remains
581    /// in place. The function never reveals whether the digest matched
582    /// through timing — comparison goes through
583    /// [`subtle::ConstantTimeEq`].
584    ///
585    /// # Errors
586    ///
587    /// - [`Error::InvalidConfig`](crate::Error::InvalidConfig) if no
588    ///   master credential is registered.
589    /// - [`Error::Acquisition`](crate::Error::Acquisition) with source
590    ///   `"master"` on mismatch.
591    pub fn unlock_with_master(&self, attempt: &[u8]) -> Result<()> {
592        let stored = self.inner.master_hash.ok_or_else(|| {
593            Error::InvalidConfig(
594                "vault has no master key registered; pass with_master_key at build time"
595                    .to_string(),
596            )
597        })?;
598        let attempt_hash = blake3::hash(attempt);
599        if bool::from(stored.as_slice().ct_eq(attempt_hash.as_bytes())) {
600            self.clear_lockout();
601            Ok(())
602        } else {
603            // Record as a failure on a reserved name so threshold rules
604            // apply to repeated master-unlock attempts too.
605            self.report_failure("<master>", Some("invalid master credential"));
606            Err(Error::Acquisition {
607                source: Cow::Borrowed("master"),
608                reason: "master credential did not match".to_string(),
609            })
610        }
611    }
612
613    /// `true` if a master credential was registered at build time.
614    #[must_use]
615    pub fn has_master_key(&self) -> bool {
616        self.inner.master_hash.is_some()
617    }
618}
619
620/// Apply a codex's transformation to every byte of a key.
621///
622/// Used both for encoding (pre-fragment) and decoding (post-defragment).
623/// For involution-based codices `decode == encode`; the function name
624/// reflects that — it's a single transformation pass either way.
625fn codex_apply(codex: &dyn Codex, key: &RawKey) -> RawKey {
626    let bytes: Vec<u8> = key.as_bytes().iter().map(|&b| codex.encode(b)).collect();
627    RawKey::new(bytes)
628}
629
630impl core::fmt::Debug for KeyVault {
631    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
632        f.debug_struct("KeyVault")
633            .field("locked_out", &self.is_locked_out())
634            .field("config", &self.inner.config)
635            .finish()
636    }
637}
638
639/// Fluent builder for [`KeyVault`].
640///
641/// The builder is the only way to construct a vault; the inherent
642/// `KeyVault::new` constructor is intentionally not provided so that future
643/// required configuration cannot be silently bypassed.
644#[derive(Clone)]
645pub struct KeyVaultBuilder {
646    config: VaultConfig,
647    fragmenter: StandardFragmenter,
648    codex: Option<Arc<dyn Codex>>,
649    monitor: Option<Arc<dyn SecurityMonitor>>,
650    /// Hash of the master credential, if one was registered. We hold
651    /// the hash (not the plaintext) so the master bytes don't linger
652    /// in the builder's state.
653    master_hash: Option<[u8; 32]>,
654}
655
656impl Default for KeyVaultBuilder {
657    fn default() -> Self {
658        Self::new()
659    }
660}
661
662impl core::fmt::Debug for KeyVaultBuilder {
663    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
664        f.debug_struct("KeyVaultBuilder")
665            .field("config", &self.config)
666            .field("fragmenter", &self.fragmenter)
667            .field("codex", &self.codex.as_ref().map(|_| "<set>"))
668            .field("monitor", &self.monitor.as_ref().map(|_| "<set>"))
669            .field("master_key", &self.master_hash.as_ref().map(|_| "<set>"))
670            .finish()
671    }
672}
673
674impl KeyVaultBuilder {
675    /// Start a new builder with default configuration and a default-range
676    /// [`StandardFragmenter`].
677    #[must_use]
678    pub fn new() -> Self {
679        Self {
680            config: VaultConfig::new(),
681            fragmenter: StandardFragmenter::new(),
682            codex: None,
683            monitor: None,
684            master_hash: None,
685        }
686    }
687
688    /// Enable or disable BLAKE3 normalization of input key material.
689    ///
690    /// Default: `true`. Disabling normalization preserves the original byte
691    /// pattern of the key in storage, which can leak format cues (DER
692    /// envelopes, PEM markers, ASCII-armored data). Disable only when you
693    /// have a specific reason to preserve the original bytes.
694    #[must_use]
695    pub fn normalize_with_blake3(mut self, enabled: bool) -> Self {
696        self.config.key_normalization = enabled;
697        self
698    }
699
700    /// Customize the fragmenter chunk-size range.
701    ///
702    /// Defaults are documented on [`StandardFragmenter::new`]. `min` is
703    /// clamped to `>= 1` and `max` to `>= min`. Calling this replaces any
704    /// previously-configured chunk range and resets the decoy strategy to
705    /// `None`; configure decoy *after* this call.
706    #[must_use]
707    pub fn with_chunk_range(mut self, min: usize, max: usize) -> Self {
708        self.fragmenter = StandardFragmenter::with_chunk_range(min, max);
709        self
710    }
711
712    /// Attach a Layer-5 codex to the vault.
713    ///
714    /// When set, every byte of the (optionally BLAKE3-normalized) key
715    /// passes through `codex.encode()` before being handed to the
716    /// fragmenter; `defragment` applies `codex.decode()` to recover the
717    /// original bytes. For involution-based codices ([`StaticCodex`](crate::StaticCodex),
718    /// [`DynamicCodex`](crate::DynamicCodex), involution closures wrapped in
719    /// [`FnCodex`](crate::codex::FnCodex)) `decode == encode`, but the
720    /// vault calls them by name so non-involution codices would also
721    /// work in principle.
722    ///
723    /// The codex is held in an `Arc<dyn Codex>` so the same codex can be
724    /// shared across multiple vaults (rarely useful — usually each vault
725    /// wants its own [`DynamicCodex`](crate::DynamicCodex)).
726    ///
727    /// # Examples
728    ///
729    /// ```
730    /// use key_vault::{DynamicCodex, KeyVaultBuilder};
731    ///
732    /// let vault = KeyVaultBuilder::new()
733    ///     .with_codex(DynamicCodex::new().unwrap())
734    ///     .build();
735    /// // The vault now applies the codex transformation transparently
736    /// // on every fragment / defragment.
737    /// # let _ = vault;
738    /// ```
739    #[must_use]
740    pub fn with_codex<C>(mut self, codex: C) -> Self
741    where
742        C: Codex + 'static,
743    {
744        self.codex = Some(Arc::new(codex));
745        self
746    }
747
748    /// Attach a Layer-4 decoy strategy to the underlying fragmenter.
749    ///
750    /// When set, every `KeyVault::fragment` call also produces decoy chunks
751    /// from the strategy. Decoys are interleaved with real chunks via the
752    /// same Fisher-Yates shuffle and are skipped by `defragment`. See
753    /// [`StandardFragmenter::with_decoy`] for details on chunk-count and
754    /// size selection.
755    ///
756    /// Use [`SelfReferenceDecoy`](crate::SelfReferenceDecoy) for the
757    /// strongest statistical indistinguishability (recommended default);
758    /// [`KeyDerivedDecoy`](crate::KeyDerivedDecoy) for BLAKE3-XOF–derived
759    /// CSPRNG-like output;
760    /// [`RandomDecoy`](crate::RandomDecoy) for raw CSPRNG output.
761    #[must_use]
762    pub fn with_decoy<D>(mut self, decoy: D) -> Self
763    where
764        D: DecoyStrategy + 'static,
765    {
766        self.fragmenter = self.fragmenter.with_decoy(decoy);
767        self
768    }
769
770    /// Attach a Layer-8 security monitor.
771    ///
772    /// Replaces any previously-configured monitor. The monitor receives
773    /// every event the vault produces — failure callbacks via
774    /// [`KeyVault::report_failure`], anomaly callbacks via
775    /// [`KeyVault::report_anomalous_access`], and threshold-breach
776    /// callbacks when the failure tracker fires.
777    ///
778    /// Default is [`NoMonitor`](crate::NoMonitor) — events go nowhere
779    /// but threshold-driven lockout still works (lockout state is owned
780    /// by the vault, not the monitor).
781    #[must_use]
782    pub fn with_monitor<M>(mut self, monitor: M) -> Self
783    where
784        M: SecurityMonitor + 'static,
785    {
786        self.monitor = Some(Arc::new(monitor));
787        self
788    }
789
790    /// Configure the failure-threshold detector.
791    ///
792    /// When [`KeyVault::report_failure`] records `max` failures for the
793    /// same `key_name` within `window`, the vault transitions to
794    /// lock-out state and the monitor's `on_threshold_breach` fires.
795    ///
796    /// Pass `max = 0` to disable threshold lockout (the default). The
797    /// vault will still forward every failure to the monitor; it just
798    /// won't lock out on its own.
799    ///
800    /// `window` is the sliding-window size for the per-key failure
801    /// counter; failures older than this fall off and no longer count.
802    #[must_use]
803    pub fn with_failure_threshold(mut self, max: u32, window: Duration) -> Self {
804        self.config.max_failures_before_lockout = max;
805        self.config.failure_window = window;
806        self
807    }
808
809    /// Register a master credential for emergency unlock.
810    ///
811    /// The vault stores the **BLAKE3 hash** of the supplied bytes; the
812    /// plaintext is dropped immediately (and zeroed via
813    /// `RawKey::Drop`). Use [`KeyVault::unlock_with_master`] later to
814    /// clear a threshold-driven lockout.
815    ///
816    /// Calling this twice replaces the previously-stored hash. Pass an
817    /// empty key (zero-length) to register a meaningless "match
818    /// anything" credential — strongly discouraged; the function does
819    /// not reject it for symmetry with the rest of the builder API.
820    #[must_use]
821    pub fn with_master_key(mut self, master: RawKey) -> Self {
822        let hash = blake3::hash(master.as_bytes());
823        let mut bytes = [0u8; 32];
824        bytes.copy_from_slice(hash.as_bytes());
825        self.master_hash = Some(bytes);
826        // `master` drops here; its `Drop` impl zeroes the internal Vec.
827        drop(master);
828        self
829    }
830
831    /// Finalize and produce a [`KeyVault`].
832    ///
833    /// Infallible in this phase — later phases may move this to a
834    /// `Result`-returning shape if validation is added.
835    #[must_use]
836    pub fn build(self) -> KeyVault {
837        let monitor: Arc<dyn SecurityMonitor> = self
838            .monitor
839            .unwrap_or_else(|| Arc::new(crate::monitor::NoMonitor));
840        KeyVault {
841            inner: Arc::new(VaultInner {
842                config: self.config,
843                fragmenter: self.fragmenter,
844                codex: self.codex,
845                monitor,
846                keys: ArcSwap::from_pointee(HashMap::new()),
847                failure_tracker: Mutex::new(HashMap::new()),
848                locked_out: AtomicBool::new(false),
849                master_hash: self.master_hash,
850            }),
851        }
852    }
853}
854
855#[cfg(test)]
856#[allow(clippy::unwrap_used, clippy::expect_used)]
857mod tests {
858    use super::*;
859    use alloc::format;
860
861    #[test]
862    fn builder_defaults_to_normalization_on() {
863        let v = KeyVaultBuilder::new().build();
864        assert!(v.config().key_normalization);
865    }
866
867    #[test]
868    fn builder_can_disable_normalization() {
869        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
870        assert!(!v.config().key_normalization);
871    }
872
873    #[test]
874    fn fresh_vault_is_not_locked_out() {
875        let v = KeyVaultBuilder::new().build();
876        assert!(!v.is_locked_out());
877    }
878
879    #[test]
880    fn debug_does_not_panic() {
881        let v = KeyVaultBuilder::new().build();
882        let _ = format!("{v:?}");
883    }
884
885    #[test]
886    fn fragment_defragment_roundtrip_with_normalization() {
887        let v = KeyVaultBuilder::new().build(); // normalization on
888        let raw = RawKey::new(b"hello world".to_vec());
889        let frags = v.fragment(&raw).unwrap();
890        let recovered = v.defragment(&frags).unwrap();
891        // With normalization on, the output is the BLAKE3 hash (32 bytes),
892        // not the original 11-byte input.
893        assert_eq!(recovered.len(), 32);
894        // It is deterministic — fragmenting the same input twice produces the
895        // same recovered bytes (the bytes themselves; layout still varies).
896        let frags2 = v.fragment(&raw).unwrap();
897        let recovered2 = v.defragment(&frags2).unwrap();
898        assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
899    }
900
901    #[test]
902    fn fragment_defragment_roundtrip_without_normalization() {
903        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
904        let raw = RawKey::new((0u8..40).collect());
905        let frags = v.fragment(&raw).unwrap();
906        let recovered = v.defragment(&frags).unwrap();
907        assert_eq!(recovered.as_bytes(), raw.as_bytes());
908    }
909
910    #[test]
911    fn fragment_rejects_empty_key() {
912        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
913        let err = v
914            .fragment(&RawKey::new(alloc::vec::Vec::new()))
915            .unwrap_err();
916        assert!(matches!(err, crate::Error::Fragment(_)));
917    }
918
919    #[test]
920    fn chunk_range_propagates_through_builder() {
921        let v = KeyVaultBuilder::new()
922            .normalize_with_blake3(false)
923            .with_chunk_range(4, 6)
924            .build();
925        let raw = RawKey::new((0u8..30).collect());
926        let frags = v.fragment(&raw).unwrap();
927
928        // After fragmentation, chunks have been Fisher-Yates shuffled, so the
929        // "remainder" chunk (which the size-sampling loop allows to fall below
930        // `min` when the total doesn't divide cleanly) can land at any index.
931        // We verify the post-shuffle invariants instead of indexing by order:
932        //   1. Every chunk fits in [1, max].
933        //   2. At most one chunk falls below `min` (the remainder slot).
934        //   3. Total bytes sum to the original length.
935        let chunks = frags.chunks();
936        let mut below_min = 0;
937        let mut total = 0usize;
938        for c in chunks {
939            assert!(
940                c.len() >= 1 && c.len() <= 6,
941                "chunk size {} not in [1,6]",
942                c.len()
943            );
944            if c.len() < 4 {
945                below_min += 1;
946            }
947            total += c.len();
948        }
949        assert!(
950            below_min <= 1,
951            "more than one chunk below min size: {below_min}"
952        );
953        assert_eq!(total, 30);
954    }
955
956    #[test]
957    fn fragment_with_random_decoy_roundtrips() {
958        let v = KeyVaultBuilder::new()
959            .normalize_with_blake3(false)
960            .with_decoy(crate::RandomDecoy)
961            .build();
962        let raw = RawKey::new((0u8..32).collect());
963        let frags = v.fragment(&raw).unwrap();
964        // Chunk count is real + decoy (roughly 2x the real count).
965        // Defragment must skip the decoys and return the original bytes.
966        let recovered = v.defragment(&frags).unwrap();
967        assert_eq!(recovered.as_bytes(), raw.as_bytes());
968    }
969
970    #[test]
971    fn fragment_with_self_reference_decoy_roundtrips() {
972        let v = KeyVaultBuilder::new()
973            .normalize_with_blake3(false)
974            .with_decoy(crate::SelfReferenceDecoy)
975            .build();
976        let raw = RawKey::new(b"some user-supplied key material".to_vec());
977        let frags = v.fragment(&raw).unwrap();
978        let recovered = v.defragment(&frags).unwrap();
979        assert_eq!(recovered.as_bytes(), raw.as_bytes());
980    }
981
982    #[test]
983    fn fragment_with_key_derived_decoy_roundtrips() {
984        let v = KeyVaultBuilder::new()
985            .normalize_with_blake3(false)
986            .with_decoy(crate::KeyDerivedDecoy)
987            .build();
988        let raw = RawKey::new((0u8..64).collect());
989        let frags = v.fragment(&raw).unwrap();
990        let recovered = v.defragment(&frags).unwrap();
991        assert_eq!(recovered.as_bytes(), raw.as_bytes());
992    }
993
994    #[test]
995    fn decoy_increases_chunk_count_relative_to_no_decoy() {
996        let no_decoy = KeyVaultBuilder::new()
997            .normalize_with_blake3(false)
998            .with_chunk_range(2, 4)
999            .build();
1000        let with_decoy = KeyVaultBuilder::new()
1001            .normalize_with_blake3(false)
1002            .with_chunk_range(2, 4)
1003            .with_decoy(crate::SelfReferenceDecoy)
1004            .build();
1005        let raw = RawKey::new((0u8..32).collect());
1006
1007        // The total chunk count is randomized per fragmentation, so average
1008        // over a few runs to get a stable comparison. The decoy-enabled
1009        // vault should average ~2x the chunks.
1010        let mut no_decoy_total = 0usize;
1011        let mut decoy_total = 0usize;
1012        for _ in 0..8 {
1013            no_decoy_total += no_decoy.fragment(&raw).unwrap().chunk_count();
1014            decoy_total += with_decoy.fragment(&raw).unwrap().chunk_count();
1015        }
1016        // The decoy-enabled vault adds one decoy chunk per real chunk, so
1017        // its total chunk count should be exactly twice the no-decoy count
1018        // (modulo per-call sampling that affects the real-chunk count
1019        // identically). Allow some slack for the random sampling variance.
1020        assert!(
1021            decoy_total > no_decoy_total,
1022            "decoy vault produced {decoy_total} chunks vs no-decoy {no_decoy_total}"
1023        );
1024    }
1025
1026    #[test]
1027    fn fragment_with_static_codex_roundtrips() {
1028        use crate::StaticCodex;
1029        let codex = StaticCodex::from_swaps(&[(b'A', b'#'), (b'0', b'%')]).unwrap();
1030        let v = KeyVaultBuilder::new()
1031            .normalize_with_blake3(false)
1032            .with_codex(codex)
1033            .build();
1034        let raw = RawKey::new(b"A0A0A0A0".to_vec());
1035        let frags = v.fragment(&raw).unwrap();
1036        let recovered = v.defragment(&frags).unwrap();
1037        // Codex round-trips: the recovered bytes are the original
1038        // (pre-encode) bytes, not the encoded ones.
1039        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1040    }
1041
1042    #[test]
1043    fn fragment_with_dynamic_codex_roundtrips() {
1044        use crate::DynamicCodex;
1045        let v = KeyVaultBuilder::new()
1046            .normalize_with_blake3(false)
1047            .with_codex(DynamicCodex::new().unwrap())
1048            .build();
1049        let raw = RawKey::new((0u8..=255).collect());
1050        let frags = v.fragment(&raw).unwrap();
1051        let recovered = v.defragment(&frags).unwrap();
1052        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1053    }
1054
1055    #[test]
1056    fn fragment_with_codex_and_decoy_and_normalization_roundtrips() {
1057        use crate::{DynamicCodex, SelfReferenceDecoy};
1058        // All layers stacked: BLAKE3 normalize + DynamicCodex encode +
1059        // StandardFragmenter w/ SelfReferenceDecoy. Must still round-trip.
1060        let v = KeyVaultBuilder::new()
1061            .normalize_with_blake3(true)
1062            .with_codex(DynamicCodex::new().unwrap())
1063            .with_decoy(SelfReferenceDecoy)
1064            .build();
1065        let raw = RawKey::new(b"my application key".to_vec());
1066        let frags = v.fragment(&raw).unwrap();
1067        let recovered = v.defragment(&frags).unwrap();
1068        // With normalization on, recovered is 32 bytes (BLAKE3 hash).
1069        // It must be deterministic given the same input.
1070        assert_eq!(recovered.len(), 32);
1071        let recovered2 = v.defragment(&v.fragment(&raw).unwrap()).unwrap();
1072        assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
1073    }
1074
1075    #[test]
1076    fn codex_visibly_transforms_stored_bytes() {
1077        // Without codex, the fragment chunks contain the original bytes
1078        // somewhere among them. With a non-identity codex, the stored
1079        // bytes should differ — we verify by checking that some chunk
1080        // contains a transformed byte not in the original input.
1081        use crate::StaticCodex;
1082        let v = KeyVaultBuilder::new()
1083            .normalize_with_blake3(false)
1084            // Force every byte to swap with a distinct partner.
1085            .with_codex(crate::DynamicCodex::new().unwrap())
1086            .build();
1087        let raw = RawKey::new(alloc::vec![0xaa; 8]);
1088        let frags = v.fragment(&raw).unwrap();
1089
1090        // Walk chunks and confirm at least one byte is *not* 0xaa
1091        // (the codex encoded 0xaa to something else).
1092        let mut saw_non_aa = false;
1093        for chunk in frags.chunks() {
1094            for &b in chunk.as_bytes() {
1095                if b != 0xaa {
1096                    saw_non_aa = true;
1097                    break;
1098                }
1099            }
1100            if saw_non_aa {
1101                break;
1102            }
1103        }
1104        assert!(
1105            saw_non_aa,
1106            "codex did not transform 0xaa — stored bytes still all 0xaa",
1107        );
1108
1109        // And defragment recovers the original 0xaa bytes.
1110        let recovered = v.defragment(&frags).unwrap();
1111        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1112        // Use the `_codex` import to keep the import non-dead.
1113        let _ = StaticCodex::from_swaps(&[]).unwrap();
1114    }
1115
1116    // ----- Layer 8: monitor + threshold tests -----
1117
1118    use core::sync::atomic::AtomicU32;
1119
1120    /// Helper monitor that counts each callback invocation.
1121    struct CountingMonitor {
1122        failures: AtomicU32,
1123        anomalies: AtomicU32,
1124        breaches: AtomicU32,
1125    }
1126
1127    impl CountingMonitor {
1128        fn new() -> Self {
1129            Self {
1130                failures: AtomicU32::new(0),
1131                anomalies: AtomicU32::new(0),
1132                breaches: AtomicU32::new(0),
1133            }
1134        }
1135    }
1136
1137    impl SecurityMonitor for CountingMonitor {
1138        fn on_decryption_failure(&self, _ctx: &FailureContext) {
1139            let _ = self.failures.fetch_add(1, Ordering::SeqCst);
1140        }
1141        fn on_anomalous_access(&self, _ctx: &AccessContext) {
1142            let _ = self.anomalies.fetch_add(1, Ordering::SeqCst);
1143        }
1144        fn on_threshold_breach(&self, _ctx: &ThresholdContext) {
1145            let _ = self.breaches.fetch_add(1, Ordering::SeqCst);
1146        }
1147    }
1148
1149    #[test]
1150    fn report_failure_fires_monitor() {
1151        let monitor = Arc::new(CountingMonitor::new());
1152        let v = KeyVaultBuilder::new()
1153            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1154            .build();
1155        v.report_failure("k", None);
1156        v.report_failure("k", Some("test note"));
1157        assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
1158        assert_eq!(monitor.breaches.load(Ordering::SeqCst), 0);
1159        assert!(!v.is_locked_out());
1160    }
1161
1162    #[test]
1163    fn report_anomalous_access_fires_monitor() {
1164        let monitor = Arc::new(CountingMonitor::new());
1165        let v = KeyVaultBuilder::new()
1166            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1167            .build();
1168        v.report_anomalous_access("k", None);
1169        assert_eq!(monitor.anomalies.load(Ordering::SeqCst), 1);
1170        assert!(!v.is_locked_out());
1171    }
1172
1173    #[test]
1174    fn threshold_lockout_fires_after_max_failures() {
1175        let monitor = Arc::new(CountingMonitor::new());
1176        let v = KeyVaultBuilder::new()
1177            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1178            .with_failure_threshold(3, Duration::from_secs(30))
1179            .build();
1180
1181        v.report_failure("k", None);
1182        assert!(!v.is_locked_out());
1183        v.report_failure("k", None);
1184        assert!(!v.is_locked_out());
1185        v.report_failure("k", None);
1186        // Three failures in the window → lockout.
1187        assert!(v.is_locked_out());
1188        assert_eq!(monitor.failures.load(Ordering::SeqCst), 3);
1189        assert_eq!(monitor.breaches.load(Ordering::SeqCst), 1);
1190
1191        // Subsequent failures keep counting but only one breach event fires
1192        // until clear_lockout resets the flag.
1193        v.report_failure("k", None);
1194        assert!(v.is_locked_out());
1195        assert_eq!(monitor.failures.load(Ordering::SeqCst), 4);
1196        // Breach event count grows but lockout_triggered is false now.
1197        assert_eq!(monitor.breaches.load(Ordering::SeqCst), 2);
1198    }
1199
1200    #[test]
1201    fn fragment_refuses_when_locked_out() {
1202        let v = KeyVaultBuilder::new()
1203            .normalize_with_blake3(false)
1204            .with_failure_threshold(1, Duration::from_secs(30))
1205            .build();
1206        v.report_failure("k", None);
1207        assert!(v.is_locked_out());
1208
1209        let err = v
1210            .fragment(&RawKey::new(alloc::vec![1u8, 2, 3, 4]))
1211            .unwrap_err();
1212        assert!(matches!(err, Error::LockedOut));
1213    }
1214
1215    #[test]
1216    fn defragment_refuses_when_locked_out() {
1217        let v = KeyVaultBuilder::new()
1218            .normalize_with_blake3(false)
1219            .with_failure_threshold(2, Duration::from_secs(30))
1220            .build();
1221        // Produce a fragment before lockout.
1222        let raw = RawKey::new(alloc::vec![1u8; 16]);
1223        let frags = v.fragment(&raw).unwrap();
1224        v.report_failure("k", None);
1225        v.report_failure("k", None);
1226        assert!(v.is_locked_out());
1227
1228        let err = v.defragment(&frags).unwrap_err();
1229        assert!(matches!(err, Error::LockedOut));
1230    }
1231
1232    #[test]
1233    fn clear_lockout_resets_state() {
1234        let v = KeyVaultBuilder::new()
1235            .with_failure_threshold(1, Duration::from_secs(30))
1236            .build();
1237        v.report_failure("k", None);
1238        assert!(v.is_locked_out());
1239        v.clear_lockout();
1240        assert!(!v.is_locked_out());
1241        // Failure tracker also cleared — next single failure shouldn't lock
1242        // again immediately (threshold is 1, so it WILL lock, but starting
1243        // count is fresh — verifies tracker was cleared by counting
1244        // monitor breaches).
1245        // Actually with threshold=1 a single failure re-locks. So instead
1246        // assert via tracker contents indirectly: a second `clear_lockout`
1247        // call is a no-op.
1248        v.clear_lockout();
1249        assert!(!v.is_locked_out());
1250    }
1251
1252    #[test]
1253    fn per_key_failure_counts_are_independent() {
1254        let monitor = Arc::new(CountingMonitor::new());
1255        let v = KeyVaultBuilder::new()
1256            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1257            .with_failure_threshold(2, Duration::from_secs(30))
1258            .build();
1259        v.report_failure("alpha", None);
1260        v.report_failure("beta", None);
1261        // One failure each — neither hits the threshold.
1262        assert!(!v.is_locked_out());
1263        assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
1264        v.report_failure("alpha", None);
1265        // alpha now has 2 — triggers lockout.
1266        assert!(v.is_locked_out());
1267    }
1268
1269    // ----- Phase 0.9: registry + rotation + master-key tests -----
1270
1271    #[test]
1272    fn register_returns_handle_and_increments_count() {
1273        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1274        assert_eq!(v.key_count(), 0);
1275        let h = v
1276            .register("primary", RawKey::new(alloc::vec![1u8; 32]))
1277            .unwrap();
1278        assert_eq!(v.key_count(), 1);
1279        assert!(v.contains(h));
1280    }
1281
1282    #[test]
1283    fn register_rejects_duplicate_name() {
1284        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1285        let _ = v
1286            .register("primary", RawKey::new(alloc::vec![1u8; 16]))
1287            .unwrap();
1288        let err = v
1289            .register("primary", RawKey::new(alloc::vec![2u8; 16]))
1290            .unwrap_err();
1291        assert!(matches!(err, Error::InvalidConfig(_)));
1292    }
1293
1294    #[test]
1295    fn unregister_removes_key() {
1296        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1297        let h = v
1298            .register("primary", RawKey::new(alloc::vec![1u8; 16]))
1299            .unwrap();
1300        assert!(v.contains(h));
1301        v.unregister(h).unwrap();
1302        assert!(!v.contains(h));
1303        assert_eq!(v.key_count(), 0);
1304    }
1305
1306    #[test]
1307    fn unregister_unknown_handle_errors() {
1308        let v = KeyVaultBuilder::new().build();
1309        let h = KeyHandle::__for_test();
1310        let err = v.unregister(h).unwrap_err();
1311        assert!(matches!(err, Error::KeyNotFound));
1312    }
1313
1314    #[test]
1315    fn with_key_round_trips_bytes() {
1316        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1317        let original = alloc::vec![0xa5u8; 32];
1318        let h = v.register("data", RawKey::new(original.clone())).unwrap();
1319        let observed = v.with_key(h, <[u8]>::to_vec).unwrap();
1320        assert_eq!(observed, original);
1321    }
1322
1323    #[test]
1324    fn with_key_normalization_changes_output_length() {
1325        let v = KeyVaultBuilder::new().build(); // normalization ON
1326        let h = v
1327            .register("data", RawKey::new(alloc::vec![0xa5; 17]))
1328            .unwrap();
1329        let observed_len = v.with_key(h, <[u8]>::len).unwrap();
1330        // BLAKE3 normalization → 32-byte output regardless of input.
1331        assert_eq!(observed_len, 32);
1332    }
1333
1334    #[test]
1335    fn with_key_unknown_handle_errors() {
1336        let v = KeyVaultBuilder::new().build();
1337        let h = KeyHandle::__for_test();
1338        let err = v.with_key(h, |_| ()).unwrap_err();
1339        assert!(matches!(err, Error::KeyNotFound));
1340    }
1341
1342    #[test]
1343    fn rotate_swaps_key_bytes() {
1344        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1345        let h = v
1346            .register("data", RawKey::new(alloc::vec![1u8; 16]))
1347            .unwrap();
1348
1349        v.rotate(h, RawKey::new(alloc::vec![2u8; 16])).unwrap();
1350        let observed = v.with_key(h, <[u8]>::to_vec).unwrap();
1351        assert_eq!(observed, alloc::vec![2u8; 16]);
1352    }
1353
1354    #[test]
1355    fn rotate_unknown_handle_errors() {
1356        let v = KeyVaultBuilder::new().build();
1357        let h = KeyHandle::__for_test();
1358        let err = v.rotate(h, RawKey::new(alloc::vec![0u8; 16])).unwrap_err();
1359        assert!(matches!(err, Error::KeyNotFound));
1360    }
1361
1362    #[test]
1363    fn handle_for_name_finds_registered_key() {
1364        let v = KeyVaultBuilder::new().build();
1365        let h = v
1366            .register("primary", RawKey::new(alloc::vec![0u8; 16]))
1367            .unwrap();
1368        assert_eq!(v.handle_for_name("primary"), Some(h));
1369        assert_eq!(v.handle_for_name("missing"), None);
1370    }
1371
1372    #[test]
1373    fn metadata_records_registration_length() {
1374        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1375        let h = v
1376            .register("data", RawKey::new(alloc::vec![0u8; 42]))
1377            .unwrap();
1378        let meta = v.metadata(h).expect("metadata");
1379        assert_eq!(meta.length(), 42);
1380    }
1381
1382    #[test]
1383    fn registered_key_refuses_access_when_locked_out() {
1384        let v = KeyVaultBuilder::new()
1385            .with_failure_threshold(1, Duration::from_secs(30))
1386            .build();
1387        let h = v
1388            .register("data", RawKey::new(alloc::vec![0xa5; 16]))
1389            .unwrap();
1390        v.report_failure("data", None);
1391        assert!(v.is_locked_out());
1392
1393        let err = v.with_key(h, |_| ()).unwrap_err();
1394        assert!(matches!(err, Error::LockedOut));
1395        let err = v.rotate(h, RawKey::new(alloc::vec![0u8; 16])).unwrap_err();
1396        assert!(matches!(err, Error::LockedOut));
1397    }
1398
1399    #[test]
1400    fn master_key_unlock_clears_lockout_on_match() {
1401        let master_bytes = b"correct horse battery staple".to_vec();
1402        let v = KeyVaultBuilder::new()
1403            .with_master_key(RawKey::new(master_bytes.clone()))
1404            .with_failure_threshold(1, Duration::from_secs(30))
1405            .build();
1406        assert!(v.has_master_key());
1407
1408        v.report_failure("k", None);
1409        assert!(v.is_locked_out());
1410
1411        // Wrong master → still locked.
1412        let err = v.unlock_with_master(b"wrong").unwrap_err();
1413        assert!(matches!(err, Error::Acquisition { .. }));
1414        assert!(v.is_locked_out());
1415
1416        // Correct master → unlocked.
1417        v.unlock_with_master(&master_bytes).unwrap();
1418        assert!(!v.is_locked_out());
1419    }
1420
1421    #[test]
1422    fn master_key_unlock_without_registered_master_errors() {
1423        let v = KeyVaultBuilder::new().build();
1424        assert!(!v.has_master_key());
1425        let err = v.unlock_with_master(b"anything").unwrap_err();
1426        assert!(matches!(err, Error::InvalidConfig(_)));
1427    }
1428
1429    #[test]
1430    fn composite_monitor_chains_to_all_inner() {
1431        use crate::CompositeMonitor;
1432        let a = Arc::new(CountingMonitor::new());
1433        let b = Arc::new(CountingMonitor::new());
1434        let composite = CompositeMonitor::new(alloc::vec![
1435            Arc::clone(&a) as Arc<dyn SecurityMonitor>,
1436            Arc::clone(&b) as Arc<dyn SecurityMonitor>,
1437        ]);
1438        let v = KeyVaultBuilder::new()
1439            .with_monitor(composite)
1440            .with_failure_threshold(1, Duration::from_secs(30))
1441            .build();
1442        v.report_failure("k", None);
1443        assert_eq!(a.failures.load(Ordering::SeqCst), 1);
1444        assert_eq!(b.failures.load(Ordering::SeqCst), 1);
1445        assert_eq!(a.breaches.load(Ordering::SeqCst), 1);
1446        assert_eq!(b.breaches.load(Ordering::SeqCst), 1);
1447    }
1448}