1use async_trait::async_trait;
11use std::path::Path;
12use std::sync::{Arc, RwLock};
13use tokio::sync::OnceCell;
14use tracing::warn;
15
16use super::storage::{
17 CoordinationError, CoordinationStorage, CreateHeadError, ExactObjectRef, PreparedExactObject,
18 ProtocolObjectContext, ProtocolObjectProtection, ReplaceHeadError, ResolvedProviderBinding,
19 StorageError, SyncStorage, VersionToken, VersionedObject,
20};
21use crate::encryption::{chunked_encrypted_len, EncryptionError, EncryptionService};
22use crate::keys::UserKeypair;
23use crate::storage::cloud::{
24 BlobBody, CloudFileReadError, CloudHeadStorage, CloudHome, ExactSlotStorage, ObjectSlot,
25};
26use crate::sync::store_commit::ObjectHash;
27
28const KEY_TAG_MAGIC: &[u8; 4] = b"CKF1";
33const KEY_FINGERPRINT_LEN: usize = 8;
34const KEY_TAG_LEN: usize = KEY_TAG_MAGIC.len() + KEY_FINGERPRINT_LEN;
35
36#[derive(Clone)]
40pub enum CloudCipher {
41 Encrypted(EncryptionService),
42 Plaintext,
43}
44
45pub struct CloudCipherState {
49 mode: CloudCipherMode,
50}
51
52pub trait CloudCipherAccess: Send + Sync {
56 fn snapshot(&self) -> CloudCipher;
57 fn merge_key_rotation(
58 &self,
59 new_encryption: &EncryptionService,
60 custody: &dyn crate::keys::MasterKeyCustody,
61 ) -> Result<Option<String>, crate::keys::KeyError>;
62}
63
64enum CloudCipherMode {
65 Encrypted(RwLock<EncryptionService>),
66 Plaintext,
67}
68
69impl CloudCipherState {
70 pub fn new(cipher: CloudCipher) -> Self {
71 let mode = match cipher {
72 CloudCipher::Encrypted(encryption) => {
73 CloudCipherMode::Encrypted(RwLock::new(encryption))
74 }
75 CloudCipher::Plaintext => CloudCipherMode::Plaintext,
76 };
77 Self { mode }
78 }
79
80 pub fn is_plaintext(&self) -> bool {
81 matches!(self.mode, CloudCipherMode::Plaintext)
82 }
83
84 pub fn encryption(&self) -> Option<EncryptionService> {
85 match &self.mode {
86 CloudCipherMode::Encrypted(encryption) => Some(encryption.read().unwrap().clone()),
87 CloudCipherMode::Plaintext => None,
88 }
89 }
90
91 pub(crate) fn snapshot(&self) -> CloudCipher {
92 match &self.mode {
93 CloudCipherMode::Encrypted(encryption) => {
94 CloudCipher::Encrypted(encryption.read().unwrap().clone())
95 }
96 CloudCipherMode::Plaintext => CloudCipher::Plaintext,
97 }
98 }
99
100 pub(crate) fn merge_key_rotation(
101 &self,
102 new_encryption: &EncryptionService,
103 custody: &dyn crate::keys::MasterKeyCustody,
104 ) -> Result<Option<String>, crate::keys::KeyError> {
105 let CloudCipherMode::Encrypted(live) = &self.mode else {
106 return Err(crate::keys::KeyError::Crypto(
107 "cannot rotate the key of a plaintext cloud home".to_string(),
108 ));
109 };
110 let mut live = live.write().unwrap();
111 let merged = live.merged_with(new_encryption);
112 if merged.key_count() == live.key_count() {
113 return Ok(None);
114 }
115 custody.persist(&crate::encryption::MasterKeyring::from(merged.clone()))?;
116 *live = merged;
117 Ok(Some(live.fingerprint()))
118 }
119}
120
121impl CloudCipherAccess for CloudCipherState {
122 fn snapshot(&self) -> CloudCipher {
123 CloudCipherState::snapshot(self)
124 }
125
126 fn merge_key_rotation(
127 &self,
128 new_encryption: &EncryptionService,
129 custody: &dyn crate::keys::MasterKeyCustody,
130 ) -> Result<Option<String>, crate::keys::KeyError> {
131 CloudCipherState::merge_key_rotation(self, new_encryption, custody)
132 }
133}
134
135impl CloudCipherAccess for Arc<CloudCipherState> {
136 fn snapshot(&self) -> CloudCipher {
137 self.as_ref().snapshot()
138 }
139
140 fn merge_key_rotation(
141 &self,
142 new_encryption: &EncryptionService,
143 custody: &dyn crate::keys::MasterKeyCustody,
144 ) -> Result<Option<String>, crate::keys::KeyError> {
145 self.as_ref().merge_key_rotation(new_encryption, custody)
146 }
147}
148
149#[cfg(any(test, feature = "test-utils"))]
150impl CloudCipherAccess for RwLock<CloudCipher> {
151 fn snapshot(&self) -> CloudCipher {
152 self.read().unwrap().clone()
153 }
154
155 fn merge_key_rotation(
156 &self,
157 new_encryption: &EncryptionService,
158 custody: &dyn crate::keys::MasterKeyCustody,
159 ) -> Result<Option<String>, crate::keys::KeyError> {
160 let mut cipher = self.write().unwrap();
161 let CloudCipher::Encrypted(live) = &mut *cipher else {
162 return Err(crate::keys::KeyError::Crypto(
163 "cannot rotate the key of a plaintext cloud home".to_string(),
164 ));
165 };
166 let merged = live.merged_with(new_encryption);
167 if merged.key_count() == live.key_count() {
168 return Ok(None);
169 }
170 custody.persist(&crate::encryption::MasterKeyring::from(merged.clone()))?;
171 *live = merged;
172 Ok(Some(live.fingerprint()))
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
185#[error(
186 "the store key rotated to generation {committed_generation}, which this device has not \
187 adopted (still sealing under generation {live_generation}); refusing to seal for the \
188 cloud until adoption completes"
189)]
190pub struct RotationPending {
191 pub committed_generation: u64,
192 pub live_generation: u64,
193}
194
195pub struct PendingRotation(std::sync::RwLock<Option<u64>>);
210
211impl Default for PendingRotation {
212 fn default() -> Self {
213 Self(std::sync::RwLock::new(None))
214 }
215}
216
217impl PendingRotation {
218 pub fn none() -> Self {
219 Self::default()
220 }
221
222 pub fn mark_committed(&self, generation: u64) {
228 let mut recorded = self.0.write().unwrap();
229 if recorded.is_none_or(|g| generation > g) {
230 *recorded = Some(generation);
231 }
232 }
233
234 pub fn clear(&self) {
236 *self.0.write().unwrap() = None;
237 }
238
239 pub fn resolve(&self, cipher: &CloudCipher) {
245 if self.check(cipher).is_ok() {
246 self.clear();
247 }
248 }
249
250 pub fn pending_generation(&self) -> Option<u64> {
253 *self.0.read().unwrap()
254 }
255
256 pub fn check(&self, cipher: &CloudCipher) -> Result<(), RotationPending> {
260 let live_generation = match cipher {
261 CloudCipher::Encrypted(enc) => enc.current_generation(),
262 CloudCipher::Plaintext => return Ok(()),
263 };
264 if let Some(committed_generation) = self.pending_generation() {
265 if committed_generation > live_generation {
266 return Err(RotationPending {
267 committed_generation,
268 live_generation,
269 });
270 }
271 }
272 Ok(())
273 }
274}
275
276pub const PENDING_ROTATION_STATE_KEY: &str = "pending_rotation_generation";
284
285pub async fn restore_pending_rotation(
288 db: &crate::database::Database,
289 pending_rotation: &PendingRotation,
290) -> Result<(), crate::database::DbError> {
291 if let Some(value) = db.get_protocol_state(PENDING_ROTATION_STATE_KEY).await? {
292 match value.parse::<u64>() {
293 Ok(generation) => pending_rotation.mark_committed(generation),
294 Err(_) => warn!("ignoring malformed persisted pending-rotation generation {value:?}"),
295 }
296 }
297 Ok(())
298}
299
300pub async fn persist_pending_rotation(
304 db: &crate::database::Database,
305 pending_rotation: &PendingRotation,
306) -> Result<(), crate::database::DbError> {
307 match pending_rotation.pending_generation() {
308 Some(generation) => {
309 db.set_protocol_state(PENDING_ROTATION_STATE_KEY, &generation.to_string())
310 .await
311 }
312 None => db.delete_protocol_state(PENDING_ROTATION_STATE_KEY).await,
313 }
314}
315
316#[derive(Clone, Copy)]
320pub enum BlobPathScheme {
321 Hashed,
323 Plain,
327}
328
329impl BlobPathScheme {
330 pub fn for_storage(storage: crate::config::HomeStorage) -> Self {
333 if storage.is_opaque() {
334 BlobPathScheme::Hashed
335 } else {
336 BlobPathScheme::Plain
337 }
338 }
339}
340
341impl CloudCipher {
342 pub fn for_storage(
355 storage: crate::config::HomeStorage,
356 encryption: Option<EncryptionService>,
357 ) -> Option<Self> {
358 if storage.is_opaque() {
359 encryption.map(CloudCipher::Encrypted)
360 } else {
361 Some(CloudCipher::Plaintext)
362 }
363 }
364
365 pub fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
370 self.seal_scoped(crate::blob::BlobScope::Master, plaintext, aad_context)
374 }
375
376 pub fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError> {
378 self.open_scoped(crate::blob::BlobScope::Master, stored, aad_context)
379 }
380
381 pub(crate) fn seal_scoped(
385 &self,
386 scope: crate::blob::BlobScope,
387 plaintext: Vec<u8>,
388 aad_context: &[u8],
389 ) -> Vec<u8> {
390 match self {
391 CloudCipher::Encrypted(e) => seal_scoped_encrypted(scope, e, &plaintext, aad_context),
392 CloudCipher::Plaintext => plaintext,
393 }
394 }
395
396 pub(crate) fn open_scoped(
398 &self,
399 scope: crate::blob::BlobScope,
400 stored: Vec<u8>,
401 aad_context: &[u8],
402 ) -> Result<Vec<u8>, EncryptionError> {
403 match self {
404 CloudCipher::Encrypted(e) => open_scoped_encrypted(scope, e, &stored, aad_context),
405 CloudCipher::Plaintext => Ok(stored),
406 }
407 }
408
409 pub fn suffix(&self) -> &'static str {
413 match self {
414 CloudCipher::Encrypted(_) => ".enc",
415 CloudCipher::Plaintext => "",
416 }
417 }
418
419 pub fn is_plaintext(&self) -> bool {
421 matches!(self, CloudCipher::Plaintext)
422 }
423
424 pub fn body_len(&self, plaintext_len: u64) -> u64 {
428 match self {
429 CloudCipher::Encrypted(_) => chunked_encrypted_len(plaintext_len) + KEY_TAG_LEN as u64,
430 CloudCipher::Plaintext => plaintext_len,
431 }
432 }
433
434 pub(crate) async fn open_body(
440 &self,
441 scope: crate::blob::BlobScope,
442 file_path: &std::path::Path,
443 aad_context: &[u8],
444 ) -> Result<BlobBody, String> {
445 let plaintext_len = crate::local_blob::file_len(file_path).await?;
446 let reader = crate::local_blob::open_reader(file_path).await?;
447 let (sealer, prefix) = match self {
448 CloudCipher::Encrypted(e) => {
449 let (encryption, prefix) = sealing_encryption_for_scope(scope, e);
450 (Some(encryption.sealer(plaintext_len, aad_context)), prefix)
451 }
452 CloudCipher::Plaintext => (None, Vec::new()),
453 };
454 Ok(BlobBody::from_file_with_prefix(
455 self.body_len(plaintext_len),
456 reader,
457 sealer,
458 prefix,
459 ))
460 }
461}
462
463pub struct CloudSyncStorage {
466 home: Arc<dyn CloudHome>,
471 exact: Arc<dyn ExactSlotStorage>,
472 exact_probe_peer: Arc<dyn ExactSlotStorage>,
473 cipher: Arc<CloudCipherState>,
474 pending_rotation: Arc<PendingRotation>,
479 blob_paths: BlobPathScheme,
482 store_id: String,
483 coordination: Option<Arc<dyn CloudHeadStorage>>,
484 coordination_probe_peer: Option<Arc<dyn CloudHeadStorage>>,
485 keypair: UserKeypair,
490}
491
492impl CloudSyncStorage {
493 pub fn new(
494 home: Arc<dyn CloudHome>,
495 cipher: CloudCipher,
496 blob_paths: BlobPathScheme,
497 store_id: impl Into<String>,
498 keypair: UserKeypair,
499 ) -> Result<Self, crate::storage::cloud::CloudHomeError> {
500 let exact = home.clone().exact_slot_storage().ok_or_else(|| {
501 crate::storage::cloud::CloudHomeError::Configuration(
502 "CloudSyncStorage requires exact-slot storage".to_string(),
503 )
504 })?;
505 let exact_probe_peer = home.clone().exact_slot_storage().ok_or_else(|| {
506 crate::storage::cloud::CloudHomeError::Configuration(
507 "CloudSyncStorage requires a second exact-slot probe client".to_string(),
508 )
509 })?;
510 Ok(CloudSyncStorage {
511 home,
512 exact,
513 exact_probe_peer,
514 cipher: Arc::new(CloudCipherState::new(cipher)),
515 pending_rotation: Arc::new(PendingRotation::none()),
516 blob_paths,
517 store_id: store_id.into(),
518 keypair,
519 coordination: None,
520 coordination_probe_peer: None,
521 })
522 }
523
524 #[cfg(any(test, feature = "test-utils"))]
525 pub fn with_test_serial_coordination(
526 mut self,
527 coordination: Arc<dyn CloudHeadStorage>,
528 ) -> Self {
529 self.coordination = Some(coordination.clone());
530 self.coordination_probe_peer = Some(coordination);
531 self
532 }
533
534 pub fn with_serial_coordination_clients(
535 mut self,
536 coordination: Arc<dyn CloudHeadStorage>,
537 probe_peer: Arc<dyn CloudHeadStorage>,
538 ) -> Self {
539 self.coordination = Some(coordination);
540 self.coordination_probe_peer = Some(probe_peer);
541 self
542 }
543
544 pub fn serial_coordination(&self) -> Result<&dyn CoordinationStorage, CoordinationError> {
545 self.coordination.as_ref().ok_or_else(|| {
546 CoordinationError::Unavailable(
547 "configured storage adapter has no documented coordination capability".to_string(),
548 )
549 })?;
550 Ok(self)
551 }
552
553 pub(crate) fn serial_coordination_probe_clients(
554 &self,
555 ) -> Result<(CloudCoordinationClient<'_>, CloudCoordinationClient<'_>), CoordinationError> {
556 let primary = self.primary_coordination_client()?;
557 let peer = self.coordination_probe_peer.as_deref().ok_or_else(|| {
558 CoordinationError::Unavailable(
559 "Serial coordination probe peer is not configured".to_string(),
560 )
561 })?;
562 Ok((
563 primary,
564 CloudCoordinationClient {
565 storage: self,
566 raw: peer,
567 },
568 ))
569 }
570
571 pub(crate) fn exact_slot_probe_clients(
572 &self,
573 ) -> (&dyn ExactSlotStorage, &dyn ExactSlotStorage) {
574 (self.exact.as_ref(), self.exact_probe_peer.as_ref())
575 }
576
577 fn primary_coordination_client(
578 &self,
579 ) -> Result<CloudCoordinationClient<'_>, CoordinationError> {
580 let raw = self.coordination.as_deref().ok_or_else(|| {
581 CoordinationError::Unavailable(
582 "configured storage adapter has no documented coordination capability".to_string(),
583 )
584 })?;
585 Ok(CloudCoordinationClient { storage: self, raw })
586 }
587
588 pub(crate) fn blob_path_scheme(&self) -> BlobPathScheme {
589 self.blob_paths
590 }
591
592 pub(crate) fn store_id(&self) -> &str {
593 &self.store_id
594 }
595
596 fn validate_blob_locator_home(
597 &self,
598 locator: &crate::blob::locator::BlobLocator,
599 ) -> Result<(), StorageError> {
600 let valid = matches!(
601 (locator, self.blob_paths, self.cipher.is_plaintext()),
602 (
603 crate::blob::locator::BlobLocator::Opaque { .. },
604 BlobPathScheme::Hashed,
605 false
606 ) | (
607 crate::blob::locator::BlobLocator::Browsable { .. },
608 BlobPathScheme::Plain,
609 true
610 )
611 );
612 if !valid {
613 return Err(StorageError::InvalidContent(
614 "blob locator protection does not match the cloud home's fixed storage mode"
615 .to_string(),
616 ));
617 }
618 Ok(())
619 }
620
621 async fn validate_blob_append_authority(
622 &self,
623 locator: &crate::blob::locator::BlobLocator,
624 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
625 ) -> Result<(), StorageError> {
626 authority
627 .reference
628 .verify_registration(authority.registration)
629 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
630 if locator.uploader() != authority.reference {
631 return Err(StorageError::InvalidContent(format!(
632 "blob locator uploader {:?} differs from its exact write authority",
633 locator.uploader()
634 )));
635 }
636 if authority.registration.author_pubkey != hex::encode(self.keypair.public_key()) {
637 return Err(StorageError::InvalidContent(
638 "blob write authority is not this device's identity key".to_string(),
639 ));
640 }
641 let live = self
642 .exact
643 .provider_binding()
644 .await
645 .map_err(StorageError::from)?;
646 if live.device != authority.registration.provider {
647 return Err(StorageError::InvalidContent(
648 "blob write authority differs from the authenticated provider principal"
649 .to_string(),
650 ));
651 }
652 Ok(())
653 }
654
655 pub(crate) fn user_keypair(&self) -> &UserKeypair {
656 &self.keypair
657 }
658
659 pub(crate) fn cipher_state(&self) -> &Arc<CloudCipherState> {
662 &self.cipher
663 }
664
665 pub fn shared_pending_rotation(&self) -> Arc<PendingRotation> {
671 self.pending_rotation.clone()
672 }
673
674 pub fn cloud_home(&self) -> &dyn CloudHome {
676 &*self.home
677 }
678
679 fn cipher(&self) -> CloudCipher {
680 self.cipher.snapshot()
681 }
682
683 fn suffix(&self) -> &'static str {
685 self.cipher().suffix()
686 }
687
688 pub(crate) fn self_uploader(&self) -> String {
693 hex::encode(self.keypair.public_key())
694 }
695
696 fn cipher_for_seal(&self) -> Result<CloudCipher, StorageError> {
703 let cipher = self.cipher();
704 self.pending_rotation.check(&cipher)?;
705 Ok(cipher)
706 }
707
708 fn protocol_cipher_for_seal(
709 &self,
710 context: &ProtocolObjectContext,
711 ) -> Result<CloudCipher, StorageError> {
712 match context.protection() {
713 ProtocolObjectProtection::Store => self.cipher_for_seal(),
714 ProtocolObjectProtection::Circle(encryption) => {
715 Ok(CloudCipher::Encrypted(encryption.clone()))
716 }
717 ProtocolObjectProtection::RecipientSealed => Ok(CloudCipher::Plaintext),
718 }
719 }
720
721 fn protocol_cipher_for_open(&self, context: &ProtocolObjectContext) -> CloudCipher {
722 match context.protection() {
723 ProtocolObjectProtection::Store => self.cipher(),
724 ProtocolObjectProtection::Circle(encryption) => {
725 CloudCipher::Encrypted(encryption.clone())
726 }
727 ProtocolObjectProtection::RecipientSealed => CloudCipher::Plaintext,
728 }
729 }
730
731 fn aad_context(&self, key: &str) -> Vec<u8> {
732 cloud_aad_context(&self.store_id, key)
733 }
734
735 pub fn blob_key(
759 scheme: BlobPathScheme,
760 namespace: &str,
761 uploader: Option<&str>,
762 id: &str,
763 cloud_path: Option<&str>,
764 ) -> Result<String, StorageError> {
765 match scheme {
766 BlobPathScheme::Hashed => {
767 let uploader = uploader.ok_or_else(|| {
768 StorageError::Parse(format!(
769 "an opaque-home blob requires an uploader for {namespace}/{id}"
770 ))
771 })?;
772 Ok(crate::store_dir::StoreDir::uploader_hashed_key(
773 namespace, uploader, id,
774 )?)
775 }
776 BlobPathScheme::Plain => {
777 let path = cloud_path.ok_or_else(|| {
778 StorageError::Parse(format!(
779 "unobfuscated blob-path home requires a cloud_path for blob {namespace}/{id}"
780 ))
781 })?;
782 crate::store_dir::validate_path_token(namespace)?;
783 crate::store_dir::validate_cloud_path(path)?;
784 Ok(format!("{namespace}/{path}"))
785 }
786 }
787 }
788}
789
790pub(crate) struct CloudCoordinationClient<'a> {
791 storage: &'a CloudSyncStorage,
792 raw: &'a dyn CloudHeadStorage,
793}
794
795fn coordination_home_error(error: crate::storage::cloud::CloudHomeError) -> CoordinationError {
796 match error {
797 crate::storage::cloud::CloudHomeError::NotFound(key) => CoordinationError::NotFound(key),
798 crate::storage::cloud::CloudHomeError::Configuration(message) => {
799 CoordinationError::Unavailable(message)
800 }
801 crate::storage::cloud::CloudHomeError::Transport(message) => {
802 CoordinationError::Storage(message)
803 }
804 crate::storage::cloud::CloudHomeError::Io(error) => {
805 CoordinationError::Storage(format!("I/O error: {error}"))
806 }
807 error @ (crate::storage::cloud::CloudHomeError::CleanupFailed { .. }
808 | crate::storage::cloud::CloudHomeError::UnresolvedOutcome { .. })
809 if error.is_retryable() =>
810 {
811 CoordinationError::Storage(error.to_string())
812 }
813 crate::storage::cloud::CloudHomeError::AlreadyExists(key) => {
814 CoordinationError::Unavailable(format!("coordination object already exists: {key}"))
815 }
816 error @ (crate::storage::cloud::CloudHomeError::CleanupFailed { .. }
817 | crate::storage::cloud::CloudHomeError::UnresolvedOutcome { .. }) => {
818 CoordinationError::Unavailable(error.to_string())
819 }
820 }
821}
822
823pub(crate) fn encryption_for_scope(
830 scope: crate::blob::BlobScope,
831 master: &EncryptionService,
832) -> EncryptionService {
833 match scope {
834 crate::blob::BlobScope::Master => master.clone(),
835 crate::blob::BlobScope::Derived(s) => master.derive_scoped(&s),
836 }
837}
838
839pub(crate) fn cloud_aad_context(store_id: &str, cloud_key: &str) -> Vec<u8> {
840 let mut context =
841 Vec::with_capacity(std::mem::size_of::<u64>() * 2 + store_id.len() + cloud_key.len());
842 context.extend_from_slice(&(store_id.len() as u64).to_le_bytes());
843 context.extend_from_slice(store_id.as_bytes());
844 context.extend_from_slice(&(cloud_key.len() as u64).to_le_bytes());
845 context.extend_from_slice(cloud_key.as_bytes());
846 context
847}
848
849fn protocol_object_aad_context(context: &ProtocolObjectContext, semantic_prefix: &str) -> Vec<u8> {
850 let domain = context.domain().aad_label();
851 let mut aad = Vec::with_capacity(
852 context.store_root_hash().as_bytes().len()
853 + std::mem::size_of::<u64>() * 2
854 + domain.len()
855 + semantic_prefix.len(),
856 );
857 aad.extend_from_slice(context.store_root_hash().as_bytes());
858 aad.extend_from_slice(&(domain.len() as u64).to_le_bytes());
859 aad.extend_from_slice(domain);
860 aad.extend_from_slice(&(semantic_prefix.len() as u64).to_le_bytes());
861 aad.extend_from_slice(semantic_prefix.as_bytes());
862 aad
863}
864
865fn key_tag(fingerprint: &[u8; KEY_FINGERPRINT_LEN]) -> Vec<u8> {
866 let mut tag = Vec::with_capacity(KEY_TAG_LEN);
867 tag.extend_from_slice(KEY_TAG_MAGIC);
868 tag.extend_from_slice(fingerprint);
869 tag
870}
871
872fn read_key_tag(stored: &[u8]) -> Result<([u8; KEY_FINGERPRINT_LEN], &[u8]), EncryptionError> {
873 if stored.len() < KEY_TAG_LEN {
874 return Err(EncryptionError::Decryption(
875 "ciphertext too short for key tag".to_string(),
876 ));
877 }
878 if &stored[..KEY_TAG_MAGIC.len()] != KEY_TAG_MAGIC {
879 return Err(EncryptionError::Decryption(
880 "ciphertext missing key tag".to_string(),
881 ));
882 }
883 let mut fingerprint = [0u8; KEY_FINGERPRINT_LEN];
884 fingerprint.copy_from_slice(&stored[KEY_TAG_MAGIC.len()..KEY_TAG_LEN]);
885 Ok((fingerprint, &stored[KEY_TAG_LEN..]))
886}
887
888fn sealing_encryption_for_scope(
893 scope: crate::blob::BlobScope,
894 master: &EncryptionService,
895) -> (EncryptionService, Vec<u8>) {
896 (
897 encryption_for_scope(scope, master),
898 key_tag(&master.seal_fingerprint()),
899 )
900}
901
902fn opening_encryption_for_scope(
903 scope: crate::blob::BlobScope,
904 master: &EncryptionService,
905 fingerprint: &[u8; KEY_FINGERPRINT_LEN],
906) -> Result<EncryptionService, EncryptionError> {
907 match scope {
908 crate::blob::BlobScope::Master => master.service_for_fingerprint(fingerprint),
909 crate::blob::BlobScope::Derived(scope_id) => {
910 master.derive_scoped_for_fingerprint(fingerprint, &scope_id)
911 }
912 }
913}
914
915fn seal_scoped_encrypted(
916 scope: crate::blob::BlobScope,
917 master: &EncryptionService,
918 plaintext: &[u8],
919 aad_context: &[u8],
920) -> Vec<u8> {
921 let (encryption, mut prefix) = sealing_encryption_for_scope(scope, master);
922 prefix.extend(encryption.encrypt(plaintext, aad_context));
923 prefix
924}
925
926fn open_scoped_encrypted(
927 scope: crate::blob::BlobScope,
928 master: &EncryptionService,
929 stored: &[u8],
930 aad_context: &[u8],
931) -> Result<Vec<u8>, EncryptionError> {
932 let (fingerprint, ciphertext) = read_key_tag(stored)?;
933 opening_encryption_for_scope(scope, master, &fingerprint)?.decrypt(ciphertext, aad_context)
934}
935
936pub struct BlobRangeReader {
952 home: Arc<dyn CloudHome>,
953 encryption: Option<RangeEncryption>,
956 key: String,
958 source_size: u64,
961 header: OnceCell<RangeHeader>,
963}
964
965enum ExactBlobOpening {
966 Browsable,
967 Opaque {
968 encryption: EncryptionService,
969 nonce: Vec<u8>,
970 next_chunk: u64,
971 aad_context: Vec<u8>,
972 },
973}
974
975struct ExactBlobPlaintextReader {
978 source: crate::local_blob::PlaintextReader,
979 opening: ExactBlobOpening,
980 remaining: u64,
981 total_size: u64,
982 hasher: Option<crate::blob::ContentHasher>,
983 expected_hash: ObjectHash,
984 locator_hash: ObjectHash,
985 pending: Vec<u8>,
986 pending_offset: usize,
987}
988
989impl ExactBlobPlaintextReader {
990 async fn new(
991 stored_file: &Path,
992 store_id: &str,
993 blob: &crate::blob::locator::StoredBlobRef,
994 protection: crate::sync::storage::BlobSpoolProtection,
995 ) -> Result<Self, StorageError> {
996 let locator = blob.locator();
997 let mut source = crate::local_blob::open_reader(stored_file)
998 .await
999 .map_err(StorageError::LocalFilesystem)?;
1000 let expected_stored_size = match locator {
1001 crate::blob::locator::BlobLocator::Opaque { .. } => {
1002 KEY_TAG_LEN as u64 + chunked_encrypted_len(locator.plaintext_size())
1003 }
1004 crate::blob::locator::BlobLocator::Browsable { .. } => locator.plaintext_size(),
1005 };
1006 if blob.object().stored_size() != expected_stored_size {
1007 return Err(StorageError::InvalidContent(format!(
1008 "blob {} stored length is {}, expected {expected_stored_size} for its locator",
1009 locator.locator_hash(),
1010 blob.object().stored_size()
1011 )));
1012 }
1013
1014 let opening = match (locator, protection) {
1015 (
1016 crate::blob::locator::BlobLocator::Opaque {
1017 scope,
1018 key_fingerprint,
1019 ..
1020 },
1021 crate::sync::storage::BlobSpoolProtection::Opaque(master),
1022 ) => {
1023 let header = read_source_exact(
1024 &mut source,
1025 KEY_TAG_LEN + crate::encryption::NONCE_SIZE,
1026 locator.locator_hash(),
1027 )
1028 .await?;
1029 let (fingerprint, nonce_and_chunks) = read_key_tag(&header).map_err(|error| {
1030 StorageError::Decryption(format!(
1031 "blob {} key tag: {error}",
1032 locator.locator_hash()
1033 ))
1034 })?;
1035 if crate::encryption::KeyFingerprint::from_bytes(fingerprint) != *key_fingerprint {
1036 return Err(StorageError::InvalidContent(format!(
1037 "blob {} stored key fingerprint differs from its locator",
1038 locator.locator_hash()
1039 )));
1040 }
1041 let encryption = opening_encryption_for_scope(scope.clone(), &master, &fingerprint)
1042 .map_err(|error| {
1043 StorageError::Decryption(format!(
1044 "blob {} audience key: {error}",
1045 locator.locator_hash()
1046 ))
1047 })?;
1048 ExactBlobOpening::Opaque {
1049 encryption,
1050 nonce: nonce_and_chunks.to_vec(),
1051 next_chunk: 0,
1052 aad_context: cloud_aad_context(store_id, &locator.semantic_key()),
1053 }
1054 }
1055 (
1056 crate::blob::locator::BlobLocator::Browsable { .. },
1057 crate::sync::storage::BlobSpoolProtection::Browsable,
1058 ) => ExactBlobOpening::Browsable,
1059 (crate::blob::locator::BlobLocator::Opaque { .. }, _) => {
1060 return Err(StorageError::Configuration(
1061 "opaque blob locator requires audience encryption".to_string(),
1062 ));
1063 }
1064 (crate::blob::locator::BlobLocator::Browsable { .. }, _) => {
1065 return Err(StorageError::Configuration(
1066 "browsable blob locator cannot use audience encryption".to_string(),
1067 ));
1068 }
1069 };
1070
1071 Ok(Self {
1072 source,
1073 opening,
1074 remaining: locator.plaintext_size(),
1075 total_size: locator.plaintext_size(),
1076 hasher: Some(crate::blob::ContentHasher::default()),
1077 expected_hash: locator.plaintext_hash(),
1078 locator_hash: locator.locator_hash(),
1079 pending: Vec::new(),
1080 pending_offset: 0,
1081 })
1082 }
1083
1084 fn take_pending(&mut self, max: usize) -> Vec<u8> {
1085 let end = (self.pending_offset + max).min(self.pending.len());
1086 let result = self.pending[self.pending_offset..end].to_vec();
1087 self.pending_offset = end;
1088 if self.pending_offset == self.pending.len() {
1089 self.pending.clear();
1090 self.pending_offset = 0;
1091 }
1092 result
1093 }
1094
1095 fn verify_complete(&mut self) -> Result<(), crate::local_blob::PlaintextChunkError> {
1096 let Some(hasher) = self.hasher.take() else {
1097 return Ok(());
1098 };
1099 let actual = hasher.finish();
1100 if actual != self.expected_hash.to_string() {
1101 return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1102 format!(
1103 "blob {} plaintext hash mismatch: expected {}, got {actual}",
1104 self.locator_hash, self.expected_hash
1105 ),
1106 ));
1107 }
1108 Ok(())
1109 }
1110}
1111
1112async fn read_source_exact(
1113 source: &mut crate::local_blob::PlaintextReader,
1114 len: usize,
1115 locator_hash: ObjectHash,
1116) -> Result<Vec<u8>, StorageError> {
1117 let mut bytes = Vec::with_capacity(len);
1118 while bytes.len() < len {
1119 let chunk = source
1120 .next_chunk(len - bytes.len())
1121 .await
1122 .map_err(StorageError::LocalFilesystem)?;
1123 if chunk.is_empty() {
1124 return Err(StorageError::InvalidContent(format!(
1125 "blob {locator_hash} stored body ended after {} of {len} required bytes",
1126 bytes.len()
1127 )));
1128 }
1129 bytes.extend_from_slice(&chunk);
1130 }
1131 Ok(bytes)
1132}
1133
1134#[async_trait]
1135impl crate::local_blob::PlaintextChunkReader for ExactBlobPlaintextReader {
1136 async fn next_chunk(
1137 &mut self,
1138 max: usize,
1139 ) -> Result<Vec<u8>, crate::local_blob::PlaintextChunkError> {
1140 if max == 0 {
1141 return Ok(Vec::new());
1142 }
1143 if !self.pending.is_empty() {
1144 return Ok(self.take_pending(max));
1145 }
1146 if self.remaining == 0 {
1147 self.verify_complete()?;
1148 return Ok(Vec::new());
1149 }
1150
1151 let plaintext = match &mut self.opening {
1152 ExactBlobOpening::Browsable => {
1153 let wanted = usize::try_from(self.remaining.min(max as u64)).map_err(|_| {
1154 crate::local_blob::PlaintextChunkError::InvalidContent(
1155 "blob plaintext read length does not fit this platform".to_string(),
1156 )
1157 })?;
1158 let chunk = self.source.next_chunk(wanted).await.map_err(|error| {
1159 crate::local_blob::PlaintextChunkError::Local(error.to_string())
1160 })?;
1161 if chunk.is_empty() {
1162 return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1163 format!("blob {} plaintext ended early", self.locator_hash),
1164 ));
1165 }
1166 chunk
1167 }
1168 ExactBlobOpening::Opaque {
1169 encryption,
1170 nonce,
1171 next_chunk,
1172 aad_context,
1173 } => {
1174 let plaintext_len = self.remaining.min(crate::encryption::CHUNK_SIZE as u64);
1175 let encrypted_len = usize::try_from(plaintext_len)
1176 .expect("one encryption chunk fits usize")
1177 + crate::encryption::TAG_SIZE;
1178 let encrypted =
1179 read_source_exact(&mut self.source, encrypted_len, self.locator_hash)
1180 .await
1181 .map_err(crate::local_blob::PlaintextChunkError::Remote)?;
1182 let start = *next_chunk * crate::encryption::CHUNK_SIZE as u64;
1183 let end = start + plaintext_len;
1184 let plaintext = encryption
1185 .decrypt_range_with_offset(
1186 nonce,
1187 &encrypted,
1188 *next_chunk,
1189 start,
1190 end,
1191 self.total_size,
1192 aad_context,
1193 )
1194 .map_err(|error| {
1195 crate::local_blob::PlaintextChunkError::InvalidContent(format!(
1196 "blob {} chunk {}: {error}",
1197 self.locator_hash, *next_chunk
1198 ))
1199 })?;
1200 *next_chunk += 1;
1201 plaintext
1202 }
1203 };
1204 if plaintext.len() as u64 > self.remaining {
1205 return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1206 format!("blob {} produced excess plaintext", self.locator_hash),
1207 ));
1208 }
1209 self.hasher
1210 .as_mut()
1211 .expect("hash verification remains active until EOF")
1212 .update(&plaintext);
1213 self.remaining -= plaintext.len() as u64;
1214 self.pending = plaintext;
1215 Ok(self.take_pending(max))
1216 }
1217}
1218
1219struct RangeEncryption {
1223 master: EncryptionService,
1224 scope: crate::blob::BlobScope,
1225 aad_context: Vec<u8>,
1226}
1227
1228struct RangeHeader {
1229 encryption: EncryptionService,
1230 nonce: Vec<u8>,
1231 chunk_base: u64,
1232}
1233
1234impl BlobRangeReader {
1235 pub fn new(
1241 home: Arc<dyn CloudHome>,
1242 cipher: &CloudCipher,
1243 scope: crate::blob::BlobScope,
1244 key: String,
1245 source_size: u64,
1246 aad_context: Vec<u8>,
1247 ) -> Self {
1248 let encryption = match cipher {
1249 CloudCipher::Encrypted(master) => Some(RangeEncryption {
1250 master: master.clone(),
1251 scope,
1252 aad_context,
1253 }),
1254 CloudCipher::Plaintext => None,
1255 };
1256 BlobRangeReader {
1257 home,
1258 encryption,
1259 key,
1260 source_size,
1261 header: OnceCell::new(),
1262 }
1263 }
1264
1265 pub async fn read(&self, offset: u64, len: u64) -> Result<Vec<u8>, StorageError> {
1268 if len == 0 {
1269 return Ok(Vec::new());
1270 }
1271 let end = offset.checked_add(len).ok_or_else(|| {
1272 StorageError::Storage(format!("blob range overflow: offset={offset}, len={len}"))
1273 })?;
1274 if end > self.source_size {
1275 return Err(StorageError::Storage(format!(
1276 "blob range {offset}..{end} exceeds blob size {}",
1277 self.source_size
1278 )));
1279 }
1280
1281 let encryption = match &self.encryption {
1282 Some(encryption) => encryption,
1283 None => {
1286 return self
1287 .home
1288 .read_range(&self.key, offset, end)
1289 .await
1290 .map_err(StorageError::from);
1291 }
1292 };
1293
1294 use crate::encryption::{chunked_encrypted_len, encrypted_chunk_range, CHUNK_SIZE};
1295
1296 let header = self.header(encryption).await?;
1297
1298 let (chunk_start, mut chunk_end) = encrypted_chunk_range(offset, end);
1299 chunk_end = chunk_end.min(chunked_encrypted_len(self.source_size));
1300 let stored_chunk_start =
1301 header.chunk_base + (chunk_start - crate::encryption::NONCE_SIZE as u64);
1302 let stored_chunk_end =
1303 header.chunk_base + (chunk_end - crate::encryption::NONCE_SIZE as u64);
1304 let encrypted_chunks = self
1305 .home
1306 .read_range(&self.key, stored_chunk_start, stored_chunk_end)
1307 .await
1308 .map_err(StorageError::from)?;
1309
1310 let first_chunk_index = offset / CHUNK_SIZE as u64;
1311 header
1312 .encryption
1313 .decrypt_range_with_offset(
1314 &header.nonce,
1315 &encrypted_chunks,
1316 first_chunk_index,
1317 offset,
1318 end,
1319 self.source_size,
1320 &encryption.aad_context,
1321 )
1322 .map_err(|e| StorageError::Decryption(format!("blob range {offset}..{end}: {e}")))
1323 }
1324
1325 async fn header(&self, encryption: &RangeEncryption) -> Result<&RangeHeader, StorageError> {
1327 use crate::encryption::NONCE_SIZE;
1328 self.header
1329 .get_or_try_init(|| async {
1330 let header = self
1331 .home
1332 .read_range(&self.key, 0, (KEY_TAG_LEN + NONCE_SIZE) as u64)
1333 .await
1334 .map_err(StorageError::from)?;
1335 if header.len() < KEY_TAG_LEN + NONCE_SIZE {
1336 return Err(StorageError::Decryption(format!(
1337 "blob header too short: expected {}, got {}",
1338 KEY_TAG_LEN + NONCE_SIZE,
1339 header.len()
1340 )));
1341 }
1342 let (fingerprint, nonce_and_chunks) = read_key_tag(&header)
1343 .map_err(|e| StorageError::Decryption(format!("blob key tag: {e}")))?;
1344 let service = opening_encryption_for_scope(
1345 encryption.scope.clone(),
1346 &encryption.master,
1347 &fingerprint,
1348 )
1349 .map_err(|e| {
1350 StorageError::Decryption(format!("blob key {}: {e}", hex::encode(fingerprint)))
1351 })?;
1352 Ok(RangeHeader {
1353 encryption: service,
1354 nonce: nonce_and_chunks[..NONCE_SIZE].to_vec(),
1355 chunk_base: (KEY_TAG_LEN + NONCE_SIZE) as u64,
1356 })
1357 })
1358 .await
1359 }
1360}
1361
1362#[async_trait]
1363impl CoordinationStorage for CloudCoordinationClient<'_> {
1364 async fn provider_binding(&self) -> Result<ResolvedProviderBinding, CoordinationError> {
1365 SyncStorage::provider_binding(self.storage)
1366 .await
1367 .map_err(|error| CoordinationError::Storage(error.to_string()))
1368 }
1369
1370 async fn read_head(&self, key: &str) -> Result<VersionedObject, CoordinationError> {
1371 let raw = self.raw.read_head(key).await.map_err(|error| match error {
1372 crate::storage::cloud::CloudHomeError::NotFound(_) => {
1373 CoordinationError::NotFound(key.to_string())
1374 }
1375 other => coordination_home_error(other),
1376 })?;
1377 let bytes = self
1378 .storage
1379 .cipher()
1380 .open(raw.bytes, &self.storage.aad_context(key))
1381 .map_err(|error| CoordinationError::Open(error.to_string()))?;
1382 Ok(VersionedObject {
1383 bytes,
1384 version: VersionToken::from_cloud(raw.version),
1385 })
1386 }
1387
1388 async fn create_head(
1389 &self,
1390 key: &str,
1391 bytes: &[u8],
1392 ) -> Result<VersionedObject, CreateHeadError> {
1393 let stored = self
1394 .storage
1395 .cipher_for_seal()
1396 .map_err(|error| {
1397 CreateHeadError::Coordination(CoordinationError::Open(error.to_string()))
1398 })?
1399 .seal(bytes.to_vec(), &self.storage.aad_context(key));
1400 let created = self
1401 .raw
1402 .create_head(key, stored)
1403 .await
1404 .map_err(|error| match error {
1405 crate::storage::cloud::CloudHeadCreateError::AlreadyExists => {
1406 CreateHeadError::AlreadyExists
1407 }
1408 crate::storage::cloud::CloudHeadCreateError::Storage(error) => {
1409 CreateHeadError::Coordination(coordination_home_error(error))
1410 }
1411 })?;
1412 let opened = self
1413 .storage
1414 .cipher()
1415 .open(created.bytes, &self.storage.aad_context(key))
1416 .map_err(|error| {
1417 CreateHeadError::Coordination(CoordinationError::Open(error.to_string()))
1418 })?;
1419 if opened != bytes {
1420 return Err(CreateHeadError::Coordination(CoordinationError::Open(
1421 "created coordination head readback differs".to_string(),
1422 )));
1423 }
1424 Ok(VersionedObject {
1425 bytes: opened,
1426 version: VersionToken::from_cloud(created.version),
1427 })
1428 }
1429
1430 async fn replace_head(
1431 &self,
1432 key: &str,
1433 expected: &VersionToken,
1434 bytes: &[u8],
1435 ) -> Result<VersionedObject, ReplaceHeadError> {
1436 let stored = self
1437 .storage
1438 .cipher_for_seal()
1439 .map_err(|error| {
1440 ReplaceHeadError::Coordination(CoordinationError::Open(error.to_string()))
1441 })?
1442 .seal(bytes.to_vec(), &self.storage.aad_context(key));
1443 let replaced = self
1444 .raw
1445 .replace_head(key, expected.cloud(), stored)
1446 .await
1447 .map_err(|error| match error {
1448 crate::storage::cloud::CloudHeadReplaceError::VersionMismatch => {
1449 ReplaceHeadError::VersionMismatch
1450 }
1451 crate::storage::cloud::CloudHeadReplaceError::Storage(error) => {
1452 ReplaceHeadError::Coordination(coordination_home_error(error))
1453 }
1454 })?;
1455 let opened = self
1456 .storage
1457 .cipher()
1458 .open(replaced.bytes, &self.storage.aad_context(key))
1459 .map_err(|error| {
1460 ReplaceHeadError::Coordination(CoordinationError::Open(error.to_string()))
1461 })?;
1462 if opened != bytes {
1463 return Err(ReplaceHeadError::Coordination(CoordinationError::Open(
1464 "replaced coordination head readback differs".to_string(),
1465 )));
1466 }
1467 Ok(VersionedObject {
1468 bytes: opened,
1469 version: VersionToken::from_cloud(replaced.version),
1470 })
1471 }
1472
1473 async fn delete_probe_head(&self, key: &str) -> Result<(), CoordinationError> {
1474 self.raw
1475 .delete_probe_head(key)
1476 .await
1477 .map_err(coordination_home_error)
1478 }
1479}
1480
1481#[async_trait]
1482impl CoordinationStorage for CloudSyncStorage {
1483 async fn provider_binding(&self) -> Result<ResolvedProviderBinding, CoordinationError> {
1484 SyncStorage::provider_binding(self)
1485 .await
1486 .map_err(|error| CoordinationError::Storage(error.to_string()))
1487 }
1488
1489 async fn read_head(&self, key: &str) -> Result<VersionedObject, CoordinationError> {
1490 self.primary_coordination_client()?.read_head(key).await
1491 }
1492
1493 async fn create_head(
1494 &self,
1495 key: &str,
1496 bytes: &[u8],
1497 ) -> Result<VersionedObject, CreateHeadError> {
1498 self.primary_coordination_client()
1499 .map_err(CreateHeadError::Coordination)?
1500 .create_head(key, bytes)
1501 .await
1502 }
1503
1504 async fn replace_head(
1505 &self,
1506 key: &str,
1507 expected: &VersionToken,
1508 bytes: &[u8],
1509 ) -> Result<VersionedObject, ReplaceHeadError> {
1510 self.primary_coordination_client()
1511 .map_err(ReplaceHeadError::Coordination)?
1512 .replace_head(key, expected, bytes)
1513 .await
1514 }
1515
1516 async fn delete_probe_head(&self, key: &str) -> Result<(), CoordinationError> {
1517 self.primary_coordination_client()?
1518 .delete_probe_head(key)
1519 .await
1520 }
1521}
1522
1523#[async_trait]
1524impl SyncStorage for CloudSyncStorage {
1525 fn store_blob_protection(
1526 &self,
1527 ) -> Result<crate::sync::storage::BlobSpoolProtection, StorageError> {
1528 Ok(match self.cipher_for_seal()? {
1529 CloudCipher::Encrypted(encryption) => {
1530 crate::sync::storage::BlobSpoolProtection::Opaque(encryption)
1531 }
1532 CloudCipher::Plaintext => crate::sync::storage::BlobSpoolProtection::Browsable,
1533 })
1534 }
1535
1536 async fn provider_binding(&self) -> Result<ResolvedProviderBinding, StorageError> {
1537 self.exact.provider_binding().await.map_err(Into::into)
1538 }
1539
1540 async fn allocate_protocol_slot(
1541 &self,
1542 context: &ProtocolObjectContext,
1543 semantic_prefix: &str,
1544 extension: &str,
1545 ) -> Result<ObjectSlot, StorageError> {
1546 context.validate_path(semantic_prefix)?;
1547 context.validate_extension(extension)?;
1548 Ok(self
1549 .exact
1550 .allocate_slot(&format!("{semantic_prefix}{extension}"))
1551 .await?)
1552 }
1553
1554 fn prepare_protocol_object(
1555 &self,
1556 context: &ProtocolObjectContext,
1557 slot: ObjectSlot,
1558 semantic_prefix: &str,
1559 data: Vec<u8>,
1560 ) -> Result<PreparedExactObject, StorageError> {
1561 let expected = format!("{semantic_prefix}{}", context.domain().extension());
1562 if slot.logical_key() != expected {
1563 return Err(StorageError::Parse(format!(
1564 "protocol slot {:?} does not match semantic object {expected:?}",
1565 slot.logical_key()
1566 )));
1567 }
1568 let aad = protocol_object_aad_context(context, semantic_prefix);
1569 let stored = self.protocol_cipher_for_seal(context)?.seal(data, &aad);
1570 let reference = ExactObjectRef::new(
1571 slot,
1572 stored.len() as u64,
1573 crate::sync::store_commit::ObjectHash::digest(&stored),
1574 );
1575 PreparedExactObject::new(reference, stored)
1576 }
1577
1578 async fn create_protocol_object(
1579 &self,
1580 prepared: &PreparedExactObject,
1581 ) -> Result<(), StorageError> {
1582 let create_error = self
1583 .exact
1584 .create_at(
1585 prepared.reference().slot(),
1586 BlobBody::from_bytes(prepared.stored_bytes().to_vec()),
1587 &crate::storage::cloud::no_progress(),
1588 )
1589 .await
1590 .err();
1591 if let Some(error) = &create_error {
1592 if !matches!(
1593 error,
1594 crate::storage::cloud::CloudHomeError::AlreadyExists(_)
1595 ) && !error.is_retryable()
1596 {
1597 return Err(create_error.expect("create error exists").into());
1598 }
1599 }
1600 let observed = match self.exact.read_at(prepared.reference().slot()).await {
1601 Ok(observed) => observed,
1602 Err(crate::storage::cloud::CloudHomeError::NotFound(_)) if create_error.is_some() => {
1603 return Err(create_error.expect("create error exists").into())
1604 }
1605 Err(readback) => {
1606 return match create_error {
1607 Some(operation) => Err(StorageError::UnresolvedOutcome {
1608 operation: Box::new(operation.into()),
1609 readback: Box::new(readback.into()),
1610 }),
1611 None => Err(readback.into()),
1612 }
1613 }
1614 };
1615 if observed != prepared.stored_bytes() {
1616 return Err(StorageError::SlotCollision(
1617 prepared.reference().slot().logical_key().to_string(),
1618 ));
1619 }
1620 prepared.reference().verify(&observed)?;
1621 Ok(())
1622 }
1623
1624 async fn read_protocol_object(
1625 &self,
1626 context: &ProtocolObjectContext,
1627 object: &ExactObjectRef,
1628 semantic_prefix: &str,
1629 ) -> Result<Vec<u8>, StorageError> {
1630 context.validate_reference(object, semantic_prefix)?;
1631 let stored = self.exact.read_at(object.slot()).await?;
1632 object.verify(&stored)?;
1633 let aad = protocol_object_aad_context(context, semantic_prefix);
1634 self.protocol_cipher_for_open(context)
1635 .open(stored, &aad)
1636 .map_err(|error| {
1637 StorageError::Decryption(format!(
1638 "protocol object {}: {error}",
1639 object.slot().logical_key()
1640 ))
1641 })
1642 }
1643
1644 async fn read_protocol_slot(
1645 &self,
1646 context: &ProtocolObjectContext,
1647 slot: &ObjectSlot,
1648 semantic_prefix: &str,
1649 ) -> Result<(Vec<u8>, ExactObjectRef), StorageError> {
1650 let (opened, prepared) = self
1651 .read_prepared_protocol_slot(context, slot, semantic_prefix)
1652 .await?;
1653 Ok((opened, prepared.reference().clone()))
1654 }
1655
1656 async fn read_prepared_protocol_slot(
1657 &self,
1658 context: &ProtocolObjectContext,
1659 slot: &ObjectSlot,
1660 semantic_prefix: &str,
1661 ) -> Result<(Vec<u8>, PreparedExactObject), StorageError> {
1662 context.validate_slot(slot, semantic_prefix)?;
1663 let stored = self.exact.read_at(slot).await?;
1664 let object = ExactObjectRef::new(
1665 slot.clone(),
1666 stored.len() as u64,
1667 crate::sync::store_commit::ObjectHash::digest(&stored),
1668 );
1669 let prepared = PreparedExactObject::new(object, stored.clone())?;
1670 let aad = protocol_object_aad_context(context, semantic_prefix);
1671 let opened = self
1672 .protocol_cipher_for_open(context)
1673 .open(stored, &aad)
1674 .map_err(|error| {
1675 StorageError::Decryption(format!("protocol object {}: {error}", slot.logical_key()))
1676 })?;
1677 Ok((opened, prepared))
1678 }
1679
1680 async fn delete_protocol_object(&self, object: &ExactObjectRef) -> Result<(), StorageError> {
1681 let delete_error = self.exact.delete_at(object.slot()).await.err();
1682 if delete_error
1683 .as_ref()
1684 .is_some_and(|error| !error.is_retryable())
1685 {
1686 return Err(delete_error.expect("delete error exists").into());
1687 }
1688 match self.exact.read_at(object.slot()).await {
1689 Err(crate::storage::cloud::CloudHomeError::NotFound(_)) => Ok(()),
1690 Err(readback) => match delete_error {
1691 Some(operation) => Err(StorageError::UnresolvedOutcome {
1692 operation: Box::new(operation.into()),
1693 readback: Box::new(readback.into()),
1694 }),
1695 None => Err(readback.into()),
1696 },
1697 Ok(_) => match delete_error {
1698 Some(error) => Err(error.into()),
1699 None => Err(StorageError::Storage(format!(
1700 "exact object remains after delete: {}",
1701 object.slot().logical_key()
1702 ))),
1703 },
1704 }
1705 }
1706
1707 async fn allocate_blob_slot(
1708 &self,
1709 locator: &crate::blob::locator::BlobLocator,
1710 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1711 ) -> Result<ObjectSlot, StorageError> {
1712 self.validate_blob_locator_home(locator)?;
1713 self.validate_blob_append_authority(locator, authority)
1714 .await?;
1715 Ok(self.exact.allocate_slot(&locator.semantic_key()).await?)
1716 }
1717
1718 async fn seal_blob_to_spool(
1719 &self,
1720 locator: &crate::blob::locator::BlobLocator,
1721 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1722 protection: crate::sync::storage::BlobSpoolProtection,
1723 plaintext_file: &Path,
1724 spool_file: &Path,
1725 ) -> Result<(), StorageError> {
1726 self.validate_blob_locator_home(locator)?;
1727 self.validate_blob_append_authority(locator, authority)
1728 .await?;
1729 let (plaintext_size, plaintext_hash) = crate::local_blob::exact_file_facts(plaintext_file)
1730 .await
1731 .map_err(StorageError::LocalFilesystem)?;
1732 if plaintext_size != locator.plaintext_size() || plaintext_hash != locator.plaintext_hash()
1733 {
1734 return Err(StorageError::InvalidContent(format!(
1735 "blob plaintext {}/{} does not match its locator size/hash",
1736 locator.namespace(),
1737 locator.blob_id()
1738 )));
1739 }
1740
1741 match tokio::fs::metadata(spool_file).await {
1742 Ok(metadata) => {
1743 if !metadata.is_file() {
1744 return Err(StorageError::LocalFilesystem(format!(
1745 "blob spool path is not a file: {}",
1746 spool_file.display()
1747 )));
1748 }
1749 let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(spool_file)
1750 .await
1751 .map_err(StorageError::LocalFilesystem)?;
1752 let object = ExactObjectRef::new(
1753 ObjectSlot::logical(locator.semantic_key())?,
1754 stored_size,
1755 stored_hash,
1756 );
1757 let blob = crate::blob::locator::StoredBlobRef::new(locator.clone(), object)
1758 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1759 let mut reader =
1760 ExactBlobPlaintextReader::new(spool_file, &self.store_id, &blob, protection)
1761 .await?;
1762 loop {
1763 let chunk =
1764 crate::local_blob::PlaintextChunkReader::next_chunk(&mut reader, 1 << 20)
1765 .await
1766 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1767 if chunk.is_empty() {
1768 break;
1769 }
1770 }
1771 return Ok(());
1772 }
1773 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1774 Err(error) => {
1775 return Err(StorageError::LocalFilesystem(format!(
1776 "inspect blob spool {}: {error}",
1777 spool_file.display()
1778 )));
1779 }
1780 }
1781
1782 let retry_protection = protection.clone();
1783 let body = match (locator, protection) {
1784 (
1785 crate::blob::locator::BlobLocator::Opaque {
1786 scope,
1787 key_fingerprint,
1788 ..
1789 },
1790 crate::sync::storage::BlobSpoolProtection::Opaque(encryption),
1791 ) => {
1792 if encryption.seal_key_fingerprint() != *key_fingerprint {
1793 return Err(StorageError::InvalidContent(format!(
1794 "blob locator key fingerprint {key_fingerprint} differs from the supplied audience key {}",
1795 encryption.seal_key_fingerprint()
1796 )));
1797 }
1798 let aad = cloud_aad_context(&self.store_id, &locator.semantic_key());
1799 CloudCipher::Encrypted(encryption)
1800 .open_body(scope.clone(), plaintext_file, &aad)
1801 .await
1802 .map_err(StorageError::LocalFilesystem)?
1803 }
1804 (
1805 crate::blob::locator::BlobLocator::Browsable { .. },
1806 crate::sync::storage::BlobSpoolProtection::Browsable,
1807 ) => BlobBody::from_file(plaintext_file)
1808 .await
1809 .map_err(StorageError::LocalFilesystem)?,
1810 (crate::blob::locator::BlobLocator::Opaque { .. }, _) => {
1811 return Err(StorageError::Configuration(
1812 "opaque blob locator requires audience encryption".to_string(),
1813 ));
1814 }
1815 (crate::blob::locator::BlobLocator::Browsable { .. }, _) => {
1816 return Err(StorageError::Configuration(
1817 "browsable blob locator cannot use audience encryption".to_string(),
1818 ));
1819 }
1820 };
1821 let expected_size = body.len();
1822 let stream = futures_util::stream::try_unfold(body, |mut body| async move {
1823 match body.next_part(1 << 20).await? {
1824 Some(chunk) => Ok::<_, crate::storage::cloud::CloudHomeError>(Some((chunk, body))),
1825 None => Ok::<_, crate::storage::cloud::CloudHomeError>(None),
1826 }
1827 });
1828 let staged = crate::local_blob::stage_atomic_destination(spool_file)
1829 .await
1830 .map_err(StorageError::LocalFilesystem)?;
1831 let written = crate::local_blob::write_byte_stream_atomic(staged.path(), Box::pin(stream))
1832 .await
1833 .map_err(|error| match error {
1834 crate::local_blob::ByteStreamWriteError::Source(error) => error.into(),
1835 crate::local_blob::ByteStreamWriteError::Local(error) => {
1836 StorageError::LocalFilesystem(error)
1837 }
1838 })?;
1839 if written != expected_size {
1840 return Err(StorageError::InvalidContent(format!(
1841 "blob spool {} contains {written} stored bytes, expected {expected_size}",
1842 spool_file.display()
1843 )));
1844 }
1845 match staged.commit_new().await {
1846 Ok(()) => Ok(()),
1847 Err(crate::local_blob::CommitNewFileError::DestinationExists(_)) => {
1848 let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(spool_file)
1849 .await
1850 .map_err(StorageError::LocalFilesystem)?;
1851 let object = ExactObjectRef::new(
1852 ObjectSlot::logical(locator.semantic_key())?,
1853 stored_size,
1854 stored_hash,
1855 );
1856 let blob = crate::blob::locator::StoredBlobRef::new(locator.clone(), object)
1857 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1858 let mut reader = ExactBlobPlaintextReader::new(
1859 spool_file,
1860 &self.store_id,
1861 &blob,
1862 retry_protection,
1863 )
1864 .await?;
1865 loop {
1866 let chunk =
1867 crate::local_blob::PlaintextChunkReader::next_chunk(&mut reader, 1 << 20)
1868 .await
1869 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1870 if chunk.is_empty() {
1871 break;
1872 }
1873 }
1874 Ok(())
1875 }
1876 Err(error) => Err(StorageError::LocalFilesystem(error.to_string())),
1877 }
1878 }
1879
1880 async fn prepare_blob_object(
1881 &self,
1882 locator: &crate::blob::locator::BlobLocator,
1883 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1884 slot: ObjectSlot,
1885 stored_file: &Path,
1886 ) -> Result<crate::blob::locator::StoredBlobRef, StorageError> {
1887 self.validate_blob_locator_home(locator)?;
1888 self.validate_blob_append_authority(locator, authority)
1889 .await?;
1890 let expected = locator.semantic_key();
1891 if slot.logical_key() != expected {
1892 return Err(StorageError::Parse(format!(
1893 "blob slot {:?} does not match locator key {expected:?}",
1894 slot.logical_key()
1895 )));
1896 }
1897 let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(stored_file)
1898 .await
1899 .map_err(StorageError::LocalFilesystem)?;
1900 crate::blob::locator::StoredBlobRef::new(
1901 locator.clone(),
1902 ExactObjectRef::new(slot, stored_size, stored_hash),
1903 )
1904 .map_err(|error| StorageError::InvalidContent(error.to_string()))
1905 }
1906
1907 async fn create_blob_object_from_file(
1908 &self,
1909 blob: &crate::blob::locator::StoredBlobRef,
1910 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1911 stored_file: &Path,
1912 progress: &crate::storage::cloud::UploadProgress<'_>,
1913 ) -> Result<(), StorageError> {
1914 let locator = blob.locator();
1915 let object = blob.object();
1916 self.validate_blob_locator_home(locator)?;
1917 self.validate_blob_append_authority(locator, authority)
1918 .await?;
1919 let expected = locator.semantic_key();
1920 if object.slot().logical_key() != expected {
1921 return Err(StorageError::Parse(format!(
1922 "blob object {:?} does not match locator key {expected:?}",
1923 object.slot().logical_key()
1924 )));
1925 }
1926 crate::local_blob::verify_exact_file(object, stored_file)
1927 .await
1928 .map_err(|error| match error {
1929 crate::local_blob::ExactFileVerificationError::Filesystem(error) => {
1930 StorageError::LocalFilesystem(error)
1931 }
1932 crate::local_blob::ExactFileVerificationError::IdentityMismatch(error) => {
1933 StorageError::InvalidContent(error)
1934 }
1935 })?;
1936 let body = BlobBody::from_file(stored_file)
1937 .await
1938 .map_err(StorageError::LocalFilesystem)?;
1939 let create_error = self
1940 .exact
1941 .create_at(object.slot(), body, progress)
1942 .await
1943 .err();
1944 if let Some(error) = &create_error {
1945 if !matches!(
1946 error,
1947 crate::storage::cloud::CloudHomeError::AlreadyExists(_)
1948 ) && !error.is_retryable()
1949 {
1950 return Err(create_error.expect("create error exists").into());
1951 }
1952 }
1953 match self.exact.read_at(object.slot()).await {
1954 Ok(stored) => object
1955 .verify(&stored)
1956 .map_err(|_| StorageError::SlotCollision(object.slot().logical_key().to_string())),
1957 Err(crate::storage::cloud::CloudHomeError::NotFound(_)) if create_error.is_some() => {
1958 Err(create_error.expect("create error exists").into())
1959 }
1960 Err(readback) => match create_error {
1961 Some(operation) => Err(StorageError::UnresolvedOutcome {
1962 operation: Box::new(operation.into()),
1963 readback: Box::new(readback.into()),
1964 }),
1965 None => Err(readback.into()),
1966 },
1967 }
1968 }
1969
1970 async fn verify_blob_object(
1971 &self,
1972 blob: &crate::blob::locator::StoredBlobRef,
1973 ) -> Result<(), StorageError> {
1974 self.validate_blob_locator_home(blob.locator())?;
1975 let expected = blob.locator().semantic_key();
1976 if blob.object().slot().logical_key() != expected {
1977 return Err(StorageError::Parse(format!(
1978 "blob object {:?} does not match locator key {expected:?}",
1979 blob.object().slot().logical_key()
1980 )));
1981 }
1982 let stored = self.exact.read_at(blob.object().slot()).await?;
1983 blob.object().verify(&stored)
1984 }
1985
1986 async fn stage_exact_blob_download(
1987 &self,
1988 blob: &crate::blob::locator::StoredBlobRef,
1989 dest: &Path,
1990 ) -> Result<crate::local_blob::AtomicStagedFile, StorageError> {
1991 let locator = blob.locator();
1992 let object = blob.object();
1993 self.validate_blob_locator_home(locator)?;
1994 let expected = locator.semantic_key();
1995 if object.slot().logical_key() != expected {
1996 return Err(StorageError::Parse(format!(
1997 "blob object {:?} does not match locator key {expected:?}",
1998 object.slot().logical_key()
1999 )));
2000 }
2001 let staged = crate::local_blob::stage_atomic_destination(dest)
2002 .await
2003 .map_err(StorageError::LocalFilesystem)?;
2004 self.exact
2005 .read_at_to_file(object.slot(), staged.path())
2006 .await
2007 .map_err(|error| match error {
2008 CloudFileReadError::Source(error) => StorageError::from(error),
2009 CloudFileReadError::Local(error) => StorageError::LocalFilesystem(error),
2010 })?;
2011 crate::local_blob::verify_exact_file(object, staged.path())
2012 .await
2013 .map_err(|error| match error {
2014 crate::local_blob::ExactFileVerificationError::Filesystem(error) => {
2015 StorageError::LocalFilesystem(error)
2016 }
2017 crate::local_blob::ExactFileVerificationError::IdentityMismatch(error) => {
2018 StorageError::InvalidContent(error)
2019 }
2020 })?;
2021 Ok(staged)
2022 }
2023
2024 async fn stage_verified_blob_plaintext(
2025 &self,
2026 blob: &crate::blob::locator::StoredBlobRef,
2027 protection: crate::sync::storage::BlobSpoolProtection,
2028 dest: &Path,
2029 ) -> Result<crate::local_blob::AtomicStagedFile, StorageError> {
2030 let stored_destination = dest.with_extension("coven-stored-download");
2031 let stored = self
2032 .stage_exact_blob_download(blob, &stored_destination)
2033 .await?;
2034 let plaintext = crate::local_blob::stage_atomic_destination(dest)
2035 .await
2036 .map_err(StorageError::LocalFilesystem)?;
2037 let mut reader =
2038 ExactBlobPlaintextReader::new(stored.path(), &self.store_id, blob, protection).await?;
2039 let written = crate::local_blob::write_stream_to_stage(&plaintext, &mut reader)
2040 .await
2041 .map_err(|error| match error {
2042 crate::local_blob::StreamWriteError::Source(
2043 crate::local_blob::PlaintextChunkError::Remote(error),
2044 ) => error,
2045 crate::local_blob::StreamWriteError::Source(
2046 crate::local_blob::PlaintextChunkError::InvalidContent(error),
2047 ) => StorageError::InvalidContent(error),
2048 crate::local_blob::StreamWriteError::Source(
2049 crate::local_blob::PlaintextChunkError::Local(error),
2050 )
2051 | crate::local_blob::StreamWriteError::Local(error) => {
2052 StorageError::LocalFilesystem(error)
2053 }
2054 })?;
2055 if written != blob.locator().plaintext_size() {
2056 return Err(StorageError::InvalidContent(format!(
2057 "blob {} plaintext stage contains {written} bytes, expected {}",
2058 blob.locator().locator_hash(),
2059 blob.locator().plaintext_size()
2060 )));
2061 }
2062 Ok(plaintext)
2063 }
2064
2065 async fn delete_blob_object(
2066 &self,
2067 blob: &crate::blob::locator::StoredBlobRef,
2068 ) -> Result<(), StorageError> {
2069 let locator = blob.locator();
2070 let object = blob.object();
2071 self.validate_blob_locator_home(locator)?;
2072 let expected = locator.semantic_key();
2073 if object.slot().logical_key() != expected {
2074 return Err(StorageError::Parse(format!(
2075 "blob object {:?} does not match locator key {expected:?}",
2076 object.slot().logical_key()
2077 )));
2078 }
2079 self.delete_protocol_object(object).await?;
2080 Ok(())
2081 }
2082
2083 async fn put_wrapped_key(
2084 &self,
2085 owner_pubkey: &str,
2086 recipient_pubkey: &str,
2087 data: Vec<u8>,
2088 ) -> Result<(), StorageError> {
2089 crate::store_dir::validate_path_token(owner_pubkey)?;
2090 let key = format!("keys/{owner_pubkey}/{recipient_pubkey}{}", self.suffix());
2091 self.home
2096 .write(
2097 &key,
2098 BlobBody::from_bytes(data),
2099 &crate::storage::cloud::no_progress(),
2100 )
2101 .await?;
2102 Ok(())
2103 }
2104
2105 async fn get_wrapped_key(
2106 &self,
2107 owner_pubkey: &str,
2108 recipient_pubkey: &str,
2109 ) -> Result<Vec<u8>, StorageError> {
2110 crate::store_dir::validate_path_token(owner_pubkey)?;
2111 let key = format!("keys/{owner_pubkey}/{recipient_pubkey}{}", self.suffix());
2112 self.home.read(&key).await.map_err(StorageError::from)
2114 }
2115
2116 async fn delete_wrapped_key(
2117 &self,
2118 owner_pubkey: &str,
2119 recipient_pubkey: &str,
2120 ) -> Result<(), StorageError> {
2121 crate::store_dir::validate_path_token(owner_pubkey)?;
2122 let key = format!("keys/{owner_pubkey}/{recipient_pubkey}{}", self.suffix());
2123 self.home.delete(&key).await?;
2124 Ok(())
2125 }
2126}
2127
2128#[cfg(test)]
2129mod tests {
2130 use super::*;
2131 use crate::blob::locator::{BlobLocator, RemoteAudience};
2132 use crate::blob::BlobScope;
2133 use crate::storage::cloud::test_utils::InMemoryCloudHome;
2134 use crate::sync::storage::{BlobWriteAuthority, ExactObjectRef};
2135 use crate::sync::store_commit::{
2136 DeviceStreamAnchor, ObjectHash, StoreCommitAnchor, StoreCreationId,
2137 StoreDeviceRegistration, StoreDeviceRegistrationOrigin, StoreDeviceRegistrationRef,
2138 StoreRootRef,
2139 };
2140 use crate::sync::test_helpers::open_serial_test_db;
2141
2142 async fn blob_write_registration(
2143 storage: &CloudSyncStorage,
2144 label: &str,
2145 ) -> (StoreDeviceRegistrationRef, StoreDeviceRegistration) {
2146 let root_bytes = format!("{label} Store root").into_bytes();
2147 let root = StoreRootRef {
2148 store_root_id: ObjectHash::digest(format!("{label} root id").as_bytes()),
2149 store_root_hash: ObjectHash::digest(&root_bytes),
2150 object: ExactObjectRef::new(
2151 ObjectSlot::logical(format!("store-v1/store-protocol-root/{label}.json")).unwrap(),
2152 root_bytes.len() as u64,
2153 ObjectHash::digest(&root_bytes),
2154 ),
2155 };
2156 let anchor_slot = |stream: &str| {
2157 ObjectSlot::logical(format!(
2158 "store-v1/test-device-streams/{label}/{stream}.json"
2159 ))
2160 .unwrap()
2161 };
2162 let provider = SyncStorage::provider_binding(storage).await.unwrap().device;
2163 let registration = StoreDeviceRegistration::signed(
2164 root,
2165 StoreDeviceRegistrationOrigin::Founder {
2166 creation_id: StoreCreationId::from_nonce(label),
2167 },
2168 provider,
2169 StoreCommitAnchor::MergeConcurrent {
2170 announcements: DeviceStreamAnchor::StoreAnnouncements {
2171 first_slot: anchor_slot("announcements"),
2172 },
2173 },
2174 DeviceStreamAnchor::StoreAcknowledgements {
2175 first_slot: anchor_slot("acknowledgements"),
2176 },
2177 DeviceStreamAnchor::StoreSnapshots {
2178 first_slot: anchor_slot("snapshots"),
2179 },
2180 storage.user_keypair(),
2181 )
2182 .unwrap();
2183 let bytes = registration.to_bytes();
2184 let reference = StoreDeviceRegistrationRef::from_registration(
2185 ®istration,
2186 ExactObjectRef::new(
2187 ObjectSlot::logical(format!(
2188 "store-v1/devices/{}/registration.json",
2189 registration.device_id
2190 ))
2191 .unwrap(),
2192 bytes.len() as u64,
2193 ObjectHash::digest(&bytes),
2194 ),
2195 );
2196 (reference, registration)
2197 }
2198
2199 #[tokio::test]
2200 async fn circle_blob_spool_uses_the_supplied_audience_key() {
2201 let home = InMemoryCloudHome::new();
2202 let identity = UserKeypair::generate();
2203 let storage = CloudSyncStorage::new(
2204 Arc::new(home),
2205 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2206 BlobPathScheme::Hashed,
2207 "circle-blob-spool",
2208 identity,
2209 )
2210 .expect("test cloud storage supports exact slots");
2211 let (uploader, registration) = blob_write_registration(&storage, "circle-blob-spool").await;
2212 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2213 let circle_key = EncryptionService::from_key([9u8; 32]);
2214 let plaintext = b"circle audience blob";
2215 let locator = BlobLocator::opaque(
2216 "covers",
2217 "circle-cover",
2218 uploader.clone(),
2219 RemoteAudience::Circle(crate::sync::circle::CircleId::from_bytes([8; 16])),
2220 BlobScope::Master,
2221 circle_key.seal_key_fingerprint(),
2222 plaintext.len() as u64,
2223 crate::sync::store_commit::ObjectHash::digest(plaintext),
2224 )
2225 .expect("build Circle locator");
2226 let temp = tempfile::tempdir().expect("temporary blob directory");
2227 let source = temp.path().join("plaintext");
2228 let spool = temp.path().join("spool");
2229 tokio::fs::write(&source, plaintext)
2230 .await
2231 .expect("write plaintext source");
2232
2233 storage
2234 .seal_blob_to_spool(
2235 &locator,
2236 &authority,
2237 crate::sync::storage::BlobSpoolProtection::Opaque(circle_key.clone()),
2238 &source,
2239 &spool,
2240 )
2241 .await
2242 .expect("seal Circle blob spool");
2243
2244 let stored = tokio::fs::read(&spool).await.expect("read exact spool");
2245 let opened = CloudCipher::Encrypted(circle_key)
2246 .open_scoped(
2247 BlobScope::Master,
2248 stored,
2249 &cloud_aad_context("circle-blob-spool", &locator.semantic_key()),
2250 )
2251 .expect("open Circle blob with supplied key");
2252 assert_eq!(opened, plaintext);
2253 }
2254
2255 #[tokio::test]
2256 async fn blob_spool_rejects_a_key_that_differs_from_the_locator() {
2257 let home = InMemoryCloudHome::new();
2258 let identity = UserKeypair::generate();
2259 let storage = CloudSyncStorage::new(
2260 Arc::new(home),
2261 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2262 BlobPathScheme::Hashed,
2263 "blob-spool-key-mismatch",
2264 identity,
2265 )
2266 .expect("test cloud storage supports exact slots");
2267 let (uploader, registration) =
2268 blob_write_registration(&storage, "blob-spool-key-mismatch").await;
2269 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2270 let declared_key = EncryptionService::from_key([9u8; 32]);
2271 let plaintext = b"audience blob";
2272 let locator = BlobLocator::opaque(
2273 "covers",
2274 "mismatched-cover",
2275 uploader.clone(),
2276 RemoteAudience::Store,
2277 BlobScope::Master,
2278 declared_key.seal_key_fingerprint(),
2279 plaintext.len() as u64,
2280 crate::sync::store_commit::ObjectHash::digest(plaintext),
2281 )
2282 .expect("build locator");
2283 let temp = tempfile::tempdir().expect("temporary blob directory");
2284 let source = temp.path().join("plaintext");
2285 let spool = temp.path().join("spool");
2286 tokio::fs::write(&source, plaintext)
2287 .await
2288 .expect("write plaintext source");
2289
2290 assert!(matches!(
2291 storage
2292 .seal_blob_to_spool(
2293 &locator,
2294 &authority,
2295 crate::sync::storage::BlobSpoolProtection::Opaque(EncryptionService::from_key(
2296 [10u8; 32]
2297 ),),
2298 &source,
2299 &spool,
2300 )
2301 .await,
2302 Err(StorageError::InvalidContent(_))
2303 ));
2304 assert!(!spool.exists());
2305 }
2306
2307 #[tokio::test]
2308 async fn exact_blob_plaintext_is_published_only_after_both_verifications() {
2309 let home = InMemoryCloudHome::new();
2310 let identity = UserKeypair::generate();
2311 let storage = CloudSyncStorage::new(
2312 Arc::new(home),
2313 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2314 BlobPathScheme::Hashed,
2315 "verified-blob-download",
2316 identity,
2317 )
2318 .expect("test cloud storage supports exact slots");
2319 let (uploader, registration) =
2320 blob_write_registration(&storage, "verified-blob-download").await;
2321 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2322 let audience_key = EncryptionService::from_key([9u8; 32]);
2323 let plaintext: Vec<u8> = (0..150_000u32).map(|value| (value % 251) as u8).collect();
2324 let locator = BlobLocator::opaque(
2325 "audio",
2326 "verified-track",
2327 uploader.clone(),
2328 RemoteAudience::Store,
2329 BlobScope::Derived("album-a".to_string()),
2330 audience_key.seal_key_fingerprint(),
2331 plaintext.len() as u64,
2332 ObjectHash::digest(&plaintext),
2333 )
2334 .expect("build locator");
2335 let temp = tempfile::tempdir().expect("temporary blob directory");
2336 let source = temp.path().join("plaintext");
2337 let spool = temp.path().join("spool");
2338 let destination = temp.path().join("materialized");
2339 tokio::fs::write(&source, &plaintext)
2340 .await
2341 .expect("write plaintext source");
2342 storage
2343 .seal_blob_to_spool(
2344 &locator,
2345 &authority,
2346 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key.clone()),
2347 &source,
2348 &spool,
2349 )
2350 .await
2351 .expect("seal exact spool");
2352 let slot = storage
2353 .allocate_blob_slot(&locator, &authority)
2354 .await
2355 .expect("allocate exact blob slot");
2356 let blob = storage
2357 .prepare_blob_object(&locator, &authority, slot, &spool)
2358 .await
2359 .expect("prepare exact blob");
2360 storage
2361 .create_blob_object_from_file(
2362 &blob,
2363 &authority,
2364 &spool,
2365 &crate::storage::cloud::no_progress(),
2366 )
2367 .await
2368 .expect("create exact blob");
2369
2370 let staged = storage
2371 .stage_verified_blob_plaintext(
2372 &blob,
2373 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key),
2374 &destination,
2375 )
2376 .await
2377 .expect("stage verified plaintext");
2378 assert!(!destination.exists());
2379 assert_eq!(tokio::fs::read(staged.path()).await.unwrap(), plaintext);
2380 staged.commit().await.expect("publish verified plaintext");
2381 assert_eq!(tokio::fs::read(destination).await.unwrap(), plaintext);
2382 }
2383
2384 #[tokio::test]
2385 async fn stored_blob_corruption_never_creates_a_plaintext_stage() {
2386 let home = InMemoryCloudHome::new();
2387 let identity = UserKeypair::generate();
2388 let storage = CloudSyncStorage::new(
2389 Arc::new(home.clone()),
2390 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2391 BlobPathScheme::Hashed,
2392 "corrupt-blob-download",
2393 identity,
2394 )
2395 .expect("test cloud storage supports exact slots");
2396 let (uploader, registration) =
2397 blob_write_registration(&storage, "corrupt-blob-download").await;
2398 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2399 let audience_key = EncryptionService::from_key([9u8; 32]);
2400 let plaintext = b"signed blob plaintext";
2401 let locator = BlobLocator::opaque(
2402 "covers",
2403 "corrupt-cover",
2404 uploader.clone(),
2405 RemoteAudience::Store,
2406 BlobScope::Master,
2407 audience_key.seal_key_fingerprint(),
2408 plaintext.len() as u64,
2409 ObjectHash::digest(plaintext),
2410 )
2411 .expect("build locator");
2412 let temp = tempfile::tempdir().expect("temporary blob directory");
2413 let source = temp.path().join("plaintext");
2414 let spool = temp.path().join("spool");
2415 let destination = temp.path().join("materialized");
2416 tokio::fs::write(&source, plaintext)
2417 .await
2418 .expect("write plaintext source");
2419 storage
2420 .seal_blob_to_spool(
2421 &locator,
2422 &authority,
2423 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key.clone()),
2424 &source,
2425 &spool,
2426 )
2427 .await
2428 .expect("seal exact spool");
2429 let slot = storage
2430 .allocate_blob_slot(&locator, &authority)
2431 .await
2432 .unwrap();
2433 let blob = storage
2434 .prepare_blob_object(&locator, &authority, slot, &spool)
2435 .await
2436 .unwrap();
2437 storage
2438 .create_blob_object_from_file(
2439 &blob,
2440 &authority,
2441 &spool,
2442 &crate::storage::cloud::no_progress(),
2443 )
2444 .await
2445 .unwrap();
2446 home.replace_exact_object(blob.object().slot(), b"corrupt".to_vec());
2447
2448 assert!(matches!(
2449 storage
2450 .stage_verified_blob_plaintext(
2451 &blob,
2452 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key),
2453 &destination,
2454 )
2455 .await,
2456 Err(StorageError::InvalidContent(_))
2457 ));
2458 assert!(!destination.exists());
2459 }
2460
2461 #[tokio::test]
2462 async fn reserved_protocol_slot_read_returns_its_completed_exact_reference() {
2463 let home = InMemoryCloudHome::new();
2464 let storage = CloudSyncStorage::new(
2465 Arc::new(home),
2466 CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2467 BlobPathScheme::Hashed,
2468 "reserved-slot-read",
2469 UserKeypair::generate(),
2470 )
2471 .expect("test cloud storage supports exact slots");
2472 let root = crate::sync::store_commit::ObjectHash::digest(b"reserved slot root");
2473 let semantic = "store-v1/heads/device-a/1".to_string();
2474 let context = crate::sync::storage::ProtocolObjectContext::store(
2475 root,
2476 crate::sync::storage::ProtocolObjectDomain::StoreHead,
2477 );
2478 let slot = storage
2479 .allocate_protocol_slot(&context, &semantic, ".json")
2480 .await
2481 .expect("reserve successor slot");
2482 let prepared = storage
2483 .prepare_protocol_object(
2484 &context,
2485 slot.clone(),
2486 &semantic,
2487 b"signed successor bytes".to_vec(),
2488 )
2489 .expect("prepare successor bytes");
2490 storage
2491 .create_protocol_object(&prepared)
2492 .await
2493 .expect("create successor");
2494
2495 let (opened, completed) = storage
2496 .read_protocol_slot(&context, &slot, &semantic)
2497 .await
2498 .expect("read reserved successor slot");
2499
2500 assert_eq!(opened, b"signed successor bytes");
2501 assert_eq!(&completed, prepared.reference());
2502 }
2503
2504 #[tokio::test]
2505 async fn reserved_protocol_slot_rejects_a_mismatched_semantic_path_before_read() {
2506 let home = InMemoryCloudHome::new();
2507 let storage = CloudSyncStorage::new(
2508 Arc::new(home),
2509 CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2510 BlobPathScheme::Hashed,
2511 "reserved-slot-relocation",
2512 UserKeypair::generate(),
2513 )
2514 .expect("test cloud storage supports exact slots");
2515 let root = crate::sync::store_commit::ObjectHash::digest(b"reserved slot root");
2516 let context = crate::sync::storage::ProtocolObjectContext::store(
2517 root,
2518 crate::sync::storage::ProtocolObjectDomain::StoreHead,
2519 );
2520 let original = "store-v1/heads/device-a/1".to_string();
2521 let relocated = "store-v1/heads/device-b/1".to_string();
2522 let slot = storage
2523 .allocate_protocol_slot(&context, &original, ".json")
2524 .await
2525 .expect("reserve successor slot");
2526
2527 assert!(matches!(
2528 storage
2529 .read_protocol_slot(&context, &slot, &relocated)
2530 .await,
2531 Err(StorageError::Parse(_))
2532 ));
2533 }
2534
2535 #[tokio::test]
2536 async fn protocol_object_read_rejects_root_domain_and_path_substitution() {
2537 let home = InMemoryCloudHome::new();
2538 let storage = CloudSyncStorage::new(
2539 Arc::new(home),
2540 CloudCipher::Encrypted(EncryptionService::from_key([8u8; 32])),
2541 BlobPathScheme::Hashed,
2542 "aad-store",
2543 UserKeypair::generate(),
2544 )
2545 .expect("test cloud storage supports immutable copies");
2546 let root = crate::sync::store_commit::ObjectHash::digest(b"root-a");
2547 let other_root = crate::sync::store_commit::ObjectHash::digest(b"root-b");
2548 let commit_hash = crate::sync::store_commit::ObjectHash::digest(b"commit");
2549 let family = crate::sync::store_commit::CandidateFamilyId::from_hash(
2550 crate::sync::store_commit::ObjectHash::digest(b"cloud test family"),
2551 );
2552 let semantic =
2553 crate::sync::store_commit::commit_semantic_prefix(family, "device", 1, commit_hash);
2554 let context = crate::sync::storage::ProtocolObjectContext::store(
2555 root,
2556 crate::sync::storage::ProtocolObjectDomain::StoreCommit,
2557 );
2558 let slot = storage
2559 .allocate_protocol_slot(&context, &semantic, ".json")
2560 .await
2561 .expect("allocate root-bound Store commit slot");
2562 let prepared = storage
2563 .prepare_protocol_object(&context, slot, &semantic, b"signed commit".to_vec())
2564 .expect("prepare root-bound Store commit");
2565 storage
2566 .create_protocol_object(&prepared)
2567 .await
2568 .expect("create root-bound Store commit");
2569 let object = prepared.reference().clone();
2570
2571 assert_eq!(
2572 storage
2573 .read_protocol_object(&context, &object, &semantic)
2574 .await
2575 .expect("read with the exact authenticated context"),
2576 b"signed commit",
2577 );
2578 let other_root_context = crate::sync::storage::ProtocolObjectContext::store(
2579 other_root,
2580 crate::sync::storage::ProtocolObjectDomain::StoreCommit,
2581 );
2582 assert!(matches!(
2583 storage
2584 .read_protocol_object(&other_root_context, &object, &semantic)
2585 .await,
2586 Err(crate::sync::storage::StorageError::Decryption(_))
2587 ));
2588
2589 let other_semantic =
2590 crate::sync::store_commit::commit_semantic_prefix(family, "device", 2, commit_hash);
2591 assert!(matches!(
2592 storage
2593 .read_protocol_object(&context, &object, &other_semantic)
2594 .await,
2595 Err(crate::sync::storage::StorageError::Parse(_))
2596 ));
2597
2598 let other_domain_context = crate::sync::storage::ProtocolObjectContext::store(
2599 root,
2600 crate::sync::storage::ProtocolObjectDomain::StoreHead,
2601 );
2602 assert!(matches!(
2603 storage
2604 .read_protocol_object(&other_domain_context, &object, &semantic)
2605 .await,
2606 Err(crate::sync::storage::StorageError::Parse(_))
2607 ));
2608 }
2609
2610 #[tokio::test]
2611 async fn coordination_probe_observes_one_create_and_replace_winner() {
2612 let home = InMemoryCloudHome::new();
2613 let first = CloudSyncStorage::new(
2614 Arc::new(home.clone()),
2615 CloudCipher::Plaintext,
2616 BlobPathScheme::Plain,
2617 "coordination-probe",
2618 UserKeypair::generate(),
2619 )
2620 .expect("test cloud storage supports immutable copies")
2621 .with_test_serial_coordination(Arc::new(home.clone()));
2622 let second = CloudSyncStorage::new(
2623 Arc::new(home.clone()),
2624 CloudCipher::Plaintext,
2625 BlobPathScheme::Plain,
2626 "coordination-probe",
2627 UserKeypair::generate(),
2628 )
2629 .expect("test cloud storage supports immutable copies")
2630 .with_test_serial_coordination(Arc::new(home.clone()));
2631
2632 let db = open_serial_test_db();
2633 let binding = SyncStorage::provider_binding(&first).await.unwrap();
2634 let probe_id = crate::sync::provider::ProviderProbeId::from_bytes([31; 32]);
2635 crate::sync::provider::probe_serial_coordination_receipt(
2636 first.serial_coordination().unwrap(),
2637 second.serial_coordination().unwrap(),
2638 &db,
2639 probe_id,
2640 &binding,
2641 )
2642 .await
2643 .expect("coordination race probe");
2644
2645 let key = format!(
2646 "__coven_probe__/serial/{}",
2647 hex::encode(probe_id.as_bytes())
2648 );
2649 assert!(home.get(&key).is_none());
2650 assert_eq!(home.deletes_seen(), vec![key]);
2651 }
2652
2653 #[test]
2654 fn storage_without_provider_capability_refuses_serial_coordination() {
2655 let storage = CloudSyncStorage::new(
2656 Arc::new(InMemoryCloudHome::new()),
2657 CloudCipher::Plaintext,
2658 BlobPathScheme::Plain,
2659 "unsupported-coordination",
2660 UserKeypair::generate(),
2661 )
2662 .expect("test cloud storage supports immutable copies");
2663
2664 assert!(matches!(
2665 storage.serial_coordination(),
2666 Err(CoordinationError::Unavailable(_))
2667 ));
2668 }
2669
2670 #[tokio::test]
2671 async fn coordination_probe_cleanup_failure_reports_remaining_key() {
2672 let home = InMemoryCloudHome::new();
2673 home.fail_coordination_probe_cleanup();
2674 let storage = CloudSyncStorage::new(
2675 Arc::new(home.clone()),
2676 CloudCipher::Plaintext,
2677 BlobPathScheme::Plain,
2678 "coordination-cleanup",
2679 UserKeypair::generate(),
2680 )
2681 .expect("test cloud storage supports immutable copies")
2682 .with_test_serial_coordination(Arc::new(home.clone()));
2683 let db = open_serial_test_db();
2684 let binding = SyncStorage::provider_binding(&storage).await.unwrap();
2685 let probe_id = crate::sync::provider::ProviderProbeId::from_bytes([37; 32]);
2686 let key = format!(
2687 "__coven_probe__/serial/{}",
2688 hex::encode(probe_id.as_bytes())
2689 );
2690
2691 let error = crate::sync::provider::probe_serial_coordination_receipt(
2692 storage.serial_coordination().unwrap(),
2693 storage.serial_coordination().unwrap(),
2694 &db,
2695 probe_id,
2696 &binding,
2697 )
2698 .await
2699 .expect_err("cleanup failure must abort the probe");
2700
2701 assert!(matches!(
2702 error,
2703 crate::sync::provider::ProviderProbeError::Storage(_)
2704 ));
2705 assert!(home.get(&key).is_some());
2706 }
2707}