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 = format!("{:X}", hash);
18 let (prefix, suffix) = hash_hex.split_at(5);
19 (prefix.to_string(), suffix.to_string())
20}
21
22fn 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
36pub(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 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 let (prefix, suffix) = hash_password_for_hibp("password");
78
79 assert_eq!(prefix, "5BAA6");
80 assert_eq!(suffix, "1E4C9B93F3F0682250B6CF8331B7EE68FD8");
81
82 assert_eq!(prefix.len(), 5);
84 assert_eq!(suffix.len(), 35);
85 }
86
87 #[test]
88 fn test_parse_hibp_response_found() {
89 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 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 let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\r\n";
119
120 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 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 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 #[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::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}