use argon2::password_hash::{
PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString,
};
use argon2::{Algorithm, Argon2, Params, Version};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::RwLock;
pub const DEFAULT_MEMORY_KIB: u32 = 19_456;
pub const DEFAULT_TIME_COST: u32 = 2;
pub const DEFAULT_PARALLELISM: u32 = 1;
pub const DEFAULT_OUTPUT_LEN: usize = 32;
pub fn default_params() -> Params {
Params::new(
DEFAULT_MEMORY_KIB,
DEFAULT_TIME_COST,
DEFAULT_PARALLELISM,
Some(DEFAULT_OUTPUT_LEN),
)
.expect("OWASP-recommended params are valid")
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PasswordError {
#[error("hash failed: {0}")]
Hash(String),
#[error("malformed PHC string: {0}")]
Malformed(String),
#[error("password did not match stored hash")]
WrongPassword,
#[error("verify failed: {0}")]
Verify(String),
#[error("password appears in {count} known breach records")]
Breached { count: u64 },
#[error("breached-password checker backend: {0}")]
Checker(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifyOutcome {
pub needs_rehash: bool,
}
pub struct PasswordHasher {
params: Params,
}
impl PasswordHasher {
pub fn new(params: Params) -> Self {
Self { params }
}
pub fn with_default_params() -> Self {
Self::new(default_params())
}
pub fn params(&self) -> &Params {
&self.params
}
pub fn hash(&self, password: &[u8]) -> Result<String, PasswordError> {
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, self.params.clone());
let mut salt_bytes = [0u8; 16];
getrandom::fill(&mut salt_bytes)
.map_err(|e| PasswordError::Hash(format!("getrandom: {e}")))?;
let salt = SaltString::encode_b64(&salt_bytes)
.map_err(|e| PasswordError::Hash(e.to_string()))?;
let hash = argon
.hash_password(password, &salt)
.map_err(|e| PasswordError::Hash(e.to_string()))?;
Ok(hash.to_string())
}
pub fn verify(&self, password: &[u8], encoded: &str) -> Result<VerifyOutcome, PasswordError> {
let parsed = PasswordHash::new(encoded)
.map_err(|e| PasswordError::Malformed(e.to_string()))?;
let argon = Argon2::default();
match argon.verify_password(password, &parsed) {
Ok(()) => {
let needs_rehash = match Params::try_from(&parsed) {
Ok(stored) => stored != self.params,
Err(_) => true,
};
Ok(VerifyOutcome { needs_rehash })
}
Err(argon2::password_hash::Error::Password) => Err(PasswordError::WrongPassword),
Err(e) => Err(PasswordError::Verify(e.to_string())),
}
}
}
#[async_trait]
pub trait BreachedPasswordChecker: Send + Sync {
async fn check(&self, password: &str) -> Result<Option<u64>, String>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopBreachedPasswordChecker;
#[async_trait]
impl BreachedPasswordChecker for NoopBreachedPasswordChecker {
async fn check(&self, _password: &str) -> Result<Option<u64>, String> {
Ok(None)
}
}
#[derive(Default)]
pub struct MapBreachedPasswordChecker {
inner: RwLock<HashMap<String, u64>>,
}
impl MapBreachedPasswordChecker {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, password: impl Into<String>, count: u64) {
self.inner.write().unwrap().insert(password.into(), count);
}
pub fn remove(&self, password: &str) -> Option<u64> {
self.inner.write().unwrap().remove(password)
}
pub fn len(&self) -> usize {
self.inner.read().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.inner.read().unwrap().is_empty()
}
}
#[async_trait]
impl BreachedPasswordChecker for MapBreachedPasswordChecker {
async fn check(&self, password: &str) -> Result<Option<u64>, String> {
Ok(self.inner.read().unwrap().get(password).copied())
}
}
pub struct PasswordPolicy<C> {
hasher: PasswordHasher,
checker: C,
}
impl<C> PasswordPolicy<C> {
pub fn new(hasher: PasswordHasher, checker: C) -> Self {
Self { hasher, checker }
}
pub fn hasher(&self) -> &PasswordHasher {
&self.hasher
}
pub fn checker(&self) -> &C {
&self.checker
}
}
impl<C: BreachedPasswordChecker> PasswordPolicy<C> {
pub async fn hash_new_password(&self, password: &str) -> Result<String, PasswordError> {
match self.checker.check(password).await {
Ok(None) => self.hasher.hash(password.as_bytes()),
Ok(Some(count)) => Err(PasswordError::Breached { count }),
Err(e) => Err(PasswordError::Checker(e)),
}
}
pub fn verify(&self, password: &str, encoded: &str) -> Result<VerifyOutcome, PasswordError> {
self.hasher.verify(password.as_bytes(), encoded)
}
}
#[cfg(feature = "password-hibp")]
pub mod hibp {
use super::{BreachedPasswordChecker, async_trait};
use sha1::{Digest, Sha1};
use std::fmt::Write as _;
pub const HIBP_DEFAULT_BASE_URL: &str = "https://api.pwnedpasswords.com/range";
pub fn sha1_hex_upper(password: &str) -> String {
let bytes = Sha1::digest(password.as_bytes());
let mut out = String::with_capacity(40);
for b in bytes {
let _ = write!(&mut out, "{:02X}", b);
}
out
}
pub struct HibpClient {
base_url: String,
http: reqwest::Client,
}
impl HibpClient {
pub fn new() -> Self {
Self::with_base_url(HIBP_DEFAULT_BASE_URL)
}
pub fn with_base_url(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
http: reqwest::Client::new(),
}
}
pub fn with_client(base_url: impl Into<String>, http: reqwest::Client) -> Self {
Self {
base_url: base_url.into(),
http,
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
impl Default for HibpClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl BreachedPasswordChecker for HibpClient {
async fn check(&self, password: &str) -> Result<Option<u64>, String> {
let hex = sha1_hex_upper(password);
let (prefix, suffix) = hex.split_at(5);
let url = format!("{}/{prefix}", self.base_url);
let resp = self
.http
.get(&url)
.header("Add-Padding", "true")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HIBP API returned status {}", resp.status()));
}
let body = resp.text().await.map_err(|e| e.to_string())?;
Ok(scan_hibp_body(&body, suffix))
}
}
pub fn scan_hibp_body(body: &str, suffix: &str) -> Option<u64> {
for line in body.lines() {
let line = line.trim_end_matches('\r');
let mut parts = line.splitn(2, ':');
let s = parts.next().unwrap_or("").trim();
let c = parts.next().unwrap_or("").trim();
if s.eq_ignore_ascii_case(suffix) {
let count: u64 = c.parse().unwrap_or(1);
return Some(count);
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cheap_params() -> Params {
Params::new(8, 1, 1, Some(16)).expect("cheap params are legal")
}
fn cheap_hasher() -> PasswordHasher {
PasswordHasher::new(cheap_params())
}
#[test]
fn hash_round_trip_succeeds() {
let h = cheap_hasher();
let phc = h.hash(b"correct horse battery staple").unwrap();
assert!(phc.starts_with("$argon2id$"));
let outcome = h.verify(b"correct horse battery staple", &phc).unwrap();
assert!(!outcome.needs_rehash);
}
#[test]
fn hash_produces_unique_salt_per_call() {
let h = cheap_hasher();
let a = h.hash(b"hunter2").unwrap();
let b = h.hash(b"hunter2").unwrap();
assert_ne!(a, b, "same password must hash to different PHC strings");
}
#[test]
fn verify_rejects_wrong_password() {
let h = cheap_hasher();
let phc = h.hash(b"swordfish").unwrap();
let err = h.verify(b"not-swordfish", &phc).unwrap_err();
assert!(matches!(err, PasswordError::WrongPassword));
}
#[test]
fn verify_rejects_malformed_phc() {
let h = cheap_hasher();
let err = h.verify(b"hunter2", "not-a-phc-string").unwrap_err();
assert!(matches!(err, PasswordError::Malformed(_)));
}
#[test]
fn verify_flags_needs_rehash_when_params_change() {
let weak = PasswordHasher::new(Params::new(8, 1, 1, Some(16)).unwrap());
let strong = PasswordHasher::new(Params::new(16, 2, 1, Some(16)).unwrap());
let phc = weak.hash(b"hunter2").unwrap();
let outcome = weak.verify(b"hunter2", &phc).unwrap();
assert!(!outcome.needs_rehash);
let outcome = strong.verify(b"hunter2", &phc).unwrap();
assert!(outcome.needs_rehash);
}
#[test]
fn default_params_match_owasp_2026_recommendation() {
let p = default_params();
assert_eq!(p.m_cost(), DEFAULT_MEMORY_KIB);
assert_eq!(p.t_cost(), DEFAULT_TIME_COST);
assert_eq!(p.p_cost(), DEFAULT_PARALLELISM);
assert_eq!(p.output_len(), Some(DEFAULT_OUTPUT_LEN));
}
const KNOWN_GOOD_PHC: &str =
"$argon2id$v=19$m=8,t=1,p=1$ZGV0ZXJtaW5pc3RpY19zYQ$per6Zx3YUNyNqWuJszwazg";
const KNOWN_GOOD_PASSWORD: &str = "correct horse battery staple";
#[test]
fn verify_known_good_hash_succeeds() {
let h = cheap_hasher();
let outcome = h.verify(KNOWN_GOOD_PASSWORD.as_bytes(), KNOWN_GOOD_PHC).unwrap();
assert!(!outcome.needs_rehash);
}
#[test]
fn verify_known_good_hash_rejects_wrong_password() {
let h = cheap_hasher();
let err = h.verify(b"wrong password", KNOWN_GOOD_PHC).unwrap_err();
assert!(matches!(err, PasswordError::WrongPassword));
}
#[test]
fn noop_checker_returns_none() {
pollster::block_on(async {
let c = NoopBreachedPasswordChecker;
assert_eq!(c.check("anything").await.unwrap(), None);
});
}
#[test]
fn map_checker_round_trip() {
pollster::block_on(async {
let m = MapBreachedPasswordChecker::new();
assert!(m.is_empty());
m.insert("hunter2", 12_345);
m.insert("password", 9_999_999);
assert_eq!(m.len(), 2);
assert_eq!(m.check("hunter2").await.unwrap(), Some(12_345));
assert_eq!(m.check("password").await.unwrap(), Some(9_999_999));
assert_eq!(m.check("unseen-password").await.unwrap(), None);
assert_eq!(m.remove("hunter2"), Some(12_345));
assert_eq!(m.check("hunter2").await.unwrap(), None);
});
}
fn policy_with(map: MapBreachedPasswordChecker) -> PasswordPolicy<MapBreachedPasswordChecker> {
PasswordPolicy::new(cheap_hasher(), map)
}
#[test]
fn policy_rejects_breached_password_before_hashing() {
pollster::block_on(async {
let map = MapBreachedPasswordChecker::new();
map.insert("hunter2", 100);
let policy = policy_with(map);
let err = policy.hash_new_password("hunter2").await.unwrap_err();
assert!(matches!(err, PasswordError::Breached { count: 100 }));
});
}
#[test]
fn policy_hashes_fresh_password() {
pollster::block_on(async {
let policy = policy_with(MapBreachedPasswordChecker::new());
let phc = policy
.hash_new_password("a-novel-passphrase-2026")
.await
.unwrap();
assert!(phc.starts_with("$argon2id$"));
let outcome = policy.verify("a-novel-passphrase-2026", &phc).unwrap();
assert!(!outcome.needs_rehash);
});
}
#[test]
fn policy_propagates_checker_backend_failure() {
pollster::block_on(async {
struct Boom;
#[async_trait]
impl BreachedPasswordChecker for Boom {
async fn check(&self, _: &str) -> Result<Option<u64>, String> {
Err("transport down".into())
}
}
let policy = PasswordPolicy::new(cheap_hasher(), Boom);
let err = policy.hash_new_password("anything").await.unwrap_err();
match err {
PasswordError::Checker(msg) => assert!(msg.contains("transport down")),
other => panic!("expected Checker, got {other:?}"),
}
});
}
#[test]
fn policy_verify_does_not_invoke_checker() {
struct PanicChecker;
#[async_trait]
impl BreachedPasswordChecker for PanicChecker {
async fn check(&self, _: &str) -> Result<Option<u64>, String> {
panic!("verify path must not invoke the breach checker");
}
}
let phc = cheap_hasher().hash(b"hunter2").unwrap();
let policy = PasswordPolicy::new(cheap_hasher(), PanicChecker);
let outcome = policy.verify("hunter2", &phc).unwrap();
assert!(!outcome.needs_rehash);
}
#[cfg(feature = "password-hibp")]
mod hibp_tests {
use super::super::hibp::{scan_hibp_body, sha1_hex_upper};
#[test]
fn sha1_hex_upper_known_value() {
assert_eq!(
sha1_hex_upper("password"),
"5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8"
);
}
#[test]
fn sha1_prefix_is_always_5_chars() {
for pw in ["", "a", "hunter2", "correct horse battery staple"] {
let hex = sha1_hex_upper(pw);
assert_eq!(hex.len(), 40, "sha1 hex must be 40 chars for {pw:?}");
let (prefix, suffix) = hex.split_at(5);
assert_eq!(prefix.len(), 5);
assert_eq!(suffix.len(), 35);
}
}
#[test]
fn scan_body_returns_count_for_matching_suffix() {
let body = "AAAAA:3\r\n\
BBBBB:42\r\n\
61E4C9B93F3F0682250B6CF8331B7EE68FD8:9659365\r\n\
ZZZZZ:1\r\n";
let suffix = "61E4C9B93F3F0682250B6CF8331B7EE68FD8";
assert_eq!(scan_hibp_body(body, suffix), Some(9_659_365));
}
#[test]
fn scan_body_returns_none_when_suffix_absent() {
let body = "AAAAA:3\r\nBBBBB:42\r\n";
assert_eq!(scan_hibp_body(body, "DEADBEEF"), None);
}
#[test]
fn scan_body_is_case_insensitive_on_suffix() {
let body = "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8:5\r\n";
assert_eq!(
scan_hibp_body(body, "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8"),
Some(5)
);
}
#[test]
fn scan_body_handles_missing_count_as_one() {
let body = "ABCDE:not-a-number\r\n";
assert_eq!(scan_hibp_body(body, "ABCDE"), Some(1));
}
}
}