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