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(
311 stored: &[u8],
312) -> Result<
313 (
314 coven_keys::encryption::KeyFingerprint,
315 SealedBlobHeader,
316 &[u8],
317 ),
318 EncryptionError,
319> {
320 let (fingerprint, rest) = KeyTag::read(stored)?;
321 let header = SealedBlobHeader::parse(rest)?;
322 Ok((
323 coven_keys::encryption::KeyFingerprint::from_bytes(fingerprint),
324 header,
325 &rest[header.prefix_len() as usize..],
326 ))
327}
328
329#[cfg(any(test, feature = "test-utils"))]
333pub fn open_sealed_blob(
334 stored: &[u8],
335 encryption: &EncryptionService,
336 aad_context: &[u8],
337) -> Result<(coven_keys::encryption::KeyFingerprint, Vec<u8>), EncryptionError> {
338 let (fingerprint, header, chunks) = split_sealed_blob(stored)?;
339 let plaintext = encryption
340 .blob_opener(
341 header,
342 &NoncePolicy::DerivedFromContext {
343 context: aad_context.to_vec(),
344 },
345 aad_context,
346 )?
347 .open_chunks(0..header.chunk_count(), chunks)?;
348 Ok((fingerprint, plaintext))
349}
350
351pub(crate) fn verified_sealed_blob_opener(
356 prefix: &[u8],
357 blob: &coven_protocol::blob::locator::StoredBlobRef,
358 key_fingerprint: &coven_keys::encryption::KeyFingerprint,
359 scope: &coven_protocol::blob::BlobScope,
360 master: &EncryptionService,
361 aad_context: &[u8],
362) -> Result<coven_keys::encryption::SealedBlobOpener, StorageError> {
363 let locator = blob.locator();
364 let (fingerprint, header, _) =
365 split_sealed_blob(prefix).map_err(|source| StorageError::Decryption {
366 context: format!("blob {}", locator.locator_hash()),
367 source,
368 })?;
369 if fingerprint != *key_fingerprint {
370 return Err(StorageError::InvalidContent(format!(
371 "blob {} stored key fingerprint differs from its locator",
372 locator.locator_hash()
373 )));
374 }
375 let encryption = opening_encryption_for_scope(scope.clone(), master, fingerprint.as_bytes())
376 .map_err(|source| StorageError::Decryption {
377 context: format!("blob {} audience key", locator.locator_hash()),
378 source,
379 })?;
380 if header.plaintext_len() != locator.plaintext_size() {
381 return Err(StorageError::InvalidContent(format!(
382 "blob {} header declares {} plaintext bytes, its locator declares {}",
383 locator.locator_hash(),
384 header.plaintext_len(),
385 locator.plaintext_size()
386 )));
387 }
388 check_stored_blob_length(blob, KeyTag::LEN as u64 + header.sealed_len())?;
389 encryption
390 .blob_opener(
391 header,
392 &NoncePolicy::DerivedFromContext {
393 context: aad_context.to_vec(),
394 },
395 aad_context,
396 )
397 .map_err(|source| StorageError::Decryption {
398 context: format!("blob {}", locator.locator_hash()),
399 source: source.into(),
400 })
401}
402
403pub(crate) fn check_stored_blob_length(
407 blob: &coven_protocol::blob::locator::StoredBlobRef,
408 expected: u64,
409) -> Result<(), StorageError> {
410 if blob.object().stored_size() != expected {
411 return Err(StorageError::InvalidContent(format!(
412 "blob {} stored length is {}, expected {expected} for its locator",
413 blob.locator().locator_hash(),
414 blob.object().stored_size()
415 )));
416 }
417 Ok(())
418}
419
420#[async_trait]
421impl coven_foundation::local_file::PlaintextChunkReader for ExactBlobPlaintextReader {
422 type Error = crate::local_file::PlaintextChunkError;
423
424 async fn next_chunk(
425 &mut self,
426 max: usize,
427 ) -> Result<Vec<u8>, crate::local_file::PlaintextChunkError> {
428 if max == 0 {
429 return Ok(Vec::new());
430 }
431 if !self.pending.is_empty() {
432 return Ok(self.take_pending(max));
433 }
434 if self.remaining == 0 {
435 self.verify_complete()?;
436 return Ok(Vec::new());
437 }
438
439 let plaintext = match &mut self.opening {
440 ExactBlobOpening::Browsable => {
441 let wanted = usize::try_from(self.remaining.min(max as u64)).map_err(|_| {
442 crate::local_file::PlaintextChunkError::InvalidContent(
443 "blob plaintext read length does not fit this platform".to_string(),
444 )
445 })?;
446 let chunk = self.source.next_chunk(wanted).await?;
447 if chunk.is_empty() {
448 return Err(crate::local_file::PlaintextChunkError::InvalidContent(
449 format!("blob {} plaintext ended early", self.locator_hash),
450 ));
451 }
452 chunk
453 }
454 ExactBlobOpening::Opaque { opener, next_chunk } => {
455 let index = *next_chunk;
456 let sealed_len =
457 usize::try_from(opener.header().sealed_chunk_len(index)).map_err(|_| {
458 crate::local_file::PlaintextChunkError::InvalidContent(
459 "one sealed blob chunk does not fit this platform".to_string(),
460 )
461 })?;
462 let sealed = read_source_exact(&mut self.source, sealed_len, self.locator_hash)
463 .await
464 .map_err(crate::local_file::PlaintextChunkError::Remote)?;
465 let plaintext = opener.open_chunk(index, &sealed).map_err(|source| {
466 crate::local_file::PlaintextChunkError::Decryption {
467 context: format!("blob {}", self.locator_hash),
468 source: source.into(),
469 }
470 })?;
471 *next_chunk += 1;
472 plaintext
473 }
474 };
475 if plaintext.len() as u64 > self.remaining {
476 return Err(crate::local_file::PlaintextChunkError::InvalidContent(
477 format!("blob {} produced excess plaintext", self.locator_hash),
478 ));
479 }
480 if let Some(hasher) = self.hasher.as_mut() {
483 hasher.update(&plaintext);
484 }
485 self.remaining -= plaintext.len() as u64;
486 self.pending = plaintext;
487 Ok(self.take_pending(max))
488 }
489}