use std::fmt;
use argon2::PasswordHasher as _;
use argon2::password_hash::{PasswordHash, PasswordVerifier as _, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use secrecy::{ExposeSecret, SecretSlice};
use crate::auth::{PasswordConfig, PasswordHashError, PasswordVerifyError};
const SALT_LEN: usize = 16;
#[derive(Clone)]
pub struct PasswordHasher {
argon2: Argon2<'static>,
}
impl PasswordHasher {
pub fn new(config: PasswordConfig) -> Result<Self, PasswordHashError> {
let params = config
.to_params()
.map_err(|error| PasswordHashError::InvalidParams {
detail: error.to_string(),
})?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
Ok(Self { argon2 })
}
pub fn hash(&self, password: &[u8]) -> Result<PasswordHashString, PasswordHashError> {
let salt = generate_salt().map_err(|detail| PasswordHashError::InvalidParams { detail })?;
let password_hash = self
.argon2
.hash_password(password, salt.as_salt())
.map_err(|source| PasswordHashError::Hash { source })?;
Ok(PasswordHashString::from_password_hash(password_hash))
}
#[must_use]
pub fn needs_rehash(&self, stored: &PasswordHashString) -> RehashOutcome {
let parsed = stored.password_hash();
let stored_params: Params = match (&parsed).try_into() {
Ok(params) => params,
Err(_) => return RehashOutcome::NeedsRehash,
};
let current = self.argon2.params();
if stored_params.m_cost() == current.m_cost()
&& stored_params.t_cost() == current.t_cost()
&& stored_params.p_cost() == current.p_cost()
{
RehashOutcome::Current
} else {
RehashOutcome::NeedsRehash
}
}
pub(crate) fn argon2(&self) -> &Argon2<'static> {
&self.argon2
}
}
impl fmt::Debug for PasswordHasher {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PasswordHasher")
.field("algorithm", &"argon2id")
.field("version", &"v19")
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RehashOutcome {
Current,
NeedsRehash,
}
#[derive(Clone)]
pub struct PasswordHashString {
inner: argon2::password_hash::PasswordHashString,
}
impl PasswordHashString {
pub(crate) fn from_password_hash(hash: PasswordHash<'_>) -> Self {
Self {
inner: argon2::password_hash::PasswordHashString::from(hash),
}
}
pub fn new(stored: &str) -> Result<Self, PasswordVerifyError> {
let inner = argon2::password_hash::PasswordHashString::new(stored).map_err(|e| {
PasswordVerifyError::MalformedHash {
detail: e.to_string(),
}
})?;
Ok(Self { inner })
}
#[must_use]
pub fn as_str(&self) -> &str {
self.inner.as_str()
}
pub(crate) fn password_hash(&self) -> PasswordHash<'_> {
self.inner.password_hash()
}
}
impl fmt::Debug for PasswordHashString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "PasswordHashString({})", self.as_str())
}
}
impl fmt::Display for PasswordHashString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.as_str())
}
}
pub fn verify_password(
hasher: &PasswordHasher,
password: &[u8],
stored: &PasswordHashString,
) -> Result<(), PasswordVerifyError> {
let parsed = stored.password_hash();
hasher
.argon2()
.verify_password(password, &parsed)
.map_err(|error| match error {
argon2::password_hash::Error::Password => PasswordVerifyError::PasswordMismatch,
other => PasswordVerifyError::Verify { source: other },
})
}
pub struct PasswordSecret {
inner: SecretSlice<u8>,
}
impl PasswordSecret {
#[must_use]
pub fn new(password: impl AsRef<[u8]>) -> Self {
let bytes: Vec<u8> = password.as_ref().to_vec();
Self {
inner: SecretSlice::from(bytes),
}
}
#[must_use]
pub fn expose(&self) -> &[u8] {
self.inner.expose_secret()
}
}
impl fmt::Debug for PasswordSecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"PasswordSecret(<{} redacted bytes>)",
self.inner.expose_secret().len()
)
}
}
fn generate_salt() -> Result<SaltString, String> {
let mut bytes = [0u8; SALT_LEN];
getrandom::fill(&mut bytes).map_err(|e| format!("getrandom failed: {e}"))?;
SaltString::encode_b64(&bytes).map_err(|e| format!("salt encode failed: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hashes_a_password_to_phc_string() {
let hasher = PasswordHasher::new(PasswordConfig::recommended()).expect("valid params");
let hash = hasher
.hash(b"correct horse battery staple")
.expect("hash ok");
let s = hash.as_str();
assert!(s.starts_with("$argon2id$"), "got: {s}");
assert!(s.contains("$v=19$"), "got: {s}");
assert_eq!(s.matches('$').count(), 5, "got: {s}");
}
#[test]
fn different_salts_each_hash() {
let hasher = PasswordHasher::new(PasswordConfig::recommended()).expect("valid params");
let h1 = hasher.hash(b"same password").expect("hash 1");
let h2 = hasher.hash(b"same password").expect("hash 2");
assert_ne!(h1.as_str(), h2.as_str(), "salts should differ");
}
#[test]
fn rejects_zero_iterations() {
let result = PasswordHasher::new(PasswordConfig::new(19_456, 0, 1));
assert!(matches!(
result,
Err(PasswordHashError::InvalidParams { .. })
));
}
#[test]
fn debug_does_not_leak_password() {
let hasher = PasswordHasher::new(PasswordConfig::recommended()).expect("valid params");
let debug = format!("{hasher:?}");
assert!(!debug.contains("password"));
}
#[test]
fn verifies_correct_password() {
let h = PasswordHasher::new(PasswordConfig::recommended()).expect("valid params");
let hash = h.hash(b"correct horse battery staple").expect("hash");
assert!(verify_password(&h, b"correct horse battery staple", &hash).is_ok());
}
#[test]
fn rejects_wrong_password() {
let h = PasswordHasher::new(PasswordConfig::recommended()).expect("valid params");
let hash = h.hash(b"correct horse battery staple").expect("hash");
assert!(matches!(
verify_password(&h, b"wrong password", &hash),
Err(PasswordVerifyError::PasswordMismatch)
));
}
#[test]
fn rejects_malformed_hash() {
let result = PasswordHashString::new("not-a-phc-string");
assert!(matches!(
result,
Err(PasswordVerifyError::MalformedHash { .. })
));
}
#[test]
fn debug_and_display_show_phc_not_password() {
let h = PasswordHasher::new(PasswordConfig::recommended()).expect("valid params");
let hash = h.hash(b"secret-password-value").expect("hash");
let debug = format!("{hash:?}");
let display = format!("{hash}");
assert!(!debug.contains("secret-password-value"));
assert!(!display.contains("secret-password-value"));
assert!(debug.contains("argon2id"));
}
#[test]
fn fresh_hash_does_not_need_rehash() {
let h = PasswordHasher::new(PasswordConfig::recommended()).expect("valid");
let hash = h.hash(b"password").expect("hash");
assert_eq!(h.needs_rehash(&hash), RehashOutcome::Current);
}
#[test]
fn hash_under_old_params_needs_rehash() {
let old = PasswordHasher::new(PasswordConfig::new(19_456, 2, 1)).expect("valid");
let hash = old.hash(b"password").expect("hash");
let new = PasswordHasher::new(PasswordConfig::new(47_104, 2, 1)).expect("valid");
assert_eq!(new.needs_rehash(&hash), RehashOutcome::NeedsRehash);
}
#[test]
fn secret_debug_redacts_password() {
let secret = PasswordSecret::new("hunter2");
let debug = format!("{secret:?}");
assert!(!debug.contains("hunter2"));
assert!(debug.contains("redacted"));
}
#[test]
fn secret_expose_returns_bytes() {
let secret = PasswordSecret::new("hunter2");
assert_eq!(secret.expose(), b"hunter2");
}
}