Skip to main content

max / makenotwork

Run CPU-bound Argon2 off the async runtime (ultra-fuzz Run 10 C1) verify_password/hash_password ran Argon2id (46 MiB, hundreds of ms) directly on Tokio worker threads at every auth entrypoint. A handful of concurrent logins pinned every worker and stalled all request handling; the timing- equalizer dummy verifies made the DoS lockout-immune (random-username spray bypasses per-account throttling). Add hash_password_async/verify_password_async that run the work via spawn_blocking, seal the sync forms pub(crate), and convert every per-request call site: login, OAuth password grant, SyncKit auth, TOTP confirm/disable/ regenerate, passkey password gate, signup, password reset, change-password, sandbox user creation, backup-code hashing and verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 13:39 UTC
Commit: ea22104bd5ef11fc9cf13192d483060a0f4747cb
Parent: de16361
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,