Skip to main content

bitwarden_importers/importers/onepassword/access/
client.rs

1//! The entry point: log in, unlock the account's keys, download its vaults.
2
3use 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
32/// The 1Password client. Holds the injected HTTP transport so tests can point it at a mock host.
33pub struct Client {
34    http: reqwest::Client,
35}
36
37impl Client {
38    /// Creates a client over the given HTTP transport. The caller owns TLS configuration; in the
39    /// SDK that means `bitwarden_api_base::new_http_client()` or the client's own pooled instance.
40    pub fn new(http: reqwest::Client) -> Client {
41        Client { http }
42    }
43
44    /// Asks the server how the account signs in. It needs only the email, so a UI can find out
45    /// before asking for anything else.
46    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    /// Opens the account by signing in and decrypting every accessible vault, driving 2FA through
60    /// `ui` when required.
61    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    /// Runs the login sequence, retrying the whole thing when the server rejects a TOTP code.
74    ///
75    /// A rejected code makes 1Password invalidate the session, so a wrong code restarts from
76    /// scratch, up to three times.
77    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        // Confirm password + Secret Key login is available. This does not change between attempts.
88        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
123/// Unlocks the account's keys and downloads every vault the session can open.
124pub(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
146/// A vault the account can open, with its attributes already decrypted.
147struct VaultInfo {
148    id: String,
149    name: String,
150    item_count: Option<u32>,
151}
152
153enum VaultDownload {
154    Downloaded(Vault),
155    Skipped(SkippedVault),
156}
157
158/// Downloads one vault. A missing vault becomes a partial result; every other failure aborts.
159async 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        // TODO: Double check that this is the right error code for a missing vault.
167        //       To test that create a shared vault that the user doesn't have access to.
168        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
186/// Decrypts the account keysets and every accessible vault key.
187async fn unlock_account(
188    credentials: &Credentials,
189    account_key: &AccountKey,
190    session: &Session,
191) -> Result<(Keychain, Vec<VaultInfo>, Vec<SkippedVault>), OnePasswordError> {
192    // The vault list, and the keysets that unlock it.
193    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    // Everything else hangs off the master key, which only the credentials can produce.
203    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
228/// Decrypts a vault's key and attributes. Missing keys and unsupported encryption are skippable;
229/// malformed data is an error.
230fn 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
264/// Pages through a vault's items until `batchComplete`, parsing each supported item.
265async 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        // The batch id is a cursor, so an unchanged (or rewound) version would refetch the same
300        // page forever and duplicate its items. Nothing can make progress from here.
301        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
315/// Decrypts both payloads. Missing keys and unsupported encryption are skippable; malformed data
316/// is an error.
317fn 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
347/// Why `envelope` has to be skipped: its scheme is unsupported or its key is not in the keychain.
348/// `None` does not promise it decrypts.
349fn 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
367/// Finds a readable access entry whose vault key the keychain can already decrypt.
368///
369/// Without one, the reason is `Unsupported` when a readable entry uses a scheme we cannot decrypt,
370/// otherwise `NoAccess`: a vault the account can see but not open. A malformed envelope is an
371/// error.
372fn 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
388/// Whether an ACL grants read access.
389fn 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    /// Registers an encrypted items batch at `v1/vault/{VAULT_ID}/{batch_id}/items`.
428    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            // Readable, but the key is not in the keychain.
735            access(32, "missing"),
736            // Readable, but in a scheme we cannot decrypt.
737            access_with_scheme(32, "usable", "FUTURE"),
738            // The key is in the keychain, but there is no read access.
739            access(1, "usable"),
740            // Both.
741            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            // Without read access the scheme does not matter.
757            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}