use core::fmt;
use crate::crypto::Sha1;
use crate::crypto::constant_time::constant_time_eq;
use crate::encoding::hex_encode_upper;
#[must_use]
pub fn hibp_sha1_hex(password: &str) -> String {
let digest = Sha1::digest(password.as_bytes());
hex_encode_upper(&digest)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HibpPrefix {
hash: String,
}
impl HibpPrefix {
#[must_use]
pub fn from_password(password: &str) -> Self {
Self {
hash: hibp_sha1_hex(password),
}
}
#[must_use]
#[inline]
pub fn as_str(&self) -> &str {
&self.hash[..5]
}
#[must_use]
#[inline]
pub fn suffix(&self) -> &str {
&self.hash[5..]
}
#[must_use]
#[inline]
pub fn full_hash(&self) -> &str {
&self.hash
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BreachResult {
NotBreached,
Breached {
count: u64,
},
}
impl BreachResult {
#[must_use]
#[inline]
pub fn is_breached(&self) -> bool {
matches!(self, Self::Breached { .. })
}
#[must_use]
#[inline]
pub fn count(&self) -> u64 {
match self {
Self::NotBreached => 0,
Self::Breached { count } => *count,
}
}
}
#[derive(Debug, Clone)]
pub struct HibpResponse {
entries: Vec<(String, u64)>,
}
impl HibpResponse {
pub fn parse(body: &str) -> Result<Self, HibpError> {
let mut entries = Vec::new();
for line in body.split('\n') {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some((suffix, count_str)) = line.split_once(':') else {
return Err(HibpError::new(HibpErrorKind::MalformedLine));
};
if suffix.len() != 35 {
return Err(HibpError::new(HibpErrorKind::InvalidSuffix));
}
if !suffix
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_lowercase())
{
return Err(HibpError::new(HibpErrorKind::InvalidSuffix));
}
let count: u64 = count_str
.parse()
.map_err(|_| HibpError::new(HibpErrorKind::InvalidCount))?;
entries.push((suffix.to_owned(), count));
}
if entries.is_empty() {
return Err(HibpError::new(HibpErrorKind::EmptyResponse));
}
Ok(Self { entries })
}
#[must_use]
pub fn check_password(&self, password: &str) -> BreachResult {
let prefix = HibpPrefix::from_password(password);
self.check_prefix(&prefix)
}
#[must_use]
pub fn check_prefix(&self, prefix: &HibpPrefix) -> BreachResult {
let suffix = prefix.suffix().as_bytes();
let mut count = 0u64;
let mut found = false;
for (entry_suffix, entry_count) in &self.entries {
if constant_time_eq(entry_suffix.as_bytes(), suffix) {
count = *entry_count;
found = true;
}
}
if found {
BreachResult::Breached { count }
} else {
BreachResult::NotBreached
}
}
#[must_use]
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum HibpErrorKind {
MalformedLine,
InvalidSuffix,
InvalidCount,
EmptyResponse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HibpError {
kind: HibpErrorKind,
}
impl HibpError {
const fn new(kind: HibpErrorKind) -> Self {
Self { kind }
}
}
impl fmt::Display for HibpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
HibpErrorKind::MalformedLine => f.write_str("hibp: malformed response line"),
HibpErrorKind::InvalidSuffix => f.write_str("hibp: invalid hash suffix"),
HibpErrorKind::InvalidCount => f.write_str("hibp: invalid breach count"),
HibpErrorKind::EmptyResponse => f.write_str("hibp: empty response body"),
}
}
}
impl std::error::Error for HibpError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha1_hex_of_password() {
assert_eq!(
hibp_sha1_hex("password"),
"5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8",
);
}
#[test]
fn prefix_from_password() {
let prefix = HibpPrefix::from_password("password");
assert_eq!(prefix.as_str(), "5BAA6");
}
#[test]
fn prefix_suffix_from_password() {
let prefix = HibpPrefix::from_password("password");
assert_eq!(prefix.suffix(), "1E4C9B93F3F0682250B6CF8331B7EE68FD8");
}
#[test]
fn prefix_full_hash_from_password() {
let prefix = HibpPrefix::from_password("password");
assert_eq!(
prefix.full_hash(),
"5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8",
);
}
#[test]
fn parse_valid_response_lf() {
let body = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493\n\
0018A45C4D1DEF81644B54AB7F969B88D65:1\n";
let resp = HibpResponse::parse(body).unwrap();
assert_eq!(resp.len(), 2);
assert!(!resp.is_empty());
}
#[test]
fn parse_valid_response_crlf() {
let body = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493\r\n\
0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n";
let resp = HibpResponse::parse(body).unwrap();
assert_eq!(resp.len(), 2);
}
#[test]
fn check_breached_password() {
let body = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493\n\
0018A45C4D1DEF81644B54AB7F969B88D65:1\n";
let resp = HibpResponse::parse(body).unwrap();
let result = resp.check_password("password");
assert!(result.is_breached());
assert_eq!(result.count(), 3_861_493);
}
#[test]
fn check_non_breached_password() {
let body = "0018A45C4D1DEF81644B54AB7F969B88D65:1\n\
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1:5\n";
let resp = HibpResponse::parse(body).unwrap();
let result = resp.check_password("password");
assert!(!result.is_breached());
assert_eq!(result.count(), 0);
}
#[test]
fn check_prefix_breached() {
let body = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493\n";
let resp = HibpResponse::parse(body).unwrap();
let prefix = HibpPrefix::from_password("password");
let result = resp.check_prefix(&prefix);
assert!(result.is_breached());
assert_eq!(result.count(), 3_861_493);
}
#[test]
fn parse_rejects_empty_body() {
let err = HibpResponse::parse("").unwrap_err();
assert_eq!(err, HibpError::new(HibpErrorKind::EmptyResponse));
}
#[test]
fn parse_rejects_whitespace_only() {
let err = HibpResponse::parse(" \n \n").unwrap_err();
assert_eq!(err, HibpError::new(HibpErrorKind::EmptyResponse));
}
#[test]
fn parse_rejects_malformed_line_no_colon() {
let err = HibpResponse::parse("1E4C9B93F3F0682250B6CF8331B7EE68FD8\n").unwrap_err();
assert_eq!(err, HibpError::new(HibpErrorKind::MalformedLine));
}
#[test]
fn parse_rejects_wrong_suffix_length() {
let err = HibpResponse::parse("1E4C9B93F3F0682250B6CF8331B7EE68FD:1\n").unwrap_err();
assert_eq!(err, HibpError::new(HibpErrorKind::InvalidSuffix));
}
#[test]
fn parse_rejects_lowercase_suffix() {
let err = HibpResponse::parse("1e4c9b93f3f0682250b6cf8331b7ee68fd8:1\n").unwrap_err();
assert_eq!(err, HibpError::new(HibpErrorKind::InvalidSuffix));
}
#[test]
fn parse_rejects_invalid_count() {
let err = HibpResponse::parse("1E4C9B93F3F0682250B6CF8331B7EE68FD8:abc\n").unwrap_err();
assert_eq!(err, HibpError::new(HibpErrorKind::InvalidCount));
}
#[test]
fn error_display_messages_start_with_hibp() {
let cases = [
(
HibpErrorKind::MalformedLine,
"hibp: malformed response line",
),
(HibpErrorKind::InvalidSuffix, "hibp: invalid hash suffix"),
(HibpErrorKind::InvalidCount, "hibp: invalid breach count"),
(HibpErrorKind::EmptyResponse, "hibp: empty response body"),
];
for (kind, expected) in &cases {
let err = HibpError::new(kind.clone());
let msg = err.to_string();
assert!(
msg.starts_with("hibp:"),
"expected Display to start with 'hibp:', got: {msg}",
);
assert_eq!(&msg, expected);
}
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> =
Box::new(HibpError::new(HibpErrorKind::MalformedLine));
assert!(err.source().is_none());
let _ = err.to_string();
}
#[test]
fn breach_result_not_breached() {
let r = BreachResult::NotBreached;
assert!(!r.is_breached());
assert_eq!(r.count(), 0);
}
#[test]
fn breach_result_breached() {
let r = BreachResult::Breached { count: 42 };
assert!(r.is_breached());
assert_eq!(r.count(), 42);
}
}