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 total = fragments.total_len();
521        let codex = self.inner.codex.clone();
522        let result = SCRATCH.with(|cell| -> Result<T> {
523            let mut buf = cell.borrow_mut();
524            // Grow lazily; the buffer is reused across calls so the
525            // typical steady state is a no-op resize.
526            if buf.len() < total {
527                buf.resize(total, 0);
528            }
529            // Defragment directly into the scratch — no `RawKey` /
530            // `Vec<u8>` allocation on the hot path.
531            self.inner
532                .fragmenter
533                .defragment_into(&fragments, &mut buf[..total])?;
534            // Codex decode in place (also alloc-free).
535            if let Some(c) = &codex {
536                codex_decode_in_place(c.as_ref(), &mut buf[..total]);
537            }
538            // Run the user callback under a guard that volatile-zeros
539            // the scratch on the way out — even if the callback panics.
540            let guard = ZeroOnExit(&mut buf[..total]);
541            Ok(f(&*guard.0))
542        })?;
543        if let Some(name) = name {
544            self.emit_audit(&name, AccessKind::Read, Cow::Borrowed(""));
545        }
546        Ok(result)
547    }
548
549    /// Rotate a registered key to new material.
550    ///
551    /// The new key is fragmented and atomically swapped into the
552    /// registry slot. Concurrent [`KeyVault::with_key`] callers see
553    /// either the old or the new fragmentation (never a torn read);
554    /// the old `Fragments` drops once all in-flight readers release
555    /// their `Arc` clones.
556    ///
557    /// The metadata is updated to record the new key length and a fresh
558    /// registration timestamp.
559    ///
560    /// # Errors
561    ///
562    /// - [`Error::LockedOut`](crate::Error::LockedOut)
563    /// - [`Error::KeyNotFound`](crate::Error::KeyNotFound)
564    /// - Fragmenter errors for the new key.
565    // Take `new_key` by value so its `Drop` zeroes the original buffer
566    // after we've fragmented and copied the bytes into mlock'd storage.
567    #[allow(clippy::needless_pass_by_value)]
568    pub fn rotate(&self, handle: KeyHandle, new_key: RawKey) -> Result<()> {
569        if self.is_locked_out() {
570            return Err(Error::LockedOut);
571        }
572
573        // Verify the key exists first (and capture its name for the
574        // audit event) so we don't pay the fragmentation cost on a
575        // missing handle.
576        let name = {
577            let snapshot = self.inner.keys.load();
578            snapshot
579                .get(&handle.id())
580                .map(|e| e.name.clone())
581                .ok_or(Error::KeyNotFound)?
582        };
583
584        let new_len = new_key.len();
585        let new_fragments = Arc::new(self.fragment(&new_key)?);
586        let now = SystemTime::now()
587            .duration_since(UNIX_EPOCH)
588            .unwrap_or_default();
589        let new_metadata = KeyMetadata::new(now, new_len, None);
590
591        let mut found = false;
592        let _previous = self.inner.keys.rcu(|current| {
593            let mut new_map = (**current).clone();
594            if let Some(entry) = new_map.get_mut(&handle.id()) {
595                entry.fragments = Arc::clone(&new_fragments);
596                entry.metadata = new_metadata.clone();
597                found = true;
598            }
599            new_map
600        });
601        if found {
602            self.emit_audit(&name, AccessKind::Rotate, Cow::Borrowed(""));
603            Ok(())
604        } else {
605            // Race: handle was unregistered between the check and the
606            // RCU update. Treat as not-found so the caller can react.
607            Err(Error::KeyNotFound)
608        }
609    }
610
611    /// `true` if a key is registered under the given handle.
612    #[must_use]
613    pub fn contains(&self, handle: KeyHandle) -> bool {
614        self.inner.keys.load().contains_key(&handle.id())
615    }
616
617    /// Clone the [`KeyMetadata`] for the given handle.
618    ///
619    /// Returns `None` if the handle is not registered. Metadata is a
620    /// non-secret descriptor (length, registration time, algorithm
621    /// hint) — safe to log and pass around.
622    #[must_use]
623    pub fn metadata(&self, handle: KeyHandle) -> Option<KeyMetadata> {
624        self.inner
625            .keys
626            .load()
627            .get(&handle.id())
628            .map(|e| e.metadata.clone())
629    }
630
631    /// Find the handle registered under `name`, if any.
632    #[must_use]
633    pub fn handle_for_name(&self, name: &str) -> Option<KeyHandle> {
634        self.inner
635            .keys
636            .load()
637            .iter()
638            .find_map(|(id, entry)| (entry.name == name).then(|| KeyHandle::from_id(*id)))
639    }
640
641    /// Number of keys currently registered.
642    #[must_use]
643    pub fn key_count(&self) -> usize {
644        self.inner.keys.load().len()
645    }
646
647    // ----- Master-key emergency unlock (Phase 0.9) -----
648
649    /// Attempt to clear the lockout flag using a master credential.
650    ///
651    /// If the vault has a master key registered (via
652    /// [`KeyVaultBuilder::with_master_key`]) and the supplied bytes
653    /// match the stored BLAKE3 digest in constant time, the lockout is
654    /// cleared and the failure tracker is reset.
655    ///
656    /// On mismatch, the failure is reported to the monitor under the
657    /// reserved key name `"<master>"` and the lockout (if any) remains
658    /// in place. The function never reveals whether the digest matched
659    /// through timing — comparison goes through
660    /// [`subtle::ConstantTimeEq`].
661    ///
662    /// # Errors
663    ///
664    /// - [`Error::InvalidConfig`](crate::Error::InvalidConfig) if no
665    ///   master credential is registered.
666    /// - [`Error::Acquisition`](crate::Error::Acquisition) with source
667    ///   `"master"` on mismatch.
668    pub fn unlock_with_master(&self, attempt: &[u8]) -> Result<()> {
669        let stored = self.inner.master_hash.ok_or_else(|| {
670            Error::InvalidConfig(
671                "vault has no master key registered; pass with_master_key at build time"
672                    .to_string(),
673            )
674        })?;
675        let attempt_hash = blake3::hash(attempt);
676        let matched = bool::from(stored.as_slice().ct_eq(attempt_hash.as_bytes()));
677        self.emit_audit(
678            "<master>",
679            AccessKind::MasterUnlockAttempt { matched },
680            Cow::Borrowed(""),
681        );
682        if matched {
683            self.clear_lockout();
684            Ok(())
685        } else {
686            // Record as a failure on a reserved name so threshold rules
687            // apply to repeated master-unlock attempts too.
688            self.report_failure("<master>", Some("invalid master credential"));
689            Err(Error::Acquisition {
690                source: Cow::Borrowed("master"),
691                reason: "master credential did not match".to_string(),
692            })
693        }
694    }
695
696    /// `true` if a master credential was registered at build time.
697    #[must_use]
698    pub fn has_master_key(&self) -> bool {
699        self.inner.master_hash.is_some()
700    }
701}
702
703/// Apply a codex's transformation to every byte of a key.
704///
705/// Used by the `fragment` (write) path. For involution-based codices
706/// `decode == encode`; the function name reflects that — it's a single
707/// transformation pass either way. Allocates a fresh [`RawKey`] for the
708/// transformed output.
709fn codex_apply(codex: &dyn Codex, key: &RawKey) -> RawKey {
710    let bytes: Vec<u8> = key.as_bytes().iter().map(|&b| codex.encode(b)).collect();
711    RawKey::new(bytes)
712}
713
714/// In-place codex decode over a mutable byte slice.
715///
716/// Used by the [`KeyVault::with_key`] hot path: the thread-local scratch
717/// buffer holds the defragmented (codex-encoded) bytes; we run the codex
718/// over them in place rather than allocating a fresh [`RawKey`]. Zero
719/// allocations.
720fn codex_decode_in_place(codex: &dyn Codex, bytes: &mut [u8]) {
721    for b in bytes.iter_mut() {
722        *b = codex.decode(*b);
723    }
724}
725
726// ------------------------- Hot-path scratch -------------------------
727//
728// `KeyVault::with_key` defragments into this thread-local instead of
729// allocating a fresh `RawKey` per call. The buffer grows lazily to the
730// largest key the thread has accessed; subsequent calls reuse the
731// allocation. A `ScratchGuard` zero-on-scope-exit (panic-safe).
732
733thread_local! {
734    static SCRATCH: core::cell::RefCell<Vec<u8>> = const { core::cell::RefCell::new(Vec::new()) };
735}
736
737/// Guard that volatile-zeros the borrowed scratch slice when it drops.
738/// Used at the bottom of `KeyVault::with_key` so the recovered key
739/// material is scrubbed even if the user callback panics.
740struct ZeroOnExit<'a>(&'a mut [u8]);
741
742impl Drop for ZeroOnExit<'_> {
743    fn drop(&mut self) {
744        volatile_zero_slice(self.0);
745    }
746}
747
748/// Volatile-zero a byte slice. Used to scrub the scratch buffer when
749/// the user callback returns, even if it panics.
750fn volatile_zero_slice(s: &mut [u8]) {
751    let len = s.len();
752    if len == 0 {
753        return;
754    }
755    let ptr = s.as_mut_ptr();
756    for i in 0..len {
757        // SAFETY: ptr is the unique writable borrow we hold for the
758        // duration of this function call; ptr.add(i) for i in 0..len
759        // is in-bounds.
760        unsafe {
761            core::ptr::write_volatile(ptr.add(i), 0u8);
762        }
763    }
764    core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst);
765}
766
767impl core::fmt::Debug for KeyVault {
768    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
769        f.debug_struct("KeyVault")
770            .field("locked_out", &self.is_locked_out())
771            .field("config", &self.inner.config)
772            .finish()
773    }
774}
775
776/// Fluent builder for [`KeyVault`].
777///
778/// The builder is the only way to construct a vault; the inherent
779/// `KeyVault::new` constructor is intentionally not provided so that future
780/// required configuration cannot be silently bypassed.
781#[derive(Clone)]
782pub struct KeyVaultBuilder {
783    config: VaultConfig,
784    fragmenter: StandardFragmenter,
785    codex: Option<Arc<dyn Codex>>,
786    monitor: Option<Arc<dyn SecurityMonitor>>,
787    /// Optional Layer-9 audit sink. Defaults to
788    /// [`NoAudit`](crate::NoAudit) at `build` time.
789    audit: Option<Arc<dyn AuditSink>>,
790    /// Hash of the master credential, if one was registered. We hold
791    /// the hash (not the plaintext) so the master bytes don't linger
792    /// in the builder's state.
793    master_hash: Option<[u8; 32]>,
794}
795
796impl Default for KeyVaultBuilder {
797    fn default() -> Self {
798        Self::new()
799    }
800}
801
802impl core::fmt::Debug for KeyVaultBuilder {
803    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
804        f.debug_struct("KeyVaultBuilder")
805            .field("config", &self.config)
806            .field("fragmenter", &self.fragmenter)
807            .field("codex", &self.codex.as_ref().map(|_| "<set>"))
808            .field("monitor", &self.monitor.as_ref().map(|_| "<set>"))
809            .field("audit", &self.audit.as_ref().map(|_| "<set>"))
810            .field("master_key", &self.master_hash.as_ref().map(|_| "<set>"))
811            .finish()
812    }
813}
814
815impl KeyVaultBuilder {
816    /// Start a new builder with default configuration and a default-range
817    /// [`StandardFragmenter`].
818    #[must_use]
819    pub fn new() -> Self {
820        Self {
821            config: VaultConfig::new(),
822            fragmenter: StandardFragmenter::new(),
823            codex: None,
824            monitor: None,
825            audit: None,
826            master_hash: None,
827        }
828    }
829
830    /// Enable or disable BLAKE3 normalization of input key material.
831    ///
832    /// Default: `true`. Disabling normalization preserves the original byte
833    /// pattern of the key in storage, which can leak format cues (DER
834    /// envelopes, PEM markers, ASCII-armored data). Disable only when you
835    /// have a specific reason to preserve the original bytes.
836    #[must_use]
837    pub fn normalize_with_blake3(mut self, enabled: bool) -> Self {
838        self.config.key_normalization = enabled;
839        self
840    }
841
842    /// Customize the fragmenter chunk-size range.
843    ///
844    /// Defaults are documented on [`StandardFragmenter::new`]. `min` is
845    /// clamped to `>= 1` and `max` to `>= min`. Calling this replaces any
846    /// previously-configured chunk range and resets the decoy strategy to
847    /// `None`; configure decoy *after* this call.
848    #[must_use]
849    pub fn with_chunk_range(mut self, min: usize, max: usize) -> Self {
850        self.fragmenter = StandardFragmenter::with_chunk_range(min, max);
851        self
852    }
853
854    /// Attach a Layer-5 codex to the vault.
855    ///
856    /// When set, every byte of the (optionally BLAKE3-normalized) key
857    /// passes through `codex.encode()` before being handed to the
858    /// fragmenter; `defragment` applies `codex.decode()` to recover the
859    /// original bytes. For involution-based codices ([`StaticCodex`](crate::StaticCodex),
860    /// [`DynamicCodex`](crate::DynamicCodex), involution closures wrapped in
861    /// [`FnCodex`](crate::codex::FnCodex)) `decode == encode`, but the
862    /// vault calls them by name so non-involution codices would also
863    /// work in principle.
864    ///
865    /// The codex is held in an `Arc<dyn Codex>` so the same codex can be
866    /// shared across multiple vaults (rarely useful — usually each vault
867    /// wants its own [`DynamicCodex`](crate::DynamicCodex)).
868    ///
869    /// # Examples
870    ///
871    /// ```
872    /// use key_vault::{DynamicCodex, KeyVaultBuilder};
873    ///
874    /// let vault = KeyVaultBuilder::new()
875    ///     .with_codex(DynamicCodex::new().unwrap())
876    ///     .build();
877    /// // The vault now applies the codex transformation transparently
878    /// // on every fragment / defragment.
879    /// # let _ = vault;
880    /// ```
881    #[must_use]
882    pub fn with_codex<C>(mut self, codex: C) -> Self
883    where
884        C: Codex + 'static,
885    {
886        self.codex = Some(Arc::new(codex));
887        self
888    }
889
890    /// Attach a Layer-4 decoy strategy to the underlying fragmenter.
891    ///
892    /// When set, every `KeyVault::fragment` call also produces decoy chunks
893    /// from the strategy. Decoys are interleaved with real chunks via the
894    /// same Fisher-Yates shuffle and are skipped by `defragment`. See
895    /// [`StandardFragmenter::with_decoy`] for details on chunk-count and
896    /// size selection.
897    ///
898    /// Use [`SelfReferenceDecoy`](crate::SelfReferenceDecoy) for the
899    /// strongest statistical indistinguishability (recommended default);
900    /// [`KeyDerivedDecoy`](crate::KeyDerivedDecoy) for BLAKE3-XOF–derived
901    /// CSPRNG-like output;
902    /// [`RandomDecoy`](crate::RandomDecoy) for raw CSPRNG output.
903    #[must_use]
904    pub fn with_decoy<D>(mut self, decoy: D) -> Self
905    where
906        D: DecoyStrategy + 'static,
907    {
908        self.fragmenter = self.fragmenter.with_decoy(decoy);
909        self
910    }
911
912    /// Attach a Layer-8 security monitor.
913    ///
914    /// Replaces any previously-configured monitor. The monitor receives
915    /// every event the vault produces — failure callbacks via
916    /// [`KeyVault::report_failure`], anomaly callbacks via
917    /// [`KeyVault::report_anomalous_access`], and threshold-breach
918    /// callbacks when the failure tracker fires.
919    ///
920    /// Default is [`NoMonitor`](crate::NoMonitor) — events go nowhere
921    /// but threshold-driven lockout still works (lockout state is owned
922    /// by the vault, not the monitor).
923    #[must_use]
924    pub fn with_monitor<M>(mut self, monitor: M) -> Self
925    where
926        M: SecurityMonitor + 'static,
927    {
928        self.monitor = Some(Arc::new(monitor));
929        self
930    }
931
932    /// Configure the failure-threshold detector.
933    ///
934    /// When [`KeyVault::report_failure`] records `max` failures for the
935    /// same `key_name` within `window`, the vault transitions to
936    /// lock-out state and the monitor's `on_threshold_breach` fires.
937    ///
938    /// Pass `max = 0` to disable threshold lockout (the default). The
939    /// vault will still forward every failure to the monitor; it just
940    /// won't lock out on its own.
941    ///
942    /// `window` is the sliding-window size for the per-key failure
943    /// counter; failures older than this fall off and no longer count.
944    #[must_use]
945    pub fn with_failure_threshold(mut self, max: u32, window: Duration) -> Self {
946        self.config.max_failures_before_lockout = max;
947        self.config.failure_window = window;
948        self
949    }
950
951    /// Attach a Layer-9 audit sink.
952    ///
953    /// Every vault operation (register, unregister, read, rotate,
954    /// fragment, defragment, master-unlock attempt) emits an
955    /// [`AuditEvent`](crate::AuditEvent) through this sink. Default
956    /// is [`NoAudit`](crate::NoAudit) — events are constructed and
957    /// discarded.
958    ///
959    /// See [`AuditSink`](crate::AuditSink) for the implementor
960    /// contract (non-blocking, no panics, no back-pressure).
961    #[must_use]
962    pub fn with_audit_sink<A>(mut self, sink: A) -> Self
963    where
964        A: AuditSink + 'static,
965    {
966        self.audit = Some(Arc::new(sink));
967        self
968    }
969
970    /// Register a master credential for emergency unlock.
971    ///
972    /// The vault stores the **BLAKE3 hash** of the supplied bytes; the
973    /// plaintext is dropped immediately (and zeroed via
974    /// `RawKey::Drop`). Use [`KeyVault::unlock_with_master`] later to
975    /// clear a threshold-driven lockout.
976    ///
977    /// Calling this twice replaces the previously-stored hash. Pass an
978    /// empty key (zero-length) to register a meaningless "match
979    /// anything" credential — strongly discouraged; the function does
980    /// not reject it for symmetry with the rest of the builder API.
981    #[must_use]
982    pub fn with_master_key(mut self, master: RawKey) -> Self {
983        let hash = blake3::hash(master.as_bytes());
984        let mut bytes = [0u8; 32];
985        bytes.copy_from_slice(hash.as_bytes());
986        self.master_hash = Some(bytes);
987        // `master` drops here; its `Drop` impl zeroes the internal Vec.
988        drop(master);
989        self
990    }
991
992    /// Finalize and produce a [`KeyVault`].
993    ///
994    /// Infallible in this phase — later phases may move this to a
995    /// `Result`-returning shape if validation is added.
996    #[must_use]
997    pub fn build(self) -> KeyVault {
998        let monitor: Arc<dyn SecurityMonitor> = self
999            .monitor
1000            .unwrap_or_else(|| Arc::new(crate::monitor::NoMonitor));
1001        let audit: Arc<dyn AuditSink> = self
1002            .audit
1003            .unwrap_or_else(|| Arc::new(crate::audit::NoAudit));
1004        KeyVault {
1005            inner: Arc::new(VaultInner {
1006                config: self.config,
1007                fragmenter: self.fragmenter,
1008                codex: self.codex,
1009                monitor,
1010                keys: ArcSwap::from_pointee(HashMap::new()),
1011                failure_tracker: Mutex::new(HashMap::new()),
1012                locked_out: AtomicBool::new(false),
1013                master_hash: self.master_hash,
1014                audit,
1015            }),
1016        }
1017    }
1018}
1019
1020#[cfg(test)]
1021#[allow(clippy::unwrap_used, clippy::expect_used)]
1022mod tests {
1023    use super::*;
1024    use alloc::format;
1025
1026    #[test]
1027    fn builder_defaults_to_normalization_on() {
1028        let v = KeyVaultBuilder::new().build();
1029        assert!(v.config().key_normalization);
1030    }
1031
1032    #[test]
1033    fn builder_can_disable_normalization() {
1034        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1035        assert!(!v.config().key_normalization);
1036    }
1037
1038    #[test]
1039    fn fresh_vault_is_not_locked_out() {
1040        let v = KeyVaultBuilder::new().build();
1041        assert!(!v.is_locked_out());
1042    }
1043
1044    #[test]
1045    fn debug_does_not_panic() {
1046        let v = KeyVaultBuilder::new().build();
1047        let _ = format!("{v:?}");
1048    }
1049
1050    #[test]
1051    fn fragment_defragment_roundtrip_with_normalization() {
1052        let v = KeyVaultBuilder::new().build(); // normalization on
1053        let raw = RawKey::new(b"hello world".to_vec());
1054        let frags = v.fragment(&raw).unwrap();
1055        let recovered = v.defragment(&frags).unwrap();
1056        // With normalization on, the output is the BLAKE3 hash (32 bytes),
1057        // not the original 11-byte input.
1058        assert_eq!(recovered.len(), 32);
1059        // It is deterministic — fragmenting the same input twice produces the
1060        // same recovered bytes (the bytes themselves; layout still varies).
1061        let frags2 = v.fragment(&raw).unwrap();
1062        let recovered2 = v.defragment(&frags2).unwrap();
1063        assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
1064    }
1065
1066    #[test]
1067    fn fragment_defragment_roundtrip_without_normalization() {
1068        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1069        let raw = RawKey::new((0u8..40).collect());
1070        let frags = v.fragment(&raw).unwrap();
1071        let recovered = v.defragment(&frags).unwrap();
1072        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1073    }
1074
1075    #[test]
1076    fn fragment_rejects_empty_key() {
1077        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1078        let err = v
1079            .fragment(&RawKey::new(alloc::vec::Vec::new()))
1080            .unwrap_err();
1081        assert!(matches!(err, crate::Error::Fragment(_)));
1082    }
1083
1084    #[test]
1085    fn chunk_range_propagates_through_builder() {
1086        let v = KeyVaultBuilder::new()
1087            .normalize_with_blake3(false)
1088            .with_chunk_range(4, 6)
1089            .build();
1090        let raw = RawKey::new((0u8..30).collect());
1091        let frags = v.fragment(&raw).unwrap();
1092
1093        // After fragmentation, chunks have been Fisher-Yates shuffled, so the
1094        // "remainder" chunk (which the size-sampling loop allows to fall below
1095        // `min` when the total doesn't divide cleanly) can land at any index.
1096        // We verify the post-shuffle invariants instead of indexing by order:
1097        //   1. Every chunk fits in [1, max].
1098        //   2. At most one chunk falls below `min` (the remainder slot).
1099        //   3. Total bytes sum to the original length.
1100        let chunks = frags.chunks();
1101        let mut below_min = 0;
1102        let mut total = 0usize;
1103        for c in chunks {
1104            assert!(
1105                c.len() >= 1 && c.len() <= 6,
1106                "chunk size {} not in [1,6]",
1107                c.len()
1108            );
1109            if c.len() < 4 {
1110                below_min += 1;
1111            }
1112            total += c.len();
1113        }
1114        assert!(
1115            below_min <= 1,
1116            "more than one chunk below min size: {below_min}"
1117        );
1118        assert_eq!(total, 30);
1119    }
1120
1121    #[test]
1122    fn fragment_with_random_decoy_roundtrips() {
1123        let v = KeyVaultBuilder::new()
1124            .normalize_with_blake3(false)
1125            .with_decoy(crate::RandomDecoy)
1126            .build();
1127        let raw = RawKey::new((0u8..32).collect());
1128        let frags = v.fragment(&raw).unwrap();
1129        // Chunk count is real + decoy (roughly 2x the real count).
1130        // Defragment must skip the decoys and return the original bytes.
1131        let recovered = v.defragment(&frags).unwrap();
1132        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1133    }
1134
1135    #[test]
1136    fn fragment_with_self_reference_decoy_roundtrips() {
1137        let v = KeyVaultBuilder::new()
1138            .normalize_with_blake3(false)
1139            .with_decoy(crate::SelfReferenceDecoy)
1140            .build();
1141        let raw = RawKey::new(b"some user-supplied key material".to_vec());
1142        let frags = v.fragment(&raw).unwrap();
1143        let recovered = v.defragment(&frags).unwrap();
1144        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1145    }
1146
1147    #[test]
1148    fn fragment_with_key_derived_decoy_roundtrips() {
1149        let v = KeyVaultBuilder::new()
1150            .normalize_with_blake3(false)
1151            .with_decoy(crate::KeyDerivedDecoy)
1152            .build();
1153        let raw = RawKey::new((0u8..64).collect());
1154        let frags = v.fragment(&raw).unwrap();
1155        let recovered = v.defragment(&frags).unwrap();
1156        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1157    }
1158
1159    #[test]
1160    fn decoy_increases_chunk_count_relative_to_no_decoy() {
1161        let no_decoy = KeyVaultBuilder::new()
1162            .normalize_with_blake3(false)
1163            .with_chunk_range(2, 4)
1164            .build();
1165        let with_decoy = KeyVaultBuilder::new()
1166            .normalize_with_blake3(false)
1167            .with_chunk_range(2, 4)
1168            .with_decoy(crate::SelfReferenceDecoy)
1169            .build();
1170        let raw = RawKey::new((0u8..32).collect());
1171
1172        // The total chunk count is randomized per fragmentation, so average
1173        // over a few runs to get a stable comparison. The decoy-enabled
1174        // vault should average ~2x the chunks.
1175        let mut no_decoy_total = 0usize;
1176        let mut decoy_total = 0usize;
1177        for _ in 0..8 {
1178            no_decoy_total += no_decoy.fragment(&raw).unwrap().chunk_count();
1179            decoy_total += with_decoy.fragment(&raw).unwrap().chunk_count();
1180        }
1181        // The decoy-enabled vault adds one decoy chunk per real chunk, so
1182        // its total chunk count should be exactly twice the no-decoy count
1183        // (modulo per-call sampling that affects the real-chunk count
1184        // identically). Allow some slack for the random sampling variance.
1185        assert!(
1186            decoy_total > no_decoy_total,
1187            "decoy vault produced {decoy_total} chunks vs no-decoy {no_decoy_total}"
1188        );
1189    }
1190
1191    #[test]
1192    fn fragment_with_static_codex_roundtrips() {
1193        use crate::StaticCodex;
1194        let codex = StaticCodex::from_swaps(&[(b'A', b'#'), (b'0', b'%')]).unwrap();
1195        let v = KeyVaultBuilder::new()
1196            .normalize_with_blake3(false)
1197            .with_codex(codex)
1198            .build();
1199        let raw = RawKey::new(b"A0A0A0A0".to_vec());
1200        let frags = v.fragment(&raw).unwrap();
1201        let recovered = v.defragment(&frags).unwrap();
1202        // Codex round-trips: the recovered bytes are the original
1203        // (pre-encode) bytes, not the encoded ones.
1204        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1205    }
1206
1207    #[test]
1208    fn fragment_with_dynamic_codex_roundtrips() {
1209        use crate::DynamicCodex;
1210        let v = KeyVaultBuilder::new()
1211            .normalize_with_blake3(false)
1212            .with_codex(DynamicCodex::new().unwrap())
1213            .build();
1214        let raw = RawKey::new((0u8..=255).collect());
1215        let frags = v.fragment(&raw).unwrap();
1216        let recovered = v.defragment(&frags).unwrap();
1217        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1218    }
1219
1220    #[test]
1221    fn fragment_with_codex_and_decoy_and_normalization_roundtrips() {
1222        use crate::{DynamicCodex, SelfReferenceDecoy};
1223        // All layers stacked: BLAKE3 normalize + DynamicCodex encode +
1224        // StandardFragmenter w/ SelfReferenceDecoy. Must still round-trip.
1225        let v = KeyVaultBuilder::new()
1226            .normalize_with_blake3(true)
1227            .with_codex(DynamicCodex::new().unwrap())
1228            .with_decoy(SelfReferenceDecoy)
1229            .build();
1230        let raw = RawKey::new(b"my application key".to_vec());
1231        let frags = v.fragment(&raw).unwrap();
1232        let recovered = v.defragment(&frags).unwrap();
1233        // With normalization on, recovered is 32 bytes (BLAKE3 hash).
1234        // It must be deterministic given the same input.
1235        assert_eq!(recovered.len(), 32);
1236        let recovered2 = v.defragment(&v.fragment(&raw).unwrap()).unwrap();
1237        assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
1238    }
1239
1240    #[test]
1241    fn codex_visibly_transforms_stored_bytes() {
1242        // Without codex, the fragment chunks contain the original bytes
1243        // somewhere among them. With a non-identity codex, the stored
1244        // bytes should differ — we verify by checking that some chunk
1245        // contains a transformed byte not in the original input.
1246        use crate::StaticCodex;
1247        let v = KeyVaultBuilder::new()
1248            .normalize_with_blake3(false)
1249            // Force every byte to swap with a distinct partner.
1250            .with_codex(crate::DynamicCodex::new().unwrap())
1251            .build();
1252        let raw = RawKey::new(alloc::vec![0xaa; 8]);
1253        let frags = v.fragment(&raw).unwrap();
1254
1255        // Walk chunks and confirm at least one byte is *not* 0xaa
1256        // (the codex encoded 0xaa to something else).
1257        let mut saw_non_aa = false;
1258        for chunk in frags.chunks() {
1259            for &b in chunk.as_bytes() {
1260                if b != 0xaa {
1261                    saw_non_aa = true;
1262                    break;
1263                }
1264            }
1265            if saw_non_aa {
1266                break;
1267            }
1268        }
1269        assert!(
1270            saw_non_aa,
1271            "codex did not transform 0xaa — stored bytes still all 0xaa",
1272        );
1273
1274        // And defragment recovers the original 0xaa bytes.
1275        let recovered = v.defragment(&frags).unwrap();
1276        assert_eq!(recovered.as_bytes(), raw.as_bytes());
1277        // Use the `_codex` import to keep the import non-dead.
1278        let _ = StaticCodex::from_swaps(&[]).unwrap();
1279    }
1280
1281    // ----- Layer 8: monitor + threshold tests -----
1282
1283    use core::sync::atomic::AtomicU32;
1284
1285    /// Helper monitor that counts each callback invocation.
1286    struct CountingMonitor {
1287        failures: AtomicU32,
1288        anomalies: AtomicU32,
1289        breaches: AtomicU32,
1290    }
1291
1292    impl CountingMonitor {
1293        fn new() -> Self {
1294            Self {
1295                failures: AtomicU32::new(0),
1296                anomalies: AtomicU32::new(0),
1297                breaches: AtomicU32::new(0),
1298            }
1299        }
1300    }
1301
1302    impl SecurityMonitor for CountingMonitor {
1303        fn on_decryption_failure(&self, _ctx: &FailureContext) {
1304            let _ = self.failures.fetch_add(1, Ordering::SeqCst);
1305        }
1306        fn on_anomalous_access(&self, _ctx: &AccessContext) {
1307            let _ = self.anomalies.fetch_add(1, Ordering::SeqCst);
1308        }
1309        fn on_threshold_breach(&self, _ctx: &ThresholdContext) {
1310            let _ = self.breaches.fetch_add(1, Ordering::SeqCst);
1311        }
1312    }
1313
1314    #[test]
1315    fn report_failure_fires_monitor() {
1316        let monitor = Arc::new(CountingMonitor::new());
1317        let v = KeyVaultBuilder::new()
1318            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1319            .build();
1320        v.report_failure("k", None);
1321        v.report_failure("k", Some("test note"));
1322        assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
1323        assert_eq!(monitor.breaches.load(Ordering::SeqCst), 0);
1324        assert!(!v.is_locked_out());
1325    }
1326
1327    #[test]
1328    fn report_anomalous_access_fires_monitor() {
1329        let monitor = Arc::new(CountingMonitor::new());
1330        let v = KeyVaultBuilder::new()
1331            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1332            .build();
1333        v.report_anomalous_access("k", None);
1334        assert_eq!(monitor.anomalies.load(Ordering::SeqCst), 1);
1335        assert!(!v.is_locked_out());
1336    }
1337
1338    #[test]
1339    fn threshold_lockout_fires_after_max_failures() {
1340        let monitor = Arc::new(CountingMonitor::new());
1341        let v = KeyVaultBuilder::new()
1342            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1343            .with_failure_threshold(3, Duration::from_secs(30))
1344            .build();
1345
1346        v.report_failure("k", None);
1347        assert!(!v.is_locked_out());
1348        v.report_failure("k", None);
1349        assert!(!v.is_locked_out());
1350        v.report_failure("k", None);
1351        // Three failures in the window → lockout.
1352        assert!(v.is_locked_out());
1353        assert_eq!(monitor.failures.load(Ordering::SeqCst), 3);
1354        assert_eq!(monitor.breaches.load(Ordering::SeqCst), 1);
1355
1356        // Subsequent failures keep counting but only one breach event fires
1357        // until clear_lockout resets the flag.
1358        v.report_failure("k", None);
1359        assert!(v.is_locked_out());
1360        assert_eq!(monitor.failures.load(Ordering::SeqCst), 4);
1361        // Breach event count grows but lockout_triggered is false now.
1362        assert_eq!(monitor.breaches.load(Ordering::SeqCst), 2);
1363    }
1364
1365    #[test]
1366    fn fragment_refuses_when_locked_out() {
1367        let v = KeyVaultBuilder::new()
1368            .normalize_with_blake3(false)
1369            .with_failure_threshold(1, Duration::from_secs(30))
1370            .build();
1371        v.report_failure("k", None);
1372        assert!(v.is_locked_out());
1373
1374        let err = v
1375            .fragment(&RawKey::new(alloc::vec![1u8, 2, 3, 4]))
1376            .unwrap_err();
1377        assert!(matches!(err, Error::LockedOut));
1378    }
1379
1380    #[test]
1381    fn defragment_refuses_when_locked_out() {
1382        let v = KeyVaultBuilder::new()
1383            .normalize_with_blake3(false)
1384            .with_failure_threshold(2, Duration::from_secs(30))
1385            .build();
1386        // Produce a fragment before lockout.
1387        let raw = RawKey::new(alloc::vec![1u8; 16]);
1388        let frags = v.fragment(&raw).unwrap();
1389        v.report_failure("k", None);
1390        v.report_failure("k", None);
1391        assert!(v.is_locked_out());
1392
1393        let err = v.defragment(&frags).unwrap_err();
1394        assert!(matches!(err, Error::LockedOut));
1395    }
1396
1397    #[test]
1398    fn clear_lockout_resets_state() {
1399        let v = KeyVaultBuilder::new()
1400            .with_failure_threshold(1, Duration::from_secs(30))
1401            .build();
1402        v.report_failure("k", None);
1403        assert!(v.is_locked_out());
1404        v.clear_lockout();
1405        assert!(!v.is_locked_out());
1406        // Failure tracker also cleared — next single failure shouldn't lock
1407        // again immediately (threshold is 1, so it WILL lock, but starting
1408        // count is fresh — verifies tracker was cleared by counting
1409        // monitor breaches).
1410        // Actually with threshold=1 a single failure re-locks. So instead
1411        // assert via tracker contents indirectly: a second `clear_lockout`
1412        // call is a no-op.
1413        v.clear_lockout();
1414        assert!(!v.is_locked_out());
1415    }
1416
1417    #[test]
1418    fn per_key_failure_counts_are_independent() {
1419        let monitor = Arc::new(CountingMonitor::new());
1420        let v = KeyVaultBuilder::new()
1421            .with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
1422            .with_failure_threshold(2, Duration::from_secs(30))
1423            .build();
1424        v.report_failure("alpha", None);
1425        v.report_failure("beta", None);
1426        // One failure each — neither hits the threshold.
1427        assert!(!v.is_locked_out());
1428        assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
1429        v.report_failure("alpha", None);
1430        // alpha now has 2 — triggers lockout.
1431        assert!(v.is_locked_out());
1432    }
1433
1434    // ----- Phase 0.9: registry + rotation + master-key tests -----
1435
1436    #[test]
1437    fn register_returns_handle_and_increments_count() {
1438        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1439        assert_eq!(v.key_count(), 0);
1440        let h = v
1441            .register("primary", RawKey::new(alloc::vec![1u8; 32]))
1442            .unwrap();
1443        assert_eq!(v.key_count(), 1);
1444        assert!(v.contains(h));
1445    }
1446
1447    #[test]
1448    fn register_rejects_duplicate_name() {
1449        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1450        let _ = v
1451            .register("primary", RawKey::new(alloc::vec![1u8; 16]))
1452            .unwrap();
1453        let err = v
1454            .register("primary", RawKey::new(alloc::vec![2u8; 16]))
1455            .unwrap_err();
1456        assert!(matches!(err, Error::InvalidConfig(_)));
1457    }
1458
1459    #[test]
1460    fn unregister_removes_key() {
1461        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1462        let h = v
1463            .register("primary", RawKey::new(alloc::vec![1u8; 16]))
1464            .unwrap();
1465        assert!(v.contains(h));
1466        v.unregister(h).unwrap();
1467        assert!(!v.contains(h));
1468        assert_eq!(v.key_count(), 0);
1469    }
1470
1471    #[test]
1472    fn unregister_unknown_handle_errors() {
1473        let v = KeyVaultBuilder::new().build();
1474        let h = KeyHandle::__for_test();
1475        let err = v.unregister(h).unwrap_err();
1476        assert!(matches!(err, Error::KeyNotFound));
1477    }
1478
1479    #[test]
1480    fn with_key_round_trips_bytes() {
1481        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1482        let original = alloc::vec![0xa5u8; 32];
1483        let h = v.register("data", RawKey::new(original.clone())).unwrap();
1484        let observed = v.with_key(h, <[u8]>::to_vec).unwrap();
1485        assert_eq!(observed, original);
1486    }
1487
1488    #[test]
1489    fn with_key_normalization_changes_output_length() {
1490        let v = KeyVaultBuilder::new().build(); // normalization ON
1491        let h = v
1492            .register("data", RawKey::new(alloc::vec![0xa5; 17]))
1493            .unwrap();
1494        let observed_len = v.with_key(h, <[u8]>::len).unwrap();
1495        // BLAKE3 normalization → 32-byte output regardless of input.
1496        assert_eq!(observed_len, 32);
1497    }
1498
1499    #[test]
1500    fn with_key_unknown_handle_errors() {
1501        let v = KeyVaultBuilder::new().build();
1502        let h = KeyHandle::__for_test();
1503        let err = v.with_key(h, |_| ()).unwrap_err();
1504        assert!(matches!(err, Error::KeyNotFound));
1505    }
1506
1507    #[test]
1508    fn rotate_swaps_key_bytes() {
1509        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1510        let h = v
1511            .register("data", RawKey::new(alloc::vec![1u8; 16]))
1512            .unwrap();
1513
1514        v.rotate(h, RawKey::new(alloc::vec![2u8; 16])).unwrap();
1515        let observed = v.with_key(h, <[u8]>::to_vec).unwrap();
1516        assert_eq!(observed, alloc::vec![2u8; 16]);
1517    }
1518
1519    #[test]
1520    fn rotate_unknown_handle_errors() {
1521        let v = KeyVaultBuilder::new().build();
1522        let h = KeyHandle::__for_test();
1523        let err = v.rotate(h, RawKey::new(alloc::vec![0u8; 16])).unwrap_err();
1524        assert!(matches!(err, Error::KeyNotFound));
1525    }
1526
1527    #[test]
1528    fn handle_for_name_finds_registered_key() {
1529        let v = KeyVaultBuilder::new().build();
1530        let h = v
1531            .register("primary", RawKey::new(alloc::vec![0u8; 16]))
1532            .unwrap();
1533        assert_eq!(v.handle_for_name("primary"), Some(h));
1534        assert_eq!(v.handle_for_name("missing"), None);
1535    }
1536
1537    #[test]
1538    fn metadata_records_registration_length() {
1539        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1540        let h = v
1541            .register("data", RawKey::new(alloc::vec![0u8; 42]))
1542            .unwrap();
1543        let meta = v.metadata(h).expect("metadata");
1544        assert_eq!(meta.length(), 42);
1545    }
1546
1547    #[test]
1548    fn registered_key_refuses_access_when_locked_out() {
1549        let v = KeyVaultBuilder::new()
1550            .with_failure_threshold(1, Duration::from_secs(30))
1551            .build();
1552        let h = v
1553            .register("data", RawKey::new(alloc::vec![0xa5; 16]))
1554            .unwrap();
1555        v.report_failure("data", None);
1556        assert!(v.is_locked_out());
1557
1558        let err = v.with_key(h, |_| ()).unwrap_err();
1559        assert!(matches!(err, Error::LockedOut));
1560        let err = v.rotate(h, RawKey::new(alloc::vec![0u8; 16])).unwrap_err();
1561        assert!(matches!(err, Error::LockedOut));
1562    }
1563
1564    #[test]
1565    fn master_key_unlock_clears_lockout_on_match() {
1566        let master_bytes = b"correct horse battery staple".to_vec();
1567        let v = KeyVaultBuilder::new()
1568            .with_master_key(RawKey::new(master_bytes.clone()))
1569            .with_failure_threshold(1, Duration::from_secs(30))
1570            .build();
1571        assert!(v.has_master_key());
1572
1573        v.report_failure("k", None);
1574        assert!(v.is_locked_out());
1575
1576        // Wrong master → still locked.
1577        let err = v.unlock_with_master(b"wrong").unwrap_err();
1578        assert!(matches!(err, Error::Acquisition { .. }));
1579        assert!(v.is_locked_out());
1580
1581        // Correct master → unlocked.
1582        v.unlock_with_master(&master_bytes).unwrap();
1583        assert!(!v.is_locked_out());
1584    }
1585
1586    // ----- Phase 0.9: Layer 9 audit-trail tests -----
1587
1588    /// Helper audit sink that captures every event for assertions.
1589    struct CapturingAudit {
1590        events: Mutex<Vec<(crate::audit::AccessKind, String)>>,
1591    }
1592
1593    impl CapturingAudit {
1594        fn new() -> Self {
1595            Self {
1596                events: Mutex::new(Vec::new()),
1597            }
1598        }
1599        fn count_of(&self, kind: crate::audit::AccessKind) -> usize {
1600            self.events
1601                .lock()
1602                .unwrap()
1603                .iter()
1604                .filter(|(k, _)| *k == kind)
1605                .count()
1606        }
1607        fn last_for(&self, kind: crate::audit::AccessKind) -> Option<String> {
1608            self.events
1609                .lock()
1610                .unwrap()
1611                .iter()
1612                .rev()
1613                .find_map(|(k, name)| (*k == kind).then(|| name.clone()))
1614        }
1615    }
1616
1617    impl crate::audit::AuditSink for CapturingAudit {
1618        fn on_event(&self, event: &crate::audit::AuditEvent) {
1619            self.events
1620                .lock()
1621                .unwrap()
1622                .push((event.kind, event.key_name.clone()));
1623        }
1624    }
1625
1626    #[test]
1627    fn register_emits_register_event() {
1628        let audit = Arc::new(CapturingAudit::new());
1629        let v = KeyVaultBuilder::new()
1630            .normalize_with_blake3(false)
1631            .with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
1632            .build();
1633        let _ = v
1634            .register("primary", RawKey::new(alloc::vec![1u8; 16]))
1635            .unwrap();
1636        assert_eq!(audit.count_of(crate::audit::AccessKind::Register), 1);
1637        assert_eq!(
1638            audit.last_for(crate::audit::AccessKind::Register),
1639            Some("primary".to_string())
1640        );
1641    }
1642
1643    #[test]
1644    fn unregister_emits_unregister_event() {
1645        let audit = Arc::new(CapturingAudit::new());
1646        let v = KeyVaultBuilder::new()
1647            .normalize_with_blake3(false)
1648            .with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
1649            .build();
1650        let h = v
1651            .register("primary", RawKey::new(alloc::vec![1u8; 16]))
1652            .unwrap();
1653        v.unregister(h).unwrap();
1654        assert_eq!(audit.count_of(crate::audit::AccessKind::Unregister), 1);
1655        assert_eq!(
1656            audit.last_for(crate::audit::AccessKind::Unregister),
1657            Some("primary".to_string())
1658        );
1659    }
1660
1661    #[test]
1662    fn with_key_emits_read_event() {
1663        let audit = Arc::new(CapturingAudit::new());
1664        let v = KeyVaultBuilder::new()
1665            .normalize_with_blake3(false)
1666            .with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
1667            .build();
1668        let h = v
1669            .register("data", RawKey::new(alloc::vec![0xa5u8; 16]))
1670            .unwrap();
1671        let _ = v.with_key(h, <[u8]>::to_vec).unwrap();
1672        assert_eq!(audit.count_of(crate::audit::AccessKind::Read), 1);
1673        assert_eq!(
1674            audit.last_for(crate::audit::AccessKind::Read),
1675            Some("data".to_string())
1676        );
1677    }
1678
1679    #[test]
1680    fn rotate_emits_rotate_event() {
1681        let audit = Arc::new(CapturingAudit::new());
1682        let v = KeyVaultBuilder::new()
1683            .normalize_with_blake3(false)
1684            .with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
1685            .build();
1686        let h = v
1687            .register("data", RawKey::new(alloc::vec![1u8; 16]))
1688            .unwrap();
1689        v.rotate(h, RawKey::new(alloc::vec![2u8; 16])).unwrap();
1690        assert_eq!(audit.count_of(crate::audit::AccessKind::Rotate), 1);
1691        assert_eq!(
1692            audit.last_for(crate::audit::AccessKind::Rotate),
1693            Some("data".to_string())
1694        );
1695    }
1696
1697    #[test]
1698    fn fragment_and_defragment_emit_oneshot_events() {
1699        let audit = Arc::new(CapturingAudit::new());
1700        let v = KeyVaultBuilder::new()
1701            .normalize_with_blake3(false)
1702            .with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
1703            .build();
1704        let raw = RawKey::new(alloc::vec![0u8; 16]);
1705        let frags = v.fragment(&raw).unwrap();
1706        let _ = v.defragment(&frags).unwrap();
1707        assert_eq!(audit.count_of(crate::audit::AccessKind::OneShotFragment), 1);
1708        assert_eq!(
1709            audit.count_of(crate::audit::AccessKind::OneShotDefragment),
1710            1
1711        );
1712    }
1713
1714    #[test]
1715    fn master_unlock_emits_event_with_match_status() {
1716        let audit = Arc::new(CapturingAudit::new());
1717        let master = b"correct".to_vec();
1718        let v = KeyVaultBuilder::new()
1719            .with_master_key(RawKey::new(master.clone()))
1720            .with_failure_threshold(1, Duration::from_secs(30))
1721            .with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
1722            .build();
1723
1724        v.report_failure("k", None);
1725        assert!(v.is_locked_out());
1726
1727        let _ = v.unlock_with_master(b"wrong");
1728        assert_eq!(
1729            audit.count_of(crate::audit::AccessKind::MasterUnlockAttempt { matched: false }),
1730            1
1731        );
1732
1733        v.unlock_with_master(&master).unwrap();
1734        assert_eq!(
1735            audit.count_of(crate::audit::AccessKind::MasterUnlockAttempt { matched: true }),
1736            1
1737        );
1738    }
1739
1740    #[test]
1741    fn no_audit_default_does_not_panic() {
1742        // Default sink is NoAudit — events are still constructed but go
1743        // nowhere. Verify the happy path completes without surprises.
1744        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
1745        let h = v.register("k", RawKey::new(alloc::vec![0u8; 16])).unwrap();
1746        let _ = v.with_key(h, <[u8]>::to_vec).unwrap();
1747        v.unregister(h).unwrap();
1748    }
1749
1750    #[test]
1751    fn master_key_unlock_without_registered_master_errors() {
1752        let v = KeyVaultBuilder::new().build();
1753        assert!(!v.has_master_key());
1754        let err = v.unlock_with_master(b"anything").unwrap_err();
1755        assert!(matches!(err, Error::InvalidConfig(_)));
1756    }
1757
1758    #[test]
1759    fn composite_monitor_chains_to_all_inner() {
1760        use crate::CompositeMonitor;
1761        let a = Arc::new(CountingMonitor::new());
1762        let b = Arc::new(CountingMonitor::new());
1763        let composite = CompositeMonitor::new(alloc::vec![
1764            Arc::clone(&a) as Arc<dyn SecurityMonitor>,
1765            Arc::clone(&b) as Arc<dyn SecurityMonitor>,
1766        ]);
1767        let v = KeyVaultBuilder::new()
1768            .with_monitor(composite)
1769            .with_failure_threshold(1, Duration::from_secs(30))
1770            .build();
1771        v.report_failure("k", None);
1772        assert_eq!(a.failures.load(Ordering::SeqCst), 1);
1773        assert_eq!(b.failures.load(Ordering::SeqCst), 1);
1774        assert_eq!(a.breaches.load(Ordering::SeqCst), 1);
1775        assert_eq!(b.breaches.load(Ordering::SeqCst), 1);
1776    }
1777}