1use std::sync::Arc;
8
9use tokio::sync::watch;
10use tracing::info;
11
12use crate::joining::{build_config, derive_credentials, BootstrapCleanup, BootstrapError};
13use coven_database::{CovenMigrationPolicy, Migration};
14use coven_foundation::config::{Config, HomeStorage};
15use coven_foundation::store_dir::StoreLayout;
16use coven_keys::custody::KeyCustody;
17use coven_keys::encryption::{EncryptionService, MasterKeyring};
18use coven_keys::identity_custody::IdentityCustody;
19use coven_keys::keys::{StoreKeys, UserKeypair};
20use coven_protocol::synced_schema::SyncedTable;
21use coven_replication::sync::store::{PreparedSnapshotBootstrap, SnapshotError};
22use coven_storage::cloud::{CloudHomeJoinInfo, ExactCloudHome};
23use coven_storage::oauth::OAuthTokens;
24use coven_storage::{BlobPathScheme, CloudCipher, CloudSyncConnection};
25
26pub struct RestoreSource {
31 join_info: CloudHomeJoinInfo,
32 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
33 cloud_homes: coven_storage::cloud::CloudHomeFactory,
34 oauth_tokens: Option<OAuthTokens>,
35 cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
36}
37
38impl RestoreSource {
39 pub fn new(
40 join_info: CloudHomeJoinInfo,
41 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
42 oauth_clients: coven_storage::oauth::OAuthClients,
43 oauth_tokens: Option<OAuthTokens>,
44 cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
45 ) -> Self {
46 Self {
47 join_info,
48 exact_upload_verification,
49 cloud_homes: coven_storage::cloud::CloudHomeFactory::new(oauth_clients),
50 oauth_tokens,
51 cloudkit_ops,
52 }
53 }
54
55 async fn open_cloud_home(
56 &self,
57 store_keys: &StoreKeys,
58 clock: coven_foundation::clock::ClockRef,
59 ) -> Result<Arc<dyn ExactCloudHome>, BootstrapError> {
60 use coven_storage::cloud::*;
61
62 let Self {
63 join_info,
64 exact_upload_verification,
65 cloud_homes,
66 oauth_tokens,
67 cloudkit_ops,
68 } = self;
69
70 #[cfg(not(feature = "oauth-providers"))]
72 let _ = (&store_keys, &clock, &cloud_homes, &oauth_tokens);
73
74 #[cfg(feature = "oauth-providers")]
75 let require_oauth = |provider_name: &str| {
76 let tokens = oauth_tokens.clone().ok_or_else(|| {
77 BootstrapError::Provider(format!("{provider_name} restore requires OAuth token"))
78 })?;
79 store_keys.set_cloud_home_oauth_tokens(&tokens)?;
80 Ok::<_, BootstrapError>(tokens)
81 };
82
83 #[cfg(feature = "oauth-providers")]
84 let credential_custody =
85 coven_keys::keys::CloudHomeCredentialsOwner::new(store_keys.clone()).current();
86
87 let home: Arc<dyn ExactCloudHome> = match join_info {
88 CloudHomeJoinInfo::S3 {
89 bucket,
90 region,
91 endpoint,
92 access_key,
93 secret_key,
94 key_prefix,
95 } => Arc::new(
96 cloud_homes
97 .open_s3(
98 bucket.clone(),
99 region.clone(),
100 endpoint.clone(),
101 access_key.clone(),
102 secret_key.clone(),
103 key_prefix.clone(),
104 *exact_upload_verification,
105 clock.clone(),
106 )
107 .await?,
108 ),
109
110 CloudHomeJoinInfo::CloudKit => {
111 let ops = cloudkit_ops.clone().ok_or_else(|| {
112 BootstrapError::Provider("CloudKit driver not provided".to_string())
113 })?;
114 Arc::new(cloudkit::CloudKitCloudHome::new_private(
115 ops,
116 *exact_upload_verification,
117 ))
118 }
119
120 CloudHomeJoinInfo::CloudKitShare { .. } => {
125 return Err(BootstrapError::Provider(
126 "restoring from a CloudKit share is not supported — restore recovers your own zone, not a shared one".to_string(),
127 ));
128 }
129
130 #[cfg(feature = "oauth-providers")]
131 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
132 let tokens = require_oauth("Google Drive")?;
133 let oauth_config = cloud_homes
134 .oauth_config_for(coven_foundation::config::CloudProvider::GoogleDrive)?;
135 let session = oauth_session::OAuthSession::new(
136 tokens,
137 credential_custody.clone(),
138 clock,
139 oauth_config,
140 "Google Drive",
141 );
142 Arc::new(google_drive::GoogleDriveCloudHome::new(
143 folder_id.clone(),
144 session,
145 *exact_upload_verification,
146 ))
147 }
148
149 #[cfg(feature = "oauth-providers")]
150 CloudHomeJoinInfo::Dropbox { folder_path } => {
151 let tokens = require_oauth("Dropbox")?;
152 let oauth_config = cloud_homes
153 .oauth_config_for(coven_foundation::config::CloudProvider::Dropbox)?;
154 let session = oauth_session::OAuthSession::new(
155 tokens,
156 credential_custody.clone(),
157 clock,
158 oauth_config,
159 "Dropbox",
160 );
161 Arc::new(dropbox::DropboxCloudHome::new(
162 folder_path.clone(),
163 session,
164 *exact_upload_verification,
165 ))
166 }
167
168 #[cfg(feature = "oauth-providers")]
169 CloudHomeJoinInfo::OneDrive {
170 drive_id,
171 folder_id,
172 } => {
173 let tokens = require_oauth("OneDrive")?;
174 let oauth_config = cloud_homes
175 .oauth_config_for(coven_foundation::config::CloudProvider::OneDrive)?;
176 let session = oauth_session::OAuthSession::new(
177 tokens,
178 credential_custody,
179 clock,
180 oauth_config,
181 "OneDrive",
182 );
183 Arc::new(onedrive::OneDriveCloudHome::new(
184 drive_id.clone(),
185 folder_id.clone(),
186 session,
187 *exact_upload_verification,
188 ))
189 }
190
191 #[cfg(not(feature = "oauth-providers"))]
192 CloudHomeJoinInfo::GoogleDrive { .. }
193 | CloudHomeJoinInfo::Dropbox { .. }
194 | CloudHomeJoinInfo::OneDrive { .. } => {
195 return Err(BootstrapError::Provider(
196 "OAuth cloud providers are not supported in this build".to_string(),
197 ));
198 }
199 };
200
201 Ok(home)
202 }
203}
204
205#[allow(clippy::too_many_arguments)]
214pub async fn restore_from_cloud(
215 store_id: &str,
216 store_root: coven_protocol::store_commit::StoreRootRef,
217 serialized_keyring: Option<&str>,
218 store_name: &str,
219 synced_tables: &[SyncedTable],
220 migrations: &[Migration],
221 coven_migration_policy: CovenMigrationPolicy,
222 transfer_limits: coven_protocol::blob::TransferLimits,
223 key_custody: KeyCustody,
224 identity_custody: IdentityCustody,
225 source: RestoreSource,
226 membership_floor: &coven_protocol::membership::MembershipFloor,
227 keypair: &UserKeypair,
228 authority: &coven_protocol::recovery::RestoreAuthority,
229 continuation_device_signer: Option<&UserKeypair>,
230 layout: &StoreLayout,
231 clock: coven_foundation::clock::ClockRef,
232 ids: coven_foundation::id_provider::IdRef,
233 on_status: impl Fn(&str),
234 cancel: &watch::Receiver<bool>,
235) -> Result<Config, BootstrapError> {
236 coven_foundation::store_dir::validate_path_token(store_id)?;
239 coven_storage::cloud::setup::require_exact_slot_capabilities_join_info(
240 &source.join_info,
241 source.exact_upload_verification,
242 )
243 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
244 let exact_upload_verification = source.exact_upload_verification;
245
246 let store_dir = layout.store_dir(store_id);
247
248 let store_keys = StoreKeys::bind(store_id.to_string());
253 let custody = key_custody.resolve(&store_keys, &store_dir);
254 let identity_custody = identity_custody.resolve(&store_keys, &store_dir);
255
256 let cleanup = BootstrapCleanup::new(
265 &store_dir,
266 &store_keys,
267 custody.as_ref(),
268 identity_custody.as_ref(),
269 );
270 cleanup.refuse_completed_or_clear(store_id)?;
271
272 let result = async {
273 on_status("Preparing restore...");
274
275 let storage = if serialized_keyring.is_some() {
282 HomeStorage::Opaque
283 } else {
284 HomeStorage::Browsable
285 };
286 let master_key: Option<MasterKeyring> = match serialized_keyring {
287 Some(serialized_keyring) => {
288 on_status("Verifying encryption key...");
289 Some(MasterKeyring::from_serialized(serialized_keyring)?)
290 }
291 None => None,
292 };
293 let cipher = match &master_key {
294 Some(keyring) => CloudCipher::Encrypted(keyring.clone().into()),
295 None => CloudCipher::Plaintext,
296 };
297
298 let blob_paths = BlobPathScheme::for_storage(storage);
299
300 let cloud_home: Arc<dyn ExactCloudHome> =
301 Arc::new(coven_storage::cloud::CountingCloudHome::new(
302 source.open_cloud_home(&store_keys, clock.clone()).await?,
303 ));
304 let join_info = &source.join_info;
305
306 let storage: Arc<dyn coven_storage::CloudSyncObjectStorage> =
307 Arc::new(CloudSyncConnection::new(
308 cloud_home,
309 cipher.clone(),
310 blob_paths,
311 store_id.to_string(),
312 keypair.clone(),
313 ));
314
315 let device_id = match authority {
319 coven_protocol::recovery::RestoreAuthority::ActivatedContinuation(continuation) => {
320 continuation.registration.device_id.to_string()
321 }
322 coven_protocol::recovery::RestoreAuthority::OwnerRecovery(_) => ids.new_id(),
323 };
324 store_dir.ensure_created()?;
325
326 let continuation = match (authority, continuation_device_signer) {
327 (
328 coven_protocol::recovery::RestoreAuthority::ActivatedContinuation(continuation),
329 Some(_),
330 ) => Some(continuation),
331 (coven_protocol::recovery::RestoreAuthority::ActivatedContinuation(_), None) => {
332 return Err(crate::joining::SigningKeyError::MissingContinuationSigner.into());
333 }
334 (coven_protocol::recovery::RestoreAuthority::OwnerRecovery(_), None) => None,
335 (coven_protocol::recovery::RestoreAuthority::OwnerRecovery(_), Some(_)) => {
336 return Err(crate::joining::SigningKeyError::UnexpectedOwnerRecoverySigner.into());
337 }
338 };
339
340 if *cancel.borrow() {
344 return Err(BootstrapError::Cancelled);
345 }
346 on_status("Downloading store snapshot...");
347 let history_verifier =
348 coven_replication::sync::store::HistoryConstructionAuthority::for_snapshot()
349 .open_pinned(storage.as_ref(), &store_root)
350 .await
351 .map_err(SnapshotError::from)?;
352 history_verifier.adopt_published_membership_rollup().await;
356 let bootstrap = PreparedSnapshotBootstrap::prepare(
357 &storage,
358 history_verifier,
359 membership_floor,
360 coven_database::supported_version(migrations),
361 &store_dir.db_path(),
362 keypair,
363 std::sync::Arc::new(|_| {}),
364 cancel,
365 )
366 .await?;
367
368 info!(
369 "Bootstrapped from snapshot ({} device coverage entries)",
370 bootstrap.coverage_count()
371 );
372
373 if *cancel.borrow() {
374 return Err(BootstrapError::Cancelled);
375 }
376 on_status("Applying recent changes...");
377 let routing_encryption = master_key
378 .as_ref()
379 .map(|keyring| EncryptionService::from(keyring.clone()));
380 let mut store = bootstrap
381 .install(
382 &store_dir,
383 synced_tables.to_vec(),
384 coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
385 transfer_limits,
386 device_id.clone(),
387 clock.clone(),
388 migrations,
389 coven_migration_policy,
390 routing_encryption.as_ref(),
391 )
392 .await?;
393
394 if *cancel.borrow() {
395 return Err(BootstrapError::Cancelled);
396 }
397 let pull_result = store.pull(routing_encryption.as_ref()).await?;
398
399 if let Some(continuation) = continuation {
400 store
401 .install_activated_device_continuation(continuation.clone())
402 .await?;
403 }
404 if let coven_protocol::recovery::RestoreAuthority::OwnerRecovery(recovery) = authority {
405 store
406 .recover_owner_device(recovery, routing_encryption.as_ref())
407 .await?;
408 }
409
410 if pull_result.changesets_applied > 0 {
411 info!(
412 "Applied {} changesets since snapshot",
413 pull_result.changesets_applied
414 );
415 }
416
417 if let Some(keyring) = &master_key {
418 custody.persist(keyring)?;
419 }
420 if let Some(credentials) = derive_credentials(join_info) {
421 store_keys.set_cloud_home_credentials(&credentials)?;
422 }
423 identity_custody.establish(keypair)?;
424
425 on_status("Saving configuration...");
428 let mut config = build_config(store_id, &device_id, store_name, join_info, &cipher);
429 config.cloud_home.exact_upload_verification = exact_upload_verification;
430 config.save_to_config_yaml(&store_dir)?;
431 Ok(config)
432 }
433 .await;
434
435 match result {
436 Ok(config) => {
437 info!("Cloud restore complete: store at {}", store_dir.display());
439 Ok(config)
440 }
441 Err(err) => Err(cleanup.after_failure(err)),
442 }
443}
444
445#[allow(clippy::too_many_arguments)]
452pub async fn restore_from_code(
453 code: &str,
454 synced_tables: &[SyncedTable],
455 migrations: &[Migration],
456 coven_migration_policy: CovenMigrationPolicy,
457 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
458 transfer_limits: coven_protocol::blob::TransferLimits,
459 key_custody: KeyCustody,
460 identity_custody: IdentityCustody,
461 oauth_clients: coven_storage::oauth::OAuthClients,
462 oauth_tokens: Option<coven_storage::oauth::OAuthTokens>,
463 cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
464 layout: &StoreLayout,
465 clock: coven_foundation::clock::ClockRef,
466 ids: coven_foundation::id_provider::IdRef,
467 on_status: impl Fn(&str),
468 cancel: &watch::Receiver<bool>,
469) -> Result<Config, BootstrapError> {
470 let parsed = super::code::decode_restore_code(code)?;
471 coven_storage::cloud::setup::require_exact_slot_capabilities_join_info(
472 &parsed.provider,
473 exact_upload_verification,
474 )
475 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
476 let identity_secret = match &parsed.authority {
481 coven_protocol::recovery::RestoreAuthority::ActivatedContinuation(continuation) => {
482 &continuation.identity_signing_secret
483 }
484 coven_protocol::recovery::RestoreAuthority::OwnerRecovery(recovery) => {
485 &recovery.owner_identity_secret
486 }
487 };
488 let signing_key: [u8; coven_keys::keys::SIGN_SECRETKEYBYTES] =
489 coven_foundation::code_envelope::decode_fixed_hex(
490 "identity signing key",
491 identity_secret,
492 coven_keys::keys::SIGN_SECRETKEYBYTES,
493 )
494 .map_err(crate::joining::SigningKeyError::from)?
495 .try_into()
496 .expect("decode_fixed_hex returned the requested signing-key length");
497 let keypair = UserKeypair::from_signing_key_bytes(&signing_key).map_err(BootstrapError::Key)?;
498 let continuation_device_signer = match &parsed.authority {
499 coven_protocol::recovery::RestoreAuthority::ActivatedContinuation(continuation) => {
500 let bytes: [u8; coven_keys::keys::SIGN_SECRETKEYBYTES] =
501 coven_foundation::code_envelope::decode_fixed_hex(
502 "device signing key",
503 &continuation.device_signing_secret,
504 coven_keys::keys::SIGN_SECRETKEYBYTES,
505 )
506 .map_err(crate::joining::SigningKeyError::from)?
507 .try_into()
508 .expect("decode_fixed_hex returned the requested signing-key length");
509 Some(UserKeypair::from_signing_key_bytes(&bytes).map_err(BootstrapError::Key)?)
510 }
511 coven_protocol::recovery::RestoreAuthority::OwnerRecovery(_) => None,
512 };
513
514 let source = RestoreSource::new(
518 parsed.provider.clone(),
519 exact_upload_verification,
520 oauth_clients,
521 oauth_tokens,
522 cloudkit_ops,
523 );
524
525 Box::pin(restore_from_cloud(
529 &parsed.sid,
530 parsed.store_root,
531 parsed.ek.as_deref(),
532 &parsed.name,
533 synced_tables,
534 migrations,
535 coven_migration_policy,
536 transfer_limits,
537 key_custody,
538 identity_custody,
539 source,
540 &parsed.membership_floor,
541 &keypair,
542 &parsed.authority,
543 continuation_device_signer.as_ref(),
544 layout,
545 clock,
546 ids,
547 on_status,
548 cancel,
549 ))
550 .await
551}
552
553#[cfg(all(test, feature = "oauth-providers"))]
557mod tests {
558 use super::*;
559
560 #[tokio::test]
568 async fn restore_dropbox_open_cloud_home_persists_oauth_tokens() {
569 coven_keys::keys::test_keyring::install();
570
571 let store_id = "restore-dropbox-persist-test";
572 let tokens = OAuthTokens {
573 access_token: "access-token".to_string(),
574 refresh_token: Some("refresh-token".to_string()),
575 expires_at: None,
576 };
577 let source = RestoreSource::new(
578 CloudHomeJoinInfo::Dropbox {
579 folder_path: "/Apps/coven/my-store".to_string(),
580 },
581 coven_foundation::config::ExactUploadVerification::MetadataHash,
582 coven_storage::oauth::OAuthClients::for_tests(),
583 Some(tokens.clone()),
584 None,
585 );
586
587 let store_keys = StoreKeys::bind(store_id.to_string());
588 source
589 .open_cloud_home(&store_keys, Arc::new(coven_foundation::clock::SystemClock))
590 .await
591 .expect("build restore cloud home for Dropbox");
592
593 let stored = StoreKeys::bind(store_id.to_string())
594 .get_cloud_home_oauth_tokens()
595 .expect("read cloud home credentials")
596 .expect("restore must persist OAuth tokens to the keyring");
597 assert_eq!(stored.access_token, tokens.access_token);
598 assert_eq!(stored.refresh_token, tokens.refresh_token);
599 }
600}
601
602#[cfg(test)]
606mod open_cloud_home_tests {
607 use super::*;
608
609 #[tokio::test]
612 async fn open_cloud_home_s3_preserves_key_prefix() {
613 let source = RestoreSource::new(
614 CloudHomeJoinInfo::S3 {
615 bucket: "b".to_string(),
616 region: "us-east-1".to_string(),
617 endpoint: None,
618 access_key: "ak".to_string(),
619 secret_key: "sk".to_string(),
620 key_prefix: Some("prefix/".to_string()),
621 },
622 coven_foundation::config::ExactUploadVerification::MetadataHash,
623 coven_storage::oauth::OAuthClients::empty(),
624 None,
625 None,
626 );
627
628 let store_keys = StoreKeys::bind("store-id".to_string());
629 source
630 .open_cloud_home(&store_keys, Arc::new(coven_foundation::clock::SystemClock))
631 .await
632 .expect("build S3 cloud home");
633
634 match &source.join_info {
635 CloudHomeJoinInfo::S3 { key_prefix, .. } => {
636 assert_eq!(key_prefix, &Some("prefix/".to_string()));
637 }
638 other => panic!("expected S3 join info, got {other:?}"),
639 }
640 }
641
642 #[tokio::test]
647 async fn open_cloud_home_rejects_cloudkit_share() {
648 let source = RestoreSource::new(
649 CloudHomeJoinInfo::CloudKitShare {
650 share_url: "https://share.example".to_string(),
651 owner_name: "owner".to_string(),
652 zone_name: "zone".to_string(),
653 },
654 coven_foundation::config::ExactUploadVerification::MetadataHash,
655 coven_storage::oauth::OAuthClients::empty(),
656 None,
657 None,
658 );
659
660 let store_keys = StoreKeys::bind("store-id".to_string());
661 let result = source
662 .open_cloud_home(&store_keys, Arc::new(coven_foundation::clock::SystemClock))
663 .await;
664
665 match result {
666 Err(BootstrapError::Provider(_)) => {}
667 Ok(_) => panic!("expected a Provider error rejecting the CloudKit share, got Ok"),
668 Err(other) => {
669 panic!("expected a Provider error rejecting the CloudKit share, got {other:?}")
670 }
671 }
672 }
673}