bitwarden_vault/cipher_risk/
hibp.rs1use super::CipherRiskError;
6
7pub(super) const HIBP_DEFAULT_BASE_URL: &str = "https://api.pwnedpasswords.com";
9
10fn 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
25fn 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
39pub(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 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 let (prefix, suffix) = hash_password_for_hibp("password");
83
84 assert_eq!(prefix, "5BAA6");
85 assert_eq!(suffix, "1E4C9B93F3F0682250B6CF8331B7EE68FD8");
86
87 assert_eq!(prefix.len(), 5);
89 assert_eq!(suffix.len(), 35);
90 }
91
92 #[test]
93 fn test_parse_hibp_response_found() {
94 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 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 let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\r\n";
124
125 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 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 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 #[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::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}