Confirming a second factor returned "Upstream service is unreachable" - the Next proxy's message for a backend it cannot reach. The backend was not unreachable, it was dead: the process had just been OOM-killed. generateRecoveryCodes hashed all ten codes with Promise.all. Argon2id here is configured for 64 MiB per hash, so ten concurrently reserve 640 MiB against a container limited to 512 MiB. Hashing is now sequential, peaking at 64 MiB and taking a few seconds - the right trade for something that happens once per enrolment. A timing-based test covers it, against the real hash rather than a mock, because the cost being guarded is memory held concurrently and only the real implementation reserves it. The worse half is what the crash left behind. The enrolment row was marked CONFIRMED before the codes were generated, so the account came out of this with a second factor enabled, no recovery codes, and nobody having seen any: locked behind an authenticator entry with no way back. That is how an account ends up "assuming" it has a factor its owner cannot use. The order is now verify, store the codes, then confirm. Anything that fails in between leaves the enrolment pending and retryable, which is the recoverable end of that failure. Both bugs sat behind the one fixed in #28 - the code check failed first, so the crash was never reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
41 lines
1.7 KiB
TypeScript
41 lines
1.7 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { hashPassword, verifyPassword } from "./crypto.js";
|
|
|
|
/**
|
|
* Generates single-use recovery codes. Only the hashes are ever stored, so a
|
|
* database dump cannot be turned back into usable codes (PLAN.md section 9).
|
|
*/
|
|
export async function generateRecoveryCodes(count = 10): Promise<{ rawCodes: string[]; hashedCodes: string[] }> {
|
|
const rawCodes: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
rawCodes.push(crypto.randomBytes(6).toString("hex").substring(0, 10).toUpperCase());
|
|
}
|
|
// Hashed one at a time, deliberately. Argon2id is configured for 64 MiB per
|
|
// hash, so ten of them in parallel reserve 640 MiB - more than the auth
|
|
// container is allowed - and the process was OOM-killed partway through
|
|
// enrolment. From the browser that surfaced as "upstream service is
|
|
// unreachable"; in the database it left an account with a confirmed second
|
|
// factor and no recovery codes, because the row is written before this runs.
|
|
//
|
|
// Sequentially it peaks at 64 MiB and takes a few seconds, which is the
|
|
// right trade for an operation that happens once per enrolment.
|
|
const hashedCodes: string[] = [];
|
|
for (const code of rawCodes) {
|
|
hashedCodes.push(await hashPassword(code));
|
|
}
|
|
return { rawCodes, hashedCodes };
|
|
}
|
|
|
|
export function generateAppPassword(): string {
|
|
const part = () => crypto.randomBytes(4).toString("hex");
|
|
return `lmp_${part()}-${part()}-${part()}`;
|
|
}
|
|
|
|
export async function hashAppPassword(rawPassword: string): Promise<string> {
|
|
return hashPassword(rawPassword);
|
|
}
|
|
|
|
export async function verifyAppPassword(rawPassword: string, storedHash: string): Promise<boolean> {
|
|
return verifyPassword(rawPassword, storedHash);
|
|
}
|