「验证码对不上」的排查之所以难,是因为 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-Z 加 2-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'));
验证自己写对了
不要只和自己的另一份代码对。 两份代码很可能错得一样。
正确的验证方式是和一个已知的标准实现比对:
- 用 TOTP 调试台 填同一个密钥、同一个时间戳出码,逐个时间步对照
- 位数、周期、算法三个参数逐项确认一致
- 拿服务端日志里那个被拒的码去反查面板——落在正负一两个窗口说明算法是对的、只是时钟差,怎么找都找不到才说明密钥或参数不一致
这两种故障的处理方向完全相反,分不清会浪费很多时间。
服务端还要做的两件事
算法写对只是一半。
一、校验窗口开多宽。 通行做法是正负 1 个窗口(±30 秒)。只认当前窗口会让在边缘提交的用户失败,开到正负 2 个以上则把重放窗口拉到了分钟级。
二、记录已用过的码。 TOTP 在一个窗口内是恒定的,不做一次性校验,同一个码在窗口内可以重复使用。窗口开得越宽,这个风险越大。
相关
- 验证码对不上的完整排查顺序 → TOTP 验证码不匹配怎么查
- otpauth:// 链接里那七个参数 → otpauth:// 二维码里存了什么
- 一张二维码里打包多个账号的那层结构 → otpauth-migration:// 的结构
- 服务器时间不准会连带影响什么 → 时间漂移与 HTTPS、Kerberos 失败
调试时请用随机生成的测试密钥,不要把生产环境的真实密钥填进任何在线工具。