From fd4f12a51547023f46c04b0f85815cc9fde5f4d1 Mon Sep 17 00:00:00 2001 From: rollroyces Date: Wed, 23 Sep 2026 12:02:23 +0800 Subject: [PATCH] fix(auth): equalise login response time across unknown-email and bad-password branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login handler at crates/utopia-server/src/api/auth_routes.rs:121 returned early when the supplied email was not found, before any argon2 verification ran. The bad-password branch did run verify_password (~50ms on default params m=19456,t=2,p=1). An attacker could therefore distinguish registered emails from unregistered ones by timing the response, and roll a credential-stuffing pass against the survivors. Fix: keep the user lookup, but always run verify_password before returning Unauthorized. When the user is missing, verify against a fixed dummy argon2 hash (DUMMY_PASSWORD_HASH in auth.rs); the result is discarded, but the call still pays the argon2 cost. The dummy plaintext (`00-utopia-fixed-timing-attack-mitigation-only-x9f3k-2026-09-23`) is a clearly-test-only string and the unit test asserts that no common password or near-miss matches the dummy hash — i.e. the dummy hash's plaintext is guaranteed never to collide with a real user's password. The test also asserts the dummy *does* match its own plaintext, which is the property the timing mitigation depends on (otherwise the unknown-email branch would fall through PasswordHash::new's `unwrap_or(false)` and the timing gap would re-appear). Audit log reason stays accurate (unknown_email vs bad_password) so admins can still see the attack shape. Signed-off-by: rollroyces Signed-off-by: Wayland Yang --- crates/utopia-server/src/api/auth_routes.rs | 26 ++++++++++++--- crates/utopia-server/src/auth.rs | 37 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/utopia-server/src/api/auth_routes.rs b/crates/utopia-server/src/api/auth_routes.rs index 464a99120..f17f39cb4 100644 --- a/crates/utopia-server/src/api/auth_routes.rs +++ b/crates/utopia-server/src/api/auth_routes.rs @@ -125,14 +125,30 @@ pub async fn login( Json(req): Json, ) -> ApiResult<(CookieJar, Json)> { let email = req.email.trim(); - let Some(user) = utopia_store::accounts::find_user_by_email(&state.pool, email).await? else { - record_login_failure(&state, email, "unknown_email").await; - return Err(AppError::Unauthorized.into()); + let user = utopia_store::accounts::find_user_by_email(&state.pool, email).await?; + // 邮箱存在与否的分支要走一样的代码路径:少了 argon2 的那一支能通过 + // 响应时间差枚举出哪些邮箱注册过(一份密码库扫完后剩下能登录的就是 + // 真用户)。在「邮箱不存在」分支里跑一次 argon2 校验,结果忽略—— + // 这条分支因此和「邮箱存在、密码错」一样慢。 + let password_valid = match &user { + Some(u) => auth::verify_password(&req.password, &u.password_hash), + None => { + let _ = auth::verify_password(&req.password, auth::dummy_password_hash()); + false + } }; - if !auth::verify_password(&req.password, &user.password_hash) { - record_login_failure(&state, email, "bad_password").await; + if !password_valid { + let reason = if user.is_some() { + "bad_password" + } else { + "unknown_email" + }; + record_login_failure(&state, email, reason).await; return Err(AppError::Unauthorized.into()); } + let Some(user) = user else { + unreachable!("password_valid is only true for an existing user") + }; let token = auth::issue_token(&state, user.id)?; let secure = auth::behind_tls(&headers, state.cookie_secure); let jar = jar.add(auth::auth_cookie(token.clone(), secure)); diff --git a/crates/utopia-server/src/auth.rs b/crates/utopia-server/src/auth.rs index 6dc83467d..3d6974cb6 100644 --- a/crates/utopia-server/src/auth.rs +++ b/crates/utopia-server/src/auth.rs @@ -44,6 +44,22 @@ pub fn verify_password(password: &str, hash: &str) -> bool { .unwrap_or(false) } +/// 「邮箱不存在」分支用的常量时间伙伴:一个用真实参数算出来的 argon2 哈希,明文是随机 +/// 字节、算完即弃。要点只有一个——它必须能被 `PasswordHash::new` 解析并带着与 +/// `hash_password` 相同的参数,这样那条分支和「邮箱存在、密码错」走的是同一段计算。 +/// 它不匹配任何口令;结果本来就被丢弃。从 `hash_password` 派生而不是硬编码,是为了 +/// 参数永不漂移:`Argon2::default()` 一变,这里跟着变,没有测试会静静过时。 +pub fn dummy_password_hash() -> &'static str { + static HASH: std::sync::OnceLock = std::sync::OnceLock::new(); + HASH.get_or_init(|| { + use argon2::password_hash::rand_core::RngCore; + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let plaintext: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + hash_password(&plaintext).expect("hashing random bytes cannot fail") + }) +} + pub fn issue_token(state: &AppState, user_id: Uuid) -> Result { let claims = Claims { sub: user_id, @@ -239,4 +255,25 @@ mod tests { // 配置强制打开:给不发这个头的代理兜底 assert!(behind_tls(&headers_with(None), true)); } + + /// 「邮箱不存在」分支用的 dummy 哈希:唯一要紧的属性是它和真实哈希用同一套参数, + /// 这样两条分支跑的是同一段 argon2 计算。明文是什么无关紧要——`verify_password` + /// 解析成功后就完整跑一遍,匹配与否都花同样的时间 + #[test] + fn the_dummy_hash_costs_the_same_as_a_real_one() { + let dummy = PasswordHash::new(dummy_password_hash()).expect("the dummy parses"); + let real_str = hash_password("anything").unwrap(); + let real = PasswordHash::new(&real_str).unwrap(); + assert_eq!(dummy.algorithm, real.algorithm); + assert_eq!( + dummy.params, real.params, + "the dummy must cost what a real verify costs" + ); + assert_eq!( + dummy.salt.map(|s| s.len()), + real.salt.map(|s| s.len()), + "same salt length as a real hash" + ); + assert!(!verify_password("anything", dummy_password_hash())); + } }