#[cfg(feature = "argon2")]
use argon2::Argon2;
#[cfg(any(feature = "argon2", feature = "scrypt"))]
use password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString};
#[cfg(feature = "scrypt")]
use scrypt::{Params as ScryptParams, Scrypt};
use crate::error::{Error, PasswordHashError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Algorithm {
#[cfg(feature = "argon2")]
Argon2id,
#[cfg(feature = "bcrypt")]
Bcrypt,
#[cfg(feature = "scrypt")]
Scrypt,
}
#[cfg(not(any(feature = "argon2", feature = "bcrypt", feature = "scrypt")))]
compile_error!(
"At least one password hashing algorithm (argon2, bcrypt, or scrypt) must be enabled. Enable one of the password hashing features."
);
#[allow(clippy::derivable_impls)]
impl Default for Algorithm {
fn default() -> Self {
#[cfg(feature = "argon2")]
{
Algorithm::Argon2id
}
#[cfg(all(not(feature = "argon2"), feature = "bcrypt"))]
{
Algorithm::Bcrypt
}
#[cfg(all(not(any(feature = "argon2", feature = "bcrypt")), feature = "scrypt"))]
{
Algorithm::Scrypt
}
}
}
#[derive(Debug, Clone)]
pub struct PasswordHasher {
algorithm: Algorithm,
#[cfg(feature = "bcrypt")]
bcrypt_cost: u32,
#[cfg(feature = "scrypt")]
scrypt_params: ScryptParams,
}
impl Default for PasswordHasher {
fn default() -> Self {
Self {
algorithm: Algorithm::default(),
#[cfg(feature = "bcrypt")]
bcrypt_cost: 12,
#[cfg(feature = "scrypt")]
scrypt_params: ScryptParams::recommended(),
}
}
}
impl PasswordHasher {
pub fn new(algorithm: Algorithm) -> Self {
Self {
algorithm,
#[cfg(feature = "bcrypt")]
bcrypt_cost: 12,
#[cfg(feature = "scrypt")]
scrypt_params: ScryptParams::recommended(),
}
}
#[cfg(feature = "bcrypt")]
pub fn with_bcrypt_cost(mut self, cost: u32) -> Self {
assert!(
(4..=31).contains(&cost),
"bcrypt cost must be between 4 and 31"
);
self.bcrypt_cost = cost;
self
}
#[cfg(feature = "scrypt")]
pub fn with_scrypt_params(mut self, params: ScryptParams) -> Self {
self.scrypt_params = params;
self
}
pub fn hash(&self, password: &str) -> Result<String> {
match self.algorithm {
#[cfg(feature = "argon2")]
Algorithm::Argon2id => self.hash_argon2(password),
#[cfg(feature = "bcrypt")]
Algorithm::Bcrypt => self.hash_bcrypt(password),
#[cfg(feature = "scrypt")]
Algorithm::Scrypt => self.hash_scrypt(password),
}
}
pub fn verify(&self, password: &str, hash: &str) -> Result<bool> {
#[cfg(feature = "argon2")]
if hash.starts_with("$argon2") {
return self.verify_argon2(password, hash);
}
#[cfg(feature = "bcrypt")]
if hash.starts_with("$2") {
return self.verify_bcrypt(password, hash);
}
#[cfg(feature = "scrypt")]
if hash.starts_with("$scrypt$") {
return self.verify_scrypt(password, hash);
}
Err(Error::PasswordHash(PasswordHashError::InvalidFormat(
"unknown hash format".to_string(),
)))
}
pub fn needs_rehash(&self, hash: &str) -> bool {
match self.algorithm {
#[cfg(feature = "argon2")]
Algorithm::Argon2id => !hash.starts_with("$argon2id"),
#[cfg(feature = "bcrypt")]
Algorithm::Bcrypt => {
if !hash.starts_with("$2") {
return true;
}
if let Some(cost_str) = hash.get(4..6)
&& let Ok(cost) = cost_str.parse::<u32>()
{
return cost < self.bcrypt_cost;
}
true
}
#[cfg(feature = "scrypt")]
Algorithm::Scrypt => !hash.starts_with("$scrypt$"),
}
}
#[cfg(feature = "argon2")]
fn hash_argon2(&self, password: &str) -> Result<String> {
let mut salt_bytes = [0u8; 16];
getrandom::fill(&mut salt_bytes).map_err(|e| {
Error::PasswordHash(PasswordHashError::HashFailed(format!(
"Failed to generate random salt: {}",
e
)))
})?;
let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| {
Error::PasswordHash(PasswordHashError::HashFailed(format!(
"Failed to encode salt: {}",
e
)))
})?;
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| {
Error::PasswordHash(PasswordHashError::HashFailed(format!(
"Argon2 hash failed: {}",
e
)))
})
}
#[cfg(feature = "argon2")]
fn verify_argon2(&self, password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash).map_err(|e| {
Error::PasswordHash(PasswordHashError::InvalidFormat(format!(
"invalid Argon2 hash: {}",
e
)))
})?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(feature = "bcrypt")]
fn hash_bcrypt(&self, password: &str) -> Result<String> {
bcrypt::hash(password, self.bcrypt_cost).map_err(|e| {
Error::PasswordHash(PasswordHashError::HashFailed(format!(
"bcrypt hash failed: {}",
e
)))
})
}
#[cfg(feature = "bcrypt")]
fn verify_bcrypt(&self, password: &str, hash: &str) -> Result<bool> {
bcrypt::verify(password, hash).map_err(|e| {
Error::PasswordHash(PasswordHashError::InvalidFormat(format!(
"bcrypt verify failed: {}",
e
)))
})
}
#[cfg(feature = "scrypt")]
fn hash_scrypt(&self, password: &str) -> Result<String> {
let mut salt_bytes = [0u8; 16];
getrandom::fill(&mut salt_bytes).map_err(|e| {
Error::PasswordHash(PasswordHashError::HashFailed(format!(
"Failed to generate random salt: {}",
e
)))
})?;
let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| {
Error::PasswordHash(PasswordHashError::HashFailed(format!(
"Failed to encode salt: {}",
e
)))
})?;
Scrypt
.hash_password_customized(password.as_bytes(), None, None, self.scrypt_params, &salt)
.map(|h| h.to_string())
.map_err(|e| {
Error::PasswordHash(PasswordHashError::HashFailed(format!(
"scrypt hash failed: {}",
e
)))
})
}
#[cfg(feature = "scrypt")]
fn verify_scrypt(&self, password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash).map_err(|e| {
Error::PasswordHash(PasswordHashError::InvalidFormat(format!(
"invalid scrypt hash: {}",
e
)))
})?;
Ok(Scrypt
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
}
pub fn hash_password(password: &str) -> Result<String> {
PasswordHasher::default().hash(password)
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
PasswordHasher::default().verify(password, hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "argon2")]
fn test_argon2_hash_and_verify() {
let hasher = PasswordHasher::new(Algorithm::Argon2id);
let password = "test_password_123";
let hash = hasher.hash(password).unwrap();
assert!(hash.starts_with("$argon2id"));
assert!(hasher.verify(password, &hash).unwrap());
assert!(!hasher.verify("wrong_password", &hash).unwrap());
}
#[test]
#[cfg(feature = "bcrypt")]
fn test_bcrypt_hash_and_verify() {
let hasher = PasswordHasher::new(Algorithm::Bcrypt).with_bcrypt_cost(4); let password = "test_password_123";
let hash = hasher.hash(password).unwrap();
assert!(hash.starts_with("$2"));
assert!(hasher.verify(password, &hash).unwrap());
assert!(!hasher.verify("wrong_password", &hash).unwrap());
}
#[test]
#[cfg(feature = "scrypt")]
fn test_scrypt_hash_and_verify() {
let hasher = PasswordHasher::new(Algorithm::Scrypt);
let password = "test_password_123";
let hash = hasher.hash(password).unwrap();
assert!(hash.starts_with("$scrypt$"));
assert!(hasher.verify(password, &hash).unwrap());
assert!(!hasher.verify("wrong_password", &hash).unwrap());
}
#[test]
fn test_convenience_functions() {
let password = "my_secure_password";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
assert!(!verify_password("wrong", &hash).unwrap());
}
#[test]
fn test_auto_detect_algorithm() {
let hasher = PasswordHasher::default();
let default_hash = hasher.hash("test").unwrap();
assert!(hasher.verify("test", &default_hash).unwrap());
#[cfg(feature = "argon2")]
{
let argon2_hasher = PasswordHasher::new(Algorithm::Argon2id);
let argon2_hash = argon2_hasher.hash("test").unwrap();
assert!(hasher.verify("test", &argon2_hash).unwrap());
}
#[cfg(feature = "bcrypt")]
{
let bcrypt_hasher = PasswordHasher::new(Algorithm::Bcrypt).with_bcrypt_cost(4);
let bcrypt_hash = bcrypt_hasher.hash("test").unwrap();
assert!(hasher.verify("test", &bcrypt_hash).unwrap());
}
#[cfg(feature = "scrypt")]
{
let scrypt_hasher = PasswordHasher::new(Algorithm::Scrypt);
let scrypt_hash = scrypt_hasher.hash("test").unwrap();
assert!(hasher.verify("test", &scrypt_hash).unwrap());
}
}
#[test]
#[cfg(feature = "argon2")]
fn test_needs_rehash_argon2() {
let argon2_hasher = PasswordHasher::new(Algorithm::Argon2id);
let argon2_hash = argon2_hasher.hash("test").unwrap();
assert!(!argon2_hasher.needs_rehash(&argon2_hash));
}
#[test]
#[cfg(feature = "bcrypt")]
fn test_needs_rehash_bcrypt() {
let bcrypt_hasher = PasswordHasher::new(Algorithm::Bcrypt).with_bcrypt_cost(12);
let low_cost_hasher = PasswordHasher::new(Algorithm::Bcrypt).with_bcrypt_cost(4);
let low_cost_hash = low_cost_hasher.hash("test").unwrap();
assert!(bcrypt_hasher.needs_rehash(&low_cost_hash));
}
#[test]
#[cfg(feature = "scrypt")]
fn test_needs_rehash_scrypt() {
let hasher = PasswordHasher::new(Algorithm::Scrypt);
let hash = hasher.hash("test").unwrap();
assert!(!hasher.needs_rehash(&hash));
assert!(hasher.needs_rehash("$argon2id$dummy"));
}
#[test]
#[cfg(all(feature = "argon2", feature = "bcrypt"))]
fn test_needs_rehash_cross_algorithm() {
let argon2_hasher = PasswordHasher::new(Algorithm::Argon2id);
let bcrypt_hasher = PasswordHasher::new(Algorithm::Bcrypt).with_bcrypt_cost(12);
let argon2_hash = argon2_hasher.hash("test").unwrap();
assert!(bcrypt_hasher.needs_rehash(&argon2_hash));
}
#[test]
fn test_invalid_hash_format() {
let hasher = PasswordHasher::default();
let result = hasher.verify("test", "invalid_hash");
assert!(result.is_err());
}
#[test]
fn test_empty_password() {
let hasher = PasswordHasher::default();
let hash = hasher.hash("").unwrap();
assert!(hasher.verify("", &hash).unwrap());
assert!(!hasher.verify("not_empty", &hash).unwrap());
}
#[test]
fn test_unicode_password() {
let hasher = PasswordHasher::default();
let password = "密码测试🔐émoji";
let hash = hasher.hash(password).unwrap();
assert!(hasher.verify(password, &hash).unwrap());
assert!(!hasher.verify("wrong", &hash).unwrap());
}
#[test]
fn test_long_password() {
let hasher = PasswordHasher::default();
let password = "a".repeat(1000);
let hash = hasher.hash(&password).unwrap();
assert!(hasher.verify(&password, &hash).unwrap());
}
#[test]
#[should_panic(expected = "bcrypt cost must be between 4 and 31")]
#[cfg(feature = "bcrypt")]
fn test_invalid_bcrypt_cost_low() {
PasswordHasher::new(Algorithm::Bcrypt).with_bcrypt_cost(3);
}
#[test]
#[should_panic(expected = "bcrypt cost must be between 4 and 31")]
#[cfg(feature = "bcrypt")]
fn test_invalid_bcrypt_cost_high() {
PasswordHasher::new(Algorithm::Bcrypt).with_bcrypt_cost(32);
}
#[test]
fn test_different_hashes_same_password() {
let hasher = PasswordHasher::default();
let password = "same_password";
let hash1 = hasher.hash(password).unwrap();
let hash2 = hasher.hash(password).unwrap();
assert_ne!(hash1, hash2);
assert!(hasher.verify(password, &hash1).unwrap());
assert!(hasher.verify(password, &hash2).unwrap());
}
}