Skip to main content

coven_core/sync/
restore_code.rs

1//! Restore codes: single-string encoding of everything needed to restore a store from cloud.
2//!
3//! A restore code encodes the store ID, encryption key, cloud provider details, and
4//! credentials into a single base64url string prefixed with "coven:".
5//!
6//! The code contains secrets (encryption key, S3 credentials). OAuth tokens are NOT included
7//! because they expire -- the user re-authenticates on restore.
8//!
9//! The encryption keyring (`ek`) is present for an opaque home and absent for a
10//! browsable one, so `ek`'s presence *is* the home's storage mode: `ek` present
11//! ⇒ opaque (encrypted, obfuscated blob paths), `ek` absent ⇒ browsable
12//! (plaintext, readable blob paths). The restorer rebuilds both the cipher and
13//! the blob-path scheme from that one signal.
14
15use serde::{Deserialize, Serialize};
16
17use crate::code_envelope::{self, EnvelopeError};
18use crate::join_code::MembershipFloor;
19use crate::storage::cloud::CloudHomeJoinInfo;
20#[cfg(test)]
21use crate::sync::membership::{MembershipCoord, MembershipGrantId, MembershipHeadRef};
22#[cfg(test)]
23use crate::sync::store_commit::ObjectHash;
24
25pub const RESTORE_CODE_VERSION: u8 = 4;
26
27/// The closed authority a restore operation may exercise.
28#[derive(Clone, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case", deny_unknown_fields)]
30pub enum RestoreAuthority {
31    /// Continue one exact, already-activated Store device.
32    ActivatedContinuation(ActivatedContinuation),
33    /// Recover an Owner identity at its exact root-anchored recovery cursor.
34    OwnerRecovery(OwnerRecoveryAuthority),
35}
36
37/// Exact durable state required to continue an activated Store device.
38#[derive(Clone, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct ActivatedContinuation {
41    pub identity_signing_secret: String,
42    pub device_signing_secret: String,
43    pub registration: super::store_commit::StoreDeviceRegistrationRef,
44    pub registration_bytes: Vec<u8>,
45    pub registration_prepared: super::storage::PreparedExactObject,
46    pub initial_ack: super::store_commit::StoreAckRef,
47    pub initial_ack_bytes: Vec<u8>,
48    pub initial_ack_prepared: super::storage::PreparedExactObject,
49    pub activation: super::store_commit::StoreDeviceRegistrationActivation,
50    pub latest_ack: super::store_commit::StoreAckRef,
51    pub latest_snapshot: Option<super::store_commit::StoreSnapshotRef>,
52    pub latest_position: Option<super::store_commit::StoreBatchCommitRef>,
53}
54
55/// Exact Owner grant and recovery-stream authority used to create a replacement
56/// device when no activated device continuation survives.
57#[derive(Clone, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct OwnerRecoveryAuthority {
60    pub owner_identity_secret: String,
61    pub owner_grant: super::membership::MembershipGrantId,
62    pub recovery: super::store_commit::OwnerRecoveryCursor,
63    pub published_at: String,
64}
65
66impl std::fmt::Debug for RestoreAuthority {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::ActivatedContinuation(value) => f
70                .debug_struct("ActivatedContinuation")
71                .field("identity_signing_secret", &"<redacted>")
72                .field("device_signing_secret", &"<redacted>")
73                .field("registration", &value.registration)
74                .field("initial_ack", &value.initial_ack)
75                .field("activation", &value.activation)
76                .field("latest_ack", &value.latest_ack)
77                .field("latest_snapshot", &value.latest_snapshot)
78                .field("latest_position", &value.latest_position)
79                .finish(),
80            Self::OwnerRecovery(value) => f
81                .debug_struct("OwnerRecovery")
82                .field("owner_identity_secret", &"<redacted>")
83                .field("owner_grant", &value.owner_grant)
84                .field("recovery", &value.recovery)
85                .finish(),
86        }
87    }
88}
89
90/// Everything needed to restore a store from cloud storage.
91///
92/// `Debug` is hand-written: the encryption keyring and signing keys are
93/// secrets and print as `<redacted>` so `{:?}` in an error path
94/// cannot leak key material.
95#[derive(Clone, Serialize, Deserialize)]
96pub struct RestoreCode {
97    /// Wire-format version.
98    pub v: u8,
99    /// Store ID (UUID).
100    pub sid: String,
101    /// Encryption keyring, present only for an opaque home.
102    /// Its presence is the home's storage mode: present ⇒ opaque (the restorer
103    /// builds `CloudCipher::Encrypted` + `BlobPathScheme::Hashed`); absent ⇒
104    /// browsable (`CloudCipher::Plaintext` + `BlobPathScheme::Plain`).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub ek: Option<String>,
107    /// Store display name.
108    pub name: String,
109    /// Cloud provider and its connection details. Shared with the invite code
110    /// (`InviteCode::join_info`) — one wire representation for both. A
111    /// [`CloudHomeJoinInfo::CloudKitShare`] is never valid here: restore
112    /// recovers your own zone, not one shared to you, so
113    /// [`decode_restore_code`] rejects it.
114    pub provider: CloudHomeJoinInfo,
115    pub store_root: super::store_commit::StoreRootRef,
116    pub founder_pubkey: String,
117    /// The exact membership state the restorer must observe: causal author
118    /// heads for MergeConcurrent stores, or the exact global commit for Serial
119    /// stores.
120    pub membership_floor: MembershipFloor,
121    pub authority: RestoreAuthority,
122}
123
124impl std::fmt::Debug for RestoreCode {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("RestoreCode")
127            .field("v", &self.v)
128            .field("sid", &self.sid)
129            // Presence is the storage mode (opaque vs browsable), so show
130            // Some/None; the key bytes themselves are redacted.
131            .field("ek", &self.ek.as_ref().map(|_| "<redacted>"))
132            .field("name", &self.name)
133            .field("provider", &self.provider)
134            .field("store_root", &self.store_root)
135            .field("founder_pubkey", &self.founder_pubkey)
136            .field("membership_floor", &self.membership_floor)
137            .field("authority", &self.authority)
138            .finish()
139    }
140}
141
142#[derive(Debug, thiserror::Error)]
143pub enum RestoreCodeError {
144    #[error("That doesn't look like a coven restore code — it should start with \"coven:\".")]
145    MissingPrefix,
146    #[error(
147        "The restore code is incomplete or has a typo. Check that you copied the entire code."
148    )]
149    InvalidBase64,
150    #[error("The restore code is corrupted. Regenerate it on the source device. ({0})")]
151    InvalidJson(String),
152    #[error("This restore code uses unsupported format version v{0}. Generate a new restore code on the source device.")]
153    UnsupportedVersion(u8),
154    /// The restore code's `sid` is not a safe path component, so it cannot name a
155    /// store directory under `stores/`. The code is unsigned and anyone can
156    /// craft one, so the id is refused here at decode rather than reaching a path
157    /// operation.
158    #[error(
159        "The store id in this restore code is invalid. Regenerate it on the source device. ({0})"
160    )]
161    InvalidStoreId(crate::store_dir::PathTokenError),
162    #[error("The encryption key in this restore code is invalid. Regenerate it on the source device. ({0})")]
163    InvalidEncryptionKey(String),
164    #[error(
165        "A signing key in this restore code is invalid. Regenerate it on the source device. ({0})"
166    )]
167    InvalidSigningKey(String),
168    #[error("The activated device continuation in this restore code is invalid. Regenerate it on the source device. ({0})")]
169    InvalidContinuationAuthority(String),
170    #[error("The Owner recovery authority in this restore code is invalid. Regenerate it on the source device. ({0})")]
171    InvalidRecoveryAuthority(String),
172    #[error("The founder key in this restore code is invalid. Regenerate it on the source device. ({0})")]
173    InvalidFounderKey(String),
174    #[error("The restore code has no membership floor. Regenerate it on the source device.")]
175    EmptyMembershipFloor,
176    #[error("The membership floor in this restore code is invalid. Regenerate it on the source device. ({0})")]
177    InvalidMembershipFloor(String),
178    /// A CloudKit share is a zone shared *to* this device by another owner;
179    /// restore recovers *your own* zone, so a restore code can never carry
180    /// one. Rejected at decode rather than reaching provider setup.
181    #[error(
182        "This restore code names a shared CloudKit zone, which restore can't use. Restore recovers your own store, not one shared to you — generate a restore code from the device that owns it."
183    )]
184    CloudKitShareNotRestorable,
185}
186
187impl From<EnvelopeError> for RestoreCodeError {
188    fn from(e: EnvelopeError) -> Self {
189        match e {
190            EnvelopeError::MissingPrefix => RestoreCodeError::MissingPrefix,
191            EnvelopeError::InvalidBase64 => RestoreCodeError::InvalidBase64,
192            EnvelopeError::InvalidJson(s) => RestoreCodeError::InvalidJson(s),
193        }
194    }
195}
196
197/// Encode a `RestoreCode` into a prefixed base64url string.
198pub fn encode_restore_code(code: &RestoreCode) -> String {
199    code_envelope::encode_code(code_envelope::PREFIX, code)
200}
201
202/// Decode a restore code string back into a `RestoreCode`.
203pub fn decode_restore_code(s: &str) -> Result<RestoreCode, RestoreCodeError> {
204    let code: RestoreCode = code_envelope::decode_code(code_envelope::PREFIX, s)?;
205    if code.v != RESTORE_CODE_VERSION {
206        return Err(RestoreCodeError::UnsupportedVersion(code.v));
207    }
208    // A restore code is unsigned, so `sid` is attacker-controlled. It becomes the
209    // name of a directory the restorer creates under `stores/` and recursively
210    // deletes on a bootstrap failure, so a value carrying `..`, a separator, or an
211    // absolute path would put that create/delete outside the stores root. Reject
212    // it the moment the code is parsed: a decoded `RestoreCode` always carries a
213    // `sid` that is a single safe path component.
214    crate::store_dir::validate_path_token(&code.sid).map_err(RestoreCodeError::InvalidStoreId)?;
215    // A restore code is unsigned, so a crafted one could name a share the
216    // decoder holds no rights to. Restore recovers your own zone, never a
217    // shared one, so reject the case structurally at decode.
218    if matches!(code.provider, CloudHomeJoinInfo::CloudKitShare { .. }) {
219        return Err(RestoreCodeError::CloudKitShareNotRestorable);
220    }
221    if let Some(serialized_keyring) = &code.ek {
222        crate::encryption::EncryptionService::new(serialized_keyring)
223            .map_err(|e| RestoreCodeError::InvalidEncryptionKey(e.to_string()))?;
224    }
225    match &code.authority {
226        RestoreAuthority::ActivatedContinuation(continuation) => {
227            decode_hex_bytes(
228                "identity signing key",
229                &continuation.identity_signing_secret,
230                64,
231            )
232            .map_err(RestoreCodeError::InvalidSigningKey)?;
233            decode_hex_bytes(
234                "device signing key",
235                &continuation.device_signing_secret,
236                64,
237            )
238            .map_err(RestoreCodeError::InvalidSigningKey)?;
239        }
240        RestoreAuthority::OwnerRecovery(recovery) => {
241            decode_hex_bytes(
242                "Owner identity signing key",
243                &recovery.owner_identity_secret,
244                64,
245            )
246            .map_err(RestoreCodeError::InvalidSigningKey)?;
247            if recovery.recovery.owner_grant != recovery.owner_grant {
248                return Err(RestoreCodeError::InvalidRecoveryAuthority(
249                    "Owner recovery cursor belongs to another grant".to_string(),
250                ));
251            }
252        }
253    }
254    decode_hex_bytes("founder public key", &code.founder_pubkey, 32)
255        .map_err(RestoreCodeError::InvalidFounderKey)?;
256    match &code.membership_floor {
257        MembershipFloor::MergeConcurrent(floor) => {
258            if floor.is_empty() {
259                return Err(RestoreCodeError::EmptyMembershipFloor);
260            }
261            super::membership_ops::validate_membership_floor(floor)
262                .map_err(RestoreCodeError::InvalidMembershipFloor)?;
263        }
264        MembershipFloor::Serial(Some(reference)) => {
265            if reference.coord.policy() != crate::WritePolicy::Serial
266                || reference.coord.sequence() == 0
267            {
268                return Err(RestoreCodeError::InvalidMembershipFloor(
269                    "Serial floor must be an exact nonzero Serial commit reference".to_string(),
270                ));
271            }
272        }
273        MembershipFloor::Serial(None) => {}
274    }
275    Ok(code)
276}
277
278/// Decode `hex_value` and check it is exactly `expected_len` bytes. Shared
279/// with `join_code`'s `owner_pubkey` check, since both are fixed-length
280/// hex-encoded key material that must be validated at decode time.
281pub(crate) fn decode_hex_bytes(
282    label: &str,
283    hex_value: &str,
284    expected_len: usize,
285) -> Result<Vec<u8>, String> {
286    let bytes = hex::decode(hex_value).map_err(|e| format!("{label} is not hex: {e}"))?;
287    if bytes.len() != expected_len {
288        return Err(format!(
289            "{label} must be {expected_len} bytes, got {}",
290            bytes.len()
291        ));
292    }
293    Ok(bytes)
294}
295
296/// Returns true if this provider requires an OAuth flow before restore.
297pub fn provider_needs_oauth(provider: &CloudHomeJoinInfo) -> bool {
298    matches!(
299        provider,
300        CloudHomeJoinInfo::GoogleDrive { .. }
301            | CloudHomeJoinInfo::Dropbox { .. }
302            | CloudHomeJoinInfo::OneDrive { .. }
303    )
304}
305
306/// UI-ready info from a decoded restore code.
307pub struct RestoreCodeInfo {
308    pub store_id: String,
309    pub store_name: String,
310    pub cloud_provider: crate::config::CloudProvider,
311    pub needs_oauth: bool,
312}
313
314/// Decode a restore code and return UI-ready info.
315pub fn decode_restore_code_info(code: &str) -> Result<RestoreCodeInfo, RestoreCodeError> {
316    let parsed = decode_restore_code(code)?;
317
318    let cloud_provider = parsed.provider.cloud_provider();
319
320    Ok(RestoreCodeInfo {
321        store_id: parsed.sid,
322        store_name: parsed.name,
323        cloud_provider,
324        needs_oauth: provider_needs_oauth(&parsed.provider),
325    })
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
332    use base64::Engine;
333
334    fn test_sk() -> String {
335        hex::encode([0xAB_u8; 64])
336    }
337
338    fn test_keyring(byte: u8) -> String {
339        crate::encryption::MasterKeyring::from(crate::encryption::EncryptionService::from_key(
340            [byte; 32],
341        ))
342        .to_serialized()
343    }
344
345    fn test_store_root() -> crate::sync::store_commit::StoreRootRef {
346        let stored = b"restore protocol root object";
347        crate::sync::store_commit::StoreRootRef {
348            store_root_id: ObjectHash::digest(b"restore protocol root identity"),
349            store_root_hash: ObjectHash::digest(stored),
350            object: crate::sync::storage::ExactObjectRef::new(
351                crate::storage::cloud::ObjectSlot::logical(
352                    "store-v1/protocol/root/restore-code-test.json".to_string(),
353                )
354                .expect("valid test Store-root slot"),
355                stored.len() as u64,
356                ObjectHash::digest(stored),
357            ),
358        }
359    }
360
361    fn test_serial_commit_ref() -> crate::sync::store_commit::StoreBatchCommitRef {
362        let stored = b"restore Serial floor commit";
363        let commit_hash = ObjectHash::digest(b"restore Serial floor semantic bytes");
364        let family = crate::sync::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
365            b"restore Serial floor candidate family",
366        ));
367        crate::sync::store_commit::StoreBatchCommitRef {
368            coord: crate::sync::store_commit::StoreCommitCoord::Serial { sequence: 7 },
369            commit_hash,
370            object: crate::sync::storage::ExactObjectRef::new(
371                crate::storage::cloud::ObjectSlot::logical(format!(
372                    "{}.json",
373                    crate::sync::store_commit::commit_semantic_prefix(
374                        family,
375                        crate::sync::store_commit::SERIAL_STREAM_ID,
376                        7,
377                        commit_hash,
378                    )
379                ))
380                .expect("valid test Store-commit slot"),
381                stored.len() as u64,
382                ObjectHash::digest(stored),
383            ),
384        }
385    }
386
387    fn test_membership_floor() -> MembershipFloor {
388        let coord = MembershipCoord {
389            author_pubkey: hex::encode([0xCDu8; 32]),
390            author_owner_grant: MembershipGrantId(ObjectHash::digest(b"test owner grant")),
391            stream_id: crate::sync::membership::AuthorStreamId::from_bytes([1; 32]),
392            seq: 1,
393            entry_hash: ObjectHash::digest(b"test membership entry"),
394        };
395        let stored = b"test restore membership head";
396        MembershipFloor::MergeConcurrent(vec![MembershipHeadRef {
397            coord,
398            head_hash: ObjectHash::digest(b"test restore membership head semantic bytes"),
399            object: crate::sync::storage::ExactObjectRef::new(
400                crate::storage::cloud::ObjectSlot::logical(
401                    "store-v1/membership/heads/test-restore-owner/1.json".to_string(),
402                )
403                .expect("valid restore membership-head slot"),
404                stored.len() as u64,
405                ObjectHash::digest(stored),
406            ),
407        }])
408    }
409
410    fn test_authority() -> RestoreAuthority {
411        let owner_grant = MembershipGrantId(ObjectHash::digest(b"test owner grant"));
412        let first_slot = crate::storage::cloud::ObjectSlot::logical(
413            "store-v1/recovery/test-owner/first.json".to_string(),
414        )
415        .expect("valid recovery slot");
416        let anchor = crate::sync::store_commit::GrantStreamAnchor::OwnerRecovery { first_slot };
417        let owner_pubkey = hex::encode([0xCDu8; 32]);
418        let activation = crate::sync::store_commit::OwnerRecoveryActivationId::derive(
419            &test_store_root(),
420            &owner_pubkey,
421            &owner_grant,
422            &anchor,
423        )
424        .expect("valid recovery activation");
425        RestoreAuthority::OwnerRecovery(OwnerRecoveryAuthority {
426            owner_identity_secret: test_sk(),
427            owner_grant: owner_grant.clone(),
428            recovery: crate::sync::store_commit::OwnerRecoveryCursor {
429                owner_grant,
430                position: crate::sync::store_commit::OwnerRecoveryPosition::BeforeFirst {
431                    activation,
432                },
433            },
434            published_at: "2026-07-17T00:00:00Z".to_string(),
435        })
436    }
437
438    fn sample_s3_code() -> RestoreCode {
439        RestoreCode {
440            v: RESTORE_CODE_VERSION,
441            sid: "550e8400-e29b-41d4-a716-446655440000".to_string(),
442            ek: Some(test_keyring(0xaa)),
443            name: "Test Store".to_string(),
444            provider: CloudHomeJoinInfo::S3 {
445                bucket: "my-bucket".to_string(),
446                region: "us-east-1".to_string(),
447                endpoint: Some("https://s3.example.com".to_string()),
448                key_prefix: None,
449                access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
450                secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
451            },
452            store_root: test_store_root(),
453            founder_pubkey: hex::encode([0xCDu8; 32]),
454            membership_floor: test_membership_floor(),
455            authority: test_authority(),
456        }
457    }
458
459    #[test]
460    fn roundtrip_s3() {
461        let code = sample_s3_code();
462        let encoded = encode_restore_code(&code);
463        assert!(encoded.starts_with("coven:"));
464
465        let decoded = decode_restore_code(&encoded).unwrap();
466        assert_eq!(decoded.v, RESTORE_CODE_VERSION);
467        assert_eq!(decoded.sid, code.sid);
468        assert_eq!(decoded.ek, code.ek);
469        assert_eq!(
470            serde_json::to_value(&decoded.authority).unwrap(),
471            serde_json::to_value(&code.authority).unwrap()
472        );
473        assert_eq!(decoded.name, "Test Store");
474        assert_eq!(decoded.store_root, code.store_root);
475        assert_eq!(decoded.membership_floor, code.membership_floor);
476        match &decoded.provider {
477            CloudHomeJoinInfo::S3 {
478                bucket,
479                region,
480                endpoint,
481                key_prefix,
482                access_key,
483                secret_key,
484            } => {
485                assert_eq!(bucket, "my-bucket");
486                assert_eq!(region, "us-east-1");
487                assert_eq!(endpoint.as_deref(), Some("https://s3.example.com"));
488                assert!(key_prefix.is_none());
489                assert_eq!(access_key, "AKIAIOSFODNN7EXAMPLE");
490                assert_eq!(secret_key, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
491            }
492            _ => panic!("expected S3 provider"),
493        }
494    }
495
496    #[test]
497    fn empty_serial_store_round_trips_the_founder_only_floor() {
498        let mut code = sample_s3_code();
499        code.membership_floor = MembershipFloor::Serial(None);
500        let decoded = decode_restore_code(&encode_restore_code(&code)).unwrap();
501        assert_eq!(decoded.membership_floor, MembershipFloor::Serial(None));
502    }
503
504    #[test]
505    fn serial_store_round_trips_the_exact_commit_floor() {
506        let mut code = sample_s3_code();
507        let reference = test_serial_commit_ref();
508        code.membership_floor = MembershipFloor::Serial(Some(reference.clone()));
509        let decoded = decode_restore_code(&encode_restore_code(&code)).unwrap();
510        assert_eq!(
511            decoded.membership_floor,
512            MembershipFloor::Serial(Some(reference))
513        );
514    }
515
516    #[test]
517    fn roundtrip_cloudkit() {
518        let code = RestoreCode {
519            v: RESTORE_CODE_VERSION,
520            sid: "lib-123".to_string(),
521            ek: Some(test_keyring(0xbb)),
522            name: "CloudKit Store".to_string(),
523            provider: CloudHomeJoinInfo::CloudKit,
524            authority: test_authority(),
525            store_root: test_store_root(),
526            founder_pubkey: hex::encode([0xCDu8; 32]),
527            membership_floor: test_membership_floor(),
528        };
529        let encoded = encode_restore_code(&code);
530        let decoded = decode_restore_code(&encoded).unwrap();
531        assert_eq!(decoded.name, "CloudKit Store");
532        assert!(matches!(decoded.provider, CloudHomeJoinInfo::CloudKit));
533    }
534
535    #[test]
536    fn roundtrip_google_drive() {
537        let code = RestoreCode {
538            v: RESTORE_CODE_VERSION,
539            sid: "lib-456".to_string(),
540            ek: Some(test_keyring(0xcc)),
541            name: "GDrive Store".to_string(),
542            provider: CloudHomeJoinInfo::GoogleDrive {
543                folder_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs".to_string(),
544            },
545            authority: test_authority(),
546            store_root: test_store_root(),
547            founder_pubkey: hex::encode([0xCDu8; 32]),
548            membership_floor: test_membership_floor(),
549        };
550        let decoded = decode_restore_code(&encode_restore_code(&code)).unwrap();
551        match &decoded.provider {
552            CloudHomeJoinInfo::GoogleDrive { folder_id } => {
553                assert_eq!(folder_id, "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs");
554            }
555            _ => panic!("expected GoogleDrive provider"),
556        }
557    }
558
559    #[test]
560    fn roundtrip_dropbox() {
561        let code = RestoreCode {
562            v: RESTORE_CODE_VERSION,
563            sid: "lib-789".to_string(),
564            ek: Some(test_keyring(0xdd)),
565            name: "Dropbox Store".to_string(),
566            provider: CloudHomeJoinInfo::Dropbox {
567                folder_path: "/Apps/your-app/My Store".to_string(),
568            },
569            authority: test_authority(),
570            store_root: test_store_root(),
571            founder_pubkey: hex::encode([0xCDu8; 32]),
572            membership_floor: test_membership_floor(),
573        };
574        let decoded = decode_restore_code(&encode_restore_code(&code)).unwrap();
575        match &decoded.provider {
576            CloudHomeJoinInfo::Dropbox { folder_path } => {
577                assert_eq!(folder_path, "/Apps/your-app/My Store");
578            }
579            _ => panic!("expected Dropbox provider"),
580        }
581    }
582
583    #[test]
584    fn roundtrip_onedrive() {
585        let code = RestoreCode {
586            v: RESTORE_CODE_VERSION,
587            sid: "lib-abc".to_string(),
588            ek: Some(test_keyring(0xee)),
589            name: "OneDrive Store".to_string(),
590            provider: CloudHomeJoinInfo::OneDrive {
591                drive_id: "drive-id-123".to_string(),
592                folder_id: "folder-id-456".to_string(),
593            },
594            authority: test_authority(),
595            store_root: test_store_root(),
596            founder_pubkey: hex::encode([0xCDu8; 32]),
597            membership_floor: test_membership_floor(),
598        };
599        let decoded = decode_restore_code(&encode_restore_code(&code)).unwrap();
600        match &decoded.provider {
601            CloudHomeJoinInfo::OneDrive {
602                drive_id,
603                folder_id,
604            } => {
605                assert_eq!(drive_id, "drive-id-123");
606                assert_eq!(folder_id, "folder-id-456");
607            }
608            _ => panic!("expected OneDrive provider"),
609        }
610    }
611
612    /// A restore code naming a CloudKit share is rejected at decode: restore
613    /// recovers your own zone, never one shared to you.
614    #[test]
615    fn decode_rejects_cloudkit_share() {
616        let code = RestoreCode {
617            v: RESTORE_CODE_VERSION,
618            sid: "lib-ck-share".to_string(),
619            ek: Some(test_keyring(0xff)),
620            name: "CloudKit Share Store".to_string(),
621            provider: CloudHomeJoinInfo::CloudKitShare {
622                share_url: "https://share.example/abc".to_string(),
623                owner_name: "owner".to_string(),
624                zone_name: "zone".to_string(),
625            },
626            authority: test_authority(),
627            store_root: test_store_root(),
628            founder_pubkey: hex::encode([0xCDu8; 32]),
629            membership_floor: test_membership_floor(),
630        };
631        let encoded = encode_restore_code(&code);
632        assert!(matches!(
633            decode_restore_code(&encoded),
634            Err(RestoreCodeError::CloudKitShareNotRestorable)
635        ));
636    }
637
638    #[test]
639    fn missing_prefix() {
640        let code = sample_s3_code();
641        let encoded = encode_restore_code(&code);
642        // Strip the "coven:" prefix
643        let without_prefix = &encoded[code_envelope::PREFIX.len()..];
644        assert!(matches!(
645            decode_restore_code(without_prefix),
646            Err(RestoreCodeError::MissingPrefix)
647        ));
648    }
649
650    #[test]
651    fn invalid_base64() {
652        assert!(matches!(
653            decode_restore_code("coven:not-valid!!!"),
654            Err(RestoreCodeError::InvalidBase64)
655        ));
656    }
657
658    #[test]
659    fn invalid_json() {
660        let b64 = URL_SAFE_NO_PAD.encode(b"not json");
661        let code = format!("coven:{b64}");
662        assert!(matches!(
663            decode_restore_code(&code),
664            Err(RestoreCodeError::InvalidJson(_))
665        ));
666    }
667
668    #[test]
669    fn unsupported_version() {
670        let mut code = sample_s3_code();
671        code.v = 99;
672        let encoded = encode_restore_code(&code);
673        assert!(matches!(
674            decode_restore_code(&encoded),
675            Err(RestoreCodeError::UnsupportedVersion(99))
676        ));
677    }
678
679    #[test]
680    fn lower_unsupported_version_is_rejected_before_field_validation() {
681        let mut code = sample_s3_code();
682        code.v = 0;
683        let encoded = encode_restore_code(&code);
684        assert!(matches!(
685            decode_restore_code(&encoded),
686            Err(RestoreCodeError::UnsupportedVersion(0))
687        ));
688    }
689
690    #[test]
691    fn whitespace_trimmed() {
692        let code = sample_s3_code();
693        let encoded = encode_restore_code(&code);
694        let padded = format!("  {encoded}  \n");
695        let decoded = decode_restore_code(&padded).unwrap();
696        assert_eq!(decoded.sid, code.sid);
697    }
698
699    #[test]
700    fn optional_fields_omitted_in_json() {
701        let code = RestoreCode {
702            v: RESTORE_CODE_VERSION,
703            sid: "lib-1".to_string(),
704            ek: Some(test_keyring(0xaa)),
705            name: "Test Store".to_string(),
706            provider: CloudHomeJoinInfo::S3 {
707                bucket: "b".to_string(),
708                region: "r".to_string(),
709                endpoint: None,
710                key_prefix: None,
711                access_key: "ak".to_string(),
712                secret_key: "sk-cred".to_string(),
713            },
714            authority: test_authority(),
715            store_root: test_store_root(),
716            founder_pubkey: hex::encode([0xCDu8; 32]),
717            membership_floor: test_membership_floor(),
718        };
719        let json = serde_json::to_string(&code).unwrap();
720        // None fields should not appear in the JSON
721        assert!(!json.contains("endpoint"));
722        assert!(!json.contains("key_prefix"));
723        // Required fields should be present
724        assert!(json.contains("name"));
725    }
726
727    /// A browsable home's restore code carries no encryption key: `ek` is `None`,
728    /// so the field is omitted from the JSON, and it round-trips back to `None`.
729    #[test]
730    fn browsable_code_omits_ek() {
731        let code = RestoreCode {
732            v: RESTORE_CODE_VERSION,
733            sid: "lib-plain".to_string(),
734            ek: None,
735            name: "Plaintext Store".to_string(),
736            provider: CloudHomeJoinInfo::S3 {
737                bucket: "b".to_string(),
738                region: "r".to_string(),
739                endpoint: None,
740                key_prefix: None,
741                access_key: "ak".to_string(),
742                secret_key: "sk-cred".to_string(),
743            },
744            authority: test_authority(),
745            store_root: test_store_root(),
746            founder_pubkey: hex::encode([0xCDu8; 32]),
747            membership_floor: test_membership_floor(),
748        };
749        let json = serde_json::to_string(&code).unwrap();
750        assert!(
751            !json.contains("\"ek\""),
752            "a browsable home's code must omit ek: {json}"
753        );
754
755        let decoded = decode_restore_code(&encode_restore_code(&code)).unwrap();
756        assert_eq!(decoded.ek, None, "ek round-trips back to None");
757    }
758
759    /// An opaque home's restore code carries the key: `ek` is `Some`, present in
760    /// the JSON, and round-trips intact.
761    #[test]
762    fn opaque_code_includes_ek() {
763        let code = sample_s3_code();
764        assert!(code.ek.is_some());
765        let json = serde_json::to_string(&code).unwrap();
766        assert!(
767            json.contains("\"ek\""),
768            "an opaque home's code must include ek: {json}"
769        );
770
771        let decoded = decode_restore_code(&encode_restore_code(&code)).unwrap();
772        assert_eq!(decoded.ek, code.ek, "ek round-trips intact");
773    }
774
775    #[test]
776    fn needs_oauth() {
777        assert!(!provider_needs_oauth(&CloudHomeJoinInfo::S3 {
778            bucket: String::new(),
779            region: String::new(),
780            endpoint: None,
781            key_prefix: None,
782            access_key: String::new(),
783            secret_key: String::new(),
784        }));
785        assert!(!provider_needs_oauth(&CloudHomeJoinInfo::CloudKit));
786        assert!(provider_needs_oauth(&CloudHomeJoinInfo::GoogleDrive {
787            folder_id: String::new(),
788        }));
789        assert!(provider_needs_oauth(&CloudHomeJoinInfo::Dropbox {
790            folder_path: String::new(),
791        }));
792        assert!(provider_needs_oauth(&CloudHomeJoinInfo::OneDrive {
793            drive_id: String::new(),
794            folder_id: String::new(),
795        }));
796    }
797
798    #[test]
799    fn display_messages_name_cause_and_recovery() {
800        let missing = RestoreCodeError::MissingPrefix.to_string();
801        assert!(missing.contains("coven:"), "{missing}");
802        assert!(missing.contains("coven restore code"), "{missing}");
803
804        let invalid_b64 = RestoreCodeError::InvalidBase64.to_string();
805        assert!(
806            invalid_b64.contains("incomplete") || invalid_b64.contains("typo"),
807            "{invalid_b64}",
808        );
809
810        let invalid_json = RestoreCodeError::InvalidJson("trailing comma".to_string()).to_string();
811        assert!(invalid_json.contains("Regenerate"), "{invalid_json}");
812        assert!(invalid_json.contains("trailing comma"), "{invalid_json}");
813
814        let lower_version = RestoreCodeError::UnsupportedVersion(0).to_string();
815        assert!(lower_version.contains("v0"), "{lower_version}");
816        assert!(
817            lower_version.contains("Generate a new restore code"),
818            "{lower_version}"
819        );
820
821        let higher_version = RestoreCodeError::UnsupportedVersion(99).to_string();
822        assert!(higher_version.contains("v99"), "{higher_version}");
823        assert!(
824            higher_version.contains("Generate a new restore code"),
825            "{higher_version}"
826        );
827    }
828
829    #[test]
830    fn invalid_encryption_key_rejected_at_decode() {
831        let mut code = sample_s3_code();
832        code.ek = Some("not keyring JSON".to_string());
833        let encoded = encode_restore_code(&code);
834        assert!(matches!(
835            decode_restore_code(&encoded),
836            Err(RestoreCodeError::InvalidEncryptionKey(_))
837        ));
838
839        let mut code = sample_s3_code();
840        code.ek = Some(hex::encode([0u8; 32]));
841        let encoded = encode_restore_code(&code);
842        assert!(matches!(
843            decode_restore_code(&encoded),
844            Err(RestoreCodeError::InvalidEncryptionKey(_))
845        ));
846    }
847
848    #[test]
849    fn invalid_signing_key_rejected_at_decode() {
850        let mut code = sample_s3_code();
851        let RestoreAuthority::OwnerRecovery(authority) = &mut code.authority else {
852            panic!("test authority is Owner recovery")
853        };
854        authority.owner_identity_secret = "not hex".to_string();
855        let encoded = encode_restore_code(&code);
856        assert!(matches!(
857            decode_restore_code(&encoded),
858            Err(RestoreCodeError::InvalidSigningKey(_))
859        ));
860
861        let mut code = sample_s3_code();
862        let RestoreAuthority::OwnerRecovery(authority) = &mut code.authority else {
863            panic!("test authority is Owner recovery")
864        };
865        authority.owner_identity_secret = hex::encode([0u8; 63]);
866        let encoded = encode_restore_code(&code);
867        assert!(matches!(
868            decode_restore_code(&encoded),
869            Err(RestoreCodeError::InvalidSigningKey(_))
870        ));
871    }
872
873    /// `membership_floor` is required, not merely present-when-known: a code
874    /// serialized without it (an older minter, or a hand-crafted attack code)
875    /// must be refused at decode rather than silently read as "no floor" — the
876    /// exact masking this field exists to remove.
877    #[test]
878    fn missing_membership_floor_is_refused_at_decode() {
879        let mut json = serde_json::to_value(sample_s3_code()).unwrap();
880        json.as_object_mut().unwrap().remove("membership_floor");
881        let bytes = serde_json::to_vec(&json).unwrap();
882        let encoded = format!("coven:{}", URL_SAFE_NO_PAD.encode(bytes));
883        assert!(matches!(
884            decode_restore_code(&encoded),
885            Err(RestoreCodeError::InvalidJson(_))
886        ));
887    }
888
889    #[test]
890    fn empty_membership_floor_is_refused_at_decode() {
891        let mut code = sample_s3_code();
892        code.membership_floor = MembershipFloor::MergeConcurrent(Vec::new());
893        assert!(matches!(
894            decode_restore_code(&encode_restore_code(&code)),
895            Err(RestoreCodeError::EmptyMembershipFloor)
896        ));
897    }
898
899    #[test]
900    fn debug_redacts_key_material() {
901        let code = sample_s3_code();
902        let debug = format!("{code:?}");
903
904        assert!(debug.contains("<redacted>"), "{debug}");
905        // Non-secret fields are still visible.
906        assert!(debug.contains("Test Store"), "{debug}");
907        assert!(debug.contains("my-bucket"), "{debug}");
908        // The encryption keyring and signing key never appear.
909        let ek_hex = code.ek.as_deref().expect("sample has ek");
910        assert!(!debug.contains(ek_hex), "encryption key leaked: {debug}");
911        let RestoreAuthority::OwnerRecovery(authority) = &code.authority else {
912            panic!("test authority is Owner recovery")
913        };
914        assert!(
915            !debug.contains(&authority.owner_identity_secret),
916            "signing key leaked: {debug}"
917        );
918        // ek presence (the storage mode) is still observable.
919        assert!(debug.contains("ek: Some"), "{debug}");
920    }
921}