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    // A replayed payload is refused whole: taking its user decryption options would let the
161    // server swap out the key id and unlock data that belong to the state it just tried to undo.
162    if is_replayed_state(client, data).await {
163        warn!("WARNING: Refusing a V2 to V1 account cryptographic state downgrade.");
164        return;
165    }
166
167    // Handlers MUST NOT fail, to avoid partial state writes
168    handle_user_decryption_options(client, data).await;
169    handle_account_cryptographic_state(client, data).await;
170
171    // Further key management sync handlers go here.
172}
173
174/// Persists the user decryption options the server reported.
175async fn handle_user_decryption_options(client: &Client, data: &CryptoSyncData) {
176    let Some(user_decryption) = data.user_decryption.as_ref() else {
177        return;
178    };
179
180    // This is necessary until all clients implement the state bridge.
181    let state_bridge = client.km_state_bridge();
182    if !state_bridge.is_bridge_registered() {
183        return;
184    }
185
186    match user_decryption.master_password_unlock.as_ref() {
187        Some(master_password_unlock) => {
188            state_bridge
189                .set_masterpassword_unlock_data(master_password_unlock)
190                .await;
191            state_bridge
192                .set_kdf_config(&master_password_unlock.kdf)
193                .await;
194        }
195        None => state_bridge.clear_masterpassword_unlock_data().await,
196    }
197
198    match user_decryption.v2_upgrade_token.as_ref() {
199        Some(v2_upgrade_token) => state_bridge.set_v2_upgrade_token(v2_upgrade_token).await,
200        None => state_bridge.clear_v2_upgrade_token().await,
201    }
202
203    // An absent list and an empty list both mean the account has no PRF-capable credentials.
204    match user_decryption.web_authn_prf_options.as_ref() {
205        Some(options) if !options.is_empty() => {
206            state_bridge
207                .set_webauthn_prf_unlock_data(&WebAuthnPrfUnlockData {
208                    options: options.clone(),
209                })
210                .await
211        }
212        _ => state_bridge.clear_webauthn_prf_unlock_data().await,
213    }
214
215    // The stored key id mirrors the server's. An absent one means the server has no id recorded for
216    // this user key, so a previously stored id no longer describes anything and is dropped.
217    match user_decryption.user_key_id.as_ref() {
218        Some(user_key_id) => state_bridge.set_user_key_id(user_key_id).await,
219        None => state_bridge.clear_user_key_id().await,
220    }
221}
222
223/// Persists the account cryptographic state the server reported.
224async fn handle_account_cryptographic_state(client: &Client, data: &CryptoSyncData) {
225    let Some(incoming) = data.account_cryptographic_state.as_ref() else {
226        return;
227    };
228
229    // This is necessary until all clients implement the state bridge.
230    let state_bridge = client.km_state_bridge();
231    if !state_bridge.is_bridge_registered() {
232        return;
233    }
234
235    state_bridge.set_account_cryptographic_state(incoming).await;
236}
237
238/// Whether the sync carries a state that constitutes a cryptographic downgrade
239///
240/// Currently, the only downgrade defined is a V2 -> V1 encryption downgrade
241async fn is_replayed_state(client: &Client, data: &CryptoSyncData) -> bool {
242    let Some(incoming) = data.account_cryptographic_state.as_ref() else {
243        return false;
244    };
245    let Some(local) = client
246        .km_state_bridge()
247        .get_account_cryptographic_state()
248        .await
249    else {
250        return false;
251    };
252
253    if is_v2_to_v1_downgrade(&local, incoming) {
254        return true;
255    }
256
257    // If we define more downgrade types in the future, check them here.
258    false
259}
260
261/// Whether the incoming state moves a locally V2 account back to V1.
262///
263/// V1 accounts carry no security state, so staying on V1 or upgrading to V2 is never a downgrade.
264fn is_v2_to_v1_downgrade(
265    local: &WrappedAccountCryptographicState,
266    incoming: &WrappedAccountCryptographicState,
267) -> bool {
268    matches!(local, WrappedAccountCryptographicState::V2 { .. })
269        && matches!(incoming, WrappedAccountCryptographicState::V1 { .. })
270}
271
272/// Client for the key management work that runs on every sync.
273#[derive(Clone)]
274#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
275#[cfg_attr(feature = "wasm", wasm_bindgen)]
276pub struct CryptoSyncHandlerClient {
277    client: Client,
278}
279
280impl CryptoSyncHandlerClient {
281    fn new(client: Client) -> Self {
282        Self { client }
283    }
284}
285
286#[cfg_attr(feature = "wasm", wasm_bindgen)]
287#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
288impl CryptoSyncHandlerClient {
289    /// Runs the key management sync work. Call this after each sync, once the user's cryptographic
290    /// state has been applied.
291    pub async fn on_sync(&self, data: CryptoSyncData) {
292        handle_crypto_sync(&self.client, &data).await
293    }
294}
295
296/// Extension trait to add the key management sync handler client to the main Bitwarden SDK client.
297pub trait CryptoSyncHandlerClientExt {
298    /// Get the key management sync handler client.
299    fn crypto_sync_handler(&self) -> CryptoSyncHandlerClient;
300}
301
302impl CryptoSyncHandlerClientExt for Client {
303    fn crypto_sync_handler(&self) -> CryptoSyncHandlerClient {
304        CryptoSyncHandlerClient::new(self.clone())
305    }
306}
307
308/// [`bitwarden_sync::SyncHandler`] implementation of the same work, reading the sync data straight
309/// off the generated sync response model.
310///
311/// Unused while the clients still own sync — they call [`CryptoSyncHandlerClient::on_sync`] instead
312/// — but this is the entry point that survives once sync moves into the SDK.
313///
314/// Not available on `wasm32`: [`bitwarden_sync::SyncHandler`] requires `Send` futures, while the
315/// generated `bitwarden-api-api` client is `?Send` on that target. `bitwarden-sync` is not exposed
316/// to the wasm bindings either, so nothing is lost — wasm callers use
317/// [`CryptoSyncHandlerClient::on_sync`].
318#[cfg(not(target_arch = "wasm32"))]
319pub struct CryptoSyncHandler {
320    client: Client,
321}
322
323#[cfg(not(target_arch = "wasm32"))]
324impl CryptoSyncHandler {
325    /// Creates a handler bound to the given client.
326    pub fn new(client: Client) -> Self {
327        Self { client }
328    }
329}
330
331#[cfg(not(target_arch = "wasm32"))]
332#[async_trait::async_trait]
333impl bitwarden_sync::SyncHandler for CryptoSyncHandler {
334    async fn on_sync(
335        &self,
336        response: &bitwarden_api_api::models::SyncResponseModel,
337    ) -> Result<(), bitwarden_sync::SyncHandlerError> {
338        // Parsing happens up front so that a malformed response fails the sync without having
339        // written any state.
340        let data = CryptoSyncData::try_from(response)?;
341
342        handle_crypto_sync(&self.client, &data).await;
343        Ok(())
344    }
345}
346
347#[cfg(all(test, not(target_arch = "wasm32")))]
348mod tests {
349    use bitwarden_api_api::models::{
350        KdfType, MasterPasswordUnlockKdfResponseModel, MasterPasswordUnlockResponseModel,
351        SyncResponseModel, UserDecryptionResponseModel, WebAuthnPrfDecryptionOption,
352    };
353    use bitwarden_core::key_management::{
354        KeySlotIds, state_bridge::test_support::InMemoryStateBridge,
355    };
356    use bitwarden_crypto::{KeyStore, PublicKeyEncryptionAlgorithm, SymmetricKeyAlgorithm};
357
358    use super::*;
359
360    const TEST_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
361    const TEST_SALT: &str = "[email protected]";
362    const TEST_USER_KEY_ID: &str = "000102030405060708090a0b0c0d0e0f";
363
364    fn master_password_unlock(
365        master_key_encrypted_user_key: Option<String>,
366    ) -> MasterPasswordUnlockResponseModel {
367        MasterPasswordUnlockResponseModel {
368            kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
369                kdf_type: KdfType::PBKDF2_SHA256,
370                iterations: 600_000,
371                memory: None,
372                parallelism: None,
373            }),
374            master_key_encrypted_user_key,
375            salt: Some(TEST_SALT.to_string()),
376            contained_key_id: None,
377        }
378    }
379
380    fn sync_response(user_decryption: UserDecryptionResponseModel) -> SyncResponseModel {
381        SyncResponseModel {
382            user_decryption: Some(Box::new(user_decryption)),
383            ..Default::default()
384        }
385    }
386
387    #[test]
388    fn test_try_from_empty_response_is_empty_data() {
389        let data = CryptoSyncData::try_from(&SyncResponseModel::default()).unwrap();
390
391        assert!(data.user_decryption.is_none());
392        assert!(data.account_cryptographic_state.is_none());
393    }
394
395    #[test]
396    fn test_try_from_valid_master_password_unlock_succeeds() {
397        let response = sync_response(UserDecryptionResponseModel {
398            master_password_unlock: Some(Box::new(master_password_unlock(Some(
399                TEST_USER_KEY.to_string(),
400            )))),
401            ..Default::default()
402        });
403
404        let data = CryptoSyncData::try_from(&response).unwrap();
405
406        let user_decryption = data.user_decryption.unwrap();
407        assert_eq!(
408            user_decryption.master_password_unlock.unwrap().salt,
409            TEST_SALT
410        );
411    }
412
413    #[test]
414    fn test_try_from_malformed_master_password_unlock_errors() {
415        // Missing wrapped user key.
416        let response = sync_response(UserDecryptionResponseModel {
417            master_password_unlock: Some(Box::new(master_password_unlock(None))),
418            ..Default::default()
419        });
420
421        assert!(matches!(
422            CryptoSyncData::try_from(&response),
423            Err(CryptoSyncDataParseError::MasterPasswordUnlock(_))
424        ));
425    }
426
427    #[test]
428    fn test_try_from_malformed_webauthn_prf_option_errors() {
429        let response = sync_response(UserDecryptionResponseModel {
430            web_authn_prf_options: Some(vec![WebAuthnPrfDecryptionOption::default()]),
431            ..Default::default()
432        });
433
434        assert!(matches!(
435            CryptoSyncData::try_from(&response),
436            Err(CryptoSyncDataParseError::WebAuthnPrfOption(_))
437        ));
438    }
439
440    #[test]
441    fn test_try_from_valid_user_key_id_succeeds() {
442        let response = sync_response(UserDecryptionResponseModel {
443            user_key_id: Some(TEST_USER_KEY_ID.to_string()),
444            ..Default::default()
445        });
446
447        let data = CryptoSyncData::try_from(&response).unwrap();
448
449        let user_key_id = data.user_decryption.unwrap().user_key_id.unwrap();
450        assert_eq!(user_key_id.to_string(), TEST_USER_KEY_ID);
451    }
452
453    #[test]
454    fn test_try_from_absent_user_key_id_is_none() {
455        // The account has other unlock data, but the server has no key id recorded for it yet.
456        let response = sync_response(UserDecryptionResponseModel {
457            master_password_unlock: Some(Box::new(master_password_unlock(Some(
458                TEST_USER_KEY.to_string(),
459            )))),
460            ..Default::default()
461        });
462
463        let data = CryptoSyncData::try_from(&response).unwrap();
464
465        assert!(data.user_decryption.unwrap().user_key_id.is_none());
466    }
467
468    #[test]
469    fn test_try_from_malformed_user_key_id_errors() {
470        for malformed in [
471            "not hex at all",
472            // Valid hex, but not 16 bytes worth.
473            "00ff",
474            // Odd number of hex digits.
475            "000102030405060708090a0b0c0d0e0",
476        ] {
477            let response = sync_response(UserDecryptionResponseModel {
478                user_key_id: Some(malformed.to_string()),
479                ..Default::default()
480            });
481
482            assert!(
483                matches!(
484                    CryptoSyncData::try_from(&response),
485                    Err(CryptoSyncDataParseError::UserKeyId(_))
486                ),
487                "{malformed:?} should not parse as a key id"
488            );
489        }
490    }
491
492    /// The key id has to reach the state bridge, not just the parsed data. An absent id clears a
493    /// previously stored one.
494    #[tokio::test]
495    async fn test_handle_crypto_sync_writes_and_clears_user_key_id() {
496        let client = Client::new(None);
497        client
498            .km_state_bridge()
499            .register_bridge(Box::new(InMemoryStateBridge::default()));
500
501        let with_key_id = CryptoSyncData::try_from(&sync_response(UserDecryptionResponseModel {
502            user_key_id: Some(TEST_USER_KEY_ID.to_string()),
503            ..Default::default()
504        }))
505        .unwrap();
506        handle_crypto_sync(&client, &with_key_id).await;
507
508        let stored = client.km_state_bridge().get_user_key_id().await.unwrap();
509        assert_eq!(stored.to_string(), TEST_USER_KEY_ID);
510
511        let without_key_id =
512            CryptoSyncData::try_from(&sync_response(UserDecryptionResponseModel::default()))
513                .unwrap();
514        handle_crypto_sync(&client, &without_key_id).await;
515
516        assert!(client.km_state_bridge().get_user_key_id().await.is_none());
517    }
518
519    /// A client with an in-memory state bridge registered.
520    fn client_with_bridge() -> Client {
521        let client = Client::new(None);
522        client
523            .km_state_bridge()
524            .register_bridge(Box::new(InMemoryStateBridge::default()));
525        client
526    }
527
528    fn make_v2_state() -> WrappedAccountCryptographicState {
529        let store: KeyStore<KeySlotIds> = KeyStore::default();
530        let mut ctx = store.context_mut();
531        let (_, state) =
532            WrappedAccountCryptographicState::make(&mut ctx).expect("making a V2 state succeeds");
533        state
534    }
535
536    fn make_v1_state() -> WrappedAccountCryptographicState {
537        let store: KeyStore<KeySlotIds> = KeyStore::default();
538        let mut ctx = store.context_mut();
539        let user_key = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
540        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
541
542        WrappedAccountCryptographicState::V1 {
543            private_key: ctx
544                .wrap_private_key(user_key, private_key)
545                .expect("wrapping the private key succeeds"),
546        }
547    }
548
549    /// Runs the handler for the given incoming state and returns what the bridge holds afterwards.
550    async fn sync_account_cryptographic_state(
551        client: &Client,
552        incoming: &WrappedAccountCryptographicState,
553    ) -> Option<WrappedAccountCryptographicState> {
554        let data = CryptoSyncData {
555            account_cryptographic_state: Some(incoming.clone()),
556            ..Default::default()
557        };
558        handle_crypto_sync(client, &data).await;
559        client
560            .km_state_bridge()
561            .get_account_cryptographic_state()
562            .await
563    }
564
565    #[tokio::test]
566    async fn test_account_cryptographic_state_is_persisted_when_there_is_no_local_state() {
567        let client = client_with_bridge();
568        let incoming = make_v2_state();
569
570        let stored = sync_account_cryptographic_state(&client, &incoming).await;
571
572        assert_eq!(stored.as_ref(), Some(&incoming));
573    }
574
575    #[tokio::test]
576    async fn test_account_cryptographic_state_upgrade_from_v1_to_v2_is_persisted() {
577        let client = client_with_bridge();
578        client
579            .km_state_bridge()
580            .set_account_cryptographic_state(&make_v1_state())
581            .await;
582        let incoming = make_v2_state();
583
584        let stored = sync_account_cryptographic_state(&client, &incoming).await;
585
586        assert_eq!(stored.as_ref(), Some(&incoming));
587    }
588
589    #[tokio::test]
590    async fn test_account_cryptographic_state_downgrade_from_v2_to_v1_is_rejected() {
591        let client = client_with_bridge();
592        let local = make_v2_state();
593        client
594            .km_state_bridge()
595            .set_account_cryptographic_state(&local)
596            .await;
597
598        let stored = sync_account_cryptographic_state(&client, &make_v1_state()).await;
599
600        assert_eq!(stored.as_ref(), Some(&local));
601    }
602}