diff --git a/Cargo.lock b/Cargo.lock index 972fe3366..564915aae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -922,6 +922,7 @@ dependencies = [ "bitwarden-uuid", "chrono", "data-encoding", + "futures", "hmac", "percent-encoding", "reqwest", @@ -937,6 +938,8 @@ dependencies = [ "uuid", "wasm-bindgen", "wasm-bindgen-futures", + "wiremock", + "zxcvbn", ] [[package]] diff --git a/crates/bitwarden-vault/Cargo.toml b/crates/bitwarden-vault/Cargo.toml index 9ae609694..513cf62be 100644 --- a/crates/bitwarden-vault/Cargo.toml +++ b/crates/bitwarden-vault/Cargo.toml @@ -41,6 +41,7 @@ bitwarden-state = { workspace = true } bitwarden-uuid = { workspace = true } chrono = { workspace = true } data-encoding = { workspace = true } +futures = "0.3" hmac = ">=0.12.1, <0.13" percent-encoding = ">=2.1, <3.0" reqwest = { workspace = true } @@ -55,11 +56,13 @@ uniffi = { workspace = true, optional = true } uuid = { workspace = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } +zxcvbn = ">=3.0.1, <4.0" [dev-dependencies] bitwarden-api-api = { workspace = true, features = ["mockall"] } bitwarden-test = { workspace = true } tokio = { workspace = true, features = ["rt"] } +wiremock = { workspace = true } [lints] workspace = true diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 9c6b36173..16f4a55f3 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -486,6 +486,23 @@ impl CipherView { } } + /// Extract login details for risk evaluation (login ciphers only). + /// + /// Returns `Some(CipherLoginDetails)` if this is a login cipher with a password, + /// otherwise returns `None`. + pub fn to_login_details(&self) -> Option { + if let Some(login) = &self.login { + if let Some(password) = &login.password { + return Some(crate::cipher::cipher_risk::CipherLoginDetails { + id: self.id, + password: password.clone(), + username: login.username.clone(), + }); + } + } + None + } + fn reencrypt_attachment_keys( &mut self, ctx: &mut KeyStoreContext, diff --git a/crates/bitwarden-vault/src/cipher/cipher_risk.rs b/crates/bitwarden-vault/src/cipher/cipher_risk.rs new file mode 100644 index 000000000..0b2fe2370 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_risk.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use {tsify::Tsify, wasm_bindgen::prelude::*}; + +use crate::CipherId; + +/// Login cipher data needed for risk evaluation. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct CipherLoginDetails { + /// Cipher ID to identify which cipher in results. + pub id: Option, + /// The decrypted password to evaluate. + pub password: String, + /// Username or email (login ciphers only have one field). + pub username: Option, +} + +/// Password reuse map wrapper for WASM compatibility. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(transparent)] +pub struct PasswordReuseMap { + /// Map of passwords to their occurrence count. + #[cfg_attr(feature = "wasm", tsify(type = "Record"))] + pub map: HashMap, +} + +/// Options for configuring risk computation. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "camelCase")] +pub struct CipherRiskOptions { + /// Pre-computed password reuse map (password → count). + /// If provided, enables reuse detection across ciphers. + pub password_map: Option, + /// Whether to check passwords against Have I Been Pwned API. + /// When true, makes network requests to check for exposed passwords. + pub check_exposed: bool, + /// Optional HIBP API base URL override. When None, uses the production HIBP URL. + /// Can be used for testing or alternative password breach checking services. + pub hibp_base_url: Option, +} + +/// Risk evaluation result for a single cipher. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct CipherRisk { + /// Cipher ID matching the input CipherLoginDetails. + pub id: Option, + /// Password strength score from 0 (weakest) to 4 (strongest). + /// Calculated using zxcvbn with cipher-specific context. + pub password_strength: u8, + /// Number of times password appears in HIBP database. + /// None if check_exposed was false in options. + pub exposed_count: Option, + /// Number of times this password appears in the provided cipher list. + /// Minimum value is 1 (the cipher itself). + pub reuse_count: u32, +} + +#[cfg(feature = "wasm")] +impl wasm_bindgen::__rt::VectorIntoJsValue for CipherRisk { + fn vector_into_jsvalue( + vector: wasm_bindgen::__rt::std::boxed::Box<[Self]>, + ) -> wasm_bindgen::JsValue { + wasm_bindgen::__rt::js_value_vector_into_jsvalue(vector) + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_risk_client.rs b/crates/bitwarden-vault/src/cipher/cipher_risk_client.rs new file mode 100644 index 000000000..0124cdd94 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_risk_client.rs @@ -0,0 +1,617 @@ +use std::{collections::HashMap, sync::Arc}; + +use bitwarden_core::Client; +use futures::{StreamExt, TryStreamExt, stream}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::wasm_bindgen; + +use super::cipher_risk::{CipherLoginDetails, CipherRisk, CipherRiskOptions, PasswordReuseMap}; +use crate::CipherRiskError; + +/// Default base URL for the Have I Been Pwned (HIBP) Pwned Passwords API. +const HIBP_DEFAULT_BASE_URL: &str = "https://api.pwnedpasswords.com"; + +/// Maximum number of concurrent requests when checking passwords. +const MAX_CONCURRENT_REQUESTS: usize = 100; + +/// Client for evaluating credential risk for login ciphers. +#[cfg_attr(feature = "wasm", wasm_bindgen)] +pub struct CipherRiskClient { + pub(crate) client: Client, +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl CipherRiskClient { + /// Build password reuse map for a list of login ciphers. + /// + /// Returns a map where keys are passwords and values are the number of times + /// each password appears in the provided list. This map can be passed to `compute_risk()` + /// to enable password reuse detection. + pub fn password_reuse_map( + &self, + login_details: Vec, + ) -> Result { + let mut map = HashMap::new(); + for details in login_details { + if !details.password.is_empty() { + *map.entry(details.password).or_insert(0) += 1; + } + } + Ok(PasswordReuseMap { map }) + } + + /// Evaluate security risks for multiple login ciphers concurrently. + /// + /// For each cipher: + /// 1. Calculates password strength (0-4) using zxcvbn with cipher-specific context + /// 2. Optionally checks if the password has been exposed via Have I Been Pwned API + /// 3. Counts how many times the password is reused across the provided ciphers + /// + /// Returns a vector of `CipherRisk` results, one for each input cipher. + /// + /// # Errors + /// + /// Returns `CipherRiskError::Reqwest` if HIBP API requests fail when `check_exposed` is + /// enabled. Network errors include timeouts, connection failures, HTTP errors, or rate + /// limiting. On error, the entire operation fails - no partial results are returned. + pub async fn compute_risk( + &self, + login_details: Vec, + options: CipherRiskOptions, + ) -> Result, CipherRiskError> { + // Wrap password_map in Arc to avoid cloning the HashMap for each future + let password_map = options.password_map.map(Arc::new); + + // Create futures that can run concurrently + let futures = login_details.into_iter().map(|details| { + let http_client = self.client.internal.get_http_client().clone(); + let password_map = password_map.clone(); + let base_url = options + .hibp_base_url + .clone() + .unwrap_or_else(|| HIBP_DEFAULT_BASE_URL.to_string()); + + async move { + let password_strength = Self::calculate_password_strength( + &details.password, + details.username.as_deref(), + ); + + // Check exposure via HIBP API if enabled + // Network errors now propagate up instead of being silently ignored + let exposed_count = if options.check_exposed { + Some( + Self::check_password_exposed(&http_client, &details.password, &base_url) + .await?, + ) + } else { + None + }; + + // Check reuse from provided map (default to 1 if not in map) + let reuse_count = password_map + .as_ref() + .and_then(|reuse_map| reuse_map.map.get(&details.password)) + .copied() + .unwrap_or(1); + + Ok::(CipherRisk { + id: details.id, + password_strength, + exposed_count, + reuse_count, + }) + } + }); + + // Process up to MAX_CONCURRENT_REQUESTS futures concurrently, fail fast on first error + let results: Vec = stream::iter(futures) + .buffer_unordered(MAX_CONCURRENT_REQUESTS) + .try_collect() + .await?; + + Ok(results) + } + + /// Calculate password strength with cipher-specific context. + /// + /// Uses zxcvbn to score password strength from 0 (weakest) to 4 (strongest). + /// Penalizes passwords that contain parts of the username/email. + fn calculate_password_strength(password: &str, username: Option<&str>) -> u8 { + let mut user_inputs = Vec::new(); + + // Extract meaningful parts from username field + if let Some(username) = username { + user_inputs.extend(Self::extract_user_inputs(username)); + } + + // Call zxcvbn with cipher-specific inputs only (no "bitwarden" globals) + let inputs_refs: Vec<&str> = user_inputs.iter().map(|s| s.as_str()).collect(); + zxcvbn::zxcvbn(password, &inputs_refs).score().into() + } + + /// Extract meaningful tokens from username/email for password penalization. + /// + /// Handles both email addresses and plain usernames: + /// - For emails: extracts and tokenizes the local part (before @) + /// - For usernames: tokenizes the entire string + /// - Splits on non-alphanumeric characters and converts to lowercase + fn extract_user_inputs(username: &str) -> Vec { + // Check if it's email-like (contains @) + if let Some((local_part, _domain)) = username.split_once('@') { + // Email: extract local part tokens + local_part + .trim() + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect() + } else { + // Username: split on non-alphanumeric + username + .trim() + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect() + } + } + + /// Hash password with SHA-1 and split into prefix/suffix for k-anonymity. + /// + /// Returns a tuple of (prefix: first 5 chars, suffix: remaining chars). + fn hash_password_for_hibp(password: &str) -> (String, String) { + use sha1::{Digest, Sha1}; + + let hash = Sha1::digest(password.as_bytes()); + let hash_hex = format!("{:X}", hash); + let (prefix, suffix) = hash_hex.split_at(5); + (prefix.to_string(), suffix.to_string()) + } + + /// Parse HIBP API response to find password hash and return breach count. + /// + /// Response format: "SUFFIX:COUNT\r\n..." (e.g., + /// "0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n..."). + /// Returns the number of times the password appears in breaches (0 if not found). + fn parse_hibp_response(response: &str, target_suffix: &str) -> u32 { + for line in response.lines() { + if let Some((hash_suffix, count_str)) = line.split_once(':') { + if hash_suffix.eq_ignore_ascii_case(target_suffix) { + return count_str.trim().parse().unwrap_or(0); + } + } + } + 0 + } + + /// Check password exposure via HIBP API using k-anonymity model. + /// + /// Implements k-anonymity to ensure privacy: + /// 1. Hash password with SHA-1 + /// 2. Send only first 5 characters of hash to HIBP API + /// 3. API returns all hash suffixes matching that prefix + /// 4. Check locally if full hash exists in results + /// + /// This ensures the actual password never leaves the client. + /// Returns the number of times the password appears in HIBP database (0 if not found). + async fn check_password_exposed( + http_client: &reqwest::Client, + password: &str, + hibp_base_url: &str, + ) -> Result { + let (prefix, suffix) = Self::hash_password_for_hibp(password); + + // Query HIBP API with prefix only (k-anonymity) + let url = format!("{}/range/{}", hibp_base_url, prefix); + let response = http_client + .get(&url) + .send() + .await? + .error_for_status()? + .text() + .await?; + + Ok(Self::parse_hibp_response(&response, &suffix)) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_core::client::test_accounts::test_bitwarden_com_account; + + use super::*; + + #[test] + fn test_extract_user_inputs_from_email() { + let inputs = CipherRiskClient::extract_user_inputs("john.doe@example.com"); + assert_eq!(inputs, vec!["john", "doe"]); + } + + #[test] + fn test_extract_user_inputs_from_username() { + let inputs = CipherRiskClient::extract_user_inputs("john_doe123"); + assert_eq!(inputs, vec!["john", "doe123"]); + } + + #[test] + fn test_extract_user_inputs_lowercase() { + let inputs = CipherRiskClient::extract_user_inputs("JohnDoe@Example.COM"); + assert_eq!(inputs, vec!["johndoe"]); + } + + #[test] + fn test_extract_user_inputs_empty() { + let inputs = CipherRiskClient::extract_user_inputs(""); + assert!(inputs.is_empty()); + } + + #[tokio::test] + async fn test_password_reuse_map() { + let client = Client::init_test_account(test_bitwarden_com_account()).await; + let risk_client = CipherRiskClient { + client: client.clone(), + }; + + let login_details = vec![ + CipherLoginDetails { + id: None, + password: "password123".to_string(), + username: Some("user1".to_string()), + }, + CipherLoginDetails { + id: None, + password: "password123".to_string(), + username: Some("user2".to_string()), + }, + CipherLoginDetails { + id: None, + password: "unique_password".to_string(), + username: Some("user3".to_string()), + }, + ]; + + let password_map = risk_client.password_reuse_map(login_details).unwrap(); + + assert_eq!(password_map.map.get("password123"), Some(&2)); + assert_eq!(password_map.map.get("unique_password"), Some(&1)); + } + + #[tokio::test] + async fn test_calculate_password_strength_penalizes_username() { + // Password containing username should be weaker + let strength_with_username = + CipherRiskClient::calculate_password_strength("johndoe123!", Some("johndoe")); + let strength_without_username = + CipherRiskClient::calculate_password_strength("johndoe123!", None); + + assert!( + strength_with_username <= strength_without_username, + "Password should be weaker when it contains username" + ); + } + + #[tokio::test] + async fn test_password_reuse_map_empty_passwords() { + let client = Client::init_test_account(test_bitwarden_com_account()).await; + let risk_client = CipherRiskClient { + client: client.clone(), + }; + + let login_details = vec![ + CipherLoginDetails { + id: None, + password: "".to_string(), + username: Some("user1".to_string()), + }, + CipherLoginDetails { + id: None, + password: "valid_password".to_string(), + username: Some("user2".to_string()), + }, + ]; + + let password_map = risk_client.password_reuse_map(login_details).unwrap(); + + // Empty passwords should not be in the map + assert!(!password_map.map.contains_key("")); + assert_eq!(password_map.map.get("valid_password"), Some(&1)); + } + + #[test] + fn test_hash_password_for_hibp() { + // Test with a known password: "password" + // SHA-1 hash of "password" is: 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 + let (prefix, suffix) = CipherRiskClient::hash_password_for_hibp("password"); + + assert_eq!(prefix, "5BAA6"); + assert_eq!(suffix, "1E4C9B93F3F0682250B6CF8331B7EE68FD8"); + + // Validate expected lengths (5 for prefix, 35 for suffix = 40 total SHA-1 hex) + assert_eq!(prefix.len(), 5); + assert_eq!(suffix.len(), 35); + } + + #[test] + fn test_parse_hibp_response_found() { + // Simulate real HIBP API response format with the target password + let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:6\r\n\ + 0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n\ + 00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2\r\n"; + + let target_suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8"; + + let count = CipherRiskClient::parse_hibp_response(mock_response, target_suffix); + + assert_eq!(count, 6); + } + + #[test] + fn test_parse_hibp_response_not_found() { + // Simulate HIBP API response without target hash + let mock_response = "0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n\ + 00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2\r\n\ + 011053FD0102E94D6AE2F8B83D76FAF94F6:1\r\n"; + + let target_suffix = "NOTFOUNDNOTFOUNDNOTFOUNDNOTFOUND"; + + let count = CipherRiskClient::parse_hibp_response(mock_response, target_suffix); + + assert_eq!(count, 0); + } + + #[test] + fn test_parse_hibp_response_case_insensitive() { + // HIBP API returns uppercase hashes, but we should match case-insensitively + let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\r\n"; + + // Test with lowercase suffix + let target_suffix_lower = "1e4c9b93f3f0682250b6cf8331b7ee68fd8"; + + let count = CipherRiskClient::parse_hibp_response(mock_response, target_suffix_lower); + + assert_eq!(count, 12345); + } + + #[test] + fn test_parse_hibp_response_empty() { + // Empty response + let mock_response = ""; + + let count = CipherRiskClient::parse_hibp_response(mock_response, "ANYTHING"); + assert_eq!(count, 0); + } + + #[test] + fn test_parse_hibp_response_malformed_count() { + // Response with invalid count (should return 0 on parse failure) + let mock_response = "AAA111:not_a_number\r\n"; + + let count = CipherRiskClient::parse_hibp_response(mock_response, "AAA111"); + assert_eq!(count, 0); + } + + // Wiremock tests for actual HIBP API integration + #[tokio::test] + async fn test_hibp_api_network_error() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + + let server = MockServer::start().await; + + // Mock network error (500 status) + Mock::given(method("GET")) + .and(path("/range/5BAA6")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let result = CipherRiskClient::check_password_exposed( + &reqwest::Client::new(), + "password", + &server.uri(), + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherRiskError::Reqwest(_))); + } + + #[tokio::test] + async fn test_compute_risk_propagates_network_errors() { + // Test that network errors from HIBP API are properly propagated + // instead of being silently swallowed + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path_regex}, + }; + + let server = MockServer::start().await; + + // Mock network error (500 status) for all HIBP range requests + Mock::given(method("GET")) + .and(path_regex(r"^/range/[A-F0-9]{5}$")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let client = Client::init_test_account(test_bitwarden_com_account()).await; + let risk_client = CipherRiskClient { client }; + + let login_details = vec![CipherLoginDetails { + id: None, + password: "password123".to_string(), + username: Some("user1".to_string()), + }]; + + let options = CipherRiskOptions { + password_map: None, + check_exposed: true, // Enable HIBP checking + hibp_base_url: Some(server.uri()), + }; + + let result = risk_client.compute_risk(login_details, options).await; + + // Verify error is propagated, not swallowed + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, CipherRiskError::Reqwest(_)), + "Expected CipherRiskError::Reqwest, got {:?}", + err + ); + } + + #[tokio::test] + async fn test_compute_risk_integration() { + // Integration test verifying the full compute_risk flow + // This tests compute_risk without HIBP (check_exposed=false) to avoid + // network calls and test stability issues + let client = Client::init_test_account(test_bitwarden_com_account()).await; + let risk_client = CipherRiskClient { + client: client.clone(), + }; + + let login_details = vec![ + CipherLoginDetails { + id: None, + password: "weak".to_string(), + username: Some("user1".to_string()), + }, + CipherLoginDetails { + id: None, + password: "xK9#mP$2qL@7vN&4wR".to_string(), + username: Some("user2".to_string()), + }, + ]; + + let password_map = risk_client + .password_reuse_map(login_details.clone()) + .unwrap(); + + let options = CipherRiskOptions { + password_map: Some(password_map), + check_exposed: false, + hibp_base_url: None, + }; + + let results = risk_client + .compute_risk(login_details, options) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + + // Weak password should have low strength + assert!( + results[0].password_strength <= 1, + "Expected weak password strength, got {}", + results[0].password_strength + ); + + // Strong password should have high strength + assert!( + results[1].password_strength >= 3, + "Expected strong password strength, got {}", + results[1].password_strength + ); + + // Both passwords used once + assert_eq!(results[0].reuse_count, 1); + assert_eq!(results[1].reuse_count, 1); + + // HIBP not checked + assert!(results[0].exposed_count.is_none()); + assert!(results[1].exposed_count.is_none()); + } + + #[tokio::test] + async fn test_compute_risk_concurrent_requests() { + // This test verifies that compute_risk truly executes requests concurrently + // by tracking request timestamps. If concurrent, multiple requests arrive + // within a short time window. If sequential, requests are spaced out. + use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, + }; + + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path_regex}, + }; + + let server = MockServer::start().await; + + // Track when each request arrives + let request_times = Arc::new(Mutex::new(Vec::new())); + + // Mock HIBP API that records request times + Mock::given(method("GET")) + .and(path_regex(r"^/range/[A-F0-9]{5}$")) + .respond_with({ + let request_times = request_times.clone(); + move |_req: &wiremock::Request| { + // Record the time this request arrived + request_times.lock().unwrap().push(Instant::now()); + + ResponseTemplate::new(200) + .set_body_string("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:1\r\n") + .set_delay(Duration::from_millis(10)) + } + }) + .mount(&server) + .await; + + let client = Client::init_test_account(test_bitwarden_com_account()).await; + let risk_client = CipherRiskClient { client }; + + // Create 5 different passwords to ensure different hash prefixes + // This forces 5 separate API calls + let login_details: Vec = (0..5) + .map(|i| CipherLoginDetails { + id: None, + password: format!("password{}", i), + username: Some(format!("user{}", i)), + }) + .collect(); + + let options = CipherRiskOptions { + password_map: None, + check_exposed: true, // Enable HIBP checking to test concurrency + hibp_base_url: Some(server.uri()), // Use mock server URL + }; + + let results = risk_client + .compute_risk(login_details, options) + .await + .unwrap(); + + // Verify all results were returned + assert_eq!(results.len(), 5); + + // Verify all passwords were checked + for result in &results { + assert!(result.exposed_count.is_some()); + } + + // Prove concurrency by analyzing request arrival times + // If truly concurrent, all 5 requests should arrive within a very short window (< 5ms + // window) If sequential with 10ms delays, they'd be spread over 40-50ms + let times = request_times.lock().unwrap(); + let first = times[0]; + let last = times[times.len() - 1]; + let time_span = last.duration_since(first); + + assert!( + time_span < Duration::from_millis(5), + "Expected concurrent execution (all requests within 5ms), \ + but requests were spread over {}ms. This suggests requests \ + are being made sequentially instead of concurrently.", + time_span.as_millis() + ); + } +} diff --git a/crates/bitwarden-vault/src/cipher/mod.rs b/crates/bitwarden-vault/src/cipher/mod.rs index 39fe85361..1db307059 100644 --- a/crates/bitwarden-vault/src/cipher/mod.rs +++ b/crates/bitwarden-vault/src/cipher/mod.rs @@ -5,6 +5,8 @@ pub(crate) mod card; pub(crate) mod cipher; pub(crate) mod cipher_client; pub(crate) mod cipher_permissions; +pub(crate) mod cipher_risk; +pub(crate) mod cipher_risk_client; pub(crate) mod field; pub(crate) mod identity; pub(crate) mod linked_id; @@ -23,6 +25,8 @@ pub use cipher::{ CipherType, CipherView, DecryptCipherListResult, EncryptionContext, }; pub use cipher_client::CiphersClient; +pub use cipher_risk::{CipherLoginDetails, CipherRisk, CipherRiskOptions, PasswordReuseMap}; +pub use cipher_risk_client::CipherRiskClient; pub use field::{FieldType, FieldView}; pub use identity::IdentityView; pub use login::{ diff --git a/crates/bitwarden-vault/src/error.rs b/crates/bitwarden-vault/src/error.rs index b713296ef..acf123150 100644 --- a/crates/bitwarden-vault/src/error.rs +++ b/crates/bitwarden-vault/src/error.rs @@ -31,3 +31,12 @@ pub enum VaultParseError { #[error(transparent)] MissingField(#[from] bitwarden_core::MissingFieldError), } + +/// Error type for cipher risk evaluation operations +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum CipherRiskError { + #[error(transparent)] + Reqwest(#[from] reqwest::Error), +} diff --git a/crates/bitwarden-vault/src/lib.rs b/crates/bitwarden-vault/src/lib.rs index 61d5b099f..f42747e89 100644 --- a/crates/bitwarden-vault/src/lib.rs +++ b/crates/bitwarden-vault/src/lib.rs @@ -20,7 +20,7 @@ pub use totp::{ Totp, TotpAlgorithm, TotpError, TotpResponse, generate_totp, generate_totp_cipher_view, }; mod error; -pub use error::{DecryptError, EncryptError, VaultParseError}; +pub use error::{CipherRiskError, DecryptError, EncryptError, VaultParseError}; mod vault_client; pub use vault_client::{VaultClient, VaultClientExt}; diff --git a/crates/bitwarden-vault/src/vault_client.rs b/crates/bitwarden-vault/src/vault_client.rs index 2fd6a513e..e671a7ba2 100644 --- a/crates/bitwarden-vault/src/vault_client.rs +++ b/crates/bitwarden-vault/src/vault_client.rs @@ -3,8 +3,8 @@ use bitwarden_core::Client; use wasm_bindgen::prelude::*; use crate::{ - AttachmentsClient, CiphersClient, FoldersClient, PasswordHistoryClient, SyncRequest, - SyncResponse, TotpClient, + AttachmentsClient, CipherRiskClient, CiphersClient, FoldersClient, PasswordHistoryClient, + SyncRequest, SyncResponse, TotpClient, collection_client::CollectionsClient, sync::{SyncError, sync}, }; @@ -70,6 +70,13 @@ impl VaultClient { client: self.client.clone(), } } + + /// Cipher risk evaluation operations. + pub fn cipher_risk(&self) -> CipherRiskClient { + CipherRiskClient { + client: self.client.clone(), + } + } } #[allow(missing_docs)]