Skip to main content

auth_framework/utils/
breach_check.rs

1//! Breached password detection using the Have I Been Pwned (HIBP) k-anonymity API.
2//!
3//! This module checks passwords against known data breaches without sending the
4//! full password or its hash over the network. Only the first 5 characters of the
5//! SHA-1 hash are transmitted, preserving password privacy through k-anonymity.
6
7use crate::errors::{AuthError, Result};
8use sha1::{Digest, Sha1};
9
10/// Check if a password has appeared in known data breaches using the HIBP
11/// k-anonymity range API.
12///
13/// Returns `Ok(true)` if the password has been seen in at least one breach,
14/// `Ok(false)` if it has not, or an error if the HIBP API is unreachable.
15///
16/// The check transmits only the first 5 hex characters of the SHA-1 hash,
17/// so the full password is never exposed to the remote service.
18pub async fn is_password_breached(password: &str) -> Result<bool> {
19    let hash = hex::encode(Sha1::digest(password.as_bytes())).to_uppercase();
20    let (prefix, suffix) = hash.split_at(5);
21
22    let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
23
24    let response = reqwest::Client::builder()
25        .timeout(std::time::Duration::from_secs(5))
26        .build()
27        .map_err(|e| AuthError::internal(format!("HTTP client error: {e}")))?
28        .get(&url)
29        .header("Add-Padding", "true")
30        .send()
31        .await
32        .map_err(|e| AuthError::internal(format!("HIBP API request failed: {e}")))?;
33
34    if !response.status().is_success() {
35        return Err(AuthError::internal(format!(
36            "HIBP API returned status {}",
37            response.status()
38        )));
39    }
40
41    let body = response
42        .text()
43        .await
44        .map_err(|e| AuthError::internal(format!("Failed to read HIBP response: {e}")))?;
45
46    // Each line is "HASH_SUFFIX:COUNT". Check if our suffix appears.
47    let breached = body.lines().any(|line| line.trim().starts_with(suffix));
48
49    Ok(breached)
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn sha1_prefix_suffix_split() {
58        // "password" SHA-1 = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
59        let hash = hex::encode(Sha1::digest(b"password")).to_uppercase();
60        assert_eq!(&hash[..5], "5BAA6");
61        assert_eq!(hash.len(), 40);
62    }
63}