use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString},
Argon2, Params, Version,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PasswordError {
#[error("Failed to hash password: {0}")]
HashingFailed(String),
#[error("Failed to verify password: {0}")]
VerificationFailed(String),
#[error("Invalid password hash format: {0}")]
InvalidHash(String),
#[error("Invalid Argon2 parameters: {0}")]
InvalidParams(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PasswordHashConfig {
pub memory_cost: u32,
pub iterations: u32,
pub parallelism: u32,
pub output_length: usize,
}
impl Default for PasswordHashConfig {
fn default() -> Self {
Self {
memory_cost: 19456, iterations: 2, parallelism: 1, output_length: 32, }
}
}
#[derive(Clone, Default)]
pub struct PasswordHasher {
config: PasswordHashConfig,
}
impl PasswordHasher {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn with_config(config: PasswordHashConfig) -> Self {
Self { config }
}
#[must_use]
pub fn builder() -> PasswordHasherBuilder {
PasswordHasherBuilder::new()
}
pub fn hash(&self, password: &str) -> Result<String, PasswordError> {
let salt = SaltString::generate(&mut OsRng);
let params = Params::new(
self.config.memory_cost,
self.config.iterations,
self.config.parallelism,
Some(self.config.output_length),
)
.map_err(|e| PasswordError::InvalidParams(e.to_string()))?;
let argon2 = Argon2::new(
argon2::Algorithm::Argon2id, Version::V0x13, params,
);
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| PasswordError::HashingFailed(e.to_string()))?;
Ok(password_hash.to_string())
}
pub fn verify(&self, password: &str, hash: &str) -> Result<bool, PasswordError> {
let parsed_hash =
PasswordHash::new(hash).map_err(|e| PasswordError::InvalidHash(e.to_string()))?;
let argon2 = Argon2::default();
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(true),
Err(argon2::password_hash::Error::Password) => Ok(false), Err(e) => Err(PasswordError::VerificationFailed(e.to_string())),
}
}
#[must_use]
pub const fn config(&self) -> &PasswordHashConfig {
&self.config
}
}
#[derive(Default)]
pub struct PasswordHasherBuilder {
config: PasswordHashConfig,
}
impl PasswordHasherBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn memory_cost(mut self, cost: u32) -> Self {
self.config.memory_cost = cost;
self
}
#[must_use]
pub const fn iterations(mut self, iterations: u32) -> Self {
self.config.iterations = iterations;
self
}
#[must_use]
pub const fn parallelism(mut self, parallelism: u32) -> Self {
self.config.parallelism = parallelism;
self
}
#[must_use]
pub const fn output_length(mut self, length: usize) -> Self {
self.config.output_length = length;
self
}
pub fn build(self) -> Result<PasswordHasher, PasswordError> {
Params::new(
self.config.memory_cost,
self.config.iterations,
self.config.parallelism,
Some(self.config.output_length),
)
.map_err(|e| PasswordError::InvalidParams(e.to_string()))?;
Ok(PasswordHasher {
config: self.config,
})
}
}
pub fn hash_password(password: &str) -> Result<String, PasswordError> {
PasswordHasher::default().hash(password)
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, PasswordError> {
PasswordHasher::default().verify(password, hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_password_hashing() {
let hasher = PasswordHasher::new();
let password = "test-password-123";
let hash = hasher.hash(password).expect("Failed to hash password");
assert!(hash.starts_with("$argon2id$"));
assert!(hasher
.verify(password, &hash)
.expect("Failed to verify password"));
assert!(!hasher
.verify("wrong-password", &hash)
.expect("Failed to verify wrong password"));
}
#[test]
fn test_convenience_functions() {
let password = "test-password-456";
let hash = hash_password(password).expect("Failed to hash");
assert!(verify_password(password, &hash).expect("Failed to verify"));
assert!(!verify_password("wrong", &hash).expect("Failed to verify wrong"));
}
#[test]
fn test_custom_parameters() {
let hasher = PasswordHasher::builder()
.memory_cost(16 * 1024) .iterations(3)
.parallelism(2)
.build()
.expect("Failed to build hasher");
let password = "custom-params-test";
let hash = hasher.hash(password).expect("Failed to hash");
assert!(hasher.verify(password, &hash).expect("Failed to verify"));
}
#[test]
fn test_invalid_hash_format() {
let hasher = PasswordHasher::new();
let result = hasher.verify("password", "invalid-hash");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), PasswordError::InvalidHash(_)));
}
#[test]
fn test_different_hashes_for_same_password() {
let hasher = PasswordHasher::new();
let password = "same-password";
let hash1 = hasher.hash(password).expect("Failed to hash 1");
let hash2 = hasher.hash(password).expect("Failed to hash 2");
assert_ne!(hash1, hash2);
assert!(hasher.verify(password, &hash1).expect("Failed to verify 1"));
assert!(hasher.verify(password, &hash2).expect("Failed to verify 2"));
}
#[test]
fn test_default_config() {
let config = PasswordHashConfig::default();
assert_eq!(config.memory_cost, 19456); assert_eq!(config.iterations, 2);
assert_eq!(config.parallelism, 1);
assert_eq!(config.output_length, 32);
}
#[test]
fn test_builder_pattern() {
let hasher = PasswordHasher::builder()
.memory_cost(20000)
.iterations(4)
.parallelism(4)
.output_length(64)
.build()
.expect("Failed to build");
assert_eq!(hasher.config().memory_cost, 20000);
assert_eq!(hasher.config().iterations, 4);
assert_eq!(hasher.config().parallelism, 4);
assert_eq!(hasher.config().output_length, 64);
}
#[test]
fn test_invalid_parameters() {
let result = PasswordHasher::builder().memory_cost(1).build();
assert!(result.is_err());
let result = PasswordHasher::builder().iterations(0).build();
assert!(result.is_err());
}
#[test]
fn test_constant_time_verification() {
let hasher = PasswordHasher::new();
let hash = hasher.hash("test").expect("Failed to hash");
let _ = hasher.verify("test", &hash);
let _ = hasher.verify("wrong", &hash);
}
}