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}