#28: stop enrolment from silently replacing a secret the authenticator already has

Enrolling a second factor could reach a state where the authenticator showed
perfectly valid codes and every one of them was rejected, with the only
feedback being "TOTP verification failed".

The browser holds the enrolment secret in component state alone. Reloading the
settings page - or leaving and coming back - brought the "enable" button back,
and pressing it called the enrol endpoint again, which deleted the pending row
and stored a brand new secret. The entry already saved in the authenticator
then belonged to a secret the server no longer had. Nothing about that is
visible from either side: the app generates codes correctly, the server
compares them correctly, and they are for different keys.

Enrolment now resumes. The endpoint returns the pending secret if there is
one, so the QR shown after a reload is the same one already scanned and
rescanning is harmless. Starting over is still possible - ?reset=1, exposed in
the settings screen - because someone who deleted the entry from their app
needs a secret they can actually generate codes for. A pending row that can no
longer be decrypted is replaced rather than resumed, since nobody could ever
confirm it.

Also: the submitted code now has every non-digit stripped rather than just
surrounding whitespace. Authenticators display the code grouped as "123 456",
and a value typed or pasted with that space failed the six-character length
check before it was ever compared - which, to someone reading the right number
off their phone, looks like the server being wrong. The inputs sanitise as you
type for the same reason.

Ruled out first, rather than guessed at: the algorithm passes all six RFC 6238
reference vectors, base32 round-trips, and the deployment's clock is exactly in
sync with real time, so neither the implementation nor drift explains it.

Verified: the resume test fails without the change and passes with it; 47 auth
tests green against a real Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-03 09:47:59 -03:00
1 parent de16efe874
commit 57198e0263
11 files changed
+189 -25

No files matched your search

+48 -4
View File
@@ -127,16 +127,60 @@ async function registerFailedLogin(mailboxId: string): Promise<void> {
* accepted back from the client - a secret the client chooses is a secret the
* client can replay for somebody else's account.
*/
export async function startTotpEnrollment(mailboxId: string, base32Secret: string, label = "Authenticator"): Promise<void> {
/**
* Starts - or resumes - a TOTP enrollment, returning the secret to display.
*
* Resuming matters. This used to delete any pending row and store a freshly
* generated secret on every call, and the browser only holds the secret in
* component state: reloading the settings page, or coming back to it, brought
* the "enable" button back, and pressing it silently replaced the secret the
* authenticator had already been given. The app then showed perfectly valid
* codes for a secret the server no longer had, and every confirmation failed
* with no indication why.
*
* Returning the pending secret instead makes enrollment idempotent: the QR
* shown after a reload is the same one already scanned, so an existing entry
* keeps working and rescanning is harmless.
*/
export async function startTotpEnrollment(
mailboxId: string,
base32Secret: string,
label = "Authenticator",
): Promise<string> {
const pending = await queryOne<{ secret_enc: Buffer; secret_nonce: Buffer; key_version: number }>(
`SELECT secret_enc, secret_nonce, key_version
FROM mfa_totp WHERE mailbox_id = $1 AND status = 'PENDING'
ORDER BY created_at DESC LIMIT 1`,
[mailboxId],
);
if (pending) {
try {
return decryptSecret(pending.secret_enc, pending.secret_nonce, pending.key_version);
} catch {
// Undecryptable - the master key changed under it. Nobody can ever
// confirm this row, so replacing it is the only way forward.
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1 AND status = 'PENDING'`, [mailboxId]);
}
}
const { encrypted, nonce, keyVersion } = encryptSecret(base32Secret);
// Any earlier unconfirmed attempt is replaced, so a user who restarts
// enrollment is not left with rows nobody can complete.
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1 AND status = 'PENDING'`, [mailboxId]);
await query(
`INSERT INTO mfa_totp (mailbox_id, label, secret_enc, secret_nonce, key_version, status)
VALUES ($1, $2, $3, $4, $5, 'PENDING')`,
[mailboxId, label, encrypted, nonce, keyVersion],
);
return base32Secret;
}
/**
* Abandons a pending enrollment, so the next one starts from a new secret.
*
* Needed because startTotpEnrollment now resumes: someone who deleted the
* entry from their authenticator would otherwise be handed the same secret
* they can no longer generate codes for.
*/
export async function resetTotpEnrollment(mailboxId: string): Promise<void> {
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1 AND status = 'PENDING'`, [mailboxId]);
}
interface TotpRow {