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}