1use bitwarden_api_api::models::{
2 DomainsResponseModel, ProfileOrganizationResponseModel, ProfileResponseModel, SyncResponseModel,
3};
4use bitwarden_collections::{collection::Collection, error::CollectionsParseError};
5use bitwarden_core::{
6 Client, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
7 client::encryption_settings::EncryptionSettingsError,
8 key_management::{MasterPasswordError, UserDecryptionData},
9 require,
10};
11use bitwarden_vault::{Cipher, Folder, GlobalDomains, VaultParseError};
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum SyncError {
17 #[error(transparent)]
18 Api(#[from] bitwarden_core::ApiError),
19 #[error(transparent)]
20 MissingField(#[from] MissingFieldError),
21 #[error(transparent)]
22 VaultParse(#[from] VaultParseError),
23 #[error(transparent)]
24 CollectionParse(#[from] CollectionsParseError),
25 #[error(transparent)]
26 EncryptionSettings(#[from] EncryptionSettingsError),
27 #[error(transparent)]
28 MasterPassword(#[from] MasterPasswordError),
29 #[error(transparent)]
30 NotAuthenticated(#[from] NotAuthenticatedError),
31}
32
33#[allow(missing_docs)]
34#[derive(Serialize, Deserialize, Debug)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct SyncRequest {
37 pub exclude_subdomains: Option<bool>,
39}
40
41pub(crate) async fn sync(client: &Client, input: &SyncRequest) -> Result<SyncResponse, SyncError> {
42 let config = client.internal.get_api_configurations().await;
43 let sync = config
44 .api_client
45 .sync_api()
46 .get(input.exclude_subdomains)
47 .await
48 .map_err(|e| SyncError::Api(e.into()))?;
49
50 let master_password_unlock = sync
51 .user_decryption
52 .as_deref()
53 .map(UserDecryptionData::try_from)
54 .transpose()?
55 .and_then(|user_decryption| user_decryption.master_password_unlock);
56 if let Some(master_password_unlock) = master_password_unlock {
57 client
58 .internal
59 .set_user_master_password_unlock(master_password_unlock)?;
60 }
61
62 let org_keys: Vec<_> = require!(sync.profile.as_ref())
63 .organizations
64 .as_deref()
65 .unwrap_or_default()
66 .iter()
67 .filter_map(|o| o.id.zip(o.key.as_deref().and_then(|k| k.parse().ok())))
68 .map(|(id, key)| (OrganizationId::new(id), key))
69 .collect();
70
71 client.internal.initialize_org_crypto(org_keys)?;
72
73 SyncResponse::process_response(sync)
74}
75
76#[derive(Serialize, Deserialize, Debug)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct ProfileResponse {
79 pub id: UserId,
80 pub name: String,
81 pub email: String,
82
83 pub organizations: Vec<ProfileOrganizationResponse>,
86}
87
88#[derive(Serialize, Deserialize, Debug)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct ProfileOrganizationResponse {
91 pub id: OrganizationId,
92}
93
94#[derive(Serialize, Deserialize, Debug)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct DomainResponse {
97 pub equivalent_domains: Vec<Vec<String>>,
98 pub global_equivalent_domains: Vec<GlobalDomains>,
99}
100
101#[allow(missing_docs)]
102#[derive(Serialize, Deserialize, Debug)]
103#[serde(rename_all = "camelCase", deny_unknown_fields)]
104pub struct SyncResponse {
105 pub profile: ProfileResponse,
108 pub folders: Vec<Folder>,
109 pub collections: Vec<Collection>,
110 pub ciphers: Vec<Cipher>,
112 pub domains: Option<DomainResponse>,
113 }
116
117impl SyncResponse {
118 pub(crate) fn process_response(response: SyncResponseModel) -> Result<SyncResponse, SyncError> {
119 let profile = require!(response.profile);
120 let ciphers = require!(response.ciphers);
121
122 fn try_into_iter<In, InItem, Out, OutItem>(iter: In) -> Result<Out, InItem::Error>
123 where
124 In: IntoIterator<Item = InItem>,
125 InItem: TryInto<OutItem>,
126 Out: FromIterator<OutItem>,
127 {
128 iter.into_iter().map(|i| i.try_into()).collect()
129 }
130
131 Ok(SyncResponse {
132 profile: ProfileResponse::process_response(*profile)?,
133 folders: try_into_iter(require!(response.folders))?,
134 collections: try_into_iter(require!(response.collections))?,
135 ciphers: try_into_iter(ciphers)?,
136 domains: response.domains.map(|d| (*d).try_into()).transpose()?,
137 })
140 }
141}
142
143impl ProfileOrganizationResponse {
144 fn process_response(
145 response: ProfileOrganizationResponseModel,
146 ) -> Result<ProfileOrganizationResponse, MissingFieldError> {
147 Ok(ProfileOrganizationResponse {
148 id: OrganizationId::new(require!(response.id)),
149 })
150 }
151}
152
153impl ProfileResponse {
154 fn process_response(
155 response: ProfileResponseModel,
156 ) -> Result<ProfileResponse, MissingFieldError> {
157 Ok(ProfileResponse {
158 id: UserId::new(require!(response.id)),
159 name: require!(response.name),
160 email: require!(response.email),
161 organizations: response
164 .organizations
165 .unwrap_or_default()
166 .into_iter()
167 .map(ProfileOrganizationResponse::process_response)
168 .collect::<Result<_, _>>()?,
169 })
170 }
171}
172
173impl TryFrom<DomainsResponseModel> for DomainResponse {
174 type Error = SyncError;
175
176 fn try_from(value: DomainsResponseModel) -> Result<Self, Self::Error> {
177 Ok(Self {
178 equivalent_domains: value.equivalent_domains.unwrap_or_default(),
179 global_equivalent_domains: value
180 .global_equivalent_domains
181 .unwrap_or_default()
182 .into_iter()
183 .map(|s| s.try_into())
184 .collect::<Result<Vec<GlobalDomains>, _>>()?,
185 })
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use std::num::NonZeroU32;
192
193 use bitwarden_api_api::models::{
194 KdfType, MasterPasswordUnlockKdfResponseModel, MasterPasswordUnlockResponseModel,
195 UserDecryptionResponseModel,
196 };
197 use bitwarden_core::{
198 ClientSettings, DeviceType,
199 key_management::{
200 SymmetricKeyId,
201 crypto::{InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest},
202 },
203 };
204 use bitwarden_crypto::{EncString, Kdf, UnsignedSharedKey};
205 use bitwarden_test::start_api_mock;
206 use wiremock::{Mock, MockServer, Request, ResponseTemplate, matchers};
207
208 use super::*;
209
210 const TEST_USER_NAME: &str = "Test User";
211 const TEST_USER_EMAIL: &str = "[email protected]";
212 const TEST_USER_PASSWORD: &str = "asdfasdfasdf";
213 const TEST_USER_ID: &str = "060000fb-0922-4dd3-b170-6e15cb5df8c8";
214 const TEST_ACCOUNT_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
215 const TEST_ACCOUNT_PRIVATE_KEY: &str = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=";
216 const TEST_ACCOUNT_ORGANIZATION_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
217 const TEST_ACCOUNT_ORGANIZATION_KEY: &str = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==";
218
219 fn create_profile_response(user_id: UserId) -> ProfileResponseModel {
220 ProfileResponseModel {
221 id: Some(user_id.into()),
222 name: Some(TEST_USER_NAME.to_string()),
223 email: Some(TEST_USER_EMAIL.to_string()),
224 organizations: Some(vec![]),
225 ..ProfileResponseModel::new()
226 }
227 }
228
229 fn create_sync_response(user_id: UserId) -> SyncResponseModel {
230 SyncResponseModel {
231 profile: Some(Box::new(create_profile_response(user_id))),
232 folders: Some(vec![]),
233 collections: Some(vec![]),
234 ciphers: Some(vec![]),
235 ..SyncResponseModel::new()
236 }
237 }
238
239 async fn setup_sync_client(
240 response: SyncResponseModel,
241 user_crypto_request: InitUserCryptoRequest,
242 org_crypto_request: Option<InitOrgCryptoRequest>,
243 ) -> (MockServer, Client) {
244 let (server, api_config) = start_api_mock(vec![
245 Mock::given(matchers::path("/sync"))
246 .respond_with(move |_: &Request| {
247 ResponseTemplate::new(200).set_body_json(response.to_owned())
248 })
249 .expect(1),
250 ])
251 .await;
252
253 let client = Client::new(Some(ClientSettings {
254 identity_url: api_config.base_path.clone(),
255 api_url: api_config.base_path,
256 user_agent: api_config.user_agent.unwrap(),
257 device_type: DeviceType::SDK,
258 bitwarden_client_version: None,
259 }));
260
261 client
262 .crypto()
263 .initialize_user_crypto(user_crypto_request)
264 .await
265 .unwrap();
266
267 if let Some(org_crypto_request) = org_crypto_request {
268 client
269 .crypto()
270 .initialize_org_crypto(org_crypto_request)
271 .await
272 .unwrap();
273 }
274
275 (server, client)
276 }
277
278 fn make_user_crypto_request() -> InitUserCryptoRequest {
279 InitUserCryptoRequest {
280 user_id: Some(TEST_USER_ID.parse().unwrap()),
281 kdf_params: Kdf::default(),
282 email: TEST_USER_EMAIL.to_string(),
283 private_key: TEST_ACCOUNT_PRIVATE_KEY.parse().unwrap(),
284 signing_key: None,
285 security_state: None,
286 method: InitUserCryptoMethod::Password {
287 password: TEST_USER_PASSWORD.to_string(),
288 user_key: TEST_ACCOUNT_USER_KEY.parse().unwrap(),
289 },
290 }
291 }
292
293 #[tokio::test]
294 async fn test_sync_user_empty_vault_no_organizations() {
295 let user_id: UserId = TEST_USER_ID.parse().unwrap();
296 let organization_id: OrganizationId = TEST_ACCOUNT_ORGANIZATION_ID
297 .parse()
298 .expect("Invalid organization ID");
299 let user_crypto_request = make_user_crypto_request();
300 let (_server, client) =
301 setup_sync_client(create_sync_response(user_id), user_crypto_request, None).await;
302
303 let sync_request = SyncRequest {
304 exclude_subdomains: Some(false),
305 };
306
307 let sync_response = sync(&client, &sync_request).await;
308 assert!(sync_response.is_ok());
309
310 let sync_response = sync_response.unwrap();
311 assert_eq!(sync_response.profile.id, user_id);
312 assert_eq!(sync_response.profile.name, TEST_USER_NAME);
313 assert_eq!(sync_response.profile.email, TEST_USER_EMAIL);
314 assert!(sync_response.profile.organizations.is_empty());
315 assert!(sync_response.ciphers.is_empty());
316 assert!(sync_response.folders.is_empty());
317 assert!(sync_response.collections.is_empty());
318 assert!(sync_response.domains.is_none());
319 assert!(
320 !client
321 .internal
322 .get_key_store()
323 .context()
324 .has_symmetric_key(SymmetricKeyId::Organization(organization_id))
325 );
326 }
327
328 #[tokio::test]
329 async fn test_sync_user_with_organization() {
330 let user_id = UserId::new(uuid::uuid!(TEST_USER_ID));
331 let organization_id: OrganizationId = TEST_ACCOUNT_ORGANIZATION_ID
332 .parse()
333 .expect("Invalid organization ID");
334 let organization_key: UnsignedSharedKey = TEST_ACCOUNT_ORGANIZATION_KEY
335 .parse()
336 .expect("Invalid organization key");
337 let user_crypto_request = make_user_crypto_request();
338 let response = SyncResponseModel {
339 profile: Some(Box::new(ProfileResponseModel {
340 organizations: Some(vec![ProfileOrganizationResponseModel {
341 id: Some(organization_id.into()),
342 key: Some(organization_key.to_string()),
343 ..ProfileOrganizationResponseModel::new()
344 }]),
345 ..create_profile_response(user_id)
346 })),
347 ..create_sync_response(user_id)
348 };
349 let (_server, client) = setup_sync_client(response, user_crypto_request, None).await;
350
351 let sync_request = SyncRequest {
352 exclude_subdomains: Some(false),
353 };
354
355 let sync_response = sync(&client, &sync_request).await;
356 assert!(sync_response.is_ok());
357
358 let sync_response = sync_response.unwrap();
359 assert_eq!(sync_response.profile.id, user_id);
360 assert_eq!(sync_response.profile.name, TEST_USER_NAME);
361 assert_eq!(sync_response.profile.email, TEST_USER_EMAIL);
362 assert_eq!(sync_response.profile.organizations.len(), 1);
363 let organization = sync_response.profile.organizations.first().unwrap();
364 assert_eq!(organization.id, organization_id);
365 assert!(sync_response.ciphers.is_empty());
366 assert!(sync_response.folders.is_empty());
367 assert!(sync_response.collections.is_empty());
368 assert!(sync_response.domains.is_none());
369 assert!(
370 client
371 .internal
372 .get_key_store()
373 .context()
374 .has_symmetric_key(SymmetricKeyId::Organization(organization_id))
375 );
376 }
377
378 #[tokio::test]
379 async fn test_sync_user_with_decryption_options_master_password_unlock() {
380 let user_id = UserId::new(uuid::uuid!(TEST_USER_ID));
381 let user_key: EncString = TEST_ACCOUNT_USER_KEY.parse().expect("Invalid user key");
382 let user_crypto_request = make_user_crypto_request();
383 let response = SyncResponseModel {
384 user_decryption: Some(Box::new(UserDecryptionResponseModel {
385 master_password_unlock: Some(Box::new(MasterPasswordUnlockResponseModel {
386 kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
387 kdf_type: KdfType::Argon2id,
388 iterations: 4,
389 memory: Some(65),
390 parallelism: Some(5),
391 }),
392 salt: Some(TEST_USER_EMAIL.to_string()),
393 master_key_encrypted_user_key: Some(user_key.to_string()),
394 })),
395 })),
396 ..create_sync_response(user_id)
397 };
398 let (_server, client) = setup_sync_client(response, user_crypto_request, None).await;
399
400 let sync_request = SyncRequest {
401 exclude_subdomains: Some(false),
402 };
403
404 let sync_response = sync(&client, &sync_request).await;
405 assert!(sync_response.is_ok());
406
407 assert_eq!(
408 client.internal.get_kdf().unwrap(),
409 Kdf::Argon2id {
410 iterations: NonZeroU32::new(4).unwrap(),
411 memory: NonZeroU32::new(65).unwrap(),
412 parallelism: NonZeroU32::new(5).unwrap(),
413 }
414 );
415 }
416}