1use 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#[derive(Clone, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case", deny_unknown_fields)]
30pub enum RestoreAuthority {
31 ActivatedContinuation(ActivatedContinuation),
33 OwnerRecovery(OwnerRecoveryAuthority),
35}
36
37#[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#[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#[derive(Clone, Serialize, Deserialize)]
96pub struct RestoreCode {
97 pub v: u8,
99 pub sid: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
106 pub ek: Option<String>,
107 pub name: String,
109 pub provider: CloudHomeJoinInfo,
115 pub store_root: super::store_commit::StoreRootRef,
116 pub founder_pubkey: String,
117 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 .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 #[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 #[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
195pub fn encode_restore_code(code: &RestoreCode) -> String {
197 code_envelope::encode_code(code_envelope::PREFIX, code)
198}
199
200pub 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 crate::store_dir::validate_path_token(&code.sid).map_err(RestoreCodeError::InvalidStoreId)?;
213 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
263pub(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
281pub fn provider_needs_oauth(provider: &CloudHomeJoinInfo) -> bool {
283 matches!(
284 provider,
285 CloudHomeJoinInfo::GoogleDrive { .. }
286 | CloudHomeJoinInfo::Dropbox { .. }
287 | CloudHomeJoinInfo::OneDrive { .. }
288 )
289}
290
291pub 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
299pub 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 #[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 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 assert!(!json.contains("endpoint"));
661 assert!(!json.contains("key_prefix"));
662 assert!(json.contains("name"));
664 }
665
666 #[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 #[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 #[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 assert!(debug.contains("Test Store"), "{debug}");
846 assert!(debug.contains("my-bucket"), "{debug}");
847 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 assert!(debug.contains("ek: Some"), "{debug}");
859 }
860}