Skip to main content

bitwarden_user_crypto_management/
v2_migration_permission.rs

1//! Grace period gating for the v2 encrypted migrations master-password prompt.
2//!
3//! A user gets two weeks before the client asks for the master password. The window is anchored by
4//! the state bridge value `v2_encrypted_migrations_grace_period_start`, which this module is the
5//! only consumer of. The first query starts the clock.
6
7use bitwarden_core::key_management::V2EncryptedMigrationsGracePeriodStart;
8use chrono::{TimeDelta, Utc};
9use serde::{Deserialize, Serialize};
10#[cfg(feature = "wasm")]
11use tsify::Tsify;
12#[cfg(feature = "wasm")]
13use wasm_bindgen::prelude::*;
14
15use crate::UserCryptoManagementClient;
16
17/// How long the prompt stays suppressed after the window opens.
18const GRACE_PERIOD: TimeDelta = TimeDelta::weeks(2);
19
20/// Whether the client may prompt the user to migrate to v2 encryption.
21#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
22#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
23#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
24pub enum MigrationPermission {
25    /// The user is inside the grace period. Do not show the migration prompt.
26    Wait,
27    /// The grace period has elapsed. Show the migration prompt.
28    Migrate,
29}
30
31#[cfg_attr(feature = "wasm", wasm_bindgen)]
32#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
33impl UserCryptoManagementClient {
34    /// Returns whether the client may prompt the user to migrate to v2 encryption.
35    ///
36    /// [`MigrationPermission::Wait`] means the user is still inside the two-week grace period and
37    /// the prompt must not be shown. [`MigrationPermission::Migrate`] means the grace period has
38    /// elapsed.
39    ///
40    /// The first call has a side effect: when no timestamp is stored, it writes
41    /// the current timestamp and returns [`MigrationPermission::Wait`]. The
42    /// window is therefore two weeks from the first call, not from login. A
43    /// client that never calls this method never opens the window.
44    ///
45    /// The SDK never clears the timestamp. Clearing is the client's decision
46    /// and resets the window. The timestamp is persisted so a user who logs out
47    /// often cannot avoid the prompt indefinitely.
48    ///
49    /// Panics when no state bridge is registered.
50    pub async fn request_permission_to_migrate_to_v2(&self) -> MigrationPermission {
51        let state_bridge = self.client.km_state_bridge();
52
53        match state_bridge
54            .get_v2_encrypted_migrations_grace_period_start()
55            .await
56        {
57            // The grace period's starting timestamp isn't set yet. The user is
58            // inside the window by definition.
59            None => {
60                state_bridge
61                    .set_v2_encrypted_migrations_grace_period_start(
62                        &V2EncryptedMigrationsGracePeriodStart(Utc::now()),
63                    )
64                    .await;
65                MigrationPermission::Wait
66            }
67            Some(start) if Utc::now().signed_duration_since(start.0) < GRACE_PERIOD => {
68                MigrationPermission::Wait
69            }
70            Some(_) => MigrationPermission::Migrate,
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use bitwarden_core::{Client, key_management::state_bridge::test_support::InMemoryStateBridge};
78    use chrono::DateTime;
79
80    use super::*;
81    use crate::UserCryptoManagementClientExt;
82
83    /// Builds a client with an empty in-memory state bridge registered.
84    fn client_with_bridge() -> Client {
85        let client = Client::new(None);
86        client
87            .km_state_bridge()
88            .register_bridge(Box::new(InMemoryStateBridge::default()));
89        client
90    }
91
92    /// Builds a client whose grace period anchor sits `offset` away from now. A negative offset is
93    /// an anchor in the past.
94    async fn client_with_anchor(offset: TimeDelta) -> Client {
95        let client = client_with_bridge();
96        client
97            .km_state_bridge()
98            .set_v2_encrypted_migrations_grace_period_start(&V2EncryptedMigrationsGracePeriodStart(
99                Utc::now() + offset,
100            ))
101            .await;
102        client
103    }
104
105    async fn stored_anchor(client: &Client) -> Option<DateTime<Utc>> {
106        client
107            .km_state_bridge()
108            .get_v2_encrypted_migrations_grace_period_start()
109            .await
110            .map(|start| start.0)
111    }
112
113    #[tokio::test]
114    async fn test_unset_anchor_starts_the_window_and_reports_wait() {
115        let client = client_with_bridge();
116
117        assert_eq!(
118            client
119                .user_crypto_management()
120                .request_permission_to_migrate_to_v2()
121                .await,
122            MigrationPermission::Wait
123        );
124
125        let anchor = stored_anchor(&client).await.expect("the window was armed");
126        assert!(Utc::now().signed_duration_since(anchor) < TimeDelta::seconds(5));
127    }
128
129    #[tokio::test]
130    async fn test_second_call_does_not_move_the_anchor() {
131        let client = client_with_bridge();
132        let user_crypto_management = client.user_crypto_management();
133
134        assert_eq!(
135            user_crypto_management
136                .request_permission_to_migrate_to_v2()
137                .await,
138            MigrationPermission::Wait
139        );
140        let armed = stored_anchor(&client).await.expect("the window was armed");
141
142        assert_eq!(
143            user_crypto_management
144                .request_permission_to_migrate_to_v2()
145                .await,
146            MigrationPermission::Wait
147        );
148        assert_eq!(stored_anchor(&client).await, Some(armed));
149    }
150
151    #[tokio::test]
152    async fn test_one_week_old_anchor_waits() {
153        let client = client_with_anchor(-TimeDelta::weeks(1)).await;
154
155        assert_eq!(
156            client
157                .user_crypto_management()
158                .request_permission_to_migrate_to_v2()
159                .await,
160            MigrationPermission::Wait
161        );
162    }
163
164    #[tokio::test]
165    async fn test_three_week_old_anchor_migrates() {
166        let client = client_with_anchor(-TimeDelta::weeks(3)).await;
167
168        assert_eq!(
169            client
170                .user_crypto_management()
171                .request_permission_to_migrate_to_v2()
172                .await,
173            MigrationPermission::Migrate
174        );
175    }
176
177    #[tokio::test]
178    async fn test_exactly_two_weeks_migrates() {
179        // The anchor is written a moment before it is read, so the elapsed time is just past the
180        // grace period and the strict comparison reports the user as outside it.
181        let client = client_with_anchor(-GRACE_PERIOD).await;
182
183        assert_eq!(
184            client
185                .user_crypto_management()
186                .request_permission_to_migrate_to_v2()
187                .await,
188            MigrationPermission::Migrate
189        );
190    }
191
192    #[tokio::test]
193    async fn test_future_anchor_waits_and_is_untouched() {
194        let client = client_with_anchor(TimeDelta::weeks(1)).await;
195        let anchor = stored_anchor(&client).await;
196
197        assert_eq!(
198            client
199                .user_crypto_management()
200                .request_permission_to_migrate_to_v2()
201                .await,
202            MigrationPermission::Wait
203        );
204        assert_eq!(stored_anchor(&client).await, anchor);
205    }
206
207    #[tokio::test]
208    #[should_panic(expected = "StateBridge not registered")]
209    async fn test_without_a_state_bridge_panics() {
210        let client = Client::new(None);
211
212        client
213            .user_crypto_management()
214            .request_permission_to_migrate_to_v2()
215            .await;
216    }
217}