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