213 lines
No EOL
6.2 KiB
JavaScript
213 lines
No EOL
6.2 KiB
JavaScript
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
|
||
}; |