Skip to main content

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