Skip to main content

coven_storage/remote/
blob_io.rs

1use super::cipher::*;
2use super::*;
3
4/// How a cloud home names its blob objects. Paired with the at-rest
5/// [`CloudCipher`] by the home's [`HomeStorage`](coven_foundation::config::HomeStorage): an
6/// opaque home is `Hashed` + encrypted, a browsable home is `Plain` + plaintext.
7#[derive(Clone, Copy)]
8pub enum BlobPathScheme {
9    /// Content-addressed shard `{namespace}/{ab}/{cd}/{id}` (an opaque home).
10    Hashed,
11    /// The consumer's own readable path, verbatim: `{namespace}/{cloud_path}`
12    /// (a browsable home). The consumer must supply `cloud_path` on every blob;
13    /// coven errors otherwise.
14    Plain,
15}
16
17impl BlobPathScheme {
18    /// The blob-path scheme a home's storage mode selects: an opaque home
19    /// obfuscates (`Hashed`), a browsable home is readable (`Plain`).
20    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/// The two numbers that decide what a blob transfer costs. They are independent
30/// on purpose: the chunk is fixed when a blob is sealed and bounds how little a
31/// read can fetch, so it sets how long a seek waits for its first byte; the
32/// window is a live reader-side choice about how much one request carries, so it
33/// sets how many round-trips a long read costs. Neither can be derived from the
34/// other, and changing the window never touches a stored blob.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct BlobChunking {
37    chunk: std::num::NonZeroU32,
38    window: std::num::NonZeroU64,
39}
40
41impl BlobChunking {
42    /// 64 KiB chunks read one mebibyte of stored bytes at a time.
43    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
65/// Serves plaintext ranges of one stored blob by fetching only the sealed chunks
66/// that cover them. A read costs the chunks it touches and nothing else — never
67/// the whole object, however many ranges the stream asks for.
68///
69/// Opening a sealed blob reads its `[key tag][header]` prefix once, which is
70/// what names the key and the chunk size; every later range is arithmetic over
71/// that header plus one ranged request per
72/// [window](BlobChunking::window)-worth of chunks. A chunk that opens is
73/// authentic — its tag covers its bytes, its index, and the header — so there is
74/// nothing else to check and no whole-object pass to amortize.
75pub 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    /// The blob's whole plaintext length, as its row declares it.
101    pub fn plaintext_size(&self) -> u64 {
102        self.plaintext_size
103    }
104
105    /// Read exactly `len` plaintext bytes at `offset`. A range past the blob's
106    /// end is an error, never a short read.
107    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    /// One ranged request against the stored object.
152    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        // A provider that ignored the range and answered with more (or less)
159        // than was asked for has not served this range; splicing its answer
160        // would silently read the wrong bytes.
161        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
180/// Opens one already exact-verified stored blob and withholds EOF until the
181/// complete plaintext size and hash match the signed locator.
182pub(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            // A sealed blob is verified by opening it: every chunk's tag covers
254            // its bytes, its index, and the header that frames them, so nothing
255            // the provider can serve opens as this blob's plaintext. A browsable
256            // home stores the plaintext in the clear and has no tags, so there
257            // the row's content hash is the only thing that can refuse the
258            // provider's bytes — the two homes verify by different means, not by
259            // one mechanism plus a spare.
260            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
302/// Split a stored sealed blob into the three things its bytes declare: the key
303/// fingerprint naming what sealed it, the header framing its chunks, and the
304/// sealed chunks themselves.
305///
306/// The layout is `[CKF1][fingerprint: 32][version: 1][chunk_size: 4][plaintext_len: 8][chunks…]`.
307/// Everything before the chunks is cleartext — a reader must know the key and the
308/// chunk size before it can open anything — and all of it is bound into every
309/// chunk's AAD, so a rewritten prefix fails the first open rather than re-framing
310/// the object.
311pub(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/// Open a blob this layer sealed: split the prefix, then open every chunk under
331/// `encryption` with the AAD context the seal was bound to. Returns the
332/// fingerprint of the key that sealed it alongside the plaintext.
333#[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
352/// Resolve a sealed blob's `[key tag][header]` prefix into the key that sealed
353/// it and the layout it declares. The fingerprint must be the one the row's
354/// locator names — a blob sealed under any other key is not this row's blob,
355/// whatever it decrypts to.
356pub(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
404/// Check a stored blob's length against what its own framing implies. The row
405/// pins the stored object's exact size, so a length the framing cannot produce
406/// means the object is not the one the row names.
407pub(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        // Present only for a browsable home, where the content hash is what
482        // refuses the provider's bytes; a sealed blob is refused by its tags.
483        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}