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::fetcher::RawKey;
24use crate::fragment::{FragmentStrategy, Fragments, StandardFragmenter};
25use crate::normalize::blake3_normalize;
26
27/// Vault configuration.
28///
29/// Concrete fields are added in later phases as each layer comes online —
30/// decoy strategy in 0.4, additional fragment strategies in 0.5, codex in
31/// 0.6, monitor in 0.8.
32#[derive(Debug, Default, Clone)]
33#[non_exhaustive]
34pub struct VaultConfig {
35 /// If `true`, raw key material is BLAKE3-normalized to 32 bytes before
36 /// fragmentation. Default is `true`.
37 pub key_normalization: bool,
38}
39
40impl VaultConfig {
41 /// Default-on configuration.
42 #[must_use]
43 pub fn new() -> Self {
44 Self {
45 key_normalization: true,
46 }
47 }
48}
49
50/// In-memory key vault.
51///
52/// The vault is the entry point for everything `key-vault` does. Application
53/// code constructs one via [`KeyVaultBuilder`], hands it [`RawKey`] values
54/// to be fragmented, and (in later phases) receives
55/// [`KeyHandle`](crate::KeyHandle)s in return. The vault itself is cheap to
56/// clone (it is `Arc`-backed internally) and safe to share across threads.
57///
58/// In Phase 0.3 the vault exposes [`KeyVault::fragment`] and
59/// [`KeyVault::defragment`] convenience methods that route through the
60/// configured normalizer and [`StandardFragmenter`]. The full named-key
61/// registry arrives in Phase 0.9.
62#[derive(Clone)]
63pub struct KeyVault {
64 inner: Arc<VaultInner>,
65}
66
67struct VaultInner {
68 config: VaultConfig,
69 fragmenter: StandardFragmenter,
70 /// Set to `true` when a [`SecurityMonitor`](crate::SecurityMonitor)
71 /// threshold breach has put the vault into lock-out state. Lock-out is
72 /// not yet driven by the monitor — that arrives in Phase 0.8.
73 locked_out: AtomicBool,
74}
75
76impl KeyVault {
77 /// Returns `true` if the vault is in lock-out state.
78 ///
79 /// Lock-out is the [`SecurityMonitor`](crate::SecurityMonitor)'s response
80 /// to repeated failures: once the threshold is crossed, access to every
81 /// key in the vault is denied until the configured recovery condition is
82 /// met. In Phase 0.2 the lock-out flag exists but is never set; Phase 0.8
83 /// connects it to monitor events.
84 #[must_use]
85 pub fn is_locked_out(&self) -> bool {
86 self.inner.locked_out.load(Ordering::Acquire)
87 }
88
89 /// Snapshot of the vault's configuration.
90 #[must_use]
91 pub fn config(&self) -> &VaultConfig {
92 &self.inner.config
93 }
94
95 /// Fragment a raw key through the configured normalizer and fragmenter.
96 ///
97 /// The returned [`Fragments`] is opaque; pass it back to
98 /// [`KeyVault::defragment`] to recover the (normalized) bytes.
99 ///
100 /// # Errors
101 ///
102 /// Returns whatever the underlying [`FragmentStrategy`] surfaces — in
103 /// practice an [`Error::Fragment`](crate::Error::Fragment) for a
104 /// zero-length input.
105 pub fn fragment(&self, key: &RawKey) -> Result<Fragments> {
106 if self.inner.config.key_normalization {
107 let normalized = blake3_normalize(key);
108 self.inner.fragmenter.fragment(&normalized)
109 } else {
110 self.inner.fragmenter.fragment(key)
111 }
112 }
113
114 /// Reassemble fragments produced by [`KeyVault::fragment`].
115 ///
116 /// The output is the same bytes that the fragmenter saw — i.e. the
117 /// normalized key when normalization is on, the raw key otherwise.
118 ///
119 /// # Errors
120 ///
121 /// Returns [`Error::Defragment`](crate::Error::Defragment) when the
122 /// supplied fragments do not match the configured fragmenter's layout.
123 pub fn defragment(&self, fragments: &Fragments) -> Result<RawKey> {
124 self.inner.fragmenter.defragment(fragments)
125 }
126}
127
128impl core::fmt::Debug for KeyVault {
129 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130 f.debug_struct("KeyVault")
131 .field("locked_out", &self.is_locked_out())
132 .field("config", &self.inner.config)
133 .finish()
134 }
135}
136
137/// Fluent builder for [`KeyVault`].
138///
139/// The builder is the only way to construct a vault; the inherent
140/// `KeyVault::new` constructor is intentionally not provided so that future
141/// required configuration cannot be silently bypassed.
142#[derive(Debug, Default, Clone)]
143pub struct KeyVaultBuilder {
144 config: VaultConfig,
145 fragmenter: StandardFragmenter,
146}
147
148impl KeyVaultBuilder {
149 /// Start a new builder with default configuration and a default-range
150 /// [`StandardFragmenter`].
151 #[must_use]
152 pub fn new() -> Self {
153 Self {
154 config: VaultConfig::new(),
155 fragmenter: StandardFragmenter::new(),
156 }
157 }
158
159 /// Enable or disable BLAKE3 normalization of input key material.
160 ///
161 /// Default: `true`. Disabling normalization preserves the original byte
162 /// pattern of the key in storage, which can leak format cues (DER
163 /// envelopes, PEM markers, ASCII-armored data). Disable only when you
164 /// have a specific reason to preserve the original bytes.
165 #[must_use]
166 pub fn normalize_with_blake3(mut self, enabled: bool) -> Self {
167 self.config.key_normalization = enabled;
168 self
169 }
170
171 /// Customize the fragmenter chunk-size range.
172 ///
173 /// Defaults are documented on [`StandardFragmenter::new`]. `min` is
174 /// clamped to `>= 1` and `max` to `>= min`.
175 #[must_use]
176 pub fn with_chunk_range(mut self, min: usize, max: usize) -> Self {
177 self.fragmenter = StandardFragmenter::with_chunk_range(min, max);
178 self
179 }
180
181 /// Finalize and produce a [`KeyVault`].
182 ///
183 /// Infallible in this phase — later phases may move this to a
184 /// `Result`-returning shape if validation is added.
185 #[must_use]
186 pub fn build(self) -> KeyVault {
187 KeyVault {
188 inner: Arc::new(VaultInner {
189 config: self.config,
190 fragmenter: self.fragmenter,
191 locked_out: AtomicBool::new(false),
192 }),
193 }
194 }
195}
196
197#[cfg(test)]
198#[allow(clippy::unwrap_used, clippy::expect_used)]
199mod tests {
200 use super::*;
201 use alloc::format;
202
203 #[test]
204 fn builder_defaults_to_normalization_on() {
205 let v = KeyVaultBuilder::new().build();
206 assert!(v.config().key_normalization);
207 }
208
209 #[test]
210 fn builder_can_disable_normalization() {
211 let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
212 assert!(!v.config().key_normalization);
213 }
214
215 #[test]
216 fn fresh_vault_is_not_locked_out() {
217 let v = KeyVaultBuilder::new().build();
218 assert!(!v.is_locked_out());
219 }
220
221 #[test]
222 fn debug_does_not_panic() {
223 let v = KeyVaultBuilder::new().build();
224 let _ = format!("{v:?}");
225 }
226
227 #[test]
228 fn fragment_defragment_roundtrip_with_normalization() {
229 let v = KeyVaultBuilder::new().build(); // normalization on
230 let raw = RawKey::new(b"hello world".to_vec());
231 let frags = v.fragment(&raw).unwrap();
232 let recovered = v.defragment(&frags).unwrap();
233 // With normalization on, the output is the BLAKE3 hash (32 bytes),
234 // not the original 11-byte input.
235 assert_eq!(recovered.len(), 32);
236 // It is deterministic — fragmenting the same input twice produces the
237 // same recovered bytes (the bytes themselves; layout still varies).
238 let frags2 = v.fragment(&raw).unwrap();
239 let recovered2 = v.defragment(&frags2).unwrap();
240 assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
241 }
242
243 #[test]
244 fn fragment_defragment_roundtrip_without_normalization() {
245 let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
246 let raw = RawKey::new((0u8..40).collect());
247 let frags = v.fragment(&raw).unwrap();
248 let recovered = v.defragment(&frags).unwrap();
249 assert_eq!(recovered.as_bytes(), raw.as_bytes());
250 }
251
252 #[test]
253 fn fragment_rejects_empty_key() {
254 let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
255 let err = v
256 .fragment(&RawKey::new(alloc::vec::Vec::new()))
257 .unwrap_err();
258 assert!(matches!(err, crate::Error::Fragment(_)));
259 }
260
261 #[test]
262 fn chunk_range_propagates_through_builder() {
263 let v = KeyVaultBuilder::new()
264 .normalize_with_blake3(false)
265 .with_chunk_range(4, 6)
266 .build();
267 let raw = RawKey::new((0u8..30).collect());
268 let frags = v.fragment(&raw).unwrap();
269
270 // After fragmentation, chunks have been Fisher-Yates shuffled, so the
271 // "remainder" chunk (which the size-sampling loop allows to fall below
272 // `min` when the total doesn't divide cleanly) can land at any index.
273 // We verify the post-shuffle invariants instead of indexing by order:
274 // 1. Every chunk fits in [1, max].
275 // 2. At most one chunk falls below `min` (the remainder slot).
276 // 3. Total bytes sum to the original length.
277 let chunks = frags.chunks();
278 let mut below_min = 0;
279 let mut total = 0usize;
280 for c in chunks {
281 assert!(
282 c.len() >= 1 && c.len() <= 6,
283 "chunk size {} not in [1,6]",
284 c.len()
285 );
286 if c.len() < 4 {
287 below_min += 1;
288 }
289 total += c.len();
290 }
291 assert!(
292 below_min <= 1,
293 "more than one chunk below min size: {below_min}"
294 );
295 assert_eq!(total, 30);
296 }
297}