'use strict'; import type { Db } from '../types/db'; const crypto = require('crypto'); const { generateSecret, generateURI, verifySync } = require('otplib'); const QRCode = require('qrcode'); const { encryptSecret, decryptSecret } = require('./encryptionService.cts'); const APP_NAME = 'Bill Tracker'; const RECOVERY_CODE_COUNT = 8; const CHALLENGE_TTL_MS = 5 * 60 * 1000; function newSecret(): string { return generateSecret(20); } async function generateQrCode( secret: string, username: string, ): Promise<{ uri: string; qr_data_url: string }> { const uri = generateURI({ secret, account: username, issuer: APP_NAME, type: 'totp' }); const qr_data_url = await QRCode.toDataURL(uri, { width: 200, margin: 2 }); return { uri, qr_data_url }; } function verifyToken( encryptedSecret: string | null | undefined, token: string | number | null | undefined, ): boolean { if (!encryptedSecret || !token) return false; try { const secret = decryptSecret(encryptedSecret); const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' }); return result?.valid === true; } catch { return false; } } function verifyTokenRaw( secret: string | null | undefined, token: string | number | null | undefined, ): boolean { if (!secret || !token) return false; try { const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' }); return result?.valid === true; } catch { return false; } } function makeRecoveryCodes(): string[] { return Array.from({ length: RECOVERY_CODE_COUNT }, () => { const bytes = crypto.randomBytes(5); const hex = bytes.toString('hex').toUpperCase(); return `${hex.slice(0, 5)}-${hex.slice(5)}`; }); } function hashRecoveryCode(code: string): string { return crypto.createHash('sha256').update(code.replace(/-/g, '').toUpperCase()).digest('hex'); } function consumeRecoveryCode( db: Db, userId: number, code: string, ): { used: boolean; remaining?: number } { const normalized = code.replace(/[-\s]/g, '').toUpperCase(); const user = db.prepare('SELECT totp_recovery_codes FROM users WHERE id = ?').get(userId); if (!user?.totp_recovery_codes) return { used: false }; let stored: string[]; try { stored = JSON.parse(decryptSecret(user.totp_recovery_codes)); } catch { return { used: false }; } const incomingHash = crypto.createHash('sha256').update(normalized).digest('hex'); const idx = stored.findIndex((h) => h === incomingHash); if (idx === -1) return { used: false }; stored.splice(idx, 1); db.prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?').run( encryptSecret(JSON.stringify(stored)), userId, ); return { used: true, remaining: stored.length }; } function createChallenge(db: Db, userId: number): string { const id = crypto.randomUUID(); const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS) .toISOString() .slice(0, 19) .replace('T', ' '); db.prepare('DELETE FROM totp_challenges WHERE user_id = ?').run(userId); db.prepare('INSERT INTO totp_challenges (id, user_id, expires_at) VALUES (?, ?, ?)').run( id, userId, expiresAt, ); return id; } function consumeChallenge(db: Db, challengeId: string): number | null { const row = db .prepare("SELECT user_id FROM totp_challenges WHERE id = ? AND expires_at > datetime('now')") .get(challengeId); if (!row) return null; db.prepare('DELETE FROM totp_challenges WHERE id = ?').run(challengeId); return row.user_id; } function pruneExpiredChallenges(db: Db): void { db.prepare("DELETE FROM totp_challenges WHERE expires_at <= datetime('now')").run(); } module.exports = { generateSecret: newSecret, generateQrCode, verifyToken, verifyTokenRaw, generateRecoveryCodes: makeRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, createChallenge, consumeChallenge, pruneExpiredChallenges, };