一、核心公式只有三步
TOTP(RFC 6238)建立在 HOTP(RFC 4226)之上:
counter = floor(当前 Unix 时间 / 30)
hmac = HMAC-SHA1(密钥, counter 的 8 字节大端表示)
offset = hmac[19] & 0x0f
code = (hmac[offset..offset+3] 取 31 位) % 10^6
拆开看:
- 计数器:把时间按 30 秒切片,得到一个整数。时间同步,双方算出的计数器就相同。
- HMAC:用共享密钥对这个计数器做 HMAC-SHA1,得到 20 字节。
- 动态截断:用最后一个字节的低 4 位当偏移量,取 4 个字节,去掉符号位,对 10⁶ 取模,左侧补零成 6 位。
所以同一个密钥、同一时间窗内,任何设备算出的都是同一串数字,不需要联网。
二、密钥长什么样
服务端给的通常是一串 Base32 字符(A-Z2-7),例如:
JBSWY3DPEHPK3PXP
Base32 而不是 Base64,是因为它只用大写字母和数字 2–7,不含易混淆的 0/O、1/I,方便手输,也便于二维码编码。扫码时二维码里其实是一个 otpauth:// URL:
otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=6&period=30
三、时钟偏移怎么办
服务器和手机时间不可能完全一致,所以校验时通常允许 ±1 个窗口(也就是接受上一个、当前、下一个 30 秒的码)。这意味着一个码在极端情况下有效时间可达 90 秒,安全性略降但可用性大幅提升。
如果用户手机时间偏差很大(比如手动改过时间),验证会一直失败。引导用户开启「自动设置时间」是最有效的解决办法。有些实现会记录上次成功的时间窗,防止同一窗口内重复使用同一个码(防重放)。
四、自己实现时最容易错的三处
- 计数器字节序:必须是 8 字节大端。写成小端,结果完全不同,而且不会有任何报错。
- SHA-1 而非 SHA-256:虽然 RFC 6238 支持 SHA-256/512,但绝大多数 App 默认 SHA-1,改了就对不上。要看二维码 URL 里的
algorithm参数。 - 截断后取模:是取 4 个字节拼成 31 位整数再
% 1000000,不是简单取前 6 位数字。
import hmac, hashlib, struct, time, base64
key = base64.b32decode('JBSWY3DPEHPK3PXP')
counter = int(time.time()) // 30
h = hmac.new(key, struct.pack('>Q', counter), hashlib.sha1).digest()
o = h[19] & 0x0f
code = (struct.unpack('>I', h[o:o+4])[0] & 0x7fffffff) % 1000000
print('%06d' % code)
五、安全边界
- 密钥泄露 = 全部失效:secret 等同于第二因素,必须加密存储,不能进日志。
- TOTP 不防钓鱼:用户把码输进钓鱼站,攻击者立刻用它登录。要防钓鱼得用 FIDO2/WebAuthn 硬件密钥。
- 一定要配限流:6 位码只有 100 万种可能,不限流可以被穷举。通常 5 次失败锁定一段时间。
- 恢复码:用户换手机就收不到码了,注册时必须给一次性恢复码。
六、什么时候用得上
给自建服务加二次验证、给 CI 里的自动化账号生成动态码、测试环境模拟 2FA 登录、排查「为什么我的验证码总是不对」。用 TOTP 动态码生成 输入 Base32 密钥即可看到当前码和剩余秒数,本地计算、密钥不上传。需要单独验证 HMAC 步骤时,用 HMAC 生成 对照中间结果。
小结
TOTP = 时间片当计数器 + HMAC-SHA1 + 动态截断取 6 位;密钥是 Base32;校验留 ±1 窗口;实现时注意大端、SHA-1、31 位截断这三点。