From 683b956b9faa65f968c803da4e2e69aa66c85ad5 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 12:22:02 +0200 Subject: [PATCH] feat(2FA): add core library functions for TOTP, backup codes and rate limiting --- src/lib/two-factor/backup-codes.ts | 31 ++++++++++++++++++++ src/lib/two-factor/crypto.ts | 39 +++++++++++++++++++++++++ src/lib/two-factor/rate-limit.ts | 31 ++++++++++++++++++++ src/lib/two-factor/replay-prevention.ts | 24 +++++++++++++++ src/lib/two-factor/totp.ts | 23 +++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 src/lib/two-factor/backup-codes.ts create mode 100644 src/lib/two-factor/crypto.ts create mode 100644 src/lib/two-factor/rate-limit.ts create mode 100644 src/lib/two-factor/replay-prevention.ts create mode 100644 src/lib/two-factor/totp.ts diff --git a/src/lib/two-factor/backup-codes.ts b/src/lib/two-factor/backup-codes.ts new file mode 100644 index 000000000..a201cf24e --- /dev/null +++ b/src/lib/two-factor/backup-codes.ts @@ -0,0 +1,31 @@ +import { randomBytes } from 'crypto'; +import bcrypt from 'bcryptjs'; + +const BACKUP_CODE_COUNT = 10; +const BCRYPT_ROUNDS = 10; + +export async function generateBackupCodes(): Promise<{ + plaintext: Array; + hashed: Array; +}> { + const plaintext = Array.from( + { length: BACKUP_CODE_COUNT }, + () => + randomBytes(5).toString('hex').toUpperCase() + + '-' + + randomBytes(5).toString('hex').toUpperCase(), + ); + const hashed = await Promise.all(plaintext.map(code => bcrypt.hash(code, BCRYPT_ROUNDS))); + return { plaintext, hashed }; +} + +export async function verifyBackupCode( + inputCode: string, + storedHashes: Array, +): Promise { + for (let i = 0; i < storedHashes.length; i++) { + const match = await bcrypt.compare(inputCode.toUpperCase(), storedHashes[i]); + if (match) return i; + } + return null; +} diff --git a/src/lib/two-factor/crypto.ts b/src/lib/two-factor/crypto.ts new file mode 100644 index 000000000..009109e77 --- /dev/null +++ b/src/lib/two-factor/crypto.ts @@ -0,0 +1,39 @@ +import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; + +/** Loads and validates the 256-bit encryption key from TWO_FACTOR_ENCRYPTION_KEY. */ +function getKey(): Buffer { + const hex = process.env.TWO_FACTOR_ENCRYPTION_KEY; + if (!hex || hex.length !== 64) throw new Error('TWO_FACTOR_ENCRYPTION_KEY is missing or invalid'); + return Buffer.from(hex, 'hex'); +} + +/** Encrypts a TOTP secret using AES-256-GCM. Returns `ciphertext:iv:authTag` as hex. */ +export function encryptSecret(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${encrypted.toString('hex')}:${iv.toString('hex')}:${authTag.toString('hex')}`; +} + +/** Decrypts a TOTP secret previously encrypted by {@link encryptSecret}. */ +export function decryptSecret(stored: string): string { + const key = getKey(); + const [ciphertextHex, ivHex, authTagHex] = stored.split(':'); + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex')); + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, 'hex')), + decipher.final(), + ]); + return decrypted.toString('utf8'); +} + +/** Compares two strings in constant time to prevent timing attacks. */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} diff --git a/src/lib/two-factor/rate-limit.ts b/src/lib/two-factor/rate-limit.ts new file mode 100644 index 000000000..9671549df --- /dev/null +++ b/src/lib/two-factor/rate-limit.ts @@ -0,0 +1,31 @@ +import prisma from '@/lib/prisma'; + +const MAX_ATTEMPTS = 5; +const LOCKOUT_MINUTES = 15; + +export async function checkRateLimit( + userId: string, +): Promise<{ allowed: boolean; lockedUntil?: Date }> { + const record = await prisma.client.twoFactorRateLimit.findUnique({ where: { userId } }); + if (!record) return { allowed: true }; + if (record.lockedUntil && record.lockedUntil > new Date()) { + return { allowed: false, lockedUntil: record.lockedUntil }; + } + return { allowed: true }; +} + +export async function recordFailedAttempt(userId: string): Promise { + const record = await prisma.client.twoFactorRateLimit.upsert({ + where: { userId }, + update: { attempts: { increment: 1 }, updatedAt: new Date() }, + create: { userId, attempts: 1, updatedAt: new Date() }, + }); + if (record.attempts >= MAX_ATTEMPTS) { + const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000); + await prisma.client.twoFactorRateLimit.update({ where: { userId }, data: { lockedUntil } }); + } +} + +export async function resetRateLimit(userId: string): Promise { + await prisma.client.twoFactorRateLimit.deleteMany({ where: { userId } }); +} diff --git a/src/lib/two-factor/replay-prevention.ts b/src/lib/two-factor/replay-prevention.ts new file mode 100644 index 000000000..46f8dc3f9 --- /dev/null +++ b/src/lib/two-factor/replay-prevention.ts @@ -0,0 +1,24 @@ +import prisma from '@/lib/prisma'; + +/** Records a one-time password as used so it cannot be reused within the next 90 seconds. */ +export async function markOtpUsed(userId: string, otp: string): Promise { + const expiresAt = new Date(Date.now() + 90 * 1000); + await prisma.client.twoFactorOtpUsed.upsert({ + where: { userId_otp: { userId, otp } }, + update: { expiresAt }, + create: { userId, otp, expiresAt }, + }); +} + +/** Returns true if this one-time password was already used recently, indicating a replay attack. Expired records are cleaned up automatically. */ +export async function isOtpReplayed(userId: string, otp: string): Promise { + const record = await prisma.client.twoFactorOtpUsed.findUnique({ + where: { userId_otp: { userId, otp } }, + }); + if (!record) return false; + if (record.expiresAt < new Date()) { + await prisma.client.twoFactorOtpUsed.delete({ where: { userId_otp: { userId, otp } } }); + return false; + } + return true; +} diff --git a/src/lib/two-factor/totp.ts b/src/lib/two-factor/totp.ts new file mode 100644 index 000000000..b5cbe6c48 --- /dev/null +++ b/src/lib/two-factor/totp.ts @@ -0,0 +1,23 @@ +import { generateSecret, generateURI, verify } from 'otplib'; +import qrcode from 'qrcode'; + +export function generateTotpSecret(): string { + return generateSecret(); +} + +export function generateOtpAuthUri(secret: string, userEmail: string): string { + return generateURI({ + issuer: 'Umami', + label: userEmail, + secret, + }); +} + +export async function generateQrCodeDataUrl(otpAuthUri: string): Promise { + return qrcode.toDataURL(otpAuthUri, { errorCorrectionLevel: 'H' }); +} + +export async function verifyTotp(token: string, secret: string): Promise { + const result = await verify({ secret, token }); + return result.valid; +}