diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 668e648..6320188 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -228,6 +228,16 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier); +CREATE TABLE IF NOT EXISTS totp_login_replays ( + user_id TEXT NOT NULL, + time_counter INTEGER NOT NULL, + consumed_at INTEGER NOT NULL, + PRIMARY KEY (user_id, time_counter), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at + ON totp_login_replays(consumed_at); + CREATE TABLE IF NOT EXISTS webauthn_credentials ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index c26b9fa..e584e28 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -4,7 +4,7 @@ import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { LIMITS } from '../config/limits'; -import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; +import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { createRefreshToken } from '../utils/jwt'; import { readAuthRequestDeviceInfo } from '../utils/device'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; @@ -409,8 +409,12 @@ export async function handleToken(request: Request, env: Env): Promise return twoFactorRequiredResponse('Two factor required.'); } } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { - const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); - if (!totpOk) { + const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken); + if (matchedCounter == null) { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } + const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter); + if (!consumed) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } } else if ( diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 0192f93..c7821ed 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -127,6 +127,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', + 'CREATE TABLE IF NOT EXISTS totp_login_replays (' + + 'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' + + 'PRIMARY KEY (user_id, time_counter), ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)', + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + diff --git a/src/services/storage-totp-replay-repo.ts b/src/services/storage-totp-replay-repo.ts new file mode 100644 index 0000000..9167be3 --- /dev/null +++ b/src/services/storage-totp-replay-repo.ts @@ -0,0 +1,35 @@ +type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean; + +export async function consumeTotpLoginCounter( + db: D1Database, + shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup, + lastCleanupAt: number, + cleanupIntervalMs: number, + userId: string, + timeCounter: number, + consumedAtMs: number, + markerTtlMs: number +): Promise<{ consumed: boolean; cleanedUpAt: number | null }> { + let cleanedUpAt: number | null = null; + + if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) { + await db + .prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?') + .bind(consumedAtMs - markerTtlMs) + .run(); + cleanedUpAt = consumedAtMs; + } + + const result = await db + .prepare( + 'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' + + 'ON CONFLICT(user_id, time_counter) DO NOTHING' + ) + .bind(userId, timeCounter, consumedAtMs) + .run(); + + return { + consumed: (result.meta.changes ?? 0) > 0, + cleanedUpAt, + }; +} diff --git a/src/services/storage.ts b/src/services/storage.ts index 337497b..a6ed61f 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -123,6 +123,9 @@ import { ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, } from './storage-attachment-token-repo'; +import { + consumeTotpLoginCounter as consumeStoredTotpLoginCounter, +} from './storage-totp-replay-repo'; import { getRevisionDate as getStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate, @@ -150,8 +153,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-06-23-invite-used-by'; -const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; +const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay'; +const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; // D1-backed storage. // Contract: @@ -164,10 +167,13 @@ export class StorageService { private static schemaVerified = false; private static lastRefreshTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0; + private static lastTotpReplayCleanupAt = 0; private static readonly MAX_D1_SQL_VARIABLES = 100; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; + private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; + private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; constructor(private db: D1Database) {} @@ -833,6 +839,24 @@ export class StorageService { return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); } + async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise { + if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false; + const result = await consumeStoredTotpLoginCounter( + this.db, + this.shouldRunPeriodicCleanup.bind(this), + StorageService.lastTotpReplayCleanupAt, + StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS, + userId, + timeCounter, + consumedAtMs, + StorageService.TOTP_REPLAY_MARKER_TTL_MS + ); + if (result.cleanedUpAt !== null) { + StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt; + } + return result.consumed; + } + // --- Revision dates --- async getRevisionDate(userId: string): Promise { diff --git a/src/utils/totp.ts b/src/utils/totp.ts index 48e9c10..b8f5a9b 100644 --- a/src/utils/totp.ts +++ b/src/utils/totp.ts @@ -70,17 +70,22 @@ function normalizeToken(token: string): string { return token.replace(/\s+/g, ''); } -export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise { +export async function findMatchingTotpCounter( + secretRaw: string, + tokenRaw: string, + nowMs: number = Date.now() +): Promise { const token = normalizeToken(tokenRaw); - if (!/^\d{6}$/.test(token)) return false; + if (!/^\d{6}$/.test(token)) return null; const secret = base32Decode(secretRaw); - if (!secret) return false; + if (!secret) return null; const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); - let matched = false; + let matchedCounter: number | null = null; for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { - const expected = await hotp(secret, currentCounter + delta); + const candidateCounter = currentCounter + delta; + const expected = await hotp(secret, candidateCounter); // Constant-time comparison: always check all windows, never short-circuit. const a = new TextEncoder().encode(expected); const b = new TextEncoder().encode(token); @@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs for (let i = 0; i < a.length && i < b.length; i++) { diff |= a[i] ^ b[i]; } - if (diff === 0) matched = true; + if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter; } - return matched; + return matchedCounter; +} + +export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise { + return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null; } export function isTotpEnabled(secretRaw: string | undefined | null): boolean {