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 core::sync::atomic::AtomicBool;
20use core::sync::atomic::Ordering;
21
22use crate::Result;
23use crate::decoy::DecoyStrategy;
24use crate::fetcher::RawKey;
25use crate::fragment::{FragmentStrategy, Fragments, StandardFragmenter};
26use crate::normalize::blake3_normalize;
27
28/// Vault configuration.
29///
30/// Concrete fields are added in later phases as each layer comes online —
31/// decoy strategy in 0.4, additional fragment strategies in 0.5, codex in
32/// 0.6, monitor in 0.8.
33#[derive(Debug, Default, Clone)]
34#[non_exhaustive]
35pub struct VaultConfig {
36    /// If `true`, raw key material is BLAKE3-normalized to 32 bytes before
37    /// fragmentation. Default is `true`.
38    pub key_normalization: bool,
39}
40
41impl VaultConfig {
42    /// Default-on configuration.
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            key_normalization: true,
47        }
48    }
49}
50
51/// In-memory key vault.
52///
53/// The vault is the entry point for everything `key-vault` does. Application
54/// code constructs one via [`KeyVaultBuilder`], hands it [`RawKey`] values
55/// to be fragmented, and (in later phases) receives
56/// [`KeyHandle`](crate::KeyHandle)s in return. The vault itself is cheap to
57/// clone (it is `Arc`-backed internally) and safe to share across threads.
58///
59/// In Phase 0.3 the vault exposes [`KeyVault::fragment`] and
60/// [`KeyVault::defragment`] convenience methods that route through the
61/// configured normalizer and [`StandardFragmenter`]. The full named-key
62/// registry arrives in Phase 0.9.
63#[derive(Clone)]
64pub struct KeyVault {
65    inner: Arc<VaultInner>,
66}
67
68struct VaultInner {
69    config: VaultConfig,
70    fragmenter: StandardFragmenter,
71    /// Set to `true` when a [`SecurityMonitor`](crate::SecurityMonitor)
72    /// threshold breach has put the vault into lock-out state. Lock-out is
73    /// not yet driven by the monitor — that arrives in Phase 0.8.
74    locked_out: AtomicBool,
75}
76
77impl KeyVault {
78    /// Returns `true` if the vault is in lock-out state.
79    ///
80    /// Lock-out is the [`SecurityMonitor`](crate::SecurityMonitor)'s response
81    /// to repeated failures: once the threshold is crossed, access to every
82    /// key in the vault is denied until the configured recovery condition is
83    /// met. In Phase 0.2 the lock-out flag exists but is never set; Phase 0.8
84    /// connects it to monitor events.
85    #[must_use]
86    pub fn is_locked_out(&self) -> bool {
87        self.inner.locked_out.load(Ordering::Acquire)
88    }
89
90    /// Snapshot of the vault's configuration.
91    #[must_use]
92    pub fn config(&self) -> &VaultConfig {
93        &self.inner.config
94    }
95
96    /// Fragment a raw key through the configured normalizer and fragmenter.
97    ///
98    /// The returned [`Fragments`] is opaque; pass it back to
99    /// [`KeyVault::defragment`] to recover the (normalized) bytes.
100    ///
101    /// # Errors
102    ///
103    /// Returns whatever the underlying [`FragmentStrategy`] surfaces — in
104    /// practice an [`Error::Fragment`](crate::Error::Fragment) for a
105    /// zero-length input.
106    pub fn fragment(&self, key: &RawKey) -> Result<Fragments> {
107        if self.inner.config.key_normalization {
108            let normalized = blake3_normalize(key);
109            self.inner.fragmenter.fragment(&normalized)
110        } else {
111            self.inner.fragmenter.fragment(key)
112        }
113    }
114
115    /// Reassemble fragments produced by [`KeyVault::fragment`].
116    ///
117    /// The output is the same bytes that the fragmenter saw — i.e. the
118    /// normalized key when normalization is on, the raw key otherwise.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`Error::Defragment`](crate::Error::Defragment) when the
123    /// supplied fragments do not match the configured fragmenter's layout.
124    pub fn defragment(&self, fragments: &Fragments) -> Result<RawKey> {
125        self.inner.fragmenter.defragment(fragments)
126    }
127}
128
129impl core::fmt::Debug for KeyVault {
130    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
131        f.debug_struct("KeyVault")
132            .field("locked_out", &self.is_locked_out())
133            .field("config", &self.inner.config)
134            .finish()
135    }
136}
137
138/// Fluent builder for [`KeyVault`].
139///
140/// The builder is the only way to construct a vault; the inherent
141/// `KeyVault::new` constructor is intentionally not provided so that future
142/// required configuration cannot be silently bypassed.
143#[derive(Debug, Default, Clone)]
144pub struct KeyVaultBuilder {
145    config: VaultConfig,
146    fragmenter: StandardFragmenter,
147}
148
149impl KeyVaultBuilder {
150    /// Start a new builder with default configuration and a default-range
151    /// [`StandardFragmenter`].
152    #[must_use]
153    pub fn new() -> Self {
154        Self {
155            config: VaultConfig::new(),
156            fragmenter: StandardFragmenter::new(),
157        }
158    }
159
160    /// Enable or disable BLAKE3 normalization of input key material.
161    ///
162    /// Default: `true`. Disabling normalization preserves the original byte
163    /// pattern of the key in storage, which can leak format cues (DER
164    /// envelopes, PEM markers, ASCII-armored data). Disable only when you
165    /// have a specific reason to preserve the original bytes.
166    #[must_use]
167    pub fn normalize_with_blake3(mut self, enabled: bool) -> Self {
168        self.config.key_normalization = enabled;
169        self
170    }
171
172    /// Customize the fragmenter chunk-size range.
173    ///
174    /// Defaults are documented on [`StandardFragmenter::new`]. `min` is
175    /// clamped to `>= 1` and `max` to `>= min`. Calling this replaces any
176    /// previously-configured chunk range and resets the decoy strategy to
177    /// `None`; configure decoy *after* this call.
178    #[must_use]
179    pub fn with_chunk_range(mut self, min: usize, max: usize) -> Self {
180        self.fragmenter = StandardFragmenter::with_chunk_range(min, max);
181        self
182    }
183
184    /// Attach a Layer-4 decoy strategy to the underlying fragmenter.
185    ///
186    /// When set, every `KeyVault::fragment` call also produces decoy chunks
187    /// from the strategy. Decoys are interleaved with real chunks via the
188    /// same Fisher-Yates shuffle and are skipped by `defragment`. See
189    /// [`StandardFragmenter::with_decoy`] for details on chunk-count and
190    /// size selection.
191    ///
192    /// Use [`SelfReferenceDecoy`](crate::SelfReferenceDecoy) for the
193    /// strongest statistical indistinguishability (recommended default);
194    /// [`KeyDerivedDecoy`](crate::KeyDerivedDecoy) for BLAKE3-XOF–derived
195    /// CSPRNG-like output;
196    /// [`RandomDecoy`](crate::RandomDecoy) for raw CSPRNG output.
197    #[must_use]
198    pub fn with_decoy<D>(mut self, decoy: D) -> Self
199    where
200        D: DecoyStrategy + 'static,
201    {
202        self.fragmenter = self.fragmenter.with_decoy(decoy);
203        self
204    }
205
206    /// Finalize and produce a [`KeyVault`].
207    ///
208    /// Infallible in this phase — later phases may move this to a
209    /// `Result`-returning shape if validation is added.
210    #[must_use]
211    pub fn build(self) -> KeyVault {
212        KeyVault {
213            inner: Arc::new(VaultInner {
214                config: self.config,
215                fragmenter: self.fragmenter,
216                locked_out: AtomicBool::new(false),
217            }),
218        }
219    }
220}
221
222#[cfg(test)]
223#[allow(clippy::unwrap_used, clippy::expect_used)]
224mod tests {
225    use super::*;
226    use alloc::format;
227
228    #[test]
229    fn builder_defaults_to_normalization_on() {
230        let v = KeyVaultBuilder::new().build();
231        assert!(v.config().key_normalization);
232    }
233
234    #[test]
235    fn builder_can_disable_normalization() {
236        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
237        assert!(!v.config().key_normalization);
238    }
239
240    #[test]
241    fn fresh_vault_is_not_locked_out() {
242        let v = KeyVaultBuilder::new().build();
243        assert!(!v.is_locked_out());
244    }
245
246    #[test]
247    fn debug_does_not_panic() {
248        let v = KeyVaultBuilder::new().build();
249        let _ = format!("{v:?}");
250    }
251
252    #[test]
253    fn fragment_defragment_roundtrip_with_normalization() {
254        let v = KeyVaultBuilder::new().build(); // normalization on
255        let raw = RawKey::new(b"hello world".to_vec());
256        let frags = v.fragment(&raw).unwrap();
257        let recovered = v.defragment(&frags).unwrap();
258        // With normalization on, the output is the BLAKE3 hash (32 bytes),
259        // not the original 11-byte input.
260        assert_eq!(recovered.len(), 32);
261        // It is deterministic — fragmenting the same input twice produces the
262        // same recovered bytes (the bytes themselves; layout still varies).
263        let frags2 = v.fragment(&raw).unwrap();
264        let recovered2 = v.defragment(&frags2).unwrap();
265        assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
266    }
267
268    #[test]
269    fn fragment_defragment_roundtrip_without_normalization() {
270        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
271        let raw = RawKey::new((0u8..40).collect());
272        let frags = v.fragment(&raw).unwrap();
273        let recovered = v.defragment(&frags).unwrap();
274        assert_eq!(recovered.as_bytes(), raw.as_bytes());
275    }
276
277    #[test]
278    fn fragment_rejects_empty_key() {
279        let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
280        let err = v
281            .fragment(&RawKey::new(alloc::vec::Vec::new()))
282            .unwrap_err();
283        assert!(matches!(err, crate::Error::Fragment(_)));
284    }
285
286    #[test]
287    fn chunk_range_propagates_through_builder() {
288        let v = KeyVaultBuilder::new()
289            .normalize_with_blake3(false)
290            .with_chunk_range(4, 6)
291            .build();
292        let raw = RawKey::new((0u8..30).collect());
293        let frags = v.fragment(&raw).unwrap();
294
295        // After fragmentation, chunks have been Fisher-Yates shuffled, so the
296        // "remainder" chunk (which the size-sampling loop allows to fall below
297        // `min` when the total doesn't divide cleanly) can land at any index.
298        // We verify the post-shuffle invariants instead of indexing by order:
299        //   1. Every chunk fits in [1, max].
300        //   2. At most one chunk falls below `min` (the remainder slot).
301        //   3. Total bytes sum to the original length.
302        let chunks = frags.chunks();
303        let mut below_min = 0;
304        let mut total = 0usize;
305        for c in chunks {
306            assert!(
307                c.len() >= 1 && c.len() <= 6,
308                "chunk size {} not in [1,6]",
309                c.len()
310            );
311            if c.len() < 4 {
312                below_min += 1;
313            }
314            total += c.len();
315        }
316        assert!(
317            below_min <= 1,
318            "more than one chunk below min size: {below_min}"
319        );
320        assert_eq!(total, 30);
321    }
322
323    #[test]
324    fn fragment_with_random_decoy_roundtrips() {
325        let v = KeyVaultBuilder::new()
326            .normalize_with_blake3(false)
327            .with_decoy(crate::RandomDecoy)
328            .build();
329        let raw = RawKey::new((0u8..32).collect());
330        let frags = v.fragment(&raw).unwrap();
331        // Chunk count is real + decoy (roughly 2x the real count).
332        // Defragment must skip the decoys and return the original bytes.
333        let recovered = v.defragment(&frags).unwrap();
334        assert_eq!(recovered.as_bytes(), raw.as_bytes());
335    }
336
337    #[test]
338    fn fragment_with_self_reference_decoy_roundtrips() {
339        let v = KeyVaultBuilder::new()
340            .normalize_with_blake3(false)
341            .with_decoy(crate::SelfReferenceDecoy)
342            .build();
343        let raw = RawKey::new(b"some user-supplied key material".to_vec());
344        let frags = v.fragment(&raw).unwrap();
345        let recovered = v.defragment(&frags).unwrap();
346        assert_eq!(recovered.as_bytes(), raw.as_bytes());
347    }
348
349    #[test]
350    fn fragment_with_key_derived_decoy_roundtrips() {
351        let v = KeyVaultBuilder::new()
352            .normalize_with_blake3(false)
353            .with_decoy(crate::KeyDerivedDecoy)
354            .build();
355        let raw = RawKey::new((0u8..64).collect());
356        let frags = v.fragment(&raw).unwrap();
357        let recovered = v.defragment(&frags).unwrap();
358        assert_eq!(recovered.as_bytes(), raw.as_bytes());
359    }
360
361    #[test]
362    fn decoy_increases_chunk_count_relative_to_no_decoy() {
363        let no_decoy = KeyVaultBuilder::new()
364            .normalize_with_blake3(false)
365            .with_chunk_range(2, 4)
366            .build();
367        let with_decoy = KeyVaultBuilder::new()
368            .normalize_with_blake3(false)
369            .with_chunk_range(2, 4)
370            .with_decoy(crate::SelfReferenceDecoy)
371            .build();
372        let raw = RawKey::new((0u8..32).collect());
373
374        // The total chunk count is randomized per fragmentation, so average
375        // over a few runs to get a stable comparison. The decoy-enabled
376        // vault should average ~2x the chunks.
377        let mut no_decoy_total = 0usize;
378        let mut decoy_total = 0usize;
379        for _ in 0..8 {
380            no_decoy_total += no_decoy.fragment(&raw).unwrap().chunk_count();
381            decoy_total += with_decoy.fragment(&raw).unwrap().chunk_count();
382        }
383        // The decoy-enabled vault adds one decoy chunk per real chunk, so
384        // its total chunk count should be exactly twice the no-decoy count
385        // (modulo per-call sampling that affects the real-chunk count
386        // identically). Allow some slack for the random sampling variance.
387        assert!(
388            decoy_total > no_decoy_total,
389            "decoy vault produced {decoy_total} chunks vs no-decoy {no_decoy_total}"
390        );
391    }
392}