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