use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use rustlavel_core::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cost {
pub memory_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl Cost {
pub const DEFAULT: Cost = Cost { memory_kib: 19_456, iterations: 2, parallelism: 1 };
pub const FAST: Cost = Cost { memory_kib: 8, iterations: 1, parallelism: 1 };
fn params(self) -> Result<Params> {
Params::new(self.memory_kib, self.iterations, self.parallelism, None)
.map_err(|e| Error::msg(format!("invalid argon2 parameters: {e}")))
}
}
impl Default for Cost {
fn default() -> Self {
Cost::DEFAULT
}
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
}
pub fn hash_password(password: &str) -> Result<String> {
hash_password_with(password, Cost::DEFAULT)
}
pub fn hash_password_with(password: &str, cost: Cost) -> Result<String> {
let salt = SaltString::encode_b64(&crate::random::bytes(16))
.map_err(|e| Error::msg(format!("could not encode a password salt: {e}")))?;
let hasher = Argon2::new(Algorithm::Argon2id, Version::V0x13, cost.params()?);
let hash = hasher
.hash_password(password.as_bytes(), &salt)
.map_err(|e| Error::msg(format!("could not hash the password: {e}")))?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
pub fn needs_rehash(hash: &str, cost: Cost) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return true;
};
if parsed.algorithm != Algorithm::Argon2id.ident() {
return true;
}
match Params::try_from(&parsed) {
Ok(params) => {
params.m_cost() < cost.memory_kib
|| params.t_cost() < cost.iterations
|| params.p_cost() != cost.parallelism
}
Err(_) => true,
}
}
#[cfg(test)]
mod tests {
#[test]
fn sha256_matches_the_published_vectors() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
use super::*;
#[test]
fn a_hashed_password_verifies_against_itself() {
let hash = hash_password_with("correct horse battery staple", Cost::FAST).unwrap();
assert!(verify_password("correct horse battery staple", &hash));
}
#[test]
fn the_wrong_password_is_rejected() {
let hash = hash_password_with("s3cret", Cost::FAST).unwrap();
assert!(!verify_password("s3cre", &hash));
assert!(!verify_password("s3cret ", &hash));
assert!(!verify_password("", &hash));
assert!(!verify_password("S3cret", &hash));
}
#[test]
fn the_same_password_hashes_differently_every_time() {
let first = hash_password_with("repeated", Cost::FAST).unwrap();
let second = hash_password_with("repeated", Cost::FAST).unwrap();
assert_ne!(first, second, "each hash must carry its own salt");
assert!(verify_password("repeated", &first));
assert!(verify_password("repeated", &second));
}
#[test]
fn hashes_are_phc_strings_naming_argon2id() {
let hash = hash_password_with("x", Cost::FAST).unwrap();
assert!(hash.starts_with("$argon2id$v=19$"), "unexpected hash format: {hash}");
assert!(hash.contains("m=8,t=1,p=1"), "cost should be recorded in the hash: {hash}");
}
#[test]
fn a_corrupt_or_foreign_hash_never_authenticates() {
assert!(!verify_password("anything", ""));
assert!(!verify_password("anything", "not-a-hash"));
assert!(!verify_password("anything", "$argon2id$v=19$m=8,t=1,p=1$c2FsdA$"));
assert!(!verify_password("anything", "$2y$10$abcdefghijklmnopqrstuv"));
}
#[test]
fn a_hash_verifies_even_though_the_default_cost_is_higher() {
let cheap = hash_password_with("legacy", Cost::FAST).unwrap();
assert!(verify_password("legacy", &cheap));
assert!(needs_rehash(&cheap, Cost::DEFAULT));
assert!(!needs_rehash(&cheap, Cost::FAST));
}
#[test]
fn unreadable_hashes_are_reported_as_needing_a_rehash() {
assert!(needs_rehash("", Cost::DEFAULT));
assert!(needs_rehash("$2y$10$abcdefghijklmnopqrstuv", Cost::DEFAULT));
}
#[test]
fn the_default_cost_produces_a_working_hash() {
let hash = hash_password("default cost").unwrap();
assert!(verify_password("default cost", &hash));
assert!(!verify_password("wrong", &hash));
assert!(!needs_rehash(&hash, Cost::DEFAULT));
}
}