const { hmac } = require('./hmac.js'); const { decode: base32Decode } = require('./base32.js'); const { constantTimeEqual, safeIntegerParse } = require('./crypto.js'); // 支持的哈希算法 const HASH_ALGOS = { 'SHA1': 'SHA1', 'SHA256': 'SHA256', 'SHA512': 'SHA512' }; // 默认配置 const DEFAULT_CONFIG = { algorithm: HASH_ALGOS.SHA1, // 默认使用SHA1 period: 30, // 默认30秒时间窗口 digits: 6, // 默认6位数字 timestamp: null, // 默认使用当前时间 window: 1 // 默认验证前后1个时间窗口 }; /** * 生成TOTP值 * * @param {string} secret - Base32编码的密钥 * @param {Object} options - 配置选项 * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) * @param {number} [options.period=30] - 时间窗口大小(秒) * @param {number} [options.digits=6] - 生成的OTP位数 * @param {number} [options.timestamp=null] - 指定时间戳(秒),null表示使用当前时间 * @returns {Promise} 生成的TOTP值 * @throws {Error} 参数无效时抛出错误 */ async function generateTOTP(secret, options = {}) { // 验证密钥 if (!secret || typeof secret !== 'string') { throw new Error('Secret must be a non-empty string'); } // 合并配置 const config = { ...DEFAULT_CONFIG, ...options }; // 验证算法 if (!HASH_ALGOS[config.algorithm]) { throw new Error(`Unsupported algorithm: ${config.algorithm}`); } // 验证时间窗口 const period = safeIntegerParse(config.period, 1, 300); if (period === null) { throw new Error('Period must be an integer between 1 and 300'); } // 验证位数 const digits = safeIntegerParse(config.digits, 6, 8); if (digits === null) { throw new Error('Digits must be an integer between 6 and 8'); } // 获取时间戳 const timestamp = config.timestamp === null ? Math.floor(Date.now() / 1000) : config.timestamp; if (!Number.isInteger(timestamp) || timestamp < 0) { throw new Error('Invalid timestamp'); } try { // 解码密钥 const key = base32Decode(secret); // 计算时间计数器 const counter = Math.floor(timestamp / period); // 生成8字节的计数器缓冲区 const counterBuffer = new ArrayBuffer(8); const counterView = new DataView(counterBuffer); counterView.setBigInt64(0, BigInt(counter), false); // big-endian // 计算HMAC const hmacInput = new Uint8Array(counterBuffer); const hash = await hmac(config.algorithm, key, hmacInput); // 根据RFC 6238获取偏移量 const offset = hash[hash.length - 1] & 0xf; // 生成4字节的动态截断数 const binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); // 生成指定位数的OTP const otp = binary % Math.pow(10, digits); // 补齐前导零 return otp.toString().padStart(digits, '0'); } catch (error) { throw new Error(`Failed to generate TOTP: ${error.message}`); } } /** * 验证TOTP值 * * @param {string} token - 要验证的TOTP值 * @param {string} secret - Base32编码的密钥 * @param {Object} options - 配置选项 * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) * @param {number} [options.period=30] - 时间窗口大小(秒) * @param {number} [options.digits=6] - TOTP位数 * @param {number} [options.timestamp=null] - 指定时间戳(秒),null表示使用当前时间 * @param {number} [options.window=1] - 验证窗口大小(前后几个时间周期) * @returns {Promise} 验证是否通过 * @throws {Error} 参数无效时抛出错误 */ async function verifyTOTP(token, secret, options = {}) { // 验证token if (!token || typeof token !== 'string') { throw new Error('Token must be a non-empty string'); } // 合并配置 const config = { ...DEFAULT_CONFIG, ...options }; // 验证窗口大小 const window = safeIntegerParse(config.window, 0, 10); if (window === null) { throw new Error('Window must be an integer between 0 and 10'); } // 验证token长度 if (token.length !== config.digits) { return false; } // 验证token是否为纯数字 if (!/^\d+$/.test(token)) { return false; } try { const timestamp = config.timestamp === null ? Math.floor(Date.now() / 1000) : config.timestamp; // 检查前后window个时间窗口 for (let i = -window; i <= window; i++) { const checkTime = timestamp + (i * config.period); const generatedToken = await generateTOTP(secret, { ...config, timestamp: checkTime }); // 使用常量时间比较 if (constantTimeEqual(token, generatedToken)) { return true; } } return false; } catch (error) { throw new Error(`Failed to verify TOTP: ${error.message}`); } } /** * 获取当前TOTP的剩余有效时间(秒) * * @param {Object} options - 配置选项 * @param {number} [options.period=30] - 时间窗口大小(秒) * @param {number} [options.timestamp=null] - 指定时间戳(秒),null表示使用当前时间 * @returns {number} 剩余秒数 * @throws {Error} 参数无效时抛出错误 */ function getRemainingSeconds(options = {}) { const config = { ...DEFAULT_CONFIG, ...options }; // 验证时间窗口 const period = safeIntegerParse(config.period, 1, 300); if (period === null) { throw new Error('Period must be an integer between 1 and 300'); } const timestamp = config.timestamp === null ? Math.floor(Date.now() / 1000) : config.timestamp; if (!Number.isInteger(timestamp) || timestamp < 0) { throw new Error('Invalid timestamp'); } // 返回从(period-1)到0的倒计时,而不是从period到1 return (period - (timestamp % period) - 1 + period) % period; } /** * 生成TOTP URI * 遵循 otpauth:// 规范 * * @param {string} secret - Base32编码的密钥 * @param {string} accountName - 账户名称 * @param {string} issuer - 发行者名称 * @param {Object} options - 配置选项 * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) * @param {number} [options.period=30] - 时间窗口大小(秒) * @param {number} [options.digits=6] - TOTP位数 * @returns {string} TOTP URI * @throws {Error} 参数无效时抛出错误 */ function generateTOTPUri(secret, accountName, issuer, options = {}) { if (!secret || typeof secret !== 'string') { throw new Error('Secret must be a non-empty string'); } if (!accountName || typeof accountName !== 'string') { throw new Error('Account name must be a non-empty string'); } if (!issuer || typeof issuer !== 'string') { throw new Error('Issuer must be a non-empty string'); } const config = { ...DEFAULT_CONFIG, ...options }; // 验证算法 if (!HASH_ALGOS[config.algorithm]) { throw new Error(`Unsupported algorithm: ${config.algorithm}`); } // 验证时间窗口 const period = safeIntegerParse(config.period, 1, 300); if (period === null) { throw new Error('Period must be an integer between 1 and 300'); } // 验证位数 const digits = safeIntegerParse(config.digits, 6, 8); if (digits === null) { throw new Error('Digits must be an integer between 6 and 8'); } // 构建参数 const params = new URLSearchParams({ secret: secret, issuer: issuer, algorithm: config.algorithm, digits: config.digits.toString(), period: config.period.toString() }); // 生成URI // 注意:需要对issuer和accountName进行URI编码 const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`; return `otpauth://totp/${label}?${params.toString()}`; } module.exports = { generateTOTP, verifyTOTP, getRemainingSeconds, generateTOTPUri, HASH_ALGOS };