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#[allow(missing_docs)]
19#[bitwarden_error(flat)]
20#[derive(Debug, Error)]
21pub enum CipherRiskError {
22 #[error(transparent)]
23 Reqwest(#[from] reqwest::Error),
24}
25
26const MAX_CONCURRENT_REQUESTS: usize = 100;
28
29#[bitwarden_ffi::wasm_object]
31pub struct CipherRiskClient {
32 pub(crate) client: Client,
33}
34
35#[bitwarden_ffi::wasm_export]
36impl CipherRiskClient {
37 pub fn password_reuse_map(&self, login_details: Vec<CipherLoginDetails>) -> PasswordReuseMap {
43 PasswordReuseMap::new(login_details)
44 }
45
46 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 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 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 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 pub async fn compute_risk(
121 &self,
122 login_details: Vec<CipherLoginDetails>,
123 options: CipherRiskOptions,
124 ) -> Result<Vec<CipherRiskResult>, CipherRiskError> {
125 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 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 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 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, hibp_base_url: None,
233 };
234
235 let result = risk_client.compute_risk(login_details, options).await;
236
237 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 use wiremock::{
251 Mock, MockServer, ResponseTemplate,
252 matchers::{method, path_regex},
253 };
254
255 let server = MockServer::start().await;
256
257 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, hibp_base_url: Some(server.uri()),
277 };
278
279 let result = risk_client.compute_risk(login_details, options).await;
280
281 assert!(result.is_ok());
283 let results = result.unwrap();
284 assert_eq!(results.len(), 1);
285
286 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 use wiremock::{
305 Mock, MockServer, ResponseTemplate,
306 matchers::{method, path},
307 };
308
309 let server = MockServer::start().await;
310
311 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::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 assert!(result.is_ok());
357 let results = result.unwrap();
358 assert_eq!(results.len(), 2);
359
360 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 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 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 assert!(
427 results[0].password_strength <= 1,
428 "Expected weak password strength, got {}",
429 results[0].password_strength
430 );
431
432 assert!(
434 results[1].password_strength >= 3,
435 "Expected strong password strength, got {}",
436 results[1].password_strength
437 );
438
439 assert_eq!(results[0].reuse_count, Some(1));
441 assert_eq!(results[1].reuse_count, Some(1));
442
443 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 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 let request_times = Arc::new(Mutex::new(Vec::new()));
467
468 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 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 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, hibp_base_url: Some(server.uri()), };
503
504 let results = risk_client
505 .compute_risk(login_details, options)
506 .await
507 .unwrap();
508
509 assert_eq!(results.len(), 5);
511
512 for result in &results {
514 match &result.exposed_result {
515 ExposedPasswordResult::Found(_) => {
516 }
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 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}