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