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 super::*;
75
76    #[test]
77    fn test_hash_password_for_hibp() {
78        // Test with a known password: "password"
79        // SHA-1 hash of "password" is: 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
80        let (prefix, suffix) = hash_password_for_hibp("password");
81
82        assert_eq!(prefix, "5BAA6");
83        assert_eq!(suffix, "1E4C9B93F3F0682250B6CF8331B7EE68FD8");
84
85        // Validate expected lengths (5 for prefix, 35 for suffix = 40 total SHA-1 hex)
86        assert_eq!(prefix.len(), 5);
87        assert_eq!(suffix.len(), 35);
88    }
89
90    #[test]
91    fn test_parse_hibp_response_found() {
92        // Simulate real HIBP API response format with the target password
93        let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:6\r\n\
94                            0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n\
95                            00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2\r\n";
96
97        let target_suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8";
98
99        let count = parse_hibp_response(mock_response, target_suffix);
100
101        assert_eq!(count, 6);
102    }
103
104    #[test]
105    fn test_parse_hibp_response_not_found() {
106        // Simulate HIBP API response without target hash
107        let mock_response = "0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n\
108                            00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2\r\n\
109                            011053FD0102E94D6AE2F8B83D76FAF94F6:1\r\n";
110
111        let target_suffix = "NOTFOUNDNOTFOUNDNOTFOUNDNOTFOUND";
112
113        let count = parse_hibp_response(mock_response, target_suffix);
114
115        assert_eq!(count, 0);
116    }
117
118    #[test]
119    fn test_parse_hibp_response_case_insensitive() {
120        // HIBP API returns uppercase hashes, but we should match case-insensitively
121        let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\r\n";
122
123        // Test with lowercase suffix
124        let target_suffix_lower = "1e4c9b93f3f0682250b6cf8331b7ee68fd8";
125
126        let count = parse_hibp_response(mock_response, target_suffix_lower);
127
128        assert_eq!(count, 12345);
129    }
130
131    #[test]
132    fn test_parse_hibp_response_empty() {
133        // Empty response
134        let mock_response = "";
135
136        let count = parse_hibp_response(mock_response, "ANYTHING");
137        assert_eq!(count, 0);
138    }
139
140    #[test]
141    fn test_parse_hibp_response_malformed_count() {
142        // Response with invalid count (should return 0 on parse failure)
143        let mock_response = "AAA111:not_a_number\r\n";
144
145        let count = parse_hibp_response(mock_response, "AAA111");
146        assert_eq!(count, 0);
147    }
148
149    // Wiremock tests for actual HIBP API integration
150    #[tokio::test]
151    async fn test_hibp_api_network_error() {
152        use wiremock::{
153            Mock, MockServer, ResponseTemplate,
154            matchers::{method, path},
155        };
156
157        let server = MockServer::start().await;
158
159        // Mock network error (500 status)
160        Mock::given(method("GET"))
161            .and(path("/range/5BAA6"))
162            .respond_with(ResponseTemplate::new(500))
163            .mount(&server)
164            .await;
165
166        let result =
167            check_password_exposed(&reqwest::Client::new(), "password", &server.uri()).await;
168
169        assert!(result.is_err());
170        assert!(matches!(result.unwrap_err(), CipherRiskError::Reqwest(_)));
171    }
172}