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::{CloudHome, CloudHomeJoinInfo};
20use crate::store_dir::StoreLayout;
21use crate::sync::cloud_storage::{BlobPathScheme, CloudCipher, CloudSyncStorage};
22use crate::sync::join::{
23 bootstrap_and_save_store, cleanup_after_bootstrap_failure, BootstrapError,
24};
25use crate::sync::session::SyncedTable;
26
27pub struct RestoreSource {
32 pub join_info: CloudHomeJoinInfo,
33 pub custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
34 pub oauth_tokens: Option<OAuthTokens>,
35 pub cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
36}
37
38#[cfg(feature = "oauth-providers")]
43fn require_and_persist_oauth(
44 oauth_tokens: Option<OAuthTokens>,
45 store_id: &str,
46 provider_name: &str,
47) -> Result<(OAuthTokens, StoreKeys), BootstrapError> {
48 let tokens = oauth_tokens.ok_or_else(|| {
49 BootstrapError::Provider(format!("{provider_name} restore requires OAuth token"))
50 })?;
51 let ks = StoreKeys::new(store_id.to_string());
52 crate::sync::join::persist_oauth_tokens(&ks, &tokens)?;
53 Ok((tokens, ks))
54}
55
56async fn build_cloud_home(
58 source: RestoreSource,
59 store_id: &str,
60 clock: crate::clock::ClockRef,
61) -> Result<(CloudHomeJoinInfo, Arc<dyn CloudHome>), BootstrapError> {
62 use crate::storage::cloud::*;
63
64 let RestoreSource {
65 join_info,
66 custom_s3_exact_slots,
67 oauth_tokens,
68 cloudkit_ops,
69 } = source;
70
71 #[cfg(not(feature = "oauth-providers"))]
73 let _ = (store_id, &clock, &oauth_tokens);
74
75 let home: Arc<dyn CloudHome> = match &join_info {
76 CloudHomeJoinInfo::S3 {
77 bucket,
78 region,
79 endpoint,
80 access_key,
81 secret_key,
82 key_prefix,
83 } => Arc::new(
84 s3::S3CloudHome::new(
85 bucket.clone(),
86 region.clone(),
87 endpoint.clone(),
88 access_key.clone(),
89 secret_key.clone(),
90 key_prefix.clone(),
91 custom_s3_exact_slots,
92 )
93 .await?,
94 ),
95
96 CloudHomeJoinInfo::CloudKit => {
97 let ops = cloudkit_ops.ok_or_else(|| {
98 BootstrapError::Provider("CloudKit driver not provided".to_string())
99 })?;
100 Arc::new(cloudkit::CloudKitCloudHome::new_private(ops))
101 }
102
103 CloudHomeJoinInfo::CloudKitShare { .. } => {
108 return Err(BootstrapError::Provider(
109 "restoring from a CloudKit share is not supported — restore recovers your own zone, not a shared one".to_string(),
110 ));
111 }
112
113 #[cfg(feature = "oauth-providers")]
114 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
115 let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "Google Drive")?;
116 Arc::new(google_drive::GoogleDriveCloudHome::new(
117 folder_id.clone(),
118 tokens,
119 ks,
120 clock,
121 )?)
122 }
123
124 #[cfg(feature = "oauth-providers")]
125 CloudHomeJoinInfo::Dropbox { folder_path } => {
126 let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "Dropbox")?;
127 Arc::new(dropbox::DropboxCloudHome::new(
128 folder_path.clone(),
129 tokens,
130 ks,
131 clock,
132 )?)
133 }
134
135 #[cfg(feature = "oauth-providers")]
136 CloudHomeJoinInfo::OneDrive {
137 drive_id,
138 folder_id,
139 } => {
140 let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "OneDrive")?;
141 Arc::new(onedrive::OneDriveCloudHome::new(
142 drive_id.clone(),
143 folder_id.clone(),
144 tokens,
145 ks,
146 clock,
147 )?)
148 }
149
150 #[cfg(not(feature = "oauth-providers"))]
151 CloudHomeJoinInfo::GoogleDrive { .. }
152 | CloudHomeJoinInfo::Dropbox { .. }
153 | CloudHomeJoinInfo::OneDrive { .. } => {
154 return Err(BootstrapError::Provider(
155 "OAuth cloud providers are not supported in this build".to_string(),
156 ));
157 }
158 };
159
160 Ok((join_info, home))
161}
162
163#[allow(clippy::too_many_arguments)]
171pub async fn restore_from_cloud(
172 store_id: &str,
173 store_root: crate::sync::store_commit::StoreRootRef,
174 founder_pubkey: &str,
175 serialized_keyring: Option<&str>,
176 store_name: &str,
177 synced_tables: &[SyncedTable],
178 migrations: &[Migration],
179 custody: Arc<dyn MasterKeyCustody>,
180 identity_custody: Arc<dyn DeviceIdentityCustody>,
181 source: RestoreSource,
182 membership_floor: &crate::join_code::MembershipFloor,
183 keypair: &UserKeypair,
184 authority: &crate::sync::restore_code::RestoreAuthority,
185 continuation_device_signer: Option<&UserKeypair>,
186 layout: &StoreLayout,
187 clock: crate::clock::ClockRef,
188 ids: crate::id_provider::IdRef,
189 on_status: impl Fn(&str),
190 cancel: &watch::Receiver<bool>,
191) -> Result<Config, BootstrapError> {
192 crate::store_dir::validate_path_token(store_id)
195 .map_err(|e| BootstrapError::InvalidCode(format!("invalid store id: {e}")))?;
196 crate::storage::cloud::setup::require_exact_slot_capabilities_join_info(
197 &source.join_info,
198 source.custom_s3_exact_slots,
199 )
200 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
201 let custom_s3_exact_slots = source.custom_s3_exact_slots;
202
203 let store_dir = layout.store_dir(store_id);
204
205 let store_keys = StoreKeys::new(store_id.to_string());
210
211 crate::sync::join::refuse_completed_or_clear_torn_store(
220 &store_dir,
221 &store_keys,
222 custody.as_ref(),
223 identity_custody.as_ref(),
224 store_id,
225 )?;
226
227 let result = async {
228 on_status("Preparing restore...");
229
230 let storage = if serialized_keyring.is_some() {
238 HomeStorage::Opaque
239 } else {
240 HomeStorage::Browsable
241 };
242 let master_key: Option<MasterKeyring> = match serialized_keyring {
243 Some(serialized_keyring) => {
244 on_status("Verifying encryption key...");
245 Some(MasterKeyring::from_serialized(serialized_keyring)?)
246 }
247 None => None,
248 };
249 let cipher = match &master_key {
250 Some(keyring) => CloudCipher::Encrypted(keyring.clone().into()),
251 None => CloudCipher::Plaintext,
252 };
253
254 let blob_paths = BlobPathScheme::for_storage(storage);
255
256 let (join_info, cloud_home) = build_cloud_home(source, store_id, clock.clone()).await?;
257
258 let storage = CloudSyncStorage::new(
259 cloud_home,
260 cipher.clone(),
261 blob_paths,
262 store_id.to_string(),
263 keypair.clone(),
264 )?;
265
266 let device_id = match authority {
270 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
271 continuation.registration.device_id.to_string()
272 }
273 crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_) => ids.new_id(),
274 };
275 std::fs::create_dir_all(&*store_dir)?;
276
277 let continuation = match (authority, continuation_device_signer) {
278 (
279 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation),
280 Some(device_signer),
281 ) => Some((continuation, device_signer)),
282 (crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(_), None) => {
283 return Err(BootstrapError::InvalidSigningKey(
284 "activated continuation has no device signing key".to_string(),
285 ));
286 }
287 (crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_), None) => None,
288 (crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_), Some(_)) => {
289 return Err(BootstrapError::InvalidSigningKey(
290 "Owner recovery cannot carry an activated device signer".to_string(),
291 ));
292 }
293 };
294
295 Box::pin(bootstrap_and_save_store(
296 &storage,
297 &cipher,
298 master_key.as_ref(),
299 &store_dir,
300 store_id,
301 &device_id,
302 store_root,
303 crate::sync::join::RestoreBootstrapContext {
304 founder_pubkey,
305 keypair,
306 authority,
307 continuation,
308 },
309 membership_floor,
310 synced_tables,
311 migrations,
312 &join_info,
313 store_name,
314 custom_s3_exact_slots,
315 &store_keys,
316 custody.as_ref(),
317 identity_custody.as_ref(),
318 &on_status,
319 cancel,
320 ))
321 .await
322 }
323 .await;
324
325 match result {
326 Ok(config) => {
327 info!(
329 "Cloud restore complete: store at {}",
330 config.store_dir.display()
331 );
332 Ok(config)
333 }
334 Err(err) => Err(cleanup_after_bootstrap_failure(
335 &store_dir,
336 &store_keys,
337 custody.as_ref(),
338 identity_custody.as_ref(),
339 err,
340 )),
341 }
342}
343
344#[allow(clippy::too_many_arguments)]
350pub async fn restore_from_code(
351 code: &str,
352 synced_tables: &[SyncedTable],
353 migrations: &[Migration],
354 custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
355 key_custody: KeyCustody,
356 identity_custody: IdentityCustody,
357 oauth_tokens: Option<crate::oauth::OAuthTokens>,
358 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
359 layout: &StoreLayout,
360 clock: crate::clock::ClockRef,
361 ids: crate::id_provider::IdRef,
362 on_status: impl Fn(&str),
363 cancel: &watch::Receiver<bool>,
364) -> Result<Config, BootstrapError> {
365 use crate::sync::restore_code;
366
367 let parsed = restore_code::decode_restore_code(code)
368 .map_err(|e| BootstrapError::InvalidCode(e.to_string()))?;
369 crate::storage::cloud::setup::require_exact_slot_capabilities_join_info(
370 &parsed.provider,
371 custom_s3_exact_slots,
372 )
373 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
374 let custody = key_custody.resolve(&parsed.sid, &layout.store_dir(&parsed.sid));
375 let identity_custody = identity_custody.resolve(&parsed.sid, &layout.store_dir(&parsed.sid));
376
377 let identity_secret = match &parsed.authority {
382 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
383 &continuation.identity_signing_secret
384 }
385 crate::sync::restore_code::RestoreAuthority::OwnerRecovery(recovery) => {
386 &recovery.owner_identity_secret
387 }
388 };
389 let signing_key: [u8; crate::keys::SIGN_SECRETKEYBYTES] = hex::decode(identity_secret)
390 .map_err(|e| BootstrapError::InvalidSigningKey(format!("invalid encoding: {e}")))?
391 .try_into()
392 .map_err(|_| {
393 BootstrapError::InvalidSigningKey(format!(
394 "Signing key must be {} bytes",
395 crate::keys::SIGN_SECRETKEYBYTES
396 ))
397 })?;
398 let keypair = UserKeypair::from_signing_key_bytes(&signing_key).map_err(BootstrapError::Key)?;
399 let continuation_device_signer = match &parsed.authority {
400 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
401 let bytes: [u8; crate::keys::SIGN_SECRETKEYBYTES] =
402 hex::decode(&continuation.device_signing_secret)
403 .map_err(|error| {
404 BootstrapError::InvalidSigningKey(format!(
405 "invalid device signing key encoding: {error}"
406 ))
407 })?
408 .try_into()
409 .map_err(|_| {
410 BootstrapError::InvalidSigningKey(format!(
411 "Device signing key must be {} bytes",
412 crate::keys::SIGN_SECRETKEYBYTES
413 ))
414 })?;
415 Some(UserKeypair::from_signing_key_bytes(&bytes).map_err(BootstrapError::Key)?)
416 }
417 crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_) => None,
418 };
419
420 let source = RestoreSource {
424 join_info: parsed.provider.clone(),
425 custom_s3_exact_slots,
426 oauth_tokens,
427 cloudkit_ops,
428 };
429
430 Box::pin(restore_from_cloud(
434 &parsed.sid,
435 parsed.store_root,
436 &parsed.founder_pubkey,
437 parsed.ek.as_deref(),
438 &parsed.name,
439 synced_tables,
440 migrations,
441 custody.clone(),
442 identity_custody.clone(),
443 source,
444 &parsed.membership_floor,
445 &keypair,
446 &parsed.authority,
447 continuation_device_signer.as_ref(),
448 layout,
449 clock,
450 ids,
451 on_status,
452 cancel,
453 ))
454 .await
455}
456
457#[cfg(all(test, feature = "oauth-providers"))]
461mod tests {
462 use super::*;
463 use crate::keys::CloudHomeCredentials;
464
465 #[tokio::test]
473 async fn restore_dropbox_build_cloud_home_persists_oauth_tokens() {
474 crate::keys::test_keyring::install();
475 crate::oauth::install_test_client_creds();
476
477 let store_id = "restore-dropbox-persist-test";
478 let tokens = OAuthTokens {
479 access_token: "access-token".to_string(),
480 refresh_token: Some("refresh-token".to_string()),
481 expires_at: None,
482 };
483 let source = RestoreSource {
484 join_info: CloudHomeJoinInfo::Dropbox {
485 folder_path: "/Apps/coven/my-store".to_string(),
486 },
487 custom_s3_exact_slots: None,
488 oauth_tokens: Some(tokens.clone()),
489 cloudkit_ops: None,
490 };
491
492 build_cloud_home(source, store_id, Arc::new(crate::clock::SystemClock))
493 .await
494 .expect("build restore cloud home for Dropbox");
495
496 let stored = StoreKeys::new(store_id.to_string())
497 .get_cloud_home_credentials()
498 .expect("read cloud home credentials")
499 .expect("restore must persist OAuth tokens to the keyring");
500 match stored {
501 CloudHomeCredentials::OAuth { token_json } => {
502 let stored_tokens: OAuthTokens =
503 serde_json::from_str(&token_json).expect("stored OAuth tokens deserialize");
504 assert_eq!(stored_tokens.access_token, tokens.access_token);
505 assert_eq!(stored_tokens.refresh_token, tokens.refresh_token);
506 }
507 other => panic!("expected OAuth credentials, got {other:?}"),
508 }
509 }
510}
511
512#[cfg(test)]
516mod build_cloud_home_tests {
517 use super::*;
518
519 #[tokio::test]
527 async fn build_cloud_home_s3_preserves_key_prefix() {
528 let source = RestoreSource {
529 join_info: CloudHomeJoinInfo::S3 {
530 bucket: "b".to_string(),
531 region: "us-east-1".to_string(),
532 endpoint: None,
533 access_key: "ak".to_string(),
534 secret_key: "sk".to_string(),
535 key_prefix: Some("prefix/".to_string()),
536 },
537 custom_s3_exact_slots: None,
538 oauth_tokens: None,
539 cloudkit_ops: None,
540 };
541
542 let (returned_info, _home) =
543 build_cloud_home(source, "store-id", Arc::new(crate::clock::SystemClock))
544 .await
545 .expect("build S3 cloud home");
546
547 match returned_info {
548 CloudHomeJoinInfo::S3 { key_prefix, .. } => {
549 assert_eq!(key_prefix, Some("prefix/".to_string()));
550 }
551 other => panic!("expected S3 join info, got {other:?}"),
552 }
553 }
554
555 #[tokio::test]
560 async fn build_cloud_home_rejects_cloudkit_share() {
561 let source = RestoreSource {
562 join_info: CloudHomeJoinInfo::CloudKitShare {
563 share_url: "https://share.example".to_string(),
564 owner_name: "owner".to_string(),
565 zone_name: "zone".to_string(),
566 },
567 custom_s3_exact_slots: None,
568 oauth_tokens: None,
569 cloudkit_ops: None,
570 };
571
572 let result =
573 build_cloud_home(source, "store-id", Arc::new(crate::clock::SystemClock)).await;
574
575 match result {
576 Err(BootstrapError::Provider(_)) => {}
577 Ok(_) => panic!("expected a Provider error rejecting the CloudKit share, got Ok"),
578 Err(other) => {
579 panic!("expected a Provider error rejecting the CloudKit share, got {other:?}")
580 }
581 }
582 }
583}