1use 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#[derive(Clone, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct RestoreCode {
37 pub v: u8,
39 pub sid: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
46 pub ek: Option<String>,
47 pub name: String,
49 pub provider: CloudHomeJoinInfo,
54 pub store_root: coven_protocol::store_commit::StoreRootRef,
55 pub founder_pubkey: String,
56 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 .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 #[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 #[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
132pub fn encode_restore_code(code: &RestoreCode) -> String {
134 code_envelope::encode_code(code_envelope::PREFIX, code)
135}
136
137pub 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 coven_foundation::store_dir::validate_path_token(&code.sid)
150 .map_err(RestoreCodeError::InvalidStoreId)?;
151 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
205pub 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
213pub 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 #[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 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 assert!(!json.contains("endpoint"));
591 assert!(!json.contains("key_prefix"));
592 assert!(json.contains("name"));
594 }
595
596 #[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 #[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 #[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 assert!(debug.contains("Test Store"), "{debug}");
771 assert!(debug.contains("my-bucket"), "{debug}");
772 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 assert!(debug.contains("ek: Some"), "{debug}");
784 }
785}