1use async_trait::async_trait;
12use std::path::Path;
13use std::sync::{Arc, RwLock};
14use tokio::sync::OnceCell;
15
16use super::storage::{
17 ExactObjectRef, PreparedExactObject, ProtocolObjectContext, ProtocolObjectProtection,
18 ResolvedProviderBinding, StorageError, SyncStorage,
19};
20use crate::encryption::{chunked_encrypted_len, EncryptionError, EncryptionService};
21use crate::keys::UserKeypair;
22use crate::storage::cloud::{
23 BlobBody, CloudFileReadError, CloudHome, ExactSlotStorage, ObjectSlot,
24};
25#[cfg(test)]
26use crate::sync::storage::ProtocolObjectDomain;
27use crate::sync::store_commit::ObjectHash;
28
29const KEY_TAG_MAGIC: &[u8; 4] = b"CKF1";
34const KEY_FINGERPRINT_LEN: usize = 32;
35const KEY_TAG_LEN: usize = KEY_TAG_MAGIC.len() + KEY_FINGERPRINT_LEN;
36
37#[derive(Clone)]
41pub enum CloudCipher {
42 Encrypted(EncryptionService),
43 Plaintext,
44}
45
46pub struct CloudCipherState {
50 mode: CloudCipherMode,
51}
52
53pub trait CloudCipherAccess: Send + Sync {
57 fn snapshot(&self) -> CloudCipher;
58 fn merge_key_rotation(
59 &self,
60 new_encryption: &EncryptionService,
61 custody: &dyn crate::keys::MasterKeyCustody,
62 ) -> Result<Option<String>, crate::keys::KeyError>;
63}
64
65enum CloudCipherMode {
66 Encrypted(RwLock<EncryptionService>),
67 Plaintext,
68}
69
70impl CloudCipherState {
71 pub fn new(cipher: CloudCipher) -> Self {
72 let mode = match cipher {
73 CloudCipher::Encrypted(encryption) => {
74 CloudCipherMode::Encrypted(RwLock::new(encryption))
75 }
76 CloudCipher::Plaintext => CloudCipherMode::Plaintext,
77 };
78 Self { mode }
79 }
80
81 pub fn is_plaintext(&self) -> bool {
82 matches!(self.mode, CloudCipherMode::Plaintext)
83 }
84
85 pub fn encryption(&self) -> Option<EncryptionService> {
86 match &self.mode {
87 CloudCipherMode::Encrypted(encryption) => Some(encryption.read().unwrap().clone()),
88 CloudCipherMode::Plaintext => None,
89 }
90 }
91
92 pub(crate) fn snapshot(&self) -> CloudCipher {
93 match &self.mode {
94 CloudCipherMode::Encrypted(encryption) => {
95 CloudCipher::Encrypted(encryption.read().unwrap().clone())
96 }
97 CloudCipherMode::Plaintext => CloudCipher::Plaintext,
98 }
99 }
100
101 pub(crate) fn merge_key_rotation(
102 &self,
103 new_encryption: &EncryptionService,
104 custody: &dyn crate::keys::MasterKeyCustody,
105 ) -> Result<Option<String>, crate::keys::KeyError> {
106 let CloudCipherMode::Encrypted(live) = &self.mode else {
107 return Err(crate::keys::KeyError::Crypto(
108 "cannot rotate the key of a plaintext cloud home".to_string(),
109 ));
110 };
111 let mut live = live.write().unwrap();
112 let merged = live
113 .merged_with(new_encryption)
114 .map_err(|error| crate::keys::KeyError::Crypto(error.to_string()))?;
115 if merged.key_count() == live.key_count() {
116 return Ok(None);
117 }
118 custody.persist(&crate::encryption::MasterKeyring::from(merged.clone()))?;
119 *live = merged;
120 Ok(Some(live.fingerprint()))
121 }
122}
123
124impl CloudCipherAccess for CloudCipherState {
125 fn snapshot(&self) -> CloudCipher {
126 CloudCipherState::snapshot(self)
127 }
128
129 fn merge_key_rotation(
130 &self,
131 new_encryption: &EncryptionService,
132 custody: &dyn crate::keys::MasterKeyCustody,
133 ) -> Result<Option<String>, crate::keys::KeyError> {
134 CloudCipherState::merge_key_rotation(self, new_encryption, custody)
135 }
136}
137
138impl CloudCipherAccess for Arc<CloudCipherState> {
139 fn snapshot(&self) -> CloudCipher {
140 self.as_ref().snapshot()
141 }
142
143 fn merge_key_rotation(
144 &self,
145 new_encryption: &EncryptionService,
146 custody: &dyn crate::keys::MasterKeyCustody,
147 ) -> Result<Option<String>, crate::keys::KeyError> {
148 self.as_ref().merge_key_rotation(new_encryption, custody)
149 }
150}
151
152#[cfg(any(test, feature = "test-utils"))]
153impl CloudCipherAccess for RwLock<CloudCipher> {
154 fn snapshot(&self) -> CloudCipher {
155 self.read().unwrap().clone()
156 }
157
158 fn merge_key_rotation(
159 &self,
160 new_encryption: &EncryptionService,
161 custody: &dyn crate::keys::MasterKeyCustody,
162 ) -> Result<Option<String>, crate::keys::KeyError> {
163 let mut cipher = self.write().unwrap();
164 let CloudCipher::Encrypted(live) = &mut *cipher else {
165 return Err(crate::keys::KeyError::Crypto(
166 "cannot rotate the key of a plaintext cloud home".to_string(),
167 ));
168 };
169 let merged = live
170 .merged_with(new_encryption)
171 .map_err(|error| crate::keys::KeyError::Crypto(error.to_string()))?;
172 if merged.key_count() == live.key_count() {
173 return Ok(None);
174 }
175 custody.persist(&crate::encryption::MasterKeyring::from(merged.clone()))?;
176 *live = merged;
177 Ok(Some(live.fingerprint()))
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
186#[error(
187 "store-key rotation is pending ({state:?}) while this device is sealing under generation \
188 {live_generation}; refusing to seal for the cloud until the pending state is completed"
189)]
190pub struct RotationPending {
191 pub state: RotationPendingState,
192 pub live_generation: u64,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum RotationPendingState {
197 Candidate {
198 generation: u64,
199 },
200 LocalCommitted {
201 generation: u64,
202 },
203 PeerCommitted {
204 generation: u64,
205 },
206 CandidateAndPeer {
207 candidate_generation: u64,
208 peer_generation: u64,
209 },
210 LocalCommittedAndPeer {
211 local_generation: u64,
212 peer_generation: u64,
213 },
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
228#[serde(deny_unknown_fields)]
229pub(crate) struct RotationGate {
230 candidate: Option<RotationCandidateGate>,
231 local_committed: Option<RotationLocalCommittedGate>,
232 peer_committed_generation: Option<u64>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
236#[serde(deny_unknown_fields)]
237struct RotationCandidateGate {
238 generation: u64,
239 mutation: crate::sync::store_commit::ObjectHash,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
243#[serde(deny_unknown_fields)]
244struct RotationLocalCommittedGate {
245 generation: u64,
246 mutation: crate::sync::store_commit::ObjectHash,
247}
248
249impl RotationGate {
250 pub(crate) fn empty() -> Self {
251 Self {
252 candidate: None,
253 local_committed: None,
254 peer_committed_generation: None,
255 }
256 }
257
258 pub(crate) fn generation(&self) -> Option<u64> {
259 self.candidate
260 .as_ref()
261 .map(|gate| gate.generation)
262 .into_iter()
263 .chain(self.local_committed.as_ref().map(|gate| gate.generation))
264 .chain(self.peer_committed_generation)
265 .max()
266 }
267
268 fn pending_state(&self) -> Result<RotationPendingState, String> {
269 self.validate()?;
270 match (
271 self.candidate.as_ref(),
272 self.local_committed.as_ref(),
273 self.peer_committed_generation,
274 ) {
275 (Some(candidate), None, None) => Ok(RotationPendingState::Candidate {
276 generation: candidate.generation,
277 }),
278 (None, Some(local), None) => Ok(RotationPendingState::LocalCommitted {
279 generation: local.generation,
280 }),
281 (None, None, Some(generation)) => {
282 Ok(RotationPendingState::PeerCommitted { generation })
283 }
284 (Some(candidate), None, Some(peer_generation)) => {
285 Ok(RotationPendingState::CandidateAndPeer {
286 candidate_generation: candidate.generation,
287 peer_generation,
288 })
289 }
290 (None, Some(local), Some(peer_generation)) => {
291 Ok(RotationPendingState::LocalCommittedAndPeer {
292 local_generation: local.generation,
293 peer_generation,
294 })
295 }
296 _ => Err("rotation gate has an impossible combination of states".to_string()),
297 }
298 }
299
300 pub(crate) fn validate(&self) -> Result<(), String> {
301 if self.generation().is_none()
302 || self
303 .candidate
304 .as_ref()
305 .is_some_and(|gate| gate.generation == 0)
306 || self
307 .local_committed
308 .as_ref()
309 .is_some_and(|gate| gate.generation == 0)
310 || self.peer_committed_generation == Some(0)
311 || (self.candidate.is_some() && self.local_committed.is_some())
312 {
313 return Err("rotation gate is empty or names generation zero".to_string());
314 }
315 Ok(())
316 }
317
318 pub(crate) fn with_candidate(
319 mut self,
320 generation: u64,
321 mutation: crate::sync::store_commit::ObjectHash,
322 ) -> Result<Self, String> {
323 let candidate = RotationCandidateGate {
324 generation,
325 mutation,
326 };
327 if generation == 0 {
328 return Err("rotation candidate names generation zero".to_string());
329 }
330 if self.local_committed.is_some() {
331 return Err("a committed local rotation already owns the gate".to_string());
332 }
333 match &self.candidate {
334 Some(existing) if existing != &candidate => {
335 return Err("another rotation candidate already owns the gate".to_string())
336 }
337 Some(_) => {}
338 None => self.candidate = Some(candidate),
339 }
340 self.validate()?;
341 Ok(self)
342 }
343
344 pub(crate) fn commit_candidate(
345 mut self,
346 generation: u64,
347 mutation: crate::sync::store_commit::ObjectHash,
348 ) -> Result<Self, String> {
349 if self.candidate.is_none() {
350 match &self.local_committed {
351 Some(committed)
352 if committed.generation == generation && committed.mutation == mutation =>
353 {
354 return Ok(self);
355 }
356 _ => {}
357 }
358 }
359 if self.candidate
360 != Some(RotationCandidateGate {
361 generation,
362 mutation,
363 })
364 {
365 return Err("rotation commit does not own the pending candidate gate".to_string());
366 }
367 self.candidate = None;
368 let committed = RotationLocalCommittedGate {
369 generation,
370 mutation,
371 };
372 self.local_committed = Some(committed);
373 self.validate()?;
374 Ok(self)
375 }
376
377 pub(crate) fn merge_peer_commit(mut self, generation: u64) -> Result<Self, String> {
378 if generation == 0 {
379 return Err("committed rotation names generation zero".to_string());
380 }
381 if self
382 .peer_committed_generation
383 .is_none_or(|existing| generation > existing)
384 {
385 self.peer_committed_generation = Some(generation);
386 }
387 self.validate()?;
388 Ok(self)
389 }
390
391 pub(crate) fn remove_candidate(
392 mut self,
393 generation: u64,
394 mutation: crate::sync::store_commit::ObjectHash,
395 ) -> Result<Option<Self>, String> {
396 if self.candidate
397 != Some(RotationCandidateGate {
398 generation,
399 mutation,
400 })
401 {
402 return Err("rotation loss does not own the pending candidate gate".to_string());
403 }
404 self.candidate = None;
405 if self.local_committed.is_none() && self.peer_committed_generation.is_none() {
406 return Ok(None);
407 }
408 self.validate()?;
409 Ok(Some(self))
410 }
411
412 pub(crate) fn replace_candidate_mutation(
413 mut self,
414 generation: u64,
415 previous: crate::sync::store_commit::ObjectHash,
416 replacement: crate::sync::store_commit::ObjectHash,
417 ) -> Result<Self, String> {
418 if self.candidate
419 != Some(RotationCandidateGate {
420 generation,
421 mutation: previous,
422 })
423 {
424 return Err("rotation candidate replacement lost its exact owner".to_string());
425 }
426 self.candidate = Some(RotationCandidateGate {
427 generation,
428 mutation: replacement,
429 });
430 self.validate()?;
431 Ok(self)
432 }
433
434 pub(crate) fn complete_local_adoption(
435 mut self,
436 generation: u64,
437 mutation: crate::sync::store_commit::ObjectHash,
438 ) -> Result<Option<Self>, String> {
439 if self.candidate.is_some() {
440 return Err("rotation adoption cannot close while a candidate is pending".to_string());
441 }
442 match &self.local_committed {
443 Some(committed)
444 if committed.generation == generation && committed.mutation == mutation =>
445 {
446 self.local_committed = None;
447 }
448 _ => return Err("rotation adoption does not own the committed gate".to_string()),
449 }
450 if self
451 .peer_committed_generation
452 .is_some_and(|peer| peer <= generation)
453 {
454 self.peer_committed_generation = None;
455 }
456 if self.peer_committed_generation.is_none() {
457 return Ok(None);
458 }
459 self.validate()?;
460 Ok(Some(self))
461 }
462
463 pub(crate) fn complete_peer_adoption(
464 mut self,
465 adopted_generation: u64,
466 ) -> Result<Option<Self>, String> {
467 if adopted_generation == 0 {
468 return Err("adopted rotation names generation zero".to_string());
469 }
470 if self
471 .peer_committed_generation
472 .is_some_and(|generation| generation <= adopted_generation)
473 {
474 self.peer_committed_generation = None;
475 }
476 if self.candidate.is_none()
477 && self.local_committed.is_none()
478 && self.peer_committed_generation.is_none()
479 {
480 return Ok(None);
481 }
482 self.validate()?;
483 Ok(Some(self))
484 }
485}
486
487pub struct PendingRotation(std::sync::RwLock<Option<RotationGate>>);
488
489impl Default for PendingRotation {
490 fn default() -> Self {
491 Self(std::sync::RwLock::new(None))
492 }
493}
494
495impl PendingRotation {
496 pub fn none() -> Self {
497 Self::default()
498 }
499
500 #[cfg(any(test, feature = "test-utils"))]
506 pub fn mark_committed(&self, generation: u64) -> Result<(), String> {
507 let mut recorded = self.0.write().unwrap();
508 let gate = recorded.take().unwrap_or_else(RotationGate::empty);
509 match gate.clone().merge_peer_commit(generation) {
510 Ok(next) => {
511 *recorded = Some(next);
512 Ok(())
513 }
514 Err(error) => {
515 *recorded = Some(gate);
516 Err(error)
517 }
518 }
519 }
520
521 pub(crate) fn mark_candidate(
522 &self,
523 generation: u64,
524 mutation: crate::sync::store_commit::ObjectHash,
525 ) -> Result<(), String> {
526 let mut recorded = self.0.write().unwrap();
527 let gate = recorded.take().unwrap_or_else(RotationGate::empty);
528 match gate.clone().with_candidate(generation, mutation) {
529 Ok(next) => {
530 *recorded = Some(next);
531 Ok(())
532 }
533 Err(error) => {
534 *recorded = Some(gate);
535 Err(error)
536 }
537 }
538 }
539
540 pub(crate) fn mark_committed_mutation(
541 &self,
542 generation: u64,
543 mutation: crate::sync::store_commit::ObjectHash,
544 ) -> Result<(), String> {
545 let mut recorded = self.0.write().unwrap();
546 let gate = recorded.take().unwrap_or_else(RotationGate::empty);
547 match gate.clone().commit_candidate(generation, mutation) {
548 Ok(next) => {
549 *recorded = Some(next);
550 Ok(())
551 }
552 Err(error) => {
553 *recorded = Some(gate);
554 Err(error)
555 }
556 }
557 }
558
559 pub(crate) fn remove_candidate(
560 &self,
561 generation: u64,
562 mutation: crate::sync::store_commit::ObjectHash,
563 ) -> Result<(), String> {
564 let mut recorded = self.0.write().unwrap();
565 let gate = recorded.take().ok_or_else(|| {
566 "rotation candidate gate is absent during proven nonactivation".to_string()
567 })?;
568 match gate.clone().remove_candidate(generation, mutation) {
569 Ok(next) => {
570 *recorded = next;
571 Ok(())
572 }
573 Err(error) => {
574 *recorded = Some(gate);
575 Err(error)
576 }
577 }
578 }
579
580 pub(crate) fn replace_candidate_mutation(
581 &self,
582 generation: u64,
583 previous: crate::sync::store_commit::ObjectHash,
584 replacement: crate::sync::store_commit::ObjectHash,
585 ) -> Result<(), String> {
586 let mut recorded = self.0.write().unwrap();
587 let gate = recorded.take().ok_or_else(|| {
588 "rotation candidate gate is absent during candidate replacement".to_string()
589 })?;
590 match gate
591 .clone()
592 .replace_candidate_mutation(generation, previous, replacement)
593 {
594 Ok(next) => {
595 *recorded = Some(next);
596 Ok(())
597 }
598 Err(error) => {
599 *recorded = Some(gate);
600 Err(error)
601 }
602 }
603 }
604
605 #[cfg(any(test, feature = "test-utils"))]
608 pub fn pending_generation(&self) -> Option<u64> {
609 self.0
610 .read()
611 .unwrap()
612 .as_ref()
613 .and_then(RotationGate::generation)
614 }
615
616 pub(crate) fn gate(&self) -> Option<RotationGate> {
617 self.0.read().unwrap().clone()
618 }
619
620 pub(crate) fn install_durable_gate(&self, gate: Option<RotationGate>) -> Result<(), String> {
621 if let Some(gate) = &gate {
622 gate.validate()?;
623 }
624 *self.0.write().unwrap() = gate;
625 Ok(())
626 }
627
628 pub fn check(&self, cipher: &CloudCipher) -> Result<(), RotationPending> {
632 let live_generation = match cipher {
633 CloudCipher::Encrypted(enc) => enc.current_generation(),
634 CloudCipher::Plaintext => return Ok(()),
635 };
636 if let Some(gate) = self.gate() {
637 let state = gate
638 .pending_state()
639 .expect("in-memory rotation gate must be validated before installation");
640 return Err(RotationPending {
641 state,
642 live_generation,
643 });
644 }
645 Ok(())
646 }
647}
648
649pub const ROTATION_GATE_STATE_KEY: &str = "rotation_gate";
653
654pub async fn restore_pending_rotation(
657 db: &crate::database::Database,
658 pending_rotation: &PendingRotation,
659) -> Result<(), crate::database::DbError> {
660 if let Some(value) = db.get_protocol_state(ROTATION_GATE_STATE_KEY).await? {
661 let gate: RotationGate = serde_json::from_str(&value).map_err(|error| {
662 crate::database::DbError::Message(format!(
663 "persisted rotation gate is invalid: {error}"
664 ))
665 })?;
666 pending_rotation
667 .install_durable_gate(Some(gate))
668 .map_err(crate::database::DbError::Message)?;
669 }
670 Ok(())
671}
672
673#[derive(Clone, Copy)]
677pub enum BlobPathScheme {
678 Hashed,
680 Plain,
684}
685
686impl BlobPathScheme {
687 pub fn for_storage(storage: crate::config::HomeStorage) -> Self {
690 if storage.is_opaque() {
691 BlobPathScheme::Hashed
692 } else {
693 BlobPathScheme::Plain
694 }
695 }
696}
697
698impl CloudCipher {
699 pub fn for_storage(
712 storage: crate::config::HomeStorage,
713 encryption: Option<EncryptionService>,
714 ) -> Option<Self> {
715 if storage.is_opaque() {
716 encryption.map(CloudCipher::Encrypted)
717 } else {
718 Some(CloudCipher::Plaintext)
719 }
720 }
721
722 pub fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
727 self.seal_scoped(crate::blob::BlobScope::Master, plaintext, aad_context)
731 }
732
733 pub fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError> {
735 self.open_scoped(crate::blob::BlobScope::Master, stored, aad_context)
736 }
737
738 pub(crate) fn seal_scoped(
742 &self,
743 scope: crate::blob::BlobScope,
744 plaintext: Vec<u8>,
745 aad_context: &[u8],
746 ) -> Vec<u8> {
747 match self {
748 CloudCipher::Encrypted(e) => seal_scoped_encrypted(scope, e, &plaintext, aad_context),
749 CloudCipher::Plaintext => plaintext,
750 }
751 }
752
753 pub(crate) fn open_scoped(
755 &self,
756 scope: crate::blob::BlobScope,
757 stored: Vec<u8>,
758 aad_context: &[u8],
759 ) -> Result<Vec<u8>, EncryptionError> {
760 match self {
761 CloudCipher::Encrypted(e) => open_scoped_encrypted(scope, e, &stored, aad_context),
762 CloudCipher::Plaintext => Ok(stored),
763 }
764 }
765
766 pub fn suffix(&self) -> &'static str {
770 match self {
771 CloudCipher::Encrypted(_) => ".enc",
772 CloudCipher::Plaintext => "",
773 }
774 }
775
776 pub fn is_plaintext(&self) -> bool {
778 matches!(self, CloudCipher::Plaintext)
779 }
780
781 pub fn body_len(&self, plaintext_len: u64) -> u64 {
785 match self {
786 CloudCipher::Encrypted(_) => chunked_encrypted_len(plaintext_len) + KEY_TAG_LEN as u64,
787 CloudCipher::Plaintext => plaintext_len,
788 }
789 }
790
791 pub(crate) async fn open_body(
797 &self,
798 scope: crate::blob::BlobScope,
799 file_path: &std::path::Path,
800 aad_context: &[u8],
801 ) -> Result<BlobBody, String> {
802 let plaintext_len = crate::local_blob::file_len(file_path).await?;
803 let reader = crate::local_blob::open_reader(file_path).await?;
804 let (sealer, prefix) = match self {
805 CloudCipher::Encrypted(e) => {
806 let (encryption, prefix) = sealing_encryption_for_scope(scope, e);
807 (Some(encryption.sealer(plaintext_len, aad_context)), prefix)
808 }
809 CloudCipher::Plaintext => (None, Vec::new()),
810 };
811 Ok(BlobBody::from_file_with_prefix(
812 self.body_len(plaintext_len),
813 reader,
814 sealer,
815 prefix,
816 ))
817 }
818}
819
820pub struct CloudSyncStorage {
823 home: Arc<dyn CloudHome>,
828 exact: Arc<dyn ExactSlotStorage>,
829 exact_probe_peer: Arc<dyn ExactSlotStorage>,
830 cipher: Arc<CloudCipherState>,
831 pending_rotation: Arc<PendingRotation>,
836 blob_paths: BlobPathScheme,
839 store_id: String,
840 keypair: UserKeypair,
845}
846
847impl CloudSyncStorage {
848 pub fn new(
849 home: Arc<dyn CloudHome>,
850 cipher: CloudCipher,
851 blob_paths: BlobPathScheme,
852 store_id: impl Into<String>,
853 keypair: UserKeypair,
854 ) -> Result<Self, crate::storage::cloud::CloudHomeError> {
855 let exact = home.clone().exact_slot_storage().ok_or_else(|| {
856 crate::storage::cloud::CloudHomeError::Configuration(
857 "CloudSyncStorage requires exact-slot storage".to_string(),
858 )
859 })?;
860 let exact_probe_peer = home.clone().exact_slot_storage().ok_or_else(|| {
861 crate::storage::cloud::CloudHomeError::Configuration(
862 "CloudSyncStorage requires a second exact-slot probe client".to_string(),
863 )
864 })?;
865 Ok(CloudSyncStorage {
866 home,
867 exact,
868 exact_probe_peer,
869 cipher: Arc::new(CloudCipherState::new(cipher)),
870 pending_rotation: Arc::new(PendingRotation::none()),
871 blob_paths,
872 store_id: store_id.into(),
873 keypair,
874 })
875 }
876
877 pub(crate) fn exact_slot_probe_clients(
878 &self,
879 ) -> (&dyn ExactSlotStorage, &dyn ExactSlotStorage) {
880 (self.exact.as_ref(), self.exact_probe_peer.as_ref())
881 }
882
883 pub(crate) fn exact_slot_storage(&self) -> &dyn ExactSlotStorage {
884 self.exact.as_ref()
885 }
886
887 pub(crate) fn blob_path_scheme(&self) -> BlobPathScheme {
888 self.blob_paths
889 }
890
891 pub(crate) fn store_id(&self) -> &str {
892 &self.store_id
893 }
894
895 fn validate_blob_locator_home(
896 &self,
897 locator: &crate::blob::locator::BlobLocator,
898 ) -> Result<(), StorageError> {
899 let valid = matches!(
900 (locator, self.blob_paths, self.cipher.is_plaintext()),
901 (
902 crate::blob::locator::BlobLocator::Opaque { .. },
903 BlobPathScheme::Hashed,
904 false
905 ) | (
906 crate::blob::locator::BlobLocator::Browsable { .. },
907 BlobPathScheme::Plain,
908 true
909 )
910 );
911 if !valid {
912 return Err(StorageError::InvalidContent(
913 "blob locator protection does not match the cloud home's fixed storage mode"
914 .to_string(),
915 ));
916 }
917 Ok(())
918 }
919
920 async fn validate_blob_append_authority(
921 &self,
922 locator: &crate::blob::locator::BlobLocator,
923 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
924 ) -> Result<(), StorageError> {
925 authority
926 .reference
927 .verify_registration(authority.registration)
928 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
929 if locator.uploader() != authority.reference {
930 return Err(StorageError::InvalidContent(format!(
931 "blob locator uploader {:?} differs from its exact write authority",
932 locator.uploader()
933 )));
934 }
935 if authority.registration.author_pubkey != hex::encode(self.keypair.public_key()) {
936 return Err(StorageError::InvalidContent(
937 "blob write authority is not this device's identity key".to_string(),
938 ));
939 }
940 let live = self
941 .exact
942 .provider_binding()
943 .await
944 .map_err(StorageError::from)?;
945 if live.device != authority.registration.provider {
946 return Err(StorageError::InvalidContent(
947 "blob write authority differs from the authenticated provider principal"
948 .to_string(),
949 ));
950 }
951 Ok(())
952 }
953
954 pub(crate) fn user_keypair(&self) -> &UserKeypair {
955 &self.keypair
956 }
957
958 pub(crate) fn cipher_state(&self) -> &Arc<CloudCipherState> {
961 &self.cipher
962 }
963
964 pub fn shared_pending_rotation(&self) -> Arc<PendingRotation> {
970 self.pending_rotation.clone()
971 }
972
973 pub fn cloud_home(&self) -> &dyn CloudHome {
975 &*self.home
976 }
977
978 fn cipher(&self) -> CloudCipher {
979 self.cipher.snapshot()
980 }
981
982 pub(crate) fn self_uploader(&self) -> String {
987 hex::encode(self.keypair.public_key())
988 }
989
990 fn cipher_for_seal(&self) -> Result<CloudCipher, StorageError> {
997 let cipher = self.cipher();
998 self.pending_rotation.check(&cipher)?;
999 Ok(cipher)
1000 }
1001
1002 fn protocol_cipher_for_seal(
1003 &self,
1004 context: &ProtocolObjectContext,
1005 ) -> Result<CloudCipher, StorageError> {
1006 match context.protection() {
1007 ProtocolObjectProtection::StoreEncrypted => self.cipher_for_seal(),
1008 ProtocolObjectProtection::SignedPlaintext => Ok(CloudCipher::Plaintext),
1009 ProtocolObjectProtection::Circle(encryption) => {
1010 Ok(CloudCipher::Encrypted(encryption.clone()))
1011 }
1012 ProtocolObjectProtection::RecipientSealed => Ok(CloudCipher::Plaintext),
1013 }
1014 }
1015
1016 fn protocol_cipher_for_open(&self, context: &ProtocolObjectContext) -> CloudCipher {
1017 match context.protection() {
1018 ProtocolObjectProtection::StoreEncrypted => self.cipher(),
1019 ProtocolObjectProtection::SignedPlaintext => CloudCipher::Plaintext,
1020 ProtocolObjectProtection::Circle(encryption) => {
1021 CloudCipher::Encrypted(encryption.clone())
1022 }
1023 ProtocolObjectProtection::RecipientSealed => CloudCipher::Plaintext,
1024 }
1025 }
1026
1027 pub fn blob_key(
1051 scheme: BlobPathScheme,
1052 namespace: &str,
1053 uploader: Option<&str>,
1054 id: &str,
1055 cloud_path: Option<&str>,
1056 ) -> Result<String, StorageError> {
1057 match scheme {
1058 BlobPathScheme::Hashed => {
1059 let uploader = uploader.ok_or_else(|| {
1060 StorageError::Parse(format!(
1061 "an opaque-home blob requires an uploader for {namespace}/{id}"
1062 ))
1063 })?;
1064 Ok(crate::store_dir::StoreDir::uploader_hashed_key(
1065 namespace, uploader, id,
1066 )?)
1067 }
1068 BlobPathScheme::Plain => {
1069 let path = cloud_path.ok_or_else(|| {
1070 StorageError::Parse(format!(
1071 "unobfuscated blob-path home requires a cloud_path for blob {namespace}/{id}"
1072 ))
1073 })?;
1074 crate::store_dir::validate_path_token(namespace)?;
1075 crate::store_dir::validate_cloud_path(path)?;
1076 Ok(format!("{namespace}/{path}"))
1077 }
1078 }
1079 }
1080}
1081
1082pub(crate) fn encryption_for_scope(
1089 scope: crate::blob::BlobScope,
1090 master: &EncryptionService,
1091) -> EncryptionService {
1092 match scope {
1093 crate::blob::BlobScope::Master => master.clone(),
1094 crate::blob::BlobScope::Derived(s) => master.derive_scoped(&s),
1095 }
1096}
1097
1098pub(crate) fn cloud_aad_context(store_id: &str, cloud_key: &str) -> Vec<u8> {
1099 let mut context =
1100 Vec::with_capacity(std::mem::size_of::<u64>() * 2 + store_id.len() + cloud_key.len());
1101 context.extend_from_slice(&(store_id.len() as u64).to_le_bytes());
1102 context.extend_from_slice(store_id.as_bytes());
1103 context.extend_from_slice(&(cloud_key.len() as u64).to_le_bytes());
1104 context.extend_from_slice(cloud_key.as_bytes());
1105 context
1106}
1107
1108fn protocol_object_aad_context(context: &ProtocolObjectContext, semantic_prefix: &str) -> Vec<u8> {
1109 let domain = context.domain().aad_label();
1110 let mut aad = Vec::with_capacity(
1111 context.store_root_hash().as_bytes().len()
1112 + std::mem::size_of::<u64>() * 2
1113 + domain.len()
1114 + semantic_prefix.len(),
1115 );
1116 aad.extend_from_slice(context.store_root_hash().as_bytes());
1117 aad.extend_from_slice(&(domain.len() as u64).to_le_bytes());
1118 aad.extend_from_slice(domain);
1119 aad.extend_from_slice(&(semantic_prefix.len() as u64).to_le_bytes());
1120 aad.extend_from_slice(semantic_prefix.as_bytes());
1121 aad
1122}
1123
1124async fn run_storage_cpu<T>(
1125 operation: &'static str,
1126 work: Box<dyn FnOnce() -> Result<T, StorageError> + Send>,
1127) -> Result<T, StorageError>
1128where
1129 T: Send + 'static,
1130{
1131 super::blocking::run(work)
1132 .await
1133 .map_err(|error| StorageError::Storage(format!("{operation}: {error}")))?
1134}
1135
1136fn key_tag(fingerprint: &[u8; KEY_FINGERPRINT_LEN]) -> Vec<u8> {
1137 let mut tag = Vec::with_capacity(KEY_TAG_LEN);
1138 tag.extend_from_slice(KEY_TAG_MAGIC);
1139 tag.extend_from_slice(fingerprint);
1140 tag
1141}
1142
1143fn read_key_tag(stored: &[u8]) -> Result<([u8; KEY_FINGERPRINT_LEN], &[u8]), EncryptionError> {
1144 if stored.len() < KEY_TAG_LEN {
1145 return Err(EncryptionError::Decryption(
1146 "ciphertext too short for key tag".to_string(),
1147 ));
1148 }
1149 if &stored[..KEY_TAG_MAGIC.len()] != KEY_TAG_MAGIC {
1150 return Err(EncryptionError::Decryption(
1151 "ciphertext missing key tag".to_string(),
1152 ));
1153 }
1154 let mut fingerprint = [0u8; KEY_FINGERPRINT_LEN];
1155 fingerprint.copy_from_slice(&stored[KEY_TAG_MAGIC.len()..KEY_TAG_LEN]);
1156 Ok((fingerprint, &stored[KEY_TAG_LEN..]))
1157}
1158
1159fn sealing_encryption_for_scope(
1164 scope: crate::blob::BlobScope,
1165 master: &EncryptionService,
1166) -> (EncryptionService, Vec<u8>) {
1167 (
1168 encryption_for_scope(scope, master),
1169 key_tag(&master.seal_fingerprint()),
1170 )
1171}
1172
1173fn opening_encryption_for_scope(
1174 scope: crate::blob::BlobScope,
1175 master: &EncryptionService,
1176 fingerprint: &[u8; KEY_FINGERPRINT_LEN],
1177) -> Result<EncryptionService, EncryptionError> {
1178 match scope {
1179 crate::blob::BlobScope::Master => master.service_for_fingerprint(fingerprint),
1180 crate::blob::BlobScope::Derived(scope_id) => {
1181 master.derive_scoped_for_fingerprint(fingerprint, &scope_id)
1182 }
1183 }
1184}
1185
1186fn seal_scoped_encrypted(
1187 scope: crate::blob::BlobScope,
1188 master: &EncryptionService,
1189 plaintext: &[u8],
1190 aad_context: &[u8],
1191) -> Vec<u8> {
1192 let (encryption, mut prefix) = sealing_encryption_for_scope(scope, master);
1193 prefix.extend(encryption.encrypt(plaintext, aad_context));
1194 prefix
1195}
1196
1197fn open_scoped_encrypted(
1198 scope: crate::blob::BlobScope,
1199 master: &EncryptionService,
1200 stored: &[u8],
1201 aad_context: &[u8],
1202) -> Result<Vec<u8>, EncryptionError> {
1203 let (fingerprint, ciphertext) = read_key_tag(stored)?;
1204 opening_encryption_for_scope(scope, master, &fingerprint)?.decrypt(ciphertext, aad_context)
1205}
1206
1207pub struct BlobRangeReader {
1223 home: Arc<dyn CloudHome>,
1224 encryption: Option<RangeEncryption>,
1227 key: String,
1229 source_size: u64,
1232 header: OnceCell<RangeHeader>,
1234}
1235
1236enum ExactBlobOpening {
1237 Browsable,
1238 Opaque {
1239 encryption: EncryptionService,
1240 nonce: Vec<u8>,
1241 next_chunk: u64,
1242 aad_context: Vec<u8>,
1243 },
1244}
1245
1246struct ExactBlobPlaintextReader {
1249 source: crate::local_blob::PlaintextReader,
1250 opening: ExactBlobOpening,
1251 remaining: u64,
1252 total_size: u64,
1253 hasher: Option<crate::blob::ContentHasher>,
1254 expected_hash: ObjectHash,
1255 locator_hash: ObjectHash,
1256 pending: Vec<u8>,
1257 pending_offset: usize,
1258}
1259
1260impl ExactBlobPlaintextReader {
1261 async fn new(
1262 stored_file: &Path,
1263 store_id: &str,
1264 blob: &crate::blob::locator::StoredBlobRef,
1265 protection: crate::sync::storage::BlobSpoolProtection,
1266 ) -> Result<Self, StorageError> {
1267 let locator = blob.locator();
1268 let mut source = crate::local_blob::open_reader(stored_file)
1269 .await
1270 .map_err(StorageError::LocalFilesystem)?;
1271 let expected_stored_size = match locator {
1272 crate::blob::locator::BlobLocator::Opaque { .. } => {
1273 KEY_TAG_LEN as u64 + chunked_encrypted_len(locator.plaintext_size())
1274 }
1275 crate::blob::locator::BlobLocator::Browsable { .. } => locator.plaintext_size(),
1276 };
1277 if blob.object().stored_size() != expected_stored_size {
1278 return Err(StorageError::InvalidContent(format!(
1279 "blob {} stored length is {}, expected {expected_stored_size} for its locator",
1280 locator.locator_hash(),
1281 blob.object().stored_size()
1282 )));
1283 }
1284
1285 let opening = match (locator, protection) {
1286 (
1287 crate::blob::locator::BlobLocator::Opaque {
1288 scope,
1289 key_fingerprint,
1290 ..
1291 },
1292 crate::sync::storage::BlobSpoolProtection::Opaque(master),
1293 ) => {
1294 let header = read_source_exact(
1295 &mut source,
1296 KEY_TAG_LEN + crate::encryption::NONCE_SIZE,
1297 locator.locator_hash(),
1298 )
1299 .await?;
1300 let (fingerprint, nonce_and_chunks) = read_key_tag(&header).map_err(|error| {
1301 StorageError::Decryption(format!(
1302 "blob {} key tag: {error}",
1303 locator.locator_hash()
1304 ))
1305 })?;
1306 if crate::encryption::KeyFingerprint::from_bytes(fingerprint) != *key_fingerprint {
1307 return Err(StorageError::InvalidContent(format!(
1308 "blob {} stored key fingerprint differs from its locator",
1309 locator.locator_hash()
1310 )));
1311 }
1312 let encryption = opening_encryption_for_scope(scope.clone(), &master, &fingerprint)
1313 .map_err(|error| {
1314 StorageError::Decryption(format!(
1315 "blob {} audience key: {error}",
1316 locator.locator_hash()
1317 ))
1318 })?;
1319 ExactBlobOpening::Opaque {
1320 encryption,
1321 nonce: nonce_and_chunks.to_vec(),
1322 next_chunk: 0,
1323 aad_context: cloud_aad_context(store_id, &locator.semantic_key()),
1324 }
1325 }
1326 (
1327 crate::blob::locator::BlobLocator::Browsable { .. },
1328 crate::sync::storage::BlobSpoolProtection::Browsable,
1329 ) => ExactBlobOpening::Browsable,
1330 (crate::blob::locator::BlobLocator::Opaque { .. }, _) => {
1331 return Err(StorageError::Configuration(
1332 "opaque blob locator requires audience encryption".to_string(),
1333 ));
1334 }
1335 (crate::blob::locator::BlobLocator::Browsable { .. }, _) => {
1336 return Err(StorageError::Configuration(
1337 "browsable blob locator cannot use audience encryption".to_string(),
1338 ));
1339 }
1340 };
1341
1342 Ok(Self {
1343 source,
1344 opening,
1345 remaining: locator.plaintext_size(),
1346 total_size: locator.plaintext_size(),
1347 hasher: Some(crate::blob::ContentHasher::default()),
1348 expected_hash: locator.plaintext_hash(),
1349 locator_hash: locator.locator_hash(),
1350 pending: Vec::new(),
1351 pending_offset: 0,
1352 })
1353 }
1354
1355 fn take_pending(&mut self, max: usize) -> Vec<u8> {
1356 let end = (self.pending_offset + max).min(self.pending.len());
1357 let result = self.pending[self.pending_offset..end].to_vec();
1358 self.pending_offset = end;
1359 if self.pending_offset == self.pending.len() {
1360 self.pending.clear();
1361 self.pending_offset = 0;
1362 }
1363 result
1364 }
1365
1366 fn verify_complete(&mut self) -> Result<(), crate::local_blob::PlaintextChunkError> {
1367 let Some(hasher) = self.hasher.take() else {
1368 return Ok(());
1369 };
1370 let actual = hasher.finish();
1371 if actual != self.expected_hash.to_string() {
1372 return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1373 format!(
1374 "blob {} plaintext hash mismatch: expected {}, got {actual}",
1375 self.locator_hash, self.expected_hash
1376 ),
1377 ));
1378 }
1379 Ok(())
1380 }
1381}
1382
1383async fn read_source_exact(
1384 source: &mut crate::local_blob::PlaintextReader,
1385 len: usize,
1386 locator_hash: ObjectHash,
1387) -> Result<Vec<u8>, StorageError> {
1388 let mut bytes = Vec::with_capacity(len);
1389 while bytes.len() < len {
1390 let chunk = source
1391 .next_chunk(len - bytes.len())
1392 .await
1393 .map_err(StorageError::LocalFilesystem)?;
1394 if chunk.is_empty() {
1395 return Err(StorageError::InvalidContent(format!(
1396 "blob {locator_hash} stored body ended after {} of {len} required bytes",
1397 bytes.len()
1398 )));
1399 }
1400 bytes.extend_from_slice(&chunk);
1401 }
1402 Ok(bytes)
1403}
1404
1405#[async_trait]
1406impl crate::local_blob::PlaintextChunkReader for ExactBlobPlaintextReader {
1407 async fn next_chunk(
1408 &mut self,
1409 max: usize,
1410 ) -> Result<Vec<u8>, crate::local_blob::PlaintextChunkError> {
1411 if max == 0 {
1412 return Ok(Vec::new());
1413 }
1414 if !self.pending.is_empty() {
1415 return Ok(self.take_pending(max));
1416 }
1417 if self.remaining == 0 {
1418 self.verify_complete()?;
1419 return Ok(Vec::new());
1420 }
1421
1422 let plaintext = match &mut self.opening {
1423 ExactBlobOpening::Browsable => {
1424 let wanted = usize::try_from(self.remaining.min(max as u64)).map_err(|_| {
1425 crate::local_blob::PlaintextChunkError::InvalidContent(
1426 "blob plaintext read length does not fit this platform".to_string(),
1427 )
1428 })?;
1429 let chunk = self.source.next_chunk(wanted).await.map_err(|error| {
1430 crate::local_blob::PlaintextChunkError::Local(error.to_string())
1431 })?;
1432 if chunk.is_empty() {
1433 return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1434 format!("blob {} plaintext ended early", self.locator_hash),
1435 ));
1436 }
1437 chunk
1438 }
1439 ExactBlobOpening::Opaque {
1440 encryption,
1441 nonce,
1442 next_chunk,
1443 aad_context,
1444 } => {
1445 let plaintext_len = self.remaining.min(crate::encryption::CHUNK_SIZE as u64);
1446 let encrypted_len = usize::try_from(plaintext_len)
1447 .expect("one encryption chunk fits usize")
1448 + crate::encryption::TAG_SIZE;
1449 let encrypted =
1450 read_source_exact(&mut self.source, encrypted_len, self.locator_hash)
1451 .await
1452 .map_err(crate::local_blob::PlaintextChunkError::Remote)?;
1453 let start = *next_chunk * crate::encryption::CHUNK_SIZE as u64;
1454 let end = start + plaintext_len;
1455 let plaintext = encryption
1456 .decrypt_range_with_offset(
1457 nonce,
1458 &encrypted,
1459 *next_chunk,
1460 start,
1461 end,
1462 self.total_size,
1463 aad_context,
1464 )
1465 .map_err(|error| {
1466 crate::local_blob::PlaintextChunkError::InvalidContent(format!(
1467 "blob {} chunk {}: {error}",
1468 self.locator_hash, *next_chunk
1469 ))
1470 })?;
1471 *next_chunk += 1;
1472 plaintext
1473 }
1474 };
1475 if plaintext.len() as u64 > self.remaining {
1476 return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1477 format!("blob {} produced excess plaintext", self.locator_hash),
1478 ));
1479 }
1480 self.hasher
1481 .as_mut()
1482 .expect("hash verification remains active until EOF")
1483 .update(&plaintext);
1484 self.remaining -= plaintext.len() as u64;
1485 self.pending = plaintext;
1486 Ok(self.take_pending(max))
1487 }
1488}
1489
1490struct RangeEncryption {
1494 master: EncryptionService,
1495 scope: crate::blob::BlobScope,
1496 aad_context: Vec<u8>,
1497}
1498
1499struct RangeHeader {
1500 encryption: EncryptionService,
1501 nonce: Vec<u8>,
1502 chunk_base: u64,
1503}
1504
1505impl BlobRangeReader {
1506 pub fn new(
1512 home: Arc<dyn CloudHome>,
1513 cipher: &CloudCipher,
1514 scope: crate::blob::BlobScope,
1515 key: String,
1516 source_size: u64,
1517 aad_context: Vec<u8>,
1518 ) -> Self {
1519 let encryption = match cipher {
1520 CloudCipher::Encrypted(master) => Some(RangeEncryption {
1521 master: master.clone(),
1522 scope,
1523 aad_context,
1524 }),
1525 CloudCipher::Plaintext => None,
1526 };
1527 BlobRangeReader {
1528 home,
1529 encryption,
1530 key,
1531 source_size,
1532 header: OnceCell::new(),
1533 }
1534 }
1535
1536 pub async fn read(&self, offset: u64, len: u64) -> Result<Vec<u8>, StorageError> {
1539 if len == 0 {
1540 return Ok(Vec::new());
1541 }
1542 let end = offset.checked_add(len).ok_or_else(|| {
1543 StorageError::Storage(format!("blob range overflow: offset={offset}, len={len}"))
1544 })?;
1545 if end > self.source_size {
1546 return Err(StorageError::Storage(format!(
1547 "blob range {offset}..{end} exceeds blob size {}",
1548 self.source_size
1549 )));
1550 }
1551
1552 let encryption = match &self.encryption {
1553 Some(encryption) => encryption,
1554 None => {
1557 return self
1558 .home
1559 .read_range(&self.key, offset, end)
1560 .await
1561 .map_err(StorageError::from);
1562 }
1563 };
1564
1565 use crate::encryption::{chunked_encrypted_len, encrypted_chunk_range, CHUNK_SIZE};
1566
1567 let header = self.header(encryption).await?;
1568
1569 let (chunk_start, mut chunk_end) = encrypted_chunk_range(offset, end);
1570 chunk_end = chunk_end.min(chunked_encrypted_len(self.source_size));
1571 let stored_chunk_start =
1572 header.chunk_base + (chunk_start - crate::encryption::NONCE_SIZE as u64);
1573 let stored_chunk_end =
1574 header.chunk_base + (chunk_end - crate::encryption::NONCE_SIZE as u64);
1575 let encrypted_chunks = self
1576 .home
1577 .read_range(&self.key, stored_chunk_start, stored_chunk_end)
1578 .await
1579 .map_err(StorageError::from)?;
1580
1581 let first_chunk_index = offset / CHUNK_SIZE as u64;
1582 header
1583 .encryption
1584 .decrypt_range_with_offset(
1585 &header.nonce,
1586 &encrypted_chunks,
1587 first_chunk_index,
1588 offset,
1589 end,
1590 self.source_size,
1591 &encryption.aad_context,
1592 )
1593 .map_err(|e| StorageError::Decryption(format!("blob range {offset}..{end}: {e}")))
1594 }
1595
1596 async fn header(&self, encryption: &RangeEncryption) -> Result<&RangeHeader, StorageError> {
1598 use crate::encryption::NONCE_SIZE;
1599 self.header
1600 .get_or_try_init(|| async {
1601 let header = self
1602 .home
1603 .read_range(&self.key, 0, (KEY_TAG_LEN + NONCE_SIZE) as u64)
1604 .await
1605 .map_err(StorageError::from)?;
1606 if header.len() < KEY_TAG_LEN + NONCE_SIZE {
1607 return Err(StorageError::Decryption(format!(
1608 "blob header too short: expected {}, got {}",
1609 KEY_TAG_LEN + NONCE_SIZE,
1610 header.len()
1611 )));
1612 }
1613 let (fingerprint, nonce_and_chunks) = read_key_tag(&header)
1614 .map_err(|e| StorageError::Decryption(format!("blob key tag: {e}")))?;
1615 let service = opening_encryption_for_scope(
1616 encryption.scope.clone(),
1617 &encryption.master,
1618 &fingerprint,
1619 )
1620 .map_err(|e| {
1621 StorageError::Decryption(format!("blob key {}: {e}", hex::encode(fingerprint)))
1622 })?;
1623 Ok(RangeHeader {
1624 encryption: service,
1625 nonce: nonce_and_chunks[..NONCE_SIZE].to_vec(),
1626 chunk_base: (KEY_TAG_LEN + NONCE_SIZE) as u64,
1627 })
1628 })
1629 .await
1630 }
1631}
1632
1633#[async_trait]
1634impl SyncStorage for CloudSyncStorage {
1635 fn store_blob_protection(
1636 &self,
1637 ) -> Result<crate::sync::storage::BlobSpoolProtection, StorageError> {
1638 Ok(match self.cipher_for_seal()? {
1639 CloudCipher::Encrypted(encryption) => {
1640 crate::sync::storage::BlobSpoolProtection::Opaque(encryption)
1641 }
1642 CloudCipher::Plaintext => crate::sync::storage::BlobSpoolProtection::Browsable,
1643 })
1644 }
1645
1646 async fn provider_binding(&self) -> Result<ResolvedProviderBinding, StorageError> {
1647 self.exact.provider_binding().await.map_err(Into::into)
1648 }
1649
1650 async fn allocate_protocol_slot(
1651 &self,
1652 context: &ProtocolObjectContext,
1653 semantic_prefix: &str,
1654 extension: &str,
1655 ) -> Result<ObjectSlot, StorageError> {
1656 context.validate_path(semantic_prefix)?;
1657 context.validate_extension(extension)?;
1658 Ok(self
1659 .exact
1660 .allocate_slot(&format!("{semantic_prefix}{extension}"))
1661 .await?)
1662 }
1663
1664 fn prepare_protocol_object(
1665 &self,
1666 context: &ProtocolObjectContext,
1667 slot: ObjectSlot,
1668 semantic_prefix: &str,
1669 data: Vec<u8>,
1670 ) -> Result<PreparedExactObject, StorageError> {
1671 context.validate_slot(&slot, semantic_prefix)?;
1672 let aad = protocol_object_aad_context(context, semantic_prefix);
1673 let stored = self.protocol_cipher_for_seal(context)?.seal(data, &aad);
1674 let reference = ExactObjectRef::new(
1675 slot,
1676 stored.len() as u64,
1677 crate::sync::store_commit::ObjectHash::digest(&stored),
1678 );
1679 PreparedExactObject::new(reference, stored)
1680 }
1681
1682 async fn create_protocol_object(
1683 &self,
1684 prepared: &PreparedExactObject,
1685 ) -> Result<(), StorageError> {
1686 let create_error = self
1687 .exact
1688 .create_at(
1689 prepared.reference().slot(),
1690 BlobBody::from_bytes(prepared.stored_bytes().to_vec()),
1691 &crate::storage::cloud::no_progress(),
1692 )
1693 .await
1694 .err();
1695 if let Some(error) = &create_error {
1696 if !matches!(
1697 error,
1698 crate::storage::cloud::CloudHomeError::AlreadyExists(_)
1699 ) && !error.is_retryable()
1700 {
1701 return Err(create_error.expect("create error exists").into());
1702 }
1703 }
1704 let observed = match self.exact.read_at(prepared.reference().slot()).await {
1705 Ok(observed) => observed,
1706 Err(crate::storage::cloud::CloudHomeError::NotFound(_)) if create_error.is_some() => {
1707 return Err(create_error.expect("create error exists").into())
1708 }
1709 Err(readback) => {
1710 return match create_error {
1711 Some(operation) => Err(StorageError::UnresolvedOutcome {
1712 operation: Box::new(operation.into()),
1713 readback: Box::new(readback.into()),
1714 }),
1715 None => Err(readback.into()),
1716 }
1717 }
1718 };
1719 if observed != prepared.stored_bytes() {
1720 return Err(StorageError::SlotCollision(
1721 prepared.reference().slot().logical_key().to_string(),
1722 ));
1723 }
1724 prepared.reference().verify(&observed)?;
1725 Ok(())
1726 }
1727
1728 async fn read_protocol_object(
1729 &self,
1730 context: &ProtocolObjectContext,
1731 object: &ExactObjectRef,
1732 semantic_prefix: &str,
1733 ) -> Result<Vec<u8>, StorageError> {
1734 context.validate_reference(object, semantic_prefix)?;
1735 let stored = self.exact.read_at(object.slot()).await?;
1736 let aad = protocol_object_aad_context(context, semantic_prefix);
1737 let cipher = self.protocol_cipher_for_open(context);
1738 let object = object.clone();
1739 run_storage_cpu(
1740 "verify and open protocol object",
1741 Box::new(move || {
1742 object.verify(&stored)?;
1743 cipher.open(stored, &aad).map_err(|error| {
1744 StorageError::Decryption(format!(
1745 "protocol object {}: {error}",
1746 object.slot().logical_key()
1747 ))
1748 })
1749 }),
1750 )
1751 .await
1752 }
1753
1754 async fn read_protocol_slot(
1755 &self,
1756 context: &ProtocolObjectContext,
1757 slot: &ObjectSlot,
1758 semantic_prefix: &str,
1759 ) -> Result<(Vec<u8>, ExactObjectRef), StorageError> {
1760 let (opened, prepared) = self
1761 .read_prepared_protocol_slot(context, slot, semantic_prefix)
1762 .await?;
1763 Ok((opened, prepared.reference().clone()))
1764 }
1765
1766 async fn read_prepared_protocol_slot(
1767 &self,
1768 context: &ProtocolObjectContext,
1769 slot: &ObjectSlot,
1770 semantic_prefix: &str,
1771 ) -> Result<(Vec<u8>, PreparedExactObject), StorageError> {
1772 context.validate_slot(slot, semantic_prefix)?;
1773 let stored = self.exact.read_at(slot).await?;
1774 let aad = protocol_object_aad_context(context, semantic_prefix);
1775 let cipher = self.protocol_cipher_for_open(context);
1776 let slot = slot.clone();
1777 run_storage_cpu(
1778 "identify and open protocol slot",
1779 Box::new(move || {
1780 let object = ExactObjectRef::new(
1781 slot.clone(),
1782 stored.len() as u64,
1783 crate::sync::store_commit::ObjectHash::digest(&stored),
1784 );
1785 let prepared = PreparedExactObject::new(object, stored.clone())?;
1786 let opened = cipher.open(stored, &aad).map_err(|error| {
1787 StorageError::Decryption(format!(
1788 "protocol object {}: {error}",
1789 slot.logical_key()
1790 ))
1791 })?;
1792 Ok((opened, prepared))
1793 }),
1794 )
1795 .await
1796 }
1797
1798 async fn delete_protocol_object(&self, object: &ExactObjectRef) -> Result<(), StorageError> {
1799 match self.exact.read_at(object.slot()).await {
1800 Err(crate::storage::cloud::CloudHomeError::NotFound(_)) => return Ok(()),
1801 Err(error) => return Err(error.into()),
1802 Ok(stored)
1803 if stored.len() as u64 != object.stored_size()
1804 || crate::sync::store_commit::ObjectHash::digest(&stored)
1805 != object.stored_hash() =>
1806 {
1807 return Err(StorageError::SlotCollision(format!(
1808 "exact delete target {} contains different bytes",
1809 object.slot().logical_key()
1810 )));
1811 }
1812 Ok(_) => {}
1813 }
1814 let delete_error = self.exact.delete_at(object.slot()).await.err();
1815 if delete_error
1816 .as_ref()
1817 .is_some_and(|error| !error.is_retryable())
1818 {
1819 return Err(delete_error.expect("delete error exists").into());
1820 }
1821 match self.exact.read_at(object.slot()).await {
1822 Err(crate::storage::cloud::CloudHomeError::NotFound(_)) => Ok(()),
1823 Err(readback) => match delete_error {
1824 Some(operation) => Err(StorageError::UnresolvedOutcome {
1825 operation: Box::new(operation.into()),
1826 readback: Box::new(readback.into()),
1827 }),
1828 None => Err(readback.into()),
1829 },
1830 Ok(_) => match delete_error {
1831 Some(error) => Err(error.into()),
1832 None => Err(StorageError::Storage(format!(
1833 "exact object remains after delete: {}",
1834 object.slot().logical_key()
1835 ))),
1836 },
1837 }
1838 }
1839
1840 async fn allocate_blob_slot(
1841 &self,
1842 locator: &crate::blob::locator::BlobLocator,
1843 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1844 ) -> Result<ObjectSlot, StorageError> {
1845 self.validate_blob_locator_home(locator)?;
1846 self.validate_blob_append_authority(locator, authority)
1847 .await?;
1848 Ok(self.exact.allocate_slot(&locator.semantic_key()).await?)
1849 }
1850
1851 async fn seal_blob_to_spool(
1852 &self,
1853 locator: &crate::blob::locator::BlobLocator,
1854 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1855 protection: crate::sync::storage::BlobSpoolProtection,
1856 plaintext_file: &Path,
1857 spool_file: &Path,
1858 ) -> Result<crate::sync::storage::BlobSpoolWrite, StorageError> {
1859 self.validate_blob_locator_home(locator)?;
1860 self.validate_blob_append_authority(locator, authority)
1861 .await?;
1862 let (plaintext_size, plaintext_hash) = crate::local_blob::exact_file_facts(plaintext_file)
1863 .await
1864 .map_err(StorageError::LocalFilesystem)?;
1865 if plaintext_size != locator.plaintext_size() || plaintext_hash != locator.plaintext_hash()
1866 {
1867 return Err(StorageError::InvalidContent(format!(
1868 "blob plaintext {}/{} does not match its locator size/hash",
1869 locator.namespace(),
1870 locator.blob_id()
1871 )));
1872 }
1873
1874 match tokio::fs::metadata(spool_file).await {
1875 Ok(metadata) => {
1876 if !metadata.is_file() {
1877 return Err(StorageError::LocalFilesystem(format!(
1878 "blob spool path is not a file: {}",
1879 spool_file.display()
1880 )));
1881 }
1882 let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(spool_file)
1883 .await
1884 .map_err(StorageError::LocalFilesystem)?;
1885 let object = ExactObjectRef::new(
1886 ObjectSlot::logical(locator.semantic_key())?,
1887 stored_size,
1888 stored_hash,
1889 );
1890 let blob = crate::blob::locator::StoredBlobRef::new(locator.clone(), object)
1891 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1892 let mut reader =
1893 ExactBlobPlaintextReader::new(spool_file, &self.store_id, &blob, protection)
1894 .await?;
1895 loop {
1896 let chunk =
1897 crate::local_blob::PlaintextChunkReader::next_chunk(&mut reader, 1 << 20)
1898 .await
1899 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1900 if chunk.is_empty() {
1901 break;
1902 }
1903 }
1904 return Ok(crate::sync::storage::BlobSpoolWrite::Reused);
1905 }
1906 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1907 Err(error) => {
1908 return Err(StorageError::LocalFilesystem(format!(
1909 "inspect blob spool {}: {error}",
1910 spool_file.display()
1911 )));
1912 }
1913 }
1914
1915 let retry_protection = protection.clone();
1916 let body = match (locator, protection) {
1917 (
1918 crate::blob::locator::BlobLocator::Opaque {
1919 scope,
1920 key_fingerprint,
1921 ..
1922 },
1923 crate::sync::storage::BlobSpoolProtection::Opaque(encryption),
1924 ) => {
1925 if encryption.seal_key_fingerprint() != *key_fingerprint {
1926 return Err(StorageError::InvalidContent(format!(
1927 "blob locator key fingerprint {key_fingerprint} differs from the supplied audience key {}",
1928 encryption.seal_key_fingerprint()
1929 )));
1930 }
1931 let aad = cloud_aad_context(&self.store_id, &locator.semantic_key());
1932 CloudCipher::Encrypted(encryption)
1933 .open_body(scope.clone(), plaintext_file, &aad)
1934 .await
1935 .map_err(StorageError::LocalFilesystem)?
1936 }
1937 (
1938 crate::blob::locator::BlobLocator::Browsable { .. },
1939 crate::sync::storage::BlobSpoolProtection::Browsable,
1940 ) => BlobBody::from_file(plaintext_file)
1941 .await
1942 .map_err(StorageError::LocalFilesystem)?,
1943 (crate::blob::locator::BlobLocator::Opaque { .. }, _) => {
1944 return Err(StorageError::Configuration(
1945 "opaque blob locator requires audience encryption".to_string(),
1946 ));
1947 }
1948 (crate::blob::locator::BlobLocator::Browsable { .. }, _) => {
1949 return Err(StorageError::Configuration(
1950 "browsable blob locator cannot use audience encryption".to_string(),
1951 ));
1952 }
1953 };
1954 let expected_size = body.len();
1955 let stream = futures_util::stream::try_unfold(body, |mut body| async move {
1956 match body.next_part(1 << 20).await? {
1957 Some(chunk) => Ok::<_, crate::storage::cloud::CloudHomeError>(Some((chunk, body))),
1958 None => Ok::<_, crate::storage::cloud::CloudHomeError>(None),
1959 }
1960 });
1961 let staged = crate::local_blob::stage_atomic_destination(spool_file)
1962 .await
1963 .map_err(StorageError::LocalFilesystem)?;
1964 let written = crate::local_blob::write_byte_stream_atomic(staged.path(), Box::pin(stream))
1965 .await
1966 .map_err(|error| match error {
1967 crate::local_blob::ByteStreamWriteError::Source(error) => error.into(),
1968 crate::local_blob::ByteStreamWriteError::Local(error) => {
1969 StorageError::LocalFilesystem(error)
1970 }
1971 })?;
1972 if written != expected_size {
1973 return Err(StorageError::InvalidContent(format!(
1974 "blob spool {} contains {written} stored bytes, expected {expected_size}",
1975 spool_file.display()
1976 )));
1977 }
1978 match staged.commit_new().await {
1979 Ok(()) => Ok(crate::sync::storage::BlobSpoolWrite::Created),
1980 Err(crate::local_blob::CommitNewFileError::DestinationExists(_)) => {
1981 let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(spool_file)
1982 .await
1983 .map_err(StorageError::LocalFilesystem)?;
1984 let object = ExactObjectRef::new(
1985 ObjectSlot::logical(locator.semantic_key())?,
1986 stored_size,
1987 stored_hash,
1988 );
1989 let blob = crate::blob::locator::StoredBlobRef::new(locator.clone(), object)
1990 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1991 let mut reader = ExactBlobPlaintextReader::new(
1992 spool_file,
1993 &self.store_id,
1994 &blob,
1995 retry_protection,
1996 )
1997 .await?;
1998 loop {
1999 let chunk =
2000 crate::local_blob::PlaintextChunkReader::next_chunk(&mut reader, 1 << 20)
2001 .await
2002 .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
2003 if chunk.is_empty() {
2004 break;
2005 }
2006 }
2007 Ok(crate::sync::storage::BlobSpoolWrite::Reused)
2008 }
2009 Err(error) => Err(StorageError::LocalFilesystem(error.to_string())),
2010 }
2011 }
2012
2013 async fn prepare_blob_object(
2014 &self,
2015 locator: &crate::blob::locator::BlobLocator,
2016 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
2017 slot: ObjectSlot,
2018 stored_file: &Path,
2019 ) -> Result<crate::blob::locator::StoredBlobRef, StorageError> {
2020 self.validate_blob_locator_home(locator)?;
2021 self.validate_blob_append_authority(locator, authority)
2022 .await?;
2023 let expected = locator.semantic_key();
2024 if slot.logical_key() != expected {
2025 return Err(StorageError::Parse(format!(
2026 "blob slot {:?} does not match locator key {expected:?}",
2027 slot.logical_key()
2028 )));
2029 }
2030 let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(stored_file)
2031 .await
2032 .map_err(StorageError::LocalFilesystem)?;
2033 crate::blob::locator::StoredBlobRef::new(
2034 locator.clone(),
2035 ExactObjectRef::new(slot, stored_size, stored_hash),
2036 )
2037 .map_err(|error| StorageError::InvalidContent(error.to_string()))
2038 }
2039
2040 async fn create_blob_object_from_file(
2041 &self,
2042 blob: &crate::blob::locator::StoredBlobRef,
2043 authority: &crate::sync::storage::BlobWriteAuthority<'_>,
2044 stored_file: &Path,
2045 progress: &crate::storage::cloud::UploadProgress<'_>,
2046 ) -> Result<(), StorageError> {
2047 let locator = blob.locator();
2048 let object = blob.object();
2049 self.validate_blob_locator_home(locator)?;
2050 self.validate_blob_append_authority(locator, authority)
2051 .await?;
2052 let expected = locator.semantic_key();
2053 if object.slot().logical_key() != expected {
2054 return Err(StorageError::Parse(format!(
2055 "blob object {:?} does not match locator key {expected:?}",
2056 object.slot().logical_key()
2057 )));
2058 }
2059 crate::local_blob::verify_exact_file(object, stored_file)
2060 .await
2061 .map_err(|error| match error {
2062 crate::local_blob::ExactFileVerificationError::Filesystem(error) => {
2063 StorageError::LocalFilesystem(error)
2064 }
2065 crate::local_blob::ExactFileVerificationError::IdentityMismatch(error) => {
2066 StorageError::InvalidContent(error)
2067 }
2068 })?;
2069 let body = BlobBody::from_file(stored_file)
2070 .await
2071 .map_err(StorageError::LocalFilesystem)?;
2072 let create_error = self
2073 .exact
2074 .create_at(object.slot(), body, progress)
2075 .await
2076 .err();
2077 if let Some(error) = &create_error {
2078 if !matches!(
2079 error,
2080 crate::storage::cloud::CloudHomeError::AlreadyExists(_)
2081 ) && !error.is_retryable()
2082 {
2083 return Err(create_error.expect("create error exists").into());
2084 }
2085 }
2086 match self.exact.read_at(object.slot()).await {
2087 Ok(stored) => object
2088 .verify(&stored)
2089 .map_err(|_| StorageError::SlotCollision(object.slot().logical_key().to_string())),
2090 Err(crate::storage::cloud::CloudHomeError::NotFound(_)) if create_error.is_some() => {
2091 Err(create_error.expect("create error exists").into())
2092 }
2093 Err(readback) => match create_error {
2094 Some(operation) => Err(StorageError::UnresolvedOutcome {
2095 operation: Box::new(operation.into()),
2096 readback: Box::new(readback.into()),
2097 }),
2098 None => Err(readback.into()),
2099 },
2100 }
2101 }
2102
2103 async fn verify_blob_object(
2104 &self,
2105 blob: &crate::blob::locator::StoredBlobRef,
2106 ) -> Result<(), StorageError> {
2107 self.validate_blob_locator_home(blob.locator())?;
2108 let expected = blob.locator().semantic_key();
2109 if blob.object().slot().logical_key() != expected {
2110 return Err(StorageError::Parse(format!(
2111 "blob object {:?} does not match locator key {expected:?}",
2112 blob.object().slot().logical_key()
2113 )));
2114 }
2115 let stored = self.exact.read_at(blob.object().slot()).await?;
2116 blob.object().verify(&stored)
2117 }
2118
2119 async fn stage_exact_blob_download(
2120 &self,
2121 blob: &crate::blob::locator::StoredBlobRef,
2122 dest: &Path,
2123 ) -> Result<crate::local_blob::AtomicStagedFile, StorageError> {
2124 let locator = blob.locator();
2125 let object = blob.object();
2126 self.validate_blob_locator_home(locator)?;
2127 let expected = locator.semantic_key();
2128 if object.slot().logical_key() != expected {
2129 return Err(StorageError::Parse(format!(
2130 "blob object {:?} does not match locator key {expected:?}",
2131 object.slot().logical_key()
2132 )));
2133 }
2134 let staged = crate::local_blob::stage_atomic_destination(dest)
2135 .await
2136 .map_err(StorageError::LocalFilesystem)?;
2137 self.exact
2138 .read_at_to_file(object.slot(), staged.path())
2139 .await
2140 .map_err(|error| match error {
2141 CloudFileReadError::Source(error) => StorageError::from(error),
2142 CloudFileReadError::Local(error) => StorageError::LocalFilesystem(error),
2143 })?;
2144 crate::local_blob::verify_exact_file(object, staged.path())
2145 .await
2146 .map_err(|error| match error {
2147 crate::local_blob::ExactFileVerificationError::Filesystem(error) => {
2148 StorageError::LocalFilesystem(error)
2149 }
2150 crate::local_blob::ExactFileVerificationError::IdentityMismatch(error) => {
2151 StorageError::InvalidContent(error)
2152 }
2153 })?;
2154 Ok(staged)
2155 }
2156
2157 async fn stage_verified_blob_plaintext(
2158 &self,
2159 blob: &crate::blob::locator::StoredBlobRef,
2160 protection: crate::sync::storage::BlobSpoolProtection,
2161 dest: &Path,
2162 ) -> Result<crate::local_blob::AtomicStagedFile, StorageError> {
2163 let stored_destination = dest.with_extension("coven-stored-download");
2164 let stored = self
2165 .stage_exact_blob_download(blob, &stored_destination)
2166 .await?;
2167 let plaintext = crate::local_blob::stage_atomic_destination(dest)
2168 .await
2169 .map_err(StorageError::LocalFilesystem)?;
2170 let mut reader =
2171 ExactBlobPlaintextReader::new(stored.path(), &self.store_id, blob, protection).await?;
2172 let written = crate::local_blob::write_stream_to_stage(&plaintext, &mut reader)
2173 .await
2174 .map_err(|error| match error {
2175 crate::local_blob::StreamWriteError::Source(
2176 crate::local_blob::PlaintextChunkError::Remote(error),
2177 ) => error,
2178 crate::local_blob::StreamWriteError::Source(
2179 crate::local_blob::PlaintextChunkError::InvalidContent(error),
2180 ) => StorageError::InvalidContent(error),
2181 crate::local_blob::StreamWriteError::Source(
2182 crate::local_blob::PlaintextChunkError::Local(error),
2183 )
2184 | crate::local_blob::StreamWriteError::Local(error) => {
2185 StorageError::LocalFilesystem(error)
2186 }
2187 })?;
2188 if written != blob.locator().plaintext_size() {
2189 return Err(StorageError::InvalidContent(format!(
2190 "blob {} plaintext stage contains {written} bytes, expected {}",
2191 blob.locator().locator_hash(),
2192 blob.locator().plaintext_size()
2193 )));
2194 }
2195 Ok(plaintext)
2196 }
2197
2198 async fn delete_blob_object(
2199 &self,
2200 blob: &crate::blob::locator::StoredBlobRef,
2201 ) -> Result<(), StorageError> {
2202 let locator = blob.locator();
2203 let object = blob.object();
2204 self.validate_blob_locator_home(locator)?;
2205 let expected = locator.semantic_key();
2206 if object.slot().logical_key() != expected {
2207 return Err(StorageError::Parse(format!(
2208 "blob object {:?} does not match locator key {expected:?}",
2209 object.slot().logical_key()
2210 )));
2211 }
2212 self.delete_protocol_object(object).await?;
2213 Ok(())
2214 }
2215}
2216
2217#[cfg(test)]
2218mod tests {
2219 use super::*;
2220 use crate::blob::locator::{BlobLocator, RemoteAudience};
2221 use crate::blob::BlobScope;
2222 use crate::storage::cloud::test_utils::InMemoryCloudHome;
2223 use crate::sync::storage::{BlobWriteAuthority, ExactObjectRef};
2224 use crate::sync::store_commit::{
2225 DeviceStreamAnchor, ObjectHash, StoreCreationId, StoreDeviceRegistration,
2226 StoreDeviceRegistrationOrigin, StoreDeviceRegistrationRef, StoreRootRef,
2227 };
2228
2229 #[test]
2230 fn encrypted_cloud_object_tag_carries_the_full_key_digest() {
2231 let encryption = EncryptionService::from_key([0xA5u8; 32]);
2232 let fingerprint = encryption.seal_key_fingerprint();
2233 let cipher = CloudCipher::Encrypted(encryption);
2234 let plaintext = b"full fingerprint cloud object".to_vec();
2235 let aad = b"full-fingerprint-test";
2236
2237 let stored = cipher.seal(plaintext.clone(), aad);
2238
2239 assert_eq!(&stored[..KEY_TAG_MAGIC.len()], KEY_TAG_MAGIC);
2240 assert_eq!(
2241 &stored[KEY_TAG_MAGIC.len()..KEY_TAG_LEN],
2242 fingerprint.as_bytes()
2243 );
2244 assert_eq!(stored.len() as u64, cipher.body_len(plaintext.len() as u64));
2245 assert_eq!(cipher.open(stored, aad).unwrap(), plaintext);
2246 }
2247
2248 #[test]
2249 fn peer_rotation_cannot_stand_in_for_the_exact_local_candidate() {
2250 let mutation = ObjectHash::digest(b"local rotation mutation");
2251 let gate = RotationGate::empty()
2252 .merge_peer_commit(2)
2253 .expect("record peer rotation");
2254
2255 assert!(gate.commit_candidate(2, mutation).is_err());
2256 }
2257
2258 #[test]
2259 fn local_adoption_cannot_close_another_local_rotation() {
2260 let adopted = ObjectHash::digest(b"adopted local rotation");
2261 let other = ObjectHash::digest(b"other local rotation");
2262 let gate = RotationGate {
2263 candidate: None,
2264 local_committed: Some(RotationLocalCommittedGate {
2265 generation: 3,
2266 mutation: other,
2267 }),
2268 peer_committed_generation: None,
2269 };
2270
2271 assert!(gate.complete_local_adoption(2, adopted).is_err());
2272 }
2273
2274 #[test]
2275 fn rotation_gate_rejects_empty_zero_and_two_local_owners() {
2276 assert!(RotationGate::empty().validate().is_err());
2277 assert!(RotationGate {
2278 candidate: None,
2279 local_committed: None,
2280 peer_committed_generation: Some(0),
2281 }
2282 .validate()
2283 .is_err());
2284 let mutation = ObjectHash::digest(b"rotation owner");
2285 assert!(RotationGate {
2286 candidate: Some(RotationCandidateGate {
2287 generation: 2,
2288 mutation,
2289 }),
2290 local_committed: Some(RotationLocalCommittedGate {
2291 generation: 2,
2292 mutation,
2293 }),
2294 peer_committed_generation: None,
2295 }
2296 .validate()
2297 .is_err());
2298 }
2299
2300 #[test]
2301 fn local_adoption_clears_the_same_peer_fact_but_preserves_a_newer_one() {
2302 let mutation = ObjectHash::digest(b"local removal");
2303 let committed = RotationGate::empty()
2304 .with_candidate(2, mutation)
2305 .unwrap()
2306 .commit_candidate(2, mutation)
2307 .unwrap();
2308 assert_eq!(
2309 committed
2310 .clone()
2311 .merge_peer_commit(2)
2312 .unwrap()
2313 .complete_local_adoption(2, mutation)
2314 .unwrap(),
2315 None
2316 );
2317 assert_eq!(
2318 committed
2319 .merge_peer_commit(3)
2320 .unwrap()
2321 .complete_local_adoption(2, mutation)
2322 .unwrap()
2323 .unwrap()
2324 .pending_state()
2325 .unwrap(),
2326 RotationPendingState::PeerCommitted { generation: 3 }
2327 );
2328 }
2329
2330 async fn blob_write_registration(
2331 storage: &CloudSyncStorage,
2332 label: &str,
2333 ) -> (StoreDeviceRegistrationRef, StoreDeviceRegistration) {
2334 let root_bytes = format!("{label} Store root").into_bytes();
2335 let root = StoreRootRef {
2336 store_root_id: ObjectHash::digest(format!("{label} root id").as_bytes()),
2337 store_root_hash: ObjectHash::digest(&root_bytes),
2338 object: ExactObjectRef::new(
2339 ObjectSlot::logical(format!("store-v1/store-protocol-root/{label}.json")).unwrap(),
2340 root_bytes.len() as u64,
2341 ObjectHash::digest(&root_bytes),
2342 ),
2343 };
2344 let anchor_slot = |stream: &str| {
2345 ObjectSlot::logical(format!(
2346 "store-v1/test-device-streams/{label}/{stream}.json"
2347 ))
2348 .unwrap()
2349 };
2350 let provider = SyncStorage::provider_binding(storage).await.unwrap().device;
2351 let registration = StoreDeviceRegistration::signed(
2352 root,
2353 StoreDeviceRegistrationOrigin::Founder {
2354 creation_id: StoreCreationId::from_nonce(label),
2355 },
2356 provider,
2357 DeviceStreamAnchor::StoreAnnouncements {
2358 first_slot: anchor_slot("announcements"),
2359 },
2360 DeviceStreamAnchor::StoreAcknowledgements {
2361 first_slot: anchor_slot("acknowledgements"),
2362 },
2363 DeviceStreamAnchor::StoreSnapshots {
2364 first_slot: anchor_slot("snapshots"),
2365 },
2366 storage.user_keypair(),
2367 )
2368 .unwrap();
2369 let bytes = registration.to_bytes();
2370 let reference = StoreDeviceRegistrationRef::from_registration(
2371 ®istration,
2372 ExactObjectRef::new(
2373 ObjectSlot::logical(format!(
2374 "store-v1/devices/{}/registration.json",
2375 registration.device_id
2376 ))
2377 .unwrap(),
2378 bytes.len() as u64,
2379 ObjectHash::digest(&bytes),
2380 ),
2381 );
2382 (reference, registration)
2383 }
2384
2385 #[tokio::test]
2386 async fn circle_blob_spool_uses_the_supplied_audience_key() {
2387 let home = InMemoryCloudHome::new();
2388 let identity = UserKeypair::generate();
2389 let storage = CloudSyncStorage::new(
2390 Arc::new(home),
2391 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2392 BlobPathScheme::Hashed,
2393 "circle-blob-spool",
2394 identity,
2395 )
2396 .expect("test cloud storage supports exact slots");
2397 let (uploader, registration) = blob_write_registration(&storage, "circle-blob-spool").await;
2398 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2399 let circle_key = EncryptionService::from_key([9u8; 32]);
2400 let plaintext = b"circle audience blob";
2401 let locator = BlobLocator::opaque(
2402 "covers",
2403 "circle-cover",
2404 uploader.clone(),
2405 RemoteAudience::Circle(crate::sync::circle::CircleId::from_bytes([8; 16])),
2406 BlobScope::Master,
2407 circle_key.seal_key_fingerprint(),
2408 plaintext.len() as u64,
2409 crate::sync::store_commit::ObjectHash::digest(plaintext),
2410 )
2411 .expect("build Circle 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 tokio::fs::write(&source, plaintext)
2416 .await
2417 .expect("write plaintext source");
2418
2419 storage
2420 .seal_blob_to_spool(
2421 &locator,
2422 &authority,
2423 crate::sync::storage::BlobSpoolProtection::Opaque(circle_key.clone()),
2424 &source,
2425 &spool,
2426 )
2427 .await
2428 .expect("seal Circle blob spool");
2429
2430 let stored = tokio::fs::read(&spool).await.expect("read exact spool");
2431 let opened = CloudCipher::Encrypted(circle_key)
2432 .open_scoped(
2433 BlobScope::Master,
2434 stored,
2435 &cloud_aad_context("circle-blob-spool", &locator.semantic_key()),
2436 )
2437 .expect("open Circle blob with supplied key");
2438 assert_eq!(opened, plaintext);
2439 }
2440
2441 #[tokio::test]
2442 async fn blob_spool_rejects_a_key_that_differs_from_the_locator() {
2443 let home = InMemoryCloudHome::new();
2444 let identity = UserKeypair::generate();
2445 let storage = CloudSyncStorage::new(
2446 Arc::new(home),
2447 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2448 BlobPathScheme::Hashed,
2449 "blob-spool-key-mismatch",
2450 identity,
2451 )
2452 .expect("test cloud storage supports exact slots");
2453 let (uploader, registration) =
2454 blob_write_registration(&storage, "blob-spool-key-mismatch").await;
2455 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2456 let declared_key = EncryptionService::from_key([9u8; 32]);
2457 let plaintext = b"audience blob";
2458 let locator = BlobLocator::opaque(
2459 "covers",
2460 "mismatched-cover",
2461 uploader.clone(),
2462 RemoteAudience::Store,
2463 BlobScope::Master,
2464 declared_key.seal_key_fingerprint(),
2465 plaintext.len() as u64,
2466 crate::sync::store_commit::ObjectHash::digest(plaintext),
2467 )
2468 .expect("build locator");
2469 let temp = tempfile::tempdir().expect("temporary blob directory");
2470 let source = temp.path().join("plaintext");
2471 let spool = temp.path().join("spool");
2472 tokio::fs::write(&source, plaintext)
2473 .await
2474 .expect("write plaintext source");
2475
2476 assert!(matches!(
2477 storage
2478 .seal_blob_to_spool(
2479 &locator,
2480 &authority,
2481 crate::sync::storage::BlobSpoolProtection::Opaque(EncryptionService::from_key(
2482 [10u8; 32]
2483 ),),
2484 &source,
2485 &spool,
2486 )
2487 .await,
2488 Err(StorageError::InvalidContent(_))
2489 ));
2490 assert!(!spool.exists());
2491 }
2492
2493 #[tokio::test]
2494 async fn exact_blob_plaintext_is_published_only_after_both_verifications() {
2495 let home = InMemoryCloudHome::new();
2496 let identity = UserKeypair::generate();
2497 let storage = CloudSyncStorage::new(
2498 Arc::new(home),
2499 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2500 BlobPathScheme::Hashed,
2501 "verified-blob-download",
2502 identity,
2503 )
2504 .expect("test cloud storage supports exact slots");
2505 let (uploader, registration) =
2506 blob_write_registration(&storage, "verified-blob-download").await;
2507 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2508 let audience_key = EncryptionService::from_key([9u8; 32]);
2509 let plaintext: Vec<u8> = (0..150_000u32).map(|value| (value % 251) as u8).collect();
2510 let locator = BlobLocator::opaque(
2511 "audio",
2512 "verified-track",
2513 uploader.clone(),
2514 RemoteAudience::Store,
2515 BlobScope::Derived("album-a".to_string()),
2516 audience_key.seal_key_fingerprint(),
2517 plaintext.len() as u64,
2518 ObjectHash::digest(&plaintext),
2519 )
2520 .expect("build locator");
2521 let temp = tempfile::tempdir().expect("temporary blob directory");
2522 let source = temp.path().join("plaintext");
2523 let spool = temp.path().join("spool");
2524 let destination = temp.path().join("materialized");
2525 tokio::fs::write(&source, &plaintext)
2526 .await
2527 .expect("write plaintext source");
2528 storage
2529 .seal_blob_to_spool(
2530 &locator,
2531 &authority,
2532 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key.clone()),
2533 &source,
2534 &spool,
2535 )
2536 .await
2537 .expect("seal exact spool");
2538 let slot = storage
2539 .allocate_blob_slot(&locator, &authority)
2540 .await
2541 .expect("allocate exact blob slot");
2542 let blob = storage
2543 .prepare_blob_object(&locator, &authority, slot, &spool)
2544 .await
2545 .expect("prepare exact blob");
2546 storage
2547 .create_blob_object_from_file(
2548 &blob,
2549 &authority,
2550 &spool,
2551 &crate::storage::cloud::no_progress(),
2552 )
2553 .await
2554 .expect("create exact blob");
2555
2556 let staged = storage
2557 .stage_verified_blob_plaintext(
2558 &blob,
2559 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key),
2560 &destination,
2561 )
2562 .await
2563 .expect("stage verified plaintext");
2564 assert!(!destination.exists());
2565 assert_eq!(tokio::fs::read(staged.path()).await.unwrap(), plaintext);
2566 staged.commit().await.expect("publish verified plaintext");
2567 assert_eq!(tokio::fs::read(destination).await.unwrap(), plaintext);
2568 }
2569
2570 #[tokio::test]
2571 async fn stored_blob_corruption_never_creates_a_plaintext_stage() {
2572 let home = InMemoryCloudHome::new();
2573 let identity = UserKeypair::generate();
2574 let storage = CloudSyncStorage::new(
2575 Arc::new(home.clone()),
2576 CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2577 BlobPathScheme::Hashed,
2578 "corrupt-blob-download",
2579 identity,
2580 )
2581 .expect("test cloud storage supports exact slots");
2582 let (uploader, registration) =
2583 blob_write_registration(&storage, "corrupt-blob-download").await;
2584 let authority = BlobWriteAuthority::new(&uploader, ®istration).unwrap();
2585 let audience_key = EncryptionService::from_key([9u8; 32]);
2586 let plaintext = b"signed blob plaintext";
2587 let locator = BlobLocator::opaque(
2588 "covers",
2589 "corrupt-cover",
2590 uploader.clone(),
2591 RemoteAudience::Store,
2592 BlobScope::Master,
2593 audience_key.seal_key_fingerprint(),
2594 plaintext.len() as u64,
2595 ObjectHash::digest(plaintext),
2596 )
2597 .expect("build locator");
2598 let temp = tempfile::tempdir().expect("temporary blob directory");
2599 let source = temp.path().join("plaintext");
2600 let spool = temp.path().join("spool");
2601 let destination = temp.path().join("materialized");
2602 tokio::fs::write(&source, plaintext)
2603 .await
2604 .expect("write plaintext source");
2605 storage
2606 .seal_blob_to_spool(
2607 &locator,
2608 &authority,
2609 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key.clone()),
2610 &source,
2611 &spool,
2612 )
2613 .await
2614 .expect("seal exact spool");
2615 let slot = storage
2616 .allocate_blob_slot(&locator, &authority)
2617 .await
2618 .unwrap();
2619 let blob = storage
2620 .prepare_blob_object(&locator, &authority, slot, &spool)
2621 .await
2622 .unwrap();
2623 storage
2624 .create_blob_object_from_file(
2625 &blob,
2626 &authority,
2627 &spool,
2628 &crate::storage::cloud::no_progress(),
2629 )
2630 .await
2631 .unwrap();
2632 home.replace_exact_object(blob.object().slot(), b"corrupt".to_vec());
2633
2634 assert!(matches!(
2635 storage
2636 .stage_verified_blob_plaintext(
2637 &blob,
2638 crate::sync::storage::BlobSpoolProtection::Opaque(audience_key),
2639 &destination,
2640 )
2641 .await,
2642 Err(StorageError::InvalidContent(_))
2643 ));
2644 assert!(!destination.exists());
2645 }
2646
2647 #[tokio::test]
2648 async fn reserved_protocol_slot_read_returns_its_completed_exact_reference() {
2649 let home = InMemoryCloudHome::new();
2650 let storage = CloudSyncStorage::new(
2651 Arc::new(home),
2652 CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2653 BlobPathScheme::Hashed,
2654 "reserved-slot-read",
2655 UserKeypair::generate(),
2656 )
2657 .expect("test cloud storage supports exact slots");
2658 let root = crate::sync::store_commit::ObjectHash::digest(b"reserved slot root");
2659 let semantic = "store-v1/heads/device-a/1".to_string();
2660 let context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2661 root,
2662 ProtocolObjectDomain::StoreHead,
2663 );
2664 let slot = storage
2665 .allocate_protocol_slot(&context, &semantic, ".json")
2666 .await
2667 .expect("reserve successor slot");
2668 let prepared = storage
2669 .prepare_protocol_object(
2670 &context,
2671 slot.clone(),
2672 &semantic,
2673 b"signed successor bytes".to_vec(),
2674 )
2675 .expect("prepare successor bytes");
2676 storage
2677 .create_protocol_object(&prepared)
2678 .await
2679 .expect("create successor");
2680
2681 let (opened, completed) = storage
2682 .read_protocol_slot(&context, &slot, &semantic)
2683 .await
2684 .expect("read reserved successor slot");
2685
2686 assert_eq!(opened, b"signed successor bytes");
2687 assert_eq!(&completed, prepared.reference());
2688 }
2689
2690 #[test]
2691 fn protocol_object_prepare_rejects_a_path_outside_its_domain() {
2692 let storage = CloudSyncStorage::new(
2693 Arc::new(InMemoryCloudHome::new()),
2694 CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2695 BlobPathScheme::Hashed,
2696 "prepare-domain-path",
2697 UserKeypair::generate(),
2698 )
2699 .expect("test cloud storage supports exact slots");
2700 let context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2701 ObjectHash::digest(b"prepare domain root"),
2702 ProtocolObjectDomain::StoreHead,
2703 );
2704 let invalid_semantic = "store-v1/commits/device-a/1";
2705 let slot = ObjectSlot::logical(format!("{invalid_semantic}.json"))
2706 .expect("valid logical object slot");
2707
2708 assert!(matches!(
2709 storage.prepare_protocol_object(
2710 &context,
2711 slot,
2712 invalid_semantic,
2713 b"signed bytes".to_vec(),
2714 ),
2715 Err(StorageError::Parse(_))
2716 ));
2717 }
2718
2719 #[tokio::test]
2720 async fn exact_delete_refuses_to_remove_different_bytes_in_the_same_slot() {
2721 let home = InMemoryCloudHome::new();
2722 let storage = CloudSyncStorage::new(
2723 Arc::new(home.clone()),
2724 CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2725 BlobPathScheme::Hashed,
2726 "exact-delete-identity",
2727 UserKeypair::generate(),
2728 )
2729 .expect("test cloud storage supports exact slots");
2730 let root = ObjectHash::digest(b"exact delete root");
2731 let semantic = "store-v1/heads/device-a/1";
2732 let context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2733 root,
2734 ProtocolObjectDomain::StoreHead,
2735 );
2736 let slot = storage
2737 .allocate_protocol_slot(&context, semantic, ".json")
2738 .await
2739 .expect("allocate exact slot");
2740 let prepared = storage
2741 .prepare_protocol_object(&context, slot.clone(), semantic, b"original".to_vec())
2742 .expect("prepare exact object");
2743 storage
2744 .create_protocol_object(&prepared)
2745 .await
2746 .expect("create exact object");
2747 home.replace_exact_object(&slot, b"competing stored bytes".to_vec());
2748
2749 assert!(matches!(
2750 storage.delete_protocol_object(prepared.reference()).await,
2751 Err(StorageError::SlotCollision(_))
2752 ));
2753 assert_eq!(
2754 home.get(slot.logical_key()),
2755 Some(b"competing stored bytes".to_vec())
2756 );
2757 }
2758
2759 #[tokio::test]
2760 async fn reserved_protocol_slot_rejects_a_mismatched_semantic_path_before_read() {
2761 let home = InMemoryCloudHome::new();
2762 let storage = CloudSyncStorage::new(
2763 Arc::new(home),
2764 CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2765 BlobPathScheme::Hashed,
2766 "reserved-slot-relocation",
2767 UserKeypair::generate(),
2768 )
2769 .expect("test cloud storage supports exact slots");
2770 let root = crate::sync::store_commit::ObjectHash::digest(b"reserved slot root");
2771 let context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2772 root,
2773 ProtocolObjectDomain::StoreHead,
2774 );
2775 let original = "store-v1/heads/device-a/1".to_string();
2776 let relocated = "store-v1/heads/device-b/1".to_string();
2777 let slot = storage
2778 .allocate_protocol_slot(&context, &original, ".json")
2779 .await
2780 .expect("reserve successor slot");
2781
2782 assert!(matches!(
2783 storage
2784 .read_protocol_slot(&context, &slot, &relocated)
2785 .await,
2786 Err(StorageError::Parse(_))
2787 ));
2788 }
2789
2790 #[tokio::test]
2791 async fn protocol_object_read_rejects_domain_and_path_substitution() {
2792 let home = InMemoryCloudHome::new();
2793 let storage = CloudSyncStorage::new(
2794 Arc::new(home),
2795 CloudCipher::Encrypted(EncryptionService::from_key([8u8; 32])),
2796 BlobPathScheme::Hashed,
2797 "aad-store",
2798 UserKeypair::generate(),
2799 )
2800 .expect("test cloud storage supports immutable copies");
2801 let root = crate::sync::store_commit::ObjectHash::digest(b"root-a");
2802 let other_root = crate::sync::store_commit::ObjectHash::digest(b"root-b");
2803 let commit_hash = crate::sync::store_commit::ObjectHash::digest(b"commit");
2804 let family = crate::sync::store_commit::CandidateFamilyId::from_hash(
2805 crate::sync::store_commit::ObjectHash::digest(b"cloud test family"),
2806 );
2807 let semantic =
2808 crate::sync::store_commit::commit_semantic_prefix(family, "device", 1, commit_hash);
2809 let context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2810 root,
2811 ProtocolObjectDomain::StoreCommit,
2812 );
2813 let slot = storage
2814 .allocate_protocol_slot(&context, &semantic, ".json")
2815 .await
2816 .expect("allocate root-bound Store commit slot");
2817 let prepared = storage
2818 .prepare_protocol_object(&context, slot, &semantic, b"signed commit".to_vec())
2819 .expect("prepare root-bound Store commit");
2820 storage
2821 .create_protocol_object(&prepared)
2822 .await
2823 .expect("create root-bound Store commit");
2824 let object = prepared.reference().clone();
2825
2826 assert_eq!(
2827 storage
2828 .read_protocol_object(&context, &object, &semantic)
2829 .await
2830 .expect("read with the exact authenticated context"),
2831 b"signed commit",
2832 );
2833 let other_root_context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2834 other_root,
2835 ProtocolObjectDomain::StoreCommit,
2836 );
2837 assert_eq!(
2838 storage
2839 .read_protocol_object(&other_root_context, &object, &semantic)
2840 .await
2841 .expect("signed plaintext bytes are opened before their root signature is parsed"),
2842 b"signed commit",
2843 );
2844
2845 let other_semantic =
2846 crate::sync::store_commit::commit_semantic_prefix(family, "device", 2, commit_hash);
2847 assert!(matches!(
2848 storage
2849 .read_protocol_object(&context, &object, &other_semantic)
2850 .await,
2851 Err(crate::sync::storage::StorageError::Parse(_))
2852 ));
2853
2854 let other_domain_context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2855 root,
2856 ProtocolObjectDomain::StoreHead,
2857 );
2858 assert!(matches!(
2859 storage
2860 .read_protocol_object(&other_domain_context, &object, &semantic)
2861 .await,
2862 Err(crate::sync::storage::StorageError::Parse(_))
2863 ));
2864 }
2865
2866 #[tokio::test]
2867 async fn signed_control_is_readable_across_store_key_rotations_but_packages_are_not() {
2868 let home = Arc::new(InMemoryCloudHome::new());
2869 let writer = CloudSyncStorage::new(
2870 home.clone(),
2871 CloudCipher::Encrypted(EncryptionService::from_key([8u8; 32])),
2872 BlobPathScheme::Hashed,
2873 "control-plane-rotation",
2874 UserKeypair::generate(),
2875 )
2876 .expect("writer storage");
2877 let stale_reader = CloudSyncStorage::new(
2878 home,
2879 CloudCipher::Encrypted(EncryptionService::from_key([9u8; 32])),
2880 BlobPathScheme::Hashed,
2881 "control-plane-rotation",
2882 UserKeypair::generate(),
2883 )
2884 .expect("stale reader storage");
2885 let root = ObjectHash::digest(b"control plane root");
2886 let head_semantic = "store-v1/heads/device-a/1";
2887 let head_context = crate::sync::storage::ProtocolObjectContext::signed_plaintext(
2888 root,
2889 ProtocolObjectDomain::StoreHead,
2890 );
2891 let head_slot = writer
2892 .allocate_protocol_slot(&head_context, head_semantic, ".json")
2893 .await
2894 .expect("allocate signed head");
2895 let head = writer
2896 .prepare_protocol_object(
2897 &head_context,
2898 head_slot,
2899 head_semantic,
2900 b"signed control bytes".to_vec(),
2901 )
2902 .expect("prepare signed head");
2903 writer
2904 .create_protocol_object(&head)
2905 .await
2906 .expect("create signed head");
2907 assert_eq!(
2908 stale_reader
2909 .read_protocol_object(&head_context, head.reference(), head_semantic)
2910 .await
2911 .expect("read signed control with a different Store key"),
2912 b"signed control bytes",
2913 );
2914
2915 let family = crate::sync::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
2916 b"control plane package family",
2917 ));
2918 let package_hash = ObjectHash::digest(b"encrypted package");
2919 let package_semantic = format!(
2920 "store-v1/candidates/{}/packages/device-a/1/{package_hash}",
2921 family.as_hash()
2922 );
2923 let package_context = crate::sync::storage::ProtocolObjectContext::store_encrypted(
2924 root,
2925 ProtocolObjectDomain::StorePackage,
2926 );
2927 let package_slot = writer
2928 .allocate_protocol_slot(&package_context, &package_semantic, ".pkg")
2929 .await
2930 .expect("allocate encrypted package");
2931 let package = writer
2932 .prepare_protocol_object(
2933 &package_context,
2934 package_slot,
2935 &package_semantic,
2936 b"encrypted package".to_vec(),
2937 )
2938 .expect("prepare encrypted package");
2939 writer
2940 .create_protocol_object(&package)
2941 .await
2942 .expect("create encrypted package");
2943 assert!(matches!(
2944 stale_reader
2945 .read_protocol_object(&package_context, package.reference(), &package_semantic,)
2946 .await,
2947 Err(StorageError::Decryption(_))
2948 ));
2949 }
2950
2951 #[tokio::test]
2952 async fn malformed_durable_pending_rotation_blocks_session_reopen() {
2953 let directory = tempfile::tempdir().expect("pending-rotation database directory");
2954 let path = directory.path().join("store.sqlite3");
2955 let open = || {
2956 crate::database::Database::open(
2957 &path,
2958 crate::sync::test_helpers::test_synced_tables(),
2959 crate::blob::BLOB_TOMBSTONE_GRACE,
2960 crate::blob::TransferLimits::one_at_a_time(),
2961 "pending-rotation-reopen-device".to_string(),
2962 &crate::sync::test_helpers::test_migrations(),
2963 )
2964 .expect("open pending-rotation database")
2965 .0
2966 };
2967 let home = InMemoryCloudHome::new();
2968 let signer = UserKeypair::generate();
2969 let encryption = EncryptionService::from_key([17; 32]);
2970 let db = open();
2971 let storage = CloudSyncStorage::new(
2972 Arc::new(home.clone()),
2973 CloudCipher::Encrypted(encryption.clone()),
2974 BlobPathScheme::Hashed,
2975 "pending-rotation-reopen",
2976 signer.clone(),
2977 )
2978 .expect("construct pending-rotation storage");
2979 let components = crate::sync::cycle::init_sync_over_storage(
2980 &crate::sync::store::StoreDatabase::new(&db),
2981 storage,
2982 crate::sync::cycle::StoreInitialization::CreateStore,
2983 None,
2984 )
2985 .await
2986 .expect("initialize pending-rotation Store");
2987 let root = crate::sync::store::StoreDatabase::new(&db)
2988 .local_store_root_ref()
2989 .await
2990 .expect("read pending-rotation Store root")
2991 .expect("pending-rotation Store root exists");
2992 db.set_protocol_state(ROTATION_GATE_STATE_KEY, "not-a-rotation-gate")
2993 .await
2994 .expect("persist malformed pending rotation");
2995 drop(components);
2996 drop(db);
2997
2998 let reopened = open();
2999 let storage = CloudSyncStorage::new(
3000 Arc::new(home),
3001 CloudCipher::Encrypted(encryption),
3002 BlobPathScheme::Hashed,
3003 "pending-rotation-reopen",
3004 signer,
3005 )
3006 .expect("reconstruct pending-rotation storage");
3007 let result = crate::sync::cycle::init_sync_over_storage(
3008 &crate::sync::store::StoreDatabase::new(&reopened),
3009 storage,
3010 crate::sync::cycle::StoreInitialization::OpenStore {
3011 expected_store_root: root,
3012 },
3013 None,
3014 )
3015 .await;
3016
3017 assert!(matches!(
3018 result,
3019 Err(crate::sync::cycle::InitSyncError::PendingRotationRestore(_))
3020 ));
3021 }
3022}