use std::fmt;
use argon2::{
Argon2,
password_hash::{Error as PasswordHashErrorKind, PasswordHash, PasswordHasher, PasswordVerifier, Salt, SaltString},
};
use rand::{TryRng, rngs::SysRng};
#[derive(Debug)]
pub enum PasswordHashError {
InvalidHash(String),
HashFailure(String),
}
impl fmt::Display for PasswordHashError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidHash(reason) => write!(f, "invalid argon2 password hash: {reason}"),
Self::HashFailure(reason) => write!(f, "argon2 hashing failed: {reason}"),
}
}
}
impl std::error::Error for PasswordHashError {}
pub(crate) fn validate_password_hash(phc_string: &str) -> Result<(), PasswordHashError> {
let parsed = PasswordHash::new(phc_string).map_err(|error| PasswordHashError::InvalidHash(error.to_string()))?;
if !matches!(parsed.algorithm.as_str(), "argon2d" | "argon2i" | "argon2id") {
Err(PasswordHashError::InvalidHash(format!(
"password hash algorithm `{}` is not an Argon2 variant",
parsed.algorithm
)))
} else if parsed.salt.is_none() {
Err(PasswordHashError::InvalidHash("password hash is missing its salt".to_string()))
} else if parsed.hash.is_none() {
Err(PasswordHashError::InvalidHash("password hash is missing its encoded output".to_string()))
} else {
Ok(())
}
}
pub fn verify_password(password: &str, phc_string: &str) -> Result<bool, PasswordHashError> {
let parsed = PasswordHash::new(phc_string).map_err(|error| PasswordHashError::InvalidHash(error.to_string()))?;
match Argon2::default().verify_password(password.as_bytes(), &parsed) {
Ok(()) => Ok(true),
Err(PasswordHashErrorKind::Password) => Ok(false),
Err(error) => Err(PasswordHashError::InvalidHash(error.to_string())),
}
}
pub fn hash_password(password: &str) -> Result<String, PasswordHashError> {
let mut salt_bytes = [0u8; Salt::RECOMMENDED_LENGTH];
SysRng
.try_fill_bytes(&mut salt_bytes)
.map_err(|error| PasswordHashError::HashFailure(format!("failed to generate password salt: {error}")))?;
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|error| PasswordHashError::HashFailure(error.to_string()))?;
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|error| PasswordHashError::HashFailure(error.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_round_trip() {
let hash = hash_password("correct horse battery staple").expect("hash");
assert!(hash.starts_with("$argon2"));
assert!(verify_password("correct horse battery staple", &hash).expect("verify"));
assert!(!verify_password("wrong password", &hash).expect("verify"));
}
#[test]
fn malformed_hash_reports_invalid() {
let err = verify_password("anything", "not a phc string").unwrap_err();
assert!(matches!(err, PasswordHashError::InvalidHash(_)), "got: {err:?}");
}
#[test]
fn config_validation_requires_a_complete_argon2_phc() {
let valid = hash_password("correct horse battery staple").expect("hash");
validate_password_hash(&valid).expect("generated hash is a complete Argon2 PHC");
let error = validate_password_hash("$argon2id$v=19$m=19456,t=2,p=1$only-a-salt")
.expect_err("PHC without encoded output must reject");
assert!(matches!(error, PasswordHashError::InvalidHash(_)), "got: {error:?}");
}
#[test]
fn two_hashes_of_same_password_differ() {
let a = hash_password("hunter2").expect("hash a");
let b = hash_password("hunter2").expect("hash b");
assert_ne!(a, b);
assert!(verify_password("hunter2", &a).expect("verify a"));
assert!(verify_password("hunter2", &b).expect("verify b"));
}
}