From 1cd8791bfffc947effac8d1e14cd3e359e577115 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 7 Jun 2026 20:10:54 +0200 Subject: [PATCH] fix(manager-core): F-P-002 move Argon2id verify off the Tokio async worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_password (Argon2id, OWASP defaults m=19456 KiB, t=2) is CPU-bound at tens-to-hundreds of ms and was invoked synchronously on the Tokio worker. Worse, verify_api_key Argon2-verifies every candidate sharing the 8-char prefix — a hot user with N keys serialized every admin request behind N×Argon2. Wrap each call site in tokio::task::spawn_blocking: - auth_middleware::verify_api_key (per request carrying a Bearer key) - auth_api::login (admin login) - users_service::login (data-plane app-user login, both real-hash and TIMING_FLAT_DUMMY_HASH branches) Cold-cache login is now ~2× current latency due to one spawn_blocking hop, but the worker no longer parks on Argon2 so steady-state under load is dramatically better. The LRU cache for the hot-path (token → principal) is finding F-P-009 — separate commit. AUDIT.md anchor: F-P-002. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/auth_api.rs | 12 +++++++++++- crates/manager-core/src/auth_middleware.rs | 17 ++++++++++++++--- crates/manager-core/src/users_service.rs | 12 ++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/manager-core/src/auth_api.rs b/crates/manager-core/src/auth_api.rs index 29778bb..6ee3e92 100644 --- a/crates/manager-core/src/auth_api.rs +++ b/crates/manager-core/src/auth_api.rs @@ -95,7 +95,17 @@ async fn login(State(state): State, Json(input): Json) None => (crate::auth::TIMING_FLAT_DUMMY_HASH.to_string(), None, false), }; - let password_ok = verify_password(&stored_hash, &input.password); + // F-P-002: Argon2id verify off the async worker. + let password = input.password.clone(); + let password_ok = match tokio::task::spawn_blocking(move || verify_password(&stored_hash, &password)) + .await + { + Ok(b) => b, + Err(err) => { + tracing::error!(?err, "verify_password spawn_blocking join failed"); + return internal_error(); + } + }; if !password_ok || user_id.is_none() || !is_active { return invalid_credentials(); } diff --git a/crates/manager-core/src/auth_middleware.rs b/crates/manager-core/src/auth_middleware.rs index 2136534..51393d6 100644 --- a/crates/manager-core/src/auth_middleware.rs +++ b/crates/manager-core/src/auth_middleware.rs @@ -203,9 +203,20 @@ async fn verify_api_key(state: &AuthState, rest: &str) -> Result = candidates - .into_iter() - .find(|c| verify_password(&c.hash, rest)); + // F-P-002: Argon2id verify is CPU-bound (~tens of ms each at OWASP + // defaults). Move it off the Tokio async worker so a hot user with + // N prefix-colliding keys doesn't park the worker for N×Argon2. + let rest_owned = rest.to_string(); + let matched: Option = tokio::task::spawn_blocking(move || { + candidates + .into_iter() + .find(|c| verify_password(&c.hash, &rest_owned)) + }) + .await + .map_err(|e| { + tracing::error!(error = ?e, "verify_api_key spawn_blocking join failed"); + InternalError + })?; let Some(matched) = matched else { return Ok(None); }; diff --git a/crates/manager-core/src/users_service.rs b/crates/manager-core/src/users_service.rs index d03c241..41f543d 100644 --- a/crates/manager-core/src/users_service.rs +++ b/crates/manager-core/src/users_service.rs @@ -590,10 +590,15 @@ impl UsersService for UsersServiceImpl { .await?; // Both branches normalize the email so an attacker can't probe // existence via "" / "x" / very-long-string differences. + let password_owned = password.to_string(); let Ok(normalized) = validate_email(email) else { // Still run a dummy verify so the timing of an invalid // email shape matches the timing of a legitimate miss. - let _ = verify_password(TIMING_FLAT_DUMMY_HASH, password); + // F-P-002: off the async worker — Argon2 is CPU-bound. + let _ = tokio::task::spawn_blocking(move || { + verify_password(TIMING_FLAT_DUMMY_HASH, &password_owned) + }) + .await; return Ok(None); }; let creds = self @@ -604,7 +609,10 @@ impl UsersService for UsersServiceImpl { Some(c) => (c.password_hash, Some(c.id), Some(c.app_id)), None => (TIMING_FLAT_DUMMY_HASH.to_string(), None, None), }; - let password_ok = verify_password(&hash, password); + // F-P-002: Argon2id verify off the async worker. + let password_ok = tokio::task::spawn_blocking(move || verify_password(&hash, &password_owned)) + .await + .map_err(|e| UsersError::Backend(format!("verify spawn_blocking join: {e}")))?; let (Some(user_id), Some(app_id)) = (user_id, app_id) else { return Ok(None); };