#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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user