Skip to main content

rustauth_plugins/have_i_been_pwned/
checker.rs

1//! Have I Been Pwned k-anonymity range checker.
2
3use 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}