1use zeroize::Zeroizing;
4
5use super::{
6 account_key::AccountKey,
7 credentials::Credentials,
8 device::ClientInfo,
9 error::OnePasswordError,
10 keychain::Keychain,
11 login::{self, LoginOutcome, SignInMethods},
12 model::{
13 DownloadedAccount, Item, ItemCategory, SkippedItem, SkippedReason, SkippedVault, Vault,
14 },
15 opdata::Encrypted,
16 rest::RestClient,
17 session::Session,
18 sign_in::SignInAddress,
19 two_factor::TwoFactorUi,
20 wire::{
21 AccountInfo, EncryptedEnvelope, KeysetsInfo, VaultAccess, VaultAttributes, VaultItem,
22 VaultItemOverview, VaultItemsBatch,
23 },
24};
25
26const MAX_OTP_ATTEMPTS: u32 = 3;
27const ACCOUNT_INFO_ENDPOINT: &str =
28 "v1/account?attrs=billing,counts,groups,invite,me,settings,tier,user-flags,users,vaults";
29const KEYSETS_ENDPOINT: &str = "v1/account/keysets";
30const VAULT_ENDPOINT: &str = "v1/vault";
31
32pub struct Client {
34 http: reqwest::Client,
35}
36
37impl Client {
38 pub fn new(http: reqwest::Client) -> Client {
41 Client { http }
42 }
43
44 pub async fn sign_in_methods(
47 &self,
48 username: &str,
49 mut sign_in_address: SignInAddress,
50 ) -> Result<SignInMethods, OnePasswordError> {
51 sign_in_address.normalize()?;
52 let device_uuid = super::device::generate_device_uuid();
53 let rest = self.rest_client(&sign_in_address, &ClientInfo::for_desktop(&device_uuid))?;
54
55 let login_info = login::fetch_auth_methods(username, &rest).await?;
56 Ok(SignInMethods::of(&login_info))
57 }
58
59 pub async fn open_account(
62 &self,
63 credentials: Credentials,
64 ui: &dyn TwoFactorUi,
65 ) -> Result<DownloadedAccount, OnePasswordError> {
66 let mut credentials = Zeroizing::new(credentials);
67 credentials.sign_in_address.normalize()?;
68 let account_key = AccountKey::parse(&credentials.account_key)?;
69 let session = self.login(&credentials, &account_key, ui).await?;
70 download_vaults(&credentials, &account_key, &session).await
71 }
72
73 async fn login(
78 &self,
79 credentials: &Credentials,
80 account_key: &AccountKey,
81 ui: &dyn TwoFactorUi,
82 ) -> Result<Session, OnePasswordError> {
83 let device_uuid = super::device::generate_device_uuid();
84 let client_info = ClientInfo::for_desktop(&device_uuid);
85 let rest = self.rest_client(&credentials.sign_in_address, &client_info)?;
86
87 let login_info = login::fetch_auth_methods(&credentials.username, &rest).await?;
89 if !SignInMethods::of(&login_info).password {
90 return Err(OnePasswordError::Unsupported(format!(
91 "no password login method found for account {}",
92 credentials.username
93 )));
94 }
95
96 for attempt in 0..MAX_OTP_ATTEMPTS {
97 match login::login_attempt(credentials, account_key, &client_info, attempt, ui, &rest)
98 .await?
99 {
100 LoginOutcome::Success(session) => return Ok(*session),
101 LoginOutcome::BadOtp => continue,
102 }
103 }
104
105 Err(OnePasswordError::TwoFactorFailed)
106 }
107
108 fn rest_client(
109 &self,
110 sign_in_address: &SignInAddress,
111 client_info: &ClientInfo,
112 ) -> Result<RestClient, OnePasswordError> {
113 RestClient::new(
114 self.http.clone(),
115 format!("https://{sign_in_address}/api"),
116 &client_info.client_id(),
117 &client_info.user_agent,
118 &client_info.op_user_agent,
119 )
120 }
121}
122
123pub(super) async fn download_vaults(
125 credentials: &Credentials,
126 account_key: &AccountKey,
127 session: &Session,
128) -> Result<DownloadedAccount, OnePasswordError> {
129 let (keychain, vaults, mut skipped_vaults) =
130 unlock_account(credentials, account_key, session).await?;
131
132 let mut downloaded = Vec::with_capacity(vaults.len());
133 for info in vaults {
134 match download_vault(info, &keychain, session).await? {
135 VaultDownload::Downloaded(vault) => downloaded.push(vault),
136 VaultDownload::Skipped(vault) => skipped_vaults.push(vault),
137 }
138 }
139
140 Ok(DownloadedAccount {
141 vaults: downloaded,
142 skipped_vaults,
143 })
144}
145
146struct VaultInfo {
148 id: String,
149 name: String,
150 item_count: Option<u32>,
151}
152
153enum VaultDownload {
154 Downloaded(Vault),
155 Skipped(SkippedVault),
156}
157
158async fn download_vault(
160 info: VaultInfo,
161 keychain: &Keychain,
162 session: &Session,
163) -> Result<VaultDownload, OnePasswordError> {
164 let download = match download_vault_items(&info.id, keychain, session).await {
165 Ok(download) => download,
166 Err(OnePasswordError::NotFound) => {
169 return Ok(VaultDownload::Skipped(SkippedVault {
170 id: info.id,
171 item_count: info.item_count,
172 reason: SkippedReason::NoAccess,
173 }));
174 }
175 Err(error) => return Err(error),
176 };
177
178 Ok(VaultDownload::Downloaded(Vault {
179 id: info.id,
180 name: info.name,
181 items: download.items,
182 skipped_items: download.skipped_items,
183 }))
184}
185
186async fn unlock_account(
188 credentials: &Credentials,
189 account_key: &AccountKey,
190 session: &Session,
191) -> Result<(Keychain, Vec<VaultInfo>, Vec<SkippedVault>), OnePasswordError> {
192 let account_info: AccountInfo = session
194 .rest
195 .get_encrypted_json(ACCOUNT_INFO_ENDPOINT, &session.key)
196 .await?;
197 let keysets: KeysetsInfo = session
198 .rest
199 .get_encrypted_json(KEYSETS_ENDPOINT, &session.key)
200 .await?;
201
202 let mut keychain = Keychain::new();
204 keychain.decrypt_keysets(
205 &keysets.keysets,
206 &credentials.username,
207 &credentials.password,
208 account_key,
209 )?;
210
211 let mut vaults = Vec::new();
212 let mut skipped_vaults = Vec::new();
213 for vault in &account_info.vaults {
214 match unlock_vault(vault, &mut keychain)? {
215 VaultUnlock::Unlocked(vault) => vaults.push(vault),
216 VaultUnlock::Skipped(vault) => skipped_vaults.push(vault),
217 }
218 }
219
220 Ok((keychain, vaults, skipped_vaults))
221}
222
223enum VaultUnlock {
224 Unlocked(VaultInfo),
225 Skipped(SkippedVault),
226}
227
228fn unlock_vault(
231 vault: &super::wire::VaultInfo,
232 keychain: &mut Keychain,
233) -> Result<VaultUnlock, OnePasswordError> {
234 let skipped = |reason| {
235 VaultUnlock::Skipped(SkippedVault {
236 id: vault.uuid.clone(),
237 item_count: vault.active_item_count,
238 reason,
239 })
240 };
241
242 let enc_key = match find_working_key(&vault.access, keychain)? {
243 VaultKey::Found(enc_key) => enc_key,
244 VaultKey::Missing(reason) => return Ok(skipped(reason)),
245 };
246 keychain.decrypt_aes_key(enc_key)?;
247
248 if let Some(reason) = skip_reason(&vault.enc_attrs, keychain)? {
249 return Ok(skipped(reason));
250 }
251 let attributes: VaultAttributes = keychain.decrypt_json(&vault.enc_attrs)?;
252 Ok(VaultUnlock::Unlocked(VaultInfo {
253 id: vault.uuid.clone(),
254 name: attributes.name.unwrap_or_default(),
255 item_count: vault.active_item_count,
256 }))
257}
258
259struct VaultItemsDownload {
260 items: Vec<Item>,
261 skipped_items: Vec<SkippedItem>,
262}
263
264async fn download_vault_items(
266 vault_id: &str,
267 keychain: &Keychain,
268 session: &Session,
269) -> Result<VaultItemsDownload, OnePasswordError> {
270 let mut items = Vec::new();
271 let mut skipped_items = Vec::new();
272 let mut batch_id: i64 = 0;
273 loop {
274 let batch: VaultItemsBatch = session
275 .rest
276 .get_encrypted_json(
277 &format!("{VAULT_ENDPOINT}/{vault_id}/{batch_id}/items"),
278 &session.key,
279 )
280 .await?;
281
282 for item in batch.items.into_iter().flatten() {
283 if item.trashed == "Y" {
284 continue;
285 }
286 match parse_item(&item, keychain)? {
287 ItemDownload::Downloaded(item) => items.push(item),
288 ItemDownload::Skipped(item) => skipped_items.push(item),
289 }
290 }
291
292 if batch.complete {
293 return Ok(VaultItemsDownload {
294 items,
295 skipped_items,
296 });
297 }
298
299 if batch.version <= batch_id {
302 return Err(OnePasswordError::Internal(format!(
303 "vault {vault_id} pagination stalled at content version {batch_id}"
304 )));
305 }
306 batch_id = batch.version;
307 }
308}
309
310enum ItemDownload {
311 Downloaded(Item),
312 Skipped(SkippedItem),
313}
314
315fn parse_item(item: &VaultItem, keychain: &Keychain) -> Result<ItemDownload, OnePasswordError> {
318 let category = ItemCategory::from_template_id(&item.template_uuid);
319 if let Some(reason) = skip_reason(&item.enc_overview, keychain)? {
320 return Ok(ItemDownload::Skipped(SkippedItem {
321 id: item.uuid.clone(),
322 name: None,
323 category,
324 reason,
325 }));
326 }
327 let overview: VaultItemOverview = keychain.decrypt_json(&item.enc_overview)?;
328
329 if let Some(reason) = skip_reason(&item.enc_details, keychain)? {
330 return Ok(ItemDownload::Skipped(SkippedItem {
331 id: item.uuid.clone(),
332 name: overview.title.clone(),
333 category,
334 reason,
335 }));
336 }
337 let details = keychain.decrypt_json(&item.enc_details)?;
338
339 Ok(ItemDownload::Downloaded(Item {
340 id: item.uuid.clone(),
341 category,
342 overview,
343 details,
344 }))
345}
346
347fn skip_reason(
350 envelope: &EncryptedEnvelope,
351 keychain: &Keychain,
352) -> Result<Option<SkippedReason>, OnePasswordError> {
353 let encrypted = Encrypted::parse(envelope)?;
354 match keychain.can_decrypt(&encrypted) {
355 Ok(true) => Ok(None),
356 Ok(false) => Ok(Some(SkippedReason::NoAccess)),
357 Err(OnePasswordError::Unsupported(_)) => Ok(Some(SkippedReason::Unsupported)),
358 Err(error) => Err(error),
359 }
360}
361
362enum VaultKey<'a> {
363 Found(&'a EncryptedEnvelope),
364 Missing(SkippedReason),
365}
366
367fn find_working_key<'a>(
373 access: &'a [VaultAccess],
374 keychain: &Keychain,
375) -> Result<VaultKey<'a>, OnePasswordError> {
376 let mut reason = SkippedReason::NoAccess;
377 for entry in access.iter().filter(|entry| is_read_accessible(entry.acl)) {
378 match skip_reason(&entry.enc_vault_key, keychain)? {
379 None => return Ok(VaultKey::Found(&entry.enc_vault_key)),
380 Some(SkippedReason::Unsupported) => reason = SkippedReason::Unsupported,
381 Some(SkippedReason::NoAccess) => {}
382 }
383 }
384
385 Ok(VaultKey::Missing(reason))
386}
387
388fn is_read_accessible(acl: i32) -> bool {
390 const HAVE_READ_ACCESS: i32 = 32;
391 acl & HAVE_READ_ACCESS != 0
392}
393
394#[cfg(test)]
395mod tests {
396 use bitwarden_api_base::new_http_client;
397 use serde_json::json;
398 use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
399
400 use super::{
401 super::opdata::{AesKey, decode64_loose},
402 *,
403 };
404
405 const VAULT_ID: &str = "vault-id";
406
407 fn session(server: &MockServer) -> Session {
408 let rest = RestClient::new(
409 new_http_client(),
410 format!("http://{}/api", server.address()),
411 "client-id",
412 "user-agent",
413 "op-user-agent",
414 )
415 .expect("valid headers");
416
417 Session::new(session_key(), rest)
418 }
419
420 fn session_key() -> AesKey {
421 AesKey::new(
422 "SESSION",
423 decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"),
424 )
425 }
426
427 async fn mock_batch(server: &MockServer, batch_id: i64, body: serde_json::Value) {
429 let envelope = session_key()
430 .encrypt(body.to_string().as_bytes(), &[0u8; 12])
431 .expect("encrypts");
432 server
433 .register(
434 Mock::given(matchers::path(format!(
435 "/api/v1/vault/{VAULT_ID}/{batch_id}/items"
436 )))
437 .respond_with(
438 ResponseTemplate::new(200)
439 .set_body_json(serde_json::to_value(&envelope).expect("serializes")),
440 )
441 .expect(1),
442 )
443 .await;
444 }
445
446 fn batch(version: i64, complete: bool) -> serde_json::Value {
447 json!({"contentVersion": version, "batchComplete": complete, "items": []})
448 }
449
450 #[tokio::test]
451 async fn download_pages_until_the_batch_is_complete() {
452 let server = MockServer::start().await;
453 mock_batch(&server, 0, batch(7, false)).await;
454 mock_batch(&server, 7, batch(9, true)).await;
455
456 let download = download_vault_items(VAULT_ID, &Keychain::new(), &session(&server))
457 .await
458 .expect("pagination advances to the final batch");
459
460 assert!(download.items.is_empty());
461 assert!(download.skipped_items.is_empty());
462 server.verify().await;
463 }
464
465 #[tokio::test]
466 async fn items_without_keys_are_recorded_and_other_items_continue() {
467 let server = MockServer::start().await;
468 let readable_key = AesKey::new("VAULT", vec![7u8; 32]);
469 let missing_key = AesKey::new("MISSING", vec![8u8; 32]);
470 let overview = readable_key
471 .encrypt(br#"{"title":"partly readable"}"#, &[1u8; 12])
472 .expect("encrypts");
473 let missing_overview = missing_key
474 .encrypt(br#"{"title":"not readable"}"#, &[2u8; 12])
475 .expect("encrypts");
476 let missing_details = missing_key.encrypt(br#"{}"#, &[5u8; 12]).expect("encrypts");
477 let good_overview = readable_key
478 .encrypt(br#"{"title":"readable"}"#, &[3u8; 12])
479 .expect("encrypts");
480 let good_details = readable_key
481 .encrypt(br#"{}"#, &[4u8; 12])
482 .expect("encrypts");
483 mock_batch(
484 &server,
485 0,
486 json!({
487 "contentVersion": 1,
488 "batchComplete": true,
489 "items": [
490 {
491 "uuid": "bad-details",
492 "templateUuid": "001",
493 "trashed": "N",
494 "encOverview": overview,
495 "encDetails": missing_details
496 },
497 {
498 "uuid": "bad-overview",
499 "templateUuid": "002",
500 "trashed": "N",
501 "encOverview": missing_overview,
502 "encDetails": {
503 "kid": "VAULT", "enc": "A256GCM", "cty": "b5+jwk+json",
504 "data": "!"
505 }
506 },
507 {
508 "uuid": "good-item",
509 "templateUuid": "003",
510 "trashed": "N",
511 "encOverview": good_overview,
512 "encDetails": good_details
513 },
514 {
515 "uuid": "unsupported-encryption",
516 "templateUuid": "004",
517 "trashed": "N",
518 "encOverview": {
519 "kid": "VAULT", "enc": "FUTURE", "cty": "b5+jwk+json", "data": ""
520 },
521 "encDetails": {
522 "kid": "VAULT", "enc": "A256GCM", "cty": "b5+jwk+json", "data": ""
523 }
524 }
525 ]
526 }),
527 )
528 .await;
529
530 let mut keychain = Keychain::new();
531 keychain.add_aes(readable_key);
532 let download = download_vault_items(VAULT_ID, &keychain, &session(&server))
533 .await
534 .expect("one bad item does not fail its vault");
535
536 assert_eq!(download.items.len(), 1);
537 assert_eq!(download.items[0].id, "good-item");
538 assert_eq!(download.skipped_items.len(), 3);
539 assert_eq!(download.skipped_items[0].id, "bad-details");
540 assert_eq!(
541 download.skipped_items[0].name.as_deref(),
542 Some("partly readable")
543 );
544 assert_eq!(download.skipped_items[0].category, ItemCategory::Login);
545 assert_eq!(download.skipped_items[0].reason, SkippedReason::NoAccess);
546 assert_eq!(download.skipped_items[1].id, "bad-overview");
547 assert_eq!(download.skipped_items[1].name, None);
548 assert_eq!(download.skipped_items[1].category, ItemCategory::CreditCard);
549 assert_eq!(download.skipped_items[1].reason, SkippedReason::NoAccess);
550 assert_eq!(download.skipped_items[2].id, "unsupported-encryption");
551 assert_eq!(download.skipped_items[2].reason, SkippedReason::Unsupported);
552 server.verify().await;
553 }
554
555 #[test]
556 fn invalid_json_or_undecryptable_items_are_errors() {
557 let readable_key = AesKey::new("VAULT", vec![7u8; 32]);
558 let wrong_key = AesKey::new("VAULT", vec![8u8; 32]);
559 let details = readable_key
560 .encrypt(br#"{}"#, &[1u8; 12])
561 .expect("encrypts");
562 let invalid_json: VaultItem = serde_json::from_value(json!({
563 "uuid": "invalid-json",
564 "templateUuid": "001",
565 "trashed": "N",
566 "encOverview": readable_key
567 .encrypt(b"not json", &[4u8; 12])
568 .expect("encrypts"),
569 "encDetails": details,
570 }))
571 .expect("valid item envelope");
572 let undecryptable: VaultItem = serde_json::from_value(json!({
573 "uuid": "undecryptable",
574 "templateUuid": "001",
575 "trashed": "N",
576 "encOverview": wrong_key
577 .encrypt(br#"{}"#, &[2u8; 12])
578 .expect("encrypts"),
579 "encDetails": readable_key
580 .encrypt(br#"{}"#, &[3u8; 12])
581 .expect("encrypts"),
582 }))
583 .expect("valid item envelope");
584
585 let mut keychain = Keychain::new();
586 keychain.add_aes(readable_key);
587 assert!(matches!(
588 parse_item(&invalid_json, &keychain),
589 Err(OnePasswordError::Parse)
590 ));
591 assert!(matches!(
592 parse_item(&undecryptable, &keychain),
593 Err(OnePasswordError::Internal(_))
594 ));
595 }
596
597 #[tokio::test]
598 async fn download_stops_when_pagination_does_not_advance() {
599 let server = MockServer::start().await;
600 mock_batch(&server, 0, batch(7, false)).await;
601 mock_batch(&server, 7, batch(7, false)).await;
602
603 let error = download_vault_items(VAULT_ID, &Keychain::new(), &session(&server))
604 .await
605 .map(|_| ())
606 .expect_err("refetching the same page is an error, not a loop");
607
608 assert!(
609 error.to_string().contains("pagination stalled"),
610 "unexpected error: {error}"
611 );
612 server.verify().await;
613 }
614
615 #[tokio::test]
616 async fn a_missing_vault_is_reported() {
617 let server = MockServer::start().await;
618 server
619 .register(
620 Mock::given(matchers::path(format!("/api/v1/vault/{VAULT_ID}/0/items")))
621 .respond_with(ResponseTemplate::new(404).set_body_json(json!({
622 "errorCode": 117,
623 "errorMessage": "vault not found",
624 })))
625 .expect(1),
626 )
627 .await;
628
629 let result = download_vault(
630 VaultInfo {
631 id: VAULT_ID.into(),
632 name: "Known vault".into(),
633 item_count: Some(17),
634 },
635 &Keychain::new(),
636 &session(&server),
637 )
638 .await
639 .expect("a missing vault only skips that vault");
640
641 let VaultDownload::Skipped(vault) = result else {
642 panic!("the failed vault should be reported as skipped");
643 };
644 assert_eq!(vault.id, VAULT_ID);
645 assert_eq!(vault.item_count, Some(17));
646 assert_eq!(vault.reason, SkippedReason::NoAccess);
647 server.verify().await;
648 }
649
650 #[tokio::test]
651 async fn an_auth_or_transient_vault_failure_aborts_the_import() {
652 for status in [401, 503] {
653 let server = MockServer::start().await;
654 server
655 .register(
656 Mock::given(matchers::path(format!("/api/v1/vault/{VAULT_ID}/0/items")))
657 .respond_with(ResponseTemplate::new(status))
658 .expect(1),
659 )
660 .await;
661
662 let result = download_vault(
663 VaultInfo {
664 id: VAULT_ID.into(),
665 name: "Known vault".into(),
666 item_count: Some(17),
667 },
668 &Keychain::new(),
669 &session(&server),
670 )
671 .await;
672
673 assert!(result.is_err());
674 server.verify().await;
675 }
676 }
677
678 fn access(acl: i32, kid: &str) -> VaultAccess {
679 access_with_scheme(acl, kid, "A256GCM")
680 }
681
682 fn access_with_scheme(acl: i32, kid: &str, scheme: &str) -> VaultAccess {
683 serde_json::from_value(json!({
684 "acl": acl,
685 "encVaultKey": {"kid": kid, "enc": scheme, "cty": "b5+jwk+json", "data": ""},
686 }))
687 .expect("valid access entry")
688 }
689
690 #[test]
691 fn a_vault_with_unreadable_attributes_is_skipped() {
692 let account_key = AesKey::new("ACCOUNT", vec![1u8; 32]);
693 let vault_key = json!({"kid": "VAULT", "k": "WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM"});
694 let enc_vault_key = account_key
695 .encrypt(vault_key.to_string().as_bytes(), &[0u8; 12])
696 .expect("encrypts");
697 let mut keychain = Keychain::new();
698 keychain.add_aes(account_key);
699
700 for (kid, scheme, expected) in [
701 ("VAULT", "FUTURE", SkippedReason::Unsupported),
702 ("MISSING", "A256GCM", SkippedReason::NoAccess),
703 ] {
704 let vault: super::super::wire::VaultInfo = serde_json::from_value(json!({
705 "uuid": VAULT_ID,
706 "encAttrs": {"kid": kid, "enc": scheme, "cty": "b5+jwk+json", "data": ""},
707 "access": [{"acl": 32, "encVaultKey": enc_vault_key}],
708 }))
709 .expect("valid vault");
710
711 let unlocked =
712 unlock_vault(&vault, &mut keychain).expect("the envelopes are well formed");
713 assert!(matches!(
714 unlocked,
715 VaultUnlock::Skipped(SkippedVault { reason, .. }) if reason == expected
716 ));
717 }
718 }
719
720 #[test]
721 fn read_access_requires_the_read_bit() {
722 assert!(is_read_accessible(32));
723 assert!(is_read_accessible(0xFFFF));
724 assert!(!is_read_accessible(0));
725 assert!(!is_read_accessible(31));
726 }
727
728 #[test]
729 fn find_working_key_skips_entries_we_cannot_use() {
730 let mut keychain = Keychain::new();
731 keychain.add_aes(AesKey::new("usable", vec![0u8; 32]));
732
733 let entries = vec![
734 access(32, "missing"),
736 access_with_scheme(32, "usable", "FUTURE"),
738 access(1, "usable"),
740 access(32, "usable"),
742 ];
743
744 let found = find_working_key(&entries, &keychain).expect("the envelopes are well formed");
745 assert!(matches!(
746 found,
747 VaultKey::Found(entry) if entry.kid == "usable" && entry.enc == "A256GCM"
748 ));
749 }
750
751 #[test]
752 fn find_working_key_reports_no_access_without_a_usable_entry() {
753 let keychain = Keychain::new();
754 let entries = [
755 access(32, "missing"),
756 access_with_scheme(1, "missing", "FUTURE"),
758 ];
759
760 let found = find_working_key(&entries, &keychain).expect("the envelopes are well formed");
761 assert!(matches!(found, VaultKey::Missing(SkippedReason::NoAccess)));
762 }
763
764 #[test]
765 fn find_working_key_reports_an_unsupported_scheme() {
766 let keychain = Keychain::new();
767 let entries = [
768 access_with_scheme(32, "missing", "FUTURE"),
769 access(32, "missing"),
770 ];
771
772 let found = find_working_key(&entries, &keychain).expect("the envelopes are well formed");
773 assert!(matches!(
774 found,
775 VaultKey::Missing(SkippedReason::Unsupported)
776 ));
777 }
778}