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 account_cryptographic_state::WrappedAccountCryptographicState,
202 crypto::{InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest},
203 },
204 };
205 use bitwarden_crypto::{EncString, Kdf, UnsignedSharedKey};
206 use bitwarden_test::start_api_mock;
207 use wiremock::{Mock, MockServer, Request, ResponseTemplate, matchers};
208
209 use super::*;
210
211 const TEST_USER_NAME: &str = "Test User";
212 const TEST_USER_EMAIL: &str = "[email protected]";
213 const TEST_USER_PASSWORD: &str = "asdfasdfasdf";
214 const TEST_USER_ID: &str = "060000fb-0922-4dd3-b170-6e15cb5df8c8";
215 const TEST_ACCOUNT_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
216 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=";
217 const TEST_ACCOUNT_ORGANIZATION_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
218 const TEST_ACCOUNT_ORGANIZATION_KEY: &str = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==";
219
220 fn create_profile_response(user_id: UserId) -> ProfileResponseModel {
221 ProfileResponseModel {
222 id: Some(user_id.into()),
223 name: Some(TEST_USER_NAME.to_string()),
224 email: Some(TEST_USER_EMAIL.to_string()),
225 organizations: Some(vec![]),
226 ..ProfileResponseModel::new()
227 }
228 }
229
230 fn create_sync_response(user_id: UserId) -> SyncResponseModel {
231 SyncResponseModel {
232 profile: Some(Box::new(create_profile_response(user_id))),
233 folders: Some(vec![]),
234 collections: Some(vec![]),
235 ciphers: Some(vec![]),
236 ..SyncResponseModel::new()
237 }
238 }
239
240 async fn setup_sync_client(
241 response: SyncResponseModel,
242 user_crypto_request: InitUserCryptoRequest,
243 org_crypto_request: Option<InitOrgCryptoRequest>,
244 ) -> (MockServer, Client) {
245 let (server, api_config) = start_api_mock(vec![
246 Mock::given(matchers::path("/sync"))
247 .respond_with(move |_: &Request| {
248 ResponseTemplate::new(200).set_body_json(response.to_owned())
249 })
250 .expect(1),
251 ])
252 .await;
253
254 let client = Client::new(Some(ClientSettings {
255 identity_url: api_config.base_path.clone(),
256 api_url: api_config.base_path,
257 user_agent: api_config.user_agent.unwrap(),
258 device_type: DeviceType::SDK,
259 bitwarden_client_version: None,
260 }));
261
262 client
263 .crypto()
264 .initialize_user_crypto(user_crypto_request)
265 .await
266 .unwrap();
267
268 if let Some(org_crypto_request) = org_crypto_request {
269 client
270 .crypto()
271 .initialize_org_crypto(org_crypto_request)
272 .await
273 .unwrap();
274 }
275
276 (server, client)
277 }
278
279 fn make_user_crypto_request() -> InitUserCryptoRequest {
280 InitUserCryptoRequest {
281 user_id: Some(TEST_USER_ID.parse().unwrap()),
282 kdf_params: Kdf::default(),
283 email: TEST_USER_EMAIL.to_string(),
284 account_cryptographic_state: WrappedAccountCryptographicState::V1 {
285 private_key: TEST_ACCOUNT_PRIVATE_KEY.parse().unwrap(),
286 },
287 method: InitUserCryptoMethod::Password {
288 password: TEST_USER_PASSWORD.to_string(),
289 user_key: TEST_ACCOUNT_USER_KEY.parse().unwrap(),
290 },
291 }
292 }
293
294 #[tokio::test]
295 async fn test_sync_user_empty_vault_no_organizations() {
296 let user_id: UserId = TEST_USER_ID.parse().unwrap();
297 let organization_id: OrganizationId = TEST_ACCOUNT_ORGANIZATION_ID
298 .parse()
299 .expect("Invalid organization ID");
300 let user_crypto_request = make_user_crypto_request();
301 let (_server, client) =
302 setup_sync_client(create_sync_response(user_id), user_crypto_request, None).await;
303
304 let sync_request = SyncRequest {
305 exclude_subdomains: Some(false),
306 };
307
308 let sync_response = sync(&client, &sync_request).await;
309 assert!(sync_response.is_ok());
310
311 let sync_response = sync_response.unwrap();
312 assert_eq!(sync_response.profile.id, user_id);
313 assert_eq!(sync_response.profile.name, TEST_USER_NAME);
314 assert_eq!(sync_response.profile.email, TEST_USER_EMAIL);
315 assert!(sync_response.profile.organizations.is_empty());
316 assert!(sync_response.ciphers.is_empty());
317 assert!(sync_response.folders.is_empty());
318 assert!(sync_response.collections.is_empty());
319 assert!(sync_response.domains.is_none());
320 assert!(
321 !client
322 .internal
323 .get_key_store()
324 .context()
325 .has_symmetric_key(SymmetricKeyId::Organization(organization_id))
326 );
327 }
328
329 #[tokio::test]
330 async fn test_sync_user_with_organization() {
331 let user_id = UserId::new(uuid::uuid!(TEST_USER_ID));
332 let organization_id: OrganizationId = TEST_ACCOUNT_ORGANIZATION_ID
333 .parse()
334 .expect("Invalid organization ID");
335 let organization_key: UnsignedSharedKey = TEST_ACCOUNT_ORGANIZATION_KEY
336 .parse()
337 .expect("Invalid organization key");
338 let user_crypto_request = make_user_crypto_request();
339 let response = SyncResponseModel {
340 profile: Some(Box::new(ProfileResponseModel {
341 organizations: Some(vec![ProfileOrganizationResponseModel {
342 id: Some(organization_id.into()),
343 key: Some(organization_key.to_string()),
344 ..ProfileOrganizationResponseModel::new()
345 }]),
346 ..create_profile_response(user_id)
347 })),
348 ..create_sync_response(user_id)
349 };
350 let (_server, client) = setup_sync_client(response, user_crypto_request, None).await;
351
352 let sync_request = SyncRequest {
353 exclude_subdomains: Some(false),
354 };
355
356 let sync_response = sync(&client, &sync_request).await;
357 assert!(sync_response.is_ok());
358
359 let sync_response = sync_response.unwrap();
360 assert_eq!(sync_response.profile.id, user_id);
361 assert_eq!(sync_response.profile.name, TEST_USER_NAME);
362 assert_eq!(sync_response.profile.email, TEST_USER_EMAIL);
363 assert_eq!(sync_response.profile.organizations.len(), 1);
364 let organization = sync_response.profile.organizations.first().unwrap();
365 assert_eq!(organization.id, organization_id);
366 assert!(sync_response.ciphers.is_empty());
367 assert!(sync_response.folders.is_empty());
368 assert!(sync_response.collections.is_empty());
369 assert!(sync_response.domains.is_none());
370 assert!(
371 client
372 .internal
373 .get_key_store()
374 .context()
375 .has_symmetric_key(SymmetricKeyId::Organization(organization_id))
376 );
377 }
378
379 #[tokio::test]
380 async fn test_sync_user_with_decryption_options_master_password_unlock() {
381 let user_id = UserId::new(uuid::uuid!(TEST_USER_ID));
382 let user_key: EncString = TEST_ACCOUNT_USER_KEY.parse().expect("Invalid user key");
383 let user_crypto_request = make_user_crypto_request();
384 let response = SyncResponseModel {
385 user_decryption: Some(Box::new(UserDecryptionResponseModel {
386 master_password_unlock: Some(Box::new(MasterPasswordUnlockResponseModel {
387 kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
388 kdf_type: KdfType::Argon2id,
389 iterations: 4,
390 memory: Some(65),
391 parallelism: Some(5),
392 }),
393 salt: Some(TEST_USER_EMAIL.to_string()),
394 master_key_encrypted_user_key: Some(user_key.to_string()),
395 })),
396 })),
397 ..create_sync_response(user_id)
398 };
399 let (_server, client) = setup_sync_client(response, user_crypto_request, None).await;
400
401 let sync_request = SyncRequest {
402 exclude_subdomains: Some(false),
403 };
404
405 let sync_response = sync(&client, &sync_request).await;
406 assert!(sync_response.is_ok());
407
408 assert_eq!(
409 client.internal.get_kdf().unwrap(),
410 Kdf::Argon2id {
411 iterations: NonZeroU32::new(4).unwrap(),
412 memory: NonZeroU32::new(65).unwrap(),
413 parallelism: NonZeroU32::new(5).unwrap(),
414 }
415 );
416 }
417}