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