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