Skip to main content

bitwarden_vault/cipher_risk/
cipher_risk_client.rs

1use std::sync::Arc;
2
3use bitwarden_core::Client;
4use bitwarden_error::bitwarden_error;
5use futures::{StreamExt, stream};
6use thiserror::Error;
7
8use super::{
9    hibp::{self, HIBP_DEFAULT_BASE_URL},
10    password_strength,
11    types::{
12        CipherLoginDetails, CipherRiskOptions, CipherRiskResult, ExposedPasswordResult,
13        PasswordReuseMap,
14    },
15};
16
17/// Error type for cipher risk evaluation operations
18#[allow(missing_docs)]
19#[bitwarden_error(flat)]
20#[derive(Debug, Error)]
21pub enum CipherRiskError {
22    #[error(transparent)]
23    Reqwest(#[from] reqwest::Error),
24}
25
26/// Maximum number of concurrent requests when checking passwords.
27const MAX_CONCURRENT_REQUESTS: usize = 100;
28
29/// Client for evaluating credential risk for login ciphers.
30#[bitwarden_ffi::wasm_object]
31pub struct CipherRiskClient {
32    pub(crate) client: Client,
33}
34
35#[bitwarden_ffi::wasm_export]
36impl CipherRiskClient {
37    /// Build password reuse map for a list of login ciphers.
38    ///
39    /// Returns a map where keys are passwords and values are the number of times
40    /// each password appears in the provided list. This map can be passed to `compute_risk()`
41    /// to enable password reuse detection.
42    pub fn password_reuse_map(&self, login_details: Vec<CipherLoginDetails>) -> PasswordReuseMap {
43        PasswordReuseMap::new(login_details)
44    }
45
46    /// Convert a single login details to CipherRisk.
47    ///
48    /// For the cipher:
49    /// 1. Calculates password strength (0-4) using zxcvbn with cipher-specific context
50    /// 2. Optionally checks if the password has been exposed via Have I Been Pwned API
51    /// 3. Counts how many times the password is reused in the provided `password_map`
52    async fn to_cipher_risk(
53        http_client: reqwest::Client,
54        details: CipherLoginDetails,
55        password_map: Option<Arc<PasswordReuseMap>>,
56        check_exposed: bool,
57        base_url: String,
58    ) -> CipherRiskResult {
59        if details.password.is_empty() {
60            // Skip empty passwords, return default risk values
61            return CipherRiskResult {
62                id: details.id,
63                password_strength: 0,
64                exposed_result: ExposedPasswordResult::NotChecked,
65                reuse_count: None,
66            };
67        }
68
69        let password_strength = password_strength::calculate_password_strength(
70            &details.password,
71            details.username.as_deref(),
72        );
73
74        // Check exposure via HIBP API if enabled
75        // Capture errors per-cipher instead of propagating them
76        let exposed_result = if check_exposed {
77            match hibp::check_password_exposed(&http_client, &details.password, &base_url).await {
78                Ok(count) => ExposedPasswordResult::Found(count),
79                Err(e) => ExposedPasswordResult::Error(e.to_string()),
80            }
81        } else {
82            ExposedPasswordResult::NotChecked
83        };
84
85        // Check reuse from provided map
86        let reuse_count = password_map
87            .as_ref()
88            .and_then(|m| m.map.get(&details.password).copied());
89
90        CipherRiskResult {
91            id: details.id,
92            password_strength,
93            exposed_result,
94            reuse_count,
95        }
96    }
97
98    /// Evaluate security risks for multiple login ciphers concurrently.
99    ///
100    /// For each cipher:
101    /// 1. Calculates password strength (0-4) using zxcvbn with cipher-specific context
102    /// 2. Optionally checks if the password has been exposed via Have I Been Pwned API
103    /// 3. Counts how many times the password is reused in the provided `password_map`
104    ///
105    /// Returns a vector of `CipherRisk` results, one for each input cipher.
106    ///
107    /// ## HIBP Check Results (`exposed_result` field)
108    ///
109    /// The `exposed_result` field uses the `ExposedPasswordResult` enum with three possible states:
110    /// - `NotChecked`: Password exposure check was not performed because:
111    ///   - `check_exposed` option was `false`, or
112    ///   - Password was empty
113    /// - `Found(n)`: Successfully checked via HIBP API, password appears in `n` data breaches
114    /// - `Error(msg)`: HIBP API request failed with error message `msg`
115    ///
116    /// # Errors
117    ///
118    /// This method only returns `Err` for internal logic failures. HIBP API errors are
119    /// captured per-cipher in the `exposed_result` field as `ExposedPasswordResult::Error(msg)`.
120    pub async fn compute_risk(
121        &self,
122        login_details: Vec<CipherLoginDetails>,
123        options: CipherRiskOptions,
124    ) -> Result<Vec<CipherRiskResult>, CipherRiskError> {
125        // Wrap password_map in Arc to avoid cloning the HashMap for each future
126        let password_map = options.password_map.map(Arc::new);
127        let base_url = options
128            .hibp_base_url
129            .unwrap_or_else(|| HIBP_DEFAULT_BASE_URL.to_string());
130
131        // Create futures that can run concurrently
132        let futures = login_details.into_iter().map(|details| {
133            Self::to_cipher_risk(
134                self.client.internal.get_http_client().clone(),
135                details,
136                password_map.as_ref().map(Arc::clone),
137                options.check_exposed,
138                base_url.clone(),
139            )
140        });
141
142        // Process up to MAX_CONCURRENT_REQUESTS futures concurrently
143        // Individual HIBP errors are captured per-cipher, so we use collect() instead of
144        // try_collect()
145        let results: Vec<CipherRiskResult> = stream::iter(futures)
146            .buffer_unordered(MAX_CONCURRENT_REQUESTS)
147            .collect()
148            .await;
149
150        Ok(results)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
157
158    use super::*;
159
160    #[tokio::test]
161    async fn test_password_reuse_map() {
162        let client = Client::init_test_account(test_bitwarden_com_account()).await;
163        let risk_client = CipherRiskClient {
164            client: client.clone(),
165        };
166
167        let login_details = vec![
168            CipherLoginDetails {
169                id: Default::default(),
170                password: "password123".to_string(),
171                username: Some("user1".to_string()),
172            },
173            CipherLoginDetails {
174                id: Default::default(),
175                password: "password123".to_string(),
176                username: Some("user2".to_string()),
177            },
178            CipherLoginDetails {
179                id: Default::default(),
180                password: "unique_password".to_string(),
181                username: Some("user3".to_string()),
182            },
183        ];
184
185        let password_map = risk_client.password_reuse_map(login_details);
186
187        assert_eq!(password_map.map.get("password123"), Some(&2));
188        assert_eq!(password_map.map.get("unique_password"), Some(&1));
189    }
190
191    #[tokio::test]
192    async fn test_password_reuse_map_empty_passwords() {
193        let client = Client::init_test_account(test_bitwarden_com_account()).await;
194        let risk_client = CipherRiskClient {
195            client: client.clone(),
196        };
197
198        let login_details = vec![
199            CipherLoginDetails {
200                id: Default::default(),
201                password: "".to_string(),
202                username: Some("user1".to_string()),
203            },
204            CipherLoginDetails {
205                id: Default::default(),
206                password: "valid_password".to_string(),
207                username: Some("user2".to_string()),
208            },
209        ];
210
211        let password_map = risk_client.password_reuse_map(login_details);
212
213        // Empty passwords should not be in the map
214        assert!(!password_map.map.contains_key(""));
215        assert_eq!(password_map.map.get("valid_password"), Some(&1));
216    }
217
218    #[tokio::test]
219    async fn test_compute_risk_skips_empty_passwords() {
220        let client = Client::init_test_account(test_bitwarden_com_account()).await;
221        let risk_client = CipherRiskClient { client };
222
223        let login_details = vec![CipherLoginDetails {
224            id: Default::default(),
225            password: "".to_string(),
226            username: Some("user1".to_string()),
227        }];
228
229        let options = CipherRiskOptions {
230            password_map: None,
231            check_exposed: true, // Enable HIBP checking
232            hibp_base_url: None,
233        };
234
235        let result = risk_client.compute_risk(login_details, options).await;
236
237        // Verify that empty passwords are skipped (no HIBP check performed)
238        assert!(result.is_ok());
239        let results = result.unwrap();
240        assert_eq!(results.len(), 1);
241        assert_eq!(results[0].password_strength, 0);
242        assert_eq!(results[0].exposed_result, ExposedPasswordResult::NotChecked);
243        assert_eq!(results[0].reuse_count, None);
244    }
245
246    #[tokio::test]
247    async fn test_compute_risk_captures_network_errors_per_cipher() {
248        // Test that network errors from HIBP API are captured per-cipher
249        // instead of canceling the entire batch
250        use wiremock::{
251            Mock, MockServer, ResponseTemplate,
252            matchers::{method, path_regex},
253        };
254
255        let server = MockServer::start().await;
256
257        // Mock network error (500 status) for all HIBP range requests
258        Mock::given(method("GET"))
259            .and(path_regex(r"^/range/[A-F0-9]{5}$"))
260            .respond_with(ResponseTemplate::new(500))
261            .mount(&server)
262            .await;
263
264        let client = Client::init_test_account(test_bitwarden_com_account()).await;
265        let risk_client = CipherRiskClient { client };
266
267        let login_details = vec![CipherLoginDetails {
268            id: Default::default(),
269            password: "password123".to_string(),
270            username: Some("user1".to_string()),
271        }];
272
273        let options = CipherRiskOptions {
274            password_map: None,
275            check_exposed: true, // Enable HIBP checking
276            hibp_base_url: Some(server.uri()),
277        };
278
279        let result = risk_client.compute_risk(login_details, options).await;
280
281        // Verify operation succeeds but error is captured per-cipher
282        assert!(result.is_ok());
283        let results = result.unwrap();
284        assert_eq!(results.len(), 1);
285
286        // The exposed_result should be Error(...)
287        match &results[0].exposed_result {
288            ExposedPasswordResult::Error(msg) => {
289                assert!(!msg.is_empty(), "Error message should not be empty");
290            }
291            ExposedPasswordResult::Found(_) => {
292                panic!("Expected Error variant, but got Found");
293            }
294            ExposedPasswordResult::NotChecked => {
295                panic!("Expected Error variant, but got NotChecked");
296            }
297        }
298    }
299
300    #[tokio::test]
301    async fn test_compute_risk_partial_failures() {
302        // Test that when some HIBP checks succeed and others fail,
303        // all results are returned with appropriate success/error states
304        use wiremock::{
305            Mock, MockServer, ResponseTemplate,
306            matchers::{method, path},
307        };
308
309        let server = MockServer::start().await;
310
311        // Hash prefix for "password1": E38AD (SHA1: E38AD214943DAAD1D64C102FAEC29DE4AFE9DA3D)
312        // Hash prefix for "password2": 2AA60 (SHA1: 2AA60A8FF7FCD473D321E0146AFD9E26DF395147)
313
314        // Mock success for password1's hash prefix - return the suffix for password1
315        Mock::given(method("GET"))
316            .and(path("/range/E38AD"))
317            .respond_with(
318                ResponseTemplate::new(200)
319                    .set_body_string("214943DAAD1D64C102FAEC29DE4AFE9DA3D:5\r\n"),
320            )
321            .mount(&server)
322            .await;
323
324        // Mock failure for password2's hash prefix
325        Mock::given(method("GET"))
326            .and(path("/range/2AA60"))
327            .respond_with(ResponseTemplate::new(500))
328            .mount(&server)
329            .await;
330
331        let client = Client::init_test_account(test_bitwarden_com_account()).await;
332        let risk_client = CipherRiskClient { client };
333
334        let login_details = vec![
335            CipherLoginDetails {
336                id: Default::default(),
337                password: "password1".to_string(),
338                username: Some("user1".to_string()),
339            },
340            CipherLoginDetails {
341                id: Default::default(),
342                password: "password2".to_string(),
343                username: Some("user2".to_string()),
344            },
345        ];
346
347        let options = CipherRiskOptions {
348            password_map: None,
349            check_exposed: true,
350            hibp_base_url: Some(server.uri()),
351        };
352
353        let result = risk_client.compute_risk(login_details, options).await;
354
355        // Operation should succeed
356        assert!(result.is_ok());
357        let results = result.unwrap();
358        assert_eq!(results.len(), 2);
359
360        // Count successes and failures
361        let mut success_count = 0;
362        let mut error_count = 0;
363
364        for result in &results {
365            match &result.exposed_result {
366                ExposedPasswordResult::Found(_) => success_count += 1,
367                ExposedPasswordResult::Error(_) => error_count += 1,
368                ExposedPasswordResult::NotChecked => {
369                    panic!("Expected Found or Error, but got NotChecked")
370                }
371            }
372        }
373
374        // We should have exactly one success and one failure
375        assert_eq!(
376            success_count, 1,
377            "Expected 1 successful HIBP check, got {}",
378            success_count
379        );
380        assert_eq!(
381            error_count, 1,
382            "Expected 1 failed HIBP check, got {}",
383            error_count
384        );
385    }
386
387    #[tokio::test]
388    async fn test_compute_risk_integration() {
389        // Integration test verifying the full compute_risk flow
390        // This tests compute_risk without HIBP (check_exposed=false) to avoid
391        // network calls and test stability issues
392        let client = Client::init_test_account(test_bitwarden_com_account()).await;
393        let risk_client = CipherRiskClient {
394            client: client.clone(),
395        };
396
397        let login_details = vec![
398            CipherLoginDetails {
399                id: Default::default(),
400                password: "weak".to_string(),
401                username: Some("user1".to_string()),
402            },
403            CipherLoginDetails {
404                id: Default::default(),
405                password: "xK9#mP$2qL@7vN&4wR".to_string(),
406                username: Some("user2".to_string()),
407            },
408        ];
409
410        let password_map = risk_client.password_reuse_map(login_details.clone());
411
412        let options = CipherRiskOptions {
413            password_map: Some(password_map),
414            check_exposed: false,
415            hibp_base_url: None,
416        };
417
418        let results = risk_client
419            .compute_risk(login_details, options)
420            .await
421            .unwrap();
422
423        assert_eq!(results.len(), 2);
424
425        // Weak password should have low strength
426        assert!(
427            results[0].password_strength <= 1,
428            "Expected weak password strength, got {}",
429            results[0].password_strength
430        );
431
432        // Strong password should have high strength
433        assert!(
434            results[1].password_strength >= 3,
435            "Expected strong password strength, got {}",
436            results[1].password_strength
437        );
438
439        // Both passwords used once
440        assert_eq!(results[0].reuse_count, Some(1));
441        assert_eq!(results[1].reuse_count, Some(1));
442
443        // HIBP not checked
444        assert_eq!(results[0].exposed_result, ExposedPasswordResult::NotChecked);
445        assert_eq!(results[1].exposed_result, ExposedPasswordResult::NotChecked);
446    }
447
448    #[tokio::test]
449    async fn test_compute_risk_concurrent_requests() {
450        // This test verifies that compute_risk truly executes requests concurrently
451        // by tracking request timestamps. If concurrent, multiple requests arrive
452        // within a short time window. If sequential, requests are spaced out.
453        use std::{
454            sync::{Arc, Mutex},
455            time::{Duration, Instant},
456        };
457
458        use wiremock::{
459            Mock, MockServer, ResponseTemplate,
460            matchers::{method, path_regex},
461        };
462
463        let server = MockServer::start().await;
464
465        // Track when each request arrives
466        let request_times = Arc::new(Mutex::new(Vec::new()));
467
468        // Mock HIBP API that records request times
469        Mock::given(method("GET"))
470            .and(path_regex(r"^/range/[A-F0-9]{5}$"))
471            .respond_with({
472                let request_times = request_times.clone();
473                move |_req: &wiremock::Request| {
474                    // Record the time this request arrived
475                    request_times.lock().unwrap().push(Instant::now());
476
477                    ResponseTemplate::new(200)
478                        .set_body_string("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:1\r\n")
479                        .set_delay(Duration::from_millis(10))
480                }
481            })
482            .mount(&server)
483            .await;
484
485        let client = Client::init_test_account(test_bitwarden_com_account()).await;
486        let risk_client = CipherRiskClient { client };
487
488        // Create 5 different passwords to ensure different hash prefixes
489        // This forces 5 separate API calls
490        let login_details: Vec<CipherLoginDetails> = (0..5)
491            .map(|i| CipherLoginDetails {
492                id: Default::default(),
493                password: format!("password{}", i),
494                username: Some(format!("user{}", i)),
495            })
496            .collect();
497
498        let options = CipherRiskOptions {
499            password_map: None,
500            check_exposed: true, // Enable HIBP checking to test concurrency
501            hibp_base_url: Some(server.uri()), // Use mock server URL
502        };
503
504        let results = risk_client
505            .compute_risk(login_details, options)
506            .await
507            .unwrap();
508
509        // Verify all results were returned
510        assert_eq!(results.len(), 5);
511
512        // Verify all passwords were checked successfully
513        for result in &results {
514            match &result.exposed_result {
515                ExposedPasswordResult::Found(_) => {
516                    // Success - password was checked
517                }
518                ExposedPasswordResult::Error(err) => {
519                    panic!("HIBP check should succeed, got error: {}", err);
520                }
521                ExposedPasswordResult::NotChecked => {
522                    panic!("All passwords should be checked when check_exposed=true");
523                }
524            }
525        }
526
527        // Prove concurrency by analyzing request arrival times
528        // If truly concurrent, all 5 requests should arrive within a very short window (< 5ms
529        // window) If sequential with 10ms delays, they'd be spread over 40-50ms
530        let times = request_times.lock().unwrap();
531        let first = times[0];
532        let last = times[times.len() - 1];
533        let time_span = last.duration_since(first);
534
535        assert!(
536            time_span < Duration::from_millis(5),
537            "Expected concurrent execution (all requests within 5ms), \
538             but requests were spread over {}ms. This suggests requests \
539             are being made sequentially instead of concurrently.",
540            time_span.as_millis()
541        );
542    }
543}