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