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::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}