#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:
1 parent
de16efe874
commit
57198e0263
11 files changed
+189
-25
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import crypto from "node:crypto";
|
||||
import { generateTotpSecret } from "./totp.js";
|
||||
import { generateTotpSecret, generateTotpUri } from "./totp.js";
|
||||
import { generateRecoveryCodes, generateAppPassword } from "./mfaHelpers.js";
|
||||
import { createJwt, verifyJwt, isSurfaceAuthorized, type SessionTokenPayload } from "./session.js";
|
||||
import * as repo from "./repository.js";
|
||||
@@ -134,7 +134,7 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
||||
if (url === "/api/v1/user/sieve" && method === "GET") return userGetSieve(res, session!);
|
||||
if (url === "/api/v1/user/sieve" && method === "POST") return userSaveSieve(req, res, session!);
|
||||
if (url === "/api/v1/user/vacation" && method === "POST") return userSaveVacation(req, res, session!);
|
||||
if (url === "/api/v1/user/mfa/totp/enroll" && method === "POST") return totpEnroll(res, session!);
|
||||
if (url === "/api/v1/user/mfa/totp/enroll" && method === "POST") return totpEnroll(req, res, session!);
|
||||
if (url === "/api/v1/user/mfa/totp/confirm" && method === "POST") return totpConfirm(req, res, session!);
|
||||
if (url === "/api/v1/user/app-passwords" && method === "GET") return appPasswordsList(res, session!);
|
||||
if (url === "/api/v1/user/app-passwords" && method === "POST") return appPasswordCreate(req, res, session!);
|
||||
@@ -395,12 +395,28 @@ async function updateLocale(req: IncomingMessage, res: ServerResponse, session:
|
||||
sendJson(res, 200, { locale });
|
||||
}
|
||||
|
||||
async function totpEnroll(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
const secret = generateTotpSecret(session.email);
|
||||
async function totpEnroll(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
session: SessionTokenPayload,
|
||||
): Promise<void> {
|
||||
// ?reset=1 abandons a pending enrollment. Without it this endpoint resumes,
|
||||
// which is what stops a page reload from invalidating a secret the
|
||||
// authenticator has already stored.
|
||||
if (new URL(req.url ?? "", "http://internal").searchParams.get("reset") === "1") {
|
||||
await repo.resetTotpEnrollment(session.sub);
|
||||
}
|
||||
|
||||
const generated = generateTotpSecret(session.email);
|
||||
// Stored server-side; the confirm step reads it from the database rather
|
||||
// than trusting the client to send it back.
|
||||
await repo.startTotpEnrollment(session.sub, secret.base32Secret);
|
||||
sendJson(res, 200, { secret: secret.base32Secret, uri: secret.uri });
|
||||
// than trusting the client to send it back. The value returned is whichever
|
||||
// secret is now pending, which may be one from an earlier attempt.
|
||||
const secret = await repo.startTotpEnrollment(session.sub, generated.base32Secret);
|
||||
const uri =
|
||||
secret === generated.base32Secret
|
||||
? generated.uri
|
||||
: generateTotpUri(session.email, secret);
|
||||
sendJson(res, 200, { secret, uri });
|
||||
}
|
||||
|
||||
async function totpConfirm(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
|
||||
Reference in new issue
Block a user