259 lines
No EOL
7.9 KiB
JavaScript
259 lines
No EOL
7.9 KiB
JavaScript
const { hmac } = require('./hmac.js');
|
||
const { decode: 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
|
||
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_ALGORITHMS[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_ALGORITHMS[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_ALGORITHMS
|
||
}; |