use anyhow::{anyhow, Result};
use rand::RngExt;
use std::sync::OnceLock;
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Algorithm, Argon2, Params, Version,
};
const ARGON2_M_COST: u32 = 19456;
const ARGON2_T_COST: u32 = 2;
const ARGON2_P_COST: u32 = 1;
pub const TEMPORARY_PASSWORD_LEN: usize = 20;
const TEMP_UPPER: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
const TEMP_LOWER: &[u8] = b"abcdefghijkmnopqrstuvwxyz";
const TEMP_DIGIT: &[u8] = b"23456789";
const TEMP_SYMBOL: &[u8] = b"-_.=,";
#[must_use]
pub fn generate_temporary_password() -> String {
let mut rng = rand::rng();
let pools = [TEMP_UPPER, TEMP_LOWER, TEMP_DIGIT, TEMP_SYMBOL];
let mut chars: Vec<char> = Vec::with_capacity(TEMPORARY_PASSWORD_LEN);
for pool in pools {
chars.push(char::from(pool[rng.random_range(0..pool.len())]));
}
let all: Vec<u8> = pools.concat();
while chars.len() < TEMPORARY_PASSWORD_LEN {
chars.push(char::from(all[rng.random_range(0..all.len())]));
}
for i in (1..chars.len()).rev() {
let j = rng.random_range(0..=i);
chars.swap(i, j);
}
chars.into_iter().collect()
}
fn argon2_instance() -> &'static Argon2<'static> {
static INSTANCE: OnceLock<Argon2<'static>> = OnceLock::new();
INSTANCE.get_or_init(|| {
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, None)
.expect("hardcoded Argon2 parameters are valid by construction");
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
})
}
pub fn hash_password(password: &str) -> Result<String> {
let mut salt_bytes = [0u8; 16];
rand::rng().fill(&mut salt_bytes);
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|e| anyhow!("salt encoding failed: {e}"))?;
let hash = argon2_instance()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow!("password hash failed: {e}"))?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed = PasswordHash::new(hash).map_err(|e| anyhow!("invalid hash format: {e}"))?;
Ok(argon2_instance()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
const HOSTILE: &str = "$`\"'\\|&;<>()[]{}*?!~#% \t\n";
#[test]
fn test_hash_and_verify() {
let hash = hash_password("test123").unwrap();
assert!(verify_password("test123", &hash).unwrap());
assert!(!verify_password("wrong", &hash).unwrap());
}
#[test]
fn test_hash_uniqueness() {
let h1 = hash_password("same-password").unwrap();
let h2 = hash_password("same-password").unwrap();
assert_ne!(h1, h2, "same password should produce different hashes");
assert!(verify_password("same-password", &h1).unwrap());
assert!(verify_password("same-password", &h2).unwrap());
}
#[test]
fn test_invalid_hash_format() {
assert!(verify_password("anything", "not-a-valid-phc-hash").is_err());
assert!(verify_password("anything", "").is_err());
}
#[test]
fn test_empty_password() {
let hash = hash_password("").unwrap();
assert!(verify_password("", &hash).unwrap());
assert!(!verify_password("x", &hash).unwrap());
}
#[test]
fn test_unicode_password() {
let hash = hash_password("密码🔑安全123!aA").unwrap();
assert!(verify_password("密码🔑安全123!aA", &hash).unwrap());
assert!(!verify_password("密码🔑安全123!aB", &hash).unwrap());
}
#[test]
fn test_generate_temporary_password_shape() {
let password = generate_temporary_password();
assert_eq!(password.chars().count(), TEMPORARY_PASSWORD_LEN);
assert!(password.chars().any(|c| c.is_ascii_uppercase()));
assert!(password.chars().any(|c| c.is_ascii_lowercase()));
assert!(password.chars().any(|c| c.is_ascii_digit()));
assert!(password.chars().any(|c| c.is_ascii_punctuation()));
}
#[test]
fn test_generate_temporary_password_is_random() {
let a = generate_temporary_password();
let b = generate_temporary_password();
assert_ne!(a, b, "a bootstrap password must never be a fixed literal");
}
#[test]
fn test_generate_temporary_password_stays_pasteable() {
for _ in 0..64 {
let password = generate_temporary_password();
assert!(
!password.chars().any(|c| HOSTILE.contains(c)),
"shell/URL metacharacters must not appear in a pasted bootstrap password"
);
}
}
#[test]
fn test_generate_temporary_password_pools_are_paste_safe() {
for pool in [TEMP_UPPER, TEMP_LOWER, TEMP_DIGIT, TEMP_SYMBOL] {
for &byte in pool {
assert!(
!HOSTILE.contains(char::from(byte)),
"character pool contains a paste-hostile character: {:?}",
char::from(byte)
);
}
}
}
#[test]
fn test_generate_temporary_password_satisfies_strength_rules() {
for _ in 0..64 {
let password = generate_temporary_password();
assert!(
crate::service::login::validate_password(&password).is_ok(),
"generated bootstrap passwords must pass the kernel's strength rules"
);
}
}
}