//! Who is asking. Sign-in is either a local password or a header set by whatever fronts //! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated //! address in `Cf-Access-Authenticated-User-Email`. use anyhow::{Result, bail}; use argon2::Argon2; use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}; /// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The /// salt is generated per password by the hasher itself. pub fn hash_password(password: &str) -> Result { if password.len() < 8 { bail!("password must be at least 8 characters"); } Argon2::default() .hash_password(password.as_bytes()) .map(|h| h.to_string()) .map_err(|e| anyhow::anyhow!("could not hash the password: {e}")) } /// False for a wrong password *and* for a stored hash this build cannot parse; either way /// the answer is no. pub fn verify_password(password: &str, stored: &str) -> bool { let Ok(parsed) = PasswordHash::new(stored) else { tracing::warn!("stored password hash is unreadable; refusing the sign-in"); return false; }; Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() } /// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy. pub fn new_session_token() -> String { let mut bytes = [0u8; 32]; if getrandom(&mut bytes).is_err() { // Falling back to the clock would be a predictable session id. Better to fail. panic!("no source of randomness for a session token"); } bytes.iter().map(|b| format!("{b:02x}")).collect() } fn getrandom(buf: &mut [u8]) -> std::io::Result<()> { use std::io::Read; std::fs::File::open("/dev/urandom")?.read_exact(buf) } /// A username taken from a proxy header. Cloudflare sends an email address; the local part /// is what a person recognises, and the whole thing stays unique enough for one household. pub fn name_from_header(raw: &str) -> Option { let name = raw.trim(); if name.is_empty() || name.len() > 190 { return None; } // Anything that could confuse a lookup or a log line is not a name. if name.chars().any(|c| c.is_control() || c == ',' || c == ';') { return None; } Some(name.to_ascii_lowercase()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_password_verifies_only_against_itself() { let h = hash_password("correct horse battery").unwrap(); assert!(verify_password("correct horse battery", &h)); assert!(!verify_password("Correct horse battery", &h)); assert!(!verify_password("", &h)); // A hash from a different scheme, or a truncated one, must not authenticate. assert!(!verify_password("correct horse battery", "not-a-hash")); assert!(hash_password("short").is_err()); } #[test] fn session_tokens_are_long_and_distinct() { let a = new_session_token(); let b = new_session_token(); assert_eq!(a.len(), 64); assert_ne!(a, b); } #[test] fn a_header_name_is_cleaned_or_refused() { assert_eq!(name_from_header(" Ray@Example.COM "), Some("ray@example.com".into())); assert_eq!(name_from_header(""), None); assert_eq!(name_from_header("ray\nadmin"), None); assert_eq!(name_from_header("ray;admin"), None); } }