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 an immutable Store object or mutable membership/key object for
258    /// storage. Encrypted homes seal under the current store-key generation and
259    /// prefix that generation in cleartext; plaintext homes return the bytes
260    /// unchanged.
261    pub fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
262        // A control object is always whole-home scoped; only blobs carry a scope.
263        // This is exactly the master-scoped blob path: `encryption_for_scope`
264        // maps `Master` to the store key itself.
265        self.seal_scoped(
266            coven_protocol::blob::BlobScope::Master,
267            plaintext,
268            aad_context,
269        )
270    }
271
272    /// Recover a control object read from storage. Inverse of [`Self::seal`].
273    pub fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError> {
274        self.open_scoped(coven_protocol::blob::BlobScope::Master, stored, aad_context)
275    }
276
277    /// Protect a blob under its scope. Encrypted blobs carry the current
278    /// store-key generation in cleartext, so a later read knows which
279    /// generation to open with.
280    pub fn seal_scoped(
281        &self,
282        scope: coven_protocol::blob::BlobScope,
283        plaintext: Vec<u8>,
284        aad_context: &[u8],
285    ) -> Vec<u8> {
286        match self {
287            CloudCipher::Encrypted(master) => {
288                ScopedBlobSealing::new(scope, master).seal(plaintext, aad_context)
289            }
290            CloudCipher::Plaintext => plaintext,
291        }
292    }
293
294    /// Recover a blob under its resolved scope. Inverse of [`Self::seal_scoped`].
295    pub fn open_scoped(
296        &self,
297        scope: coven_protocol::blob::BlobScope,
298        stored: Vec<u8>,
299        aad_context: &[u8],
300    ) -> Result<Vec<u8>, EncryptionError> {
301        match self {
302            CloudCipher::Encrypted(e) => open_scoped_encrypted(scope, e, &stored, aad_context),
303            CloudCipher::Plaintext => Ok(stored),
304        }
305    }
306
307    /// The object-key suffix this cipher implies: `.enc` for an encrypted home,
308    /// empty for a plaintext one. Note `"x".strip_suffix("")` returns `Some("x")`,
309    /// so the listing parsers strip an empty suffix as a clean no-op.
310    pub fn suffix(&self) -> &'static str {
311        match self {
312            CloudCipher::Encrypted(_) => ".enc",
313            CloudCipher::Plaintext => "",
314        }
315    }
316
317    /// Whether this is a plaintext (unencrypted) home.
318    pub fn is_plaintext(&self) -> bool {
319        matches!(self, CloudCipher::Plaintext)
320    }
321
322    /// The final object length for a blob framed by `header` under this cipher:
323    /// the key tag plus the sealed body for an encrypted home, the plaintext
324    /// length verbatim for a browsable one. Known before a byte is sealed, so a
325    /// streaming upload can declare its length up front.
326    pub fn body_len(&self, header: SealedBlobHeader) -> u64 {
327        match self {
328            CloudCipher::Encrypted(_) => KeyTag::LEN as u64 + header.sealed_len(),
329            CloudCipher::Plaintext => header.plaintext_len(),
330        }
331    }
332
333    /// Open a streaming [`BlobBody`] over the local plaintext file at `file_path`,
334    /// sealing each chunk under `scope`'s key for an encrypted home or passing the
335    /// plaintext through for a browsable one — without ever reading or sealing the
336    /// whole blob into memory. The streaming sibling of [`seal_scoped`](Self::seal_scoped),
337    /// used by the upload drain.
338    pub async fn open_body(
339        &self,
340        scope: coven_protocol::blob::BlobScope,
341        file_path: &std::path::Path,
342        aad_context: &[u8],
343        chunk_size: std::num::NonZeroU32,
344    ) -> Result<BlobBody, coven_foundation::atomic_file::FileError> {
345        let plaintext_len = coven_foundation::local_file::file_len(file_path).await?;
346        let header = SealedBlobHeader::new(
347            chunk_size,
348            plaintext_len,
349            &NoncePolicy::DerivedFromContext {
350                context: aad_context.to_vec(),
351            },
352        );
353        let reader = crate::local_file::open_reader(file_path).await?;
354        Ok(match self {
355            CloudCipher::Encrypted(encryption) => {
356                ScopedBlobSealing::new(scope, encryption).into_body(header, reader, aad_context)
357            }
358            CloudCipher::Plaintext => {
359                BlobBody::from_file_with_prefix(self.body_len(header), reader, None, Vec::new())
360            }
361        })
362    }
363
364    /// Open a streaming body whose plaintext reader verifies the exact row
365    /// size/hash while it is consumed and reports each source-buffer advance.
366    /// This avoids a separate plaintext hashing pass before sealing.
367    pub async fn open_exact_body(
368        &self,
369        scope: coven_protocol::blob::BlobScope,
370        file_path: &std::path::Path,
371        aad_context: &[u8],
372        chunk_size: std::num::NonZeroU32,
373        expected_size: u64,
374        expected_hash: coven_protocol::store_commit::ObjectHash,
375        progress: crate::cloud::PreparationProgress,
376    ) -> Result<BlobBody, coven_foundation::atomic_file::FileError> {
377        let plaintext_len = coven_foundation::local_file::file_len(file_path).await?;
378        if plaintext_len != expected_size {
379            return Err(coven_foundation::atomic_file::FileError::Path {
380                operation: "validate local blob source size",
381                path: file_path.to_path_buf(),
382                source: std::io::Error::new(
383                    std::io::ErrorKind::InvalidData,
384                    format!("declares {expected_size} bytes but the source has {plaintext_len}"),
385                ),
386            });
387        }
388        let header = SealedBlobHeader::new(
389            chunk_size,
390            plaintext_len,
391            &NoncePolicy::DerivedFromContext {
392                context: aad_context.to_vec(),
393            },
394        );
395        let reader =
396            crate::local_file::open_exact_reader(file_path, expected_size, expected_hash, progress)
397                .await?;
398        Ok(match self {
399            CloudCipher::Encrypted(encryption) => {
400                ScopedBlobSealing::new(scope, encryption).into_body(header, reader, aad_context)
401            }
402            CloudCipher::Plaintext => {
403                BlobBody::from_file_with_prefix(self.body_len(header), reader, None, Vec::new())
404            }
405        })
406    }
407}
408
409/// The `EncryptionService` a blob's `scope` selects, against `master`: the
410/// store master itself, or a per-scope key derived from it. The blob storage
411/// methods and the outbox drain both turn a [`coven_protocol::blob::BlobScope`] into a
412/// key the same way, so they share this one mapping. Only an encrypted home has
413/// per-scope keys, so this is reached only from the [`CloudCipher::Encrypted`]
414/// branches.
415pub(crate) fn encryption_for_scope(
416    scope: coven_protocol::blob::BlobScope,
417    master: &EncryptionService,
418) -> EncryptionService {
419    match scope {
420        coven_protocol::blob::BlobScope::Master => master.clone(),
421        coven_protocol::blob::BlobScope::Derived(s) => master.derive_scoped(&s),
422    }
423}
424
425pub fn cloud_aad_context(store_id: &str, cloud_key: &str) -> Vec<u8> {
426    let mut context =
427        Vec::with_capacity(std::mem::size_of::<u64>() * 2 + store_id.len() + cloud_key.len());
428    context.extend_from_slice(&(store_id.len() as u64).to_le_bytes());
429    context.extend_from_slice(store_id.as_bytes());
430    context.extend_from_slice(&(cloud_key.len() as u64).to_le_bytes());
431    context.extend_from_slice(cloud_key.as_bytes());
432    context
433}
434
435pub(crate) fn protocol_object_aad_context(
436    context: &ProtocolObjectContext,
437    semantic_prefix: &str,
438) -> Vec<u8> {
439    let domain = context.domain().aad_label();
440    let mut aad = Vec::with_capacity(
441        context.store_root_hash().as_bytes().len()
442            + std::mem::size_of::<u64>() * 2
443            + domain.len()
444            + semantic_prefix.len(),
445    );
446    aad.extend_from_slice(context.store_root_hash().as_bytes());
447    aad.extend_from_slice(&(domain.len() as u64).to_le_bytes());
448    aad.extend_from_slice(domain);
449    aad.extend_from_slice(&(semantic_prefix.len() as u64).to_le_bytes());
450    aad.extend_from_slice(semantic_prefix.as_bytes());
451    aad
452}
453
454/// The key `scope` seals under plus the cleartext key-tag prefix every encrypted
455/// object carries (the master seal key's fingerprint, so a later read resolves
456/// the exact key to open with — for a derived scope it re-derives from that
457/// master key).
458pub(crate) struct ScopedBlobSealing {
459    encryption: EncryptionService,
460    key_tag: Vec<u8>,
461}
462
463impl ScopedBlobSealing {
464    fn new(scope: coven_protocol::blob::BlobScope, master: &EncryptionService) -> Self {
465        Self {
466            encryption: encryption_for_scope(scope, master),
467            key_tag: KeyTag::write(&master.seal_fingerprint()),
468        }
469    }
470
471    fn seal(self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
472        let mut stored = self.key_tag;
473        stored.extend(self.encryption.encrypt(&plaintext, aad_context));
474        stored
475    }
476
477    fn into_body(
478        self,
479        header: SealedBlobHeader,
480        reader: crate::local_file::PlaintextReader,
481        aad_context: &[u8],
482    ) -> BlobBody {
483        let mut prefix = self.key_tag;
484        prefix.extend_from_slice(&header.to_bytes());
485        BlobBody::from_file_with_prefix(
486            KeyTag::LEN as u64 + header.sealed_len(),
487            reader,
488            Some(
489                self.encryption
490                    .blob_sealer(
491                        header,
492                        &NoncePolicy::DerivedFromContext {
493                            context: aad_context.to_vec(),
494                        },
495                        aad_context,
496                    )
497                    .expect("a blob header records the derived policy it was built under"),
498            ),
499            prefix,
500        )
501    }
502}
503
504pub(crate) fn opening_encryption_for_scope(
505    scope: coven_protocol::blob::BlobScope,
506    master: &EncryptionService,
507    fingerprint: &[u8; 32],
508) -> Result<EncryptionService, EncryptionError> {
509    match scope {
510        coven_protocol::blob::BlobScope::Master => master.service_for_fingerprint(fingerprint),
511        coven_protocol::blob::BlobScope::Derived(scope_id) => {
512            master.derive_scoped_for_fingerprint(fingerprint, &scope_id)
513        }
514    }
515}
516
517pub(crate) fn open_scoped_encrypted(
518    scope: coven_protocol::blob::BlobScope,
519    master: &EncryptionService,
520    stored: &[u8],
521    aad_context: &[u8],
522) -> Result<Vec<u8>, EncryptionError> {
523    let (fingerprint, ciphertext) = KeyTag::read(stored)?;
524    opening_encryption_for_scope(scope, master, &fingerprint)?.decrypt(ciphertext, aad_context)
525}