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 super::*;
75
76 #[test]
77 fn test_hash_password_for_hibp() {
78 let (prefix, suffix) = hash_password_for_hibp("password");
81
82 assert_eq!(prefix, "5BAA6");
83 assert_eq!(suffix, "1E4C9B93F3F0682250B6CF8331B7EE68FD8");
84
85 assert_eq!(prefix.len(), 5);
87 assert_eq!(suffix.len(), 35);
88 }
89
90 #[test]
91 fn test_parse_hibp_response_found() {
92 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 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 let mock_response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\r\n";
122
123 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 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 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 #[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::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}