Skip to main content

void_crypto/
vault.rs

1//! KeyVault: opaque custodian of repository key material.
2//!
3//! # Security Architecture
4//!
5//! This is the **ONLY** struct in the entire codebase that holds key material.
6//! The raw bytes never leave this struct — all operations are provided as methods.
7//! External crates receive only:
8//! - Derived keys (`&SecretKey`) for purpose-specific encryption
9//! - Operation handles (`CommitReader`) for per-commit decryption
10//! - Encrypted/decrypted data from vault operations
11//!
12//! # Modes
13//!
14//! - **RootKey**: Full read/write access. Can seal and open commits, derive keys,
15//!   and perform all encryption operations. Created via `KeyVault::new()`.
16//! - **ContentKey**: Scoped read-only access to a single commit's objects. Can
17//!   open commits and decrypt metadata/shards, but cannot seal new objects or
18//!   access derived keyring keys. Created via `KeyVault::from_content_key()`.
19//!
20//! # ring-inspired pattern
21//!
22//! Following the `ring` crypto library's approach:
23//! - Key material enters the vault once and is **consumed** into an opaque struct
24//! - No `as_bytes()`, no `into_inner()`, no escape hatch for key material
25//! - The vault provides **operations** (open, seal, derive), never key accessors
26//! - `CommitReader` mirrors ring's `OpeningKey` — a per-operation handle
27
28use zeroize::Zeroize;
29
30use crate::aead::{AAD_COMMIT, AAD_METADATA, AAD_SHARD};
31use crate::blob_types::{EncryptedCommit, EncryptedMetadata, EncryptedShard};
32use crate::envelope::{encrypt_with_envelope, generate_key_nonce};
33use crate::kdf::{ContentKey, KeyNonce, KeyRing, SecretKey};
34use crate::reader::CommitReader;
35use crate::{CryptoError, CryptoResult};
36
37/// Internal mode of the vault — determines which operations are available.
38enum VaultMode {
39    /// Full access: can seal, open, derive, and access all keyring keys.
40    RootKey {
41        root_key: [u8; 32],
42        keyring: KeyRing,
43    },
44    /// Scoped read-only: can open a single commit and decrypt its objects.
45    ContentKey {
46        content_key: ContentKey,
47    },
48}
49
50/// Holds repository key material and provides all key-dependent operations.
51///
52/// # Security
53///
54/// This is the ONLY struct that holds key material. The raw bytes never leave
55/// this struct — all operations are provided as methods. External crates receive
56/// only derived keys (`SecretKey`) or operation handles (`CommitReader`).
57///
58/// Two modes:
59/// - **RootKey** (`new`): full read/write, can seal new commits
60/// - **ContentKey** (`from_content_key`): scoped read-only, single commit
61pub struct KeyVault {
62    mode: VaultMode,
63}
64
65impl KeyVault {
66    /// Construct a root-key vault from raw key bytes.
67    ///
68    /// # Security
69    ///
70    /// The root key grants full read/write access to the entire repository history.
71    /// After this call, the caller should zero the raw bytes. The vault takes
72    /// ownership of key material from this point forward.
73    pub fn new(root_key: [u8; 32]) -> CryptoResult<Self> {
74        let keyring = KeyRing::from_root(&root_key)?;
75        Ok(Self {
76            mode: VaultMode::RootKey { root_key, keyring },
77        })
78    }
79
80    /// Construct a content-key vault for scoped read-only access.
81    ///
82    /// The content key grants access to a single commit's objects (metadata,
83    /// shards, manifest) but cannot seal new commits or access derived keyring
84    /// keys. Used for `--content-key` clone/fork paths.
85    pub fn from_content_key(content_key: ContentKey) -> Self {
86        Self {
87            mode: VaultMode::ContentKey { content_key },
88        }
89    }
90
91    /// Construct a content-key vault from raw bytes.
92    ///
93    /// Convenience wrapper for SDK consumers who have content key bytes
94    /// but not a `ContentKey` type.
95    pub fn from_content_key_bytes(bytes: [u8; 32]) -> Self {
96        Self::from_content_key(ContentKey::new(bytes))
97    }
98
99    /// Returns `true` if this vault is in content-key (read-only) mode.
100    pub fn is_content_key_mode(&self) -> bool {
101        matches!(self.mode, VaultMode::ContentKey { .. })
102    }
103
104    // =========================================================================
105    // Helper: require root key
106    // =========================================================================
107
108    /// Extract a reference to the root key, or error if in content-key mode.
109    fn require_root_key(&self) -> CryptoResult<&[u8; 32]> {
110        match &self.mode {
111            VaultMode::RootKey { root_key, .. } => Ok(root_key),
112            VaultMode::ContentKey { .. } => Err(CryptoError::ReadOnlyVault),
113        }
114    }
115
116    /// Extract a reference to the keyring, or error if in content-key mode.
117    fn require_keyring(&self) -> CryptoResult<&KeyRing> {
118        match &self.mode {
119            VaultMode::RootKey { keyring, .. } => Ok(keyring),
120            VaultMode::ContentKey { .. } => Err(CryptoError::ReadOnlyVault),
121        }
122    }
123
124    // =========================================================================
125    // Commit operations
126    // =========================================================================
127
128    /// Decrypt a commit blob, returning plaintext and a session handle.
129    ///
130    /// Works in both modes:
131    /// - **RootKey**: decrypts the VD01 envelope and derives the content key
132    /// - **ContentKey**: decrypts the envelope body using the content key directly
133    ///
134    /// The returned `CommitReader` holds a per-commit derived content key
135    /// for decrypting the commit's metadata and shards.
136    pub fn open_commit(&self, blob: &EncryptedCommit) -> CryptoResult<(Vec<u8>, CommitReader)> {
137        match &self.mode {
138            VaultMode::RootKey { root_key, .. } => {
139                CommitReader::open(root_key, blob.as_bytes())
140            }
141            VaultMode::ContentKey { content_key } => {
142                let reader = CommitReader::from_content_key(*content_key);
143                let (plaintext, _nonce) =
144                    reader.decrypt_envelope_body(blob.as_bytes(), AAD_COMMIT)?;
145                Ok((plaintext, reader))
146            }
147        }
148    }
149
150    /// Encrypt a commit with envelope format (VD01), generating a fresh nonce.
151    ///
152    /// Requires root-key mode.
153    pub fn seal_commit(&self, plaintext: &[u8]) -> CryptoResult<EncryptedCommit> {
154        let root_key = self.require_root_key()?;
155        let nonce = generate_key_nonce();
156        let bytes = encrypt_with_envelope(root_key, &nonce, plaintext, AAD_COMMIT)?;
157        Ok(EncryptedCommit::from_bytes(bytes))
158    }
159
160    /// Encrypt a commit with envelope format using a pre-derived nonce.
161    ///
162    /// Requires root-key mode.
163    pub fn seal_commit_with_nonce(
164        &self,
165        plaintext: &[u8],
166        nonce: &KeyNonce,
167    ) -> CryptoResult<EncryptedCommit> {
168        let root_key = self.require_root_key()?;
169        let bytes = encrypt_with_envelope(root_key, nonce, plaintext, AAD_COMMIT)?;
170        Ok(EncryptedCommit::from_bytes(bytes))
171    }
172
173    /// Derive the per-commit content key for a new commit (used during seal).
174    ///
175    /// Returns `(ContentKey, KeyNonce)` — the nonce is embedded in the envelope.
176    /// Requires root-key mode.
177    pub fn derive_commit_key(&self) -> CryptoResult<(ContentKey, KeyNonce)> {
178        let root_key = self.require_root_key()?;
179        let nonce = generate_key_nonce();
180        let scope = format!("commit:{}", hex::encode(nonce.as_bytes()));
181        let derived = crate::kdf::derive_scoped_key(root_key, &scope)?;
182        Ok((ContentKey::new(derived), nonce))
183    }
184
185    // =========================================================================
186    // Derived keys (purpose-specific, handed out to callers)
187    // =========================================================================
188
189    /// Key for encrypting/decrypting the index file.
190    ///
191    /// Requires root-key mode (keyring not available in content-key mode).
192    pub fn index_key(&self) -> CryptoResult<&SecretKey> {
193        Ok(&self.require_keyring()?.index)
194    }
195
196    /// Key for encrypting/decrypting stash entries.
197    ///
198    /// Requires root-key mode.
199    pub fn stash_key(&self) -> CryptoResult<&SecretKey> {
200        Ok(&self.require_keyring()?.stash)
201    }
202
203    /// Key for encrypting/decrypting staged blobs.
204    ///
205    /// Requires root-key mode.
206    pub fn staged_key(&self) -> CryptoResult<&SecretKey> {
207        Ok(&self.require_keyring()?.staged)
208    }
209
210    /// Key for encrypting/decrypting commits (used by seal pipeline).
211    ///
212    /// Requires root-key mode.
213    pub fn commits_key(&self) -> CryptoResult<&SecretKey> {
214        Ok(&self.require_keyring()?.commits)
215    }
216
217    /// Key for encrypting/decrypting metadata bundles.
218    ///
219    /// Requires root-key mode.
220    pub fn metadata_key(&self) -> CryptoResult<&SecretKey> {
221        Ok(&self.require_keyring()?.metadata)
222    }
223
224    /// Key for encrypting/decrypting content (file data in shards).
225    ///
226    /// Requires root-key mode.
227    pub fn content_key(&self) -> CryptoResult<&SecretKey> {
228        Ok(&self.require_keyring()?.content)
229    }
230
231    /// Access the full keyring (for operations that need multiple derived keys).
232    ///
233    /// Requires root-key mode.
234    pub fn keyring(&self) -> CryptoResult<&KeyRing> {
235        self.require_keyring()
236    }
237
238    // =========================================================================
239    // Generic decryption (uses root key internally)
240    // =========================================================================
241
242    /// Decrypt a blob with VD01 envelope format.
243    ///
244    /// Requires root-key mode.
245    pub fn decrypt_blob(&self, blob: &[u8], aad: &[u8]) -> CryptoResult<Vec<u8>> {
246        let root_key = self.require_root_key()?;
247        crate::decrypt_envelope(root_key, blob, aad)
248            .map(|(plaintext, _nonce)| plaintext)
249    }
250
251    /// Decrypt a blob to raw bytes with VD01 envelope format.
252    ///
253    /// Derives content key from the embedded envelope nonce.
254    /// Requires root-key mode.
255    pub fn decrypt_blob_raw(
256        &self,
257        blob: &[u8],
258        aad: &[u8],
259    ) -> CryptoResult<Vec<u8>> {
260        let root_key = self.require_root_key()?;
261        use crate::kdf::derive_scoped_key;
262
263        const ENVELOPE_HEADER_SIZE: usize = 4 + KeyNonce::SIZE;
264
265        if blob.len() <= ENVELOPE_HEADER_SIZE || !blob.starts_with(crate::MAGIC_V1) {
266            return Err(CryptoError::Decryption(
267                "missing VD01 envelope header".into(),
268            ));
269        }
270
271        let nonce = KeyNonce::from_bytes(&blob[4..ENVELOPE_HEADER_SIZE])
272            .ok_or_else(|| CryptoError::Decryption(
273                "invalid envelope nonce length".into(),
274            ))?;
275        let scope = format!("commit:{}", hex::encode(nonce.as_bytes()));
276        let derived_key = derive_scoped_key(root_key, &scope)?;
277        crate::decrypt(&derived_key, &blob[ENVELOPE_HEADER_SIZE..], aad)
278    }
279
280    // =========================================================================
281    // Purpose-specific seal/unseal (no raw key parameters)
282    // =========================================================================
283
284    /// Encrypt metadata with the root key (AAD_METADATA baked in).
285    ///
286    /// Used as the fallback when no per-commit content key is available
287    /// (e.g., merge commits). Requires root-key mode.
288    pub fn seal_metadata(&self, plaintext: &[u8]) -> CryptoResult<EncryptedMetadata> {
289        let root_key = self.require_root_key()?;
290        let bytes = crate::encrypt(root_key, plaintext, AAD_METADATA)?;
291        Ok(EncryptedMetadata::from_bytes(bytes))
292    }
293
294    /// Encrypt metadata with a content key (AAD_METADATA baked in).
295    ///
296    /// Used when a per-commit content key is available (normal commit path).
297    /// Works in both root-key and content-key modes (uses the provided key,
298    /// not the vault's root key).
299    pub fn seal_metadata_with_key(&self, key: &ContentKey, plaintext: &[u8]) -> CryptoResult<EncryptedMetadata> {
300        let bytes = crate::encrypt(key.as_bytes(), plaintext, AAD_METADATA)?;
301        Ok(EncryptedMetadata::from_bytes(bytes))
302    }
303
304    /// Encrypt shard data with the root key (AAD_SHARD baked in).
305    ///
306    /// Requires root-key mode.
307    pub fn seal_shard(&self, plaintext: &[u8]) -> CryptoResult<EncryptedShard> {
308        let root_key = self.require_root_key()?;
309        let bytes = crate::encrypt(root_key, plaintext, AAD_SHARD)?;
310        Ok(EncryptedShard::from_bytes(bytes))
311    }
312
313    /// Decrypt shard data encrypted with the root key (AAD_SHARD baked in).
314    ///
315    /// Requires root-key mode.
316    pub fn unseal_shard(&self, blob: &EncryptedShard) -> CryptoResult<Vec<u8>> {
317        let root_key = self.require_root_key()?;
318        crate::decrypt(root_key, blob.as_bytes(), AAD_SHARD)
319    }
320
321    /// Decrypt a commit blob returning plaintext and envelope nonce.
322    ///
323    /// Requires root-key mode.
324    pub fn unseal_commit_with_nonce(&self, blob: &[u8]) -> CryptoResult<(Vec<u8>, KeyNonce)> {
325        let root_key = self.require_root_key()?;
326        crate::decrypt_envelope(root_key, blob, AAD_COMMIT)
327    }
328
329    /// Derive a scoped content key from the root key.
330    ///
331    /// Requires root-key mode.
332    pub fn derive_scoped_key(&self, scope: &str) -> CryptoResult<ContentKey> {
333        let root_key = self.require_root_key()?;
334        let derived = crate::kdf::derive_scoped_key(root_key, scope)?;
335        Ok(ContentKey::new(derived))
336    }
337
338    /// Derive a deterministic repo secret from the root key via HKDF.
339    ///
340    /// Requires root-key mode.
341    pub fn repo_secret_fallback(&self) -> CryptoResult<SecretKey> {
342        let root_key = self.require_root_key()?;
343        Ok(SecretKey::new(
344            crate::derive_key(root_key, "void-v1-repo-secret")
345                .expect("HKDF derivation is infallible")
346        ))
347    }
348}
349
350impl Drop for KeyVault {
351    fn drop(&mut self) {
352        match &mut self.mode {
353            VaultMode::RootKey { root_key, .. } => root_key.zeroize(),
354            VaultMode::ContentKey { content_key } => content_key.zeroize(),
355        }
356    }
357}
358
359impl std::fmt::Debug for KeyVault {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        match &self.mode {
362            VaultMode::RootKey { .. } => {
363                f.debug_struct("KeyVault")
364                    .field("mode", &"RootKey [REDACTED]")
365                    .finish()
366            }
367            VaultMode::ContentKey { .. } => {
368                f.debug_struct("KeyVault")
369                    .field("mode", &"ContentKey [REDACTED]")
370                    .finish()
371            }
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::{encrypt, AAD_SHARD};
380
381    #[test]
382    fn vault_new_and_derived_keys() {
383        let root_key = crate::generate_key();
384        let vault = KeyVault::new(root_key).unwrap();
385
386        // Derived keys should all be different
387        assert_ne!(vault.index_key().unwrap().as_bytes(), vault.stash_key().unwrap().as_bytes());
388        assert_ne!(vault.stash_key().unwrap().as_bytes(), vault.staged_key().unwrap().as_bytes());
389        assert_ne!(
390            vault.index_key().unwrap().as_bytes(),
391            vault.staged_key().unwrap().as_bytes()
392        );
393    }
394
395    #[test]
396    fn vault_open_commit_roundtrip() {
397        let root_key = crate::generate_key();
398        let vault = KeyVault::new(root_key).unwrap();
399
400        let plaintext = b"commit data";
401        let sealed = vault.seal_commit(plaintext).unwrap();
402
403        let (decrypted, reader) = vault.open_commit(&sealed).unwrap();
404        assert_eq!(decrypted, plaintext);
405
406        // Content key should differ from root key (envelope format)
407        assert_ne!(reader.content_key().as_bytes(), &root_key);
408    }
409
410    #[test]
411    fn vault_seal_then_decrypt_shard() {
412        let root_key = crate::generate_key();
413        let vault = KeyVault::new(root_key).unwrap();
414
415        let commit_data = b"commit";
416        let sealed = vault.seal_commit(commit_data).unwrap();
417        let (_decrypted, reader) = vault.open_commit(&sealed).unwrap();
418
419        // Encrypt a shard with the content key
420        let shard_data = b"shard content";
421        let shard_blob =
422            encrypt(reader.content_key().as_bytes(), shard_data, AAD_SHARD).unwrap();
423
424        let decrypted_shard = reader.decrypt_shard(&shard_blob, None, &[]).unwrap();
425        assert_eq!(decrypted_shard, shard_data);
426    }
427
428    #[test]
429    fn vault_derive_commit_key() {
430        let root_key = crate::generate_key();
431        let vault = KeyVault::new(root_key).unwrap();
432
433        let (ck1, nonce1) = vault.derive_commit_key().unwrap();
434        let (ck2, nonce2) = vault.derive_commit_key().unwrap();
435
436        // Each call produces unique key + nonce
437        assert_ne!(nonce1, nonce2);
438        assert_ne!(ck1.as_bytes(), ck2.as_bytes());
439    }
440
441    #[test]
442    fn vault_debug_redacts_key() {
443        let root_key = crate::generate_key();
444        let vault = KeyVault::new(root_key).unwrap();
445
446        let debug = format!("{:?}", vault);
447        assert!(debug.contains("REDACTED"));
448        assert!(debug.contains("RootKey"));
449    }
450
451    // === Content-key mode tests ===
452
453    #[test]
454    fn content_key_vault_open_commit() {
455        let root_key = crate::generate_key();
456        let root_vault = KeyVault::new(root_key).unwrap();
457
458        // Seal a commit with root vault
459        let plaintext = b"test commit payload";
460        let sealed = root_vault.seal_commit(plaintext).unwrap();
461
462        // Get the content key from root-vault open
463        let (_, root_reader) = root_vault.open_commit(&sealed).unwrap();
464        let ck = *root_reader.content_key();
465
466        // Open the same commit with a content-key vault
467        let ck_vault = KeyVault::from_content_key(ck);
468        let (ck_plaintext, ck_reader) = ck_vault.open_commit(&sealed).unwrap();
469
470        assert_eq!(ck_plaintext, plaintext);
471        assert_eq!(ck_reader.content_key(), &ck);
472    }
473
474    #[test]
475    fn content_key_vault_rejects_seal() {
476        let ck = ContentKey::from_hex(&hex::encode([0x42u8; 32])).unwrap();
477        let vault = KeyVault::from_content_key(ck);
478
479        assert!(vault.seal_commit(b"test").is_err());
480        assert!(vault.seal_metadata(b"test").is_err());
481        assert!(vault.seal_shard(b"test").is_err());
482    }
483
484    #[test]
485    fn content_key_vault_rejects_keyring_access() {
486        let ck = ContentKey::from_hex(&hex::encode([0x42u8; 32])).unwrap();
487        let vault = KeyVault::from_content_key(ck);
488
489        assert!(vault.index_key().is_err());
490        assert!(vault.staged_key().is_err());
491        assert!(vault.stash_key().is_err());
492    }
493
494    #[test]
495    fn content_key_vault_is_content_key_mode() {
496        let root_vault = KeyVault::new(crate::generate_key()).unwrap();
497        assert!(!root_vault.is_content_key_mode());
498
499        let ck = ContentKey::from_hex(&hex::encode([0x42u8; 32])).unwrap();
500        let ck_vault = KeyVault::from_content_key(ck);
501        assert!(ck_vault.is_content_key_mode());
502    }
503
504    #[test]
505    fn content_key_vault_debug_redacts() {
506        let ck = ContentKey::from_hex(&hex::encode([0x42u8; 32])).unwrap();
507        let vault = KeyVault::from_content_key(ck);
508        let debug = format!("{:?}", vault);
509        assert!(debug.contains("REDACTED"));
510        assert!(debug.contains("ContentKey"));
511    }
512
513    #[test]
514    fn root_seal_then_content_key_open_roundtrip() {
515        let root_key = crate::generate_key();
516        let root_vault = KeyVault::new(root_key).unwrap();
517
518        // Seal commit + metadata with root vault
519        let commit_data = b"commit payload";
520        let sealed = root_vault.seal_commit(commit_data).unwrap();
521        let (_, root_reader) = root_vault.open_commit(&sealed).unwrap();
522        let ck = *root_reader.content_key();
523
524        let metadata = b"metadata payload";
525        let enc_metadata = root_vault.seal_metadata_with_key(&ck, metadata).unwrap();
526
527        // Open with content-key vault
528        let ck_vault = KeyVault::from_content_key(ck);
529        let (ck_plaintext, ck_reader) = ck_vault.open_commit(&sealed).unwrap();
530        assert_eq!(ck_plaintext, commit_data);
531
532        // Decrypt metadata with both readers
533        let dec_root: Vec<u8> = root_reader.decrypt_metadata_raw(enc_metadata.as_bytes()).unwrap();
534        let dec_ck: Vec<u8> = ck_reader.decrypt_metadata_raw(enc_metadata.as_bytes()).unwrap();
535        assert_eq!(dec_root, metadata);
536        assert_eq!(dec_ck, metadata);
537    }
538}