验证器 App 里每 30 秒变一次的 6 位数字,用的是 TOTP(Time-Based One-Time Password,基于时间的一次性密码),定义在 RFC 6238。阿里云把它叫作「虚拟 MFA」,很多银行和企业系统叫「动态口令」,Google Authenticator 中文名是「谷歌身份验证器」,背后都是同一个算法:服务端和 App 事先共享一个密钥,之后各自用「密钥 + 当前时间」算 HMAC,再截成几位数字。两边密钥相同、时钟大致一致,不需要通信也能得到同样的数字。

这篇文章用 RFC 里现成的测试数据把一个验证码完整算一遍,再看二维码里装了什么、阿里云 RAM 的虚拟 MFA 文档里那些规则对应算法的哪一步,以及服务端校验时必须做的几件事。文中的数字都可以用 TOTP 生成器或几行 Node.js 复现。

一个验证码是怎么算出来的

TOTP 建立在 RFC 4226 的 HOTP 之上:HOTP 用一个计数器算验证码,TOTP 把计数器换成「当前时间是第几个 30 秒」。RFC 6238 的测试密钥是 ASCII 字符串 12345678901234567890(20 字节),Base32 写作 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ。下面用测试表里的 Unix 时间 1111111109 秒(UTC 2005-03-18 01:58:29)来算。

第 1 步:时间步 T。 周期 X = 30 秒,起点 T0 = 0(RFC 6238 §4.2):

T = floor((1111111109 - 0) / 30) = 37037036 = 0x23523EC

T 按 8 字节大端整数参与计算:00 00 00 00 02 35 23 ec。

第 2 步:HMAC。 以密钥为 key、这 8 个字节为消息算 HMAC-SHA1,得到 20 字节:

278c02e53610f84c40bd9135acd4101012410a14

第 3 步:动态截取(Dynamic Truncation)。 最后一个字节是 14,取它的低 4 位,得到偏移量 4。从第 4 个字节(从 0 数起)开始取 4 个字节:36 10 f8 4c。把最高位清零,得到一个 31 位正整数:0x3610f84c = 907081804(RFC 4226 §5.3)。

第 4 步:取位数。 对 10 的位数次方取余,不足的位在前面补 0:

907081804 mod 10^8 = 07081804
907081804 mod 10^6 =   081804

07081804 就是 RFC 6238 附录 B 表中「1111111109 秒、SHA1」那一格。注意开头的 0:验证码是字符串,不是数字,存成整数的实现会把 081804 变成 81804,用户输入的 6 位数就永远对不上。

用 Node.js 自带的 crypto 就能把上面四步跑一遍:

import { createHmac } from 'node:crypto';

const key = Buffer.from('12345678901234567890');   // RFC 6238 测试密钥
const T = Math.floor(1111111109 / 30);              // 第 1 步
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(T));
const h = createHmac('sha1', key).update(msg).digest(); // 第 2 步
const offset = h[h.length - 1] & 0x0f;                  // 第 3 步
const bin = h.readUInt32BE(offset) & 0x7fffffff;
console.log(T, offset, bin);
console.log(String(bin % 1e8).padStart(8, '0'));        // 第 4 步
console.log(String(bin % 1e6).padStart(6, '0'));

在 TOTP 生成器里粘贴上面的 Base32 密钥,位数选 8,「Unix 时间(可选)」填 1111111109,当前验证码显示 07081804,上一个(T = 37037035)是 89731029,下一个(T = 37037037)是 14050471。

最后这个 14050471 恰好也是 RFC 表里 1111111111 秒那一行的值。1111111109 和 1111111111 只差 2 秒,但中间隔着 30 秒的边界 1111111110,所以落在相邻的两个时间步里,验证码完全不同。用户「刚看到就输入,还是错了」,很多时候就是跨过了这样一个边界。

测试向量与种子长度

RFC 6238 的测试表有 6 个时间点,每个时间点分别给出 SHA-1、SHA-256、SHA-512 的 8 位结果,共 18 个值。节选三行:

Unix 时间T(十六进制)SHA-1SHA-256SHA-512
590000000000000001942870824611924690693936
1234567890000000000273EF07890059249181942493441116
200000000000000000027BC86AA653531307773770647863826

