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},
12    model::{Item, ItemCategory, Vault},
13    opdata::Encrypted,
14    rest::RestClient,
15    session::Session,
16    two_factor::TwoFactorUi,
17    wire::{
18        AccountInfo, EncryptedEnvelope, KeysetsInfo, VaultAccess, VaultAttributes, VaultItem,
19        VaultItemsBatch,
20    },
21};
22
23const PASSWORD_SK_METHOD: &str = "PASSWORD+SK";
24const MAX_OTP_ATTEMPTS: u32 = 3;
25const ACCOUNT_INFO_ENDPOINT: &str =
26    "v1/account?attrs=billing,counts,groups,invite,me,settings,tier,user-flags,users,vaults";
27const KEYSETS_ENDPOINT: &str = "v1/account/keysets";
28const VAULT_ENDPOINT: &str = "v1/vault";
29
30/// The 1Password client. Holds the injected HTTP transport so tests can point it at a mock host.
31pub struct Client {
32    http: reqwest::Client,
33}
34
35impl Client {
36    /// Creates a client over the given HTTP transport. The caller owns TLS configuration; in the
37    /// SDK that means `bitwarden_api_base::new_http_client()` or the client's own pooled instance.
38    pub fn new(http: reqwest::Client) -> Client {
39        Client { http }
40    }
41
42    /// Logs in and downloads every vault the account can open, driving 2FA through `ui` when
43    /// required.
44    ///
45    /// An import takes the whole account, so there is no vault selection.
46    pub async fn download_all_vaults(
47        &self,
48        credentials: Credentials,
49        ui: &dyn TwoFactorUi,
50    ) -> Result<Vec<Vault>, OnePasswordError> {
51        let mut credentials = Zeroizing::new(credentials);
52        credentials.sign_in_address.normalize()?;
53        let account_key = AccountKey::parse(&credentials.account_key)?;
54        let session = self.login(&credentials, &account_key, ui).await?;
55        download_vaults(&credentials, &account_key, &session).await
56    }
57
58    /// Runs the login sequence, retrying the whole thing when the server rejects a TOTP code.
59    ///
60    /// A rejected code makes 1Password invalidate the session, so a wrong code restarts from
61    /// scratch, up to three times.
62    async fn login(
63        &self,
64        credentials: &Credentials,
65        account_key: &AccountKey,
66        ui: &dyn TwoFactorUi,
67    ) -> Result<Session, OnePasswordError> {
68        let device_uuid = super::device::generate_device_uuid();
69        let client_info = ClientInfo::for_desktop(&device_uuid);
70        let rest = RestClient::new(
71            self.http.clone(),
72            format!("https://{}/api", credentials.sign_in_address),
73            &client_info.client_id(),
74            &client_info.user_agent,
75            &client_info.op_user_agent,
76        )?;
77
78        // Confirm password + Secret Key login is available. This does not change between attempts.
79        let login_info = login::fetch_auth_methods(&credentials.username, &rest).await?;
80        if !login_info
81            .auth_methods
82            .iter()
83            .any(|m| m.kind == PASSWORD_SK_METHOD)
84        {
85            return Err(OnePasswordError::Unsupported(format!(
86                "no password login method found for account {}",
87                credentials.username
88            )));
89        }
90
91        for attempt in 0..MAX_OTP_ATTEMPTS {
92            match login::login_attempt(credentials, account_key, &client_info, attempt, ui, &rest)
93                .await?
94            {
95                LoginOutcome::Success(session) => return Ok(*session),
96                LoginOutcome::BadOtp => continue,
97            }
98        }
99
100        Err(OnePasswordError::TwoFactorFailed)
101    }
102}
103
104/// Unlocks the account's keys and downloads every vault the session can open.
105///
106/// Split out of [`Client::download_all_vaults`] so the fixture replay can drive the real download
107/// over captured responses without performing the login exchange.
108pub(super) async fn download_vaults(
109    credentials: &Credentials,
110    account_key: &AccountKey,
111    session: &Session,
112) -> Result<Vec<Vault>, OnePasswordError> {
113    let (keychain, vaults) = unlock(credentials, account_key, session).await?;
114
115    let mut downloaded = Vec::with_capacity(vaults.len());
116    for info in &vaults {
117        downloaded.push(Vault {
118            id: info.id.clone(),
119            name: info.name.clone(),
120            items: download_vault_items(&info.id, &keychain, session).await?,
121        });
122    }
123
124    Ok(downloaded)
125}
126
127/// A vault the account can open, with its attributes already decrypted.
128struct VaultInfo {
129    id: String,
130    name: String,
131}
132
133/// Decrypts the account keysets and every accessible vault key.
134///
135/// The keychain is complete when this returns, so the download itself never adds to it.
136async fn unlock(
137    credentials: &Credentials,
138    account_key: &AccountKey,
139    session: &Session,
140) -> Result<(Keychain, Vec<VaultInfo>), OnePasswordError> {
141    // The vault list, and the keysets that unlock it.
142    let account_info: AccountInfo = session
143        .rest
144        .get_encrypted_json(ACCOUNT_INFO_ENDPOINT, &session.key)
145        .await?;
146    let keysets: KeysetsInfo = session
147        .rest
148        .get_encrypted_json(KEYSETS_ENDPOINT, &session.key)
149        .await?;
150
151    // Everything else hangs off the master key, which only the credentials can produce.
152    let mut keychain = Keychain::new();
153    keychain.decrypt_keysets(
154        &keysets.keysets,
155        &credentials.username,
156        &credentials.password,
157        account_key,
158    )?;
159
160    // A vault whose key we do not hold is one the account can see but not open.
161    // TODO: Report skipped vaults and failed items instead of dropping them silently or failing the
162    // entire import.
163    let mut vaults = Vec::new();
164    for vault in &account_info.vaults {
165        let Some(enc_key) = find_working_key(&vault.access, &keychain)? else {
166            continue;
167        };
168        keychain.decrypt_aes_key(enc_key)?;
169
170        let attributes: VaultAttributes = keychain.decrypt_json(&vault.enc_attrs)?;
171        vaults.push(VaultInfo {
172            id: vault.uuid.clone(),
173            name: attributes.name.unwrap_or_default(),
174        });
175    }
176
177    Ok((keychain, vaults))
178}
179
180/// Pages through a vault's items until `batchComplete`, parsing each supported item.
181async fn download_vault_items(
182    vault_id: &str,
183    keychain: &Keychain,
184    session: &Session,
185) -> Result<Vec<Item>, OnePasswordError> {
186    let mut items = Vec::new();
187    let mut batch_id: i64 = 0;
188    loop {
189        let batch: VaultItemsBatch = session
190            .rest
191            .get_encrypted_json(
192                &format!("{VAULT_ENDPOINT}/{vault_id}/{batch_id}/items"),
193                &session.key,
194            )
195            .await?;
196
197        for item in batch.items.into_iter().flatten() {
198            if item.trashed == "Y" {
199                continue;
200            }
201            items.push(parse_item(&item, keychain)?);
202        }
203
204        if batch.complete {
205            return Ok(items);
206        }
207
208        // The batch id is a cursor, so an unchanged (or rewound) version would refetch the same
209        // page forever and duplicate its items. Nothing can make progress from here.
210        if batch.version <= batch_id {
211            return Err(OnePasswordError::Internal(format!(
212                "vault {vault_id} pagination stalled at content version {batch_id}"
213            )));
214        }
215        batch_id = batch.version;
216    }
217}
218
219/// Decrypts both payloads. Every category is kept, not only logins.
220fn parse_item(item: &VaultItem, keychain: &Keychain) -> Result<Item, OnePasswordError> {
221    Ok(Item {
222        id: item.uuid.clone(),
223        category: ItemCategory::from_template_id(&item.template_uuid),
224        overview: keychain.decrypt_json(&item.enc_overview)?,
225        details: keychain.decrypt_json(&item.enc_details)?,
226    })
227}
228
229/// Finds a readable access entry whose vault key the keychain can already decrypt.
230///
231/// `None` means every readable entry names a key we do not hold, which is a vault the account can
232/// see but not open. A malformed envelope or an unsupported scheme is an error instead, so an
233/// unreadable format never passes for a missing key.
234fn find_working_key<'a>(
235    access: &'a [VaultAccess],
236    keychain: &Keychain,
237) -> Result<Option<&'a EncryptedEnvelope>, OnePasswordError> {
238    for entry in access {
239        if is_read_accessible(entry.acl) {
240            let encrypted = Encrypted::parse(&entry.enc_vault_key)?;
241            if keychain.can_decrypt(&encrypted)? {
242                return Ok(Some(&entry.enc_vault_key));
243            }
244        }
245    }
246
247    Ok(None)
248}
249
250/// Whether an ACL grants read access.
251fn is_read_accessible(acl: i32) -> bool {
252    const HAVE_READ_ACCESS: i32 = 32;
253    acl & HAVE_READ_ACCESS != 0
254}
255
256#[cfg(test)]
257mod tests {
258    use bitwarden_api_base::new_http_client;
259    use serde_json::json;
260    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
261
262    use super::{
263        super::opdata::{AesKey, decode64_loose},
264        *,
265    };
266
267    const VAULT_ID: &str = "vault-id";
268
269    fn session(server: &MockServer) -> Session {
270        let rest = RestClient::new(
271            new_http_client(),
272            format!("http://{}/api", server.address()),
273            "client-id",
274            "user-agent",
275            "op-user-agent",
276        )
277        .expect("valid headers");
278
279        Session::new(session_key(), rest)
280    }
281
282    fn session_key() -> AesKey {
283        AesKey::new(
284            "SESSION",
285            decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"),
286        )
287    }
288
289    /// Registers an encrypted items batch at `v1/vault/{VAULT_ID}/{batch_id}/items`.
290    async fn mock_batch(server: &MockServer, batch_id: i64, body: serde_json::Value) {
291        let envelope = session_key()
292            .encrypt(body.to_string().as_bytes(), &[0u8; 12])
293            .expect("encrypts");
294        server
295            .register(
296                Mock::given(matchers::path(format!(
297                    "/api/v1/vault/{VAULT_ID}/{batch_id}/items"
298                )))
299                .respond_with(
300                    ResponseTemplate::new(200)
301                        .set_body_json(serde_json::to_value(&envelope).expect("serializes")),
302                )
303                .expect(1),
304            )
305            .await;
306    }
307
308    fn batch(version: i64, complete: bool) -> serde_json::Value {
309        json!({"contentVersion": version, "batchComplete": complete, "items": []})
310    }
311
312    #[tokio::test]
313    async fn download_pages_until_the_batch_is_complete() {
314        let server = MockServer::start().await;
315        mock_batch(&server, 0, batch(7, false)).await;
316        mock_batch(&server, 7, batch(9, true)).await;
317
318        let items = download_vault_items(VAULT_ID, &Keychain::new(), &session(&server))
319            .await
320            .expect("pagination advances to the final batch");
321
322        assert!(items.is_empty());
323        server.verify().await;
324    }
325
326    #[tokio::test]
327    async fn download_stops_when_pagination_does_not_advance() {
328        let server = MockServer::start().await;
329        mock_batch(&server, 0, batch(7, false)).await;
330        mock_batch(&server, 7, batch(7, false)).await;
331
332        let error = download_vault_items(VAULT_ID, &Keychain::new(), &session(&server))
333            .await
334            .map(|_| ())
335            .expect_err("refetching the same page is an error, not a loop");
336
337        assert!(
338            error.to_string().contains("pagination stalled"),
339            "unexpected error: {error}"
340        );
341        server.verify().await;
342    }
343
344    fn access(acl: i32, kid: &str) -> VaultAccess {
345        serde_json::from_value(json!({
346            "acl": acl,
347            "encVaultKey": {"kid": kid, "enc": "A256GCM", "cty": "b5+jwk+json", "data": ""},
348        }))
349        .expect("valid access entry")
350    }
351
352    #[test]
353    fn read_access_requires_the_read_bit() {
354        assert!(is_read_accessible(32));
355        assert!(is_read_accessible(0xFFFF));
356        assert!(!is_read_accessible(0));
357        assert!(!is_read_accessible(31));
358    }
359
360    #[test]
361    fn find_working_key_skips_entries_we_cannot_use() {
362        let mut keychain = Keychain::new();
363        keychain.add_aes(AesKey::new("usable", vec![0u8; 32]));
364
365        let entries = vec![
366            // Readable, but the key is not in the keychain.
367            access(32, "missing"),
368            // The key is in the keychain, but there is no read access.
369            access(1, "usable"),
370            // Both.
371            access(32, "usable"),
372        ];
373
374        let found = find_working_key(&entries, &keychain)
375            .expect("the schemes are all supported")
376            .expect("a usable entry");
377        assert_eq!(found.kid, "usable");
378    }
379
380    #[test]
381    fn find_working_key_returns_nothing_without_a_usable_entry() {
382        let keychain = Keychain::new();
383        let entries = [access(32, "missing")];
384        let found = find_working_key(&entries, &keychain).expect("the scheme is supported");
385        assert!(found.is_none());
386    }
387}