Skip to main content

bitwarden_crypto_sync_handler/
crypto_sync_handler.rs

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