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