自己实现一遍 TOTP:RFC 6238 的六个步骤,和动态截断那一步的四个坑

· 约 5 分钟 🔬 TOTP 调试台

「验证码对不上」的排查之所以难,是因为 TOTP 的六个步骤里,每一步出错的表现都长得一样——码就是不对,没有任何额外信息。

知道这六步分别在做什么,排查就从猜变成了对照。

全流程

Base32 密钥
   ↓ ① 解码成字节
密钥字节
   ↓ ② counter = floor(unix秒 / 周期)
计数器
   ↓ ③ 打包成 8 字节大端
8 字节消息
   ↓ ④ HMAC-SHA1(密钥字节, 消息)
20 字节摘要
   ↓ ⑤ 动态截断 → 31 位整数
整数
   ↓ ⑥ % 10^位数,左补零
"012345"

TOTP(RFC 6238)其实是 HOTP(RFC 4226)的一层薄封装:HOTP 用计数器,TOTP 把计数器换成了「当前时间除以周期」。除了第 ② 步,两者完全一样。

① Base32 解码 —— 最经典的一个 bug

密钥在 otpauth 链接和二维码里都是 Base32 文本(RFC 4648 字母表:A-Z2-7)。

JBSWY3DPEHPK3PXP  →  [0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0xde, 0xad, 0xbe, 0xef]

必须解成字节再交给 HMAC 把 Base32 字符串当 ASCII 字节直接喂进去,是这个领域最经典的实现 bug——

为什么它这么难发现:你的出码和你的验码用了同一份错误逻辑,自测能全过。只有拿标准验证器 App 扫同一个密钥时才会穿帮。

几个输入处理细节:

  • 去掉空格和连字符(很多服务为了便于手抄会分组)
  • 统一成大写
  • 尾部的 = 填充可以忽略——otpauth 链接里习惯不带填充

② 时间步取整

counter = floor(unixSeconds / period)     // period 默认 30

三个容易错的地方:

  • 用毫秒时间戳忘了除 1000 —— counter 大了一千倍,永远对不上
  • 用了四舍五入而不是向下取整 —— 每个周期里有一半时间是错的,表现为「时好时坏」
  • 时区 —— Unix 时间戳本身与时区无关,如果你的代码里出现了时区转换,那一定是多余的

③ 打包成 8 字节大端

counter 要写成 64 位无符号整数的大端字节序,高位在前:

counter = 1                →  00 00 00 00 00 00 00 01
counter = 57190800         →  00 00 00 00 03 68 F7 90

JavaScript 的坑在这里:位运算是 32 位的counter << 32 直接溢出。要用 BigInt,或者手动拆成高低两个 32 位分别写。

④ HMAC

digest = HMAC-SHA1(key = 密钥字节, message = 8 字节计数器)

摘要 20 字节。密钥长度不需要自己补齐或截断——HMAC 算法本身会处理(短的补零、长的先哈希)。

算法可以换成 SHA-256(32 字节)或 SHA-512(64 字节),但要注意不少验证器 App 只支持 SHA-1,服务端换算法前要先确认用户侧能用。

⑤ 动态截断 —— 四个坑都在这里

这是整个算法里最反直觉的一步,也是手写实现最容易错的一步:

const offset = digest[digest.length - 1] & 0x0f;      // 坑 1、坑 2
const code =
  ((digest[offset]     & 0x7f) << 24) |               // 坑 3
  ((digest[offset + 1] & 0xff) << 16) |
  ((digest[offset + 2] & 0xff) <<  8) |
   (digest[offset + 3] & 0xff);

坑 1:offset 不是固定的。 它由摘要自己的最后一个字节决定,每次都不一样。写死成 digest[0] 或某个固定位置,能算出一个稳定的数,但和标准实现永远对不上。

坑 2:不要写死 digest[19] SHA-1 的摘要正好 20 字节,所以 digest[19] 在默认情况下是对的;一旦换成 SHA-256 就错了。写成 digest[digest.length - 1] 才通用。

顺带解释一个常见疑问:offset 的取值范围是 0 – 15,连取 4 字节最多读到第 19 个字节——SHA-1 的 20 字节刚好够用。换成更长的摘要时,后面的字节用不上,这不是 bug,规范就是这么定的。

坑 3:& 0x7f 不能漏。 它把最高位清掉,避免在有符号整数的语言里被当成负数。

这个坑的可怕之处在于它有一半的概率不发作——那个字节的最高位是 0 时,漏不漏结果一样。于是自测大概率能过,上线之后表现为「偶尔有一次码不对」,而服务端通常容忍前后窗口,排查时很容易误判成时钟问题。

坑 4(JS 特有)<< 24 之后结果可能是负数,记得 >>> 0 转成无符号。

⑥ 取模与左补零

const otp = String(code % 10 ** digits).padStart(digits, '0');

左补零是必须的。 取模结果可能是 12345,六位码要显示成 012345。漏掉 padStart,大约每十次会出现一次五位数的码——用户看到会以为自己看错了,而你的日志里那一条也长得挺正常。

完整实现

import crypto from 'node:crypto';

const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

