//! Authentication and authorisation (hub spec §5, §15.3): passwords, //! session cookies, CSRF tokens, TOTP, personal access tokens, roles, and //! the viewer extractor pages use. pub mod phone; pub mod roles; pub mod totp; pub mod viewer; pub use roles::{Access, Role}; pub use viewer::{Viewer, ViewerAccount}; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::Argon2; use rand::RngCore; use std::path::Path; use subtle::ConstantTimeEq; /// The key that signs cookies and CSRF tokens, kept in `/keys/cookie.key`. #[derive(Clone)] pub struct CookieKey([u8; 32]); impl CookieKey { pub fn load_or_create(keys: &Path) -> anyhow::Result { std::fs::create_dir_all(keys)?; let path = keys.join("cookie.key"); if let Ok(bytes) = std::fs::read(&path) { if bytes.len() == 32 { return Ok(CookieKey(bytes.try_into().unwrap())); } } let mut k = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut k); std::fs::write(&path, k)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; } Ok(CookieKey(k)) } /// Keyed BLAKE3 over `message`, as hex. pub fn mac(&self, message: &[u8]) -> String { hex::encode(blake3::keyed_hash(&self.0, message).as_bytes()) } } /// Constant-time equality of two hex MACs. pub fn mac_equal(a: &str, b: &str) -> bool { a.len() == b.len() && a.as_bytes().ct_eq(b.as_bytes()).into() } pub fn hash_password(password: &str) -> anyhow::Result { let salt = SaltString::generate(&mut rand::rngs::OsRng); Ok(Argon2::default().hash_password(password.as_bytes(), &salt).map_err(|e| anyhow::anyhow!("{e}"))?.to_string()) } pub fn verify_password(hash: &str, password: &str) -> bool { match PasswordHash::new(hash) { Ok(parsed) => Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok(), Err(_) => false, } } /// A password policy that is honest rather than theatrical: length only. pub fn password_ok(password: &str) -> Result<(), &'static str> { if password.chars().count() < 10 { return Err("a password is at least 10 characters"); } if password.len() > 256 { return Err("a password is at most 256 bytes"); } Ok(()) } pub fn random_hex(bytes: usize) -> String { let mut b = vec![0u8; bytes]; rand::rngs::OsRng.fill_bytes(&mut b); hex::encode(b) } /// A personal access token: `oxyhub_` plus 32 random bytes as hex. Stored /// as its BLAKE3 hash. pub fn new_token() -> (String, String) { let secret = format!("oxyhub_{}", random_hex(32)); (secret.clone(), token_hash(&secret)) } pub fn token_hash(secret: &str) -> String { hex::encode(blake3::hash(secret.as_bytes()).as_bytes()) } /// A new session id for the cookie (oxygenhub#223): random, and kept on /// the server only as its [`token_hash`]. pub fn new_session_id() -> String { random_hex(32) } /// Proof, for the code step of a sign-in with two-factor authentication, /// that `username` gave the right password: `UNTIL.MAC`, good until `until` /// (ms) and void as soon as the account's session salt changes (a new /// password, or everyone signed out), so the code step need not ask for /// the password again. pub fn pending_login(key: &CookieKey, username: &str, session_salt: &str, until: i64) -> String { format!("{until}.{}", key.mac(format!("pending-login:{username}:{session_salt}:{until}").as_bytes())) } /// Whether `token` is a [`pending_login`] for this account still in time. pub fn pending_login_ok(key: &CookieKey, username: &str, session_salt: &str, token: &str, now: i64) -> bool { let Some((until, _)) = token.split_once('.') else { return false }; let Ok(until) = until.parse::() else { return false }; until > now && mac_equal(token, &pending_login(key, username, session_salt, until)) } /// The CSRF token bound to a session value. pub fn csrf_token(key: &CookieKey, session_value: &str) -> String { key.mac(format!("csrf:{session_value}").as_bytes()) } pub const SESSION_COOKIE: &str = "oxyhub"; #[cfg(test)] mod tests { use super::*; #[test] fn passwords_and_tokens() { let h = hash_password("correct horse battery").unwrap(); assert!(verify_password(&h, "correct horse battery")); assert!(!verify_password(&h, "wrong")); assert!(!verify_password("garbage", "x")); assert!(password_ok("short").is_err()); let (secret, hash) = new_token(); assert!(secret.starts_with("oxyhub_")); assert_eq!(token_hash(&secret), hash); assert!(mac_equal("ab", "ab") && !mac_equal("ab", "ac") && !mac_equal("a", "ab")); } #[test] fn cookie_key_persists() { let dir = tempfile::tempdir().unwrap(); let a = CookieKey::load_or_create(dir.path()).unwrap(); let b = CookieKey::load_or_create(dir.path()).unwrap(); assert_eq!(a.mac(b"x"), b.mac(b"x")); let v = new_session_id(); assert_eq!(v.len(), 64); assert_ne!(v, new_session_id()); assert_ne!(csrf_token(&a, &v), csrf_token(&a, "other")); } }