← 中文资源

简体中文版本

TOTP 动态口令生成(RFC 6238)

从 Base32 密钥生成并校验基于时间的一次性口令,可配置位数、周期与 HMAC 算法。

阅读英文原文

功能说明

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;
}