Skip to main content

coven_storage/remote/
cipher.rs

1use super::*;
2
3#[derive(Clone, Debug, PartialEq, Eq)]
4pub struct CloudKeyringMerge {
5    live_key_count: usize,
6    merged_key_count: usize,
7    merged_generation: u64,
8}
9
10impl CloudKeyringMerge {
11    pub fn live_key_count(&self) -> usize {
12        self.live_key_count
13    }
14
15    pub fn merged_key_count(&self) -> usize {
16        self.merged_key_count
17    }
18
19    pub fn merged_generation(&self) -> u64 {
20        self.merged_generation
21    }
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct AdoptedCloudKeyRotation {
26    fingerprint: String,
27    generation: u64,
28}
29
30#[cfg(any(test, feature = "test-utils"))]
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct CloudKeyringFacts {
33    entries: Vec<(u64, [u8; 32])>,
34    seal_key: [u8; 32],
35    current_generation: u64,
36}
37
38#[cfg(any(test, feature = "test-utils"))]
39impl CloudKeyringFacts {
40    pub(super) fn from_encryption(encryption: &EncryptionService) -> Self {
41        Self {
42            entries: encryption.keyring_entries(),
43            seal_key: encryption.key_bytes(),
44            current_generation: encryption.current_generation(),
45        }
46    }
47
48    pub fn entries(&self) -> &[(u64, [u8; 32])] {
49        &self.entries
50    }
51
52    pub fn seal_key(&self) -> [u8; 32] {
53        self.seal_key
54    }
55
56    pub fn current_generation(&self) -> u64 {
57        self.current_generation
58    }
59}
60
61impl AdoptedCloudKeyRotation {
62    pub fn fingerprint(&self) -> &str {
63        &self.fingerprint
64    }
65
66    pub fn generation(&self) -> u64 {
67        self.generation
68    }
69}
70
71/// Closed access to one session's live at-rest keyring. Callers can use the
72/// cipher but cannot take the retained key service out of its owner.
73pub trait CloudSyncCipherStateAccess: Send + Sync {
74    fn is_plaintext(&self) -> bool;
75    fn suffix(&self) -> &'static str;
76    fn current_generation(&self) -> Option<u64>;
77    fn current_fingerprint(&self) -> Option<String>;
78    fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError>;
79    fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8>;
80    #[cfg(any(test, feature = "test-utils"))]
81    fn open_sealed_blob_for_test(
82        &self,
83        stored: &[u8],
84        aad_context: &[u8],
85    ) -> Result<
86        (coven_keys::encryption::KeyFingerprint, Vec<u8>),
87        coven_keys::encryption::EncryptionError,
88    >;
89    fn merged_keyring(
90        &self,
91        new_encryption: &EncryptionService,
92    ) -> Result<CloudKeyringMerge, EncryptionError>;
93    fn merge_key_rotation(
94        &self,
95        new_encryption: &EncryptionService,
96        custody: &dyn coven_keys::keys::MasterKeyCustody,
97    ) -> Result<Option<String>, coven_keys::keys::KeyError>;
98
99    fn adopt_key_rotation(
100        &self,
101        new_encryption: &EncryptionService,
102        custody: &dyn coven_keys::keys::MasterKeyCustody,
103    ) -> Result<AdoptedCloudKeyRotation, coven_keys::keys::KeyError> {
104        let fingerprint = match self.merge_key_rotation(new_encryption, custody)? {
105            Some(fingerprint) => fingerprint,
106            None => self
107                .merged_keyring(new_encryption)
108                .map_err(coven_keys::keys::KeyError::Encryption)
109                .and_then(|status| {
110                    if status.live_key_count() != status.merged_key_count() {
111                        return Err(coven_keys::keys::KeyError::UnretainedKeyRotation);
112                    }
113                    self.current_fingerprint()
114                        .ok_or_else(|| coven_keys::keys::KeyError::PlaintextCloudKeyRotation)
115                })?,
116        };
117        let generation = self
118            .current_generation()
119            .ok_or_else(|| coven_keys::keys::KeyError::PlaintextCloudKeyRotation)?;
120        Ok(AdoptedCloudKeyRotation {
121            fingerprint,
122            generation,
123        })
124    }
125}
126
127/// Adopt `new_encryption`'s generations into the live keyring, or report that
128/// it held them all already.
129///
130/// Custody is written before the live keyring is replaced: a generation this
131/// process starts sealing under must never be one custody has not stored, or a
132/// restart would leave objects nothing can open. `Ok(None)` means nothing was
133/// adopted, so nothing was written either.
134fn merge_into(
135    live: &mut EncryptionService,
136    new_encryption: &EncryptionService,
137    custody: &dyn coven_keys::keys::MasterKeyCustody,
138) -> Result<Option<String>, coven_keys::keys::KeyError> {
139    let merged = live
140        .merged_with(new_encryption)
141        .map_err(coven_keys::keys::KeyError::Encryption)?;
142    if merged.key_count() == live.key_count() {
143        return Ok(None);
144    }
145    custody.persist(&coven_keys::encryption::MasterKeyring::from(merged.clone()))?;
146    *live = merged;
147    Ok(Some(live.fingerprint()))
148}
149
150impl CloudSyncCipherStateAccess for RwLock<CloudCipher> {
151    fn is_plaintext(&self) -> bool {
152        self.read().unwrap().is_plaintext()
153    }
154
155    fn suffix(&self) -> &'static str {
156        self.read().unwrap().suffix()
157    }
158
159    fn current_generation(&self) -> Option<u64> {
160        match &*self.read().unwrap() {
161            CloudCipher::Encrypted(encryption) => Some(encryption.current_generation()),
162            CloudCipher::Plaintext => None,
163        }
164    }
165
166    fn current_fingerprint(&self) -> Option<String> {
167        match &*self.read().unwrap() {
168            CloudCipher::Encrypted(encryption) => Some(encryption.fingerprint()),
169            CloudCipher::Plaintext => None,
170        }
171    }
172
173    fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError> {
174        self.read().unwrap().open(stored, aad_context)
175    }
176
177    fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
178        self.read().unwrap().seal(plaintext, aad_context)
179    }
180
181    #[cfg(any(test, feature = "test-utils"))]
182    fn open_sealed_blob_for_test(
183        &self,
184        stored: &[u8],
185        aad_context: &[u8],
186    ) -> Result<
187        (coven_keys::encryption::KeyFingerprint, Vec<u8>),
188        coven_keys::encryption::EncryptionError,
189    > {
190        let cipher = self.read().unwrap();
191        let CloudCipher::Encrypted(encryption) = &*cipher else {
192            return Err(coven_keys::encryption::EncryptionError::PlaintextKeyring);
193        };
194        super::blob_io::open_sealed_blob(stored, encryption, aad_context)
195    }
196
197    fn merged_keyring(
198        &self,
199        new_encryption: &EncryptionService,
200    ) -> Result<CloudKeyringMerge, EncryptionError> {
201        let cipher = self.read().unwrap();
202        let CloudCipher::Encrypted(live) = &*cipher else {
203            return Err(EncryptionError::PlaintextKeyring);
204        };
205        let merged = live.merged_with(new_encryption)?;
206        Ok(CloudKeyringMerge {
207            live_key_count: live.key_count(),
208            merged_key_count: merged.key_count(),
209            merged_generation: merged.current_generation(),
210        })
211    }
212
213    fn merge_key_rotation(
214        &self,
215        new_encryption: &EncryptionService,
216        custody: &dyn coven_keys::keys::MasterKeyCustody,
217    ) -> Result<Option<String>, coven_keys::keys::KeyError> {
218        let mut cipher = self.write().unwrap();
219        let CloudCipher::Encrypted(live) = &mut *cipher else {
220            return Err(coven_keys::keys::KeyError::PlaintextCloudKeyRotation);
221        };
222        merge_into(live, new_encryption, custody)
223    }
224}
225
226impl CloudCipher {
227    pub(super) fn current_generation(&self) -> Option<u64> {
228        match self {
229            CloudCipher::Encrypted(encryption) => Some(encryption.current_generation()),
230            CloudCipher::Plaintext => None,
231        }
232    }
233
234    /// The at-rest cipher a home's storage mode selects: an opaque home seals
235    /// under its store key (`Encrypted`), a browsable home stores in the clear
236    /// (`Plaintext`). The sibling of [`BlobPathScheme::for_storage`] — together
237    /// they map a [`HomeStorage`](coven_foundation::config::HomeStorage) to its
238    /// (path scheme, at-rest cipher) pair.
239    ///
240    /// `encryption` is the store master service; it is required for (and only
241    /// consulted on) an opaque home. `None` is returned only for an opaque home
242    /// with no service (a locked store) — a browsable home is always
243    /// `Plaintext` regardless. A host streaming a Remote blob opens a
244    /// [`BlobRangeReader`] under this cipher, so a read applies the same
245    /// protection the upload sealed under.
246    pub fn for_storage(
247        storage: coven_foundation::config::HomeStorage,
248        encryption: Option<EncryptionService>,
249    ) -> Option<Self> {
250        if storage.is_opaque() {
251            encryption.map(CloudCipher::Encrypted)
252        } else {
253            Some(CloudCipher::Plaintext)
254        }
255    }
256
257    /// Protect a whole payload assigned to this cipher. Encryption prefixes the
258    /// selected key's fingerprint in cleartext; plaintext returns the bytes
259    /// unchanged. The caller supplies the protection domain's context.
260    pub fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
261        // Master scope uses this cipher's key directly; blob scopes can instead
262        // derive a scoped key from it.
263        self.seal_scoped(
264            coven_protocol::blob::BlobScope::Master,
265            plaintext,
266            aad_context,
267        )
268    }
269
270    /// Recover a whole payload read from storage. Inverse of [`Self::seal`].
271    pub fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError> {
272        self.open_scoped(coven_protocol::blob::BlobScope::Master, stored, aad_context)
273    }
274
275    /// Protect a blob under its scope. Encrypted blobs carry the selected
276    /// audience key's fingerprint in cleartext; opening uses that key and
277    /// derives the same scoped key.
278    pub fn seal_scoped(
279        &self,
280        scope: coven_protocol::blob::BlobScope,
281        plaintext: Vec<u8>,
282        aad_context: &[u8],
283    ) -> Vec<u8> {
284        match self {
285            CloudCipher::Encrypted(master) => {
286                ScopedBlobSealing::new(scope, master).seal(plaintext, aad_context)
287            }
288            CloudCipher::Plaintext => plaintext,
289        }
290    }
291
292    /// Recover a blob under its resolved scope. Inverse of [`Self::seal_scoped`].
293    pub fn open_scoped(
294        &self,
295        scope: coven_protocol::blob::BlobScope,
296        stored: Vec<u8>,
297        aad_context: &[u8],
298    ) -> Result<Vec<u8>, EncryptionError> {
299        match self {
300            CloudCipher::Encrypted(e) => open_scoped_encrypted(scope, e, &stored, aad_context),
301            CloudCipher::Plaintext => Ok(stored),
302        }
303    }
304
305    /// A cipher-dependent suffix for auxiliary object names: `.enc` when
306    /// encrypted, empty when plaintext. Exact protocol slots and blob locators
307    /// determine their own paths without this suffix.
308    pub fn suffix(&self) -> &'static str {
309        match self {
310            CloudCipher::Encrypted(_) => ".enc",
311            CloudCipher::Plaintext => "",
312        }
313    }
314
315    /// Whether this is a plaintext (unencrypted) home.
316    pub fn is_plaintext(&self) -> bool {
317        matches!(self, CloudCipher::Plaintext)
318    }
319
320    /// The final object length for a blob framed by `header` under this cipher:
321    /// the key tag plus the sealed body for an encrypted home, the plaintext
322    /// length verbatim for a browsable one. Known before a byte is sealed, so a
323    /// streaming upload can declare its length up front.
324    pub fn body_len(&self, header: SealedBlobHeader) -> u64 {
325        match self {
326            CloudCipher::Encrypted(_) => KeyTag::LEN as u64 + header.sealed_len(),
327            CloudCipher::Plaintext => header.plaintext_len(),
328        }
329    }
330
331    /// Open a streaming [`BlobBody`] over the local plaintext file at `file_path`,
332    /// sealing each chunk under `scope`'s key for an encrypted home or passing the
333    /// plaintext through for a browsable one — without ever reading or sealing the
334    /// whole blob into memory. The streaming sibling of [`seal_scoped`](Self::seal_scoped),
335    /// used by the upload drain.
336    pub async fn open_body(
337        &self,
338        scope: coven_protocol::blob::BlobScope,
339        file_path: &std::path::Path,
340        aad_context: &[u8],
341        chunk_size: std::num::NonZeroU32,
342    ) -> Result<BlobBody, coven_foundation::atomic_file::FileError> {
343        let plaintext_len = coven_foundation::local_file::file_len(file_path).await?;
344        let header = SealedBlobHeader::new(
345            chunk_size,
346            plaintext_len,
347            &NoncePolicy::DerivedFromContext {
348                context: aad_context.to_vec(),
349            },
350        );
351        let reader = crate::local_file::open_reader(file_path).await?;
352        Ok(match self {
353            CloudCipher::Encrypted(encryption) => {
354                ScopedBlobSealing::new(scope, encryption).into_body(header, reader, aad_context)
355            }
356            CloudCipher::Plaintext => {
357                BlobBody::from_file_with_prefix(self.body_len(header), reader, None, Vec::new())
358            }
359        })
360    }
361
362    /// Open a streaming body whose plaintext reader verifies the exact row
363    /// size/hash while it is consumed and reports each source-buffer advance.
364    /// This avoids a separate plaintext hashing pass before sealing.
365    pub async fn open_exact_body(
366        &self,
367        scope: coven_protocol::blob::BlobScope,
368        file_path: &std::path::Path,
369        aad_context: &[u8],
370        chunk_size: std::num::NonZeroU32,
371        expected_size: u64,
372        expected_hash: coven_protocol::store_commit::ObjectHash,
373        progress: crate::cloud::PreparationProgress,
374    ) -> Result<BlobBody, coven_foundation::atomic_file::FileError> {
375        let plaintext_len = coven_foundation::local_file::file_len(file_path).await?;
376        if plaintext_len != expected_size {
377            return Err(coven_foundation::atomic_file::FileError::Path {
378                operation: "validate local blob source size",
379                path: file_path.to_path_buf(),
380                source: std::io::Error::new(
381                    std::io::ErrorKind::InvalidData,
382                    format!("declares {expected_size} bytes but the source has {plaintext_len}"),
383                ),
384            });
385        }
386        let header = SealedBlobHeader::new(
387            chunk_size,
388            plaintext_len,
389            &NoncePolicy::DerivedFromContext {
390                context: aad_context.to_vec(),
391            },
392        );
393        let reader =
394            crate::local_file::open_exact_reader(file_path, expected_size, expected_hash, progress)
395                .await?;
396        Ok(match self {
397            CloudCipher::Encrypted(encryption) => {
398                ScopedBlobSealing::new(scope, encryption).into_body(header, reader, aad_context)
399            }
400            CloudCipher::Plaintext => {
401                BlobBody::from_file_with_prefix(self.body_len(header), reader, None, Vec::new())
402            }
403        })
404    }
405}
406
407/// The `EncryptionService` a blob's `scope` selects, against `master`: the
408/// store master itself, or a per-scope key derived from it. The blob storage
409/// methods and the outbox drain both turn a [`coven_protocol::blob::BlobScope`] into a
410/// key the same way, so they share this one mapping. Only an encrypted home has
411/// per-scope keys, so this is reached only from the [`CloudCipher::Encrypted`]
412/// branches.
413pub(crate) fn encryption_for_scope(
414    scope: coven_protocol::blob::BlobScope,
415    master: &EncryptionService,
416) -> EncryptionService {
417    match scope {
418        coven_protocol::blob::BlobScope::Master => master.clone(),
419        coven_protocol::blob::BlobScope::Derived(s) => master.derive_scoped(&s),
420    }
421}
422
423pub fn cloud_aad_context(store_id: &str, cloud_key: &str) -> Vec<u8> {
424    let mut context =
425        Vec::with_capacity(std::mem::size_of::<u64>() * 2 + store_id.len() + cloud_key.len());
426    context.extend_from_slice(&(store_id.len() as u64).to_le_bytes());
427    context.extend_from_slice(store_id.as_bytes());
428    context.extend_from_slice(&(cloud_key.len() as u64).to_le_bytes());
429    context.extend_from_slice(cloud_key.as_bytes());
430    context
431}
432
433pub(crate) fn protocol_object_aad_context(
434    context: &ProtocolObjectContext,
435    semantic_prefix: &str,
436) -> Vec<u8> {
437    let domain = context.domain().aad_label();
438    let mut aad = Vec::with_capacity(
439        context.store_root_hash().as_bytes().len()
440            + std::mem::size_of::<u64>() * 2
441            + domain.len()
442            + semantic_prefix.len(),
443    );
444    aad.extend_from_slice(context.store_root_hash().as_bytes());
445    aad.extend_from_slice(&(domain.len() as u64).to_le_bytes());
446    aad.extend_from_slice(domain);
447    aad.extend_from_slice(&(semantic_prefix.len() as u64).to_le_bytes());
448    aad.extend_from_slice(semantic_prefix.as_bytes());
449    aad
450}
451
452/// The key `scope` seals under plus the cleartext key-tag prefix every encrypted
453/// object carries (the master seal key's fingerprint, so a later read resolves
454/// the exact key to open with — for a derived scope it re-derives from that
455/// master key).
456pub(crate) struct ScopedBlobSealing {
457    encryption: EncryptionService,
458    key_tag: Vec<u8>,
459}
460
461impl ScopedBlobSealing {
462    fn new(scope: coven_protocol::blob::BlobScope, master: &EncryptionService) -> Self {
463        Self {
464            encryption: encryption_for_scope(scope, master),
465            key_tag: KeyTag::write(&master.seal_fingerprint()),
466        }
467    }
468
469    fn seal(self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
470        let mut stored = self.key_tag;
471        stored.extend(self.encryption.encrypt(&plaintext, aad_context));
472        stored
473    }
474
475    fn into_body(
476        self,
477        header: SealedBlobHeader,
478        reader: crate::local_file::PlaintextReader,
479        aad_context: &[u8],
480    ) -> BlobBody {
481        let mut prefix = self.key_tag;
482        prefix.extend_from_slice(&header.to_bytes());
483        BlobBody::from_file_with_prefix(
484            KeyTag::LEN as u64 + header.sealed_len(),
485            reader,
486            Some(
487                self.encryption
488                    .blob_sealer(
489                        header,
490                        &NoncePolicy::DerivedFromContext {
491                            context: aad_context.to_vec(),
492                        },
493                        aad_context,
494                    )
495                    .expect("a blob header records the derived policy it was built under"),
496            ),
497            prefix,
498        )
499    }
500}
501
502pub(crate) fn opening_encryption_for_scope(
503    scope: coven_protocol::blob::BlobScope,
504    master: &EncryptionService,
505    fingerprint: &[u8; 32],
506) -> Result<EncryptionService, EncryptionError> {
507    match scope {
508        coven_protocol::blob::BlobScope::Master => master.service_for_fingerprint(fingerprint),
509        coven_protocol::blob::BlobScope::Derived(scope_id) => {
510            master.derive_scoped_for_fingerprint(fingerprint, &scope_id)
511        }
512    }
513}
514
515pub(crate) fn open_scoped_encrypted(
516    scope: coven_protocol::blob::BlobScope,
517    master: &EncryptionService,
518    stored: &[u8],
519    aad_context: &[u8],
520) -> Result<Vec<u8>, EncryptionError> {
521    let (fingerprint, ciphertext) = KeyTag::read(stored)?;
522    opening_encryption_for_scope(scope, master, &fingerprint)?.decrypt(ciphertext, aad_context)
523}