diff --git a/crates/didbot-serve/src/routes.rs b/crates/didbot-serve/src/routes.rs index 47059bbc..2a0008ce 100644 --- a/crates/didbot-serve/src/routes.rs +++ b/crates/didbot-serve/src/routes.rs @@ -195,6 +195,23 @@ pub const LOGIN_ATTEMPT_LIMIT: u32 = 10; /// The window [`LOGIN_ATTEMPT_LIMIT`] is measured over. pub const LOGIN_ATTEMPT_WINDOW: std::time::Duration = std::time::Duration::from_secs(300); +/// How many `createSession` calls may run Argon2id at once, across every +/// caller. +/// +/// [`SESSION_RATE_LIMIT`] and [`LOGIN_ATTEMPT_LIMIT`] bound how often a +/// caller may *ask* for a hash; neither bounds how many the process runs at +/// the same time, and `spawn_blocking`'s pool defaults to hundreds of +/// threads — far more than this host's own concurrency budget. Argon2's +/// default parameters (`argon2::Params::DEFAULT_M_COST`) cost 19 MiB of +/// working memory per call, so 8 permits tops resident memory for hashing +/// at 8 × 19 MiB ≈ 152 MiB — comfortable headroom on the 2 GiB `t3.small` +/// `infra/pds` deploys onto, against everything else the process holds. It +/// also already exceeds the host's 2 vCPUs: Argon2id's single-lane default +/// (`p_cost` 1) saturates one core per call, so more permits would only +/// queue additional memory pressure onto the same two cores, not hash any +/// faster. +static ARGON2_CONCURRENCY: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(8); + /// How long any one request may take before this router gives up on it and /// answers `503` with an empty body. /// @@ -2951,10 +2968,35 @@ async fn create_session( // body and status exists to prevent. Matching bodies are not enough on // their own when one branch skips a KDF the other pays for. let resolved = resolve_identifier(&state, &request.identifier); - let session = state.sessions.create( - resolved.as_deref().unwrap_or(NO_SUCH_ACCOUNT), - &request.password, - ); + let did_to_verify = resolved + .clone() + .unwrap_or_else(|| NO_SUCH_ACCOUNT.to_owned()); + let sessions = state.sessions.clone(); + // Moved rather than cloned: nothing after this reads `request.password`, + // so this is the last copy of the plaintext this route ever holds. + let password = request.password; + // The permit is acquired before `spawn_blocking`, not inside it: a + // permit is what bounds concurrent Argon2id memory (see + // `ARGON2_CONCURRENCY`), and claiming a blocking-pool thread first would + // just hold that thread idle while this one waits its turn instead. + // Moved into the closure so it is held for exactly as long as the hash + // it is bounding, and released the moment that call returns. + let permit = ARGON2_CONCURRENCY + .acquire() + .await + .expect("this semaphore is never closed"); + let outcome = tokio::task::spawn_blocking(move || { + let _permit = permit; + sessions.create(&did_to_verify, &password) + }) + .await; + let session = match outcome { + Ok(session) => session, + Err(join_err) => { + return blocking_task_failed("com.atproto.server.createSession", join_err) + .into_response() + } + }; // The identifier is logged and the password never is, on every branch: // an identifier is a handle or a DID, which this deployment already // publishes, and a password is the one secret this route exists to