max / makenotwork
11 files changed,
+115 insertions,
-57 deletions
| @@ -398,7 +398,12 @@ impl FromRequestParts<crate::AppState> for ServiceAuth { | |||
| 398 | 398 | /// | |
| 399 | 399 | /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms). | |
| 400 | 400 | /// Verification auto-detects params from the hash string, so no feature flag needed there. | |
| 401 | - | pub fn hash_password(password: &str) -> Result<String, AppError> { | |
| 401 | + | /// Synchronous Argon2id hash. CPU-bound (hundreds of ms); do NOT call from an | |
| 402 | + | /// async handler — use [`hash_password_async`], which runs this on a blocking | |
| 403 | + | /// thread so a burst of signups can't starve the Tokio worker pool. Kept | |
| 404 | + | /// `pub(crate)` for the async wrapper, the one-time `DUMMY_HASH` initializers, | |
| 405 | + | /// and tests. | |
| 406 | + | pub(crate) fn hash_password(password: &str) -> Result<String, AppError> { | |
| 402 | 407 | let salt = SaltString::generate(&mut OsRng); | |
| 403 | 408 | #[cfg(feature = "fast-tests")] | |
| 404 | 409 | let params = Params::new(8 * 1024, 1, 1, None) | |
| @@ -425,7 +430,12 @@ pub fn hash_password(password: &str) -> Result<String, AppError> { | |||
| 425 | 430 | /// else fails out at `Algorithm::try_from`. Forward-compatible with a | |
| 426 | 431 | /// future algorithm migration — when one lands, add a dispatch table | |
| 427 | 432 | /// instead of swapping the default instance under the verifier's feet. | |
| 428 | - | pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> { | |
| 433 | + | /// | |
| 434 | + | /// CPU-bound (hundreds of ms); do NOT call from an async handler — use | |
| 435 | + | /// [`verify_password_async`], which runs this on a blocking thread so concurrent | |
| 436 | + | /// logins can't starve the Tokio worker pool. Kept `pub(crate)` for the async | |
| 437 | + | /// wrapper, the timing-equalizer dummy verifies, and tests. | |
| 438 | + | pub(crate) fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> { | |
| 429 | 439 | // A stored hash that won't parse (corruption, or a non-Argon2 algorithm we | |
| 430 | 440 | // don't verify) is a server-side data problem, not a 500 for the user: treat | |
| 431 | 441 | // it as a non-match so login simply fails, and log it for ops. Returning an | |
| @@ -460,6 +470,25 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> { | |||
| 460 | 470 | .is_ok()) | |
| 461 | 471 | } | |
| 462 | 472 | ||
| 473 | + | /// Async wrapper for [`hash_password`]: runs the CPU-bound Argon2id work on the | |
| 474 | + | /// blocking thread pool so it never occupies a Tokio worker. This is the form | |
| 475 | + | /// handlers must use. | |
| 476 | + | pub async fn hash_password_async(password: String) -> Result<String, AppError> { | |
| 477 | + | tokio::task::spawn_blocking(move || hash_password(&password)) | |
| 478 | + | .await | |
| 479 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 hash task join: {e}")))? | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | /// Async wrapper for [`verify_password`]: runs the CPU-bound Argon2id verify on | |
| 483 | + | /// the blocking thread pool so concurrent logins can't starve the async runtime. | |
| 484 | + | /// This is the form handlers (including the timing-equalizer dummy verifies) | |
| 485 | + | /// must use. | |
| 486 | + | pub async fn verify_password_async(password: String, hash: String) -> Result<bool, AppError> { | |
| 487 | + | tokio::task::spawn_blocking(move || verify_password(&password, &hash)) | |
| 488 | + | .await | |
| 489 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 verify task join: {e}")))? | |
| 490 | + | } | |
| 491 | + | ||
| 463 | 492 | /// Store user in session with session regeneration to prevent fixation attacks | |
| 464 | 493 | #[tracing::instrument(skip_all, fields(user_id = %user.id))] | |
| 465 | 494 | pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> { |
| @@ -182,29 +182,38 @@ pub async fn verify_and_consume_backup_code( | |||
| 182 | 182 | // verifications (each ~46 MiB) on every attempt, which would hand an attacker | |
| 183 | 183 | // a memory-amplification lever on the 2FA endpoint. Brute force is bounded | |
| 184 | 184 | // separately by the shared failed-attempt lockout. | |
| 185 | - | let mut matched_id: Option<uuid::Uuid> = None; | |
| 186 | - | for (id, stored) in &rows { | |
| 187 | - | let is_match = if stored.starts_with("$argon2") { | |
| 188 | - | match PasswordHash::new(stored) { | |
| 189 | - | Ok(parsed) => Argon2::default() | |
| 190 | - | .verify_password(code.as_bytes(), &parsed) | |
| 191 | - | .is_ok(), | |
| 192 | - | Err(e) => { | |
| 193 | - | tracing::warn!(error = %e, "malformed argon2 backup code hash in DB; skipping"); | |
| 194 | - | false | |
| 185 | + | // Run the per-code Argon2 verifies on a blocking thread so a 2FA attempt | |
| 186 | + | // (up to N verifies) can't occupy a Tokio worker. | |
| 187 | + | let matched_id: Option<uuid::Uuid> = { | |
| 188 | + | let code = code.to_string(); | |
| 189 | + | let legacy_hmac = legacy_hmac.to_string(); | |
| 190 | + | tokio::task::spawn_blocking(move || { | |
| 191 | + | for (id, stored) in &rows { | |
| 192 | + | let is_match = if stored.starts_with("$argon2") { | |
| 193 | + | match PasswordHash::new(stored) { | |
| 194 | + | Ok(parsed) => Argon2::default() | |
| 195 | + | .verify_password(code.as_bytes(), &parsed) | |
| 196 | + | .is_ok(), | |
| 197 | + | Err(e) => { | |
| 198 | + | tracing::warn!(error = %e, "malformed argon2 backup code hash in DB; skipping"); | |
| 199 | + | false | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } else { | |
| 203 | + | // Legacy HMAC-SHA256 hex. Length-equality short-circuits | |
| 204 | + | // before the constant-time compare, matching the existing | |
| 205 | + | // behavior of `crypto::constant_time_compare`. | |
| 206 | + | crate::crypto::constant_time_compare(stored, &legacy_hmac) | |
| 207 | + | }; | |
| 208 | + | if is_match { | |
| 209 | + | return Some(*id); | |
| 195 | 210 | } | |
| 196 | 211 | } | |
| 197 | - | } else { | |
| 198 | - | // Legacy HMAC-SHA256 hex. Length-equality short-circuits before | |
| 199 | - | // the constant-time compare, matching the existing behavior of | |
| 200 | - | // `crypto::constant_time_compare`. | |
| 201 | - | crate::crypto::constant_time_compare(stored, legacy_hmac) | |
| 202 | - | }; | |
| 203 | - | if is_match { | |
| 204 | - | matched_id = Some(*id); | |
| 205 | - | break; | |
| 206 | - | } | |
| 207 | - | } | |
| 212 | + | None | |
| 213 | + | }) | |
| 214 | + | .await | |
| 215 | + | .map_err(|e| anyhow::anyhow!("backup-code verify task join: {e}"))? | |
| 216 | + | }; | |
| 208 | 217 | ||
| 209 | 218 | let Some(id) = matched_id else { | |
| 210 | 219 | return Ok(false); |
| @@ -10,7 +10,7 @@ use tower_sessions::Session; | |||
| 10 | 10 | use webauthn_rs::prelude::*; | |
| 11 | 11 | ||
| 12 | 12 | use crate::{ | |
| 13 | - | auth::{verify_password, AuthUser}, | |
| 13 | + | auth::{verify_password_async, AuthUser}, | |
| 14 | 14 | db::{self, PasskeyId}, | |
| 15 | 15 | error::{AppError, Result, ResultExt}, | |
| 16 | 16 | helpers::hx_toast, | |
| @@ -45,7 +45,7 @@ pub(super) async fn register_start( | |||
| 45 | 45 | let db_user = db::users::get_user_by_id(&state.db, user.id) | |
| 46 | 46 | .await? | |
| 47 | 47 | .ok_or(AppError::Unauthorized)?; | |
| 48 | - | if !verify_password(&form.password, &db_user.password_hash)? { | |
| 48 | + | if !verify_password_async(form.password.clone(), db_user.password_hash.clone()).await? { | |
| 49 | 49 | return Err(AppError::BadRequest("Incorrect password".to_string())); | |
| 50 | 50 | } | |
| 51 | 51 | // Enforce registration cap | |
| @@ -202,7 +202,7 @@ pub(super) async fn delete( | |||
| 202 | 202 | .await? | |
| 203 | 203 | .ok_or(AppError::Unauthorized)?; | |
| 204 | 204 | ||
| 205 | - | if !verify_password(&form.password, &db_user.password_hash)? { | |
| 205 | + | if !verify_password_async(form.password.clone(), db_user.password_hash.clone()).await? { | |
| 206 | 206 | return Err(AppError::BadRequest("Incorrect password".to_string())); | |
| 207 | 207 | } | |
| 208 | 208 |
| @@ -8,7 +8,7 @@ use axum::{ | |||
| 8 | 8 | use serde::Deserialize; | |
| 9 | 9 | ||
| 10 | 10 | use crate::{ | |
| 11 | - | auth::{verify_password, AuthUser}, | |
| 11 | + | auth::{verify_password_async, AuthUser}, | |
| 12 | 12 | constants::{BACKUP_CODE_COUNT, BACKUP_CODE_LENGTH, TOTP_DIGITS, TOTP_SKEW, TOTP_STEP}, | |
| 13 | 13 | db, | |
| 14 | 14 | error::{AppError, Result, ResultExt}, | |
| @@ -52,10 +52,20 @@ pub(super) async fn setup( | |||
| 52 | 52 | ||
| 53 | 53 | // Generate backup codes | |
| 54 | 54 | let backup_codes = generate_backup_codes(); | |
| 55 | - | let code_hashes: Vec<String> = backup_codes | |
| 56 | - | .iter() | |
| 57 | - | .map(|code| hash_backup_code(code, &state.config.signing_secret)) | |
| 58 | - | .collect(); | |
| 55 | + | // Hash the backup codes (Argon2id, one per code) on a blocking thread so the | |
| 56 | + | // batch can't occupy a Tokio worker. | |
| 57 | + | let code_hashes: Vec<String> = { | |
| 58 | + | let codes = backup_codes.clone(); | |
| 59 | + | let secret = state.config.signing_secret.clone(); | |
| 60 | + | tokio::task::spawn_blocking(move || { | |
| 61 | + | codes | |
| 62 | + | .iter() | |
| 63 | + | .map(|code| hash_backup_code(code, &secret)) | |
| 64 | + | .collect::<Vec<String>>() | |
| 65 | + | }) | |
| 66 | + | .await | |
| 67 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("backup-code hash task join: {e}")))? | |
| 68 | + | }; | |
| 59 | 69 | ||
| 60 | 70 | db::totp::create_backup_codes(&state.db, user.id, &code_hashes).await?; | |
| 61 | 71 | ||
| @@ -132,7 +142,7 @@ pub(super) async fn disable( | |||
| 132 | 142 | .await? | |
| 133 | 143 | .ok_or(AppError::Unauthorized)?; | |
| 134 | 144 | ||
| 135 | - | if !verify_password(&form.password, &db_user.password_hash)? { | |
| 145 | + | if !verify_password_async(form.password.clone(), db_user.password_hash.clone()).await? { | |
| 136 | 146 | return Ok(( | |
| 137 | 147 | [("HX-Retarget", "#totp-disable-status"), ("HX-Reswap", "innerHTML")], | |
| 138 | 148 | Html("<span class=\"save-error\">Incorrect password.</span>"), | |
| @@ -166,7 +176,7 @@ pub(super) async fn regenerate_backup_codes( | |||
| 166 | 176 | .await? | |
| 167 | 177 | .ok_or(AppError::Unauthorized)?; | |
| 168 | 178 | ||
| 169 | - | if !verify_password(&form.password, &db_user.password_hash)? { | |
| 179 | + | if !verify_password_async(form.password.clone(), db_user.password_hash.clone()).await? { | |
| 170 | 180 | return Ok(( | |
| 171 | 181 | [("HX-Retarget", "#backup-regen-status"), ("HX-Reswap", "innerHTML")], | |
| 172 | 182 | Html("<span class=\"save-error\">Incorrect password.</span>"), | |
| @@ -175,10 +185,20 @@ pub(super) async fn regenerate_backup_codes( | |||
| 175 | 185 | } | |
| 176 | 186 | ||
| 177 | 187 | let backup_codes = generate_backup_codes(); | |
| 178 | - | let code_hashes: Vec<String> = backup_codes | |
| 179 | - | .iter() | |
| 180 | - | .map(|code| hash_backup_code(code, &state.config.signing_secret)) | |
| 181 | - | .collect(); | |
| 188 | + | // Hash the backup codes (Argon2id, one per code) on a blocking thread so the | |
| 189 | + | // batch can't occupy a Tokio worker. | |
| 190 | + | let code_hashes: Vec<String> = { | |
| 191 | + | let codes = backup_codes.clone(); | |
| 192 | + | let secret = state.config.signing_secret.clone(); | |
| 193 | + | tokio::task::spawn_blocking(move || { | |
| 194 | + | codes | |
| 195 | + | .iter() | |
| 196 | + | .map(|code| hash_backup_code(code, &secret)) | |
| 197 | + | .collect::<Vec<String>>() | |
| 198 | + | }) | |
| 199 | + | .await | |
| 200 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("backup-code hash task join: {e}")))? | |
| 201 | + | }; | |
| 182 | 202 | ||
| 183 | 203 | db::totp::create_backup_codes(&state.db, user.id, &code_hashes).await?; | |
| 184 | 204 |
| @@ -149,7 +149,7 @@ pub(in crate::routes::api) async fn update_password( | |||
| 149 | 149 | .ok_or(AppError::NotFound)?; | |
| 150 | 150 | ||
| 151 | 151 | // Verify current password | |
| 152 | - | if !crate::auth::verify_password(&req.current_password, &db_user.password_hash)? { | |
| 152 | + | if !crate::auth::verify_password_async(req.current_password.clone(), db_user.password_hash.clone()).await? { | |
| 153 | 153 | if is_htmx { | |
| 154 | 154 | return Ok(Html(SaveStatusTemplate { | |
| 155 | 155 | success: false, | |
| @@ -199,7 +199,7 @@ pub(in crate::routes::api) async fn update_password( | |||
| 199 | 199 | // ROWS — it is deliberately NOT the JWT-revocation mechanism. Do not "fix" this | |
| 200 | 200 | // by swapping in `delete_all_sessions_for_user`: that would also log the user | |
| 201 | 201 | // out of their current web session, and the JWT bump already happened here. | |
| 202 | - | let new_hash = crate::auth::hash_password(&req.new_password)?; | |
| 202 | + | let new_hash = crate::auth::hash_password_async(req.new_password.clone()).await?; | |
| 203 | 203 | db::users::update_user_password(&state.db, user.id, &new_hash).await?; | |
| 204 | 204 | ||
| 205 | 205 | // Invalidate all other sessions so stolen/leaked sessions can't survive a password change |
| @@ -13,7 +13,7 @@ use tower_governor::GovernorLayer; | |||
| 13 | 13 | use tower_sessions::{Expiry, Session}; | |
| 14 | 14 | ||
| 15 | 15 | use crate::{ | |
| 16 | - | auth::{login_user, logout_user, track_session, verify_password, AuthUser, SessionUser, SESSION_TRACKING_KEY}, | |
| 16 | + | auth::{login_user, logout_user, track_session, verify_password_async, AuthUser, SessionUser, SESSION_TRACKING_KEY}, | |
| 17 | 17 | csrf::{post_csrf, with_csrf, with_csrf_manual, with_csrf_skip, CsrfRouter}, | |
| 18 | 18 | constants::{self, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES}, | |
| 19 | 19 | db::{self, UserSessionId, Username}, | |
| @@ -134,7 +134,7 @@ async fn login_handler( | |||
| 134 | 134 | // it, a malformed-email submit completes ~2 orders of magnitude | |
| 135 | 135 | // faster and lets an attacker distinguish "you typed something | |
| 136 | 136 | // not email-shaped" from "valid email, unknown account." | |
| 137 | - | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 137 | + | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 138 | 138 | return return_error("Invalid username or password"); | |
| 139 | 139 | }; | |
| 140 | 140 | db::users::get_user_by_email(&state.db, &email) | |
| @@ -146,7 +146,7 @@ async fn login_handler( | |||
| 146 | 146 | let username = match Username::new(&form.login) { | |
| 147 | 147 | Ok(u) => u, | |
| 148 | 148 | Err(_) => { | |
| 149 | - | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 149 | + | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 150 | 150 | return return_error("Invalid username/email or password"); | |
| 151 | 151 | } | |
| 152 | 152 | }; | |
| @@ -160,7 +160,7 @@ async fn login_handler( | |||
| 160 | 160 | None => { | |
| 161 | 161 | // Run a dummy Argon2 verify to equalize timing with the "wrong password" | |
| 162 | 162 | // path, preventing user enumeration via response time differences. | |
| 163 | - | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 163 | + | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 164 | 164 | tracing::info!(login = %form.login, event = "login_unknown_user", "Login attempt for non-existent account"); | |
| 165 | 165 | return return_error("Invalid username/email or password"); | |
| 166 | 166 | } | |
| @@ -182,7 +182,7 @@ async fn login_handler( | |||
| 182 | 182 | return return_error("Invalid username/email or password"); | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | if !verify_password(&form.password, &user.password_hash)? { | |
| 185 | + | if !verify_password_async(form.password.clone(), user.password_hash.clone()).await? { | |
| 186 | 186 | // Atomically increment failed attempts and lock if threshold reached | |
| 187 | 187 | let result = db::auth::increment_failed_login( | |
| 188 | 188 | &state.db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES, |
| @@ -19,7 +19,7 @@ use tower_governor::GovernorLayer; | |||
| 19 | 19 | use tower_sessions::Session; | |
| 20 | 20 | ||
| 21 | 21 | use crate::{ | |
| 22 | - | auth::{verify_password, MaybeUserVerified}, | |
| 22 | + | auth::{verify_password_async, MaybeUserVerified}, | |
| 23 | 23 | constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS}, | |
| 24 | 24 | csrf, | |
| 25 | 25 | db::{self, CreatorTier, SyncAppId, UserId, Username}, | |
| @@ -470,7 +470,7 @@ async fn authorize_post( | |||
| 470 | 470 | Some(u) => u, | |
| 471 | 471 | None => { | |
| 472 | 472 | // Perform a dummy hash verification to prevent timing-based user enumeration | |
| 473 | - | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 473 | + | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 474 | 474 | return Ok(render_authorize_error( | |
| 475 | 475 | Some(csrf_token), | |
| 476 | 476 | session_user, | |
| @@ -507,7 +507,7 @@ async fn authorize_post( | |||
| 507 | 507 | } | |
| 508 | 508 | ||
| 509 | 509 | // Verify password | |
| 510 | - | if !verify_password(password, &user.password_hash)? { | |
| 510 | + | if !verify_password_async(password.to_string(), user.password_hash.clone()).await? { | |
| 511 | 511 | let result = db::auth::increment_failed_login( | |
| 512 | 512 | &state.db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES, | |
| 513 | 513 | ).await?; |
| @@ -10,7 +10,7 @@ use serde::Deserialize; | |||
| 10 | 10 | use tower_sessions::Session; | |
| 11 | 11 | ||
| 12 | 12 | use crate::{ | |
| 13 | - | auth::hash_password, | |
| 13 | + | auth::hash_password_async, | |
| 14 | 14 | db::{self}, | |
| 15 | 15 | error::Result, | |
| 16 | 16 | helpers::{get_csrf_token, is_htmx_request}, | |
| @@ -245,7 +245,7 @@ pub(super) async fn reset_password_handler( | |||
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | 247 | // Hash new password and update | |
| 248 | - | let new_password_hash = hash_password(&form.password)?; | |
| 248 | + | let new_password_hash = hash_password_async(form.password.clone()).await?; | |
| 249 | 249 | db::users::update_user_password(&state.db, user_id, &new_password_hash).await?; | |
| 250 | 250 | ||
| 251 | 251 | // Kill any other outstanding reset links for this user (e.g. a double |
| @@ -14,7 +14,7 @@ use serde::Deserialize; | |||
| 14 | 14 | use tower_sessions::Session; | |
| 15 | 15 | ||
| 16 | 16 | use crate::{ | |
| 17 | - | auth::{hash_password, login_user, track_session, AuthUser, MaybeUserVerified, SessionUser}, | |
| 17 | + | auth::{hash_password_async, login_user, track_session, AuthUser, MaybeUserVerified, SessionUser}, | |
| 18 | 18 | db::{self}, | |
| 19 | 19 | email, | |
| 20 | 20 | error::{AppError, Result}, | |
| @@ -183,7 +183,7 @@ pub async fn step_account_create( | |||
| 183 | 183 | // slip between the SELECT and the INSERT and raise a 23505. Catch it and | |
| 184 | 184 | // surface as a validation error so the user sees a friendly message | |
| 185 | 185 | // (with their typed values preserved) instead of a 500. | |
| 186 | - | let password_hash = hash_password(&form.password)?; | |
| 186 | + | let password_hash = hash_password_async(form.password.clone()).await?; | |
| 187 | 187 | let user = match db::users::create_user(&state.db, &username, &email, &password_hash).await { | |
| 188 | 188 | Ok(u) => u, | |
| 189 | 189 | Err(AppError::Database(sqlx::Error::Database(ref db_err))) |
| @@ -85,7 +85,7 @@ pub(super) async fn create_sandbox( | |||
| 85 | 85 | ||
| 86 | 86 | let username = db::Username::from_trusted(format!("sandbox_{}", suffix)); | |
| 87 | 87 | let email = db::Email::from_trusted(format!("sandbox_{}@sandbox.local", suffix)); | |
| 88 | - | let password_hash = auth::hash_password(&format!("sandbox_{}", uuid::Uuid::new_v4()))?; | |
| 88 | + | let password_hash = auth::hash_password_async(format!("sandbox_{}", uuid::Uuid::new_v4())).await?; | |
| 89 | 89 | ||
| 90 | 90 | // Create the sandbox user | |
| 91 | 91 | let user = db::users::create_sandbox_user( |
| @@ -7,7 +7,7 @@ use axum::{ | |||
| 7 | 7 | }; | |
| 8 | 8 | ||
| 9 | 9 | use crate::{ | |
| 10 | - | auth::verify_password, | |
| 10 | + | auth::verify_password_async, | |
| 11 | 11 | db, | |
| 12 | 12 | error::{AppError, Result}, | |
| 13 | 13 | synckit_auth, | |
| @@ -60,7 +60,7 @@ pub(super) async fn sync_auth( | |||
| 60 | 60 | // Reject oversized passwords early (before user lookup — no timing leak | |
| 61 | 61 | // since this branch doesn't touch the DB or run Argon2). | |
| 62 | 62 | if req.password.len() > 128 { | |
| 63 | - | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 63 | + | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 64 | 64 | return Err(AppError::Unauthorized); | |
| 65 | 65 | } | |
| 66 | 66 | ||
| @@ -70,7 +70,7 @@ pub(super) async fn sync_auth( | |||
| 70 | 70 | Ok(e) => e, | |
| 71 | 71 | Err(_) => { | |
| 72 | 72 | // Equalize timing on malformed input too — same enumeration concern. | |
| 73 | - | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 73 | + | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 74 | 74 | return Err(AppError::Unauthorized); | |
| 75 | 75 | } | |
| 76 | 76 | }; | |
| @@ -78,12 +78,12 @@ pub(super) async fn sync_auth( | |||
| 78 | 78 | Some(u) => u, | |
| 79 | 79 | None => { | |
| 80 | 80 | // Equalize timing to prevent email enumeration | |
| 81 | - | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 81 | + | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 82 | 82 | return Err(AppError::Unauthorized); | |
| 83 | 83 | } | |
| 84 | 84 | }; | |
| 85 | 85 | ||
| 86 | - | let valid = crate::auth::verify_password(&req.password, &user.password_hash)?; | |
| 86 | + | let valid = crate::auth::verify_password_async(req.password.clone(), user.password_hash.clone()).await?; | |
| 87 | 87 | ||
| 88 | 88 | // Account-status checks run after verify_password to avoid timing oracles. | |
| 89 | 89 | // A correct password that still can't complete login here — suspended, |