Skip to main content

bitwarden_crypto_sync_handler/
crypto_sync_handler.rs

1//! Key management work that runs on every sync.
2
3#[cfg(not(target_arch = "wasm32"))]
4use bitwarden_core::key_management::{
5    MasterPasswordError, V2UpgradeTokenError, WebAuthnPrfError,
6    account_cryptographic_state::AccountKeysResponseParseError,
7};
8use bitwarden_core::{
9    Client,
10    key_management::{
11        MasterPasswordUnlockData, V2UpgradeToken, WebAuthnPrfUnlockData, WebAuthnPrfUnlockOption,
12        account_cryptographic_state::WrappedAccountCryptographicState,
13    },
14};
15use bitwarden_crypto::KeyId;
16use serde::{Deserialize, Serialize};
17#[cfg(feature = "wasm")]
18use wasm_bindgen::prelude::*;
19
20/// The parts of a sync response the key management sync handler needs.
21#[derive(Serialize, Deserialize, Debug, Clone, Default)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
24#[cfg_attr(
25    feature = "wasm",
26    derive(tsify::Tsify),
27    tsify(into_wasm_abi, from_wasm_abi)
28)]
29pub struct CryptoSyncData {
30    /// The account's user decryption options, as the server reports them on sync.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    #[cfg_attr(feature = "wasm", tsify(optional))]
33    pub user_decryption: Option<CryptoSyncUserDecryption>,
34    /// The account's cryptographic state, as the server reports it on sync.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    #[cfg_attr(feature = "wasm", tsify(optional))]
37    pub account_cryptographic_state: Option<WrappedAccountCryptographicState>,
38}
39
40/// The user decryption options a sync response carries, narrowed to the parts key management owns.
41#[derive(Serialize, Deserialize, Debug, Clone, Default)]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
44#[cfg_attr(
45    feature = "wasm",
46    derive(tsify::Tsify),
47    tsify(into_wasm_abi, from_wasm_abi)
48)]
49pub struct CryptoSyncUserDecryption {
50    /// Unlock data for accounts that have a master password.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    #[cfg_attr(feature = "wasm", tsify(optional))]
53    pub master_password_unlock: Option<MasterPasswordUnlockData>,
54    /// Token allowing unlock after a V1 to V2 upgrade, when one is outstanding.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    #[cfg_attr(feature = "wasm", tsify(optional))]
57    pub v2_upgrade_token: Option<V2UpgradeToken>,
58    /// The WebAuthn PRF credentials the account can unlock with.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    #[cfg_attr(feature = "wasm", tsify(optional))]
61    pub web_authn_prf_options: Option<Vec<WebAuthnPrfUnlockOption>>,
62    /// The id of the account's current user key.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    #[cfg_attr(feature = "wasm", tsify(optional))]
65    pub user_key_id: Option<KeyId>,
66}
67
68/// Errors returned when a sync response cannot be converted into [`CryptoSyncData`].
69///
70/// The conversion happens before anything is written to state, so returning one of these leaves
71/// state untouched rather than partially updated.
72#[cfg(not(target_arch = "wasm32"))]
73#[derive(Debug, thiserror::Error)]
74pub enum CryptoSyncDataParseError {
75    /// The sync response carried master password unlock data that could not be parsed.
76    #[error("Sync response carried unparseable master password unlock data")]
77    MasterPasswordUnlock(#[source] MasterPasswordError),
78    /// The sync response carried a V2 upgrade token that could not be parsed.
79    #[error("Sync response carried an unparseable V2 upgrade token")]
80    V2UpgradeToken(#[source] V2UpgradeTokenError),
81    /// The sync response carried a WebAuthn PRF unlock option that could not be parsed.
82    #[error("Sync response carried an unparseable WebAuthn PRF unlock option")]
83    WebAuthnPrfOption(#[source] WebAuthnPrfError),
84    /// The sync response carried a user key id that could not be parsed.
85    #[error("Sync response carried an unparseable user key id")]
86    UserKeyId(#[source] bitwarden_crypto::CryptoError),
87    /// The sync response carried account cryptographic state that could not be parsed.
88    #[error("Sync response carried unparseable account cryptographic state")]
89    AccountCryptographicState(#[source] AccountKeysResponseParseError),
90}
91
92#[cfg(not(target_arch = "wasm32"))]
93impl TryFrom<&bitwarden_api_api::models::SyncResponseModel> for CryptoSyncData {
94    type Error = CryptoSyncDataParseError;
95
96    fn try_from(
97        response: &bitwarden_api_api::models::SyncResponseModel,
98    ) -> Result<Self, Self::Error> {
99        Ok(Self {
100            user_decryption: response
101                .user_decryption
102                .as_deref()
103                .map(CryptoSyncUserDecryption::try_from)
104                .transpose()?,
105            account_cryptographic_state: response
106                .profile
107                .as_deref()
108                .and_then(|p| p.account_keys.as_deref())
109                .map(WrappedAccountCryptographicState::try_from)
110                .transpose()
111                .map_err(CryptoSyncDataParseError::AccountCryptographicState)?,
112        })
113    }
114}
115
116#[cfg(not(target_arch = "wasm32"))]
117impl TryFrom<&bitwarden_api_api::models::UserDecryptionResponseModel> for CryptoSyncUserDecryption {
118    type Error = CryptoSyncDataParseError;
119
120    fn try_from(
121        response: &bitwarden_api_api::models::UserDecryptionResponseModel,
122    ) -> Result<Self, Self::Error> {
123        Ok(Self {
124            master_password_unlock: response
125                .master_password_unlock
126                .as_deref()
127                .map(MasterPasswordUnlockData::try_from)
128                .transpose()
129                .map_err(CryptoSyncDataParseError::MasterPasswordUnlock)?,
130            v2_upgrade_token: response
131                .v2_upgrade_token
132                .as_deref()
133                .map(V2UpgradeToken::try_from)
134                .transpose()
135                .map_err(CryptoSyncDataParseError::V2UpgradeToken)?,
136            web_authn_prf_options: response
137                .web_authn_prf_options
138                .as_deref()
139                .map(|options| {
140                    options
141                        .iter()
142                        .map(WebAuthnPrfUnlockOption::try_from)
143                        .collect::<Result<Vec<_>, _>>()
144                })
145                .transpose()
146                .map_err(CryptoSyncDataParseError::WebAuthnPrfOption)?,
147            user_key_id: response
148                .user_key_id
149                .as_deref()
150                .map(str::parse)
151                .transpose()
152                .map_err(CryptoSyncDataParseError::UserKeyId)?,
153        })
154    }
155}
156
157/// Runs the key management sync work for the given sync data.
158async fn handle_crypto_sync(client: &Client, data: &CryptoSyncData) {
159    // Handlers MUST NOT fail, to avoid partial state writes
160    handle_user_decryption_options(client, data).await;
161    handle_account_cryptographic_state(client, data).await;
162
163    // Further key management sync handlers go here.
164}
165
166/// Persists the user decryption options the server reported.
167async fn handle_user_decryption_options(client: &Client, data: &CryptoSyncData) {
168    let Some(user_decryption) = data.user_decryption.as_ref() else {
169        return;
170    };
171
172    // This is necessary until all clients implement the state bridge.
173    let state_bridge = client.km_state_bridge();
174    if !state_bridge.is_bridge_registered() {
175        return;
176    }
177
178    match user_decryption.master_password_unlock.as_ref() {
179        Some(master_password_unlock) => {
180            state_bridge
181                .set_masterpassword_unlock_data(master_password_unlock)
182                .await;
183            state_bridge
184                .set_kdf_config(&master_password_unlock.kdf)
185                .await;
186        }
187        None => state_bridge.clear_masterpassword_unlock_data().await,
188    }
189
190    match user_decryption.v2_upgrade_token.as_ref() {
191        Some(v2_upgrade_token) => state_bridge.set_v2_upgrade_token(v2_upgrade_token).await,
192        None => state_bridge.clear_v2_upgrade_token().await,
193    }
194
195    // An absent list and an empty list both mean the account has no PRF-capable credentials.
196    match user_decryption.web_authn_prf_options.as_ref() {
197        Some(options) if !options.is_empty() => {
198            state_bridge
199                .set_webauthn_prf_unlock_data(&WebAuthnPrfUnlockData {
200                    options: options.clone(),
201                })
202                .await
203        }
204        _ => state_bridge.clear_webauthn_prf_unlock_data().await,
205    }
206
207    // The stored key id mirrors the server's. An absent one means the server has no id recorded for
208    // this user key, so a previously stored id no longer describes anything and is dropped.
209    match user_decryption.user_key_id.as_ref() {
210        Some(user_key_id) => state_bridge.set_user_key_id(user_key_id).await,
211        None => state_bridge.clear_user_key_id().await,
212    }
213}
214
215/// Persists the account cryptographic state the server reported.
216async fn handle_account_cryptographic_state(client: &Client, data: &CryptoSyncData) {
217    let Some(account_cryptographic_state) = data.account_cryptographic_state.as_ref() else {
218        return;
219    };
220
221    // This is necessary until all clients implement the state bridge.
222    let state_bridge = client.km_state_bridge();
223    if !state_bridge.is_bridge_registered() {
224        return;
225    }
226
227    state_bridge
228        .set_account_cryptographic_state(account_cryptographic_state)
229        .await;
230}
231
232/// Client for the key management work that runs on every sync.
233#[derive(Clone)]
234#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
235#[cfg_attr(feature = "wasm", wasm_bindgen)]
236pub struct CryptoSyncHandlerClient {
237    client: Client,
238}
239
240impl CryptoSyncHandlerClient {
241    fn new(client: Client) -> Self {
242        Self { client }
243    }
244}
245
246#[cfg_attr(feature = "wasm", wasm_bindgen)]
247#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
248impl CryptoSyncHandlerClient {
249    /// Runs the key management sync work. Call this after each sync, once the user's cryptographic
250    /// state has been applied.
251    pub async fn on_sync(&self, data: CryptoSyncData) {
252        handle_crypto_sync(&self.client, &data).await
253    }
254}
255
256/// Extension trait to add the key management sync handler client to the main Bitwarden SDK client.
257pub trait CryptoSyncHandlerClientExt {
258    /// Get the key management sync handler client.
259    fn crypto_sync_handler(&self) -> CryptoSyncHandlerClient;
260}
261
262impl CryptoSyncHandlerClientExt for Client {
263    fn crypto_sync_handler(&self) -> CryptoSyncHandlerClient {
264        CryptoSyncHandlerClient::new(self.clone())
265    }
266}
267
268/// [`bitwarden_sync::SyncHandler`] implementation of the same work, reading the sync data straight
269/// off the generated sync response model.
270///
271/// Unused while the clients still own sync — they call [`CryptoSyncHandlerClient::on_sync`] instead
272/// — but this is the entry point that survives once sync moves into the SDK.
273///
274/// Not available on `wasm32`: [`bitwarden_sync::SyncHandler`] requires `Send` futures, while the
275/// generated `bitwarden-api-api` client is `?Send` on that target. `bitwarden-sync` is not exposed
276/// to the wasm bindings either, so nothing is lost — wasm callers use
277/// [`CryptoSyncHandlerClient::on_sync`].
278#[cfg(not(target_arch = "wasm32"))]
279pub struct CryptoSyncHandler {
280    client: Client,
281}
282
283#[cfg(not(target_arch = "wasm32"))]
284impl CryptoSyncHandler {
285    /// Creates a handler bound to the given client.
286    pub fn new(client: Client) -> Self {
287        Self { client }
288    }
289}
290
291#[cfg(not(target_arch = "wasm32"))]
292#[async_trait::async_trait]
293impl bitwarden_sync::SyncHandler for CryptoSyncHandler {
294    async fn on_sync(
295        &self,
296        response: &bitwarden_api_api::models::SyncResponseModel,
297    ) -> Result<(), bitwarden_sync::SyncHandlerError> {
298        // Parsing happens up front so that a malformed response fails the sync without having
299        // written any state.
300        let data = CryptoSyncData::try_from(response)?;
301
302        handle_crypto_sync(&self.client, &data).await;
303        Ok(())
304    }
305}
306
307#[cfg(all(test, not(target_arch = "wasm32")))]
308mod tests {
309    use bitwarden_api_api::models::{
310        KdfType, MasterPasswordUnlockKdfResponseModel, MasterPasswordUnlockResponseModel,
311        SyncResponseModel, UserDecryptionResponseModel, WebAuthnPrfDecryptionOption,
312    };
313    use bitwarden_core::key_management::state_bridge::test_support::InMemoryStateBridge;
314
315    use super::*;
316
317    const TEST_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
318    const TEST_SALT: &str = "[email protected]";
319    const TEST_USER_KEY_ID: &str = "000102030405060708090a0b0c0d0e0f";
320
321    fn master_password_unlock(
322        master_key_encrypted_user_key: Option<String>,
323    ) -> MasterPasswordUnlockResponseModel {
324        MasterPasswordUnlockResponseModel {
325            kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
326                kdf_type: KdfType::PBKDF2_SHA256,
327                iterations: 600_000,
328                memory: None,
329                parallelism: None,
330            }),
331            master_key_encrypted_user_key,
332            salt: Some(TEST_SALT.to_string()),
333            contained_key_id: None,
334        }
335    }
336
337    fn sync_response(user_decryption: UserDecryptionResponseModel) -> SyncResponseModel {
338        SyncResponseModel {
339            user_decryption: Some(Box::new(user_decryption)),
340            ..Default::default()
341        }
342    }
343
344    #[test]
345    fn test_try_from_empty_response_is_empty_data() {
346        let data = CryptoSyncData::try_from(&SyncResponseModel::default()).unwrap();
347
348        assert!(data.user_decryption.is_none());
349        assert!(data.account_cryptographic_state.is_none());
350    }
351
352    #[test]
353    fn test_try_from_valid_master_password_unlock_succeeds() {
354        let response = sync_response(UserDecryptionResponseModel {
355            master_password_unlock: Some(Box::new(master_password_unlock(Some(
356                TEST_USER_KEY.to_string(),
357            )))),
358            ..Default::default()
359        });
360
361        let data = CryptoSyncData::try_from(&response).unwrap();
362
363        let user_decryption = data.user_decryption.unwrap();
364        assert_eq!(
365            user_decryption.master_password_unlock.unwrap().salt,
366            TEST_SALT
367        );
368    }
369
370    #[test]
371    fn test_try_from_malformed_master_password_unlock_errors() {
372        // Missing wrapped user key.
373        let response = sync_response(UserDecryptionResponseModel {
374            master_password_unlock: Some(Box::new(master_password_unlock(None))),
375            ..Default::default()
376        });
377
378        assert!(matches!(
379            CryptoSyncData::try_from(&response),
380            Err(CryptoSyncDataParseError::MasterPasswordUnlock(_))
381        ));
382    }
383
384    #[test]
385    fn test_try_from_malformed_webauthn_prf_option_errors() {
386        let response = sync_response(UserDecryptionResponseModel {
387            web_authn_prf_options: Some(vec![WebAuthnPrfDecryptionOption::default()]),
388            ..Default::default()
389        });
390
391        assert!(matches!(
392            CryptoSyncData::try_from(&response),
393            Err(CryptoSyncDataParseError::WebAuthnPrfOption(_))
394        ));
395    }
396
397    #[test]
398    fn test_try_from_valid_user_key_id_succeeds() {
399        let response = sync_response(UserDecryptionResponseModel {
400            user_key_id: Some(TEST_USER_KEY_ID.to_string()),
401            ..Default::default()
402        });
403
404        let data = CryptoSyncData::try_from(&response).unwrap();
405
406        let user_key_id = data.user_decryption.unwrap().user_key_id.unwrap();
407        assert_eq!(user_key_id.to_string(), TEST_USER_KEY_ID);
408    }
409
410    #[test]
411    fn test_try_from_absent_user_key_id_is_none() {
412        // The account has other unlock data, but the server has no key id recorded for it yet.
413        let response = sync_response(UserDecryptionResponseModel {
414            master_password_unlock: Some(Box::new(master_password_unlock(Some(
415                TEST_USER_KEY.to_string(),
416            )))),
417            ..Default::default()
418        });
419
420        let data = CryptoSyncData::try_from(&response).unwrap();
421
422        assert!(data.user_decryption.unwrap().user_key_id.is_none());
423    }
424
425    #[test]
426    fn test_try_from_malformed_user_key_id_errors() {
427        for malformed in [
428            "not hex at all",
429            // Valid hex, but not 16 bytes worth.
430            "00ff",
431            // Odd number of hex digits.
432            "000102030405060708090a0b0c0d0e0",
433        ] {
434            let response = sync_response(UserDecryptionResponseModel {
435                user_key_id: Some(malformed.to_string()),
436                ..Default::default()
437            });
438
439            assert!(
440                matches!(
441                    CryptoSyncData::try_from(&response),
442                    Err(CryptoSyncDataParseError::UserKeyId(_))
443                ),
444                "{malformed:?} should not parse as a key id"
445            );
446        }
447    }
448
449    /// The key id has to reach the state bridge, not just the parsed data. An absent id clears a
450    /// previously stored one.
451    #[tokio::test]
452    async fn test_handle_crypto_sync_writes_and_clears_user_key_id() {
453        let client = Client::new(None);
454        client
455            .km_state_bridge()
456            .register_bridge(Box::new(InMemoryStateBridge::default()));
457
458        let with_key_id = CryptoSyncData::try_from(&sync_response(UserDecryptionResponseModel {
459            user_key_id: Some(TEST_USER_KEY_ID.to_string()),
460            ..Default::default()
461        }))
462        .unwrap();
463        handle_crypto_sync(&client, &with_key_id).await;
464
465        let stored = client.km_state_bridge().get_user_key_id().await.unwrap();
466        assert_eq!(stored.to_string(), TEST_USER_KEY_ID);
467
468        let without_key_id =
469            CryptoSyncData::try_from(&sync_response(UserDecryptionResponseModel::default()))
470                .unwrap();
471        handle_crypto_sync(&client, &without_key_id).await;
472
473        assert!(client.km_state_bridge().get_user_key_id().await.is_none());
474    }
475}