//! Passwords, sessions, and token minting. //! //! Logging in *is* pairing. There is no QR code, no enrollment token, no //! out-of-band step: a phone posts credentials and gets back a `token_id` and a //! 32-byte key, which is exactly the mental model of a browser session. A //! reinstall is just another login, and because positions belong to the account, //! the new token writes into the same continuous history. use std::net::IpAddr; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; // `SaltString::generate` wants the rand_core that password-hash was built // against, which is not the same major version as the `rand` used elsewhere in // this crate. Importing it through argon2 keeps the two from being confused. use argon2::password_hash::rand_core::OsRng as PhOsRng; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::{Algorithm, Argon2, Params, Version}; use dashmap::DashMap; use sqlx::SqlitePool; use crate::config::Config; use crate::db::now; use crate::ingest::{Ingest, TokenSlot}; use crate::keys::{KeyVault, random_token_id, random_token_key}; use otproto::RevokeReason; /// Failed logins allowed per (IP, username) before the pair is locked out. const MAX_ATTEMPTS: u32 = 5; const ATTEMPT_WINDOW: Duration = Duration::from_secs(15 * 60); /// An argon2 hash of a throwaway password, used to spend the same CPU on an /// unknown username as on a known one. /// /// Without this, "user not found" returns in microseconds while a real user's /// verify takes ~50 ms, and the difference enumerates the whole user list. const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$\ Fj5r5rD/hqCvQeYqNSlF9y5FZbTCUYPl5jrCLj/eKz0"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthError { /// Wrong username, wrong password, or a disabled account — deliberately not /// distinguished, so a caller cannot learn which usernames exist. Invalid, /// Too many failures for this (IP, username) pair. LockedOut { retry_after_s: u64 }, } impl std::fmt::Display for AuthError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Invalid => write!(f, "invalid username or password"), Self::LockedOut { retry_after_s } => { write!(f, "too many attempts; try again in {retry_after_s}s") } } } } impl std::error::Error for AuthError {} #[derive(Debug, Default)] struct Attempts { count: u32, window_started: Option, } /// Per-(IP, username) failed-login throttle. #[derive(Default)] pub struct LoginThrottle { attempts: DashMap<(IpAddr, String), Attempts>, } impl LoginThrottle { /// Keyed on the pair, not on either alone: keying on IP would let one shared /// office address lock out a whole team, and keying on username alone would /// let anyone lock a known user out on purpose. pub fn check(&self, ip: IpAddr, username: &str) -> Result<(), AuthError> { let key = (ip, username.to_lowercase()); let now = Instant::now(); if let Some(a) = self.attempts.get(&key) && let Some(started) = a.window_started && now.duration_since(started) <= ATTEMPT_WINDOW && a.count >= MAX_ATTEMPTS { return Err(AuthError::LockedOut { retry_after_s: (ATTEMPT_WINDOW - now.duration_since(started)).as_secs(), }); } Ok(()) } pub fn note_failure(&self, ip: IpAddr, username: &str) { let key = (ip, username.to_lowercase()); let now = Instant::now(); let mut entry = self.attempts.entry(key).or_default(); match entry.window_started { Some(started) if now.duration_since(started) <= ATTEMPT_WINDOW => entry.count += 1, _ => { entry.window_started = Some(now); entry.count = 1; } } } pub fn note_success(&self, ip: IpAddr, username: &str) { self.attempts.remove(&(ip, username.to_lowercase())); } pub fn gc(&self) { let now = Instant::now(); self.attempts.retain(|_, a| { a.window_started .is_some_and(|t| now.duration_since(t) <= ATTEMPT_WINDOW) }); } } /// Argon2id parameters from config. /// /// 19 MiB / 2 passes / 1 lane is OWASP's second recommended profile and fits a /// small VM. The parameters are stored inside each PHC hash string, so raising /// them later does not invalidate anything — [`verify`] re-hashes on the next /// successful login instead. fn hasher(cfg: &Config) -> Result> { let params = Params::new( cfg.argon2_memory_kib, cfg.argon2_iterations, cfg.argon2_parallelism, None, ) .map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?; Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) } /// Hash a password. Runs on a blocking thread: argon2 is deliberately expensive, /// and ~50 ms of CPU on an async worker would stall every other request on it. pub async fn hash_password(cfg: &Config, password: String) -> Result { let argon = hasher(cfg)?; tokio::task::spawn_blocking(move || { let salt = SaltString::generate(&mut PhOsRng); argon .hash_password(password.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|e| anyhow::anyhow!("hashing failed: {e}")) }) .await .context("hashing task panicked")? } /// Outcome of a verify, including whether the stored hash should be upgraded. pub struct Verified { pub ok: bool, /// A fresh hash under current policy, when the stored one used older params. pub rehashed: Option, } pub async fn verify(cfg: &Config, stored: String, password: String) -> Result { let argon = hasher(cfg)?; let want = Params::new( cfg.argon2_memory_kib, cfg.argon2_iterations, cfg.argon2_parallelism, None, ) .map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?; tokio::task::spawn_blocking(move || { let parsed = match PasswordHash::new(&stored) { Ok(p) => p, Err(_) => { // A corrupt hash must still cost the same time as a real one, or // the failure mode becomes an oracle. let dummy = PasswordHash::new(DUMMY_HASH).expect("the dummy hash is a literal"); let _ = argon.verify_password(password.as_bytes(), &dummy); return Ok(Verified { ok: false, rehashed: None, }); } }; if argon.verify_password(password.as_bytes(), &parsed).is_err() { return Ok(Verified { ok: false, rehashed: None, }); } // Transparent upgrade when policy has moved on since this hash was made. // // Only the three cost parameters are compared. A parsed `Params` also // carries an output length and optional keyid/data fields that the freshly // built one does not, so comparing whole structs would rehash on every // single login — an easy 50 ms tax to add by accident. let current: Params = (&parsed).try_into().unwrap_or_else(|_| want.clone()); let costs_differ = current.m_cost() != want.m_cost() || current.t_cost() != want.t_cost() || current.p_cost() != want.p_cost(); let rehashed = if costs_differ { let salt = SaltString::generate(&mut PhOsRng); argon .hash_password(password.as_bytes(), &salt) .ok() .map(|h| h.to_string()) } else { None }; Ok(Verified { ok: true, rehashed }) }) .await .context("verify task panicked")? } /// Spend the same CPU as a real verify would, then fail. /// /// Called when the username does not exist, so that the response time carries no /// information about which accounts are real. pub async fn dummy_verify(cfg: &Config, password: String) { let _ = verify(cfg, DUMMY_HASH.to_string(), password).await; } #[derive(Debug)] pub struct Account { pub id: i64, pub username: String, pub display_name: String, pub is_admin: bool, } /// Look up and authenticate a user. pub async fn authenticate( pool: &SqlitePool, writer: &crate::writer::WriteHandle, cfg: &Config, throttle: &LoginThrottle, ip: IpAddr, username: &str, password: String, ) -> Result { throttle.check(ip, username)?; let row: Option<(i64, String, String, String, i64, Option)> = sqlx::query_as( "SELECT id, username, display_name, pw_hash, is_admin, disabled_at \ FROM users WHERE username = ?", ) .bind(username) .fetch_optional(pool) .await .map_err(|_| AuthError::Invalid)?; let Some((id, username_stored, display_name, pw_hash, is_admin, disabled_at)) = row else { dummy_verify(cfg, password).await; throttle.note_failure(ip, username); return Err(AuthError::Invalid); }; // A disabled account still pays for a full verify, so disabling somebody is // not observable from outside. let verified = verify(cfg, pw_hash, password) .await .map_err(|_| AuthError::Invalid)?; if !verified.ok || disabled_at.is_some() { throttle.note_failure(ip, username); return Err(AuthError::Invalid); } if let Some(new_hash) = verified.rehashed { let _ = writer .send(crate::writer::WriteOp::Audit { user_id: Some(id), at: now(), action: "password_rehashed".into(), detail: String::new(), src_ip: Some(ip.to_string()), }) .await; // Best effort: a failed upgrade must not fail the login. let _ = sqlx::query("UPDATE users SET pw_hash = ? WHERE id = ?") .bind(new_hash) .bind(id) .execute(pool) .await; } throttle.note_success(ip, username); Ok(Account { id, username: username_stored, display_name, is_admin: is_admin != 0, }) } /// A freshly minted device credential. pub struct MintedToken { pub token_id: u64, pub token_key: [u8; 32], pub config_version: u16, } /// Mint a token for a login. /// /// The plaintext key is returned exactly once — here — and stored only wrapped. /// The caller must hand it to the device and then drop it. pub async fn mint_token( pool: &SqlitePool, vault: &KeyVault, ingest: &Arc, user_id: i64, name: &str, platform: &str, ip: IpAddr, ) -> Result { let token_id = random_token_id()?; let token_key = random_token_key()?; let wrapped = vault.wrap(token_id, &token_key)?; let config_version: u16 = 1; sqlx::query( "INSERT INTO tokens (token_id, user_id, key_wrapped, name, platform, config_version, \ created_at, created_ip) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(token_id as i64) .bind(user_id) .bind(&wrapped) .bind(name) .bind(platform) .bind(i64::from(config_version)) .bind(now()) .bind(ip.to_string()) .execute(pool) .await .context("inserting token")?; // Insert into the ingest cache before returning, so the device's very first // datagram is served — there is no window where a just-issued token looks // unknown. ingest.insert_token(TokenSlot::new( token_id, user_id, &token_key, config_version, )); Ok(MintedToken { token_id, token_key, config_version, }) } /// Load every usable token into the ingest cache. Called once at startup. /// /// A token whose key cannot be unwrapped is skipped with a warning rather than /// aborting startup: that is what a botched `OT_SECRET_KEY` rotation looks like, /// and refusing to boot would turn a recoverable mistake into an outage. pub async fn load_tokens( pool: &SqlitePool, vault: &KeyVault, ingest: &Arc, ) -> Result { // Revoked tokens are loaded too, flagged. Their keys authorise nothing; they // exist so a phone that has not noticed yet gets a sealed answer instead of // silence. A disabled account is treated as a revocation, since the effect on // the device is the same. /// `(token_id, user_id, key_wrapped, config_version, revoked_at, disabled_at)`. type TokenRow = (i64, i64, Vec, i64, Option, Option); let rows: Vec = sqlx::query_as( "SELECT t.token_id, t.user_id, t.key_wrapped, t.config_version, t.revoked_at, \ u.disabled_at \ FROM tokens t JOIN users u ON u.id = t.user_id", ) .fetch_all(pool) .await .context("loading tokens")?; let mut loaded = 0; let mut revoked = 0; for (token_id, user_id, wrapped, config_version, revoked_at, disabled_at) in rows { match vault.unwrap(token_id as u64, &wrapped) { Ok(key) => { let slot = TokenSlot::new(token_id as u64, user_id, &key, config_version as u16); if revoked_at.is_some() || disabled_at.is_some() { ingest.insert_token(slot.revoked(RevokeReason::Revoked)); revoked += 1; } else { ingest.insert_token(slot); loaded += 1; } } Err(e) => tracing::warn!(token_id, error = %e, "skipping token with an unusable key"), } } tracing::debug!(revoked, "revoked tokens kept so their devices can be told"); Ok(loaded) } /// Revoke one token: database row, then ingest cache. /// /// The row and its wrapped key are kept, and the cache slot is flagged rather /// than dropped. That is deliberate: the key is the only thing that lets the /// server tell this device it has been logged out, in a message no third party /// could forge. Dropping it would leave silence as the only safe answer. pub async fn revoke_token(pool: &SqlitePool, ingest: &Arc, token_id: i64) -> Result { let affected = sqlx::query("UPDATE tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL") .bind(now()) .bind(token_id) .execute(pool) .await .context("revoking token")? .rows_affected(); ingest.mark_revoked(token_id as u64, RevokeReason::Revoked); Ok(affected > 0) } /// Revoke every token for an account except optionally one. /// /// This is what "log out all other devices" and a password change both do. pub async fn revoke_other_tokens( pool: &SqlitePool, ingest: &Arc, user_id: i64, keep: Option, ) -> Result { let ids: Vec = sqlx::query_scalar( "SELECT token_id FROM tokens WHERE user_id = ? AND revoked_at IS NULL AND token_id IS NOT ?", ) .bind(user_id) .bind(keep) .fetch_all(pool) .await .context("listing tokens to revoke")?; for id in &ids { revoke_token(pool, ingest, *id).await?; } Ok(ids.len()) } #[cfg(test)] mod tests { use super::*; use crate::db::Db; const IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)); /// Cheap argon2 parameters: these tests are about logic, not about how long a /// hash takes, and the real parameters make the suite unbearably slow. fn cfg() -> Config { Config { argon2_memory_kib: 8, argon2_iterations: 1, argon2_parallelism: 1, admin_email: "ops@example.net".into(), ..Config::default() } } async fn fixture() -> ( Db, Arc, crate::writer::WriteHandle, tempfile::TempDir, ) { let dir = tempfile::tempdir().expect("temp dir"); let db = Db::open(&dir.path().join("t.db")).await.expect("open"); let (writer, _task) = crate::writer::spawn(db.write.clone()); let ingest = Arc::new(Ingest::new(writer.clone(), 30 * 86_400, None)); (db, ingest, writer, dir) } async fn make_user(db: &Db, cfg: &Config, username: &str, password: &str) -> i64 { let hash = hash_password(cfg, password.to_string()) .await .expect("hash"); let n = now(); sqlx::query( "INSERT INTO users (username, pw_hash, display_name, created_at, pw_changed_at) \ VALUES (?, ?, ?, ?, ?)", ) .bind(username) .bind(hash) .bind(username) .bind(n) .bind(n) .execute(&db.write) .await .expect("user"); sqlx::query_scalar("SELECT id FROM users WHERE username = ?") .bind(username) .fetch_one(&db.read) .await .expect("id") } #[tokio::test] async fn a_password_verifies_and_a_wrong_one_does_not() { let cfg = cfg(); let hash = hash_password(&cfg, "correct horse".into()) .await .expect("hash"); assert!( verify(&cfg, hash.clone(), "correct horse".into()) .await .expect("v") .ok ); assert!( !verify(&cfg, hash, "wrong horse".into()) .await .expect("v") .ok ); } #[tokio::test] async fn hashes_are_salted() { let cfg = cfg(); let a = hash_password(&cfg, "same".into()).await.expect("hash"); let b = hash_password(&cfg, "same".into()).await.expect("hash"); assert_ne!(a, b, "two hashes of one password must differ"); } #[tokio::test] async fn a_corrupt_stored_hash_fails_closed() { let cfg = cfg(); let v = verify(&cfg, "not a phc string".into(), "anything".into()) .await .expect("v"); assert!(!v.ok); } #[tokio::test] async fn a_hash_with_stale_parameters_is_upgraded_on_login() { // Hash under weak parameters, then verify under stronger ones. let weak = cfg(); let hash = hash_password(&weak, "pw".into()).await.expect("hash"); let strong = Config { argon2_iterations: 3, ..weak }; let v = verify(&strong, hash, "pw".into()).await.expect("v"); assert!(v.ok); let rehashed = v .rehashed .expect("stale parameters should produce a new hash"); assert!( rehashed.contains("t=3"), "the new hash should use current parameters: {rehashed}" ); } #[tokio::test] async fn a_hash_with_current_parameters_is_not_rehashed() { let cfg = cfg(); let hash = hash_password(&cfg, "pw".into()).await.expect("hash"); let v = verify(&cfg, hash, "pw".into()).await.expect("v"); assert!(v.ok); assert!( v.rehashed.is_none(), "no upgrade needed, so no needless write" ); } #[tokio::test] async fn usernames_are_case_insensitive() { let (db, _ingest, writer, _dir) = fixture().await; let cfg = cfg(); make_user(&db, &cfg, "Marc", "pw").await; let throttle = LoginThrottle::default(); let account = authenticate(&db.read, &writer, &cfg, &throttle, IP, "MARC", "pw".into()) .await .expect("should authenticate regardless of case"); assert_eq!( account.username, "Marc", "the stored spelling is what is returned" ); } #[tokio::test] async fn a_disabled_account_cannot_log_in() { let (db, _ingest, writer, _dir) = fixture().await; let cfg = cfg(); let id = make_user(&db, &cfg, "gone", "pw").await; sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?") .bind(now()) .bind(id) .execute(&db.write) .await .expect("disable"); let throttle = LoginThrottle::default(); let err = authenticate(&db.read, &writer, &cfg, &throttle, IP, "gone", "pw".into()) .await .expect_err("must be refused"); assert_eq!( err, AuthError::Invalid, "a disabled account must be indistinguishable from a wrong password" ); } #[tokio::test] async fn repeated_failures_lock_the_pair_out() { let (db, _ingest, writer, _dir) = fixture().await; let cfg = cfg(); make_user(&db, &cfg, "victim", "pw").await; let throttle = LoginThrottle::default(); for _ in 0..MAX_ATTEMPTS { assert_eq!( authenticate( &db.read, &writer, &cfg, &throttle, IP, "victim", "bad".into() ) .await .expect_err("wrong password"), AuthError::Invalid ); } let err = authenticate( &db.read, &writer, &cfg, &throttle, IP, "victim", "pw".into(), ) .await .expect_err("should be locked out even with the right password"); assert!(matches!(err, AuthError::LockedOut { .. })); } #[tokio::test] async fn a_lockout_does_not_spread_to_other_addresses() { let (db, _ingest, writer, _dir) = fixture().await; let cfg = cfg(); make_user(&db, &cfg, "victim", "pw").await; let throttle = LoginThrottle::default(); for _ in 0..MAX_ATTEMPTS { let _ = authenticate( &db.read, &writer, &cfg, &throttle, IP, "victim", "bad".into(), ) .await; } let elsewhere = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 1)); authenticate( &db.read, &writer, &cfg, &throttle, elsewhere, "victim", "pw".into(), ) .await .expect("another address must not be locked out — otherwise this is a DoS on the user"); } #[tokio::test] async fn a_successful_login_clears_the_failure_count() { let (db, _ingest, writer, _dir) = fixture().await; let cfg = cfg(); make_user(&db, &cfg, "u", "pw").await; let throttle = LoginThrottle::default(); for _ in 0..MAX_ATTEMPTS - 1 { let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await; } authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into()) .await .expect("still allowed"); for _ in 0..MAX_ATTEMPTS - 1 { let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await; } authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into()) .await .expect("the counter should have been reset by the successful login"); } #[tokio::test] async fn minting_a_token_makes_it_immediately_usable() { let (db, ingest, _writer, _dir) = fixture().await; let cfg = cfg(); let user_id = make_user(&db, &cfg, "u", "pw").await; let vault = KeyVault::for_test([9; 32]); let minted = mint_token(&db.write, &vault, &ingest, user_id, "Pixel", "android", IP) .await .expect("mint"); // A device that logs in and immediately sends a LOC must be served: there // must be no window where a just-issued token looks unknown. assert!( ingest .key_for(minted.token_id, otproto::msg::Direction::Up) .is_some(), "the token must be in the ingest cache before mint_token returns" ); // And the stored key must round-trip through the vault. let wrapped: Vec = sqlx::query_scalar("SELECT key_wrapped FROM tokens WHERE token_id = ?") .bind(minted.token_id as i64) .fetch_one(&db.read) .await .expect("wrapped"); assert_eq!( vault.unwrap(minted.token_id, &wrapped).expect("unwrap"), minted.token_key ); assert_ne!( wrapped.as_slice(), minted.token_key.as_slice(), "the plaintext key must never be what is stored" ); } #[tokio::test] async fn tokens_reload_into_the_cache_at_startup() { let (db, ingest, _writer, _dir) = fixture().await; let cfg = cfg(); let user_id = make_user(&db, &cfg, "u", "pw").await; let vault = KeyVault::for_test([9; 32]); let a = mint_token(&db.write, &vault, &ingest, user_id, "A", "android", IP) .await .expect("a"); let b = mint_token(&db.write, &vault, &ingest, user_id, "B", "android", IP) .await .expect("b"); revoke_token(&db.write, &ingest, b.token_id as i64) .await .expect("revoke"); // Simulate a restart: a fresh cache, repopulated from the database. let (writer2, _t) = crate::writer::spawn(db.write.clone()); let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None)); let loaded = load_tokens(&db.read, &vault, &fresh).await.expect("load"); assert_eq!(loaded, 1, "a revoked token must not come back as active"); assert_eq!(fresh.active_token_count(), 1); assert!( fresh .key_for(a.token_id, otproto::msg::Direction::Up) .is_some() ); // The revoked token keeps its key across a restart, flagged. Without it // the phone that still holds that token could only be met with silence, // and silence is indistinguishable from a network fault. assert!( fresh .key_for(b.token_id, otproto::msg::Direction::Up) .is_some(), "a revoked token must keep its key so its device can be told" ); } #[tokio::test] async fn revoke_others_keeps_the_current_token_only() { let (db, ingest, _writer, _dir) = fixture().await; let cfg = cfg(); let user_id = make_user(&db, &cfg, "u", "pw").await; let vault = KeyVault::for_test([9; 32]); let keep = mint_token(&db.write, &vault, &ingest, user_id, "keep", "android", IP) .await .expect("k"); for name in ["a", "b", "c"] { mint_token(&db.write, &vault, &ingest, user_id, name, "android", IP) .await .expect("m"); } assert_eq!(ingest.active_token_count(), 4); let revoked = revoke_other_tokens(&db.write, &ingest, user_id, Some(keep.token_id as i64)) .await .expect("revoke others"); assert_eq!(revoked, 3); assert_eq!(ingest.active_token_count(), 1); assert!( ingest .key_for(keep.token_id, otproto::msg::Direction::Up) .is_some() ); } #[tokio::test] async fn a_token_belonging_to_a_disabled_user_is_not_loaded() { let (db, ingest, _writer, _dir) = fixture().await; let cfg = cfg(); let user_id = make_user(&db, &cfg, "u", "pw").await; let vault = KeyVault::for_test([9; 32]); mint_token(&db.write, &vault, &ingest, user_id, "phone", "android", IP) .await .expect("m"); sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?") .bind(now()) .bind(user_id) .execute(&db.write) .await .expect("disable"); let (writer2, _t) = crate::writer::spawn(db.write.clone()); let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None)); assert_eq!( load_tokens(&db.read, &vault, &fresh).await.expect("load"), 0, "disabling an account must stop its phones, not just its browser logins" ); } #[test] fn the_dummy_hash_is_parseable() { // If this literal ever stops parsing, the unknown-username path silently // becomes fast and the timing oracle reopens. PasswordHash::new(DUMMY_HASH).expect("the dummy hash must be a valid PHC string"); } }