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
+1
View File
@@ -12,6 +12,7 @@
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
"./lib/logger": "./src/lib/logger.ts",
"./lib/runtime-security": "./src/lib/runtime-security.ts",
"./lib/totp-consume": "./src/lib/totp-consume.ts",
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
},
"scripts": {