自己写实现时最容易卡住的是 SHA-256 和 SHA-512 两列。表格上方的正文只提到 20 字节的种子,但附录 A 的 Java 参考代码给 SHA-256 用的是 32 字节种子 12345678901234567890123456789012,给 SHA-512 用的是 64 字节种子(同一串数字重复到 64 个字符)。拿 20 字节种子去算这两列,结果一个都对不上。它们的 Base32 写法是:

SHA-256:GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA====
SHA-512:GEZDGNBVGY3TQOJQ 重复 6 次,末尾接 GEZDGNA=

这也符合 RFC 6238 §5.1 的建议:密钥长度应当与 HMAC 输出长度相同。最后一行 20000000000 秒在 2603 年,用来检查实现有没有把时间压成 32 位整数(§4.2 要求 2038 年之后必须支持超过 32 位的时间值)。

密钥、Base32 与 otpauth:// 二维码

密钥本身是随机字节,展示给用户时用 Base32 编码(RFC 4648 §6,字母 A–Z 加数字 2–7)。Base32 里没有 0、1、8、9,手抄的密钥里出现这几个字符,就是抄错了。RFC 4226 §4 要求密钥至少 128 位,推荐 160 位,也就是 Base32 下 32 个字符。

扫码绑定时,二维码里是一条 Google Key Uri Format 格式的 URI。发行方或账户里有中文时,按 UTF-8 百分号编码:

otpauth://totp/%E7%A4%BA%E4%BE%8B%E5%85%AC%E5%8F%B8:zhangsan%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=%E7%A4%BA%E4%BE%8B%E5%85%AC%E5%8F%B8

这条 URI 由 TOTP 生成器生成,与 pyotp 2.10.0 的 provisioning_uri(name='[email protected]', issuer_name='示例公司') 输出逐字相同。几个要点:

  • 标签是「发行方:账户」,两部分都不能含 :;secret 是去掉 = 的 Base32。
  • algorithm(SHA1、SHA256、SHA512)、digits(6 或 8)、period(默认 30)可以省略,省略即取默认值。
  • 同一份文档写明,Google Authenticator 会忽略 algorithm 和 period,Android 版还忽略 digits。服务端如果用 SHA-256 或 60 秒周期,用户拿谷歌身份验证器扫码后算出的数字可能永远对不上。除非能指定用户使用的 App,就保持 SHA-1、6 位、30 秒。SHA-1 在这里不是弱点:已知的 SHA-1 碰撞攻击并不能攻破 HMAC-SHA1(RFC 6194 §3.3)。
  • JBSWY3DPEHPK3PXP 解码后只有 10 字节(80 位),作为文档示例没问题,正式使用达不到 RFC 4226 的下限。

阿里云虚拟 MFA 背后的规则

阿里云 RAM 的自行绑定 MFA 设备文档写明,虚拟 MFA「通过兼容 TOTP 协议的身份验证器 App 生成动态验证码」,可以用阿里云 App,也可以用 Google Authenticator,扫码添加或手动填写账号和密钥都行。它的 MFA 常见问题里有几条,对照算法就很好理解:

文档原文(节选)对应的算法细节
「MFA 是基于时间的,您需要检查并确保手机端的时间不存在延迟或超前」第 1 步的 T 由手机时钟决定,时钟差出一个 30 秒边界,T 就不同
「需要确保输入的是最新的、未被使用过的安全码」服务端拒绝重放:同一个时间步的验证码只认一次(RFC 6238 §5.2)
「当前绑定页面打开时间过长,二维码(密钥)已过期」每次打开绑定页都会生成新密钥,旧二维码里的密钥不再有效
「多次打开 MFA 绑定页面并扫码,在手机端 MFA 设备上会显示相同的用户名,但实际安全码是不同的」账户名相同,密钥不同;App 只按标签显示,分不出哪个是有效的那一条

第四条是很常见的坑:App 里有两条一模一样的「RAM 用户名」,用户输入了旧密钥那一条的数字。按文档的建议,扫码前先删掉 App 里同名的旧条目。

绑定文档还写明,阿里云从 2024 年 8 月 20 日开始陆续为所有 RAM 用户开启登录时强制 MFA;文档里的 MFA 方式对比表中,虚拟 MFA 的安全级别是「高」,通行密钥(Passkey)是「最高」,原因见本文最后一节。

