security: atomic compare-and-swap for TOTP replay window (#43, part 1)

The previous SELECT → compare → UPDATE sequence let two concurrent login
requests with the same valid 6-digit code both observe a stale lastTotpAt,
both pass the in-JS replay check, and both succeed. A stolen TOTP (shoulder-
surf, phishing-proxy replay) was usable twice within its 30 s window.

Replace the three callsites (login authorize, self-service enable, self-
service verify) with a shared consumeTotpWindow() helper: a single
updateMany() expresses "window unused" as a SQL WHERE clause, so Postgres'
row lock serialises concurrent writers and whichever commits second sees
count=0 and is treated as a replay.

Backup codes (ticket part 2) are tracked as follow-up work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 09:11:50 +02:00
parent d1075af77d
commit 3222bec8a5
5 changed files with 123 additions and 26 deletions
@@ -5,6 +5,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import { consumeTotpWindow } from "../lib/totp-consume.js";
import { totpRateLimiter } from "../middleware/rate-limit.js";
import type { TRPCContext } from "../trpc.js";
@@ -235,8 +236,10 @@ export async function verifyAndEnableTotp(
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." });
}
// Replay-attack prevention: reject if the same 30-second window was already used
if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) {
// Atomic replay-guard: single UPDATE with WHERE-guard on lastTotpAt. See
// packages/api/src/lib/totp-consume.ts for rationale.
const accepted = await consumeTotpWindow(ctx.db, user.id);
if (!accepted) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "TOTP code already used. Wait for the next code.",
@@ -245,7 +248,7 @@ export async function verifyAndEnableTotp(
await (ctx.db.user.update as Function)({
where: { id: user.id },
data: { totpEnabled: true, lastTotpAt: new Date() },
data: { totpEnabled: true },
});
void createAuditEntry({
@@ -309,17 +312,12 @@ export async function verifyTotp(
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
}
// Replay-attack prevention: reject if the same 30-second window was already used
if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) {
// Atomic replay-guard — see packages/api/src/lib/totp-consume.ts.
const accepted = await consumeTotpWindow(ctx.db, user.id);
if (!accepted) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
}
// Record successful TOTP use to prevent replay within the same window
await (ctx.db.user.update as Function)({
where: { id: user.id },
data: { lastTotpAt: new Date() },
});
return { valid: true };
}