use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2, PasswordHash, PasswordVerifier,
};
pub fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("argon2 hash should succeed")
.to_string()
}
pub fn verify_password(password: &str, hash: &str) -> bool {
let parsed = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
pub fn dummy_hash() -> &'static str {
"$argon2id$v=19$m=19456,t=2,p=1$YWFhYWFhYWFhYWFhYWFhYQ$b3W/3pZzm6S8w5qYvJ8y3A"
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PasswordPolicyError {
TooShort { got: usize, want: usize },
Pwned { occurrences: u64 },
}
impl std::fmt::Display for PasswordPolicyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooShort { got, want } => {
write!(f, "password too short ({got} chars, need {want})")
}
Self::Pwned { occurrences } => write!(
f,
"password appears in {occurrences} known data breaches; choose a different password"
),
}
}
}
pub const MIN_PASSWORD_LEN: usize = 10;
pub fn validate_length(password: &str) -> Result<(), PasswordPolicyError> {
let n = password.chars().count();
if n < MIN_PASSWORD_LEN {
return Err(PasswordPolicyError::TooShort {
got: n,
want: MIN_PASSWORD_LEN,
});
}
Ok(())
}
pub fn check_pwned(password: &str) -> Result<u64, String> {
let hash = sha1_hex_upper(password.as_bytes());
let (prefix, suffix) = hash.split_at(5);
let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout_read(std::time::Duration::from_secs(5))
.user_agent("pylon-auth")
.build();
let body = agent
.get(&url)
.set("Add-Padding", "true")
.call()
.map_err(|e| format!("hibp request: {e}"))?
.into_string()
.map_err(|e| format!("hibp body: {e}"))?;
Ok(parse_hibp_range(&body, suffix))
}
fn parse_hibp_range(body: &str, suffix: &str) -> u64 {
for line in body.lines() {
let line = line.trim();
let Some((s, c)) = line.split_once(':') else {
continue;
};
if s.eq_ignore_ascii_case(suffix) {
return c.trim().parse().unwrap_or(0);
}
}
0
}
fn sha1_hex_upper(input: &[u8]) -> String {
use sha1::{Digest, Sha1};
let mut h = Sha1::new();
h.update(input);
let out = h.finalize();
let mut s = String::with_capacity(40);
for b in out {
use std::fmt::Write;
let _ = write!(s, "{b:02X}");
}
s
}
pub fn validate(password: &str) -> Result<(), PasswordPolicyError> {
validate_length(password)?;
match check_pwned(password) {
Ok(0) => Ok(()),
Ok(n) => Err(PasswordPolicyError::Pwned { occurrences: n }),
Err(_) => Ok(()), }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_length_rejects_short() {
let err = validate_length("short").unwrap_err();
assert!(matches!(err, PasswordPolicyError::TooShort { .. }));
}
#[test]
fn validate_length_accepts_min_len() {
assert!(validate_length("0123456789").is_ok());
}
#[test]
fn sha1_known_vector() {
let h = sha1_hex_upper(b"password");
assert_eq!(h, "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8");
}
#[test]
fn parse_hibp_range_finds_match() {
let body = "0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n\
003D68EB55068C33ACE09247EE4C639306B:3\r\n\
012345678901234567890123456789012345:42\r\n";
assert_eq!(parse_hibp_range(body, "012345678901234567890123456789012345"), 42);
assert_eq!(parse_hibp_range(body, "0018A45C4D1DEF81644B54AB7F969B88D65"), 1);
assert_eq!(parse_hibp_range(body, "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEF"), 0);
}
#[test]
fn parse_hibp_range_is_case_insensitive() {
let body = "ABCDEF0123456789ABCDEF0123456789ABCD:7\r\n";
assert_eq!(
parse_hibp_range(body, "abcdef0123456789abcdef0123456789abcd"),
7
);
}
}