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::sync::Arc;
19use alloc::vec::Vec;
20use core::sync::atomic::AtomicBool;
21use core::sync::atomic::Ordering;
22
23use crate::Result;
24use crate::codex::Codex;
25use crate::decoy::DecoyStrategy;
26use crate::fetcher::RawKey;
27use crate::fragment::{FragmentStrategy, Fragments, StandardFragmenter};
28use crate::normalize::blake3_normalize;
29
30/// Vault configuration.
31///
32/// Concrete fields are added in later phases as each layer comes online —
33/// decoy strategy in 0.4, additional fragment strategies in 0.5, codex in
34/// 0.6, monitor in 0.8.
35#[derive(Debug, Default, Clone)]
36#[non_exhaustive]
37pub struct VaultConfig {
38    /// If `true`, raw key material is BLAKE3-normalized to 32 bytes before
39    /// fragmentation. Default is `true`.
40    pub key_normalization: bool,
41}
42
43impl VaultConfig {
44    /// Default-on configuration.
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            key_normalization: true,
49        }
50    }
51}
52
53/// In-memory key vault.
54///
55/// The vault is the entry point for everything `key-vault` does. Application
56/// code constructs one via [`KeyVaultBuilder`], hands it [`RawKey`] values
57/// to be fragmented, and (in later phases) receives
58/// [`KeyHandle`](crate::KeyHandle)s in return. The vault itself is cheap to
59/// clone (it is `Arc`-backed internally) and safe to share across threads.
60///
61/// In Phase 0.3 the vault exposes [`KeyVault::fragment`] and
62/// [`KeyVault::defragment`] convenience methods that route through the
63/// configured normalizer and [`StandardFragmenter`]. The full named-key
64/// registry arrives in Phase 0.9.
65#[derive(Clone)]
66pub struct KeyVault {
67    inner: Arc<VaultInner>,
68}
69
70struct VaultInner {
71    config: VaultConfig,
72    fragmenter: StandardFragmenter,
73    /// Optional Layer-5 codex. When set, every byte of normalized key
74    /// material passes through `codex.encode()` before being handed to
75    /// the fragmenter; `defragment` applies `codex.decode()` to recover.
76    codex: Option<Arc<dyn Codex>>,
77    /// Set to `true` when a [`SecurityMonitor`](crate::SecurityMonitor)
78    /// threshold breach has put the vault into lock-out state. Lock-out is
79    /// not yet driven by the monitor — that arrives in Phase 0.8.
80    locked_out: AtomicBool,
81}
82
83impl KeyVault {
84    /// Returns `true` if the vault is in lock-out state.
85    ///
86    /// Lock-out is the [`SecurityMonitor`](crate::SecurityMonitor)'s response
87    /// to repeated failures: once the threshold is crossed, access to every
88    /// key in the vault is denied until the configured recovery condition is
89    /// met. In Phase 0.2 the lock-out flag exists but is never set; Phase 0.8
90    /// connects it to monitor events.
91    #[must_use]
92    pub fn is_locked_out(&self) -> bool {
93        self.inner.locked_out.load(Ordering::Acquire)
94    }
95
96    /// Snapshot of the vault's configuration.
97    #[must_use]
98    pub fn config(&self) -> &VaultConfig {
99        &self.inner.config
100    }
101
102    /// Fragment a raw key through the configured normalizer, codex, and
103    /// fragmenter.
104    ///
105    /// The returned [`Fragments`] is opaque; pass it back to
106    /// [`KeyVault::defragment`] to recover the (normalized + codex-encoded)
107    /// bytes inverse-transformed.
108    ///
109    /// # Pipeline
110    ///
111    /// ```text
112    /// key → blake3_normalize (optional) → codex.encode (optional) → fragmenter.fragment → Fragments
113    /// ```
114    ///
115    /// # Errors
116    ///
117    /// Returns whatever the underlying [`FragmentStrategy`] surfaces — in
118    /// practice an [`Error::Fragment`](crate::Error::Fragment) for a
119    /// zero-length input.
120    pub fn fragment(&self, key: &RawKey) -> Result<Fragments> {
121        let working = if self.inner.config.key_normalization {
122            blake3_normalize(key)
123        } else {
124            RawKey::new(key.as_bytes().to_vec())
125        };
126        let encoded = if let Some(codex) = &self.inner.codex {
127            codex_apply(codex.as_ref(), &working)
128        } else {
129            working
130        };
131        self.inner.fragmenter.fragment(&encoded)
132    }
133
134    /// Reassemble fragments produced by [`KeyVault::fragment`].
135    ///
136    /// Inverts the codex transformation (if configured) so the recovered
137    /// bytes are the normalized key (or the original raw key if
138    /// normalization is off). Defragmentation itself is delegated to the
139    /// configured [`FragmentStrategy`].
140    ///
141    /// # Errors
142    ///
143    /// Returns [`Error::Defragment`](crate::Error::Defragment) when the
144    /// supplied fragments do not match the configured fragmenter's layout.
145    pub fn defragment(&self, fragments: &Fragments) -> Result<RawKey> {
146        let encoded = self.inner.fragmenter.defragment(fragments)?;
147        if let Some(codex) = &self.inner.codex {
148            Ok(codex_apply(codex.as_ref(), &encoded))
149        } else {
150            Ok(encoded)
151        }
152    }
153}
154
155/// Apply a codex's transformation to every byte of a key.
156///
157/// Used both for encoding (pre-fragment) and decoding (post-defragment).
158/// For involution-based codices `decode == encode`; the function name
159/// reflects that — it's a single transformation pass either way.
160fn codex_apply(codex: &dyn Codex, key: &RawKey) -> RawKey {
161    let bytes: Vec<u8> = key.as_bytes().iter().map(|&b| codex.encode(b)).collect();
162    RawKey::new(bytes)
163}
164
165impl core::fmt::Debug for KeyVault {
166    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
167        f.debug_struct("KeyVault")
168            .field("locked_out", &self.is_locked_out())
169            .field("config", &self.inner.config)
170            .finish()
171    }
172}
173
174/// Fluent builder for [`KeyVault`].
175///
176/// The builder is the only way to construct a vault; the inherent
177/// `KeyVault::new` constructor is intentionally not provided so that future
178/// required configuration cannot be silently bypassed.
179#[derive(Default, Clone)]
180pub struct KeyVaultBuilder {
181    config: VaultConfig,
182    fragmenter: StandardFragmenter,
183    codex: Option<Arc<dyn Codex>>,
184}
185
186impl core::fmt::Debug for KeyVaultBuilder {
187    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
188        f.debug_struct("KeyVaultBuilder")
189            .field("config", &self.config)
190            .field("fragmenter", &self.fragmenter)
191            .field("codex", &self.codex.as_ref().map(|_| "<set>"))
192            .finish()
193    }
194}
195
196impl KeyVaultBuilder {
197    /// Start a new builder with default configuration and a default-range
198    /// [`StandardFragmenter`].
199    #[must_use]
200    pub fn new() -> Self {
201        Self {
202            config: VaultConfig::new(),
203            fragmenter: StandardFragmenter::new(),
204            codex: None,
205        }
206    }
207
208    /// Enable or disable BLAKE3 normalization of input key material.
209    ///
210    /// Default: `true`. Disabling normalization preserves the original byte
211    /// pattern of the key in storage, which can leak format cues (DER
212    /// envelopes, PEM markers, ASCII-armored data). Disable only when you
213    /// have a specific reason to preserve the original bytes.
214    #[must_use]
215    pub fn normalize_with_blake3(mut self, enabled: bool) -> Self {
216        self.config.key_normalization = enabled;
217        self
218    }
219
220    /// Customize the fragmenter chunk-size range.
221    ///
222    /// Defaults are documented on [`StandardFragmenter::new`]. `min` is
223    /// clamped to `>= 1` and `max` to `>= min`. Calling this replaces any
224    /// previously-configured chunk range and resets the decoy strategy to
225    /// `None`; configure decoy *after* this call.
226    #[must_use]
227    pub fn with_chunk_range(mut self, min: usize, max: usize) -> Self {
228        self.fragmenter = StandardFragmenter::with_chunk_range(min, max);
229        self
230    }
231
232    /// Attach a Layer-5 codex to the vault.
233    ///
234    /// When set, every byte of the (optionally BLAKE3-normalized) key
235    /// passes through `codex.encode()` before being handed to the
236    /// fragmenter; `defragment` applies `codex.decode()` to recover the
237    /// original bytes. For involution-based codices ([`StaticCodex`](crate::StaticCodex),
238    /// [`DynamicCodex`](crate::DynamicCodex), involution closures wrapped in
239    /// [`FnCodex`](crate::codex::FnCodex)) `decode == encode`, but the
240    /// vault calls them by name so non-involution codices would also
241    /// work in principle.
242    ///
243    /// The codex is held in an `Arc<dyn Codex>` so the same codex can be
244    /// shared across multiple vaults (rarely useful — usually each vault
245    /// wants its own [`DynamicCodex`](crate::DynamicCodex)).
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use key_vault::{DynamicCodex, KeyVaultBuilder};
251    ///
252    /// let vault = KeyVaultBuilder::new()
253    ///     .with_codex(DynamicCodex::new().unwrap())
254    ///     .build();
255    /// // The vault now applies the codex transformation transparently
256    /// // on every fragment / defragment.
257    /// # let _ = vault;
258    /// ```
259    #[must_use]
260    pub fn with_codex<C>(mut self, codex: C) -> Self
261    where
262        C: Codex + 'static,
263    {
264        self.codex = Some(Arc::new(codex));
265        self
266    }
267
268    /// Attach a Layer-4 decoy strategy to the underlying fragmenter.
269    ///
270    /// When set, every `KeyVault::fragment` call also produces decoy chunks
271    /// from the strategy. Decoys are interleaved with real chunks via the
272    /// same Fisher-Yates shuffle and are skipped by `defragment`. See
273    /// [`StandardFragmenter::with_decoy`] for details on chunk-count and
274    /// size selection.
275    ///
276    /// Use [`SelfReferenceDecoy`](crate::SelfReferenceDecoy) for the
277    /// strongest statistical indistinguishability (recommended default);
278    /// [`KeyDerivedDecoy`](crate::KeyDerivedDecoy) for BLAKE3-XOF–derived
279    /// CSPRNG-like output;
280    /// [`RandomDecoy`](crate::RandomDecoy) for raw CSPRNG output.
281    #[must_use]
282    pub fn with_decoy<D>(mut self, decoy: D) -> Self
283    where
284        D: DecoyStrategy + 'static,
285    {
286        self.fragmenter = self.fragmenter.with_decoy(decoy);
287        self
288    }
289
290    /// Finalize and produce a [`KeyVault`].
291    ///
292    /// Infallible in this phase — later phases may move this to a
293    /// `Result`-returning shape if validation is added.
294    #[must_use]
295    pub fn build(self) -> KeyVault {
296        KeyVault {
297            inner: Arc::new(VaultInner {
298                config: self.config,
299                fragmenter: self.fragmenter,
300                codex: self.codex,
301                locked_out: AtomicBool::new(false),
302            }),
303        }
304    }
305}
306
307#[cfg(test)]
308#[allow(clippy::unwrap_used, clippy::expect_used)]
309mod tests {
310    use super::*;
311    use alloc::format;
312
313    #[test]
314    fn builder_defaults_to_normalization_on() {
315        let v = KeyVaultBuilder::new().build();
316        assert!(v.config().key_normalization);
317    }
318
319    #[test]
320    fn builder_can_disable_normalization() {
321        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
322        assert!(!v.config().key_normalization);
323    }
324
325    #[test]
326    fn fresh_vault_is_not_locked_out() {
327        let v = KeyVaultBuilder::new().build();
328        assert!(!v.is_locked_out());
329    }
330
331    #[test]
332    fn debug_does_not_panic() {
333        let v = KeyVaultBuilder::new().build();
334        let _ = format!("{v:?}");
335    }
336
337    #[test]
338    fn fragment_defragment_roundtrip_with_normalization() {
339        let v = KeyVaultBuilder::new().build(); // normalization on
340        let raw = RawKey::new(b"hello world".to_vec());
341        let frags = v.fragment(&raw).unwrap();
342        let recovered = v.defragment(&frags).unwrap();
343        // With normalization on, the output is the BLAKE3 hash (32 bytes),
344        // not the original 11-byte input.
345        assert_eq!(recovered.len(), 32);
346        // It is deterministic — fragmenting the same input twice produces the
347        // same recovered bytes (the bytes themselves; layout still varies).
348        let frags2 = v.fragment(&raw).unwrap();
349        let recovered2 = v.defragment(&frags2).unwrap();
350        assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
351    }
352
353    #[test]
354    fn fragment_defragment_roundtrip_without_normalization() {
355        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
356        let raw = RawKey::new((0u8..40).collect());
357        let frags = v.fragment(&raw).unwrap();
358        let recovered = v.defragment(&frags).unwrap();
359        assert_eq!(recovered.as_bytes(), raw.as_bytes());
360    }
361
362    #[test]
363    fn fragment_rejects_empty_key() {
364        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
365        let err = v
366            .fragment(&RawKey::new(alloc::vec::Vec::new()))
367            .unwrap_err();
368        assert!(matches!(err, crate::Error::Fragment(_)));
369    }
370
371    #[test]
372    fn chunk_range_propagates_through_builder() {
373        let v = KeyVaultBuilder::new()
374            .normalize_with_blake3(false)
375            .with_chunk_range(4, 6)
376            .build();
377        let raw = RawKey::new((0u8..30).collect());
378        let frags = v.fragment(&raw).unwrap();
379
380        // After fragmentation, chunks have been Fisher-Yates shuffled, so the
381        // "remainder" chunk (which the size-sampling loop allows to fall below
382        // `min` when the total doesn't divide cleanly) can land at any index.
383        // We verify the post-shuffle invariants instead of indexing by order:
384        //   1. Every chunk fits in [1, max].
385        //   2. At most one chunk falls below `min` (the remainder slot).
386        //   3. Total bytes sum to the original length.
387        let chunks = frags.chunks();
388        let mut below_min = 0;
389        let mut total = 0usize;
390        for c in chunks {
391            assert!(
392                c.len() >= 1 && c.len() <= 6,
393                "chunk size {} not in [1,6]",
394                c.len()
395            );
396            if c.len() < 4 {
397                below_min += 1;
398            }
399            total += c.len();
400        }
401        assert!(
402            below_min <= 1,
403            "more than one chunk below min size: {below_min}"
404        );
405        assert_eq!(total, 30);
406    }
407
408    #[test]
409    fn fragment_with_random_decoy_roundtrips() {
410        let v = KeyVaultBuilder::new()
411            .normalize_with_blake3(false)
412            .with_decoy(crate::RandomDecoy)
413            .build();
414        let raw = RawKey::new((0u8..32).collect());
415        let frags = v.fragment(&raw).unwrap();
416        // Chunk count is real + decoy (roughly 2x the real count).
417        // Defragment must skip the decoys and return the original bytes.
418        let recovered = v.defragment(&frags).unwrap();
419        assert_eq!(recovered.as_bytes(), raw.as_bytes());
420    }
421
422    #[test]
423    fn fragment_with_self_reference_decoy_roundtrips() {
424        let v = KeyVaultBuilder::new()
425            .normalize_with_blake3(false)
426            .with_decoy(crate::SelfReferenceDecoy)
427            .build();
428        let raw = RawKey::new(b"some user-supplied key material".to_vec());
429        let frags = v.fragment(&raw).unwrap();
430        let recovered = v.defragment(&frags).unwrap();
431        assert_eq!(recovered.as_bytes(), raw.as_bytes());
432    }
433
434    #[test]
435    fn fragment_with_key_derived_decoy_roundtrips() {
436        let v = KeyVaultBuilder::new()
437            .normalize_with_blake3(false)
438            .with_decoy(crate::KeyDerivedDecoy)
439            .build();
440        let raw = RawKey::new((0u8..64).collect());
441        let frags = v.fragment(&raw).unwrap();
442        let recovered = v.defragment(&frags).unwrap();
443        assert_eq!(recovered.as_bytes(), raw.as_bytes());
444    }
445
446    #[test]
447    fn decoy_increases_chunk_count_relative_to_no_decoy() {
448        let no_decoy = KeyVaultBuilder::new()
449            .normalize_with_blake3(false)
450            .with_chunk_range(2, 4)
451            .build();
452        let with_decoy = KeyVaultBuilder::new()
453            .normalize_with_blake3(false)
454            .with_chunk_range(2, 4)
455            .with_decoy(crate::SelfReferenceDecoy)
456            .build();
457        let raw = RawKey::new((0u8..32).collect());
458
459        // The total chunk count is randomized per fragmentation, so average
460        // over a few runs to get a stable comparison. The decoy-enabled
461        // vault should average ~2x the chunks.
462        let mut no_decoy_total = 0usize;
463        let mut decoy_total = 0usize;
464        for _ in 0..8 {
465            no_decoy_total += no_decoy.fragment(&raw).unwrap().chunk_count();
466            decoy_total += with_decoy.fragment(&raw).unwrap().chunk_count();
467        }
468        // The decoy-enabled vault adds one decoy chunk per real chunk, so
469        // its total chunk count should be exactly twice the no-decoy count
470        // (modulo per-call sampling that affects the real-chunk count
471        // identically). Allow some slack for the random sampling variance.
472        assert!(
473            decoy_total > no_decoy_total,
474            "decoy vault produced {decoy_total} chunks vs no-decoy {no_decoy_total}"
475        );
476    }
477
478    #[test]
479    fn fragment_with_static_codex_roundtrips() {
480        use crate::StaticCodex;
481        let codex = StaticCodex::from_swaps(&[(b'A', b'#'), (b'0', b'%')]).unwrap();
482        let v = KeyVaultBuilder::new()
483            .normalize_with_blake3(false)
484            .with_codex(codex)
485            .build();
486        let raw = RawKey::new(b"A0A0A0A0".to_vec());
487        let frags = v.fragment(&raw).unwrap();
488        let recovered = v.defragment(&frags).unwrap();
489        // Codex round-trips: the recovered bytes are the original
490        // (pre-encode) bytes, not the encoded ones.
491        assert_eq!(recovered.as_bytes(), raw.as_bytes());
492    }
493
494    #[test]
495    fn fragment_with_dynamic_codex_roundtrips() {
496        use crate::DynamicCodex;
497        let v = KeyVaultBuilder::new()
498            .normalize_with_blake3(false)
499            .with_codex(DynamicCodex::new().unwrap())
500            .build();
501        let raw = RawKey::new((0u8..=255).collect());
502        let frags = v.fragment(&raw).unwrap();
503        let recovered = v.defragment(&frags).unwrap();
504        assert_eq!(recovered.as_bytes(), raw.as_bytes());
505    }
506
507    #[test]
508    fn fragment_with_codex_and_decoy_and_normalization_roundtrips() {
509        use crate::{DynamicCodex, SelfReferenceDecoy};
510        // All layers stacked: BLAKE3 normalize + DynamicCodex encode +
511        // StandardFragmenter w/ SelfReferenceDecoy. Must still round-trip.
512        let v = KeyVaultBuilder::new()
513            .normalize_with_blake3(true)
514            .with_codex(DynamicCodex::new().unwrap())
515            .with_decoy(SelfReferenceDecoy)
516            .build();
517        let raw = RawKey::new(b"my application key".to_vec());
518        let frags = v.fragment(&raw).unwrap();
519        let recovered = v.defragment(&frags).unwrap();
520        // With normalization on, recovered is 32 bytes (BLAKE3 hash).
521        // It must be deterministic given the same input.
522        assert_eq!(recovered.len(), 32);
523        let recovered2 = v.defragment(&v.fragment(&raw).unwrap()).unwrap();
524        assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
525    }
526
527    #[test]
528    fn codex_visibly_transforms_stored_bytes() {
529        // Without codex, the fragment chunks contain the original bytes
530        // somewhere among them. With a non-identity codex, the stored
531        // bytes should differ — we verify by checking that some chunk
532        // contains a transformed byte not in the original input.
533        use crate::StaticCodex;
534        let v = KeyVaultBuilder::new()
535            .normalize_with_blake3(false)
536            // Force every byte to swap with a distinct partner.
537            .with_codex(crate::DynamicCodex::new().unwrap())
538            .build();
539        let raw = RawKey::new(alloc::vec![0xaa; 8]);
540        let frags = v.fragment(&raw).unwrap();
541
542        // Walk chunks and confirm at least one byte is *not* 0xaa
543        // (the codex encoded 0xaa to something else).
544        let mut saw_non_aa = false;
545        for chunk in frags.chunks() {
546            for &b in chunk.as_bytes() {
547                if b != 0xaa {
548                    saw_non_aa = true;
549                    break;
550                }
551            }
552            if saw_non_aa {
553                break;
554            }
555        }
556        assert!(
557            saw_non_aa,
558            "codex did not transform 0xaa — stored bytes still all 0xaa",
559        );
560
561        // And defragment recovers the original 0xaa bytes.
562        let recovered = v.defragment(&frags).unwrap();
563        assert_eq!(recovered.as_bytes(), raw.as_bytes());
564        // Use the `_codex` import to keep the import non-dead.
565        let _ = StaticCodex::from_swaps(&[]).unwrap();
566    }
567}