#30: stop enrolment from killing the auth service, and from enabling MFA without recovery codes

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>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-03 11:49:00 -03:00
1 parent c7251211b5
commit 6935f9feeb
4 files changed
+72 -7

No files matched your search

+20 -5
View File
@@ -201,19 +201,34 @@ async function loadTotp(mailboxId: string, status: "PENDING" | "CONFIRMED"): Pro
}
/** Confirms enrollment by proving one code against the stored secret. */
export async function confirmTotpEnrollment(mailboxId: string, code: string): Promise<boolean> {
/**
* Checks a code against the pending enrolment without committing anything.
*
* Split from the commit so the caller can store the recovery codes first.
* Confirming and then generating them meant a failure in between - which
* happened, on memory - left an account with a second factor enabled and no
* recovery codes, and the person enrolling never saw the codes they are told
* to save. Leaving the enrolment pending is the recoverable end of that.
*/
export async function verifyPendingTotp(
mailboxId: string,
code: string,
): Promise<{ id: string; step: number } | null> {
const row = await loadTotp(mailboxId, "PENDING");
if (!row) return false;
if (!row) return null;
const secret = decryptSecret(row.secret_enc, row.secret_nonce, row.key_version);
const result = verifyTotpCode(secret, code, row.last_used_step === null ? null : Number(row.last_used_step));
if (!result.valid) return false;
if (!result.valid) return null;
return { id: row.id, step: result.step };
}
/** Marks a verified enrolment confirmed. */
export async function markTotpConfirmed(id: string, step: number): Promise<void> {
await query(
`UPDATE mfa_totp SET status = 'CONFIRMED', confirmed_at = NOW(), last_used_step = $2 WHERE id = $1`,
[row.id, result.step],
[id, step],
);
return true;
}
/**