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