Skip to main content

bitwarden_vault/cipher_risk/
hibp.rs

1//! Have I Been Pwned (HIBP) API client for password breach checking.
2//!
3//! This module implements k-anonymity based password checking against the HIBP API.
4
5use super::CipherRiskError;
6
7/// Default base URL for the Have I Been Pwned (HIBP) Pwned Passwords API.
8pub(super) const HIBP_DEFAULT_BASE_URL: &str = "https://api.pwnedpasswords.com";
9
10/// Hash password with SHA-1 and split into prefix/suffix for k-anonymity.
11///
12/// Returns a tuple of (prefix: first 5 chars, suffix: remaining chars).
13fn hash_password_for_hibp(password: &str) -> (String, String) {
14    use sha1::{Digest, Sha1};
15
16    let hash = Sha1::digest(password.as_bytes());
17    let hash_hex = hash
18        .iter()
19        .map(|b| format!("{:02X}", b))
20        .collect::<String>();
21    let (prefix, suffix) = hash_hex.split_at(5);
22    (prefix.to_string(), suffix.to_string())
23}
24
25/// Parse HIBP API response to find password hash and return breach count.
26///
27/// Response format: "SUFFIX:COUNT\r\n..." (e.g.,
28/// "0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n...").
29/// Returns the number of times the password appears in breaches (0 if not found).
30fn parse_hibp_response(response: &str, target_suffix: &str) -> u32 {
31    response
32        .lines()
33        .filter_map(|l| l.split_once(':'))
34        .find(|(hash_suffix, _)| hash_suffix.eq_ignore_ascii_case(target_suffix))
35        .and_then(|(_, count_str)| count_str.trim().parse().ok())
36        .unwrap_or(0)
37}
38
39/// Check password exposure via HIBP API using k-anonymity model.
40///
41/// Implements k-anonymity to ensure privacy:
42/// 1. Hash password with SHA-1
43/// 2. Send only first 5 characters of hash to HIBP API
44/// 3. API returns all hash suffixes matching that prefix
45/// 4. Check locally if full hash exists in results
46///
47/// This ensures the actual password never leaves the client.
48/// Returns the number of times the password appears in HIBP database (0 if not found).
49pub(super) async fn check_password_exposed(
50    http_client: &reqwest::Client,
51    password: &str,
52    hibp_base_url: &str,
53) -> Result<u32, CipherRiskError> {
54    let (prefix, suffix) = hash_password_for_hibp(password);
55
56    // Query HIBP API with prefix only (k-anonymity)
57    let url = format!("{}/range/{}", hibp_base_url, prefix);
58    let response = http_client
59        .get(&url)
60        .send()
61        .await
62        .map_err(|e| e.without_url())?
63        .error_for_status()
64        .map_err(|e| e.without_url())?
65        .text()
66        .await
67        .map_err(|e| e.without_url())?;
68
69    Ok(parse_hibp_response(&response, &suffix))
70}
71
72#[cfg(test)]
73mod tests {
74    use bitwarden_api_api::new_http_client;
75
76    use super::*;
77
78    #[test]
79    fn test_hash_password_for_hibp() {
80        // Test with a known password: "password"
81        // SHA-1 hash of "password" is: 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
82        let (prefix, suffix) = hash_password_for_hibp("password");
83
84        assert_eq!(prefix, "5BAA6");
85        assert_eq!(suffix, "1E4C9B93F3F0682250B6CF8331B7EE68FD8");
86
87        // Validate expected lengths (5 for prefix, 35 for suffix = 40 total SHA-1 hex)
88        assert_eq!(prefix.len(), 5);
89        assert_eq!(suffix.len(), 35);
90    }
91
92    #[test]
93    fn test_parse_hibp_response_found() {
94        // Simulate real HIBP API response format with the target password
95        let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:6\r\n\
96                            0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n\
97                            00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2\r\n";
98
99        let target_suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8";
100
101        let count = parse_hibp_response(mock_response, target_suffix);
102
103        assert_eq!(count, 6);
104    }
105
106    #[test]
107    fn test_parse_hibp_response_not_found() {
108        // Simulate HIBP API response without target hash
109        let mock_response = "0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n\
110                            00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2\r\n\
111                            011053FD0102E94D6AE2F8B83D76FAF94F6:1\r\n";
112
113        let target_suffix = "NOTFOUNDNOTFOUNDNOTFOUNDNOTFOUND";
114
115        let count = parse_hibp_response(mock_response, target_suffix);
116
117        assert_eq!(count, 0);
118    }
119
120    #[test]
121    fn test_parse_hibp_response_case_insensitive() {
122        // HIBP API returns uppercase hashes, but we should match case-insensitively
123        let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\r\n";
124
125        // Test with lowercase suffix
126        let target_suffix_lower = "1e4c9b93f3f0682250b6cf8331b7ee68fd8";
127
128        let count = parse_hibp_response(mock_response, target_suffix_lower);
129
130        assert_eq!(count, 12345);
131    }
132
133    #[test]
134    fn test_parse_hibp_response_empty() {
135        // Empty response
136        let mock_response = "";
137
138        let count = parse_hibp_response(mock_response, "ANYTHING");
139        assert_eq!(count, 0);
140    }
141
142    #[test]
143    fn test_parse_hibp_response_malformed_count() {
144        // Response with invalid count (should return 0 on parse failure)
145        let mock_response = "AAA111:not_a_number\r\n";
146
147        let count = parse_hibp_response(mock_response, "AAA111");
148        assert_eq!(count, 0);
149    }
150
151    // Wiremock tests for actual HIBP API integration
152    #[tokio::test]
153    async fn test_hibp_api_network_error() {
154        use wiremock::{
155            Mock, MockServer, ResponseTemplate,
156            matchers::{method, path},
157        };
158
159        let server = MockServer::start().await;
160
161        // Mock network error (500 status)
162        Mock::given(method("GET"))
163            .and(path("/range/5BAA6"))
164            .respond_with(ResponseTemplate::new(500))
165            .mount(&server)
166            .await;
167
168        let result = check_password_exposed(&new_http_client(), "password", &server.uri()).await;
169
170        assert!(result.is_err());
171        assert!(matches!(result.unwrap_err(), CipherRiskError::Reqwest(_)));
172    }
173}