const { hmac } = require('./hmac.js'); const { 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 digits: 6, // 默认6位数字 window: 1 // 默认验证前后1个计数值 }; /** * 生成HOTP值 * * @param {string} secret - Base32编码的密钥 * @param {number} counter - 计数器值 * @param {Object} options - 配置选项 * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) * @param {number} [options.digits=6] - 生成的OTP位数 * @returns {Promise} 生成的HOTP值 * @throws {Error} 参数无效时抛出错误 */ async function generateHOTP(secret, counter, options = {}) { // 验证密钥 if (!secret || typeof secret !== 'string') { throw new Error('Secret must be a non-empty string'); } // 验证计数器 if (!Number.isInteger(counter) || counter < 0) { throw new Error('Counter must be a non-negative integer'); } // 合并配置 const config = { ...DEFAULT_CONFIG, ...options }; // 验证算法 if (!HASH_ALGOS[config.algorithm]) { throw new Error(`Unsupported algorithm: ${config.algorithm}`); } // 验证位数 const digits = safeIntegerParse(config.digits, 6, 8); if (digits === null) { throw new Error('Digits must be an integer between 6 and 8'); } try { // 解码密钥 const key = base32Decode(secret); // 生成8字节的计数器缓冲区 const counterBuffer = new ArrayBuffer(8); const counterView = new DataView(counterBuffer); counterView.setBigInt64(0, BigInt(counter), false); // big-endian // 计算HMAC const hash = await hmac(config.algorithm, key, new Uint8Array(counterBuffer)); // 根据RFC 4226获取偏移量 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 HOTP: ${error.message}`); } } /** * 验证HOTP值 * * @param {string} token - 要验证的HOTP值 * @param {string} secret - Base32编码的密钥 * @param {number} counter - 当前计数器值 * @param {Object} options - 配置选项 * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) * @param {number} [options.digits=6] - HOTP位数 * @param {number} [options.window=1] - 验证窗口大小(前后几个计数值) * @returns {Promise} 如果验证通过,返回匹配的计数器值;否则返回false * @throws {Error} 参数无效时抛出错误 */ async function verifyHOTP(token, secret, counter, options = {}) { // 验证token if (!token || typeof token !== 'string') { throw new Error('Token must be a non-empty string'); } // 验证计数器 if (!Number.isInteger(counter) || counter < 0) { throw new Error('Counter must be a non-negative integer'); } // 合并配置 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 { // 检查前后window个计数值 for (let i = -window; i <= window; i++) { const checkCounter = counter + i; if (checkCounter < 0) continue; const generatedToken = await generateHOTP(secret, checkCounter, config); // 使用常量时间比较 if (constantTimeEqual(token, generatedToken)) { return checkCounter; } } return false; } catch (error) { throw new Error(`Failed to verify HOTP: ${error.message}`); } } /** * 生成HOTP URI * 遵循 otpauth:// 规范 * * @param {string} secret - Base32编码的密钥 * @param {string} accountName - 账户名称 * @param {string} issuer - 发行者名称 * @param {number} counter - 初始计数器值 * @param {Object} options - 配置选项 * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) * @param {number} [options.digits=6] - HOTP位数 * @returns {string} HOTP URI * @throws {Error} 参数无效时抛出错误 */ function generateHOTPUri(secret, accountName, issuer, counter, 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'); } if (!Number.isInteger(counter) || counter < 0) { throw new Error('Counter must be a non-negative integer'); } const config = { ...DEFAULT_CONFIG, ...options }; // 验证算法 if (!HASH_ALGOS[config.algorithm]) { throw new Error(`Unsupported algorithm: ${config.algorithm}`); } // 验证位数 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(), counter: counter.toString() }); // 生成URI // 注意:需要对issuer和accountName进行URI编码 const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`; return `otpauth://hotp/${label}?${params.toString()}`; } module.exports = { generateHOTP, verifyHOTP, generateHOTPUri, HASH_ALGOS };