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