// src/services/otpService.js const bcrypt = require('bcrypt'); const db = require('../db'); const { markActiveOtp } = require('../middleware/rateLimitMiddleware'); // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === const { encryptPhoneNumber } = require('../utils/fieldEncryption'); // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // OTP validity changed from 10 minutes to 2 minutes (120 seconds) const OTP_TTL_SECONDS = parseInt(process.env.OTP_TTL_SECONDS || '120', 10); // 2 minutes const OTP_EXPIRY_MS = OTP_TTL_SECONDS * 1000; const MAX_OTP_ATTEMPTS = Number(process.env.OTP_VERIFY_MAX_ATTEMPTS || process.env.OTP_MAX_ATTEMPTS || 5); // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Pre-computed dummy hash for constant-time comparison when OTP not found // This ensures bcrypt.compare() always executes with similar timing // Generated once at module load to avoid performance impact let dummyOtpHash = null; async function getDummyOtpHash() { if (!dummyOtpHash) { // Generate a dummy hash that will never match any real OTP const dummyCode = 'DUMMY_OTP_' + Math.random().toString(36).substring(2, 15) + Date.now(); dummyOtpHash = await bcrypt.hash(dummyCode, 10); } return dummyOtpHash; } /** * Extract country code from phone number (e.g., +91 from +919876543210) * @param {string} phoneNumber - Phone number in E.164 format * @returns {string} Country code (default: '+91') */ function extractCountryCode(phoneNumber) { if (!phoneNumber || !phoneNumber.startsWith('+')) { return '+91'; // Default to India } // Extract country code (typically 1-3 digits after +) const match = phoneNumber.match(/^\+(\d{1,3})/); return match ? `+${match[1]}` : '+91'; } /** * Generate a random 6-digit OTP code */ function generateOtpCode() { return Math.floor(100000 + Math.random() * 900000).toString(); } /** * Create and store an OTP for a phone number * @param {string} phoneNumber - The phone number to send OTP to * @returns {Promise<{code: string}>} - The generated OTP code */ async function createOtp(phoneNumber) { const code = generateOtpCode(); const expiresAt = new Date(Date.now() + OTP_EXPIRY_MS); const otpHash = await bcrypt.hash(code, 10); // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Encrypt phone number before storing const encryptedPhone = encryptPhoneNumber(phoneNumber); const countryCode = extractCountryCode(phoneNumber); // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // Mark existing OTPs as deleted for this phone number // Note: We search by encrypted phone to handle both encrypted and plaintext (backward compatibility) await db.query( `UPDATE otp_requests SET deleted = TRUE WHERE (phone_number = $1 OR phone_number = $2) AND deleted = FALSE`, [encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility ); // Insert new OTP with encrypted phone number (using otp_requests table from final_db.sql) await db.query( `INSERT INTO otp_requests (phone_number, country_code, otp_hash, expires_at, attempt_count, deleted) VALUES ($1, $2, $3, $4, 0, FALSE)`, [encryptedPhone, countryCode, otpHash, expiresAt] ); // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // Mark OTP as active in Redis/memory store to prevent resend within 2 minutes try { await markActiveOtp(phoneNumber, OTP_TTL_SECONDS); } catch (err) { console.error('Failed to mark active OTP in rate limiter:', err); // Don't fail OTP creation if rate limiter fails } return { code }; } /** * Verify an OTP code for a phone number * @param {string} phoneNumber - The phone number * @param {string} code - The OTP code to verify * @returns {Promise<{ok: boolean}>} - Whether the OTP is valid * * === SECURITY HARDENING: TIMING ATTACK PROTECTION === * This function uses constant-time execution to prevent timing-based attacks: * - Always performs bcrypt.compare() even for expired/max attempts cases * - Uses dummy hash for "not found" case to maintain constant time * - All code paths take similar execution time regardless of outcome */ async function verifyOtp(phoneNumber, code) { // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Encrypt phone number for search (handles both encrypted and plaintext for backward compatibility) const encryptedPhone = encryptPhoneNumber(phoneNumber); const result = await db.query( `SELECT id, otp_hash, expires_at, attempt_count, phone_number, consumed_at FROM otp_requests WHERE (phone_number = $1 OR phone_number = $2) AND deleted = FALSE AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1`, [encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility ); // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Always perform bcrypt.compare() to maintain constant execution time // Use a dummy hash if OTP not found to prevent timing leaks let otpRecord = result.rows[0]; let isNotFound = false; let isExpired = false; let isMaxAttempts = false; let hashToCompare = null; if (!otpRecord) { // OTP not found - use dummy hash to maintain constant time // This ensures bcrypt.compare() always executes with similar timing isNotFound = true; hashToCompare = await getDummyOtpHash(); otpRecord = { id: null, otp_hash: hashToCompare, // Dummy hash for constant-time comparison expires_at: new Date(Date.now() + 1000), // Future date attempt_count: 0, }; } else { hashToCompare = otpRecord.otp_hash; // Check if expired (but don't return early - continue to bcrypt.compare) isExpired = new Date() > new Date(otpRecord.expires_at); // Check if max attempts exceeded (but don't return early - continue to bcrypt.compare) isMaxAttempts = otpRecord.attempt_count >= MAX_OTP_ATTEMPTS; } // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Always perform bcrypt.compare() regardless of expiration/attempts status // This ensures all code paths take similar time // Even if we know the OTP is expired or max attempts exceeded, we still compare // to prevent attackers from distinguishing between different failure modes const matches = await bcrypt.compare(code, hashToCompare); // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Determine the actual result after constant-time comparison // Priority: not_found > expired > max_attempts > invalid > valid if (isNotFound) { // OTP not found - return generic error return { ok: false, reason: 'not_found' }; } if (isExpired) { // OTP expired - mark as consumed (but we already did bcrypt.compare for constant time) await db.query( 'UPDATE otp_requests SET consumed_at = NOW() WHERE id = $1', [otpRecord.id] ); return { ok: false, reason: 'expired' }; } if (isMaxAttempts) { // Max attempts exceeded - mark as consumed (but we already did bcrypt.compare for constant time) await db.query( 'UPDATE otp_requests SET consumed_at = NOW() WHERE id = $1', [otpRecord.id] ); return { ok: false, reason: 'max_attempts' }; } // Check if code matches (only if not expired and not max attempts) if (!matches) { // === ADDED FOR OTP ATTEMPT LIMIT === // Increment attempt count await db.query( 'UPDATE otp_requests SET attempt_count = attempt_count + 1 WHERE id = $1', [otpRecord.id] ); return { ok: false, reason: 'invalid' }; } // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // Mark OTP as consumed once verified to prevent reuse // Using consumed_at instead of deleting (matches final_db.sql schema) await db.query( 'UPDATE otp_requests SET consumed_at = NOW() WHERE id = $1', [otpRecord.id] ); return { ok: true }; } /** * Clean up expired OTPs (can be called periodically) * Marks expired OTPs as consumed instead of deleting (matches final_db.sql schema) */ async function cleanupExpiredOtps() { await db.query( `UPDATE otp_requests SET consumed_at = NOW() WHERE expires_at < NOW() AND consumed_at IS NULL AND deleted = FALSE` ); } module.exports = { createOtp, verifyOtp, cleanupExpiredOtps, };