服务端校验的四条规则

6 位数字能挡住攻击,靠的是服务端围绕它的规则:

  • 时间窗口要小。 服务端只知道验证码到达的时间,不知道它是什么时候生成的。RFC 6238 §5.2 建议同时比对传输延迟范围内的前几个时间步,并建议为网络延迟最多放宽 1 个时间步;§6 另外讨论了时钟漂移,建议记录每个令牌检测到的偏差。各个库的默认值不同:pyotp 2.10.0 的 verify() 默认 valid_window=0,只认当前时间步,要放宽得显式传 valid_window=1。
  • 防重放。 §5.2 的原话是验证方「MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP」。实现方法是为每个用户记下最后一次验证成功的 T,之后只接受比它新的时间步。阿里云文档里「未被使用过的安全码」说的就是这条。
  • 限制尝试次数。 随机猜一次,每个被接受的时间步命中概率约为百万分之一。RFC 4226 §7.3 要求做限流:失败若干次后锁定,或每次失败后加长等待。窗口放宽到 3 个时间步,每次猜中的概率也变成 3 倍,这也是窗口要小的原因。
  • 保护密钥。 拿到密钥就能算出以后所有的验证码。数据库里加密存储,日志里不要打印。

用代码实现与自测

开启两步验证的流程一般是:生成随机密钥 → 拼 otpauth:// URI → 显示二维码(同时给出 Base32 文本,方便无法扫码的用户手动添加)→ 让用户输入一次当前验证码确认 → 保存密钥。

Python 用 pyotp:

import pyotp  # pyotp 2.10.0

secret = pyotp.random_base32()                 # 32 个字符,160 位
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name='[email protected]', issuer_name='示例公司')
ok = totp.verify(user_code, valid_window=1)    # 当前 ±1 个时间步(默认 0)

# 上线前用 RFC 向量自测:固定时间算一次
assert pyotp.TOTP('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', digits=8).at(1111111109) == '07081804'

Node.js 用 otplib v13:

import { generateSecret, generateURI, verify } from 'otplib'; // otplib v13

const secret = generateSecret();
const uri = generateURI({ issuer: '示例公司', label: '[email protected]', secret });
const { valid } = await verify({ secret, token: userCode });

不管用哪个库,上线前都按 RFC 表自测:用能固定时间的接口(pyotp 是 at())、用 RFC 的三个种子,把 18 个值比对一遍。只用 SHA-1 的话,至少比对 6 个时间点。

时钟偏差

服务端和手机的时间要在几秒之内。手机一般自动同步,服务器要跑 NTP。阿里云 ECS 的配置 NTP 服务文档说明,基于公共镜像创建的实例默认运行 chrony(部分老旧镜像是 ntpd);ECS 内推荐用 VPC 内网的 ntp.cloud.aliyuncs.com,阿里云以外的机器可以用公网的 ntp.aliyun.com。用 chronyc tracking 看 System time 一行,就能知道本机比 NTP 时间快或慢多少。

用户反馈验证码总是错时,先看手机时间。TOTP 生成器会用页面服务器返回的 Date 头和本机时钟比对一次,去掉测量误差后相差超过约 3 秒就提示;它同时显示上一个和下一个验证码,能看出用户是不是正好差了一个时间步。

TOTP 的局限

TOTP 防不住实时钓鱼:假的登录页让用户输入验证码,再在同一个 30 秒内转发给真网站,就能登录。通行密钥和安全密钥用的 WebAuthn 凭据绑定在网站的源上,仿冒域名用不了,这就是阿里云把通行密钥列为「最高」的原因。尽管如此,TOTP 比只用密码强得多,而且离线可用,不依赖短信和推送。

开启 TOTP 时要同时考虑丢手机怎么办。阿里云的做法是:主账号走「提交申诉」解绑 MFA,RAM 用户由主账号或 RAM 管理员解绑,文档还建议每个 RAM 用户至少绑定两种不同类型的 MFA。自己做系统时,常见做法是开启时发放 8–10 个一次性恢复码,哈希后存储(bcrypt 或 Argon2),只展示一次,用过即作废。

要拿已知值检查密钥或服务端实现,用 TOTP 生成器;只想单独看 HMAC 那一步,用 HMAC 在线计算。