use std::fs::File;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use sha1::{Digest, Sha1};
pub const HIBP_DATA_DIR_ENV: &str = "HIBP_DATA_DIR";
pub fn dataset_path_from_env() -> PathBuf {
std::env::var(HIBP_DATA_DIR_ENV).map(PathBuf::from).unwrap_or_else(|_| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("pwndpasswords-bin")
})
}
pub const RECORD_SIZE: usize = 6;
pub const PREFIX_LEN: usize = 5;
pub const HEX_CHARS: &[u8; 16] = b"0123456789ABCDEF";
pub struct BreachChecker<'a> {
dataset_path: &'a Path,
}
impl<'a> BreachChecker<'a> {
pub fn new(dataset_path: &'a Path) -> Self {
Self { dataset_path }
}
pub fn is_breached(&self, password: &str) -> io::Result<bool> {
let mut hasher = Sha1::new();
hasher.update(password.as_bytes());
let hash: [u8; 20] = hasher.finalize().into();
let prefix_hex = Self::prefix_hex(&hash);
let mut file = self.open_file(prefix_hex)?;
let mut buf = [0u8; 16384];
let mut total = 0usize;
loop {
match file.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => {
total += n;
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
let search_key: [u8; 6] = unsafe { hash[2..8].try_into().unwrap_unchecked() };
Ok(buf[..total].as_chunks::<RECORD_SIZE>().0.binary_search(&search_key).is_ok())
}
#[doc(hidden)]
#[inline(always)]
pub fn prefix_hex(hash: &[u8; 20]) -> [u8; PREFIX_LEN] {
let mut prefix_hex = [0u8; PREFIX_LEN];
prefix_hex[0] = HEX_CHARS[(hash[0] >> 4) as usize];
prefix_hex[1] = HEX_CHARS[(hash[0] & 0x0f) as usize];
prefix_hex[2] = HEX_CHARS[(hash[1] >> 4) as usize];
prefix_hex[3] = HEX_CHARS[(hash[1] & 0x0f) as usize];
prefix_hex[4] = HEX_CHARS[(hash[2] >> 4) as usize];
prefix_hex
}
#[inline(always)]
fn build_path(&self, prefix_hex: [u8; PREFIX_LEN]) -> ([u8; 512], usize) {
let base = self.dataset_path.as_os_str().as_encoded_bytes();
let mut path_buf = [0u8; 512];
let path_len = base.len() + 1 + PREFIX_LEN + 4; path_buf[..base.len()].copy_from_slice(base);
path_buf[base.len()] = b'/';
path_buf[base.len() + 1..base.len() + 1 + PREFIX_LEN].copy_from_slice(&prefix_hex);
path_buf[base.len() + 1 + PREFIX_LEN..path_len].copy_from_slice(b".bin");
(path_buf, path_len)
}
#[doc(hidden)]
#[inline(always)]
pub fn open_file(&self, prefix_hex: [u8; PREFIX_LEN]) -> io::Result<File> {
let (path_buf, path_len) = self.build_path(prefix_hex);
let file_path = unsafe { std::str::from_utf8_unchecked(&path_buf[..path_len]) };
File::open(file_path)
}
#[cfg(feature = "tokio")]
pub async fn is_breached_async(&self, password: &str) -> io::Result<bool> {
let mut hasher = Sha1::new();
hasher.update(password.as_bytes());
let hash: [u8; 20] = hasher.finalize().into();
let search_key: [u8; 6] = unsafe { hash[2..8].try_into().unwrap_unchecked() };
let prefix_hex = Self::prefix_hex(&hash);
let (path_buf, path_len) = self.build_path(prefix_hex);
tokio::task::spawn_blocking(move || {
let file_path = unsafe { std::str::from_utf8_unchecked(&path_buf[..path_len]) };
let mut file = File::open(file_path)?;
let mut buf = [0u8; 16384];
let mut total = 0usize;
loop {
match file.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(buf[..total].as_chunks::<RECORD_SIZE>().0.binary_search(&search_key).is_ok())
})
.await
.expect("spawn_blocking task panicked")
}
#[cfg(feature = "compio")]
pub async fn is_breached_compio(&self, password: &str) -> io::Result<bool> {
use compio::fs::File;
use compio::io::AsyncReadAt;
let mut hasher = Sha1::new();
hasher.update(password.as_bytes());
let hash: [u8; 20] = hasher.finalize().into();
let search_key: [u8; 6] = unsafe { hash[2..8].try_into().unwrap_unchecked() };
let prefix_hex = Self::prefix_hex(&hash);
let (path_buf, path_len) = self.build_path(prefix_hex);
let file_path = unsafe { std::str::from_utf8_unchecked(&path_buf[..path_len]) };
let file = File::open(file_path).await?;
let mut buf = [0u8; 16384];
let mut total = 0usize;
loop {
let buf_result = file.read_at(buf, total as u64).await;
buf = buf_result.1;
match buf_result.0 {
Ok(0) => break,
Ok(n) => total += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(buf[..total].as_chunks::<RECORD_SIZE>().0.binary_search(&search_key).is_ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sha1t64_conversion() {
let mut hasher = Sha1::new();
hasher.update(b"password123");
let hash: [u8; 20] = hasher.finalize().into();
assert_eq!(hash[0], 0xCB);
assert_eq!(hash[1], 0xFD);
assert_eq!(hash[2], 0xAC);
assert_eq!(hash[3], 0x60);
assert_eq!(hash[4], 0x08);
assert_eq!(hash[5], 0xF9);
assert_eq!(hash[6], 0xCA);
assert_eq!(hash[7], 0xB4);
}
#[test]
#[ignore = "requires HIBP dataset"]
fn test_breached_password() {
let path = dataset_path_from_env();
let checker = BreachChecker::new(&path);
let result = checker.is_breached("password123").unwrap();
assert!(result, "password123 should be found in the breach database");
}
#[test]
#[ignore = "requires HIBP dataset"]
fn test_non_breached_password() {
let path = dataset_path_from_env();
let checker = BreachChecker::new(&path);
let result = checker.is_breached("hAwT?}cuC:r#kW5").unwrap();
assert!(
!result,
"random complex password should not be in the breach database"
);
}
#[test]
fn test_binary_search_sha1t48() {
let data: Vec<u8> = vec![
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, ];
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x01])
.is_ok()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x05])
.is_ok()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x10])
.is_ok()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
.is_ok()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
.is_err()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x02])
.is_err()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0xFF])
.is_err()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
.is_err()
);
}
#[test]
fn test_empty_data() {
let data: Vec<u8> = vec![];
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x01])
.is_err()
);
}
#[test]
fn test_single_record() {
let data: Vec<u8> = vec![0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0];
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0])
.is_ok()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
.is_err()
);
assert!(
data.as_chunks::<RECORD_SIZE>()
.0
.binary_search(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
.is_err()
);
}
}
#[cfg(all(test, feature = "tokio"))]
mod tokio_tests {
use super::*;
#[tokio::test]
#[ignore = "requires HIBP dataset"]
async fn test_async_breached_password() {
let path = dataset_path_from_env();
let checker = BreachChecker::new(&path);
let result = checker.is_breached_async("password123").await.unwrap();
assert!(result, "password123 should be found in breach database");
}
#[tokio::test]
#[ignore = "requires HIBP dataset"]
async fn test_async_non_breached_password() {
let path = dataset_path_from_env();
let checker = BreachChecker::new(&path);
let result = checker.is_breached_async("hAwT?}cuC:r#kW5").await.unwrap();
assert!(!result, "random password should not be in breach database");
}
#[tokio::test]
#[ignore = "requires HIBP dataset"]
async fn test_async_matches_sync() {
let path = dataset_path_from_env();
let checker = BreachChecker::new(&path);
let passwords = [
"password123",
"123456",
"qwerty",
"hAwT?}cuC:r#kW5",
"letmein",
"xK9#mP2$vL7@nQ4",
];
for password in passwords {
let sync_result = checker.is_breached(password).unwrap();
let async_result = checker.is_breached_async(password).await.unwrap();
assert_eq!(
sync_result, async_result,
"sync and async results should match for '{}'",
password
);
}
}
}
#[cfg(all(test, feature = "compio"))]
mod compio_tests {
use compio::runtime as compio_runtime;
use super::*;
#[test]
#[ignore = "requires HIBP dataset"]
fn test_compio_breached_password() {
let path = dataset_path_from_env();
compio_runtime::Runtime::new().unwrap().block_on(async {
let checker = BreachChecker::new(&path);
let result = checker.is_breached_compio("password123").await.unwrap();
assert!(result, "password123 should be found in breach database");
});
}
#[test]
#[ignore = "requires HIBP dataset"]
fn test_compio_non_breached_password() {
let path = dataset_path_from_env();
compio_runtime::Runtime::new().unwrap().block_on(async {
let checker = BreachChecker::new(&path);
let result = checker.is_breached_compio("hAwT?}cuC:r#kW5").await.unwrap();
assert!(!result, "random password should not be in breach database");
});
}
#[test]
#[ignore = "requires HIBP dataset"]
fn test_compio_matches_sync() {
let path = dataset_path_from_env();
compio_runtime::Runtime::new().unwrap().block_on(async {
let checker = BreachChecker::new(&path);
let passwords = [
"password123",
"123456",
"qwerty",
"hAwT?}cuC:r#kW5",
"letmein",
"xK9#mP2$vL7@nQ4",
];
for password in passwords {
let sync_result = checker.is_breached(password).unwrap();
let compio_result = checker.is_breached_compio(password).await.unwrap();
assert_eq!(
sync_result, compio_result,
"sync and compio results should match for '{}'",
password
);
}
});
}
}