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#[allow(missing_docs)]
21#[bitwarden_error(flat)]
22#[derive(Debug, Error)]
23pub enum CipherRiskError {
24 #[error(transparent)]
25 Reqwest(#[from] reqwest::Error),
26}
27
28const MAX_CONCURRENT_REQUESTS: usize = 100;
30
31#[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 pub fn password_reuse_map(&self, login_details: Vec<CipherLoginDetails>) -> PasswordReuseMap {
45 PasswordReuseMap::new(login_details)
46 }
47
48 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 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 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 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 pub async fn compute_risk(
123 &self,
124 login_details: Vec<CipherLoginDetails>,
125 options: CipherRiskOptions,
126 ) -> Result<Vec<CipherRiskResult>, CipherRiskError> {
127 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 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 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 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, hibp_base_url: None,
235 };
236
237 let result = risk_client.compute_risk(login_details, options).await;
238
239 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 use wiremock::{
253 Mock, MockServer, ResponseTemplate,
254 matchers::{method, path_regex},
255 };
256
257 let server = MockServer::start().await;
258
259 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, hibp_base_url: Some(server.uri()),
279 };
280
281 let result = risk_client.compute_risk(login_details, options).await;
282
283 assert!(result.is_ok());
285 let results = result.unwrap();
286 assert_eq!(results.len(), 1);
287
288 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 use wiremock::{
307 Mock, MockServer, ResponseTemplate,
308 matchers::{method, path},
309 };
310
311 let server = MockServer::start().await;
312
313 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::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 assert!(result.is_ok());
359 let results = result.unwrap();
360 assert_eq!(results.len(), 2);
361
362 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 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 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 assert!(
429 results[0].password_strength <= 1,
430 "Expected weak password strength, got {}",
431 results[0].password_strength
432 );
433
434 assert!(
436 results[1].password_strength >= 3,
437 "Expected strong password strength, got {}",
438 results[1].password_strength
439 );
440
441 assert_eq!(results[0].reuse_count, Some(1));
443 assert_eq!(results[1].reuse_count, Some(1));
444
445 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 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 let request_times = Arc::new(Mutex::new(Vec::new()));
469
470 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 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 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, hibp_base_url: Some(server.uri()), };
505
506 let results = risk_client
507 .compute_risk(login_details, options)
508 .await
509 .unwrap();
510
511 assert_eq!(results.len(), 5);
513
514 for result in &results {
516 match &result.exposed_result {
517 ExposedPasswordResult::Found(_) => {
518 }
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 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}