'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, };