otp/utils/totp.js
“xHuPo” 2b8870a40e init
2025-06-09 13:35:15 +08:00

259 lines
No EOL
7.9 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 { 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<string>} 生成的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<boolean>} 验证是否通过
* @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
};