1use async_trait::async_trait;
12use std::path::Path;
13use std::sync::{Arc, RwLock};
14
15use super::provider_probe::ProviderProbeStorage;
16use super::CloudSyncObjectStorage;
17use crate::cloud::{BlobBody, CloudFileReadError, CloudHomeError, ExactCloudHome};
18use coven_keys::encryption::{
19 EncryptionError, EncryptionService, KeyTag, NoncePolicy, SealedBlobHeader,
20 SEALED_BLOB_HEADER_LEN,
21};
22use coven_keys::keys::UserKeypair;
23use coven_protocol::objects::ObjectSlot;
24#[cfg(test)]
25use coven_protocol::objects::ProtocolObjectDomain;
26use coven_protocol::objects::{
27 ExactObjectRef, PreparedExactObject, ProtocolObjectContext, ProtocolObjectProtection,
28 ResolvedProviderBinding, RotationGate, RotationPending, StorageError,
29};
30use coven_protocol::store_commit::ObjectHash;
31
32mod blob_io;
33mod cipher;
34mod rotation;
35mod storage_impl;
36
37#[cfg(any(test, feature = "test-utils"))]
38pub use blob_io::open_sealed_blob;
39pub use blob_io::BlobChunking;
40pub use blob_io::{BlobPathScheme, BlobRangeReader};
41#[cfg(any(test, feature = "test-utils"))]
42pub use cipher::CloudKeyringFacts;
43pub use cipher::{
44 cloud_aad_context, AdoptedCloudKeyRotation, CloudKeyringMerge, CloudSyncCipherStateAccess,
45};
46pub use rotation::{CloudSyncRotationStateAccess, PendingRotation, RotationStateError};
47
48#[derive(Clone)]
52pub enum CloudCipher {
53 Encrypted(EncryptionService),
54 Plaintext,
55}
56
57pub struct CloudSyncConnection {
60 home: Arc<dyn ExactCloudHome>,
62 provider_probes: ProviderProbeStorage,
63 cipher: Arc<RwLock<CloudCipher>>,
64 pending_rotation: Arc<PendingRotation>,
69 blob_paths: BlobPathScheme,
72 blob_chunking: BlobChunking,
74 store_id: String,
75 keypair: UserKeypair,
80}
81
82fn map_cloud_file_read_error(error: CloudFileReadError) -> StorageError {
83 match error {
84 CloudFileReadError::Source(error) => StorageError::from(error),
85 CloudFileReadError::SourceCleanup { source, cleanup } => StorageError::CleanupFailed {
86 operation: Box::new(StorageError::from(source)),
87 cleanup: Box::new(StorageError::LocalFilesystem(cleanup)),
88 },
89 CloudFileReadError::Local(error) => StorageError::LocalFilesystem(error),
90 }
91}
92
93impl CloudSyncConnection {
94 pub fn new(
95 home: Arc<dyn ExactCloudHome>,
96 cipher: CloudCipher,
97 blob_paths: BlobPathScheme,
98 store_id: impl Into<String>,
99 keypair: UserKeypair,
100 ) -> Self {
101 let provider_probes = ProviderProbeStorage::new(home.clone());
102 CloudSyncConnection {
103 home,
104 provider_probes,
105 cipher: Arc::new(RwLock::new(cipher)),
106 pending_rotation: Arc::new(PendingRotation::none()),
107 blob_paths,
108 blob_chunking: BlobChunking::DEFAULT,
109 store_id: store_id.into(),
110 keypair,
111 }
112 }
113
114 pub fn provider_requests(
119 &self,
120 ) -> Option<Arc<dyn coven_foundation::stage_timing::ProviderRequests>> {
121 self.home.provider_requests()
122 }
123
124 pub fn with_blob_chunking(mut self, chunking: BlobChunking) -> Self {
129 self.blob_chunking = chunking;
130 self
131 }
132
133 pub fn blob_path_scheme(&self) -> BlobPathScheme {
134 self.blob_paths
135 }
136
137 pub fn store_id(&self) -> &str {
138 &self.store_id
139 }
140
141 pub async fn probe(&self) -> Result<(), CloudHomeError> {
142 self.home.probe().await
143 }
144
145 fn validate_blob_locator_home(
146 &self,
147 locator: &coven_protocol::blob::locator::BlobLocator,
148 ) -> Result<(), StorageError> {
149 let valid = matches!(
150 (locator, self.blob_paths, self.cipher.is_plaintext()),
151 (
152 coven_protocol::blob::locator::BlobLocator::Opaque { .. },
153 BlobPathScheme::Hashed,
154 false
155 ) | (
156 coven_protocol::blob::locator::BlobLocator::Browsable { .. },
157 BlobPathScheme::Plain,
158 true
159 )
160 );
161 if !valid {
162 return Err(StorageError::InvalidContent(
163 "blob locator protection does not match the cloud home's fixed storage mode"
164 .to_string(),
165 ));
166 }
167 Ok(())
168 }
169
170 async fn validate_blob_append_authority(
171 &self,
172 locator: &coven_protocol::blob::locator::BlobLocator,
173 authority: &coven_protocol::objects::BlobWriteAuthority<'_>,
174 ) -> Result<(), StorageError> {
175 authority
176 .reference
177 .verify_registration(authority.registration)?;
178 if locator.uploader() != authority.reference {
179 return Err(StorageError::InvalidContent(format!(
180 "blob locator uploader {:?} differs from its exact write authority",
181 locator.uploader()
182 )));
183 }
184 if authority.registration.author_pubkey != hex::encode(self.keypair.public_key()) {
185 return Err(StorageError::InvalidContent(
186 "blob write authority is not this device's identity key".to_string(),
187 ));
188 }
189 let live = self
190 .home
191 .provider_binding()
192 .await
193 .map_err(StorageError::from)?;
194 if live.device != authority.registration.provider {
195 return Err(StorageError::InvalidContent(
196 "blob write authority differs from the authenticated provider principal"
197 .to_string(),
198 ));
199 }
200 Ok(())
201 }
202
203 pub fn uses_identity(&self, identity: &UserKeypair) -> bool {
204 self.keypair.public_key() == identity.public_key()
205 }
206
207 #[cfg(any(test, feature = "test-utils"))]
208 pub fn connection_for_test_identity(&self, identity: UserKeypair) -> Self {
209 Self::new(
210 self.home.clone(),
211 self.cipher.read().unwrap().clone(),
212 self.blob_paths,
213 self.store_id.clone(),
214 identity,
215 )
216 .with_blob_chunking(self.blob_chunking)
217 }
218
219 #[cfg(any(test, feature = "test-utils"))]
220 pub fn connection_for_test_identity_and_home(
221 &self,
222 identity: UserKeypair,
223 home: Arc<dyn ExactCloudHome>,
224 ) -> Self {
225 Self::new(
226 home,
227 self.cipher.read().unwrap().clone(),
228 self.blob_paths,
229 self.store_id.clone(),
230 identity,
231 )
232 .with_blob_chunking(self.blob_chunking)
233 }
234
235 pub fn is_plaintext(&self) -> bool {
236 self.cipher.read().unwrap().is_plaintext()
237 }
238
239 fn cipher_suffix(&self) -> &'static str {
240 self.cipher.read().unwrap().suffix()
241 }
242
243 fn open_stored_data(
244 &self,
245 stored: Vec<u8>,
246 aad_context: &[u8],
247 ) -> Result<Vec<u8>, EncryptionError> {
248 self.cipher.read().unwrap().open(stored, aad_context)
249 }
250
251 fn seal_stored_data(
252 &self,
253 plaintext: Vec<u8>,
254 aad_context: &[u8],
255 ) -> Result<Vec<u8>, StorageError> {
256 let cipher = self.cipher.read().unwrap();
257 self.pending_rotation.check(cipher.current_generation())?;
258 Ok(cipher.seal(plaintext, aad_context))
259 }
260
261 fn seal_protocol_data(
262 &self,
263 context: &ProtocolObjectContext,
264 plaintext: Vec<u8>,
265 aad_context: &[u8],
266 ) -> Result<Vec<u8>, StorageError> {
267 match context.protection() {
268 ProtocolObjectProtection::StoreEncrypted => {
269 self.seal_stored_data(plaintext, aad_context)
270 }
271 ProtocolObjectProtection::SignedPlaintext
272 | ProtocolObjectProtection::RecipientSealed => {
273 Ok(CloudCipher::Plaintext.seal(plaintext, aad_context))
274 }
275 ProtocolObjectProtection::Circle(encryption) => {
276 Ok(CloudCipher::Encrypted(encryption.clone()).seal(plaintext, aad_context))
277 }
278 }
279 }
280
281 async fn verify_and_open_protocol_data(
282 &self,
283 operation: &'static str,
284 context: &ProtocolObjectContext,
285 object: ExactObjectRef,
286 stored: Vec<u8>,
287 aad_context: Vec<u8>,
288 ) -> Result<Vec<u8>, StorageError> {
289 let cipher = match context.protection() {
290 ProtocolObjectProtection::StoreEncrypted => self.cipher.read().unwrap().clone(),
291 ProtocolObjectProtection::SignedPlaintext => CloudCipher::Plaintext,
292 ProtocolObjectProtection::Circle(encryption) => {
293 CloudCipher::Encrypted(encryption.clone())
294 }
295 ProtocolObjectProtection::RecipientSealed => CloudCipher::Plaintext,
296 };
297 run_storage_cpu(
298 operation,
299 Box::new(move || {
300 object.verify(&stored)?;
301 cipher
302 .open(stored, &aad_context)
303 .map_err(|source| StorageError::Decryption {
304 context: format!("protocol object {}", object.slot().logical_key()),
305 source,
306 })
307 }),
308 )
309 .await
310 }
311
312 async fn identify_and_open_protocol_data(
313 &self,
314 context: &ProtocolObjectContext,
315 slot: ObjectSlot,
316 stored: Vec<u8>,
317 aad_context: Vec<u8>,
318 ) -> Result<(Vec<u8>, PreparedExactObject), StorageError> {
319 let cipher = match context.protection() {
320 ProtocolObjectProtection::StoreEncrypted => self.cipher.read().unwrap().clone(),
321 ProtocolObjectProtection::SignedPlaintext => CloudCipher::Plaintext,
322 ProtocolObjectProtection::Circle(encryption) => {
323 CloudCipher::Encrypted(encryption.clone())
324 }
325 ProtocolObjectProtection::RecipientSealed => CloudCipher::Plaintext,
326 };
327 run_storage_cpu(
328 "identify and open protocol slot",
329 Box::new(move || {
330 let object = ExactObjectRef::new(
331 slot.clone(),
332 stored.len() as u64,
333 ObjectHash::digest(&stored),
334 );
335 let prepared = PreparedExactObject::new(object, stored.clone())?;
336 let opened = cipher.open(stored, &aad_context).map_err(|source| {
337 StorageError::Decryption {
338 context: format!("protocol object {}", slot.logical_key()),
339 source,
340 }
341 })?;
342 Ok((opened, prepared))
343 }),
344 )
345 .await
346 }
347
348 pub fn blob_key(
373 scheme: BlobPathScheme,
374 namespace: &str,
375 uploader: Option<&str>,
376 id: &str,
377 cloud_path: Option<&str>,
378 ) -> Result<String, StorageError> {
379 match scheme {
380 BlobPathScheme::Hashed => {
381 let uploader = uploader.ok_or_else(|| {
382 StorageError::Parse(format!(
383 "an opaque-home blob requires an uploader for {namespace}/{id}"
384 ))
385 })?;
386 Ok(coven_foundation::store_dir::StoreDir::uploader_hashed_key(
387 namespace, uploader, id,
388 )?)
389 }
390 BlobPathScheme::Plain => {
391 let path = cloud_path.ok_or_else(|| {
392 StorageError::Parse(format!(
393 "unobfuscated blob-path home requires a cloud_path for blob {namespace}/{id}"
394 ))
395 })?;
396 coven_foundation::store_dir::validate_path_token(namespace)?;
397 coven_foundation::store_dir::validate_cloud_path(path)?;
398 Ok(format!("{namespace}/{path}"))
399 }
400 }
401 }
402
403 #[cfg(test)]
404 async fn blob_write_registration(
405 &self,
406 label: &str,
407 ) -> coven_protocol::store_commit::ReferencedStoreDeviceRegistration {
408 use coven_protocol::store_commit::{
409 DeviceStreamAnchor, StoreCreationId, StoreDeviceRegistration,
410 StoreDeviceRegistrationOrigin, StoreDeviceRegistrationRef, StoreRootRef,
411 };
412
413 let root_bytes = format!("{label} Store root").into_bytes();
414 let root = StoreRootRef {
415 store_root_id: ObjectHash::digest(format!("{label} root id").as_bytes()),
416 store_root_hash: ObjectHash::digest(&root_bytes),
417 object: ExactObjectRef::new(
418 ObjectSlot::logical(format!("store-v1/store-protocol-root/{label}.json")).unwrap(),
419 root_bytes.len() as u64,
420 ObjectHash::digest(&root_bytes),
421 ),
422 };
423 let anchor_slot = |stream: &str| {
424 ObjectSlot::logical(format!(
425 "store-v1/test-device-streams/{label}/{stream}.json"
426 ))
427 .unwrap()
428 };
429 let provider = CloudSyncObjectStorage::provider_binding(self)
430 .await
431 .unwrap()
432 .device;
433 let registration = StoreDeviceRegistration::signed(
434 root,
435 StoreDeviceRegistrationOrigin::Founder {
436 creation_id: StoreCreationId::from_nonce(label),
437 },
438 provider,
439 DeviceStreamAnchor::StoreAnnouncements {
440 first_slot: anchor_slot("announcements"),
441 },
442 DeviceStreamAnchor::StoreAcknowledgements {
443 first_slot: anchor_slot("acknowledgements"),
444 },
445 DeviceStreamAnchor::StoreSnapshots {
446 first_slot: anchor_slot("snapshots"),
447 },
448 &self.keypair,
449 )
450 .unwrap();
451 let bytes = registration.to_bytes();
452 let reference = StoreDeviceRegistrationRef::from_registration(
453 ®istration,
454 ExactObjectRef::new(
455 ObjectSlot::logical(format!(
456 "store-v1/devices/{}/registration.json",
457 registration.device_id
458 ))
459 .unwrap(),
460 bytes.len() as u64,
461 ObjectHash::digest(&bytes),
462 ),
463 );
464 coven_protocol::store_commit::ReferencedStoreDeviceRegistration::verified(
465 reference,
466 registration,
467 )
468 .expect("construct test blob write registration")
469 }
470
471 #[cfg(any(test, feature = "test-utils"))]
472 pub fn keyring_facts_for_test(&self) -> Option<CloudKeyringFacts> {
473 match &*self.cipher.read().unwrap() {
474 CloudCipher::Encrypted(encryption) => {
475 Some(CloudKeyringFacts::from_encryption(encryption))
476 }
477 CloudCipher::Plaintext => None,
478 }
479 }
480
481 #[cfg(any(test, feature = "test-utils"))]
482 pub fn adopt_key_rotation_for_test(
483 &self,
484 encryption: &EncryptionService,
485 custody: &dyn coven_keys::keys::MasterKeyCustody,
486 ) -> Result<String, coven_keys::keys::KeyError> {
487 CloudSyncCipherStateAccess::adopt_key_rotation(self, encryption, custody)
488 .map(|adopted| adopted.fingerprint().to_string())
489 }
490
491 #[cfg(any(test, feature = "test-utils"))]
492 pub fn mark_rotation_committed_for_test(
493 &self,
494 generation: u64,
495 ) -> Result<(), RotationStateError> {
496 self.pending_rotation.mark_committed(generation)
497 }
498
499 #[cfg(any(test, feature = "test-utils"))]
500 pub fn pending_rotation_generation_for_test(&self) -> Option<u64> {
501 self.pending_rotation.pending_generation()
502 }
503
504 #[cfg(any(test, feature = "test-utils"))]
505 pub fn clear_rotation_gate_for_test(&self) {
506 self.pending_rotation.install_durable_gate(None);
507 }
508}
509
510impl CloudSyncCipherStateAccess for CloudSyncConnection {
511 fn is_plaintext(&self) -> bool {
512 self.cipher.is_plaintext()
513 }
514
515 fn suffix(&self) -> &'static str {
516 self.cipher.suffix()
517 }
518
519 fn current_generation(&self) -> Option<u64> {
520 self.cipher.current_generation()
521 }
522
523 fn current_fingerprint(&self) -> Option<String> {
524 self.cipher.current_fingerprint()
525 }
526
527 fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError> {
528 self.cipher.open(stored, aad_context)
529 }
530
531 fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
532 self.cipher.seal(plaintext, aad_context)
533 }
534
535 #[cfg(any(test, feature = "test-utils"))]
536 fn open_sealed_blob_for_test(
537 &self,
538 stored: &[u8],
539 aad_context: &[u8],
540 ) -> Result<
541 (coven_keys::encryption::KeyFingerprint, Vec<u8>),
542 coven_keys::encryption::EncryptionError,
543 > {
544 self.cipher.open_sealed_blob_for_test(stored, aad_context)
545 }
546
547 fn merged_keyring(
548 &self,
549 new_encryption: &EncryptionService,
550 ) -> Result<CloudKeyringMerge, EncryptionError> {
551 self.cipher.merged_keyring(new_encryption)
552 }
553
554 fn merge_key_rotation(
555 &self,
556 new_encryption: &EncryptionService,
557 custody: &dyn coven_keys::keys::MasterKeyCustody,
558 ) -> Result<Option<String>, coven_keys::keys::KeyError> {
559 self.cipher.merge_key_rotation(new_encryption, custody)
560 }
561}
562
563impl CloudSyncRotationStateAccess for CloudSyncConnection {
564 fn mark_candidate(
565 &self,
566 generation: u64,
567 mutation: ObjectHash,
568 ) -> Result<(), RotationStateError> {
569 self.pending_rotation.mark_candidate(generation, mutation)
570 }
571
572 fn mark_committed_mutation(
573 &self,
574 generation: u64,
575 mutation: ObjectHash,
576 ) -> Result<(), RotationStateError> {
577 self.pending_rotation
578 .mark_committed_mutation(generation, mutation)
579 }
580
581 fn remove_candidate(
582 &self,
583 generation: u64,
584 mutation: ObjectHash,
585 ) -> Result<(), RotationStateError> {
586 self.pending_rotation.remove_candidate(generation, mutation)
587 }
588
589 fn replace_candidate_mutation(
590 &self,
591 generation: u64,
592 previous: ObjectHash,
593 replacement: ObjectHash,
594 ) -> Result<(), RotationStateError> {
595 self.pending_rotation
596 .replace_candidate_mutation(generation, previous, replacement)
597 }
598
599 fn gate(&self) -> Option<RotationGate> {
600 self.pending_rotation.gate()
601 }
602
603 fn install_durable_gate(&self, gate: Option<RotationGate>) {
604 self.pending_rotation.install_durable_gate(gate);
605 }
606
607 fn check(&self, live_generation: Option<u64>) -> Result<(), RotationPending> {
608 self.pending_rotation.check(live_generation)
609 }
610}
611
612async fn run_storage_cpu<T>(
613 operation: &'static str,
614 work: Box<dyn FnOnce() -> Result<T, StorageError> + Send>,
615) -> Result<T, StorageError>
616where
617 T: Send + 'static,
618{
619 coven_foundation::blocking::run(work)
620 .await
621 .map_err(|source| StorageError::Blocking { operation, source })?
622}
623
624async fn read_source_exact(
625 source: &mut crate::local_file::PlaintextReader,
626 len: usize,
627 locator_hash: ObjectHash,
628) -> Result<Vec<u8>, StorageError> {
629 let mut bytes = Vec::with_capacity(len);
630 while bytes.len() < len {
631 let chunk = source.next_chunk(len - bytes.len()).await?;
632 if chunk.is_empty() {
633 return Err(StorageError::InvalidContent(format!(
634 "blob {locator_hash} stored body ended after {} of {len} required bytes",
635 bytes.len()
636 )));
637 }
638 bytes.extend_from_slice(&chunk);
639 }
640 Ok(bytes)
641}
642
643#[cfg(test)]
644mod tests;