Skip to main content

void_core/crypto/
reader.rs

1//! CommitReader: encapsulates the commit->metadata->shard key derivation chain.
2//!
3//! This is the void-core version that adds void-core-specific methods:
4//! - `collect_ancestor_content_keys()` -- needs ObjectStore + Commit types
5//!
6//! Low-level crypto primitives are delegated to `void-crypto`.
7
8use crate::cid::ToVoidCid;
9use crate::store::ObjectStoreExt;
10use crate::{Result, VoidError};
11use void_crypto::{
12    ContentKey, EncryptedCommit, EncryptedMetadata, EncryptedRepoManifest,
13    EncryptedShard, KeyVault,
14};
15
16/// Decrypted shard bytes (zstd-compressed body + optional padding).
17///
18/// The result of decrypting an `EncryptedShard`. Use `decompress()` to get
19/// a `ShardBody` for manifest-driven file access.
20#[derive(Debug, Clone)]
21pub struct DecryptedShard(Vec<u8>);
22
23impl DecryptedShard {
24    /// Decompress the body into a `ShardBody` for manifest-driven file access
25    /// via `ShardBody::read_file(&ManifestEntry)`.
26    pub fn decompress(self) -> Result<crate::shard::ShardBody> {
27        crate::shard::decompress_shard_body(self.0)
28    }
29
30    /// Access the raw bytes.
31    pub fn as_bytes(&self) -> &[u8] {
32        &self.0
33    }
34
35    /// Consume into the inner bytes.
36    pub fn into_bytes(self) -> Vec<u8> {
37        self.0
38    }
39
40    /// Get the byte length.
41    pub fn len(&self) -> usize {
42        self.0.len()
43    }
44
45    /// Check if empty.
46    pub fn is_empty(&self) -> bool {
47        self.0.is_empty()
48    }
49}
50
51/// Decrypted commit plaintext bytes.
52///
53/// This newtype wraps the raw bytes returned by `CommitReader::open_with_vault()`
54/// and provides a typed `parse()` method to deserialize into a `Commit`.
55/// Prevents accidentally passing arbitrary `Vec<u8>` to `parse_commit()`.
56#[derive(Debug, Clone)]
57pub struct CommitPlaintext(Vec<u8>);
58
59impl CommitPlaintext {
60    /// Parse the plaintext bytes into a `Commit`.
61    pub fn parse(&self) -> Result<crate::metadata::Commit> {
62        crate::metadata::parse_commit(&self.0)
63    }
64
65    /// Access the raw bytes.
66    pub fn as_bytes(&self) -> &[u8] {
67        &self.0
68    }
69}
70
71/// Encapsulates the key derivation chain for reading a commit's objects.
72///
73/// Created by decrypting a commit blob, which extracts the envelope nonce
74/// and derives the content key. The content key is then used to decrypt
75/// metadata and shards.
76pub struct CommitReader {
77    content_key: ContentKey,
78}
79
80impl CommitReader {
81    /// Create a CommitReader from a share key (for share-based unseal).
82    ///
83    /// In share-based unseal the derived key serves directly as the
84    /// content key — there is no commit envelope involved.
85    pub fn from_share_key(key: void_crypto::ShareKey) -> Self {
86        let crypto_reader = void_crypto::CommitReader::from_share_key(key);
87        Self {
88            content_key: crypto_reader.into_parts(),
89        }
90    }
91
92    /// Create a CommitReader from a content key directly (for scoped-key clone).
93    ///
94    /// Used when cloning from a published repo with `--content-key`. The content
95    /// key is the only key available -- no root key exists in scoped mode.
96    pub fn from_content_key(content_key: ContentKey) -> Self {
97        Self { content_key }
98    }
99
100    /// Decrypt a commit blob using a `KeyVault`, routing key material through
101    /// the vault rather than accepting raw bytes.
102    ///
103    /// This is the preferred constructor. The vault decrypts the commit and
104    /// produces a crypto-layer `CommitReader`, whose parts are transferred
105    /// into this core-layer reader. Callers never handle raw key bytes.
106    pub fn open_with_vault(vault: &KeyVault, commit_blob: &EncryptedCommit) -> Result<(CommitPlaintext, Self)> {
107        let (plaintext, crypto_reader) = vault.open_commit(commit_blob)?;
108        let content_key = crypto_reader.into_parts();
109        Ok((CommitPlaintext(plaintext), Self { content_key }))
110    }
111
112    /// Decrypt a metadata bundle blob and deserialize from CBOR.
113    pub fn decrypt_metadata<T>(&self, blob: &EncryptedMetadata) -> Result<T>
114    where
115        T: serde::de::DeserializeOwned,
116    {
117        Ok(blob.decrypt_and_parse(self.content_key.as_bytes())?)
118    }
119
120    /// Decrypt a shard blob. Handles wrapped keys, content key, and
121    /// optional ancestor key fallback.
122    ///
123    /// Fallback chain:
124    /// 1. If `wrapped_key` is `Some`, try unwrapping with content_key, then each ancestor key
125    /// 2. Try content_key directly
126    /// 3. Try each ancestor key directly
127    pub fn decrypt_shard(
128        &self,
129        blob: &EncryptedShard,
130        wrapped_key: Option<&void_crypto::WrappedKey>,
131        ancestor_keys: &[ContentKey],
132    ) -> Result<DecryptedShard> {
133        use void_crypto::{decrypt, unwrap_shard_key, AAD_SHARD};
134
135        let data = blob.as_bytes();
136
137        if let Some(wk) = wrapped_key {
138            if let Ok(shard_key) = unwrap_shard_key(&self.content_key, wk) {
139                if let Ok(result) = decrypt(&shard_key, data, AAD_SHARD) {
140                    return Ok(DecryptedShard(result));
141                }
142            }
143            for ak in ancestor_keys {
144                if let Ok(shard_key) = unwrap_shard_key(ak, wk) {
145                    if let Ok(result) = decrypt(&shard_key, data, AAD_SHARD) {
146                        return Ok(DecryptedShard(result));
147                    }
148                }
149            }
150        }
151
152        if let Ok(result) = decrypt(self.content_key.as_bytes(), data, AAD_SHARD) {
153            return Ok(DecryptedShard(result));
154        }
155
156        for key in ancestor_keys {
157            if let Ok(result) = decrypt(key.as_bytes(), data, AAD_SHARD) {
158                return Ok(DecryptedShard(result));
159            }
160        }
161
162        Err(VoidError::Decryption(
163            "shard decryption failed with all known keys".into(),
164        ))
165    }
166
167    /// Decrypt a repo manifest blob into a typed `Manifest`.
168    pub fn decrypt_repo_manifest(&self, blob: &EncryptedRepoManifest) -> Result<crate::collab::Manifest> {
169        let bytes = blob.decrypt(self.content_key.as_bytes())?;
170        serde_json::from_slice(&bytes)
171            .map_err(|e| VoidError::Serialization(format!("repo manifest JSON: {e}")))
172    }
173
174    /// Decrypt a VD01 envelope body of a commit blob using the content key.
175    ///
176    /// Returns the commit plaintext and the envelope nonce.
177    /// Used by share-based unseal where the share key IS the content key.
178    pub fn decrypt_envelope_body(&self, blob: &EncryptedCommit) -> Result<(CommitPlaintext, void_crypto::KeyNonce)> {
179        let crypto_reader = void_crypto::CommitReader::from_content_key(*self.content_key());
180        let (plaintext, nonce) = crypto_reader.decrypt_envelope_body(blob.as_bytes(), void_crypto::AAD_COMMIT)?;
181        Ok((CommitPlaintext(plaintext), nonce))
182    }
183
184    /// Get the content key (derived key for envelope commits).
185    pub fn content_key(&self) -> &ContentKey {
186        &self.content_key
187    }
188}
189
190// ---------------------------------------------------------------------------
191// decrypt_shard_data: unified shard decryption with wrapped key support
192// ---------------------------------------------------------------------------
193
194/// Decrypt a shard blob with wrapped key support.
195///
196/// Fallback chain:
197/// 1. If `wrapped_key` is `Some`, try unwrapping with content_key, then each ancestor key
198/// 2. Try content_key directly
199/// 3. Try each ancestor key directly
200pub fn decrypt_shard_data(
201    content_key: &ContentKey,
202    ancestor_keys: &[ContentKey],
203    blob: &EncryptedShard,
204    wrapped_key: Option<&void_crypto::WrappedKey>,
205) -> Result<DecryptedShard> {
206    use void_crypto::{decrypt, unwrap_shard_key, AAD_SHARD};
207
208    let data = blob.as_bytes();
209
210    if let Some(wk) = wrapped_key {
211        if let Ok(shard_key) = unwrap_shard_key(content_key, wk) {
212            if let Ok(result) = decrypt(&shard_key, data, AAD_SHARD) {
213                return Ok(DecryptedShard(result));
214            }
215        }
216        for ak in ancestor_keys {
217            if let Ok(shard_key) = unwrap_shard_key(ak, wk) {
218                if let Ok(result) = decrypt(&shard_key, data, AAD_SHARD) {
219                    return Ok(DecryptedShard(result));
220                }
221            }
222        }
223    }
224    if let Ok(result) = decrypt(content_key.as_bytes(), data, AAD_SHARD) {
225        return Ok(DecryptedShard(result));
226    }
227    for ak in ancestor_keys {
228        if let Ok(result) = decrypt(ak.as_bytes(), data, AAD_SHARD) {
229            return Ok(DecryptedShard(result));
230        }
231    }
232    Err(VoidError::Decryption(
233        "shard decryption failed with all known keys".into(),
234    ))
235}
236
237// ---------------------------------------------------------------------------
238// collect_ancestor_content_keys
239// ---------------------------------------------------------------------------
240
241/// Collect content keys from ancestor commits for shard decryption fallback.
242///
243/// Accepts a `KeyVault` to open ancestor commits. The root key is routed
244/// through the vault -- callers never handle raw key bytes.
245pub fn collect_ancestor_content_keys_vault(
246    vault: &KeyVault,
247    store: &impl ObjectStoreExt,
248    commit: &crate::metadata::Commit,
249) -> Vec<ContentKey> {
250    let mut keys = Vec::new();
251    let mut current_parent = commit.parent().cloned();
252    for _ in 0..100 {
253        let parent_cid_bytes = match current_parent {
254            Some(ref p) => p.clone(),
255            None => break,
256        };
257        let parent_cid = match parent_cid_bytes.to_void_cid() {
258            Ok(c) => c,
259            Err(_) => break,
260        };
261        let parent_encrypted: EncryptedCommit = match store.get_blob(&parent_cid) {
262            Ok(d) => d,
263            Err(_) => break,
264        };
265        let (parent_bytes, parent_reader) =
266            match CommitReader::open_with_vault(vault, &parent_encrypted) {
267                Ok(r) => r,
268                Err(_) => break,
269            };
270        keys.push(*parent_reader.content_key());
271        let parent_commit = match parent_bytes.parse() {
272            Ok(c) => c,
273            Err(_) => break,
274        };
275        current_parent = parent_commit.parent().cloned();
276    }
277    keys
278}
279
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::crypto::{generate_key, wrap_shard_key};
285
286    #[test]
287    fn commit_reader_open_envelope() {
288        let root_key = [0x42u8; 32];
289        let vault = KeyVault::new(root_key).unwrap();
290        let plaintext = b"commit data";
291
292        let commit_blob = vault.seal_commit(plaintext).unwrap();
293        let (decrypted, reader) = CommitReader::open_with_vault(&vault, &commit_blob).unwrap();
294        assert_eq!(decrypted.as_bytes(), plaintext);
295        assert_ne!(reader.content_key().as_bytes(), &root_key);
296    }
297
298    #[test]
299    fn commit_reader_decrypt_shard() {
300        let root_key = [0x42u8; 32];
301        let vault = KeyVault::new(root_key).unwrap();
302        let shard_data = b"shard content";
303
304        let commit_blob = vault.seal_commit(b"commit").unwrap();
305        let (_commit, reader) = CommitReader::open_with_vault(&vault, &commit_blob).unwrap();
306
307        let shard_blob = EncryptedShard::encrypt(reader.content_key().as_bytes(), shard_data).unwrap();
308        let decrypted = reader.decrypt_shard(&shard_blob, None, &[]).unwrap();
309        assert_eq!(decrypted.as_bytes(), shard_data);
310    }
311
312    #[test]
313    fn decrypt_shard_data_falls_back_on_bad_wrapped_key() {
314        let content_key = ContentKey::from_hex(&hex::encode([0x42u8; 32])).unwrap();
315        let ancestor_key = ContentKey::from_hex(&hex::encode([0x99u8; 32])).unwrap();
316        let shard_data = b"standalone shard data";
317
318        let shard_blob = EncryptedShard::encrypt(ancestor_key.as_bytes(), shard_data).unwrap();
319
320        let other_key = ContentKey::from_hex(&hex::encode([0xABu8; 32])).unwrap();
321        let shard_key = generate_key();
322        let bad_wrapped = wrap_shard_key(&other_key, &shard_key).unwrap();
323
324        let decrypted = decrypt_shard_data(
325            &content_key,
326            &[ancestor_key],
327            &shard_blob,
328            Some(&bad_wrapped),
329        )
330        .unwrap();
331        assert_eq!(decrypted.as_bytes(), shard_data);
332    }
333
334}