use std::fmt;
use argon2::password_hash::{PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use crate::crypto::fill_random;
use crate::util::log::{debug, trace};
pub const MIN_MEMORY_KIB: u32 = 16_384;
pub const MAX_MEMORY_KIB: u32 = 4 * 1024 * 1024;
const DEFAULT_MEMORY_KIB: u32 = 65_536;
const DEFAULT_ITERATIONS: u32 = 3;
const DEFAULT_PARALLELISM: u32 = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasswordConfig {
memory_kib: u32,
iterations: u32,
parallelism: u32,
}
impl Default for PasswordConfig {
fn default() -> Self {
Self {
memory_kib: DEFAULT_MEMORY_KIB,
iterations: DEFAULT_ITERATIONS,
parallelism: DEFAULT_PARALLELISM,
}
}
}
impl PasswordConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_memory_kib(mut self, memory_kib: u32) -> Self {
self.memory_kib = memory_kib;
self
}
#[must_use]
pub fn with_iterations(mut self, iterations: u32) -> Self {
self.iterations = iterations;
self
}
#[must_use]
pub fn with_parallelism(mut self, parallelism: u32) -> Self {
self.parallelism = parallelism;
self
}
#[must_use]
#[inline]
pub fn memory_kib(&self) -> u32 {
self.memory_kib
}
#[must_use]
#[inline]
pub fn iterations(&self) -> u32 {
self.iterations
}
#[must_use]
#[inline]
pub fn parallelism(&self) -> u32 {
self.parallelism
}
}
#[derive(Clone)]
pub struct PasswordHash {
phc_string: String,
}
impl PasswordHash {
pub fn generate(password: &[u8], config: &PasswordConfig) -> Result<Self, PasswordError> {
if config.memory_kib < MIN_MEMORY_KIB {
return Err(PasswordError::new(PasswordErrorKind::MemoryTooLow));
}
if config.memory_kib > MAX_MEMORY_KIB {
return Err(PasswordError::new(PasswordErrorKind::MemoryTooHigh));
}
if config.iterations == 0 {
return Err(PasswordError::new(PasswordErrorKind::IterationsTooLow));
}
if config.parallelism == 0 {
return Err(PasswordError::new(PasswordErrorKind::ParallelismTooLow));
}
let params = Params::new(
config.memory_kib,
config.iterations,
config.parallelism,
None,
)
.map_err(|_| PasswordError::new(PasswordErrorKind::InvalidParams))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut salt_bytes = [0u8; 16];
fill_random(&mut salt_bytes)
.map_err(|_| PasswordError::new(PasswordErrorKind::RandomFailure))?;
let salt = SaltString::encode_b64(&salt_bytes)
.map_err(|_| PasswordError::new(PasswordErrorKind::RandomFailure))?;
trace!(
memory_kib = config.memory_kib,
iterations = config.iterations,
parallelism = config.parallelism,
"password: starting Argon2id hash"
);
let hash = argon2
.hash_password(password, &salt)
.map_err(|_| PasswordError::new(PasswordErrorKind::HashingFailed))?;
debug!(
memory_kib = config.memory_kib,
iterations = config.iterations,
parallelism = config.parallelism,
"password: hash generated"
);
Ok(Self {
phc_string: hash.to_string(),
})
}
#[must_use]
pub fn verify(&self, password: &[u8]) -> bool {
let Ok(parsed) = argon2::PasswordHash::new(&self.phc_string) else {
debug!("password: verification failed (invalid stored hash)");
return false;
};
let result = Argon2::default().verify_password(password, &parsed).is_ok();
if result {
debug!("password: verification succeeded");
} else {
debug!("password: verification failed");
}
result
}
#[must_use]
pub fn to_phc_string(&self) -> &str {
&self.phc_string
}
pub fn parse(s: &str) -> Result<Self, PasswordError> {
let parsed = argon2::PasswordHash::new(s)
.map_err(|_| PasswordError::new(PasswordErrorKind::InvalidPhcFormat))?;
if parsed.algorithm != argon2::ARGON2ID_IDENT {
return Err(PasswordError::new(PasswordErrorKind::UnsupportedAlgorithm));
}
Ok(Self {
phc_string: s.to_owned(),
})
}
#[must_use]
pub fn needs_rehash(&self, config: &PasswordConfig) -> bool {
let Ok(parsed) = argon2::PasswordHash::new(&self.phc_string) else {
return true;
};
let Ok(params) = Params::try_from(&parsed) else {
return true;
};
let latest_version = Version::V0x13 as u32;
if parsed.version.is_none_or(|v| v < latest_version) {
return true;
}
params.m_cost() < config.memory_kib
|| params.t_cost() < config.iterations
|| params.p_cost() < config.parallelism
}
}
impl fmt::Debug for PasswordHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PasswordHash")
.field("phc_string", &"[HASH]")
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PasswordErrorKind {
MemoryTooLow,
MemoryTooHigh,
IterationsTooLow,
ParallelismTooLow,
InvalidParams,
RandomFailure,
HashingFailed,
InvalidPhcFormat,
UnsupportedAlgorithm,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasswordError {
kind: PasswordErrorKind,
}
impl PasswordError {
const fn new(kind: PasswordErrorKind) -> Self {
Self { kind }
}
}
impl fmt::Display for PasswordError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
PasswordErrorKind::MemoryTooLow => {
write!(
f,
"password: memory cost below minimum ({MIN_MEMORY_KIB} KiB)"
)
}
PasswordErrorKind::MemoryTooHigh => {
write!(
f,
"password: memory cost above maximum ({MAX_MEMORY_KIB} KiB)"
)
}
PasswordErrorKind::IterationsTooLow => {
write!(f, "password: iteration count must be at least 1")
}
PasswordErrorKind::ParallelismTooLow => {
write!(f, "password: parallelism must be at least 1")
}
PasswordErrorKind::InvalidParams => {
write!(f, "password: invalid parameter combination")
}
PasswordErrorKind::RandomFailure => {
write!(f, "password: random number generation failed")
}
PasswordErrorKind::HashingFailed => {
write!(f, "password: hashing operation failed")
}
PasswordErrorKind::InvalidPhcFormat => {
write!(f, "password: invalid PHC string format")
}
PasswordErrorKind::UnsupportedAlgorithm => {
write!(f, "password: unsupported algorithm (expected argon2id)")
}
}
}
}
impl std::error::Error for PasswordError {}
fn dummy_password_hash() -> Option<&'static PasswordHash> {
static DUMMY: std::sync::OnceLock<Option<PasswordHash>> = std::sync::OnceLock::new();
DUMMY
.get_or_init(|| {
PasswordHash::generate(
b"entropy-auth-enumeration-guard",
&PasswordConfig::default(),
)
.ok()
})
.as_ref()
}
#[must_use]
pub fn verify_credential(stored: Option<&PasswordHash>, password: &[u8]) -> bool {
if let Some(hash) = stored {
hash.verify(password)
} else {
if let Some(dummy) = dummy_password_hash() {
let _ = dummy.verify(password);
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> PasswordConfig {
PasswordConfig::new()
.with_memory_kib(MIN_MEMORY_KIB)
.with_iterations(1)
.with_parallelism(1)
}
#[test]
fn generate_and_verify_round_trip() {
let config = test_config();
let hash = PasswordHash::generate(b"correct-password", &config).unwrap();
assert!(hash.verify(b"correct-password"));
}
#[test]
fn verify_rejects_wrong_password() {
let config = test_config();
let hash = PasswordHash::generate(b"correct-password", &config).unwrap();
assert!(!hash.verify(b"wrong-password"));
}
#[test]
fn verify_rejects_empty_password_when_original_not_empty() {
let config = test_config();
let hash = PasswordHash::generate(b"some-password", &config).unwrap();
assert!(!hash.verify(b""));
}
#[test]
fn generate_empty_password_accepted() {
let config = test_config();
let hash = PasswordHash::generate(b"", &config).unwrap();
assert!(hash.verify(b""));
assert!(!hash.verify(b"not-empty"));
}
#[test]
fn phc_string_round_trip() {
let config = test_config();
let hash = PasswordHash::generate(b"my-password", &config).unwrap();
let phc = hash.to_phc_string();
let parsed = PasswordHash::parse(phc).unwrap();
assert_eq!(hash.to_phc_string(), parsed.to_phc_string());
}
#[test]
fn phc_string_format() {
let config = test_config();
let hash = PasswordHash::generate(b"test", &config).unwrap();
let phc = hash.to_phc_string();
assert!(
phc.starts_with("$argon2id$"),
"PHC should start with algorithm: {phc}"
);
}
#[test]
fn parsed_hash_verifies_password() {
let config = test_config();
let hash = PasswordHash::generate(b"round-trip-verify", &config).unwrap();
let phc = hash.to_phc_string();
let parsed = PasswordHash::parse(phc).unwrap();
assert!(parsed.verify(b"round-trip-verify"));
assert!(!parsed.verify(b"wrong"));
}
#[test]
fn parse_rejects_empty_string() {
assert_eq!(
PasswordHash::parse("").unwrap_err(),
PasswordError::new(PasswordErrorKind::InvalidPhcFormat),
);
}
#[test]
fn parse_rejects_unparseable_non_argon2_phc() {
assert_eq!(
PasswordHash::parse("$pbkdf2-sha512$100000$c2FsdA==$aGFzaA==").unwrap_err(),
PasswordError::new(PasswordErrorKind::InvalidPhcFormat),
);
}
#[test]
fn parse_rejects_argon2d() {
let config = test_config();
let hash = PasswordHash::generate(b"test", &config).unwrap();
let phc = hash.to_phc_string().replace("argon2id", "argon2d");
assert_eq!(
PasswordHash::parse(&phc).unwrap_err(),
PasswordError::new(PasswordErrorKind::UnsupportedAlgorithm),
);
}
#[test]
fn parse_rejects_argon2i() {
let config = test_config();
let hash = PasswordHash::generate(b"test", &config).unwrap();
let phc = hash.to_phc_string().replace("argon2id", "argon2i");
assert_eq!(
PasswordHash::parse(&phc).unwrap_err(),
PasswordError::new(PasswordErrorKind::UnsupportedAlgorithm),
);
}
#[test]
fn parse_rejects_garbage() {
assert_eq!(
PasswordHash::parse("not-a-hash").unwrap_err(),
PasswordError::new(PasswordErrorKind::InvalidPhcFormat),
);
}
#[test]
fn needs_rehash_detects_weak_memory() {
let weak_config = test_config();
let hash = PasswordHash::generate(b"test", &weak_config).unwrap();
let strong_config = PasswordConfig::new()
.with_memory_kib(DEFAULT_MEMORY_KIB)
.with_iterations(1)
.with_parallelism(1);
assert!(hash.needs_rehash(&strong_config));
}
#[test]
fn needs_rehash_detects_weak_iterations() {
let config = test_config();
let hash = PasswordHash::generate(b"test", &config).unwrap();
let stronger = PasswordConfig::new()
.with_memory_kib(MIN_MEMORY_KIB)
.with_iterations(3)
.with_parallelism(1);
assert!(hash.needs_rehash(&stronger));
}
#[test]
fn needs_rehash_detects_weak_parallelism() {
let config = test_config();
let hash = PasswordHash::generate(b"test", &config).unwrap();
let stronger = PasswordConfig::new()
.with_memory_kib(MIN_MEMORY_KIB)
.with_iterations(1)
.with_parallelism(4);
assert!(hash.needs_rehash(&stronger));
}
#[test]
fn needs_rehash_false_when_current() {
let config = test_config();
let hash = PasswordHash::generate(b"test", &config).unwrap();
assert!(!hash.needs_rehash(&config));
}
#[test]
fn generate_rejects_low_memory() {
let config = PasswordConfig::new()
.with_memory_kib(MIN_MEMORY_KIB - 1)
.with_iterations(1)
.with_parallelism(1);
assert_eq!(
PasswordHash::generate(b"test", &config).unwrap_err(),
PasswordError::new(PasswordErrorKind::MemoryTooLow),
);
}
#[test]
fn generate_rejects_excessive_memory() {
let config = PasswordConfig::new().with_memory_kib(MAX_MEMORY_KIB + 1);
assert_eq!(
PasswordHash::generate(b"test", &config).unwrap_err(),
PasswordError::new(PasswordErrorKind::MemoryTooHigh),
);
}
#[test]
fn verify_credential_equalizes_absent_user() {
let stored = PasswordHash::generate(b"correct", &PasswordConfig::default()).unwrap();
assert!(verify_credential(Some(&stored), b"correct"));
assert!(!verify_credential(Some(&stored), b"wrong"));
assert!(!verify_credential(None, b"anything"));
}
#[test]
fn generate_rejects_zero_iterations() {
let config = PasswordConfig::new()
.with_memory_kib(MIN_MEMORY_KIB)
.with_iterations(0)
.with_parallelism(1);
assert_eq!(
PasswordHash::generate(b"test", &config).unwrap_err(),
PasswordError::new(PasswordErrorKind::IterationsTooLow),
);
}
#[test]
fn generate_rejects_zero_parallelism() {
let config = PasswordConfig::new()
.with_memory_kib(MIN_MEMORY_KIB)
.with_iterations(1)
.with_parallelism(0);
assert_eq!(
PasswordHash::generate(b"test", &config).unwrap_err(),
PasswordError::new(PasswordErrorKind::ParallelismTooLow),
);
}
#[test]
fn error_display_no_secrets() {
let errors = [
PasswordError::new(PasswordErrorKind::MemoryTooLow),
PasswordError::new(PasswordErrorKind::IterationsTooLow),
PasswordError::new(PasswordErrorKind::ParallelismTooLow),
PasswordError::new(PasswordErrorKind::InvalidParams),
PasswordError::new(PasswordErrorKind::RandomFailure),
PasswordError::new(PasswordErrorKind::HashingFailed),
PasswordError::new(PasswordErrorKind::InvalidPhcFormat),
PasswordError::new(PasswordErrorKind::UnsupportedAlgorithm),
];
for err in &errors {
let msg = err.to_string();
assert!(
msg.starts_with("password:"),
"error should be prefixed: {msg}"
);
assert!(
!msg.contains("secret") && !msg.contains("token"),
"error must not leak secret material: {msg}",
);
}
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> =
Box::new(PasswordError::new(PasswordErrorKind::InvalidPhcFormat));
let _ = err.to_string();
}
#[test]
fn default_config_uses_recommended_values() {
let config = PasswordConfig::default();
assert_eq!(config.memory_kib(), DEFAULT_MEMORY_KIB);
assert_eq!(config.iterations(), DEFAULT_ITERATIONS);
assert_eq!(config.parallelism(), DEFAULT_PARALLELISM);
}
#[test]
fn config_builder_pattern() {
let config = PasswordConfig::new()
.with_memory_kib(32_768)
.with_iterations(2)
.with_parallelism(8);
assert_eq!(config.memory_kib(), 32_768);
assert_eq!(config.iterations(), 2);
assert_eq!(config.parallelism(), 8);
}
#[test]
fn debug_redacts_hash() {
let config = test_config();
let hash = PasswordHash::generate(b"test", &config).unwrap();
let debug = format!("{hash:?}");
assert!(debug.contains("[HASH]"), "debug should redact: {debug}");
assert!(
!debug.contains("argon2id"),
"debug should not contain hash value: {debug}"
);
}
}