use std::collections::HashSet;
use std::sync::LazyLock;
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use thiserror::Error;
use crate::error::AuthError;
pub const MIN_PASSWORD_LEN: usize = 12;
pub const MAX_PASSWORD_LEN: usize = 128;
const MIN_DISTINCT_CHARS: usize = 5;
const MIN_PERSONAL_FRAGMENT_LEN: usize = 3;
static COMMON_PASSWORDS: LazyLock<HashSet<String>> = LazyLock::new(|| {
include_str!("common_passwords.txt")
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_lowercase)
.collect()
});
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PasswordPolicyError {
#[error("password must be at least {min} characters")]
TooShort {
min: usize,
},
#[error("password must be at most {max} characters")]
TooLong {
max: usize,
},
#[error("password must not contain your email address or username")]
ContainsPersonalInfo,
#[error("password is too common, choose a less predictable one")]
TooCommon,
#[error("password is too repetitive, use more varied characters")]
TooRepetitive,
}
pub fn hash(password: &str) -> Result<String, AuthError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|_| AuthError::PasswordHash)?;
Ok(hash.to_string())
}
pub fn verify(password: &str, hash: &str) -> Result<bool, AuthError> {
let parsed_hash = PasswordHash::new(hash).map_err(|_| AuthError::PasswordHash)?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
pub fn check_strength(password: &str, personal: &[&str]) -> Result<(), PasswordPolicyError> {
let len = password.chars().count();
if len < MIN_PASSWORD_LEN {
return Err(PasswordPolicyError::TooShort {
min: MIN_PASSWORD_LEN,
});
}
if len > MAX_PASSWORD_LEN {
return Err(PasswordPolicyError::TooLong {
max: MAX_PASSWORD_LEN,
});
}
let lowered = password.to_lowercase();
if personal_fragments(personal).any(|fragment| lowered.contains(&fragment)) {
return Err(PasswordPolicyError::ContainsPersonalInfo);
}
let core = lowered.trim_matches(|c: char| !c.is_alphabetic());
if COMMON_PASSWORDS.contains(&lowered) || COMMON_PASSWORDS.contains(core) {
return Err(PasswordPolicyError::TooCommon);
}
let distinct: HashSet<char> = lowered.chars().collect();
if distinct.len() < MIN_DISTINCT_CHARS || is_repeated_pattern(&lowered) {
return Err(PasswordPolicyError::TooRepetitive);
}
Ok(())
}
fn personal_fragments<'a>(personal: &'a [&'a str]) -> impl Iterator<Item = String> + 'a {
personal
.iter()
.flat_map(|entry| {
let entry = entry.trim();
let local = entry.split_once('@').map(|(local, _)| local);
[Some(entry), local]
})
.flatten()
.filter(|fragment| fragment.chars().count() >= MIN_PERSONAL_FRAGMENT_LEN)
.map(str::to_lowercase)
}
fn is_repeated_pattern(s: &str) -> bool {
let chars: Vec<char> = s.chars().collect();
(1..=chars.len() / 2).any(|period| {
chars.len().is_multiple_of(period)
&& chars.chunks(period).all(|chunk| chunk == &chars[..period])
})
}
#[cfg(test)]
mod tests {
use super::*;
const PERSONAL: [&str; 2] = ["alice@ironflow.dev", "alice"];
#[test]
fn strong_password_is_accepted() {
assert_eq!(
check_strength("correct horse battery staple", &PERSONAL),
Ok(())
);
assert_eq!(check_strength("Tq9!vR2#mZ7$", &PERSONAL), Ok(()));
}
#[test]
fn eleven_characters_is_too_short() {
assert_eq!(
check_strength("Tq9!vR2#mZ7", &[]),
Err(PasswordPolicyError::TooShort { min: 12 })
);
}
#[test]
fn empty_password_is_too_short() {
assert_eq!(
check_strength("", &[]),
Err(PasswordPolicyError::TooShort { min: 12 })
);
}
#[test]
fn length_counts_characters_not_bytes() {
assert_eq!(
check_strength("éàçùêâîôûëï", &[]),
Err(PasswordPolicyError::TooShort { min: 12 })
);
assert_eq!(check_strength("éàçùêâîôûëïü", &[]), Ok(()));
}
#[test]
fn length_bounds_are_inclusive() {
let max: String = "Tq9!vR2#mZ7$".chars().cycle().take(128).collect();
let over: String = "Tq9!vR2#mZ7$".chars().cycle().take(129).collect();
assert_eq!(check_strength(&max, &[]), Ok(()));
assert_eq!(
check_strength(&over, &[]),
Err(PasswordPolicyError::TooLong { max: 128 })
);
}
#[test]
fn password_equal_to_email_is_refused() {
assert_eq!(
check_strength("alice@ironflow.dev", &PERSONAL),
Err(PasswordPolicyError::ContainsPersonalInfo)
);
}
#[test]
fn password_containing_username_is_refused_case_insensitively() {
assert_eq!(
check_strength("my-ALICE-secret-42", &PERSONAL),
Err(PasswordPolicyError::ContainsPersonalInfo)
);
}
#[test]
fn password_containing_email_local_part_is_refused() {
assert_eq!(
check_strength("xx-jdoe.ops-2024", &["jdoe.ops@corp.example"]),
Err(PasswordPolicyError::ContainsPersonalInfo)
);
}
#[test]
fn short_personal_fragments_are_ignored() {
assert_eq!(check_strength("Tq9!vR2#mZ7$al", &["al@x.io", "al"]), Ok(()));
}
#[test]
fn common_password_is_refused() {
assert_eq!(
check_strength("Unbelievable", &[]),
Err(PasswordPolicyError::TooCommon)
);
}
#[test]
fn common_word_padded_with_digits_and_symbols_is_refused() {
assert_eq!(
check_strength("Password2024!!", &[]),
Err(PasswordPolicyError::TooCommon)
);
assert_eq!(
check_strength("!!!Qwertyuiop1", &[]),
Err(PasswordPolicyError::TooCommon)
);
}
#[test]
fn repeated_pattern_is_refused() {
assert_eq!(
check_strength("passwordpassword", &[]),
Err(PasswordPolicyError::TooRepetitive)
);
assert_eq!(
check_strength("Xk9#Xk9#Xk9#", &[]),
Err(PasswordPolicyError::TooRepetitive)
);
}
#[test]
fn too_few_distinct_characters_is_refused() {
assert_eq!(
check_strength("aaaa1111bbbb", &[]),
Err(PasswordPolicyError::TooRepetitive)
);
}
#[test]
fn hash_produces_argon2id() {
let h = hash("password123").unwrap();
assert!(h.starts_with("$argon2id$"));
}
#[test]
fn verify_correct_password() {
let h = hash("mypassword").unwrap();
assert!(verify("mypassword", &h).unwrap());
}
#[test]
fn verify_wrong_password() {
let h = hash("mypassword").unwrap();
assert!(!verify("wrongpassword", &h).unwrap());
}
#[test]
fn different_hashes_for_same_password() {
let h1 = hash("same").unwrap();
let h2 = hash("same").unwrap();
assert_ne!(h1, h2);
}
#[test]
fn malformed_hash_returns_error() {
assert!(verify("pw", "not-a-valid-hash").is_err());
}
}