diff --git a/services/totpService.js b/services/totpService.js index 93550d1..77e9165 100644 --- a/services/totpService.js +++ b/services/totpService.js @@ -1,31 +1,30 @@ 'use strict'; const crypto = require('crypto'); -const { authenticator } = require('otplib'); +const { generateSecret, generateURI, generateSync, verifySync } = 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 +const CHALLENGE_TTL_MS = 5 * 60 * 1000; -authenticator.options = { window: 1 }; // accept ±1 time step for clock drift - -function generateSecret() { - return authenticator.generateSecret(20); +function newSecret() { + return 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 }; + 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, token) { if (!encryptedSecret || !token) return false; try { const secret = decryptSecret(encryptedSecret); - return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret }); + const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' }); + return result?.valid === true; } catch { return false; } @@ -34,13 +33,14 @@ function verifyToken(encryptedSecret, token) { function verifyTokenRaw(secret, token) { if (!secret || !token) return false; try { - return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret }); + const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' }); + return result?.valid === true; } catch { return false; } } -function generateRecoveryCodes() { +function makeRecoveryCodes() { return Array.from({ length: RECOVERY_CODE_COUNT }, () => { const bytes = crypto.randomBytes(5); const hex = bytes.toString('hex').toUpperCase(); @@ -52,7 +52,6 @@ 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); @@ -76,7 +75,6 @@ function consumeRecoveryCode(db, userId, code) { 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) @@ -100,11 +98,11 @@ function pruneExpiredChallenges(db) { } module.exports = { - generateSecret, + generateSecret: newSecret, generateQrCode, verifyToken, verifyTokenRaw, - generateRecoveryCodes, + generateRecoveryCodes: makeRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, createChallenge,