rustauth_plugins/have_i_been_pwned/
checker.rs1use sha1::{Digest, Sha1};
4use std::future::Future;
5use std::pin::Pin;
6
7const HIBP_USER_AGENT: &str = "BetterAuth Password Checker";
8
9pub type HaveIBeenPwnedCheckFuture<'a> =
10 Pin<Box<dyn Future<Output = Result<bool, HaveIBeenPwnedCheckError>> + Send + 'a>>;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum HaveIBeenPwnedCheckError {
14 HttpStatus(u16),
15 Transport(String),
16}
17
18pub trait HaveIBeenPwnedChecker: Send + Sync + std::fmt::Debug {
19 fn is_hash_suffix_compromised<'a>(
20 &'a self,
21 prefix: &'a str,
22 suffix: &'a str,
23 ) -> HaveIBeenPwnedCheckFuture<'a>;
24}
25
26#[derive(Debug, Clone)]
27pub struct ReqwestHaveIBeenPwnedChecker {
28 client: reqwest::Client,
29 base_url: String,
30}
31
32impl Default for ReqwestHaveIBeenPwnedChecker {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl ReqwestHaveIBeenPwnedChecker {
39 pub fn new() -> Self {
40 Self {
41 client: reqwest::Client::new(),
42 base_url: "https://api.pwnedpasswords.com".to_owned(),
43 }
44 }
45
46 #[cfg(test)]
47 fn with_base_url(base_url: impl Into<String>) -> Self {
48 Self {
49 client: reqwest::Client::new(),
50 base_url: base_url.into(),
51 }
52 }
53
54 fn range_request(&self, prefix: &str) -> Result<reqwest::Request, reqwest::Error> {
55 let url = format!("{}/range/{prefix}", self.base_url.trim_end_matches('/'));
56 self.client
57 .get(url)
58 .header("Add-Padding", "true")
59 .header("User-Agent", HIBP_USER_AGENT)
60 .build()
61 }
62}
63
64impl HaveIBeenPwnedChecker for ReqwestHaveIBeenPwnedChecker {
65 fn is_hash_suffix_compromised<'a>(
66 &'a self,
67 prefix: &'a str,
68 suffix: &'a str,
69 ) -> HaveIBeenPwnedCheckFuture<'a> {
70 Box::pin(async move {
71 let request = self
72 .range_request(prefix)
73 .map_err(|error| HaveIBeenPwnedCheckError::Transport(error.to_string()))?;
74 let response = self
75 .client
76 .execute(request)
77 .await
78 .map_err(|error| HaveIBeenPwnedCheckError::Transport(error.to_string()))?;
79 if !response.status().is_success() {
80 return Err(HaveIBeenPwnedCheckError::HttpStatus(
81 response.status().as_u16(),
82 ));
83 }
84 let body = response
85 .text()
86 .await
87 .map_err(|error| HaveIBeenPwnedCheckError::Transport(error.to_string()))?;
88 Ok(range_response_contains_suffix(&body, suffix))
89 })
90 }
91}
92
93pub(crate) fn sha1_prefix_suffix(password: &str) -> (String, String) {
94 let digest = Sha1::digest(password.as_bytes());
95 let hash = hex::encode_upper(digest);
96 let prefix = hash[..5].to_owned();
97 let suffix = hash[5..].to_owned();
98 (prefix, suffix)
99}
100
101pub(crate) fn range_response_contains_suffix(body: &str, suffix: &str) -> bool {
102 body.lines().any(|line| {
103 let Some((candidate, _count)) = line.trim().split_once(':') else {
104 return false;
105 };
106 candidate.eq_ignore_ascii_case(suffix)
107 })
108}
109
110#[cfg(test)]
111mod tests {
112 use super::{
113 range_response_contains_suffix, sha1_prefix_suffix, ReqwestHaveIBeenPwnedChecker,
114 HIBP_USER_AGENT,
115 };
116
117 #[test]
118 fn range_response_matches_suffix_case_insensitively_with_crlf() {
119 let body = "ABCDEF:1\r\n00ff00:2\r\n";
120
121 assert!(range_response_contains_suffix(body, "00FF00"));
122 }
123
124 #[test]
125 fn range_response_ignores_non_matching_suffixes() {
126 let body = "ABCDEF:1\n123456:2\n";
127
128 assert!(!range_response_contains_suffix(body, "999999"));
129 }
130
131 #[test]
132 fn sha1_prefix_suffix_uses_uppercase_hex_and_splits_after_five_chars() {
133 let (prefix, suffix) = sha1_prefix_suffix("123456789");
134
135 assert_eq!(prefix, "F7C3B");
136 assert_eq!(suffix, "C1D808E04732ADF679965CCC34CA7AE3441");
137 }
138
139 #[test]
140 fn reqwest_checker_uses_range_endpoint_padding_and_upstream_user_agent(
141 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
142 let checker = ReqwestHaveIBeenPwnedChecker::with_base_url("http://hibp.test/");
143
144 let request = checker.range_request("ABCDE")?;
145
146 assert!(
147 request.url().as_str() == "http://hibp.test/range/ABCDE",
148 "request URL should include only the k-anonymity hash prefix"
149 );
150 assert_eq!(
151 request
152 .headers()
153 .get("Add-Padding")
154 .and_then(|value| value.to_str().ok()),
155 Some("true")
156 );
157 assert_eq!(
158 request
159 .headers()
160 .get("User-Agent")
161 .and_then(|value| value.to_str().ok()),
162 Some(HIBP_USER_AGENT)
163 );
164 assert!(!request.url().as_str().contains("012345"));
165 Ok(())
166 }
167}