use crate::error::{Result, TidewayError};
use sha1::{Digest, Sha1};
use std::time::Duration;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
const DEFAULT_MIN_BREACH_COUNT: u32 = 1;
const HIBP_API_URL: &str = "https://api.pwnedpasswords.com/range/";
#[derive(Clone, Debug)]
pub struct BreachCheckConfig {
pub timeout: Duration,
pub min_breach_count: u32,
pub fail_open: bool,
pub api_url: String,
}
impl Default for BreachCheckConfig {
fn default() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
min_breach_count: DEFAULT_MIN_BREACH_COUNT,
fail_open: true,
api_url: HIBP_API_URL.to_string(),
}
}
}
impl BreachCheckConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn min_breach_count(mut self, count: u32) -> Self {
self.min_breach_count = count;
self
}
#[must_use]
pub fn fail_open(mut self, fail_open: bool) -> Self {
self.fail_open = fail_open;
self
}
#[must_use]
pub fn api_url(mut self, url: impl Into<String>) -> Self {
self.api_url = url.into();
self
}
}
#[derive(Clone, Debug)]
pub struct BreachChecker {
config: BreachCheckConfig,
client: reqwest::Client,
}
impl BreachChecker {
#[must_use]
pub fn hibp() -> Self {
Self::with_config(BreachCheckConfig::default())
}
#[must_use]
pub fn with_config(config: BreachCheckConfig) -> Self {
let client = Self::build_client(config.timeout);
Self { config, client }
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
self.client = Self::build_client(timeout);
self
}
fn build_client(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.user_agent("tideway-auth")
.build()
.unwrap_or_default()
}
#[must_use]
pub fn with_min_breach_count(mut self, count: u32) -> Self {
self.config.min_breach_count = count;
self
}
#[must_use]
pub fn with_fail_open(mut self, fail_open: bool) -> Self {
self.config.fail_open = fail_open;
self
}
pub async fn check(&self, password: &str) -> Result<Option<u32>> {
let (prefix, suffix) = self.hash_password(password);
match self.query_api(&prefix).await {
Ok(response) => Ok(self.find_in_response(&suffix, &response)),
Err(e) => {
tracing::warn!(
target: "auth.breach.api_error",
error = %e,
"Breach check API request failed"
);
if self.config.fail_open {
Ok(None)
} else {
Err(e)
}
}
}
}
pub async fn is_breached(&self, password: &str) -> Result<bool> {
match self.check(password).await? {
Some(count) => Ok(count >= self.config.min_breach_count),
None => Ok(false),
}
}
pub async fn validate(&self, password: &str) -> Result<()> {
if self.is_breached(password).await? {
tracing::info!(
target: "auth.breach.password_blocked",
"Password rejected: found in breach database"
);
Err(TidewayError::BadRequest(
"This password has appeared in a data breach and cannot be used. \
Please choose a different password."
.to_string(),
))
} else {
Ok(())
}
}
fn hash_password(&self, password: &str) -> (String, String) {
let mut hasher = Sha1::new();
hasher.update(password.as_bytes());
let hash = hasher.finalize();
let hex = format!("{:X}", hash);
let prefix = hex[..5].to_string();
let suffix = hex[5..].to_string();
(prefix, suffix)
}
async fn query_api(&self, prefix: &str) -> Result<String> {
let url = format!("{}{}", self.config.api_url, prefix);
let response =
self.client.get(&url).send().await.map_err(|e| {
TidewayError::Internal(format!("Breach check request failed: {}", e))
})?;
if !response.status().is_success() {
return Err(TidewayError::Internal(format!(
"Breach check API returned status: {}",
response.status()
)));
}
response.text().await.map_err(|e| {
TidewayError::Internal(format!("Failed to read breach check response: {}", e))
})
}
fn find_in_response(&self, suffix: &str, response: &str) -> Option<u32> {
for line in response.lines() {
if let Some((hash_suffix, count_str)) = line.split_once(':') {
if hash_suffix.eq_ignore_ascii_case(suffix) {
return count_str.trim().parse().ok();
}
}
}
None
}
pub fn config(&self) -> &BreachCheckConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_password_format() {
let checker = BreachChecker::hibp();
let (prefix, suffix) = checker.hash_password("password");
assert_eq!(prefix.len(), 5);
assert_eq!(suffix.len(), 35);
assert_eq!(prefix, "5BAA6");
assert_eq!(suffix, "1E4C9B93F3F0682250B6CF8331B7EE68FD8");
}
#[test]
fn test_find_in_response() {
let checker = BreachChecker::hibp();
let response = "0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n\
1E4C9B93F3F0682250B6CF8331B7EE68FD8:9659365\r\n\
1E4FA36A26C8D85B3F1FA8C382D1C94E682:2";
let result = checker.find_in_response("1E4C9B93F3F0682250B6CF8331B7EE68FD8", response);
assert_eq!(result, Some(9659365));
let result = checker.find_in_response("NOTFOUND", response);
assert_eq!(result, None);
}
#[test]
fn test_find_in_response_case_insensitive() {
let checker = BreachChecker::hibp();
let response = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:100";
let result = checker.find_in_response("1e4c9b93f3f0682250b6cf8331b7ee68fd8", response);
assert_eq!(result, Some(100));
}
#[test]
fn test_config_builder() {
let config = BreachCheckConfig::new()
.timeout(Duration::from_secs(10))
.min_breach_count(5)
.fail_open(false);
assert_eq!(config.timeout, Duration::from_secs(10));
assert_eq!(config.min_breach_count, 5);
assert!(!config.fail_open);
}
#[test]
fn test_checker_builder() {
let checker = BreachChecker::hibp()
.with_timeout(Duration::from_secs(10))
.with_min_breach_count(5)
.with_fail_open(false);
assert_eq!(checker.config.timeout, Duration::from_secs(10));
assert_eq!(checker.config.min_breach_count, 5);
assert!(!checker.config.fail_open);
}
#[tokio::test]
#[ignore = "requires network access"]
async fn test_hibp_api_known_breached_password() {
let checker = BreachChecker::hibp();
let result = checker.check("password").await.unwrap();
assert!(result.is_some());
let count = result.unwrap();
assert!(
count > 1000,
"Expected 'password' to be in many breaches, got {}",
count
);
assert!(checker.is_breached("password").await.unwrap());
}
#[tokio::test]
#[ignore = "requires network access"]
async fn test_hibp_api_likely_unique_password() {
let checker = BreachChecker::hibp();
let unique_password = format!(
"tideway-test-{}-{}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
rand::random::<u64>(),
rand::random::<u64>()
);
let result = checker.check(&unique_password).await.unwrap();
assert!(
result.is_none(),
"Random password should not be in breach database"
);
}
#[tokio::test]
#[ignore = "requires network access"]
async fn test_validate_blocks_breached_password() {
let checker = BreachChecker::hibp();
let result = checker.validate("password123").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("data breach"));
}
#[tokio::test]
#[ignore = "requires network access"]
async fn test_min_breach_count_threshold() {
let checker = BreachChecker::hibp().with_min_breach_count(999_999_999);
let result = checker.is_breached("password").await.unwrap();
assert!(
!result,
"Should not be considered breached when below threshold"
);
}
}