#31: bound Argon2 concurrency so a login flood cannot kill the auth service
Fixing the recovery-code OOM removed one way to exhaust the container's memory; it did not remove the reason that was possible. Argon2id is configured for 64 MiB, and every password hash or verify reserves that for its duration. Nothing limited how many ran at once, and verification is reached by an unauthenticated login - so roughly twenty simultaneous sign-in attempts reserve 1.28 GiB against a 512 MiB container and the process is killed. No credentials needed, and account lockout does not help: it is per account, and the requests need not share one. Hashing now passes through a four-slot gate. Callers queue rather than being refused, so every request stays correct and only gets slower under load, and peak memory is four hashes regardless of traffic - about 256 MiB, comfortably inside the limit. This is the answer to "why not just remove the memory limits". The limit was never the fault; unbounded per-request memory was. Removing it would move the kill decision to the kernel, which picks by size and would take Postgres - losing queued mail rather than one request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6935f9feeb
commit
8cfe339d13
2 files changed
+65
-3
No files matched your search
@@ -243,3 +243,29 @@ describe("recovery code generation stays inside the container's memory", () => {
|
||||
expect(batch).toBeGreaterThan(single * 2.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Argon2 concurrency is bounded", () => {
|
||||
// Each Argon2 operation reserves 64 MiB for its duration, and every login
|
||||
// attempt starts one from an unauthenticated request. Without a cap, enough
|
||||
// simultaneous sign-ins reserve more than the container is allowed and the
|
||||
// process is killed - a denial of service that needs no credentials.
|
||||
it("does not let unbounded parallel verifies exhaust memory", { timeout: 120000 }, async () => {
|
||||
const { hashPassword, verifyPassword } = await import("./crypto.js");
|
||||
const stored = await hashPassword("correct horse battery staple");
|
||||
|
||||
// Twenty at once would reserve 1.28 GiB unbounded; gated, it stays at four.
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 20 }, () => verifyPassword("correct horse battery staple", stored)),
|
||||
);
|
||||
expect(results.every(Boolean)).toBe(true);
|
||||
});
|
||||
|
||||
it("still rejects a wrong password under concurrency", { timeout: 120000 }, async () => {
|
||||
const { hashPassword, verifyPassword } = await import("./crypto.js");
|
||||
const stored = await hashPassword("the real one");
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) => verifyPassword(i % 2 === 0 ? "the real one" : "wrong", stored)),
|
||||
);
|
||||
expect(results).toEqual([true, false, true, false, true, false, true, false]);
|
||||
});
|
||||
});
|
||||
@@ -28,9 +28,42 @@ function getMasterKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(envKey).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps how many Argon2 operations run at once.
|
||||
*
|
||||
* Each one reserves ARGON2_MEMORY_KIB - 64 MiB - for its whole duration, and
|
||||
* nothing else bounds how many are in flight: every login attempt starts one,
|
||||
* from an unauthenticated request. Twenty concurrent sign-ins would reserve
|
||||
* 1.28 GiB against a container allowed 512 MiB, so anyone could stop the auth
|
||||
* service by opening enough connections. Account lockout does not help; it is
|
||||
* per account, and these need not share one.
|
||||
*
|
||||
* Queuing instead of refusing keeps every request correct, just slower under
|
||||
* load, and holds peak memory at CONCURRENCY x 64 MiB no matter the traffic.
|
||||
*/
|
||||
const ARGON2_CONCURRENCY = 4;
|
||||
|
||||
let active = 0;
|
||||
const waiting: Array<() => void> = [];
|
||||
|
||||
async function withHashSlot<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (active >= ARGON2_CONCURRENCY) {
|
||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
}
|
||||
active++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
active--;
|
||||
// Waking exactly one keeps the count accurate; waking all would let every
|
||||
// queued caller through at once, which is the thing being prevented.
|
||||
waiting.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Produces an Argon2id PHC string, the only format this service stores. */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2id({
|
||||
return withHashSlot(() => argon2id({
|
||||
password,
|
||||
salt: crypto.randomBytes(ARGON2_SALT_BYTES),
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
@@ -38,7 +71,7 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
memorySize: ARGON2_MEMORY_KIB,
|
||||
hashLength: ARGON2_HASH_LENGTH,
|
||||
outputType: "encoded",
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +88,10 @@ export async function verifyPassword(password: string, storedHash: string): Prom
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await argon2Verify({ password, hash: storedHash });
|
||||
// Gated as well as hashing, and this is the one that matters most: an
|
||||
// unauthenticated login reaches it, so without the cap anyone could
|
||||
// reserve 64 MiB per request until the container is killed.
|
||||
return await withHashSlot(() => argon2Verify({ password, hash: storedHash }));
|
||||
} catch {
|
||||
// A malformed stored hash is a failed login, not a crash that would take
|
||||
// the login endpoint down for everyone.
|
||||
|
||||
Reference in new issue
Block a user