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};
17use tracing::warn;
18
19/// The parts of a sync response the key management sync handler needs.
20#[derive(Serialize, Deserialize, Debug, Clone, Default)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
23#[bitwarden_ffi::wasm_record]
24pub struct CryptoSyncData {
25    /// The account's user decryption options, as the server reports them on sync.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    #[cfg_attr(feature = "wasm", tsify(optional))]
28    pub user_decryption: Option<CryptoSyncUserDecryption>,
29    /// The account's cryptographic state, as the server reports it on sync.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    #[cfg_attr(feature = "wasm", tsify(optional))]
32    pub account_cryptographic_state: Option<WrappedAccountCryptographicState>,
33}
34
35/// The user decryption options a sync response carries, narrowed to the parts key management owns.
36#[derive(Serialize, Deserialize, Debug, Clone, Default)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
39#[bitwarden_ffi::wasm_record]
40pub struct CryptoSyncUserDecryption {
41    /// Unlock data for accounts that have a master password.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    #[cfg_attr(feature = "wasm", tsify(optional))]
44    pub master_password_unlock: Option<MasterPasswordUnlockData>,
45    /// Token allowing unlock after a V1 to V2 upgrade, when one is outstanding.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    #[cfg_attr(feature = "wasm", tsify(optional))]
48    pub v2_upgrade_token: Option<V2UpgradeToken>,
49    /// The WebAuthn PRF credentials the account can unlock with.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    #[cfg_attr(feature = "wasm", tsify(optional))]
52    pub web_authn_prf_options: Option<Vec<WebAuthnPrfUnlockOption>>,
53    /// The id of the account's current user key.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    #[cfg_attr(feature = "wasm", tsify(optional))]
56    pub user_key_id: Option<KeyId>,
57}
58
59/// Errors returned when a sync response cannot be converted into [`CryptoSyncData`].
60///
61/// The conversion happens before anything is written to state, so returning one of these leaves
62/// state untouched rather than partially updated.
63#[cfg(not(target_arch = "wasm32"))]
64#[derive(Debug, thiserror::Error)]
65pub enum CryptoSyncDataParseError {
66    /// The sync response carried master password unlock data that could not be parsed.
67    #[error("Sync response carried unparseable master password unlock data")]
68    MasterPasswordUnlock(#[source] MasterPasswordError),
69    /// The sync response carried a V2 upgrade token that could not be parsed.
70    #[error("Sync response carried an unparseable V2 upgrade token")]
71    V2UpgradeToken(#[source] V2UpgradeTokenError),
72    /// The sync response carried a WebAuthn PRF unlock option that could not be parsed.
73    #[error("Sync response carried an unparseable WebAuthn PRF unlock option")]
74    WebAuthnPrfOption(#[source] WebAuthnPrfError),
75    /// The sync response carried a user key id that could not be parsed.
76    #[error("Sync response carried an unparseable user key id")]
77    UserKeyId(#[source] bitwarden_crypto::CryptoError),
78    /// The sync response carried account cryptographic state that could not be parsed.
79    #[error("Sync response carried unparseable account cryptographic state")]
80    AccountCryptographicState(#[source] AccountKeysResponseParseError),
81}
82
83#[cfg(not(target_arch = "wasm32"))]
84impl TryFrom<&bitwarden_api_api::models::SyncResponseModel> for CryptoSyncData {
85    type Error = CryptoSyncDataParseError;
86
87    fn try_from(
88        response: &bitwarden_api_api::models::SyncResponseModel,
89    ) -> Result<Self, Self::Error> {
90        Ok(Self {
91            user_decryption: response
92                .user_decryption
93                .as_deref()
94                .map(CryptoSyncUserDecryption::try_from)
95                .transpose()?,
96            account_cryptographic_state: response
97                .profile
98                .as_deref()
99                .and_then(|p| p.account_keys.as_deref())
100                .map(WrappedAccountCryptographicState::try_from)
101                .transpose()
102                .map_err(CryptoSyncDataParseError::AccountCryptographicState)?,
103        })
104    }
105}
106
107#[cfg(not(target_arch = "wasm32"))]
108impl TryFrom<&bitwarden_api_api::models::UserDecryptionResponseModel> for CryptoSyncUserDecryption {
109    type Error = CryptoSyncDataParseError;
110
111    fn try_from(
112        response: &bitwarden_api_api::models::UserDecryptionResponseModel,
113    ) -> Result<Self, Self::Error> {
114        Ok(Self {
115            master_password_unlock: response
116                .master_password_unlock
117                .as_deref()
118                .map(MasterPasswordUnlockData::try_from)
119                .transpose()
120                .map_err(CryptoSyncDataParseError::MasterPasswordUnlock)?,
121            v2_upgrade_token: response
122                .v2_upgrade_token
123                .as_deref()
124                .map(V2UpgradeToken::try_from)
125                .transpose()
126                .map_err(CryptoSyncDataParseError::V2UpgradeToken)?,
127            web_authn_prf_options: response
128                .web_authn_prf_options
129                .as_deref()
130                .map(|options| {
131                    options
132                        .iter()
133                        .map(WebAuthnPrfUnlockOption::try_from)
134                        .collect::<Result<Vec<_>, _>>()
135                })
136                .transpose()
137                .map_err(CryptoSyncDataParseError::WebAuthnPrfOption)?,
138            user_key_id: response
139                .user_key_id
140                .as_deref()
141                .map(str::parse)
142                .transpose()
143                .map_err(CryptoSyncDataParseError::UserKeyId)?,
144        })
145    }
146}
147
148/// Runs the key management sync work for the given sync data.
149async fn handle_crypto_sync(client: &Client, data: &CryptoSyncData) {
150    // A replayed payload is refused whole: taking its user decryption options would let the
151    // server swap out the key id and unlock data that belong to the state it just tried to undo.
152    if is_replayed_state(client, data).await {
153        warn!("WARNING: Refusing a V2 to V1 account cryptographic state downgrade.");
154        return;
155    }
156
157    // Handlers MUST NOT fail, to avoid partial state writes
158    handle_user_decryption_options(client, data).await;
159    handle_account_cryptographic_state(client, data).await;
160
161    // Further key management sync handlers go here.
162}
163
164/// Persists the user decryption options the server reported.
165async fn handle_user_decryption_options(client: &Client, data: &CryptoSyncData) {
166    let Some(user_decryption) = data.user_decryption.as_ref() else {
167        return;
168    };
169
170    // This is necessary until all clients implement the state bridge.
171    let state_bridge = client.km_state_bridge();
172    if !state_bridge.is_bridge_registered() {
173        return;
174    }
175
176    match user_decryption.master_password_unlock.as_ref() {
177        Some(master_password_unlock) => {
178            state_bridge
179                .set_masterpassword_unlock_data(master_password_unlock)
180                .await;
181            state_bridge
182                .set_kdf_config(&master_password_unlock.kdf)
183                .await;
184        }
185        None => state_bridge.clear_masterpassword_unlock_data().await,
186    }
187
188    match user_decryption.v2_upgrade_token.as_ref() {
189        Some(v2_upgrade_token) => state_bridge.set_v2_upgrade_token(v2_upgrade_token).await,
190        None => state_bridge.clear_v2_upgrade_token().await,
191    }
192
193    // An absent list and an empty list both mean the account has no PRF-capable credentials.
194    match user_decryption.web_authn_prf_options.as_ref() {
195        Some(options) if !options.is_empty() => {
196            state_bridge
197                .set_webauthn_prf_unlock_data(&WebAuthnPrfUnlockData {
198                    options: options.clone(),
199                })
200                .await
201        }
202        _ => state_bridge.clear_webauthn_prf_unlock_data().await,
203    }
204
205    // The stored key id mirrors the server's. An absent one means the server has no id recorded for
206    // this user key, so a previously stored id no longer describes anything and is dropped.
207    match user_decryption.user_key_id.as_ref() {
208        Some(user_key_id) => state_bridge.set_user_key_id(user_key_id).await,
209        None => state_bridge.clear_user_key_id().await,
210    }
211}
212
213/// Persists the account cryptographic state the server reported.
214async fn handle_account_cryptographic_state(client: &Client, data: &CryptoSyncData) {
215    let Some(incoming) = data.account_cryptographic_state.as_ref() else {
216        return;
217    };
218
219    // This is necessary until all clients implement the state bridge.
220    let state_bridge = client.km_state_bridge();
221    if !state_bridge.is_bridge_registered() {
222        return;
223    }
224
225    state_bridge.set_account_cryptographic_state(incoming).await;
226}
227
228/// Whether the sync carries a state that constitutes a cryptographic downgrade
229///
230/// Currently, the only downgrade defined is a V2 -> V1 encryption downgrade
231async fn is_replayed_state(client: &Client, data: &CryptoSyncData) -> bool {
232    let Some(incoming) = data.account_cryptographic_state.as_ref() else {
233        return false;
234    };
235    let Some(local) = client
236        .km_state_bridge()
237        .get_account_cryptographic_state()
238        .await
239    else {
240        return false;
241    };
242
243    if is_v2_to_v1_downgrade(&local, incoming) {
244        return true;
245    }
246
247    // If we define more downgrade types in the future, check them here.
248    false
249}
250
251/// Whether the incoming state moves a locally V2 account back to V1.
252///
253/// V1 accounts carry no security state, so staying on V1 or upgrading to V2 is never a downgrade.
254fn is_v2_to_v1_downgrade(
255    local: &WrappedAccountCryptographicState,
256    incoming: &WrappedAccountCryptographicState,
257) -> bool {
258    matches!(local, WrappedAccountCryptographicState::V2 { .. })
259        && matches!(incoming, WrappedAccountCryptographicState::V1 { .. })
260}
261
262/// Client for the key management work that runs on every sync.
263#[derive(Clone)]
264#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
265#[bitwarden_ffi::wasm_object]
266pub struct CryptoSyncHandlerClient {
267    client: Client,
268}
269
270impl CryptoSyncHandlerClient {
271    fn new(client: Client) -> Self {
272        Self { client }
273    }
274}
275
276#[bitwarden_ffi::wasm_export]
277#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
278impl CryptoSyncHandlerClient {
279    /// Runs the key management sync work. Call this after each sync, once the user's cryptographic
280    /// state has been applied.
281    pub async fn on_sync(&self, data: CryptoSyncData) {
282        handle_crypto_sync(&self.client, &data).await
283    }
284}
285
286/// Extension trait to add the key management sync handler client to the main Bitwarden SDK client.
287pub trait CryptoSyncHandlerClientExt {
288    /// Get the key management sync handler client.
289    fn crypto_sync_handler(&self) -> CryptoSyncHandlerClient;
290}
291
292impl CryptoSyncHandlerClientExt for Client {
293    fn crypto_sync_handler(&self) -> CryptoSyncHandlerClient {
294        CryptoSyncHandlerClient::new(self.clone())
295    }
296}
297
298/// [`bitwarden_sync::SyncHandler`] implementation of the same work, reading the sync data straight
299/// off the generated sync response model.
300///
301/// Unused while the clients still own sync — they call [`CryptoSyncHandlerClient::on_sync`] instead
302/// — but this is the entry point that survives once sync moves into the SDK.
303///
304/// Not available on `wasm32`: [`bitwarden_sync::SyncHandler`] requires `Send` futures, while the
305/// generated `bitwarden-api-api` client is `?Send` on that target. `bitwarden-sync` is not exposed
306/// to the wasm bindings either, so nothing is lost — wasm callers use
307/// [`CryptoSyncHandlerClient::on_sync`].
308#[cfg(not(target_arch = "wasm32"))]
309pub struct CryptoSyncHandler {
310    client: Client,
311}
312
313#[cfg(not(target_arch = "wasm32"))]
314impl CryptoSyncHandler {
315    /// Creates a handler bound to the given client.
316    pub fn new(client: Client) -> Self {
317        Self { client }
318    }
319}
320
321#[cfg(not(target_arch = "wasm32"))]
322#[async_trait::async_trait]
323impl bitwarden_sync::SyncHandler for CryptoSyncHandler {
324    async fn on_sync(
325        &self,
326        response: &bitwarden_api_api::models::SyncResponseModel,
327    ) -> Result<(), bitwarden_sync::SyncHandlerError> {
328        // Parsing happens up front so that a malformed response fails the sync without having
329        // written any state.
330        let data = CryptoSyncData::try_from(response)?;
331
332        handle_crypto_sync(&self.client, &data).await;
333        Ok(())
334    }
335}
336
337#[cfg(all(test, not(target_arch = "wasm32")))]
338mod tests {
339    use bitwarden_api_api::models::{
340        KdfType, MasterPasswordUnlockKdfResponseModel, MasterPasswordUnlockResponseModel,
341        SyncResponseModel, UserDecryptionResponseModel, WebAuthnPrfDecryptionOption,
342    };
343    use bitwarden_core::key_management::{
344        KeySlotIds, state_bridge::test_support::InMemoryStateBridge,
345    };
346    use bitwarden_crypto::{KeyStore, PublicKeyEncryptionAlgorithm, SymmetricKeyAlgorithm};
347
348    use super::*;
349
350    const TEST_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
351    const TEST_SALT: &str = "[email protected]";
352    const TEST_USER_KEY_ID: &str = "000102030405060708090a0b0c0d0e0f";
353
354    fn master_password_unlock(
355        master_key_encrypted_user_key: Option<String>,
356    ) -> MasterPasswordUnlockResponseModel {
357        MasterPasswordUnlockResponseModel {
358            kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
359                kdf_type: KdfType::PBKDF2_SHA256,
360                iterations: 600_000,
361                memory: None,
362                parallelism: None,
363            }),
364            master_key_encrypted_user_key,
365            salt: Some(TEST_SALT.to_string()),
366            contained_key_id: None,
367        }
368    }
369
370    fn sync_response(user_decryption: UserDecryptionResponseModel) -> SyncResponseModel {
371        SyncResponseModel {
372            user_decryption: Some(Box::new(user_decryption)),
373            ..Default::default()
374        }
375    }
376
377    #[test]
378    fn test_try_from_empty_response_is_empty_data() {
379        let data = CryptoSyncData::try_from(&SyncResponseModel::default()).unwrap();
380
381        assert!(data.user_decryption.is_none());
382        assert!(data.account_cryptographic_state.is_none());
383    }
384
385    #[test]
386    fn test_try_from_valid_master_password_unlock_succeeds() {
387        let response = sync_response(UserDecryptionResponseModel {
388            master_password_unlock: Some(Box::new(master_password_unlock(Some(
389                TEST_USER_KEY.to_string(),
390            )))),
391            ..Default::default()
392        });
393
394        let data = CryptoSyncData::try_from(&response).unwrap();
395
396        let user_decryption = data.user_decryption.unwrap();
397        assert_eq!(
398            user_decryption.master_password_unlock.unwrap().salt,
399            TEST_SALT
400        );
401    }
402
403    #[test]
404    fn test_try_from_malformed_master_password_unlock_errors() {
405        // Missing wrapped user key.
406        let response = sync_response(UserDecryptionResponseModel {
407            master_password_unlock: Some(Box::new(master_password_unlock(None))),
408            ..Default::default()
409        });
410
411        assert!(matches!(
412            CryptoSyncData::try_from(&response),
413            Err(CryptoSyncDataParseError::MasterPasswordUnlock(_))
414        ));
415    }
416
417    #[test]
418    fn test_try_from_malformed_webauthn_prf_option_errors() {
419        let response = sync_response(UserDecryptionResponseModel {
420            web_authn_prf_options: Some(vec![WebAuthnPrfDecryptionOption::default()]),
421            ..Default::default()
422        });
423
424        assert!(matches!(
425            CryptoSyncData::try_from(&response),
426            Err(CryptoSyncDataParseError::WebAuthnPrfOption(_))
427        ));
428    }
429
430    #[test]
431    fn test_try_from_valid_user_key_id_succeeds() {
432        let response = sync_response(UserDecryptionResponseModel {
433            user_key_id: Some(TEST_USER_KEY_ID.to_string()),
434            ..Default::default()
435        });
436
437        let data = CryptoSyncData::try_from(&response).unwrap();
438
439        let user_key_id = data.user_decryption.unwrap().user_key_id.unwrap();
440        assert_eq!(user_key_id.to_string(), TEST_USER_KEY_ID);
441    }
442
443    #[test]
444    fn test_try_from_absent_user_key_id_is_none() {
445        // The account has other unlock data, but the server has no key id recorded for it yet.
446        let response = sync_response(UserDecryptionResponseModel {
447            master_password_unlock: Some(Box::new(master_password_unlock(Some(
448                TEST_USER_KEY.to_string(),
449            )))),
450            ..Default::default()
451        });
452
453        let data = CryptoSyncData::try_from(&response).unwrap();
454
455        assert!(data.user_decryption.unwrap().user_key_id.is_none());
456    }
457
458    #[test]
459    fn test_try_from_malformed_user_key_id_errors() {
460        for malformed in [
461            "not hex at all",
462            // Valid hex, but not 16 bytes worth.
463            "00ff",
464            // Odd number of hex digits.
465            "000102030405060708090a0b0c0d0e0",
466        ] {
467            let response = sync_response(UserDecryptionResponseModel {
468                user_key_id: Some(malformed.to_string()),
469                ..Default::default()
470            });
471
472            assert!(
473                matches!(
474                    CryptoSyncData::try_from(&response),
475                    Err(CryptoSyncDataParseError::UserKeyId(_))
476                ),
477                "{malformed:?} should not parse as a key id"
478            );
479        }
480    }
481
482    /// The key id has to reach the state bridge, not just the parsed data. An absent id clears a
483    /// previously stored one.
484    #[tokio::test]
485    async fn test_handle_crypto_sync_writes_and_clears_user_key_id() {
486        let client = Client::new(None);
487        client
488            .km_state_bridge()
489            .register_bridge(Box::new(InMemoryStateBridge::default()));
490
491        let with_key_id = CryptoSyncData::try_from(&sync_response(UserDecryptionResponseModel {
492            user_key_id: Some(TEST_USER_KEY_ID.to_string()),
493            ..Default::default()
494        }))
495        .unwrap();
496        handle_crypto_sync(&client, &with_key_id).await;
497
498        let stored = client.km_state_bridge().get_user_key_id().await.unwrap();
499        assert_eq!(stored.to_string(), TEST_USER_KEY_ID);
500
501        let without_key_id =
502            CryptoSyncData::try_from(&sync_response(UserDecryptionResponseModel::default()))
503                .unwrap();
504        handle_crypto_sync(&client, &without_key_id).await;
505
506        assert!(client.km_state_bridge().get_user_key_id().await.is_none());
507    }
508
509    /// A client with an in-memory state bridge registered.
510    fn client_with_bridge() -> Client {
511        let client = Client::new(None);
512        client
513            .km_state_bridge()
514            .register_bridge(Box::new(InMemoryStateBridge::default()));
515        client
516    }
517
518    fn make_v2_state() -> WrappedAccountCryptographicState {
519        let store: KeyStore<KeySlotIds> = KeyStore::default();
520        let mut ctx = store.context_mut();
521        let (_, state) =
522            WrappedAccountCryptographicState::make(&mut ctx).expect("making a V2 state succeeds");
523        state
524    }
525
526    fn make_v1_state() -> WrappedAccountCryptographicState {
527        let store: KeyStore<KeySlotIds> = KeyStore::default();
528        let mut ctx = store.context_mut();
529        let user_key = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
530        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
531
532        WrappedAccountCryptographicState::V1 {
533            private_key: ctx
534                .wrap_private_key(user_key, private_key)
535                .expect("wrapping the private key succeeds"),
536        }
537    }
538
539    /// Runs the handler for the given incoming state and returns what the bridge holds afterwards.
540    async fn sync_account_cryptographic_state(
541        client: &Client,
542        incoming: &WrappedAccountCryptographicState,
543    ) -> Option<WrappedAccountCryptographicState> {
544        let data = CryptoSyncData {
545            account_cryptographic_state: Some(incoming.clone()),
546            ..Default::default()
547        };
548        handle_crypto_sync(client, &data).await;
549        client
550            .km_state_bridge()
551            .get_account_cryptographic_state()
552            .await
553    }
554
555    #[tokio::test]
556    async fn test_account_cryptographic_state_is_persisted_when_there_is_no_local_state() {
557        let client = client_with_bridge();
558        let incoming = make_v2_state();
559
560        let stored = sync_account_cryptographic_state(&client, &incoming).await;
561
562        assert_eq!(stored.as_ref(), Some(&incoming));
563    }
564
565    #[tokio::test]
566    async fn test_account_cryptographic_state_upgrade_from_v1_to_v2_is_persisted() {
567        let client = client_with_bridge();
568        client
569            .km_state_bridge()
570            .set_account_cryptographic_state(&make_v1_state())
571            .await;
572        let incoming = make_v2_state();
573
574        let stored = sync_account_cryptographic_state(&client, &incoming).await;
575
576        assert_eq!(stored.as_ref(), Some(&incoming));
577    }
578
579    #[tokio::test]
580    async fn test_account_cryptographic_state_downgrade_from_v2_to_v1_is_rejected() {
581        let client = client_with_bridge();
582        let local = make_v2_state();
583        client
584            .km_state_bridge()
585            .set_account_cryptographic_state(&local)
586            .await;
587
588        let stored = sync_account_cryptographic_state(&client, &make_v1_state()).await;
589
590        assert_eq!(stored.as_ref(), Some(&local));
591    }
592}