Skip to main content

coven/sync/
restore.rs

1//! Restore an existing store from cloud storage.
2//!
3//! Unlike join (which unwraps the encryption key from an invite), restore takes
4//! the encryption key directly from the user — present for an opaque home,
5//! absent for a browsable one.
6
7use 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
27/// Cloud provider source for restore: the join info a restore code carries
28/// plus the extras it can't (`RestoreCode` omits OAuth tokens because they
29/// expire — the user re-authenticates on restore — and holds no live CloudKit
30/// driver).
31pub 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/// Require OAuth tokens for a provider that needs them and persist them to the
39/// store-scoped keyring, the same way join's parallel arms do, so the next
40/// launch's home construction (`parse_oauth_tokens` in `storage::cloud`) can
41/// read them back instead of erroring on their absence.
42#[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
56/// Build a cloud home from a `RestoreSource`.
57async 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    // Consumed only by the oauth provider arms below.
72    #[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        // Restore recovers your own zone, never one shared to you;
104        // `decode_restore_code` already rejects this for the code path, but
105        // `RestoreSource` is public API another caller could construct
106        // directly, so this guard is independent of that decode-time check.
107        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/// Restore a store from cloud storage.
164///
165/// Validates inputs, constructs the cloud home from the source, runs the sync
166/// protocol, and sets the store as active. `keypair` is the restored device's
167/// signing identity (recovered from the restore code); the storage signs the
168/// control objects it writes with it, and it is the same key the caller imports
169/// once restore succeeds.
170#[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    // Guard the destructive `stores/<id>` create/delete against any direct
193    // caller, independent of the decode-time check on untrusted input.
194    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    // Hoisted here, before any durable write below, so a failure at any step —
206    // including `build_cloud_home`'s OAuth persist, which runs before the store
207    // directory is created — funnels through the same rollback instead of a
208    // bare `?` escaping it.
209    let store_keys = StoreKeys::new(store_id.to_string());
210
211    // Refuse a *completed* store (config present) and clear a torn one before
212    // any provider side effect. The decode guaranteed the id is a safe single
213    // component, so the directory is a direct child of the layout's stores dir
214    // and cannot escape it. Re-running a restore for a store you already have
215    // adds nothing — the existing store is the data — and letting it proceed
216    // would, on any bootstrap failure below, delete that store's database and
217    // blobs during cleanup. Dispatching here makes the failure-cleanup only
218    // ever remove a directory this invocation created.
219    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        // The key's presence is the home's storage mode: a key present ⇒ an
231        // opaque home (encrypted, obfuscated blob paths); a key absent ⇒ a
232        // browsable home (plaintext, readable blob paths). The cipher and the
233        // blob-path scheme both follow from it, so this device computes the
234        // same blob keys the source wrote. Parsed once here (not re-parsed
235        // inside `bootstrap_and_save_store`) so the cipher and the persisted
236        // master key always agree on the same value.
237        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        // Create the store directory under `stores/` (its non-existence was
267        // checked up front, so this create and the failure-cleanup below own
268        // it entirely).
269        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            // The host records this as the active store after this returns.
328            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/// Restore a store from a restore code string.
345///
346/// Decodes the restore code, fills a `RestoreSource` from its join info plus
347/// the caller-supplied OAuth tokens and CloudKit driver, imports the signing
348/// key, and delegates to `restore_from_cloud`.
349#[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    // `decode_restore_code` already validated the field; rebuild this store's
378    // restored signing identity from it. The storage signs its control objects
379    // with this keypair during restore, and `restore_from_cloud` imports it into
380    // custody just before saving the config.
381    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    // `parsed.provider` is already the shared `CloudHomeJoinInfo`; `build_cloud_home`
421    // (via `restore_from_cloud`) matches on it and pulls in these extras, so there's
422    // no per-provider conversion left to do here.
423    let source = RestoreSource {
424        join_info: parsed.provider.clone(),
425        custom_s3_exact_slots,
426        oauth_tokens,
427        cloudkit_ops,
428    };
429
430    // `restore_from_cloud` imports this store's signing identity as the step
431    // before it saves the config, so a saved config always has its identity in
432    // custody. Nothing identity-related is left for this caller to do.
433    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// The only test here exercises the OAuth-provider arms of `build_cloud_home`,
458// which only exist under this feature; the module (not just the test fn) is
459// gated so its imports aren't unused in a build without the feature.
460#[cfg(all(test, feature = "oauth-providers"))]
461mod tests {
462    use super::*;
463    use crate::keys::CloudHomeCredentials;
464
465    /// Restore's per-provider `build_cloud_home` must save the caller-supplied
466    /// OAuth tokens to the store-scoped keyring, the same way join's parallel
467    /// arms already do. Launch-time home construction (`parse_oauth_tokens` in
468    /// `storage::cloud`) reads them back from there and errors when they're
469    /// absent, so a store restored over an OAuth provider must be able to build
470    /// its cloud home again on the next launch. Dropbox is the smallest OAuth
471    /// arm, so it stands in for Google Drive and OneDrive here.
472    #[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// These exercise arms of `build_cloud_home` that don't need the OAuth
513// providers (S3, CloudKit), so unlike the module above they run regardless of
514// the `oauth-providers` feature.
515#[cfg(test)]
516mod build_cloud_home_tests {
517    use super::*;
518
519    /// `RestoreSource.join_info` now carries `CloudHomeJoinInfo::S3` directly —
520    /// there's no more `RestoreSource::S3` hop that dropped `key_prefix` on the
521    /// floor (it built the `CloudHomeJoinInfo` returned to the caller with
522    /// `key_prefix: None` unconditionally). `build_cloud_home` must carry the
523    /// restore code's `key_prefix` through to the join info it returns, which
524    /// becomes `Config.cloud_home.s3_key_prefix` — otherwise every restore of
525    /// an S3 home configured with a key prefix loses it.
526    #[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    /// `RestoreSource` is public API a caller can construct directly, bypassing
556    /// `decode_restore_code`'s rejection of `CloudKitShare`. `build_cloud_home`
557    /// must refuse it on its own: restore recovers your own zone, never one
558    /// shared to you.
559    #[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}