//! Password authentication is confined to the control plane. //! Expensive Argon2 work runs on bounded blocking workers, not Tokio network workers. mod files; mod store; use crate::{db::Store, runtime::ApiError}; use anyhow::{ensure, Context, Result}; use argon2::{ password_hash::{phc::PasswordHash, PasswordHasher, PasswordVerifier}, Algorithm, Argon2, Params, Version, }; pub use files::{ load_or_create_setup_token, load_setup_token, lock_directory, prepare_database, prepare_directory, try_lock_directory, }; use http::{header::COOKIE, HeaderMap, StatusCode}; use rand::{rngs::SysRng, TryRng}; use sha2::{Digest, Sha256}; use std::{ path::PathBuf, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; pub use store::AdminRecord; use subtle::ConstantTimeEq; use tokio::sync::Semaphore; use zeroize::Zeroizing; pub const COOKIE_NAME: &str = "rustproxy_session"; pub const SESSION_SECONDS: i64 = 30 * 24 * 60 * 60; pub const IDLE_SECONDS: i64 = SESSION_SECONDS; #[derive(Clone)] pub struct Session { pub token_hash: String, pub csrf: String, pub username: String, pub expires_at: i64, pub generation: i64, } pub struct SessionToken { pub secret: String, pub session: Session, } pub struct Auth { pub store: Store, data_dir: PathBuf, setup_token: Option, dummy_hash: String, workers: Arc, } pub fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() .min(i64::MAX as u64) as i64 } pub fn random_secret() -> String { let mut bytes = [0_u8; 32]; SysRng .try_fill_bytes(&mut bytes) .expect("secure operating system random source unavailable"); bytes.iter().map(|b| format!("{b:02x}")).collect() } pub fn secret_hash(secret: &str) -> String { format!("{:x}", Sha256::digest(secret.as_bytes())) } pub fn equal_secret(candidate: &str, expected: &str) -> bool { candidate.len() == expected.len() && bool::from(candidate.as_bytes().ct_eq(expected.as_bytes())) } pub fn valid_secret(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) } pub fn cookie_secret(headers: &HeaderMap) -> Option { let mut found = None; for value in headers.get_all(COOKIE).iter() { for part in value.to_str().ok()?.split(';') { let Some((key, value)) = part.trim().split_once('=') else { continue; }; if key == COOKIE_NAME { if found.is_some() || !valid_secret(value) { return None; } found = Some(value.to_owned()); } } } found } pub fn session_cookie(secret: &str, secure: bool, max_age_seconds: i64) -> String { let max_age_seconds = max_age_seconds.max(1); format!( "{COOKIE_NAME}={secret}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age_seconds}{}", if secure { "; Secure" } else { "" } ) } pub fn expired_cookie(secure: bool) -> String { format!( "{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{}", if secure { "; Secure" } else { "" } ) } pub fn username(value: &str) -> Result { let value = value.trim().to_ascii_lowercase(); ensure!( (3..=64).contains(&value.len()) && value .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')), "auth.invalid_username" ); Ok(value) } pub fn validate_password(value: &str) -> Result<()> { ensure!( (12..=128).contains(&value.len()) && !value.contains(['\n', '\r', '\0']), "auth.password_policy" ); Ok(()) } pub(crate) fn hash_sync(password: &str) -> Result { let params = Params::new(19_456, 2, 1, None).context("Argon2 parameters")?; Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params) .hash_password(password.as_bytes())? .to_string()) } fn verify_sync(password: &str, encoded: &str) -> bool { PasswordHash::new(encoded).is_ok() && Argon2::default() .verify_password(password.as_bytes(), encoded) .is_ok() } pub async fn hash_password_offline(password: String) -> Result { validate_password(&password)?; let password = Zeroizing::new(password); tokio::task::spawn_blocking(move || hash_sync(&password)) .await .context("password worker failed")? } pub fn internal(error: impl std::fmt::Display) -> ApiError { tracing::error!(%error, "admin operation failed"); ApiError(StatusCode::SERVICE_UNAVAILABLE, "auth.unavailable".into()) } fn failure(status: StatusCode, code: &str) -> ApiError { ApiError(status, code.into()) } impl Auth { pub async fn new(store: Store, data_dir: PathBuf) -> Result> { let setup_token = if store.admin_record().await?.is_none() { Some(load_or_create_setup_token(&data_dir)?) } else { None }; // Generate valid dummy work for unknown usernames. This happens only at startup. let dummy_hash = hash_password_offline(random_secret()).await?; Ok(Arc::new(Self { store, data_dir, setup_token, dummy_hash, workers: Arc::new(Semaphore::new(2)), })) } async fn attempt(&self) -> Result<(), ApiError> { if !self .store .consume_auth_attempt(now()) .await .map_err(internal)? { return Err(failure(StatusCode::TOO_MANY_REQUESTS, "auth.rate_limited")); } Ok(()) } async fn hash(&self, password: String) -> Result { validate_password(&password) .map_err(|_| failure(StatusCode::UNPROCESSABLE_ENTITY, "auth.password_policy"))?; let permit = self .workers .clone() .try_acquire_owned() .map_err(|_| failure(StatusCode::TOO_MANY_REQUESTS, "auth.busy"))?; let password = Zeroizing::new(password); tokio::task::spawn_blocking(move || { let _permit = permit; hash_sync(&password) }) .await .map_err(internal)? .map_err(internal) } async fn verify(&self, password: String, encoded: String) -> Result { if password.len() > 128 { return Ok(false); } let permit = self .workers .clone() .try_acquire_owned() .map_err(|_| failure(StatusCode::TOO_MANY_REQUESTS, "auth.busy"))?; let password = Zeroizing::new(password); tokio::task::spawn_blocking(move || { let _permit = permit; verify_sync(&password, &encoded) }) .await .map_err(internal) } pub async fn setup( &self, supplied_token: &str, name: &str, password: String, ) -> Result<(), ApiError> { self.attempt().await?; if self.store.admin_record().await.map_err(internal)?.is_some() { return Err(failure(StatusCode::CONFLICT, "auth.already_initialized")); } if !self .setup_token .as_ref() .is_some_and(|expected| equal_secret(supplied_token, expected)) { self.store .audit("anonymous", "setup.denied", "invalid setup capability") .await .map_err(internal)?; return Err(failure(StatusCode::FORBIDDEN, "auth.invalid_setup_token")); } let name = username(name) .map_err(|_| failure(StatusCode::UNPROCESSABLE_ENTITY, "auth.invalid_username"))?; let hash = self.hash(password).await?; if !self .store .initialize_admin(&name, &hash, "browser setup") .await .map_err(internal)? { return Err(failure(StatusCode::CONFLICT, "auth.already_initialized")); } self.remove_bootstrap_files().await; Ok(()) } pub async fn remove_bootstrap_files(&self) { for name in ["setup-token", "admin-token"] { match tokio::fs::remove_file(self.data_dir.join(name)).await { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { tracing::warn!(%error, file = name, "unused bootstrap file could not be removed; it has no authority after setup") } } } } pub async fn login( &self, name: &str, password: String, previous: Option<&str>, session_seconds: i64, ) -> Result { self.attempt().await?; let record = self.store.admin_record().await.map_err(internal)?; let candidate = username(name).unwrap_or_default(); let matches = record.as_ref().is_some_and(|r| r.username == candidate); let hash = record .as_ref() .filter(|_| matches) .map(|r| r.password_hash.clone()) .unwrap_or_else(|| self.dummy_hash.clone()); let verified = self.verify(password, hash).await?; if !verified || !matches { self.store .audit("anonymous", "login.denied", "invalid credentials") .await .map_err(internal)?; return Err(failure( StatusCode::UNAUTHORIZED, "auth.invalid_credentials", )); } let record = record.ok_or_else(|| failure(StatusCode::UNAUTHORIZED, "auth.invalid_credentials"))?; self.store .create_browser_session(record.generation, previous, session_seconds) .await .map_err(internal)? .ok_or_else(|| failure(StatusCode::UNAUTHORIZED, "auth.credentials_changed")) } pub async fn change_password( &self, session: &Session, current: String, replacement: String, ) -> Result<(), ApiError> { self.attempt().await?; let record = self .store .admin_record() .await .map_err(internal)? .ok_or_else(|| failure(StatusCode::UNAUTHORIZED, "auth.required"))?; if record.generation != session.generation || !self.verify(current, record.password_hash).await? { return Err(failure( StatusCode::UNAUTHORIZED, "auth.invalid_credentials", )); } let hash = self.hash(replacement).await?; if !self .store .reset_admin_password(&hash, session.generation, "browser password change") .await .map_err(internal)? { return Err(failure(StatusCode::CONFLICT, "auth.credentials_changed")); } Ok(()) } } #[cfg(test)] include!("tests.rs");