1use std::sync::Arc;
8
9use tokio::sync::watch;
10use tracing::info;
11
12use crate::config::{Config, HomeStorage};
13use crate::custody::KeyCustody;
14use crate::encryption::MasterKeyring;
15use crate::identity_custody::IdentityCustody;
16use crate::keys::{DeviceIdentityCustody, MasterKeyCustody, StoreKeys, UserKeypair};
17use crate::migration::Migration;
18use crate::oauth::OAuthTokens;
19use crate::storage::cloud::setup::CoordinationClients;
20use crate::storage::cloud::{CloudHome, CloudHomeJoinInfo};
21use crate::store_dir::StoreLayout;
22use crate::sync::cloud_storage::{BlobPathScheme, CloudCipher, CloudSyncStorage};
23use crate::sync::join::{
24 bootstrap_and_save_store, cleanup_after_bootstrap_failure, BootstrapError,
25};
26use crate::sync::session::SyncedTable;
27
28pub struct RestoreSource {
33 pub join_info: CloudHomeJoinInfo,
34 pub custom_s3_serial: Option<crate::CustomS3Serial>,
35 pub custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
36 pub oauth_tokens: Option<OAuthTokens>,
37 pub cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
38}
39
40#[cfg(feature = "oauth-providers")]
45fn require_and_persist_oauth(
46 oauth_tokens: Option<OAuthTokens>,
47 store_id: &str,
48 provider_name: &str,
49) -> Result<(OAuthTokens, StoreKeys), BootstrapError> {
50 let tokens = oauth_tokens.ok_or_else(|| {
51 BootstrapError::Provider(format!("{provider_name} restore requires OAuth token"))
52 })?;
53 let ks = StoreKeys::new(store_id.to_string());
54 crate::sync::join::persist_oauth_tokens(&ks, &tokens)?;
55 Ok((tokens, ks))
56}
57
58async fn build_cloud_home(
60 source: RestoreSource,
61 store_id: &str,
62 clock: crate::clock::ClockRef,
63) -> Result<
64 (
65 CloudHomeJoinInfo,
66 Arc<dyn CloudHome>,
67 Option<CoordinationClients>,
68 ),
69 BootstrapError,
70> {
71 use crate::storage::cloud::*;
72
73 let RestoreSource {
74 join_info,
75 custom_s3_serial: _,
76 custom_s3_exact_slots,
77 oauth_tokens,
78 cloudkit_ops,
79 } = source;
80
81 #[cfg(not(feature = "oauth-providers"))]
83 let _ = (store_id, &clock, &oauth_tokens);
84
85 let (home, coordination): (Arc<dyn CloudHome>, Option<CoordinationClients>) = match &join_info {
86 CloudHomeJoinInfo::S3 {
87 bucket,
88 region,
89 endpoint,
90 access_key,
91 secret_key,
92 key_prefix,
93 } => {
94 let (home, peer) = s3::S3CloudHome::new_pair(
95 bucket.clone(),
96 region.clone(),
97 endpoint.clone(),
98 access_key.clone(),
99 secret_key.clone(),
100 key_prefix.clone(),
101 custom_s3_exact_slots,
102 )
103 .await?;
104 let home = Arc::new(home);
105 let peer = Arc::new(peer);
106 (home.clone(), Some((home, peer)))
107 }
108
109 CloudHomeJoinInfo::CloudKit => {
110 let ops = cloudkit_ops.ok_or_else(|| {
111 BootstrapError::Provider("CloudKit driver not provided".to_string())
112 })?;
113 let home = Arc::new(cloudkit::CloudKitCloudHome::new_private(ops.clone()));
114 let peer = Arc::new(cloudkit::CloudKitCloudHome::new_private(ops));
115 (home.clone(), Some((home, peer)))
116 }
117
118 CloudHomeJoinInfo::CloudKitShare { .. } => {
123 return Err(BootstrapError::Provider(
124 "restoring from a CloudKit share is not supported — restore recovers your own zone, not a shared one".to_string(),
125 ));
126 }
127
128 #[cfg(feature = "oauth-providers")]
129 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
130 let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "Google Drive")?;
131 (
132 Arc::new(google_drive::GoogleDriveCloudHome::new(
133 folder_id.clone(),
134 tokens,
135 ks,
136 clock,
137 )?),
138 None,
139 )
140 }
141
142 #[cfg(feature = "oauth-providers")]
143 CloudHomeJoinInfo::Dropbox { folder_path } => {
144 let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "Dropbox")?;
145 (
146 Arc::new(dropbox::DropboxCloudHome::new(
147 folder_path.clone(),
148 tokens,
149 ks,
150 clock,
151 )?),
152 None,
153 )
154 }
155
156 #[cfg(feature = "oauth-providers")]
157 CloudHomeJoinInfo::OneDrive {
158 drive_id,
159 folder_id,
160 } => {
161 let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "OneDrive")?;
162 (
163 Arc::new(onedrive::OneDriveCloudHome::new(
164 drive_id.clone(),
165 folder_id.clone(),
166 tokens,
167 ks,
168 clock,
169 )?),
170 None,
171 )
172 }
173
174 #[cfg(not(feature = "oauth-providers"))]
175 CloudHomeJoinInfo::GoogleDrive { .. }
176 | CloudHomeJoinInfo::Dropbox { .. }
177 | CloudHomeJoinInfo::OneDrive { .. } => {
178 return Err(BootstrapError::Provider(
179 "OAuth cloud providers are not supported in this build".to_string(),
180 ));
181 }
182 };
183
184 Ok((join_info, home, coordination))
185}
186
187#[allow(clippy::too_many_arguments)]
195pub async fn restore_from_cloud(
196 store_id: &str,
197 store_root: crate::sync::store_commit::StoreRootRef,
198 founder_pubkey: &str,
199 serialized_keyring: Option<&str>,
200 store_name: &str,
201 synced_tables: &[SyncedTable],
202 migrations: &[Migration],
203 expected_write_policy: crate::WritePolicy,
204 custody: Arc<dyn MasterKeyCustody>,
205 identity_custody: Arc<dyn DeviceIdentityCustody>,
206 source: RestoreSource,
207 membership_floor: &crate::join_code::MembershipFloor,
208 keypair: &UserKeypair,
209 authority: &crate::sync::restore_code::RestoreAuthority,
210 continuation_device_signer: Option<&UserKeypair>,
211 layout: &StoreLayout,
212 clock: crate::clock::ClockRef,
213 ids: crate::id_provider::IdRef,
214 on_status: impl Fn(&str),
215 cancel: &watch::Receiver<bool>,
216) -> Result<Config, BootstrapError> {
217 crate::store_dir::validate_path_token(store_id)
220 .map_err(|e| BootstrapError::InvalidCode(format!("invalid store id: {e}")))?;
221 let actual_write_policy = membership_floor.write_policy();
222 if actual_write_policy != expected_write_policy {
223 return Err(BootstrapError::WritePolicyMismatch {
224 expected: expected_write_policy,
225 actual: actual_write_policy,
226 });
227 }
228 crate::storage::cloud::setup::require_serial_coordination_join_info(
229 &source.join_info,
230 source.custom_s3_serial,
231 actual_write_policy,
232 )
233 .map_err(|provider| BootstrapError::SerialCoordinationUnavailable { provider })?;
234 crate::storage::cloud::setup::require_exact_slot_capabilities_join_info(
235 &source.join_info,
236 source.custom_s3_exact_slots,
237 )
238 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
239 let custom_s3_serial = source.custom_s3_serial;
240 let custom_s3_exact_slots = source.custom_s3_exact_slots;
241
242 let store_dir = layout.store_dir(store_id);
243
244 let store_keys = StoreKeys::new(store_id.to_string());
249
250 crate::sync::join::refuse_completed_or_clear_torn_store(
259 &store_dir,
260 &store_keys,
261 custody.as_ref(),
262 identity_custody.as_ref(),
263 store_id,
264 )?;
265
266 let result = async {
267 on_status("Preparing restore...");
268
269 let storage = if serialized_keyring.is_some() {
277 HomeStorage::Opaque
278 } else {
279 HomeStorage::Browsable
280 };
281 let master_key: Option<MasterKeyring> = match serialized_keyring {
282 Some(serialized_keyring) => {
283 on_status("Verifying encryption key...");
284 Some(MasterKeyring::from_serialized(serialized_keyring)?)
285 }
286 None => None,
287 };
288 let cipher = match &master_key {
289 Some(keyring) => CloudCipher::Encrypted(keyring.clone().into()),
290 None => CloudCipher::Plaintext,
291 };
292
293 let blob_paths = BlobPathScheme::for_storage(storage);
294
295 let (join_info, cloud_home, coordination) =
296 build_cloud_home(source, store_id, clock.clone()).await?;
297
298 let storage = CloudSyncStorage::new(
299 cloud_home,
300 cipher.clone(),
301 blob_paths,
302 store_id.to_string(),
303 keypair.clone(),
304 )?;
305 let storage = match coordination {
306 Some((primary, peer)) => storage.with_serial_coordination_clients(primary, peer),
307 None => storage,
308 };
309
310 let device_id = match authority {
314 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
315 continuation.registration.device_id.to_string()
316 }
317 crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_) => ids.new_id(),
318 };
319 std::fs::create_dir_all(&*store_dir)?;
320
321 let continuation = match (authority, continuation_device_signer) {
322 (
323 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation),
324 Some(device_signer),
325 ) => Some((continuation, device_signer)),
326 (crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(_), None) => {
327 return Err(BootstrapError::InvalidSigningKey(
328 "activated continuation has no device signing key".to_string(),
329 ));
330 }
331 (crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_), None) => None,
332 (crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_), Some(_)) => {
333 return Err(BootstrapError::InvalidSigningKey(
334 "Owner recovery cannot carry an activated device signer".to_string(),
335 ));
336 }
337 };
338
339 Box::pin(bootstrap_and_save_store(
340 &storage,
341 &cipher,
342 master_key.as_ref(),
343 &store_dir,
344 store_id,
345 &device_id,
346 store_root,
347 crate::sync::join::RestoreBootstrapContext {
348 founder_pubkey,
349 keypair,
350 authority,
351 continuation,
352 },
353 membership_floor,
354 synced_tables,
355 migrations,
356 &join_info,
357 store_name,
358 custom_s3_serial,
359 custom_s3_exact_slots,
360 &store_keys,
361 custody.as_ref(),
362 identity_custody.as_ref(),
363 &on_status,
364 cancel,
365 ))
366 .await
367 }
368 .await;
369
370 match result {
371 Ok(config) => {
372 info!(
374 "Cloud restore complete: store at {}",
375 config.store_dir.display()
376 );
377 Ok(config)
378 }
379 Err(err) => Err(cleanup_after_bootstrap_failure(
380 &store_dir,
381 &store_keys,
382 custody.as_ref(),
383 identity_custody.as_ref(),
384 err,
385 )),
386 }
387}
388
389#[allow(clippy::too_many_arguments)]
395pub async fn restore_from_code(
396 code: &str,
397 synced_tables: &[SyncedTable],
398 migrations: &[Migration],
399 expected_write_policy: crate::WritePolicy,
400 custom_s3_serial: Option<crate::CustomS3Serial>,
401 custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
402 key_custody: KeyCustody,
403 identity_custody: IdentityCustody,
404 oauth_tokens: Option<crate::oauth::OAuthTokens>,
405 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
406 layout: &StoreLayout,
407 clock: crate::clock::ClockRef,
408 ids: crate::id_provider::IdRef,
409 on_status: impl Fn(&str),
410 cancel: &watch::Receiver<bool>,
411) -> Result<Config, BootstrapError> {
412 use crate::sync::restore_code;
413
414 let parsed = restore_code::decode_restore_code(code)
415 .map_err(|e| BootstrapError::InvalidCode(e.to_string()))?;
416 let actual_write_policy = parsed.membership_floor.write_policy();
417 if actual_write_policy != expected_write_policy {
418 return Err(BootstrapError::WritePolicyMismatch {
419 expected: expected_write_policy,
420 actual: actual_write_policy,
421 });
422 }
423 crate::storage::cloud::setup::require_serial_coordination_join_info(
424 &parsed.provider,
425 custom_s3_serial,
426 actual_write_policy,
427 )
428 .map_err(|provider| BootstrapError::SerialCoordinationUnavailable { provider })?;
429 crate::storage::cloud::setup::require_exact_slot_capabilities_join_info(
430 &parsed.provider,
431 custom_s3_exact_slots,
432 )
433 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
434 let custody = key_custody.resolve(&parsed.sid, &layout.store_dir(&parsed.sid));
435 let identity_custody = identity_custody.resolve(&parsed.sid, &layout.store_dir(&parsed.sid));
436
437 let identity_secret = match &parsed.authority {
442 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
443 &continuation.identity_signing_secret
444 }
445 crate::sync::restore_code::RestoreAuthority::OwnerRecovery(recovery) => {
446 &recovery.owner_identity_secret
447 }
448 };
449 let signing_key: [u8; crate::keys::SIGN_SECRETKEYBYTES] = hex::decode(identity_secret)
450 .map_err(|e| BootstrapError::InvalidSigningKey(format!("invalid encoding: {e}")))?
451 .try_into()
452 .map_err(|_| {
453 BootstrapError::InvalidSigningKey(format!(
454 "Signing key must be {} bytes",
455 crate::keys::SIGN_SECRETKEYBYTES
456 ))
457 })?;
458 let keypair = UserKeypair::from_signing_key_bytes(&signing_key).map_err(BootstrapError::Key)?;
459 let continuation_device_signer = match &parsed.authority {
460 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
461 let bytes: [u8; crate::keys::SIGN_SECRETKEYBYTES] =
462 hex::decode(&continuation.device_signing_secret)
463 .map_err(|error| {
464 BootstrapError::InvalidSigningKey(format!(
465 "invalid device signing key encoding: {error}"
466 ))
467 })?
468 .try_into()
469 .map_err(|_| {
470 BootstrapError::InvalidSigningKey(format!(
471 "Device signing key must be {} bytes",
472 crate::keys::SIGN_SECRETKEYBYTES
473 ))
474 })?;
475 Some(UserKeypair::from_signing_key_bytes(&bytes).map_err(BootstrapError::Key)?)
476 }
477 crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_) => None,
478 };
479
480 let source = RestoreSource {
484 join_info: parsed.provider.clone(),
485 custom_s3_serial,
486 custom_s3_exact_slots,
487 oauth_tokens,
488 cloudkit_ops,
489 };
490
491 Box::pin(restore_from_cloud(
495 &parsed.sid,
496 parsed.store_root,
497 &parsed.founder_pubkey,
498 parsed.ek.as_deref(),
499 &parsed.name,
500 synced_tables,
501 migrations,
502 expected_write_policy,
503 custody.clone(),
504 identity_custody.clone(),
505 source,
506 &parsed.membership_floor,
507 &keypair,
508 &parsed.authority,
509 continuation_device_signer.as_ref(),
510 layout,
511 clock,
512 ids,
513 on_status,
514 cancel,
515 ))
516 .await
517}
518
519#[cfg(all(test, feature = "oauth-providers"))]
523mod tests {
524 use super::*;
525 use crate::keys::CloudHomeCredentials;
526
527 #[tokio::test]
535 async fn restore_dropbox_build_cloud_home_persists_oauth_tokens() {
536 crate::keys::test_keyring::install();
537 crate::oauth::install_test_client_creds();
538
539 let store_id = "restore-dropbox-persist-test";
540 let tokens = OAuthTokens {
541 access_token: "access-token".to_string(),
542 refresh_token: Some("refresh-token".to_string()),
543 expires_at: None,
544 };
545 let source = RestoreSource {
546 join_info: CloudHomeJoinInfo::Dropbox {
547 folder_path: "/Apps/coven/my-store".to_string(),
548 },
549 custom_s3_serial: None,
550 custom_s3_exact_slots: None,
551 oauth_tokens: Some(tokens.clone()),
552 cloudkit_ops: None,
553 };
554
555 build_cloud_home(source, store_id, Arc::new(crate::clock::SystemClock))
556 .await
557 .expect("build restore cloud home for Dropbox");
558
559 let stored = StoreKeys::new(store_id.to_string())
560 .get_cloud_home_credentials()
561 .expect("read cloud home credentials")
562 .expect("restore must persist OAuth tokens to the keyring");
563 match stored {
564 CloudHomeCredentials::OAuth { token_json } => {
565 let stored_tokens: OAuthTokens =
566 serde_json::from_str(&token_json).expect("stored OAuth tokens deserialize");
567 assert_eq!(stored_tokens.access_token, tokens.access_token);
568 assert_eq!(stored_tokens.refresh_token, tokens.refresh_token);
569 }
570 other => panic!("expected OAuth credentials, got {other:?}"),
571 }
572 }
573}
574
575#[cfg(test)]
579mod build_cloud_home_tests {
580 use super::*;
581
582 #[tokio::test]
590 async fn build_cloud_home_s3_preserves_key_prefix() {
591 let source = RestoreSource {
592 join_info: CloudHomeJoinInfo::S3 {
593 bucket: "b".to_string(),
594 region: "us-east-1".to_string(),
595 endpoint: None,
596 access_key: "ak".to_string(),
597 secret_key: "sk".to_string(),
598 key_prefix: Some("prefix/".to_string()),
599 },
600 custom_s3_serial: None,
601 custom_s3_exact_slots: None,
602 oauth_tokens: None,
603 cloudkit_ops: None,
604 };
605
606 let (returned_info, _home, coordination) =
607 build_cloud_home(source, "store-id", Arc::new(crate::clock::SystemClock))
608 .await
609 .expect("build S3 cloud home");
610 assert!(coordination.is_some());
611
612 match returned_info {
613 CloudHomeJoinInfo::S3 { key_prefix, .. } => {
614 assert_eq!(key_prefix, Some("prefix/".to_string()));
615 }
616 other => panic!("expected S3 join info, got {other:?}"),
617 }
618 }
619
620 #[tokio::test]
625 async fn build_cloud_home_rejects_cloudkit_share() {
626 let source = RestoreSource {
627 join_info: CloudHomeJoinInfo::CloudKitShare {
628 share_url: "https://share.example".to_string(),
629 owner_name: "owner".to_string(),
630 zone_name: "zone".to_string(),
631 },
632 custom_s3_serial: None,
633 custom_s3_exact_slots: None,
634 oauth_tokens: None,
635 cloudkit_ops: None,
636 };
637
638 let result =
639 build_cloud_home(source, "store-id", Arc::new(crate::clock::SystemClock)).await;
640
641 match result {
642 Err(BootstrapError::Provider(_)) => {}
643 Ok(_) => panic!("expected a Provider error rejecting the CloudKit share, got Ok"),
644 Err(other) => {
645 panic!("expected a Provider error rejecting the CloudKit share, got {other:?}")
646 }
647 }
648 }
649}