//! 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() } /// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong /// passwords at a time, run on the async workers, made every other request wait 4s (#137). /// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest. static CHECKS: std::sync::LazyLock = std::sync::LazyLock::new(|| { tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1))) }); /// What a name that is not an account is checked against, so that refusing it takes as long as /// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the /// time told anyone which names are accounts here (#138). static DECOY: std::sync::LazyLock = std::sync::LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password")); /// A sign-in's password against the account's hash, or against `DECOY` when there is no account /// or it has no password: false either way, in the same time. Off the async workers, and a few at /// a time; see `CHECKS`. pub async fn check_password(password: String, stored: Option) -> bool { let Ok(_turn) = CHECKS.acquire().await else { return false }; tokio::task::spawn_blocking(move || match stored { Some(h) => verify_password(&password, &h), None => { verify_password(&password, &DECOY); false } }) .await .unwrap_or(false) } /// 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() } /// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log /// or a file says what it opens (#123). pub fn new_api_token() -> String { format!("ipx_{}", new_session_token()) } /// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a /// password, so there is nothing to guess from a stolen hash. pub fn api_token_hash(token: &str) -> String { use sha2::Digest; sha2::Sha256::digest(token.as_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::*; #[tokio::test] async fn a_sign_in_without_an_account_is_checked_all_the_same() { let h = hash_password("correct horse battery").unwrap(); assert!(check_password("correct horse battery".into(), Some(h.clone())).await); assert!(!check_password("wrong".into(), Some(h)).await); assert!(!check_password("correct horse battery".into(), None).await); // A decoy that does not parse is refused before any hashing, and the time says so (#138). assert!(PasswordHash::new(&DECOY).is_ok()); } #[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 api_tokens_are_prefixed_and_hash_to_hex() { let t = new_api_token(); assert!(t.starts_with("ipx_") && t.len() == 68); let h = api_token_hash(&t); assert_eq!(h.len(), 64); assert_eq!(h, api_token_hash(&t), "the same token, the same hash"); assert_ne!(h, api_token_hash(&new_api_token())); assert_eq!( api_token_hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "SHA-256" ); } #[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); } }