otp/utils/hotp.js
2025-06-17 14:44:48 +08:00

213 lines
No EOL
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { hmac } = require('./hmac.js');
const { base32Decode } = require('./base32.js');
const { constantTimeEqual, safeIntegerParse } = require('./crypto.js');
// 支持的哈希算法
const HASH_ALGORITHMS = {
'SHA1': 'SHA1',
'SHA256': 'SHA256',
'SHA512': 'SHA512'
};
// 默认配置
const DEFAULT_CONFIG = {
algorithm: HASH_ALGORITHMS.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<string>} 生成的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_ALGORITHMS[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<number|false>} 如果验证通过返回匹配的计数器值否则返回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_ALGORITHMS[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_ALGORITHMS
};