bw/vault/
sync.rs

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    /// Exclude the subdomains from the response, defaults to false
38    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    //key: String,
84    //private_key: String,
85    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    /// Data about the user, including their encryption keys and the organizations they are a part
106    /// of
107    pub profile: ProfileResponse,
108    pub folders: Vec<Folder>,
109    pub collections: Vec<Collection>,
110    /// List of ciphers accessible by the user
111    pub ciphers: Vec<Cipher>,
112    pub domains: Option<DomainResponse>,
113    //pub policies: Vec<Policy>,
114    //pub sends: Vec<Send>,
115}
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            //policies: try_into_iter(require!(response.policies))?,
138            //sends: try_into_iter(require!(response.sends))?,
139        })
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            //key: response.key,
162            //private_key: response.private_key,
163            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}