use crate::error::{ConfigReason, CryptoReason, InvalidRequestReason};
use argon2::password_hash::{PasswordHasher as _, PasswordVerifier as _};
use argon2::{Algorithm, Argon2, Params, Version};
use rand_core::CryptoRng;
use zeroize::Zeroize;
use crate::Error;
pub const MIN_LEN: usize = 12;
pub const MAX_LEN: usize = 1024;
const SALT_LEN: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PasswordParams {
memory_kib: u32,
iterations: u32,
parallelism: u32,
}
impl PasswordParams {
pub fn new(memory_kib: u32, iterations: u32, parallelism: u32) -> Result<Self, Error> {
let params = Self {
memory_kib,
iterations,
parallelism,
};
params.to_argon2()?;
Ok(params)
}
#[must_use]
pub const fn memory_kib(&self) -> u32 {
self.memory_kib
}
#[must_use]
pub const fn iterations(&self) -> u32 {
self.iterations
}
#[must_use]
pub const fn parallelism(&self) -> u32 {
self.parallelism
}
fn to_argon2(self) -> Result<Params, Error> {
Params::new(self.memory_kib, self.iterations, self.parallelism, None)
.map_err(|_| Error::Config(ConfigReason::Argon2Parameters))
}
}
impl Default for PasswordParams {
fn default() -> Self {
Self {
memory_kib: 64 * 1024,
iterations: 3,
parallelism: 2,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PasswordHash(String);
impl PasswordHash {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(mut self) -> String {
std::mem::take(&mut self.0)
}
}
impl PasswordHash {
pub fn from_legacy_argon2(phc: &str) -> Result<Self, Error> {
parse_argon2(phc, &["argon2i", "argon2d", "argon2id"], None)?;
Ok(Self(phc.to_owned()))
}
}
fn parse_argon2(phc: &str, algorithms: &[&str], version: Option<u32>) -> Result<(), Error> {
let invalid = Error::Crypto(CryptoReason::StoredPasswordHash);
let parsed = argon2::PasswordHash::new(phc).map_err(|_| invalid.clone())?;
let shaped = algorithms.contains(&parsed.algorithm.as_str())
&& version.is_none_or(|version| parsed.version == Some(version))
&& parsed.salt.is_some()
&& parsed.hash.is_some()
&& Params::try_from(&parsed).is_ok();
if shaped { Ok(()) } else { Err(invalid) }
}
impl TryFrom<String> for PasswordHash {
type Error = Error;
fn try_from(phc: String) -> Result<Self, Error> {
parse_argon2(&phc, &["argon2id"], Some(Version::V0x13.into()))?;
Ok(Self(phc))
}
}
impl TryFrom<&str> for PasswordHash {
type Error = Error;
fn try_from(phc: &str) -> Result<Self, Error> {
Self::try_from(phc.to_owned())
}
}
impl std::fmt::Debug for PasswordHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PasswordHash(<redacted>)")
}
}
#[derive(Clone)]
pub struct PasswordHasher {
params: Params,
dummy: PasswordHash,
}
impl std::fmt::Debug for PasswordHasher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasswordHasher")
.field("params", &self.params)
.finish_non_exhaustive()
}
}
impl PasswordHasher {
pub fn new<R: CryptoRng + ?Sized>(params: PasswordParams, rng: &mut R) -> Result<Self, Error> {
let params = params.to_argon2()?;
let mut filler = [0u8; 32];
rng.fill_bytes(&mut filler);
let dummy = hash_with(¶ms, &crate::hex(&filler), rng)?;
filler.zeroize();
Ok(Self { params, dummy })
}
pub fn hash<R: CryptoRng + ?Sized>(
&self,
password: &str,
rng: &mut R,
) -> Result<PasswordHash, Error> {
hash_with(&self.params, password, rng)
}
pub fn hash_new_password<R: CryptoRng + ?Sized>(
&self,
password: &str,
rng: &mut R,
) -> Result<PasswordHash, Error> {
check_policy(password)?;
self.hash(password, rng)
}
#[must_use]
pub fn needs_rehash(&self, stored: &PasswordHash) -> bool {
let Ok(parsed) = argon2::PasswordHash::new(stored.as_str()) else {
return true;
};
if parsed.algorithm.as_str() != Algorithm::Argon2id.as_str()
|| parsed.version != Some(Version::V0x13.into())
{
return true;
}
let Ok(params) = Params::try_from(&parsed) else {
return true;
};
let output_len =
|params: &Params| params.output_len().unwrap_or(Params::DEFAULT_OUTPUT_LEN);
params.m_cost() != self.params.m_cost()
|| params.t_cost() != self.params.t_cost()
|| params.p_cost() != self.params.p_cost()
|| output_len(¶ms) != output_len(&self.params)
}
#[must_use]
pub fn verify(&self, password: &str, stored: Option<&PasswordHash>) -> bool {
let argon2 = argon2(self.params.clone());
let too_long = password.len() > MAX_LEN * 4;
let (candidate, real) = match stored {
Some(stored) if !too_long && affordable(stored, &self.params) => (stored, true),
_ => (&self.dummy, false),
};
let password = if too_long { "" } else { password };
let matched = argon2
.verify_password(password.as_bytes(), candidate.as_str())
.is_ok();
matched && real
}
}
const MAX_STORED_MEMORY_KIB: u32 = 1024 * 1024;
const MAX_STORED_ITERATIONS: u32 = 16;
const MAX_STORED_PARALLELISM: u32 = 16;
fn affordable(stored: &PasswordHash, own: &Params) -> bool {
argon2::PasswordHash::new(stored.as_str())
.ok()
.and_then(|parsed| Params::try_from(&parsed).ok())
.is_some_and(|params| {
params.m_cost() <= MAX_STORED_MEMORY_KIB.max(own.m_cost())
&& params.t_cost() <= MAX_STORED_ITERATIONS.max(own.t_cost())
&& params.p_cost() <= MAX_STORED_PARALLELISM.max(own.p_cost())
})
}
fn argon2(params: Params) -> Argon2<'static> {
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
}
fn hash_with<R: CryptoRng + ?Sized>(
params: &Params,
password: &str,
rng: &mut R,
) -> Result<PasswordHash, Error> {
let mut salt = [0u8; SALT_LEN];
rng.fill_bytes(&mut salt);
argon2(params.clone())
.hash_password_with_salt(password.as_bytes(), &salt)
.map(|hash| PasswordHash(hash.to_string()))
.map_err(|_| Error::Crypto(CryptoReason::PasswordHashing))
}
pub fn check_policy(password: &str) -> Result<(), Error> {
let len = password.chars().count();
if len < MIN_LEN {
return Err(Error::InvalidRequest(
InvalidRequestReason::PasswordTooShort,
));
}
if len > MAX_LEN {
return Err(Error::InvalidRequest(InvalidRequestReason::PasswordTooLong));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn cheap() -> PasswordParams {
PasswordParams {
memory_kib: 64,
iterations: 1,
parallelism: 1,
}
}
#[test]
fn hashes_verify_and_reject() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
let hash = hasher.hash("correct horse battery", &mut rng)?;
assert!(hash.as_str().starts_with("$argon2id$v=19$m=64,t=1,p=1$"));
assert!(hasher.verify("correct horse battery", Some(&hash)));
assert!(!hasher.verify("wrong horse battery", Some(&hash)));
Ok(())
}
#[test]
fn missing_hash_never_verifies() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
assert!(!hasher.verify("anything at all", None));
assert!(!hasher.verify("", None));
Ok(())
}
#[test]
fn oversized_passwords_never_verify() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
let long = "x".repeat(MAX_LEN * 4 + 1);
let hash = hasher.hash(&long, &mut rng)?;
assert!(!hasher.verify(&long, Some(&hash)));
Ok(())
}
#[test]
fn salts_differ_per_hash() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
let a = hasher.hash("correct horse battery", &mut rng)?;
let b = hasher.hash("correct horse battery", &mut rng)?;
assert_ne!(a, b);
Ok(())
}
#[test]
fn stored_hashes_parse_and_garbage_does_not() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
let hash = hasher.hash("correct horse battery", &mut rng)?;
let reloaded = PasswordHash::try_from(hash.as_str())?;
assert!(hasher.verify("correct horse battery", Some(&reloaded)));
assert!(PasswordHash::try_from("not a hash").is_err());
assert!(!format!("{hash:?}").contains("argon2id"));
Ok(())
}
#[test]
fn hashes_under_other_parameters_need_a_rehash() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
let current = hasher.hash("correct horse battery", &mut rng)?;
assert!(!hasher.needs_rehash(¤t));
for params in [
PasswordParams::new(128, 1, 1)?,
PasswordParams::new(64, 2, 1)?,
PasswordParams::new(64, 1, 2)?,
] {
let other = PasswordHasher::new(params, &mut rng)?;
let stored = other.hash("correct horse battery", &mut rng)?;
assert!(hasher.needs_rehash(&stored), "{params:?}");
assert!(!other.needs_rehash(&stored));
}
let salt = "c29tZXNhbHRzb21lc2FsdA";
let hash32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
for phc in [
format!("$argon2i$v=19$m=64,t=1,p=1${salt}${hash32}"),
format!("$argon2id$v=16$m=64,t=1,p=1${salt}${hash32}"),
format!("$argon2id$m=64,t=1,p=1${salt}${hash32}"),
format!("$argon2id$v=19$m=64,t=1,p=1${salt}$AAAAAAAAAAAAAAAAAAAAAA"),
] {
let stored = PasswordHash::from_legacy_argon2(&phc)?;
assert!(hasher.needs_rehash(&stored), "{phc}");
}
let same = PasswordHash::try_from(format!("$argon2id$v=19$m=64,t=1,p=1${salt}${hash32}"))?;
assert!(!hasher.needs_rehash(&same));
Ok(())
}
#[test]
fn only_argon2id_loads_as_latchkeys_own_and_legacy_argon2_explicitly() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
let salt = [7u8; 16];
let legacy = argon2::Argon2::new(
Algorithm::Argon2i,
Version::V0x10,
Params::new(64, 1, 1, None).map_err(|_| Error::Rng)?,
)
.hash_password_with_salt(b"correct horse battery", &salt)
.map_err(|_| Error::Rng)?
.to_string();
assert!(PasswordHash::try_from(legacy.as_str()).is_err());
let imported = PasswordHash::from_legacy_argon2(&legacy)?;
assert!(hasher.verify("correct horse battery", Some(&imported)));
assert!(!hasher.verify("wrong horse battery", Some(&imported)));
assert!(hasher.needs_rehash(&imported));
let salt = "c29tZXNhbHRzb21lc2FsdA";
let hash32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
for bad in [
format!("$scrypt$ln=15,r=8,p=1${salt}${hash32}"),
format!("$argon2id$v=19$m=64,t=1,p=1${salt}"),
"$2b$12$abcdefghijklmnopqrstuuJ4n7nVvC0rI7vJkQe2Yt8cG1aWm6r2".to_owned(),
"not a hash".to_owned(),
] {
assert!(PasswordHash::try_from(bad.as_str()).is_err(), "{bad}");
assert!(PasswordHash::from_legacy_argon2(&bad).is_err(), "{bad}");
}
Ok(())
}
#[test]
fn hashes_too_costly_to_verify_are_refused_without_running_them() -> Result<(), Error> {
let mut rng = crate::test_rng();
let hasher = PasswordHasher::new(cheap(), &mut rng)?;
let salt = "c29tZXNhbHRzb21lc2FsdA";
let hash32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
for params in [
"m=4194304,t=1,p=1",
"m=64,t=4294967295,p=1",
"m=8192,t=1,p=1024",
] {
let stored =
PasswordHash::try_from(format!("$argon2id$v=19${params}${salt}${hash32}"))?;
assert!(
!hasher.verify("correct horse battery", Some(&stored)),
"{params}"
);
assert!(hasher.needs_rehash(&stored));
}
Ok(())
}
#[test]
fn default_params_match_policy() {
let params = PasswordParams::default();
assert_eq!(
(
params.memory_kib(),
params.iterations(),
params.parallelism()
),
(65536, 3, 2)
);
assert!(PasswordParams::new(0, 0, 0).is_err());
}
#[test]
fn length_policy() {
assert!(check_policy("short").is_err());
assert!(check_policy("twelve chars").is_ok());
assert!(check_policy(&"x".repeat(1025)).is_err());
}
}