use argon2::{Algorithm, Argon2, Params, Version};
use password_hash::{
PasswordHash, PasswordHasher as PhcHasher, PasswordVerifier, Salt, SaltString,
};
use rand::RngCore;
use crate::error::{Error, Result};
const DEFAULT_MEMORY_KIB: u32 = 65_536;
const DEFAULT_TIME_COST: u32 = 3;
const DEFAULT_PARALLELISM: u32 = 4;
#[derive(Clone)]
pub struct PasswordHasher {
argon: Argon2<'static>,
params: Params,
}
impl Default for PasswordHasher {
fn default() -> Self {
let params = Params::new(
DEFAULT_MEMORY_KIB,
DEFAULT_TIME_COST,
DEFAULT_PARALLELISM,
None,
)
.expect("argon2 default params are within library limits");
Self {
argon: Argon2::new(Algorithm::Argon2id, Version::V0x13, params.clone()),
params,
}
}
}
impl PasswordHasher {
pub fn with_params(params: Params) -> Self {
Self {
argon: Argon2::new(Algorithm::Argon2id, Version::V0x13, params.clone()),
params,
}
}
pub fn hash(&self, plaintext: &str) -> Result<String> {
let mut salt_bytes = [0u8; Salt::RECOMMENDED_LENGTH];
rand::rng().fill_bytes(&mut salt_bytes);
let salt = SaltString::encode_b64(&salt_bytes).map_err(map_phc_err)?;
let phc = self
.argon
.hash_password(plaintext.as_bytes(), &salt)
.map_err(map_phc_err)?;
Ok(phc.to_string())
}
pub fn verify(&self, plaintext: &str, phc: &str) -> Result<bool> {
let parsed = PasswordHash::new(phc).map_err(map_phc_err)?;
match self.argon.verify_password(plaintext.as_bytes(), &parsed) {
Ok(()) => Ok(true),
Err(password_hash::Error::Password) => Ok(false),
Err(other) => Err(map_phc_err(other)),
}
}
pub fn needs_rehash(&self, phc: &str) -> Result<bool> {
let parsed = PasswordHash::new(phc).map_err(map_phc_err)?;
if parsed.algorithm.as_str() != Algorithm::Argon2id.ident().as_str() {
return Ok(true);
}
let stored = Params::try_from(&parsed).map_err(map_phc_err)?;
Ok(stored.m_cost() != self.params.m_cost()
|| stored.t_cost() != self.params.t_cost()
|| stored.p_cost() != self.params.p_cost())
}
}
fn map_phc_err(e: password_hash::Error) -> Error {
Error::Backend(anyhow::anyhow!("argon2: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_round_trip() {
let hasher = PasswordHasher::default();
let phc = hasher.hash("hunter2").unwrap();
assert!(phc.starts_with("$argon2id$"));
assert!(hasher.verify("hunter2", &phc).unwrap());
}
#[test]
fn wrong_password_returns_ok_false() {
let hasher = PasswordHasher::default();
let phc = hasher.hash("correct").unwrap();
assert!(!hasher.verify("incorrect", &phc).unwrap());
}
#[test]
fn malformed_hash_returns_err() {
let hasher = PasswordHasher::default();
let result = hasher.verify("anything", "not-a-phc-string");
assert!(matches!(result, Err(Error::Backend(_))));
}
#[test]
fn salt_is_per_call_so_two_hashes_of_same_input_differ() {
let hasher = PasswordHasher::default();
let a = hasher.hash("same").unwrap();
let b = hasher.hash("same").unwrap();
assert_ne!(a, b);
}
#[test]
fn needs_rehash_false_for_same_params() {
let hasher = PasswordHasher::default();
let phc = hasher.hash("pw").unwrap();
assert!(!hasher.needs_rehash(&phc).unwrap());
}
#[test]
fn needs_rehash_true_when_params_drift() {
let weak = PasswordHasher::with_params(Params::new(8, 1, 1, None).unwrap());
let phc = weak.hash("pw").unwrap();
let strong = PasswordHasher::default();
assert!(strong.needs_rehash(&phc).unwrap());
}
}