1use super::cipher::*;
2use super::*;
3
4#[derive(Clone, Copy)]
8pub enum BlobPathScheme {
9 Hashed,
11 Plain,
15}
16
17impl BlobPathScheme {
18 pub fn for_storage(storage: coven_foundation::config::HomeStorage) -> Self {
21 if storage.is_opaque() {
22 BlobPathScheme::Hashed
23 } else {
24 BlobPathScheme::Plain
25 }
26 }
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct BlobChunking {
37 chunk: std::num::NonZeroU32,
38 window: std::num::NonZeroU64,
39}
40
41impl BlobChunking {
42 pub const DEFAULT: Self = Self {
44 chunk: coven_keys::encryption::DEFAULT_BLOB_CHUNK_SIZE,
45 window: match std::num::NonZeroU64::new(1 << 20) {
46 Some(window) => window,
47 None => unreachable!(),
48 },
49 };
50
51 #[cfg(any(test, feature = "test-utils"))]
52 pub fn new(chunk: std::num::NonZeroU32, window: std::num::NonZeroU64) -> Self {
53 Self { chunk, window }
54 }
55
56 pub fn chunk(self) -> std::num::NonZeroU32 {
57 self.chunk
58 }
59
60 pub fn window(self) -> std::num::NonZeroU64 {
61 self.window
62 }
63}
64
65pub struct BlobRangeReader {
76 exact: Arc<dyn ExactCloudHome>,
77 slot: coven_protocol::objects::ObjectSlot,
78 opener: coven_keys::encryption::SealedBlobOpener,
79 plaintext_size: u64,
80 window: std::num::NonZeroU64,
81}
82
83impl BlobRangeReader {
84 pub(crate) fn new(
85 exact: Arc<dyn ExactCloudHome>,
86 slot: coven_protocol::objects::ObjectSlot,
87 opener: coven_keys::encryption::SealedBlobOpener,
88 plaintext_size: u64,
89 window: std::num::NonZeroU64,
90 ) -> Self {
91 Self {
92 exact,
93 slot,
94 opener,
95 plaintext_size,
96 window,
97 }
98 }
99
100 pub fn plaintext_size(&self) -> u64 {
102 self.plaintext_size
103 }
104
105 pub async fn read_at(&self, offset: u64, len: u64) -> Result<Vec<u8>, StorageError> {
108 if len == 0 {
109 return Ok(Vec::new());
110 }
111 let end = offset.checked_add(len).ok_or_else(|| {
112 StorageError::Storage(format!("blob range overflow: offset={offset}, len={len}"))
113 })?;
114 if end > self.plaintext_size {
115 return Err(StorageError::Storage(format!(
116 "blob range {offset}..{end} exceeds blob size {}",
117 self.plaintext_size
118 )));
119 }
120 let header = self.opener.header();
121 let chunks =
122 header
123 .covering_chunks(offset, end)
124 .map_err(|source| StorageError::Decryption {
125 context: format!("blob range {offset}..{end}"),
126 source: source.into(),
127 })?;
128 let mut plaintext = Vec::with_capacity(len as usize);
129 for run in header.request_runs(chunks, self.window) {
130 let span = header.sealed_span(run.clone());
131 let sealed = self
132 .read_stored(
133 KeyTag::LEN as u64 + span.start,
134 KeyTag::LEN as u64 + span.end,
135 )
136 .await?;
137 let covered = header.plaintext_span(run.clone());
138 let opened = self.opener.open_chunks(run, &sealed).map_err(|error| {
139 StorageError::Decryption {
140 context: format!("blob range {offset}..{end}"),
141 source: error.into(),
142 }
143 })?;
144 let from = (offset.max(covered.start) - covered.start) as usize;
145 let to = (end.min(covered.end) - covered.start) as usize;
146 plaintext.extend_from_slice(&opened[from..to]);
147 }
148 Ok(plaintext)
149 }
150
151 async fn read_stored(&self, start: u64, end: u64) -> Result<Vec<u8>, StorageError> {
153 let bytes = self
154 .exact
155 .read_range_at(&self.slot, start, end)
156 .await
157 .map_err(StorageError::from)?;
158 if bytes.len() as u64 != end - start {
162 return Err(StorageError::InvalidContent(format!(
163 "ranged read of {} returned {} bytes for {start}..{end}",
164 self.slot.logical_key(),
165 bytes.len()
166 )));
167 }
168 Ok(bytes)
169 }
170}
171
172pub(crate) enum ExactBlobOpening {
173 Browsable,
174 Opaque {
175 opener: coven_keys::encryption::SealedBlobOpener,
176 next_chunk: u64,
177 },
178}
179
180pub(crate) struct ExactBlobPlaintextReader {
183 source: crate::local_file::PlaintextReader,
184 opening: ExactBlobOpening,
185 remaining: u64,
186 hasher: Option<coven_protocol::blob::ContentHasher>,
187 expected_hash: ObjectHash,
188 locator_hash: ObjectHash,
189 pending: Vec<u8>,
190 pending_offset: usize,
191}
192
193impl ExactBlobPlaintextReader {
194 pub(crate) async fn new(
195 stored_file: &Path,
196 store_id: &str,
197 blob: &coven_protocol::blob::locator::StoredBlobRef,
198 protection: coven_protocol::objects::BlobSpoolProtection,
199 ) -> Result<Self, StorageError> {
200 let locator = blob.locator();
201 let mut source = crate::local_file::open_reader(stored_file)
202 .await
203 .map_err(StorageError::LocalFilesystem)?;
204
205 let opening = match (locator, protection) {
206 (
207 coven_protocol::blob::locator::BlobLocator::Opaque {
208 scope,
209 key_fingerprint,
210 ..
211 },
212 coven_protocol::objects::BlobSpoolProtection::Opaque(master),
213 ) => {
214 let prefix = read_source_exact(
215 &mut source,
216 KeyTag::LEN + SEALED_BLOB_HEADER_LEN,
217 locator.locator_hash(),
218 )
219 .await?;
220 let opener = verified_sealed_blob_opener(
221 &prefix,
222 blob,
223 key_fingerprint,
224 scope,
225 &master,
226 &cloud_aad_context(store_id, &locator.semantic_key()),
227 )?;
228 ExactBlobOpening::Opaque {
229 opener,
230 next_chunk: 0,
231 }
232 }
233 (
234 coven_protocol::blob::locator::BlobLocator::Browsable { .. },
235 coven_protocol::objects::BlobSpoolProtection::Browsable,
236 ) => {
237 check_stored_blob_length(blob, locator.plaintext_size())?;
238 ExactBlobOpening::Browsable
239 }
240 (coven_protocol::blob::locator::BlobLocator::Opaque { .. }, _) => {
241 return Err(StorageError::Configuration(
242 "opaque blob locator requires audience encryption".to_string(),
243 ));
244 }
245 (coven_protocol::blob::locator::BlobLocator::Browsable { .. }, _) => {
246 return Err(StorageError::Configuration(
247 "browsable blob locator cannot use audience encryption".to_string(),
248 ));
249 }
250 };
251
252 Ok(Self {
253 hasher: match opening {
261 ExactBlobOpening::Browsable => Some(coven_protocol::blob::ContentHasher::default()),
262 ExactBlobOpening::Opaque { .. } => None,
263 },
264 source,
265 opening,
266 remaining: locator.plaintext_size(),
267 expected_hash: locator.plaintext_hash(),
268 locator_hash: locator.locator_hash(),
269 pending: Vec::new(),
270 pending_offset: 0,
271 })
272 }
273
274 fn take_pending(&mut self, max: usize) -> Vec<u8> {
275 let end = (self.pending_offset + max).min(self.pending.len());
276 let result = self.pending[self.pending_offset..end].to_vec();
277 self.pending_offset = end;
278 if self.pending_offset == self.pending.len() {
279 self.pending.clear();
280 self.pending_offset = 0;
281 }
282 result
283 }
284
285 fn verify_complete(&mut self) -> Result<(), crate::local_file::PlaintextChunkError> {
286 let Some(hasher) = self.hasher.take() else {
287 return Ok(());
288 };
289 let actual = hasher.finish();
290 if actual != self.expected_hash.to_string() {
291 return Err(crate::local_file::PlaintextChunkError::InvalidContent(
292 format!(
293 "blob {} plaintext hash mismatch: expected {}, got {actual}",
294 self.locator_hash, self.expected_hash
295 ),
296 ));
297 }
298 Ok(())
299 }
300}
301
302pub(crate) fn split_sealed_blob(
312 stored: &[u8],
313) -> Result<
314 (
315 coven_keys::encryption::KeyFingerprint,
316 SealedBlobHeader,
317 &[u8],
318 ),
319 EncryptionError,
320> {
321 let (fingerprint, rest) = KeyTag::read(stored)?;
322 let header = SealedBlobHeader::parse(rest)?;
323 Ok((
324 coven_keys::encryption::KeyFingerprint::from_bytes(fingerprint),
325 header,
326 &rest[header.prefix_len() as usize..],
327 ))
328}
329
330#[cfg(any(test, feature = "test-utils"))]
334pub fn open_sealed_blob(
335 stored: &[u8],
336 encryption: &EncryptionService,
337 aad_context: &[u8],
338) -> Result<(coven_keys::encryption::KeyFingerprint, Vec<u8>), EncryptionError> {
339 let (fingerprint, header, chunks) = split_sealed_blob(stored)?;
340 let plaintext = encryption
341 .blob_opener(
342 header,
343 &NoncePolicy::DerivedFromContext {
344 context: aad_context.to_vec(),
345 },
346 aad_context,
347 )?
348 .open_chunks(0..header.chunk_count(), chunks)?;
349 Ok((fingerprint, plaintext))
350}
351
352pub(crate) fn verified_sealed_blob_opener(
357 prefix: &[u8],
358 blob: &coven_protocol::blob::locator::StoredBlobRef,
359 key_fingerprint: &coven_keys::encryption::KeyFingerprint,
360 scope: &coven_protocol::blob::BlobScope,
361 master: &EncryptionService,
362 aad_context: &[u8],
363) -> Result<coven_keys::encryption::SealedBlobOpener, StorageError> {
364 let locator = blob.locator();
365 let (fingerprint, header, _) =
366 split_sealed_blob(prefix).map_err(|source| StorageError::Decryption {
367 context: format!("blob {}", locator.locator_hash()),
368 source,
369 })?;
370 if fingerprint != *key_fingerprint {
371 return Err(StorageError::InvalidContent(format!(
372 "blob {} stored key fingerprint differs from its locator",
373 locator.locator_hash()
374 )));
375 }
376 let encryption = opening_encryption_for_scope(scope.clone(), master, fingerprint.as_bytes())
377 .map_err(|source| StorageError::Decryption {
378 context: format!("blob {} audience key", locator.locator_hash()),
379 source,
380 })?;
381 if header.plaintext_len() != locator.plaintext_size() {
382 return Err(StorageError::InvalidContent(format!(
383 "blob {} header declares {} plaintext bytes, its locator declares {}",
384 locator.locator_hash(),
385 header.plaintext_len(),
386 locator.plaintext_size()
387 )));
388 }
389 check_stored_blob_length(blob, KeyTag::LEN as u64 + header.sealed_len())?;
390 encryption
391 .blob_opener(
392 header,
393 &NoncePolicy::DerivedFromContext {
394 context: aad_context.to_vec(),
395 },
396 aad_context,
397 )
398 .map_err(|source| StorageError::Decryption {
399 context: format!("blob {}", locator.locator_hash()),
400 source: source.into(),
401 })
402}
403
404pub(crate) fn check_stored_blob_length(
408 blob: &coven_protocol::blob::locator::StoredBlobRef,
409 expected: u64,
410) -> Result<(), StorageError> {
411 if blob.object().stored_size() != expected {
412 return Err(StorageError::InvalidContent(format!(
413 "blob {} stored length is {}, expected {expected} for its locator",
414 blob.locator().locator_hash(),
415 blob.object().stored_size()
416 )));
417 }
418 Ok(())
419}
420
421#[async_trait]
422impl coven_foundation::local_file::PlaintextChunkReader for ExactBlobPlaintextReader {
423 type Error = crate::local_file::PlaintextChunkError;
424
425 async fn next_chunk(
426 &mut self,
427 max: usize,
428 ) -> Result<Vec<u8>, crate::local_file::PlaintextChunkError> {
429 if max == 0 {
430 return Ok(Vec::new());
431 }
432 if !self.pending.is_empty() {
433 return Ok(self.take_pending(max));
434 }
435 if self.remaining == 0 {
436 self.verify_complete()?;
437 return Ok(Vec::new());
438 }
439
440 let plaintext = match &mut self.opening {
441 ExactBlobOpening::Browsable => {
442 let wanted = usize::try_from(self.remaining.min(max as u64)).map_err(|_| {
443 crate::local_file::PlaintextChunkError::InvalidContent(
444 "blob plaintext read length does not fit this platform".to_string(),
445 )
446 })?;
447 let chunk = self.source.next_chunk(wanted).await?;
448 if chunk.is_empty() {
449 return Err(crate::local_file::PlaintextChunkError::InvalidContent(
450 format!("blob {} plaintext ended early", self.locator_hash),
451 ));
452 }
453 chunk
454 }
455 ExactBlobOpening::Opaque { opener, next_chunk } => {
456 let index = *next_chunk;
457 let sealed_len =
458 usize::try_from(opener.header().sealed_chunk_len(index)).map_err(|_| {
459 crate::local_file::PlaintextChunkError::InvalidContent(
460 "one sealed blob chunk does not fit this platform".to_string(),
461 )
462 })?;
463 let sealed = read_source_exact(&mut self.source, sealed_len, self.locator_hash)
464 .await
465 .map_err(crate::local_file::PlaintextChunkError::Remote)?;
466 let plaintext = opener.open_chunk(index, &sealed).map_err(|source| {
467 crate::local_file::PlaintextChunkError::Decryption {
468 context: format!("blob {}", self.locator_hash),
469 source: source.into(),
470 }
471 })?;
472 *next_chunk += 1;
473 plaintext
474 }
475 };
476 if plaintext.len() as u64 > self.remaining {
477 return Err(crate::local_file::PlaintextChunkError::InvalidContent(
478 format!("blob {} produced excess plaintext", self.locator_hash),
479 ));
480 }
481 if let Some(hasher) = self.hasher.as_mut() {
484 hasher.update(&plaintext);
485 }
486 self.remaining -= plaintext.len() as u64;
487 self.pending = plaintext;
488 Ok(self.take_pending(max))
489 }
490}