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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user