bitwarden_importers/importers/onepassword/access/
client.rs1use 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
30pub struct Client {
32 http: reqwest::Client,
33}
34
35impl Client {
36 pub fn new(http: reqwest::Client) -> Client {
39 Client { http }
40 }
41
42 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 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 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
104pub(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
127struct VaultInfo {
129 id: String,
130 name: String,
131}
132
133async fn unlock(
137 credentials: &Credentials,
138 account_key: &AccountKey,
139 session: &Session,
140) -> Result<(Keychain, Vec<VaultInfo>), OnePasswordError> {
141 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 let mut keychain = Keychain::new();
153 keychain.decrypt_keysets(
154 &keysets.keysets,
155 &credentials.username,
156 &credentials.password,
157 account_key,
158 )?;
159
160 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
180async 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 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
219fn 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
229fn 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
250fn 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 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 access(32, "missing"),
368 access(1, "usable"),
370 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}