function base32Decode(s) {
  const clean = s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/, '');
  let bits = 0, value = 0;
  const out = [];
  for (const ch of clean) {
    const idx = B32.indexOf(ch);
    if (idx < 0) throw new Error('非法的 Base32 字符: ' + ch);
    value = (value << 5) | idx;
    bits += 5;
    if (bits >= 8) {
      out.push((value >>> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return Buffer.from(out);
}

function hotp(secretBytes, counter, digits = 6, algo = 'sha1') {
  const msg = Buffer.alloc(8);
  msg.writeBigUInt64BE(BigInt(counter));          // ③ 8 字节大端

  const digest = crypto.createHmac(algo, secretBytes).update(msg).digest();  // ④

  const offset = digest[digest.length - 1] & 0x0f;                           // ⑤
  const code =
    (((digest[offset] & 0x7f) << 24) |
     (digest[offset + 1] << 16) |
     (digest[offset + 2] << 8) |
      digest[offset + 3]) >>> 0;

  return String(code % 10 ** digits).padStart(digits, '0');                  // ⑥
}

function totp(secret, at = Date.now(), period = 30, digits = 6, algo = 'sha1') {
  const counter = Math.floor(at / 1000 / period);   // ②
  return hotp(base32Decode(secret), counter, digits, algo);   // ①
}

console.log(totp('JBSWY3DPEHPK3PXP'));

验证自己写对了

不要只和自己的另一份代码对。 两份代码很可能错得一样。

正确的验证方式是和一个已知的标准实现比对

  1. TOTP 调试台 填同一个密钥、同一个时间戳出码,逐个时间步对照
  2. 位数、周期、算法三个参数逐项确认一致
  3. 拿服务端日志里那个被拒的码去反查面板——落在正负一两个窗口说明算法是对的、只是时钟差,怎么找都找不到才说明密钥或参数不一致

这两种故障的处理方向完全相反,分不清会浪费很多时间。

服务端还要做的两件事

算法写对只是一半。

一、校验窗口开多宽。 通行做法是正负 1 个窗口(±30 秒)。只认当前窗口会让在边缘提交的用户失败,开到正负 2 个以上则把重放窗口拉到了分钟级。

二、记录已用过的码。 TOTP 在一个窗口内是恒定的,不做一次性校验,同一个码在窗口内可以重复使用。窗口开得越宽,这个风险越大。

相关

调试时请用随机生成的测试密钥,不要把生产环境的真实密钥填进任何在线工具。

❓ 常见问题

为什么截断时要和 0x7f 与一下?

为了把最高位清掉,避免被当成负数位置:动态截断取出 4 个字节拼成一个 31 位整数,第一个字节要先 & 0x7f原因:RFC 4226 明确说明这是为了规避不同语言对有符号整数的处理差异——Java 的 int 是有符号的,最高位是 1 时直接拼出来会是负数,取模的结果也跟着变号。漏掉的表现非常隐蔽:(1) 大约有一半的概率那个字节的最高位是 0,此时漏不漏都一样;(2) 所以自测经常能过,而且过很多次;(3) 到线上才出现「偶尔有一次码不对」,频率大概是一半——但因为服务端通常容忍前后窗口,排查时容易误判成时钟问题。在有无符号运算的语言里(JS 用 >>> 0、Go 用 uint32)也要照写,否则和标准实现对不上。

服务端的校验窗口该开多宽?

正负 1 个窗口是通行做法权衡:(1) 只认当前窗口 —— 用户在窗口边缘按下提交就会失败,30 秒周期下这个概率不低,体验很差;(2) 正负 1 个(即 ±30 秒)—— 覆盖绝大多数时钟漂移和输入延迟;(3) 正负 2 个以上 —— 把重放窗口拉到分钟级,除非用户群时钟普遍不准,否则不建议。必须配套做的一件事记录已使用过的码。TOTP 在一个窗口内是恒定的,不做一次性校验的话,同一个码在窗口内可以重复使用;窗口开得越宽,这个风险越大。实测自己的窗口有多宽:用 TOTP 调试台 把时钟前后拨几档出码,拿那些码去打自己的接口,能被接受的就是窗口范围。

SHA-256 的摘要是 32 字节,offset 还是取最后一字节吗?

是,规则不变:取摘要最后一个字节的低 4 位为什么:低 4 位的取值范围是 0 – 15,而截断要连取 4 个字节,所以 offset 最大 15 时要读到第 19 个字节——SHA-1 的摘要正好 20 字节,刚好够。换成更长的摘要(SHA-256 的 32 字节、SHA-512 的 64 字节)时,offset 仍然只在 0 – 15 之间,后面的字节用不上,这不是 bug 而是规范如此。实现要点:(1) 取 digest[digest.length - 1] & 0x0f,不要写死 digest[19];(2) 其余步骤完全一致。另外注意:SHA-256 / SHA-512 虽然在 RFC 6238 里是合法选项,但不少验证器 App 只支持 SHA-1,服务端选非默认算法之前要先确认用户侧能不能用。

浏览器里为什么不能直接用 crypto.subtle 做 HMAC?

能用,但只在安全上下文里限制crypto.subtle 只在安全上下文(HTTPS,或 localhost)下可用。踩坑场景:在局域网内用 http://192.168.x.x:端口 打开自己的页面自测,crypto.subtle 直接是 undefined,代码抛错,而在本机 localhost 上一切正常——很容易误判成手机浏览器不兼容。三条路:(1) 用纯 JS 的 HMAC 实现(如 jsSHA),不依赖 Web Crypto,任何上下文都能跑;(2) 局域网测试时配一个自签证书走 HTTPS;(3) 通过 SSH 端口转发到 localhost本站的 TOTP 调试台 走的是第一条——纯 JS 实现,全程本地计算,不发任何网络请求。

🔬 打开 TOTP 调试台 指定时间戳出码 · 反查验证码属于第几个时间窗 · 模拟时钟偏移 · HOTP 计数器 · 密钥 Base32/Hex/Base64 互转

🔗 相关阅读

全部教程 →