极简工具箱

免费在线工具 · 本地计算 · 无需上传

两步验证的 6 位动态码是怎么算出来的(TOTP 原理)

每隔 30 秒变一次的 6 位数字,断网也能算出来——因为它不是「服务器发给你」,而是双方用同一个密钥和当前时间各算一遍。

广告位(百度联盟)

一、核心公式只有三步

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

拆开看:

  1. 计数器:把时间按 30 秒切片,得到一个整数。时间同步,双方算出的计数器就相同。
  2. HMAC:用共享密钥对这个计数器做 HMAC-SHA1,得到 20 字节。
  3. 动态截断:用最后一个字节的低 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 秒,安全性略降但可用性大幅提升。

如果用户手机时间偏差很大(比如手动改过时间),验证会一直失败。引导用户开启「自动设置时间」是最有效的解决办法。有些实现会记录上次成功的时间窗,防止同一窗口内重复使用同一个码(防重放)。

四、自己实现时最容易错的三处

  1. 计数器字节序:必须是 8 字节大端。写成小端,结果完全不同,而且不会有任何报错。
  2. SHA-1 而非 SHA-256:虽然 RFC 6238 支持 SHA-256/512,但绝大多数 App 默认 SHA-1,改了就对不上。要看二维码 URL 里的 algorithm 参数。
  3. 截断后取模:是取 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)

五、安全边界

六、什么时候用得上

给自建服务加二次验证、给 CI 里的自动化账号生成动态码、测试环境模拟 2FA 登录、排查「为什么我的验证码总是不对」。用 TOTP 动态码生成 输入 Base32 密钥即可看到当前码和剩余秒数,本地计算、密钥不上传。需要单独验证 HMAC 步骤时,用 HMAC 生成 对照中间结果。

小结

TOTP = 时间片当计数器 + HMAC-SHA1 + 动态截断取 6 位;密钥是 Base32;校验留 ±1 窗口;实现时注意大端、SHA-1、31 位截断这三点。

本文相关工具(本地计算,无需上传)

更多使用教程