1use std::sync::Arc;
6
7use tokio::sync::watch;
8use tracing::{info, warn};
9
10use coven_database::supported_version;
11use coven_database::Migration;
12use coven_database::{CovenMigrationPolicy, Database};
13#[cfg(feature = "oauth-providers")]
14use coven_foundation::config::CloudProvider;
15use coven_foundation::config::{Config, ConfigError, HomeStorage};
16use coven_foundation::stage_timing::StageTimings;
17use coven_foundation::store_dir::{StoreDir, StoreLayout};
18use coven_keys::encryption::{EncryptionError, EncryptionService, MasterKeyring};
19use coven_keys::identity_custody::IdentityCustody;
20use coven_keys::keys::{
21 CloudHomeCredentials, DeviceIdentityCustody, KeyError, MasterKeyCustody, StoreKeys, UserKeypair,
22};
23use coven_protocol::synced_schema::SyncedTable;
24use coven_replication::sync::store::{
25 MembershipMutationError, PreparedDeviceJoinSnapshot, PreparedSnapshotBootstrap, PullError,
26 SnapshotError,
27};
28use coven_replication::sync::MemberAdmission;
29use coven_storage::cloud::{CloudHomeError, CloudHomeJoinInfo, ExactCloudHome};
30use coven_storage::{BlobPathScheme, CloudCipher, CloudSyncConnection};
31
32#[derive(Debug, thiserror::Error)]
38pub enum BootstrapError {
39 #[error("cloud home: {0}")]
40 CloudHome(#[from] CloudHomeError),
41 #[error("encryption: {0}")]
42 Encryption(#[from] EncryptionError),
43 #[error("membership mutation: {0}")]
44 MembershipMutation(#[source] Box<MembershipMutationError>),
45 #[error("snapshot: {0}")]
46 Snapshot(SnapshotError),
47 #[error("pull: {0}")]
48 Pull(#[from] PullError),
49 #[error("Store pull: {0}")]
50 StorePull(#[from] coven_replication::sync::store::StorePullError),
51 #[error("Store device registration: {0}")]
52 StoreRegistration(#[from] coven_replication::sync::store::StoreRegistrationError),
53 #[error("Store device join: {0}")]
54 DeviceJoin(#[from] coven_replication::sync::DeviceJoinError),
55 #[error("Store device join transport: {0}")]
56 DeviceJoinTransport(#[from] coven_replication::sync::store::DeviceJoinTransportError),
57 #[error("storage: {0}")]
58 Storage(#[from] coven_protocol::objects::StorageError),
59 #[error("config: {0}")]
60 Config(#[from] ConfigError),
61 #[error("keyring: {0}")]
62 Key(#[from] KeyError),
63 #[error("I/O: {0}")]
64 Io(#[from] std::io::Error),
65 #[error("device invitation: {0}")]
66 DeviceInvite(#[from] crate::joining::DeviceInviteError),
67 #[error("device pairing: {0}")]
68 Pairing(#[from] crate::joining::DevicePairingTransportError),
69 #[error("device pairing state: {0}")]
70 PairingState(#[from] crate::joining::DevicePairingError),
71 #[error("device join invite version {0} is not supported")]
72 UnsupportedDeviceInviteVersion(u32),
73 #[error("invalid store id: {0}")]
74 InvalidStoreId(#[from] coven_foundation::store_dir::PathTokenError),
75 #[error("invalid restore code: {0}")]
76 RestoreCode(#[from] crate::restoration::RestoreCodeError),
77 #[error("store already exists locally: {0}")]
78 StoreExists(String),
79 #[error("could not clear a torn bootstrap for {store_id}: {failures}")]
83 TornBootstrapCleanup {
84 store_id: String,
85 failures: BootstrapCleanupFailures,
86 },
87 #[error("could not remove cancelled join state for {store_id}: {failures}")]
88 CancelledJoinCleanup {
89 store_id: String,
90 failures: BootstrapCleanupFailures,
91 },
92 #[error("provider: {0}")]
93 Provider(String),
94 #[cfg(feature = "oauth-providers")]
95 #[error("OAuth client configuration: {0}")]
96 OAuthClient(#[from] coven_storage::oauth::OAuthClientCredsError),
97 #[error("{provider:?} cannot provide exact protocol and blob slots with this configuration")]
98 ExactSlotsUnavailable {
99 provider: coven_foundation::config::CloudProvider,
100 },
101 #[error("database open: {0}")]
102 DatabaseOpen(#[from] coven_database::OpenError),
103 #[error("invalid signing key: {0}")]
104 InvalidSigningKey(#[from] SigningKeyError),
105 #[error("the operation was cancelled")]
111 Cancelled,
112 #[error(
117 "could not clean up the partial store after bootstrap failed: {cleanup} (bootstrap error: {cause})"
118 )]
119 Cleanup {
120 cleanup: BootstrapCleanupFailures,
121 cause: Box<BootstrapError>,
122 },
123}
124
125impl From<SnapshotError> for BootstrapError {
126 fn from(error: SnapshotError) -> Self {
127 match error {
128 SnapshotError::Cancelled => Self::Cancelled,
129 error => Self::Snapshot(error),
130 }
131 }
132}
133
134#[derive(Debug, thiserror::Error)]
135pub enum SigningKeyError {
136 #[error("{0}")]
137 Material(#[from] coven_foundation::code_envelope::FixedHexError),
138 #[error("activated continuation has no device signing key")]
139 MissingContinuationSigner,
140 #[error("Owner recovery cannot carry an activated device signer")]
141 UnexpectedOwnerRecoverySigner,
142}
143
144#[derive(Debug, thiserror::Error)]
145pub enum BootstrapCleanupFailure {
146 #[error("store directory: {0}")]
147 StoreDirectory(#[source] std::io::Error),
148 #[error("master key: {0}")]
149 MasterKey(#[source] KeyError),
150 #[error("identity: {0}")]
151 Identity(#[source] KeyError),
152 #[error("cloud home credentials: {0}")]
153 CloudHomeCredentials(#[source] KeyError),
154}
155
156#[derive(Debug)]
157pub struct BootstrapCleanupFailures(Vec<BootstrapCleanupFailure>);
158
159impl BootstrapCleanupFailures {
160 fn is_empty(&self) -> bool {
161 self.0.is_empty()
162 }
163}
164
165impl std::fmt::Display for BootstrapCleanupFailures {
166 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 for (index, failure) in self.0.iter().enumerate() {
168 if index > 0 {
169 formatter.write_str("; ")?;
170 }
171 write!(formatter, "{failure}")?;
172 }
173 Ok(())
174 }
175}
176
177impl std::error::Error for BootstrapCleanupFailures {
178 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
179 self.0.first().map(|failure| failure as _)
180 }
181}
182
183impl From<MembershipMutationError> for BootstrapError {
184 fn from(error: MembershipMutationError) -> Self {
185 Self::MembershipMutation(Box::new(error))
186 }
187}
188
189pub(crate) struct BootstrapCleanup<'a> {
191 store_dir: &'a StoreDir,
192 store_keys: &'a StoreKeys,
193 custody: &'a dyn MasterKeyCustody,
194 identity_custody: &'a dyn DeviceIdentityCustody,
195}
196
197impl<'a> BootstrapCleanup<'a> {
198 pub(crate) fn new(
199 store_dir: &'a StoreDir,
200 store_keys: &'a StoreKeys,
201 custody: &'a dyn MasterKeyCustody,
202 identity_custody: &'a dyn DeviceIdentityCustody,
203 ) -> Self {
204 Self {
205 store_dir,
206 store_keys,
207 custody,
208 identity_custody,
209 }
210 }
211
212 pub(crate) fn refuse_completed_or_clear(&self, store_id: &str) -> Result<(), BootstrapError> {
214 if self.store_dir.config_path().exists() {
215 return Err(BootstrapError::StoreExists(store_id.to_string()));
216 }
217
218 if self.store_dir.exists() {
219 warn!(
220 store_dir = %self.store_dir.display(),
221 "clearing a torn bootstrap: a store directory with no saved config, left by a restore that a crash interrupted before completion"
222 );
223 let failures = self.remove();
224 if !failures.is_empty() {
225 return Err(BootstrapError::TornBootstrapCleanup {
226 store_id: store_id.to_string(),
227 failures,
228 });
229 }
230 }
231
232 Ok(())
233 }
234
235 pub(crate) fn after_failure(&self, cause: BootstrapError) -> BootstrapError {
237 let failures = self.remove();
238 if failures.is_empty() {
239 cause
240 } else {
241 BootstrapError::Cleanup {
242 cleanup: failures,
243 cause: Box::new(cause),
244 }
245 }
246 }
247
248 pub(crate) fn remove(&self) -> BootstrapCleanupFailures {
250 let mut failures = Vec::new();
251
252 if let Err(error) = self.store_dir.remove_tree() {
253 failures.push(BootstrapCleanupFailure::StoreDirectory(error));
254 }
255 if let Err(error) = self.custody.forget() {
256 failures.push(BootstrapCleanupFailure::MasterKey(error));
257 }
258 if let Err(error) = self.identity_custody.forget() {
259 failures.push(BootstrapCleanupFailure::Identity(error));
260 }
261 if let Err(error) = self.store_keys.delete_cloud_home_credentials() {
262 failures.push(BootstrapCleanupFailure::CloudHomeCredentials(error));
263 }
264
265 BootstrapCleanupFailures(failures)
266 }
267}
268
269async fn build_cloud_home_for_join(
270 join_info: &CloudHomeJoinInfo,
271 lib_ks: &StoreKeys,
272 cloud_homes: &coven_storage::cloud::CloudHomeFactory,
273 oauth_tokens: Option<coven_storage::oauth::OAuthTokens>,
274 cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
275 clock: coven_foundation::clock::ClockRef,
276 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
277) -> Result<Arc<dyn ExactCloudHome>, BootstrapError> {
278 use coven_storage::cloud::*;
279
280 #[cfg(not(feature = "oauth-providers"))]
281 let _ = (&lib_ks, cloud_homes, &oauth_tokens, &clock);
282 #[cfg(feature = "oauth-providers")]
283 let credential_custody =
284 coven_keys::keys::CloudHomeCredentialsOwner::new(lib_ks.clone()).current();
285
286 match join_info {
287 CloudHomeJoinInfo::S3 {
288 bucket,
289 region,
290 endpoint,
291 access_key,
292 secret_key,
293 key_prefix,
294 } => {
295 let s3 = cloud_homes
296 .open_s3(
297 bucket.clone(),
298 region.clone(),
299 endpoint.clone(),
300 access_key.clone(),
301 secret_key.clone(),
302 key_prefix.clone(),
303 exact_upload_verification,
304 clock.clone(),
305 )
306 .await?;
307 Ok(Arc::new(s3))
308 }
309 #[cfg(feature = "oauth-providers")]
310 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
311 let tokens = oauth_tokens.ok_or_else(|| {
312 BootstrapError::Provider("Google Drive join requires an OAuth token".to_string())
313 })?;
314 let oauth_config = cloud_homes.oauth_config_for(CloudProvider::GoogleDrive)?;
315 let session = oauth_session::OAuthSession::new(
316 tokens,
317 credential_custody.clone(),
318 clock,
319 oauth_config,
320 "Google Drive",
321 );
322 Ok(Arc::new(google_drive::GoogleDriveCloudHome::new(
323 folder_id.clone(),
324 session,
325 exact_upload_verification,
326 )))
327 }
328 #[cfg(feature = "oauth-providers")]
329 CloudHomeJoinInfo::Dropbox { folder_path } => {
330 let tokens = oauth_tokens.ok_or_else(|| {
331 BootstrapError::Provider("Dropbox join requires an OAuth token".to_string())
332 })?;
333 let oauth_config = cloud_homes.oauth_config_for(CloudProvider::Dropbox)?;
334 let session = oauth_session::OAuthSession::new(
335 tokens,
336 credential_custody.clone(),
337 clock,
338 oauth_config,
339 "Dropbox",
340 );
341 Ok(Arc::new(dropbox::DropboxCloudHome::new(
342 folder_path.clone(),
343 session,
344 exact_upload_verification,
345 )))
346 }
347 #[cfg(feature = "oauth-providers")]
348 CloudHomeJoinInfo::OneDrive {
349 drive_id,
350 folder_id,
351 } => {
352 let tokens = oauth_tokens.ok_or_else(|| {
353 BootstrapError::Provider("OneDrive join requires an OAuth token".to_string())
354 })?;
355 let oauth_config = cloud_homes.oauth_config_for(CloudProvider::OneDrive)?;
356 let session = oauth_session::OAuthSession::new(
357 tokens,
358 credential_custody,
359 clock,
360 oauth_config,
361 "OneDrive",
362 );
363 Ok(Arc::new(onedrive::OneDriveCloudHome::new(
364 drive_id.clone(),
365 folder_id.clone(),
366 session,
367 exact_upload_verification,
368 )))
369 }
370 #[cfg(not(feature = "oauth-providers"))]
371 CloudHomeJoinInfo::GoogleDrive { .. }
372 | CloudHomeJoinInfo::Dropbox { .. }
373 | CloudHomeJoinInfo::OneDrive { .. } => Err(BootstrapError::Provider(
374 "OAuth cloud providers are not supported in this build".to_string(),
375 )),
376 CloudHomeJoinInfo::CloudKit => {
377 let ops = cloudkit_ops.ok_or_else(|| {
378 BootstrapError::Provider("CloudKit driver not provided".to_string())
379 })?;
380 Ok(Arc::new(cloudkit::CloudKitCloudHome::new_private(
381 ops,
382 exact_upload_verification,
383 )))
384 }
385 CloudHomeJoinInfo::CloudKitShare {
386 share_url,
387 owner_name,
388 zone_name,
389 } => {
390 let ops = cloudkit_ops.ok_or_else(|| {
391 BootstrapError::Provider("CloudKit driver not provided".to_string())
392 })?;
393 let accepted = cloudkit::accept_share(ops.clone(), share_url.clone()).await?;
394 if accepted.owner_name != *owner_name || accepted.zone_name != *zone_name {
395 return Err(BootstrapError::Provider(format!(
396 "CloudKit accepted share zone mismatch: invite owner/zone {owner_name}/{zone_name}, accepted {}/{}",
397 accepted.owner_name, accepted.zone_name
398 )));
399 }
400 let home = Arc::new(cloudkit::CloudKitCloudHome::new_shared(
401 ops.clone(),
402 owner_name.clone(),
403 zone_name.clone(),
404 exact_upload_verification,
405 ));
406 Ok(home)
407 }
408 }
409}
410
411pub(crate) enum EnrollmentProviderAccess {
412 Supplied(Option<coven_storage::oauth::OAuthTokens>),
413 Stored,
414 #[cfg(any(test, feature = "test-utils"))]
415 InjectedHome,
416}
417
418#[cfg(feature = "oauth-providers")]
419pub(crate) fn enrollment_oauth_tokens(
420 join_info: &CloudHomeJoinInfo,
421 store_keys: &StoreKeys,
422 access: EnrollmentProviderAccess,
423) -> Result<Option<coven_storage::oauth::OAuthTokens>, BootstrapError> {
424 let provider = match join_info {
425 CloudHomeJoinInfo::GoogleDrive { .. } => "Google Drive",
426 CloudHomeJoinInfo::Dropbox { .. } => "Dropbox",
427 CloudHomeJoinInfo::OneDrive { .. } => "OneDrive",
428 CloudHomeJoinInfo::S3 { .. }
429 | CloudHomeJoinInfo::CloudKit
430 | CloudHomeJoinInfo::CloudKitShare { .. } => return Ok(None),
431 };
432 match access {
433 EnrollmentProviderAccess::Supplied(Some(tokens)) => {
434 store_keys.set_cloud_home_oauth_tokens(&tokens)?;
435 Ok(Some(tokens))
436 }
437 EnrollmentProviderAccess::Supplied(None) | EnrollmentProviderAccess::Stored => store_keys
438 .get_cloud_home_oauth_tokens()?
439 .map(Some)
440 .ok_or_else(|| {
441 BootstrapError::Provider(format!(
442 "{provider} device enrollment requires OAuth authorization"
443 ))
444 }),
445 #[cfg(any(test, feature = "test-utils"))]
446 EnrollmentProviderAccess::InjectedHome => Ok(None),
447 }
448}
449
450#[cfg(not(feature = "oauth-providers"))]
451pub(crate) fn enrollment_oauth_tokens(
452 join_info: &CloudHomeJoinInfo,
453 _store_keys: &StoreKeys,
454 access: EnrollmentProviderAccess,
455) -> Result<Option<coven_storage::oauth::OAuthTokens>, BootstrapError> {
456 match access {
457 EnrollmentProviderAccess::Supplied(tokens) => drop(tokens),
458 EnrollmentProviderAccess::Stored => {}
459 #[cfg(any(test, feature = "test-utils"))]
460 EnrollmentProviderAccess::InjectedHome => {}
461 }
462 match join_info {
463 CloudHomeJoinInfo::GoogleDrive { .. }
464 | CloudHomeJoinInfo::Dropbox { .. }
465 | CloudHomeJoinInfo::OneDrive { .. } => Err(BootstrapError::Provider(
466 "OAuth cloud providers are not supported in this build".to_string(),
467 )),
468 CloudHomeJoinInfo::S3 { .. }
469 | CloudHomeJoinInfo::CloudKit
470 | CloudHomeJoinInfo::CloudKitShare { .. } => Ok(None),
471 }
472}
473
474pub(crate) struct DeviceJoinClient {
478 admission: MemberAdmission,
479 member_pubkey: String,
480 layout: StoreLayout,
481 synced_tables: Vec<SyncedTable>,
482 migrations: Vec<Migration>,
483 coven_migration_policy: CovenMigrationPolicy,
484 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
485 transfer_limits: coven_protocol::blob::TransferLimits,
486 store_keys: StoreKeys,
487 custody: Arc<dyn MasterKeyCustody>,
488 identity_custody: Arc<dyn DeviceIdentityCustody>,
489 cloud_homes: coven_storage::cloud::CloudHomeFactory,
490 oauth_tokens: Option<coven_storage::oauth::OAuthTokens>,
491 cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
492 clock: coven_foundation::clock::ClockRef,
493 #[cfg(any(test, feature = "test-utils"))]
494 test_home: Option<Arc<dyn ExactCloudHome>>,
495}
496
497struct DeviceJoinStorage {
498 storage: Arc<dyn coven_storage::CloudSyncObjectStorage>,
499 keyring: MasterKeyring,
500 membership: coven_replication::sync::store::AcceptedMembershipAuthority,
504}
505
506impl DeviceJoinClient {
507 #[allow(clippy::too_many_arguments)]
508 pub(crate) fn new(
509 admission: MemberAdmission,
510 member_pubkey: String,
511 layout: StoreLayout,
512 synced_tables: Vec<SyncedTable>,
513 migrations: Vec<Migration>,
514 coven_migration_policy: CovenMigrationPolicy,
515 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
516 transfer_limits: coven_protocol::blob::TransferLimits,
517 key_custody: coven_keys::custody::KeyCustody,
518 identity_custody: IdentityCustody,
519 oauth_clients: coven_storage::oauth::OAuthClients,
520 oauth_tokens: Option<coven_storage::oauth::OAuthTokens>,
521 cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
522 clock: coven_foundation::clock::ClockRef,
523 ) -> Result<Self, BootstrapError> {
524 if admission.wrapped_key.recipient_pubkey != member_pubkey {
525 return Err(crate::joining::DeviceInviteError::RecipientMismatch.into());
526 }
527 coven_storage::cloud::setup::require_exact_slot_capabilities_join_info(
528 &admission.join_info,
529 exact_upload_verification,
530 )
531 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
532 coven_foundation::store_dir::validate_path_token(&admission.store_id)?;
533 let store_dir = layout.store_dir(&admission.store_id);
534 let store_keys = StoreKeys::bind(admission.store_id.clone());
535 let custody = key_custody.resolve(&store_keys, &store_dir);
536 let identity_custody = identity_custody.resolve(&store_keys, &store_dir);
537 Ok(Self {
538 admission,
539 member_pubkey,
540 layout,
541 synced_tables,
542 migrations,
543 coven_migration_policy,
544 exact_upload_verification,
545 transfer_limits,
546 store_keys,
547 custody,
548 identity_custody,
549 cloud_homes: coven_storage::cloud::CloudHomeFactory::new(oauth_clients),
550 oauth_tokens,
551 cloudkit_ops,
552 clock,
553 #[cfg(any(test, feature = "test-utils"))]
554 test_home: None,
555 })
556 }
557
558 #[cfg(any(test, feature = "test-utils"))]
559 pub(crate) fn with_test_bootstrap_home(mut self, home: Arc<dyn ExactCloudHome>) -> Self {
560 self.test_home = Some(home);
561 self
562 }
563
564 pub(crate) async fn prepare_provider_access_request(
565 &self,
566 offer: coven_replication::sync::DeviceJoinOffer,
567 ) -> Result<coven_replication::sync::DeviceProviderAccessRequest, BootstrapError> {
568 self.require_offer(&offer)?;
569 let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
570 let storage: Arc<dyn coven_storage::CloudSyncObjectStorage> =
571 Arc::new(self.transport_storage().await?);
572 let pending = self.open_pending_journal()?;
573 let observation = coven_replication::sync::store::PendingDeviceJoinObservation::open(
574 &pending,
575 &storage,
576 &offer.store_root,
577 offer.attempt_id,
578 )
579 .await?;
580 let authority = coven_replication::sync::store::PendingDeviceJoinAuthority::open(
581 observation,
582 &signer,
583 offer,
584 )
585 .await?;
586 Ok(authority.prepare_provider_access_request().await?)
587 }
588
589 pub(crate) async fn accept_device_join_abandonment(
590 &self,
591 abandonment: coven_replication::sync::DeviceJoinAbandonment,
592 ) -> Result<coven_replication::sync::DeviceJoinAbandonment, BootstrapError> {
593 let storage: Arc<dyn coven_storage::CloudSyncObjectStorage> =
594 Arc::new(self.transport_storage().await?);
595 let pending = self.open_pending_journal()?;
596 let mut observation = coven_replication::sync::store::PendingDeviceJoinObservation::open(
597 &pending,
598 &storage,
599 &self.admission.store_root,
600 abandonment.abandonment.attempt_id,
601 )
602 .await?;
603 Ok(observation.observe_abandonment(abandonment).await?)
604 }
605
606 pub(crate) fn completed_library(&self) -> Result<Option<Config>, BootstrapError> {
613 let store_dir = self.layout.store_dir(&self.admission.store_id);
614 if !store_dir.config_path().exists() {
615 return Ok(None);
616 }
617 let config = Config::load_from_config_yaml(&store_dir)?;
618 if config.store_id != self.admission.store_id {
619 return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
620 }
621 Ok(Some(config))
622 }
623
624 pub(crate) fn device_join_status(
625 &self,
626 attempt_id: coven_protocol::DeviceJoinAttemptId,
627 ) -> Result<Option<coven_replication::sync::DeviceJoinStatus>, BootstrapError> {
628 let pending = self.open_pending_journal()?;
629 Ok(pending.status(attempt_id)?)
630 }
631
632 #[cfg(test)]
633 pub(crate) fn pending_journal_records_for_test(
634 &self,
635 ) -> Result<
636 Vec<coven_protocol::store_commit::device_join_journal::DeviceJoinJournalRecord>,
637 BootstrapError,
638 > {
639 Ok(self.open_pending_journal()?.records()?)
640 }
641
642 #[cfg(test)]
643 pub(crate) fn resume_device_joins(
644 &self,
645 ) -> Result<Vec<coven_replication::sync::DeviceJoinAction>, BootstrapError> {
646 let pending = self.open_pending_journal()?;
647 Ok(pending.actions()?)
648 }
649
650 pub(crate) async fn prepare_registration_request(
651 &self,
652 approval: coven_replication::sync::DeviceProviderAdmissionApproval,
653 ) -> Result<coven_replication::sync::DeviceRegistrationRequest, BootstrapError> {
654 let offer = &approval.request.offer;
655 self.require_offer(offer)?;
656 let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
657 let storage: Arc<dyn coven_storage::CloudSyncObjectStorage> =
658 Arc::new(self.transport_storage().await?);
659 let pending = self.open_pending_journal()?;
660 let observation = coven_replication::sync::store::PendingDeviceJoinObservation::open(
661 &pending,
662 &storage,
663 &offer.store_root,
664 offer.attempt_id,
665 )
666 .await?;
667 let mut authority = coven_replication::sync::store::PendingDeviceJoinAuthority::open(
668 observation,
669 &signer,
670 offer.as_ref().clone(),
671 )
672 .await?;
673 Ok(authority.prepare_registration_request(approval).await?)
674 }
675
676 pub(crate) fn record_same_principal_registration_request(
677 &self,
678 approval: coven_replication::sync::DeviceProviderAdmissionApproval,
679 ) -> Result<coven_replication::sync::DeviceRegistrationRequest, BootstrapError> {
680 let offer = approval.request.offer.as_ref().clone();
681 self.require_offer(&offer)?;
682 let pending = self.open_pending_journal()?;
683 Ok(coven_replication::sync::store::PendingDeviceJoinAuthority::record_same_principal_registration_request(
684 &pending,
685 &offer,
686 approval,
687 )?)
688 }
689
690 pub(crate) async fn bootstrap_pending_device(
694 &self,
695 bootstrap: coven_replication::sync::ProviderReadyDeviceBootstrap,
696 on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
697 cancel: &watch::Receiver<bool>,
698 ) -> Result<
699 coven_protocol::store_commit::device_join_exchange::DeviceJoinReadiness,
700 BootstrapError,
701 > {
702 let cloud = self.build_cloud_home().await?;
705 let mut timings =
706 StageTimings::counting("Device join bootstrap", cloud.provider_requests());
707 let outcome = Box::pin(self.bootstrap_pending_device_staged(
708 cloud,
709 bootstrap,
710 on_progress,
711 cancel,
712 &mut timings,
713 ))
714 .await;
715 timings.report();
716 outcome
717 }
718
719 async fn bootstrap_pending_device_staged(
720 &self,
721 cloud: Arc<dyn ExactCloudHome>,
722 bootstrap: coven_replication::sync::ProviderReadyDeviceBootstrap,
723 on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
724 cancel: &watch::Receiver<bool>,
725 timings: &mut StageTimings,
726 ) -> Result<
727 coven_protocol::store_commit::device_join_exchange::DeviceJoinReadiness,
728 BootstrapError,
729 > {
730 let offer = &bootstrap.bootstrap.request.approval().request.offer;
731 self.require_offer(offer)?;
732 let attempt_id = bootstrap.bootstrap.publication_authorization.attempt_id;
733 let pending = self.open_pending_journal()?;
734 if *cancel.borrow() {
735 return Err(BootstrapError::Cancelled);
736 }
737 let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
738 let join = timings
739 .stage("open Store storage", self.build_storage(cloud, &signer))
740 .await?;
741 let store_dir = self.layout.store_dir(&self.admission.store_id);
742 if let Some(readiness) = pending.completed_joiner_readiness(attempt_id)? {
743 if store_dir.db_path().exists() {
744 return Ok(readiness);
745 }
746 }
747 if store_dir.config_path().exists() {
748 return Err(BootstrapError::StoreExists(self.admission.store_id.clone()));
749 }
750 store_dir.ensure_created()?;
751 let db_path = store_dir.db_path();
752 let history_verifier = timings
753 .stage(
754 "read Store root",
755 coven_replication::sync::store::HistoryConstructionAuthority::for_snapshot()
756 .open_pinned(join.storage.as_ref(), &offer.store_root),
757 )
758 .await
759 .map_err(SnapshotError::from)?;
760 timings
764 .stage(
765 "read the membership rollup",
766 history_verifier.adopt_published_membership_rollup(),
767 )
768 .await;
769 let snapshot = timings
770 .stage(
771 "download snapshot",
772 PreparedSnapshotBootstrap::prepare_device_join(
773 &join.storage,
774 history_verifier,
775 &self.admission.membership_floor,
776 supported_version(&self.migrations),
777 &db_path,
778 &signer,
779 std::sync::Arc::clone(on_progress),
780 cancel,
781 &bootstrap
782 .bootstrap
783 .publication_authorization
784 .attempt_activation,
785 ),
786 )
787 .await?;
788 on_progress(coven_replication::sync::JoiningDeviceJoinProgress::InstallingSnapshot);
789 let routing_encryption = EncryptionService::from(join.keyring.clone());
790 let device_id = bootstrap
791 .bootstrap
792 .request
793 .expected_registration()
794 .device_id
795 .to_string();
796 let opened = timings
797 .stage(
798 "install snapshot",
799 snapshot.install(
800 &store_dir,
801 self.synced_tables.clone(),
802 coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
803 self.transfer_limits,
804 device_id,
805 self.clock.clone(),
806 &self.migrations,
807 self.coven_migration_policy,
808 Some(&routing_encryption),
809 ),
810 )
811 .await?;
812 let published_at = self.clock.now().to_rfc3339();
813 let mut joining = timings
814 .stage(
815 "load membership",
816 opened.begin_device_join(&pending, offer.as_ref().clone()),
817 )
818 .await?;
819 Ok(timings
820 .stage(
821 "install history",
822 joining.bootstrap(bootstrap, &published_at, Some(&routing_encryption)),
823 )
824 .await?)
825 }
826
827 pub(crate) async fn complete_device_join(
828 &self,
829 activation: coven_replication::sync::DeviceJoinActivation,
830 on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
831 ) -> Result<Config, BootstrapError> {
832 let cloud = self.build_cloud_home().await?;
835 let mut timings =
836 StageTimings::counting("Device join completion", cloud.provider_requests());
837 let outcome = Box::pin(self.complete_device_join_staged(
838 cloud,
839 activation,
840 on_progress,
841 &mut timings,
842 ))
843 .await;
844 timings.report();
845 outcome
846 }
847
848 async fn complete_device_join_staged(
849 &self,
850 cloud: Arc<dyn ExactCloudHome>,
851 activation: coven_replication::sync::DeviceJoinActivation,
852 on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
853 timings: &mut StageTimings,
854 ) -> Result<Config, BootstrapError> {
855 let attempt_id = activation.attempt_id;
856 let pending = self.open_pending_journal()?;
857 let store_dir = self.layout.store_dir(&self.admission.store_id);
858 let completed_config = if store_dir.config_path().exists() {
859 Some(Config::load_from_config_yaml(&store_dir)?)
860 } else {
861 None
862 };
863 if completed_config
864 .as_ref()
865 .is_some_and(|config| config.store_id != self.admission.store_id)
866 {
867 return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
868 }
869 let signer = match completed_config.as_ref() {
870 Some(_) => coven_keys::keys::require_identity(self.identity_custody.as_ref())?,
871 None => coven_keys::keys::peek_pending_identity(&self.member_pubkey)?,
872 };
873 let join = timings
874 .stage("open Store storage", self.build_storage(cloud, &signer))
875 .await?;
876 let pending_readiness = pending.observe_joiner_activation_if_pending(&activation)?;
877 let device_id = match (pending_readiness.as_ref(), completed_config.as_ref()) {
878 (Some(readiness), _) => readiness.proof.registration.device_id.to_string(),
879 (None, Some(config)) => config.device_id.clone(),
880 (None, None) => {
881 return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
882 }
883 };
884 let db_path = store_dir.db_path();
885 let db = Database::open(
886 &db_path,
887 self.synced_tables.clone(),
888 coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
889 self.transfer_limits,
890 device_id.clone(),
891 self.clock.clone(),
892 self.coven_migration_policy,
893 &self.migrations,
894 )?;
895 let database = coven_database::StoreDatabase::from_database(db.clone());
896 let routing_encryption = EncryptionService::from(join.keyring.clone());
897 let observation = timings
898 .stage(
899 "read Store root",
900 coven_replication::sync::store::PendingDeviceJoinObservation::open(
901 &pending,
902 &join.storage,
903 &self.admission.store_root,
904 attempt_id,
905 ),
906 )
907 .await?;
908 let mut joining = timings
909 .stage(
910 "load membership",
911 observation.into_joining_store(
912 database,
913 &store_dir,
914 signer.clone(),
915 Some(join.membership.chain().clone()),
916 Some(routing_encryption.clone()),
917 ),
918 )
919 .await?;
920 on_progress(coven_replication::sync::JoiningDeviceJoinProgress::CatchingUp);
921 timings
922 .stage(
923 "pull history",
924 joining.pull_store_history(Some(&routing_encryption)),
925 )
926 .await?;
927 let joined = timings
928 .stage(
929 "materialize activation",
930 joining.materialize(activation.clone()),
931 )
932 .await?;
933 if pending_readiness
934 .as_ref()
935 .is_some_and(|readiness| joined.registration != readiness.proof.registration)
936 || joined.registration.device_id.to_string() != device_id
937 {
938 return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
939 }
940 on_progress(coven_replication::sync::JoiningDeviceJoinProgress::SavingLibrary);
941 self.custody.persist(&join.keyring)?;
942 self.identity_custody.establish(&signer)?;
943 if let Some(credentials) = derive_credentials(&self.admission.join_info) {
944 self.store_keys.set_cloud_home_credentials(&credentials)?;
945 }
946 let cipher = CloudCipher::Encrypted(join.keyring.clone().into());
947 let mut config = super::build_config(
948 &self.admission.store_id,
949 &device_id,
950 &self.admission.store_name,
951 &self.admission.join_info,
952 &cipher,
953 );
954 config.cloud_home.exact_upload_verification = self.exact_upload_verification;
955 config.save_to_config_yaml(&store_dir)?;
956 timings
957 .stage("close join journal", joining.complete(activation))
958 .await?;
959 coven_keys::keys::discard_pending_identity(&self.member_pubkey)?;
960 info!(store_id = %self.admission.store_id, "joined Store device");
961 Ok(config)
962 }
963
964 pub(crate) async fn install_same_principal_device_join(
965 &self,
966 join: coven_replication::sync::SamePrincipalDeviceJoin,
967 on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
968 cancel: &watch::Receiver<bool>,
969 ) -> Result<Config, BootstrapError> {
970 let cloud = self.build_cloud_home().await?;
973 let mut timings =
974 StageTimings::counting("Same-provider device join", cloud.provider_requests());
975 let outcome = Box::pin(self.install_same_principal_device_join_staged(
976 cloud,
977 join,
978 on_progress,
979 cancel,
980 &mut timings,
981 ))
982 .await;
983 timings.report();
984 outcome
985 }
986
987 async fn install_same_principal_device_join_staged(
988 &self,
989 cloud: Arc<dyn ExactCloudHome>,
990 join: coven_replication::sync::SamePrincipalDeviceJoin,
991 on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
992 cancel: &watch::Receiver<bool>,
993 timings: &mut StageTimings,
994 ) -> Result<Config, BootstrapError> {
995 join.verify_shape()
996 .map_err(coven_replication::sync::DeviceJoinError::from)?;
997 let offer = &join.bootstrap.bootstrap.request.approval().request.offer;
998 self.require_offer(offer)?;
999 if *cancel.borrow() {
1000 return Err(BootstrapError::Cancelled);
1001 }
1002 let pending = self.open_pending_journal()?;
1003 let store_dir = self.layout.store_dir(&self.admission.store_id);
1004 if store_dir.config_path().exists() {
1005 return Err(BootstrapError::StoreExists(self.admission.store_id.clone()));
1006 }
1007 let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
1008 let storage = timings
1009 .stage("open Store storage", self.build_storage(cloud, &signer))
1010 .await?;
1011 store_dir.ensure_created()?;
1012 let prepared = timings
1013 .stage(
1014 "download snapshot",
1015 PreparedDeviceJoinSnapshot::prepare(
1016 &storage.storage,
1017 (*join.installation).clone(),
1018 &storage.membership,
1019 supported_version(&self.migrations),
1020 &store_dir.db_path(),
1021 on_progress,
1022 cancel,
1023 ),
1024 )
1025 .await?;
1026 if *cancel.borrow() {
1027 return Err(BootstrapError::Cancelled);
1028 }
1029 on_progress(coven_replication::sync::JoiningDeviceJoinProgress::InstallingSnapshot);
1030 let routing_encryption = EncryptionService::from(storage.keyring.clone());
1031 let device_id = join
1032 .bootstrap
1033 .bootstrap
1034 .request
1035 .expected_registration()
1036 .device_id
1037 .to_string();
1038 let installed = timings
1039 .stage("install snapshot", async {
1040 prepared.install(
1041 self.synced_tables.clone(),
1042 coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
1043 self.transfer_limits,
1044 device_id.clone(),
1045 self.clock.clone(),
1046 &self.migrations,
1047 self.coven_migration_policy,
1048 &routing_encryption,
1049 )
1050 })
1051 .await?;
1052 let completion = timings.stage("install history", coven_replication::sync::store::PendingDeviceJoinAuthority::prepare_same_principal_completion(
1053 &pending,
1054 &storage.storage,
1055 &store_dir,
1056 &signer,
1057 join,
1058 installed,
1059 &self.clock.now().to_rfc3339(),
1060 Some(&routing_encryption),
1061 Some(storage.membership.chain().clone()),
1062 ))
1063 .await?;
1064 if completion.joined().registration.device_id.to_string() != device_id {
1065 return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
1066 }
1067 on_progress(coven_replication::sync::JoiningDeviceJoinProgress::SavingLibrary);
1068 self.custody.persist(&storage.keyring)?;
1069 self.identity_custody.establish(&signer)?;
1070 if let Some(credentials) = derive_credentials(&self.admission.join_info) {
1071 self.store_keys.set_cloud_home_credentials(&credentials)?;
1072 }
1073 let cipher = CloudCipher::Encrypted(storage.keyring.clone().into());
1074 let mut config = super::build_config(
1075 &self.admission.store_id,
1076 &device_id,
1077 &self.admission.store_name,
1078 &self.admission.join_info,
1079 &cipher,
1080 );
1081 config.cloud_home.exact_upload_verification = self.exact_upload_verification;
1082 config.save_to_config_yaml(&store_dir)?;
1083 timings
1084 .stage("close join journal", completion.complete())
1085 .await?;
1086 coven_keys::keys::discard_pending_identity(&self.member_pubkey)?;
1087 info!(store_id = %self.admission.store_id, "joined Store device");
1088 Ok(config)
1089 }
1090
1091 fn require_offer(
1092 &self,
1093 offer: &coven_replication::sync::DeviceJoinOffer,
1094 ) -> Result<(), BootstrapError> {
1095 if offer.store_root != self.admission.store_root
1096 || offer.member_pubkey != self.member_pubkey
1097 {
1098 return Err(coven_replication::sync::DeviceJoinError::OfferMismatch.into());
1099 }
1100 Ok(())
1101 }
1102
1103 fn open_pending_journal(
1104 &self,
1105 ) -> Result<coven_replication::sync::DeviceJoinJournalDatabase, BootstrapError> {
1106 let directory = self.layout.stores_root().join(".pending-device-joins");
1107 Ok(coven_replication::sync::DeviceJoinJournalDatabase::open(
1108 directory.join(format!("{}.sqlite", self.admission.store_id)),
1109 )?)
1110 }
1111
1112 async fn build_cloud_home(&self) -> Result<Arc<dyn ExactCloudHome>, BootstrapError> {
1121 #[cfg(any(test, feature = "test-utils"))]
1122 if let Some(home) = &self.test_home {
1123 return Ok(home.clone());
1124 }
1125 let home = build_cloud_home_for_join(
1126 &self.admission.join_info,
1127 &self.store_keys,
1128 &self.cloud_homes,
1129 self.oauth_tokens.clone(),
1130 self.cloudkit_ops.clone(),
1131 self.clock.clone(),
1132 self.exact_upload_verification,
1133 )
1134 .await?;
1135 Ok(Arc::new(coven_storage::cloud::CountingCloudHome::new(home)))
1136 }
1137
1138 pub(super) async fn transport_storage(&self) -> Result<CloudSyncConnection, BootstrapError> {
1145 let signer = coven_keys::keys::peek_pending_identity(&self.member_pubkey)?;
1146 let cloud = self.build_cloud_home().await?;
1147 self.plaintext_storage(cloud, &signer)
1148 }
1149
1150 fn plaintext_storage(
1151 &self,
1152 home: Arc<dyn ExactCloudHome>,
1153 signer: &UserKeypair,
1154 ) -> Result<CloudSyncConnection, BootstrapError> {
1155 Ok(CloudSyncConnection::new(
1156 home,
1157 CloudCipher::Plaintext,
1158 BlobPathScheme::for_storage(HomeStorage::Opaque),
1159 self.admission.store_id.clone(),
1160 signer.clone(),
1161 ))
1162 }
1163
1164 async fn build_storage(
1179 &self,
1180 cloud: Arc<dyn ExactCloudHome>,
1181 signer: &UserKeypair,
1182 ) -> Result<DeviceJoinStorage, BootstrapError> {
1183 let mut timings =
1184 StageTimings::counting("Device join Store storage", cloud.provider_requests());
1185 let outcome = Box::pin(self.build_storage_staged(cloud, signer, &mut timings)).await;
1186 timings.report();
1187 outcome
1188 }
1189
1190 async fn build_storage_staged(
1191 &self,
1192 cloud: Arc<dyn ExactCloudHome>,
1193 signer: &UserKeypair,
1194 timings: &mut StageTimings,
1195 ) -> Result<DeviceJoinStorage, BootstrapError> {
1196 let bootstrap_storage = self.plaintext_storage(cloud.clone(), signer)?;
1197 let recipient = hex::encode(signer.public_key());
1198 if self.admission.wrapped_key.recipient_pubkey != recipient {
1199 return Err(
1200 coven_replication::sync::store::MembershipMutationError::Crypto(
1201 "admission wrapped-key ref names another recipient".to_string(),
1202 )
1203 .into(),
1204 );
1205 }
1206 self.admission
1207 .membership_floor
1208 .validate()
1209 .map_err(coven_replication::sync::store::MembershipMutationError::MembershipFloor)?;
1210 let history = timings
1211 .stage(
1212 "pin the Store root",
1213 coven_replication::sync::store::HistoryConstructionAuthority::admission()
1214 .open_pinned(&bootstrap_storage, &self.admission.store_root),
1215 )
1216 .await
1217 .map_err(coven_replication::sync::store::MembershipMutationError::from)?;
1218 timings
1222 .stage(
1223 "read the membership rollup",
1224 history.adopt_published_membership_rollup(),
1225 )
1226 .await;
1227 let chain = timings
1228 .stage(
1229 "walk the membership chain",
1230 history.load_accepted_membership_authority(
1231 &self.admission.membership_floor.0,
1232 Some(&self.admission.owner_pubkey),
1233 ),
1234 )
1235 .await
1236 .map_err(coven_replication::sync::store::MembershipMutationError::from)?;
1237 let encryption = timings
1238 .stage(
1239 "open the keyring",
1240 coven_replication::sync::store::StoreKeyrings::new(
1241 &bootstrap_storage,
1242 self.admission.store_root.clone(),
1243 )
1244 .open_containing(
1245 signer,
1246 chain.chain(),
1247 &self.admission.wrapped_key,
1248 ),
1249 )
1250 .await?;
1251 let keyring = MasterKeyring::from(encryption.clone());
1252 let storage = CloudSyncConnection::new(
1253 cloud,
1254 CloudCipher::Encrypted(encryption),
1255 BlobPathScheme::for_storage(HomeStorage::Opaque),
1256 self.admission.store_id.clone(),
1257 signer.clone(),
1258 );
1259 Ok(DeviceJoinStorage {
1260 storage: Arc::new(storage),
1261 keyring,
1262 membership: chain,
1263 })
1264 }
1265}
1266
1267pub(crate) fn derive_credentials(join_info: &CloudHomeJoinInfo) -> Option<CloudHomeCredentials> {
1270 match join_info {
1271 CloudHomeJoinInfo::S3 {
1272 access_key,
1273 secret_key,
1274 ..
1275 } => Some(CloudHomeCredentials::S3 {
1276 access_key: access_key.clone(),
1277 secret_key: secret_key.clone(),
1278 }),
1279 _ => None,
1280 }
1281}
1282
1283#[cfg(test)]
1284#[path = "client_tests.rs"]
1285mod tests;