use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Params,
};
use radixdb_core::{Error, Result};
pub const MAX_PASSWORD_BYTES: usize = 1024;
const MIN_MEMORY_COST_KIB: u32 = Params::DEFAULT_M_COST;
const MAX_MEMORY_COST_KIB: u32 = 256 * 1024;
const MIN_TIME_COST: u32 = Params::DEFAULT_T_COST;
const MAX_TIME_COST: u32 = 10;
const MAX_PARALLELISM: u32 = 16;
pub fn hash_password_verifier(password: &str) -> Result<String> {
validate_password_length(password)?;
let salt_seed = uuid::Uuid::now_v7();
let salt = SaltString::encode_b64(salt_seed.as_bytes())
.map_err(|_| Error::internal("failed to encode credential salt"))?;
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| Error::internal("failed to derive credential verifier"))
}
pub fn validate_password_verifier(encoded: &str) -> Result<()> {
let parsed = PasswordHash::new(encoded)
.map_err(|_| Error::invalid_argument("password verifier must be a valid PHC string"))?;
if parsed.algorithm.as_str() != "argon2id" {
return Err(Error::invalid_argument(
"password verifier must use the argon2id algorithm",
));
}
if parsed.version != Some(19) || parsed.salt.is_none() || parsed.hash.is_none() {
return Err(Error::invalid_argument(
"password verifier must contain Argon2 version 19, salt and hash",
));
}
let params = Params::try_from(&parsed)
.map_err(|_| Error::invalid_argument("password verifier parameters are invalid"))?;
let output_len = params.output_len().unwrap_or(Params::DEFAULT_OUTPUT_LEN);
if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(¶ms.m_cost())
|| !(MIN_TIME_COST..=MAX_TIME_COST).contains(¶ms.t_cost())
|| !(Params::MIN_P_COST..=MAX_PARALLELISM).contains(¶ms.p_cost())
|| !(Params::DEFAULT_OUTPUT_LEN..=64).contains(&output_len)
{
return Err(Error::invalid_argument(
"password verifier parameters are outside the supported security bounds",
));
}
Ok(())
}
pub fn verify_password_verifier(encoded: &str, password: &str) -> bool {
if password.is_empty() || password.len() > MAX_PASSWORD_BYTES {
return false;
}
if validate_password_verifier(encoded).is_err() {
return false;
}
let parsed = PasswordHash::new(encoded).expect("validated password verifier must parse");
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
fn validate_password_length(password: &str) -> Result<()> {
if password.is_empty() || password.len() > MAX_PASSWORD_BYTES {
return Err(Error::invalid_argument(
"password length must be in 1..=1024 bytes",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_verifier_is_argon2id_and_matches_only_its_password() {
let encoded = hash_password_verifier("correct horse battery staple").unwrap();
assert!(encoded.starts_with("$argon2id$"));
assert_eq!(validate_password_verifier(&encoded), Ok(()));
assert!(verify_password_verifier(
&encoded,
"correct horse battery staple"
));
assert!(!verify_password_verifier(&encoded, "wrong"));
}
#[test]
fn malformed_and_non_argon2id_verifiers_are_rejected() {
assert!(validate_password_verifier("not-a-phc-string").is_err());
assert!(validate_password_verifier("$argon2i$v=19$m=4096,t=3,p=1$c2FsdA$YWJj").is_err());
assert!(validate_password_verifier("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA").is_err());
assert!(validate_password_verifier(
"$argon2id$v=19$m=4294967295,t=2,p=1$c2FsdA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo"
)
.is_err());
assert!(!verify_password_verifier("not-a-phc-string", "secret"));
}
#[test]
fn password_length_is_bounded() {
assert!(hash_password_verifier("").is_err());
assert!(hash_password_verifier(&"x".repeat(MAX_PASSWORD_BYTES + 1)).is_err());
}
}