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