const crypto = require("crypto"); const { authenticator } = require("otplib"); const QRCode = require("qrcode"); const bcrypt = require("bcryptjs"); const logger = require("../utils/logger"); // Configuration const TOTP_ISSUER = process.env.TOTP_ISSUER; const EMAIL_OTP_EXPIRY_MINUTES = parseInt( process.env.TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES, 10, ); const STEP_UP_VALIDITY_MINUTES = parseInt( process.env.TWO_FACTOR_STEP_UP_VALIDITY_MINUTES, 10, ); const MAX_EMAIL_OTP_ATTEMPTS = 3; const RECOVERY_CODE_COUNT = 10; const BCRYPT_ROUNDS = 12; // Characters for recovery codes (excludes confusing chars: 0, O, 1, I, L) const RECOVERY_CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; class TwoFactorService { /** * Generate a new TOTP secret and QR code for setup * @param {string} email - User's email address * @returns {Promise<{qrCodeDataUrl: string, encryptedSecret: string, encryptedSecretIv: string}>} */ static async generateTotpSecret(email) { const secret = authenticator.generateSecret(); const otpAuthUrl = authenticator.keyuri(email, TOTP_ISSUER, secret); // Generate QR code as data URL const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl); // Encrypt the secret for storage const { encrypted, iv } = this._encryptSecret(secret); return { qrCodeDataUrl, encryptedSecret: encrypted, encryptedSecretIv: iv, }; } /** * Verify a TOTP code against a user's secret * @param {string} encryptedSecret - Encrypted TOTP secret * @param {string} iv - Initialization vector for decryption * @param {string} code - 6-digit TOTP code to verify * @returns {boolean} */ static verifyTotpCode(encryptedSecret, iv, code) { try { // Validate code format if (!/^\d{6}$/.test(code)) { return false; } // Decrypt the secret const secret = this._decryptSecret(encryptedSecret, iv); // Verify with window 0 (only current 30-second period) to prevent replay attacks return authenticator.verify({ token: code, secret, window: 0 }); } catch (error) { logger.error("TOTP verification error:", error); return false; } } /** * Generate a 6-digit email OTP code * @returns {{code: string, hashedCode: string, expiry: Date}} */ static generateEmailOtp() { // Generate 6-digit numeric code const code = crypto.randomInt(100000, 999999).toString(); // Hash the code for storage (SHA-256) const hashedCode = crypto.createHash("sha256").update(code).digest("hex"); // Calculate expiry const expiry = new Date(Date.now() + EMAIL_OTP_EXPIRY_MINUTES * 60 * 1000); return { code, hashedCode, expiry }; } /** * Verify an email OTP code using timing-safe comparison * @param {string} inputCode - Code entered by user * @param {string} storedHash - Hashed code stored in database * @param {Date} expiry - Expiry timestamp * @returns {boolean} */ static verifyEmailOtp(inputCode, storedHash, expiry) { try { // Validate code format if (!/^\d{6}$/.test(inputCode)) { return false; } // Check expiry if (!expiry || new Date() > new Date(expiry)) { return false; } // Hash the input code const inputHash = crypto .createHash("sha256") .update(inputCode) .digest("hex"); // Timing-safe comparison const inputBuffer = Buffer.from(inputHash, "hex"); const storedBuffer = Buffer.from(storedHash, "hex"); if (inputBuffer.length !== storedBuffer.length) { return false; } return crypto.timingSafeEqual(inputBuffer, storedBuffer); } catch (error) { logger.error("Email OTP verification error:", error); return false; } } /** * Generate recovery codes (10 codes in XXXX-XXXX format) * @returns {Promise<{codes: string[], hashedCodes: string[]}>} */ static async generateRecoveryCodes() { const codes = []; const hashedCodes = []; for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { // Generate code in XXXX-XXXX format let code = ""; for (let j = 0; j < 8; j++) { if (j === 4) code += "-"; code += RECOVERY_CODE_CHARS[crypto.randomInt(RECOVERY_CODE_CHARS.length)]; } codes.push(code); // Hash the code for storage const hashedCode = await bcrypt.hash(code, BCRYPT_ROUNDS); hashedCodes.push(hashedCode); } return { codes, hashedCodes }; } /** * Verify a recovery code and return the index if valid * @param {string} inputCode - Recovery code entered by user * @param {Object} recoveryData - Recovery codes data (structured format) * @returns {Promise<{valid: boolean, index: number}>} */ static async verifyRecoveryCode(inputCode, recoveryData) { // Normalize input (uppercase, ensure format) const normalizedCode = inputCode.toUpperCase().trim(); // Validate format if (!/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalizedCode)) { return { valid: false, index: -1 }; } // Handle both old format (array) and new format (structured object) const codes = recoveryData.version ? recoveryData.codes : recoveryData.map((hash, i) => ({ hash, used: hash === null, index: i, })); // Check each code for (let i = 0; i < codes.length; i++) { const codeEntry = codes[i]; // Skip already used codes if (codeEntry.used || !codeEntry.hash) continue; const isMatch = await bcrypt.compare(normalizedCode, codeEntry.hash); if (isMatch) { return { valid: true, index: i }; } } return { valid: false, index: -1 }; } /** * Validate if a step-up session is still valid * @param {Object} user - User object with twoFactorVerifiedAt field * @param {number} maxAgeMinutes - Maximum age in minutes (default: 15) * @returns {boolean} */ static validateStepUpSession(user, maxAgeMinutes = STEP_UP_VALIDITY_MINUTES) { if (!user.twoFactorVerifiedAt) { return false; } const verifiedAt = new Date(user.twoFactorVerifiedAt); const maxAge = maxAgeMinutes * 60 * 1000; const now = Date.now(); return now - verifiedAt.getTime() < maxAge; } /** * Get the count of remaining recovery codes * @param {Object|Array} recoveryData - Recovery codes data (structured or legacy format) * @returns {number} */ static getRemainingRecoveryCodesCount(recoveryData) { if (!recoveryData) { return 0; } // Handle new structured format if (recoveryData.version) { return recoveryData.codes.filter((code) => !code.used).length; } // Handle legacy array format if (Array.isArray(recoveryData)) { return recoveryData.filter((code) => code !== null && code !== "").length; } return 0; } /** * Encrypt a TOTP secret using AES-256-GCM * @param {string} secret - Plain text secret * @returns {{encrypted: string, iv: string}} * @private */ static _encryptSecret(secret) { const encryptionKey = process.env.TOTP_ENCRYPTION_KEY; if (!encryptionKey || encryptionKey.length !== 64) { throw new Error( "TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)", ); } const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv( "aes-256-gcm", Buffer.from(encryptionKey, "hex"), iv, ); let encrypted = cipher.update(secret, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag().toString("hex"); return { encrypted: encrypted + ":" + authTag, iv: iv.toString("hex"), }; } /** * Decrypt a TOTP secret using AES-256-GCM * @param {string} encryptedData - Encrypted data with auth tag * @param {string} iv - Initialization vector (hex) * @returns {string} - Decrypted secret * @private */ static _decryptSecret(encryptedData, iv) { const encryptionKey = process.env.TOTP_ENCRYPTION_KEY; if (!encryptionKey || encryptionKey.length !== 64) { throw new Error( "TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)", ); } const [ciphertext, authTag] = encryptedData.split(":"); const decipher = crypto.createDecipheriv( "aes-256-gcm", Buffer.from(encryptionKey, "hex"), Buffer.from(iv, "hex"), ); decipher.setAuthTag(Buffer.from(authTag, "hex")); let decrypted = decipher.update(ciphertext, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } /** * Check if email OTP attempts are locked * @param {number} attempts - Current attempt count * @returns {boolean} */ static isEmailOtpLocked(attempts) { return attempts >= MAX_EMAIL_OTP_ATTEMPTS; } } module.exports = TwoFactorService;