'use strict'; const crypto = require('crypto'); const { authenticator } = require('otplib'); const QRCode = require('qrcode'); const { encryptSecret, decryptSecret } = require('./encryptionService'); const APP_NAME = 'Bill Tracker'; const RECOVERY_CODE_COUNT = 8; const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes authenticator.options = { window: 1 }; // accept ±1 time step for clock drift function generateSecret() { return authenticator.generateSecret(20); } async function generateQrCode(secret, username) { const uri = authenticator.keyuri(username, APP_NAME, secret); const dataUrl = await QRCode.toDataURL(uri, { width: 200, margin: 2 }); return { uri, qr_data_url: dataUrl }; } function verifyToken(encryptedSecret, token) { if (!encryptedSecret || !token) return false; try { const secret = decryptSecret(encryptedSecret); return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret }); } catch { return false; } } function verifyTokenRaw(secret, token) { if (!secret || !token) return false; try { return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret }); } catch { return false; } } function generateRecoveryCodes() { 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) { return crypto.createHash('sha256').update(code.replace(/-/g, '').toUpperCase()).digest('hex'); } // Returns { used: true } if a matching unused recovery code was found and consumed. function consumeRecoveryCode(db, userId, code) { 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; 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 }; } // Short-lived challenge token issued after password passes, before TOTP is verified. function createChallenge(db, userId) { 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, challengeId) { 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.prepare("DELETE FROM totp_challenges WHERE expires_at <= datetime('now')").run(); } module.exports = { generateSecret, generateQrCode, verifyToken, verifyTokenRaw, generateRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, createChallenge, consumeChallenge, pruneExpiredChallenges, };