功能说明
generateTotp 根据 Base32 密钥与当前时间生成六到十位动态口令,verifyTotp 在允许的时间窗口内校验用户输入。实现遵循 RFC 6238,可用于双因素认证、管理后台二次验证和自动化登录流程。支持 SHA-1、SHA-256 与 SHA-512,周期默认三十秒,时间窗口默认前后各一格。
关键代码
Base32 解码逐字符取五比特并拼接为字节,忽略空格、等号与连字符。生成时把时间戳除以周期得到计数器,写入八字节大端缓冲区,再用 HMAC 计算摘要。动态截断取摘要最后一位低四位作为偏移,读取四字节并清空最高位,最后对十的位数次方取模并左补零。校验使用 timingSafeEqual 避免比较泄漏。
使用注意
动态口令必须配合服务端限流与一次性使用记录,否则窗口内可被重放。密钥应加密存储并通过安全通道下发,不能写入日志或前端源码。服务器时间需用 NTP 同步,偏移过大会导致校验失败。默认 SHA-1 是兼容性选择,新系统可选 SHA-256 或 SHA-512,但认证器必须支持相同参数。
验证方式
使用 RFC 6238 附录中的测试密钥与时间点,对比 SHA-1、SHA-256、SHA-512 的已知输出。测试六位、八位与十位口令,验证前后窗口、窗口为零、过期口令与错误口令。测试非法 Base32 字符、零周期、越界位数与非数字令牌,确认校验返回 false 而不是抛错。
代码示例
import { createHmac, timingSafeEqual } from "node:crypto";
export interface TotpOptions {
digits?: number;
period?: number;
algorithm?: "sha1" | "sha256" | "sha512";
}
export function base32Decode(secret: string): Buffer {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const clean = secret.toUpperCase().replace(/[\s=-]/g, "");
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const character of clean) {
const index = alphabet.indexOf(character);
if (index === -1) throw new TypeError("invalid base32 character: " + character);
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
export function generateTotp(secret: string, timestamp = Date.now(), options: TotpOptions = {}): string {
const { digits = 6, period = 30, algorithm = "sha1" } = options;
if (!Number.isInteger(digits) || digits < 6 || digits > 10) {
throw new RangeError("digits must be an integer between 6 and 10");
}
if (!Number.isInteger(period) || period <= 0) throw new RangeError("period must be a positive integer");
const counter = Math.floor(timestamp / 1000 / period);
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64BE(BigInt(counter));
const hmac = createHmac(algorithm, base32Decode(secret)).update(buffer).digest();
const offset = hmac[hmac.length - 1]! & 0x0f;
const binary = ((hmac[offset]! & 0x7f) << 24)
| (hmac[offset + 1]! << 16)
| (hmac[offset + 2]! << 8)
| hmac[offset + 3]!;
return String(binary % 10 ** digits).padStart(digits, "0");
}
export function verifyTotp(
secret: string,
token: string,
timestamp = Date.now(),
window = 1,
options: TotpOptions = {},
): boolean {
if (!/^\d+$/.test(token)) return false;
if (!Number.isInteger(window) || window < 0) {
throw new RangeError("window must be a non-negative integer");
}
const periodSeconds = options.period ?? 30;
for (let offset = -window; offset <= window; offset += 1) {
const candidate = generateTotp(secret, timestamp + offset * periodSeconds * 1000, options);
if (candidate.length === token.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(token))) {
return true;
}
}
return false;
}