use crate::errors::{CoreError, CoreResult};
use sha1::{Digest, Sha1};
use std::time::Duration;
use sui_id_store::models::HibpMode;
use zeroize::Zeroize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HibpCheckOutcome {
NotBreached,
Breached { count: u64 },
Unavailable,
}
#[async_trait::async_trait]
pub trait HibpClient: Send + Sync {
async fn check(&self, password: &str) -> HibpCheckOutcome;
}
pub struct HttpHibpClient {
client: reqwest::Client,
endpoint: String,
user_agent: String,
timeout: Duration,
}
impl HttpHibpClient {
pub fn new() -> Self {
let timeout = Duration::from_secs(5);
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("failed to build reqwest client for HIBP");
Self {
client,
endpoint: "https://api.pwnedpasswords.com/range".to_owned(),
user_agent: format!("sui-id/{}", env!("CARGO_PKG_VERSION")),
timeout,
}
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.client = reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("failed to build reqwest client for HIBP");
self
}
}
impl Default for HttpHibpClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl HibpClient for HttpHibpClient {
async fn check(&self, password: &str) -> HibpCheckOutcome {
let mut hasher = Sha1::new();
hasher.update(password.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(40);
for b in digest.iter() {
use std::fmt::Write;
let _ = write!(&mut hex, "{:02X}", b);
}
let (prefix, suffix) = hex.split_at(5);
let url = format!("{}/{}", self.endpoint, prefix);
let result = self.client
.get(&url)
.header("User-Agent", &self.user_agent)
.header("Add-Padding", "true")
.send()
.await;
let body = match result {
Ok(resp) => match resp.text().await {
Ok(s) => s,
Err(_) => {
hex.zeroize();
return HibpCheckOutcome::Unavailable;
}
},
Err(_) => {
hex.zeroize();
return HibpCheckOutcome::Unavailable;
}
};
let outcome = parse_response(&body, suffix);
hex.zeroize();
outcome
}
}
pub fn parse_response(body: &str, suffix: &str) -> HibpCheckOutcome {
let target = suffix.to_ascii_uppercase();
for line in body.lines() {
let (s, c) = match line.split_once(':') {
Some(pair) => pair,
None => continue,
};
if !s.eq_ignore_ascii_case(&target) {
continue;
}
let count: u64 = c.trim().parse().unwrap_or(0);
if count == 0 {
return HibpCheckOutcome::NotBreached;
}
return HibpCheckOutcome::Breached { count };
}
HibpCheckOutcome::NotBreached
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HibpEnforcement {
Allowed,
AllowedWithWarning { count: u64 },
Blocked { count: u64 },
}
pub async fn enforce_hibp(
mode: HibpMode,
client: Option<&dyn HibpClient>,
password: &str,
) -> HibpEnforcement {
if matches!(mode, HibpMode::Off) {
return HibpEnforcement::Allowed;
}
let Some(client) = client else {
return HibpEnforcement::Allowed;
};
match client.check(password).await {
HibpCheckOutcome::NotBreached | HibpCheckOutcome::Unavailable => {
HibpEnforcement::Allowed
}
HibpCheckOutcome::Breached { count } => match mode {
HibpMode::Off => HibpEnforcement::Allowed, HibpMode::Warn => HibpEnforcement::AllowedWithWarning { count },
HibpMode::Block => HibpEnforcement::Blocked { count },
},
}
}
pub async fn enforce_hibp_or_reject(
mode: HibpMode,
client: Option<&dyn HibpClient>,
password: &str,
) -> CoreResult<HibpEnforcement> {
let result = enforce_hibp(mode, client, password).await;
match result {
HibpEnforcement::Blocked { .. } => Err(CoreError::BadRequest(
"このパスワードは過去のデータ漏洩で確認されています。別のものを選んでください。"
.to_owned(),
)),
other => Ok(other),
}
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
pub struct InMemoryHibpClient {
plan: Mutex<HashMap<String, BreachCount>>,
}
enum BreachCount {
Count(u64),
Unavailable,
}
impl Default for InMemoryHibpClient {
fn default() -> Self {
Self {
plan: Mutex::new(HashMap::new()),
}
}
}
impl InMemoryHibpClient {
pub fn new() -> Self {
Self::default()
}
pub fn set_breached(&self, password: impl Into<String>, count: u64) {
self.plan
.lock()
.expect("hibp plan mutex")
.insert(password.into(), BreachCount::Count(count));
}
pub fn set_unavailable(&self, password: impl Into<String>) {
self.plan
.lock()
.expect("hibp plan mutex")
.insert(password.into(), BreachCount::Unavailable);
}
}
#[async_trait::async_trait]
impl HibpClient for InMemoryHibpClient {
async fn check(&self, password: &str) -> HibpCheckOutcome {
match self
.plan
.lock()
.expect("hibp plan mutex")
.get(password)
{
Some(BreachCount::Count(c)) => HibpCheckOutcome::Breached { count: *c },
Some(BreachCount::Unavailable) => HibpCheckOutcome::Unavailable,
None => HibpCheckOutcome::NotBreached,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct StubBreached(u64);
#[async_trait::async_trait]
impl HibpClient for StubBreached {
async fn check(&self, _password: &str) -> HibpCheckOutcome {
HibpCheckOutcome::Breached { count: self.0 }
}
}
struct StubClean;
#[async_trait::async_trait]
impl HibpClient for StubClean {
async fn check(&self, _password: &str) -> HibpCheckOutcome {
HibpCheckOutcome::NotBreached
}
}
struct StubUnavailable;
#[async_trait::async_trait]
impl HibpClient for StubUnavailable {
async fn check(&self, _password: &str) -> HibpCheckOutcome {
HibpCheckOutcome::Unavailable
}
}
#[tokio::test]
async fn parse_response_finds_match() {
let body = "0001E1559DBC1641BCFD3A30E18AAB52CDA:1\r\n\
2DC183F740EE76F27B78EB39C8AD972A757:42\r\n\
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:99\r\n";
let result = parse_response(body, "2DC183F740EE76F27B78EB39C8AD972A757");
assert_eq!(result, HibpCheckOutcome::Breached { count: 42 });
}
#[tokio::test]
async fn parse_response_returns_not_breached_on_no_match() {
let body = "0001E1559DBC1641BCFD3A30E18AAB52CDA:1\r\n\
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:99\r\n";
let result = parse_response(body, "2DC183F740EE76F27B78EB39C8AD972A757");
assert_eq!(result, HibpCheckOutcome::NotBreached);
}
#[tokio::test]
async fn parse_response_treats_zero_count_as_padding() {
let body = "2DC183F740EE76F27B78EB39C8AD972A757:0\r\n";
let result = parse_response(body, "2DC183F740EE76F27B78EB39C8AD972A757");
assert_eq!(result, HibpCheckOutcome::NotBreached);
}
#[tokio::test]
async fn parse_response_is_case_insensitive_on_suffix() {
let body = "2dc183f740ee76f27b78eb39c8ad972a757:5\r\n";
let result = parse_response(body, "2DC183F740EE76F27B78EB39C8AD972A757");
assert_eq!(result, HibpCheckOutcome::Breached { count: 5 });
}
#[tokio::test]
async fn enforce_off_skips_check_entirely() {
let stub = StubBreached(99);
let result = enforce_hibp(HibpMode::Off, Some(&stub), "anything").await;
assert_eq!(result, HibpEnforcement::Allowed);
}
#[tokio::test]
async fn enforce_warn_lets_breached_through_with_count() {
let stub = StubBreached(42);
let result = enforce_hibp(HibpMode::Warn, Some(&stub), "p").await;
assert_eq!(result, HibpEnforcement::AllowedWithWarning { count: 42 });
}
#[tokio::test]
async fn enforce_block_refuses_breached() {
let stub = StubBreached(42);
let result = enforce_hibp(HibpMode::Block, Some(&stub), "p").await;
assert_eq!(result, HibpEnforcement::Blocked { count: 42 });
}
#[tokio::test]
async fn enforce_warn_lets_clean_through() {
let stub = StubClean;
let result = enforce_hibp(HibpMode::Warn, Some(&stub), "p").await;
assert_eq!(result, HibpEnforcement::Allowed);
}
#[tokio::test]
async fn enforce_block_lets_clean_through() {
let stub = StubClean;
let result = enforce_hibp(HibpMode::Block, Some(&stub), "p").await;
assert_eq!(result, HibpEnforcement::Allowed);
}
#[tokio::test]
async fn enforce_warn_fail_open_when_unavailable() {
let stub = StubUnavailable;
let result = enforce_hibp(HibpMode::Warn, Some(&stub), "p").await;
assert_eq!(result, HibpEnforcement::Allowed);
}
#[tokio::test]
async fn enforce_block_fail_open_when_unavailable() {
let stub = StubUnavailable;
let result = enforce_hibp(HibpMode::Block, Some(&stub), "p").await;
assert_eq!(result, HibpEnforcement::Allowed);
}
#[tokio::test]
async fn enforce_hibp_or_reject_returns_bad_request_on_block() {
let stub = StubBreached(7);
let err = enforce_hibp_or_reject(HibpMode::Block, Some(&stub), "p").await
.expect_err("should reject");
match err {
CoreError::BadRequest(_) => {}
other => panic!("expected BadRequest, got {:?}", other),
}
}
}