#[derive(Debug, thiserror::Error)]
pub enum PasswordError {
#[error("hashing failed: {0}")]
Hash(String),
#[error("verification error: {0}")]
Verify(String),
}
pub fn hash(password: &str) -> Result<String, PasswordError> {
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
use argon2::Argon2;
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| PasswordError::Hash(e.to_string()))
}
pub fn verify(password: &str, stored_hash: &str) -> Result<bool, PasswordError> {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
use argon2::Argon2;
let parsed =
PasswordHash::new(stored_hash).map_err(|e| PasswordError::Verify(e.to_string()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StrengthIssue {
TooShort,
NoDigitsOrSymbols,
NoVariety,
KnownWeak,
}
#[must_use]
pub fn strength_score(password: &str) -> Vec<StrengthIssue> {
let mut issues = Vec::new();
if password.chars().count() < 12 {
issues.push(StrengthIssue::TooShort);
}
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_symbol = password
.chars()
.any(|c| !c.is_alphanumeric() && !c.is_whitespace());
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
if !has_digit && !has_symbol {
issues.push(StrengthIssue::NoDigitsOrSymbols);
}
if !has_digit && !has_symbol && !has_upper && has_lower {
issues.push(StrengthIssue::NoVariety);
}
let lower = password.to_ascii_lowercase();
if KNOWN_WEAK.iter().any(|&w| w == lower) {
issues.push(StrengthIssue::KnownWeak);
}
issues
}
const KNOWN_WEAK: &[&str] = &[
"password",
"password1",
"password123",
"12345678",
"123456789",
"qwerty",
"qwerty123",
"letmein",
"admin",
"admin123",
"welcome",
"welcome1",
"iloveyou",
"monkey",
"abc123",
"111111",
"000000",
"passw0rd",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_match() {
let h = hash("CorrectHorseBatteryStaple!42").unwrap();
assert!(verify("CorrectHorseBatteryStaple!42", &h).unwrap());
}
#[test]
fn verify_rejects_wrong_password() {
let h = hash("real-password").unwrap();
assert!(!verify("wrong-password", &h).unwrap());
}
#[test]
fn verify_invalid_hash_errors() {
let r = verify("anything", "not-a-valid-hash");
assert!(r.is_err());
}
#[test]
fn strong_password_has_no_issues() {
let issues = strength_score("Tr0ub4dor&3-CorrectBattery");
assert!(issues.is_empty(), "got issues: {:?}", issues);
}
#[test]
fn short_password_flagged() {
let issues = strength_score("aB3!");
assert!(issues.contains(&StrengthIssue::TooShort));
}
#[test]
fn all_letter_password_flagged() {
let issues = strength_score("abcdefghijklmnop");
assert!(issues.contains(&StrengthIssue::NoDigitsOrSymbols));
assert!(issues.contains(&StrengthIssue::NoVariety));
}
#[test]
fn mixed_case_no_digits_only_flags_no_digits() {
let issues = strength_score("ABCDEFGHIJKLMnop");
assert!(issues.contains(&StrengthIssue::NoDigitsOrSymbols));
assert!(!issues.contains(&StrengthIssue::NoVariety));
}
#[test]
fn known_weak_password_flagged() {
let issues = strength_score("password123");
assert!(issues.contains(&StrengthIssue::KnownWeak));
}
#[test]
fn known_weak_check_is_case_insensitive() {
let issues = strength_score("PASSWORD123");
assert!(issues.contains(&StrengthIssue::KnownWeak));
}
#[test]
fn long_password_with_digit_passes_length_and_variety_check() {
let issues = strength_score("ThisIsLongEnough1");
assert!(!issues.contains(&StrengthIssue::TooShort));
assert!(!issues.contains(&StrengthIssue::NoDigitsOrSymbols));
}
}