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