开启两步验证的时候,网站会给我们一张二维码,用 Google 身份验证器(Google Authenticator)扫一下,App 里就多出一行六位数,每 30 秒换一个。登录时把它填进去,网站就放行。

用久了难免会好奇:这个六位数到底是个什么东西?手机切到飞行模式,它照样在变,服务器又是怎么认出它的?扫码时存进手机的那串东西,是不是一张证书?整套做法和银行的 U盾 是不是差不多?

本文就从这几个疑问出发,先弄清六位数是怎么算出来的,再回头看它和证书、和 U盾 到底是不是一回事。文末附一段 30 行左右的 Python,可以自己算一遍。


一、一次性密码的由来

现象:密码一旦泄露,就能被无限次使用

普通密码的问题在于它是静态的。只要被人看到一次,无论是在网络上被窃听、被键盘记录器记下,还是从别的网站的泄露库里撞出来,对方就可以在任何时候、任何地点反复使用它。服务器分不清这次输入是我们本人,还是一份录下来的副本。

所以很早就有人想:能不能让每次登录用的密码都不一样,用过一次就作废? 这样即使被看到,也只是一张过期的票。

第一次尝试:Lamport 的哈希链

1981 年,Leslie Lamport 提出了一个很漂亮的办法1:拿一个秘密 ss 反复做哈希,得到一条链 H(s),H(H(s)),…,Hn(s)H(s), H(H(s)), \dots, H^{n}(s)。服务器只保存链的最末端 Hn(s)H^{n}(s)。第一次登录时,用户出示 Hn−1(s)H^{n-1}(s),服务器把它再哈希一次,看是否等于自己存的值;对上了就放行,并把存的值换成 Hn−1(s)H^{n-1}(s)。下一次用户出示 Hn−2(s)H^{n-2}(s),依此类推。

这个方案的好处是服务器只存一个“往后推”的值,就算服务器被攻破,攻击者也算不出下一个密码,因为哈希不能倒推。贝尔实验室后来把它做成了 S/KEY,并在 1995 年写成 RFC 1760,再演化成 RFC 2289 的 OTP 标准2。

它的不足在于链是有限的:用完 nn 次就得重新初始化。而且每次要算出第 n−kn-k 个值,要么事先打印一张密码表随身带着,要么在终端上输入口令现场计算,都谈不上方便。

第二次尝试:硬件令牌

另一条路是 RSA 的 SecurID,从 1980 年代后期开始卖。它是一个钥匙扣大小的硬件,屏幕上的数字每 60 秒变一次,看上去已经和今天的 App 很像了。

它的不足是封闭:算法是专有的,令牌里的种子(seed)由厂商生成,厂商自己也保留一份。企业要买硬件、买授权,还得信任厂商永远不出事。2011 年 3 月,RSA 公司被入侵,与 SecurID 种子相关的数据被窃取,随后美国军工企业洛克希德·马丁遭到了利用这批数据的攻击,RSA 不得不为客户更换大批令牌3。“种子在别人手里”从理论风险变成了真实事故。

解决:一个开放的标准

到了 2004 年,VeriSign 等公司发起了 OATH(Initiative for Open Authentication),目标是做一个任何人都能免费实现的一次性密码算法:不靠专有硬件,不靠厂商保管种子,用户和服务器之间直接共享一个密钥就行。它的第一个成果就是 2005 年发布的 HOTP。

下面这张图把这几代方案和它们各自留下的问题串在一起,后面的每一节,基本上都是在回答图右边那一列。

一次性密码的几代方案和它们留下的问题 1981 年 Lamport 哈希链,链会用完;1980 年代后期 RSA SecurID,算法和种子在厂商手里;2005 年 HOTP,计数器会失步;2011 年 TOTP 和 Google 两步验证,换机会丢码、码可以被钓鱼转发;2014 年 FIDO U2F 安全密钥,需要额外硬件;2022 到 2023 年 passkey 成为 Google 默认登录方式。 一次性密码的几代 · 每一代留下的问题 1981 Lamport 哈希链 → S/KEY 链会用完,需要重新初始化 1980s RSA SecurID 硬件令牌 算法专有,种子握在厂商手里 2005 HOTP · RFC 4226(OATH) 计数器会失步 2011 TOTP · RFC 6238,Google 两步验证 换机丢码;码可以被钓鱼站转发 2014 FIDO U2F 安全密钥 需要额外的硬件 2023 Passkey 成为 Google 默认登录 同步的公钥凭据,见第七节
图 1 · 每一代一次性密码都在补上一代的缺口,Google 身份验证器用的是中间那一段:HOTP 和 TOTP

那么 HOTP 是怎么做到“两边不通信,却算出同一个数”的?

二、HOTP:从密钥和计数器到六位数字

分析:两边共享什么

HOTP 的思路很直接。用户的令牌和服务器在一开始共享两样东西:一个密钥 KK(通常 20 字节随机数),和一个计数器 CC(初始为 0)。每次需要密码时,就用 KK 对 CC 算一个 HMAC-SHA1:

h=HMAC-SHA1(K,C)h = \text{HMAC-SHA1}(K, C) HOTP(K,C)=Truncate(h) mod 106\text{HOTP}(K, C) = \text{Truncate}(h) \bmod 10^{6}

其中 CC 编码成 8 字节大端整数。算完一次,两边的计数器各加一。

为什么用 HMAC?因为它有两个性质正好对口:没有 KK 的人算不出下一个值;而看到再多个历史码,也推不回 KK。这正是一次性密码需要的。顺带一提,HMAC 的安全性并不依赖 SHA-1 的抗碰撞性,所以 SHA-1 在 2017 年被找到碰撞,并不影响 HOTP4。

现象:20 个字节,怎么变成 6 位数?

HMAC-SHA1 的输出是 20 字节、160 位。人没法输入 160 位,只能输入几位十进制数字。那该从这 20 个字节里取哪一部分?

最直觉的办法是取前 4 个字节,转成整数再取后 6 位。这样做没错,但 RFC 4226 选了一个更巧的办法,叫动态截断(dynamic truncation):让 HMAC 的最后一个字节来决定从哪里取。具体是:

  1. 取最后一个字节的低 4 位,得到一个 0 到 15 的偏移量 offset;
  2. 从第 offset 个字节开始连取 4 个字节,拼成一个 32 位整数;
  3. 把最高位清零,只留 31 位;
  4. 对 10610^6 取余,不足 6 位前面补 0。

下图用 RFC 的测试密钥 12345678901234567890(20 个 ASCII 字符)和计数器 C=1C=1 走一遍,每一个字节都是真实算出来的。

HOTP 的动态截断 HMAC-SHA1(K, C=1) 的 20 个字节是 75 a4 8a 19 d4 cb e1 00 64 4e 8a c1 39 7e ea 74 7a 2d 33 ab。最后一个字节 ab 的低 4 位是 b,即偏移 11;取第 11 到 14 字节 c1 39 7e ea,最高位清零得到 0x41397eea,即 1094287082,对一百万取余得到 287082。 HMAC-SHA1(K, C = 1) 的 20 个字节 75 a4 8a 19 d4 cb e1 00 64 4e 8a c1 39 7e ea 74 7a 2d 33 ab 0 11 14 19 从第 11 字节起取 4 个 低 4 位 b = 11 动态截断 RFC 4226 §5.3 1 · 最后一字节 & 0x0f 0xab & 0x0f = 11 2 · 取第 11–14 字节 0xc1397eea 3 · & 0x7fffffff,清掉最高位 0x41397eea = 1094287082 4 · mod 10^6 287082 取哪 4 个字节由 HMAC 自己决定,每个计数器值取的位置都不一样。
图 2 · 密钥 12345678901234567890、计数器 1:HMAC 的最后半个字节指定从哪里取,最终得到 287082

为什么要清掉最高位? 不同语言对 32 位整数的处理不一样,有的当有符号数,有的当无符号数。把最高位清零以后,结果在任何实现里都是同一个非负整数,避免了 Java 和 C 算出不一样的码。

六位数够安全吗? 一次猜中的概率是百万分之一。单看这个数字不算大,所以 RFC 4226 明确要求服务器做限速:连续输错几次就延迟或锁定5。六位数的安全性,有一半是靠服务器的限速来保证的。

新问题:计数器失步

HOTP 的令牌每按一次按钮,计数器就加一。可是用户按了按钮却没去登录,或者小孩拿着令牌按着玩,令牌的计数器就会跑到服务器前面去。

RFC 的办法是让服务器往前多试几个:不只比较 CC,而是依次比较 C,C+1,…,C+sC, C+1, \dots, C+s,在这个“前瞻窗口”里对上了,就把服务器的计数器直接推进到那个位置。超出窗口,就得让用户连续输入两个码重新同步。

这能用,但不优雅:两边都得保存并维护一个会变化的状态。能不能找一个两边不用商量、天然就一致的计数器?

三、TOTP:用时间代替计数器

解决:时间本身就是计数器

答案是时间。手机和服务器各自都有时钟,只要都对准了世界时间,就不需要再同步任何东西。TOTP 把 Unix 时间(从 1970 年 1 月 1 日起的秒数)按 30 秒一段切开,用段号代替 HOTP 的计数器:

T=⌊tunix30⌋T = \left\lfloor \frac{t_{\text{unix}}}{30} \right\rfloor TOTP(K)=HOTP(K,T)\text{TOTP}(K) = \text{HOTP}(K, T)

算法的其余部分和 HOTP 一模一样。一个有趣的验证是:t=59t = 59 秒时,T=1T = 1,所以 TOTP 在第 59 秒的 8 位码 94287082,正好就是图 2 里 C=1C = 1 那个 31 位整数 1094287082 的后 8 位。RFC 6238 附录里的第一个测试向量就是它6。

这也回答了开头的问题:飞行模式下照样出码,是因为算码只需要两样东西,一个扫码时就存进手机的密钥,一个手机本来就有的时钟。 服务器那边拿着同一个密钥、看着同一个时间,自然算出同一个数。

为什么是 30 秒

时间步越短,被偷看到的码越快作废,但用户可能还没输完就过期了;越长则反过来。RFC 6238 推荐 30 秒,原文的说法就是“在安全性和可用性之间取一个平衡”6。

现象:手机时钟慢了几秒

时间当计数器,就带来了新的问题:两边的时钟不可能分毫不差,网络传输也要时间。用户在一个时间步的最后一秒看到码,等它提交到服务器,服务器那边已经是下一步了。

RFC 6238 的建议是,服务器除了当前步 TT,最多再往回多查一步 T−1T-1,用来容忍网络延迟;也可以记录每个客户端的时钟偏差,下次自动校正6。下图是一个具体的例子。

TOTP 的时间步和验证窗口 以测试密钥为例,时间步 41152262、41152263、41152264 分别覆盖 Unix 时间 1234567860 起、1234567890 起和 1234567920 起的 30 秒,对应的六位码是 980357、005924、590587。手机时钟慢 5 秒,在 1234567885 算出 980357;服务器此刻是 1234567890,当前步是 41152263,它同时检查上一步,所以仍然接受 980357。 T = floor(t / 30) · 密钥 12345678901234567890 手机 1234567885 服务器 1234567890 T − 1 = 41152262 t: 1234567860 … 889 码 980357 T = 41152263(当前) t: 1234567890 … 919 码 005924 T + 1 = 41152264 t: 1234567920 … 949 码 590587 服务器检查 T − 1 和 T,980357 落在窗口里 T + 1:有的服务也接受 手机慢了 5 秒,算出的是上一步的码;服务器往回多查一步,就对上了。
图 3 · Unix 时间 1234567890(2009-02-13 23:31:30 UTC)前后的三个时间步:验证窗口吸收了几秒的时钟偏差

窗口也带来一个副作用:同一个码在 30 秒甚至 60 秒内都有效。如果有人在我们身后看到了码,马上抢先输入,服务器是分不出来的。所以 RFC 6238 要求服务器记住每个用户最近一次成功使用的时间步,同一步的码只认一次6。

到这里,算法本身已经讲完了。它是怎么从一份 RFC,变成几乎每个网站都支持的东西的?

四、Google 的推广:两步验证与二维码

经过:从企业版到所有用户

2010 年 9 月,Google 先给付费版、政府版和教育版的 Google Apps 推出了两步验证;2011 年 2 月,开放给所有 Google 账号,由用户自愿开启7。第二步的验证码有两种拿法:用短信或语音接收,或者装一个 Google 身份验证器 App 自己算。这个 App 用的就是 TOTP。

为什么是 TOTP,而不是自己发明一套,或者像银行那样发硬件令牌?Google 没有专门解释过,以下是根据当时的情况做的推断:TOTP 是开放标准,不用付授权费;智能手机已经普及,而且手机时钟由运营商或 NTP 校准,正好满足 TOTP 对时钟的要求;离线可用,出国没有信号也不影响;而且任何网站都能照着 RFC 实现,不需要和 Google 签任何协议。

Google 还把 App 连同一个 Linux PAM 模块一起开源了。PAM 模块至今还在维护,装上以后,SSH 登录服务器也能要求输入身份验证器上的六位数8。

那张二维码里装了什么

扫码那一步,其实就是把共享密钥 KK 交给手机。二维码里只编码了一行文本,格式是 Google 定义的 Key URI9:

otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
  • totp 表示基于时间(换成 hotp 就是计数器模式,此时还要带 counter 参数);
  • Example:alice@google.com 是显示在 App 里的名字;
  • secret 就是密钥 KK,用 Base32 编码;
  • 另外还可以带 algorithm、digits、period,默认值分别是 SHA1、6、30。

为什么用 Base32,而不是更常见的 Base64? 因为二维码扫不了的时候,用户要能手工输入密钥。Base32 只用 A–Z 和 2–7,不分大小写,也没有 0/O、1/l 这种容易看错的字符。

这个格式的影响比算法本身还大。Google 身份验证器在相当长的时间里会忽略 algorithm、digits、period 的非默认值,于是网站为了兼容,几乎都用 SHA1、6 位、30 秒。RFC 6238 里写的 SHA-256、SHA-512 很少有人用,“SHA1 + 6 位 + 30 秒”就这样成了事实标准。GitHub、AWS、各大交易所,以及 Authy、1Password、微软身份验证器这些后来的 App,都按这个格式生成和读取二维码。

App 本身后来走了另一条路:Android 版在 2013 年前后转为闭源,开源社区随后做了 FreeOTP 等替代品10。但由于格式是公开的,换 App 不影响任何网站。

TOTP 普及以后,它的短板也一个个暴露出来。最先被用户感受到的,是换手机。

五、共享密钥的代价

现象:换了手机,码全没了

TOTP 是对称的:手机和服务器手里是同一个密钥 KK。早期的 Google 身份验证器把密钥只存在手机本地,不备份。手机丢了、摔坏了、或者换新手机前忘了迁移,所有账号的第二步就都进不去了,只能靠开启时保存的恢复码,或者走各个网站的人工申诉。

尝试:导出与云同步

Google 先在 2020 年加了“转移账号”功能,在旧手机上生成二维码,用新手机扫。这个二维码的内容是 otpauth-migration://offline?data=...,里面是一串没有加密的 protobuf,装着所有账号的密钥。

2023 年 4 月,App 又加入了随 Google 账号云同步。这解决了丢码的问题,却引出了一个新问题:安全研究者 Mysk 抓包发现,同步的数据不是端到端加密的,Google 的服务器能读到所有密钥。换句话说,一旦 Google 账号本身被盗,攻击者就能拿到这个账号保护的所有其他账号的第二因素。Google 随后表示会加入端到端加密11。

这件事背后是 TOTP 的一个根本性质:密钥必须同时存在于两端,所以无论放在哪里,都多了一个可能泄露的地方。 服务器那边也一样,网站的数据库要是泄露了 TOTP 密钥,攻击者就能为所有用户生成正确的码,所以密钥必须加密存放,不能像密码那样只存一个哈希。

两端共享同一个秘密,这是对称密码的天性。说到这里,常有人问:扫码导入的这串密钥,有的 App 和教程管它叫“令牌证书”;网银的 U盾 也叫“数字证书”;浏览器地址栏的小锁背后,同样是一张“证书”。它们是一回事吗?

六、数字证书:另一种信任方式

分析:对称和非对称

不是一回事。二维码里装的不是证书,而是前面一直在说的共享密钥 KK。两者的区别,在于双方手里各有什么:

  • TOTP 是对称的。 手机和服务器拿着同一串字节,能验证码的一方,也一定能生成码。所以 KK 必须对所有其他人保密。
  • 证书背后是非对称密码。 一对密钥里,私钥只在持有者手里,公钥可以公开给任何人。私钥用来签名,公钥用来验证;能验证签名的人,造不出签名。
  • 证书本身,就是把一把公钥、“这把公钥属于谁”和一个有效期写在一起,再由一个上级机构(CA,证书颁发机构)用它自己的私钥签名作保。

拿 Google 登录页自己的证书来看。在证书透明日志 crt.sh 上可以查到,accounts.google.com 的一张证书由 Google Trust Services 的中间证书 WR2 签发,有效期从 2026-09-02 到 2026-11-25,只有 84 天12。WR2 又由根证书 GTS Root R1 签发,而这张根证书预装在操作系统和浏览器里。浏览器顺着这条链一级一级验证签名,一直验到自己信任的根,就能确认一件事:对面这台服务器,握有 accounts.google.com 这把公钥对应的私钥。

下图把两种方式并排放在一起,标出了每一边手里的秘密。

TOTP 共享密钥与数字证书的对照 左边是 TOTP:手机和服务器各存一份相同的 secret GEZDGNBV…QOJQ,扫码时整份复制一次,任一边泄露都能算出码。右边是 accounts.google.com 的证书:Subject 为 accounts.google.com,Issuer 为 Google Trust Services WR2,有效期 2026-09-02 到 2026-11-25,公钥公开,签名由 WR2 的私钥做出;对应的私钥只在 Google 服务器上;浏览器用内置的根证书 GTS Root R1 验证这条链。 TOTP · 对称:两边是同一个秘密 证书 · 非对称:私钥不出门 手机 · 身份验证器 secret GEZDGNBV…QOJQ 算法 HMAC-SHA1 · 6 位 · 30 秒 扫码时整份复制一次 服务器 · 账号数据库 secret GEZDGNBV…QOJQ last_T 41152263 两边是同一串字节: 任何一边泄露,别人都能算出码。 证书 公开 Subject accounts.google.com Issuer Google Trust Services WR2 有效期 2026-09-02 → 2026-11-25 公钥 随证书发给每一个访客 签名 由 WR2 的私钥做出 私钥 · 只在 Google 的服务器上 浏览器内置根证书 · GTS Root R1 证书可以公开,拿到它也冒充不了网站。
图 4 · TOTP 的秘密两边各存一份;证书的秘密只有一份,公开出去的是能验证它的那一半

所以二维码里装的不是证书。 它没有公钥,不需要 CA,也绝不能公开。U盾 才是真正的证书:里面存着用户自己的私钥,以及由 CFCA 等机构签发的证书,转账时用私钥对交易内容签名。银行同时代发的另一种小硬件“动态令牌”,一按就显示六位数,那才是一次性密码,和 Google 身份验证器是同一类东西。

不过,把两者放在一起,也引出了一个更尖锐的问题。

七、HOTP 为什么不用非对称密码

问题:平台也握着密钥

回头看图 4 左边,会发现一个让人不安的事实:服务器手里也有一份 KK。网站数据库一旦被攻破,攻击者就能为每个用户算出正确的码,第二因素等于没有。RFC 4226 自己也承认这一点,专门要求服务器把共享密钥放在隔离的安全区域,用防篡改硬件加密保存,只在验证的那一刻解密5。但这只是把 KK 藏得更深,KK 仍然在平台上。

照着证书的思路,似乎有一个更好的设计:私钥只留在用户的令牌里,平台只存公钥,公钥泄露了也无所谓。HOTP 为什么不这么做?

第一次尝试:把 HMAC 换成签名

设想令牌不再算 HMAC,而是用私钥对计数器签名,服务器用公钥验证。问题马上出现:签名太长了。用 Ed25519 这种目前最短的常用签名算法之一,对计数器 1 签名,得到 64 字节;写成十进制有 154 位,写成 Base64 也要 88 个字符,没有人能把它抄进登录框。

那么像 HOTP 一样,截断成 6 位行不行?不行,这正是关键所在。

  • HMAC 能截断,是因为验证就是重算。 服务器有 KK,自己能算出完整的 20 字节,按同样的规则截出 6 位,比较一下就行。
  • 签名不能截断,是因为验证不是重算。 只有公钥的一方算不出签名,算得出就等于能伪造了。它只能拿到完整的签名,用公钥去检查一个等式是否成立。少了任何一个字节,这个等式都没法检查。
HMAC 可以截断成 6 位,签名不能 左边:令牌算 HMAC-SHA1(K, 1) 得到 20 字节 75a48a19…7a2d33ab,截断为 287082,用户抄写 6 位;服务器用同一个 K 重算出同样的 20 字节和 287082,比较相等。右边:令牌用 Ed25519 私钥对计数器 1 签名,得到 64 字节 4d835fad 83ee9486 b7a2cb9a…,十进制 154 位、Base64 88 个字符;如果截成 6 位,服务器只有公钥 1157567e…799d8e,算不出签名,也就无法比较,验证做不成。 对称 · 验证就是重算 非对称 · 验证要完整签名 令牌 · HMAC-SHA1(K, 1) 20 字节 75a48a19…7a2d33ab 截断 287082 人抄写 6 位 服务器 · HMAC-SHA1(K, 1) 重算 75a48a19…7a2d33ab → 287082 比较 287082 = 287082,通过 服务器有同一个 K, 能自己算出完整结果,只比较 6 位就够了。 令牌 · Ed25519 Sign(sk, 1) 签名 4d835fad 83ee9486 b7a2cb9a … 长度 64 字节 · 十进制 154 位 Base64 88 个字符 截成 6 位? 服务器 · Verify(pk, 1, sig) 公钥 1157567e…ed8799d8e 结果 只收到 6 位数字,无法验证 虚线框:截断以后,这一步做不成。 公钥算不出签名,只能检查完整的签名。
图 5 · 同样是计数器 1:HMAC 的结果服务器能自己重算,截成 6 位照样能比;签名只能整段检查,截断就没法验证

所以,“人看一眼、手抄六位数”这个通道,本身就把非对称排除掉了。 只要中间是人,验证的一方就必须能自己重算出这几位数,也就必须拥有和令牌一样的秘密。

原因:当年的设计约束

RFC 4226 开头列出了六条设计要求5,几乎每一条都指向同一个方向:

  • 算法应该能便宜地做进硬件,尽量少占电池、按键、处理能力和屏幕(R2);
  • 令牌可以没有任何输入键(R3),所以服务器没法先发一个挑战给它;
  • 显示的值必须容易读、容易输入,至少 6 位,最好全是数字(R4);
  • 使用一个足够强的共享密钥,至少 128 位(R6)。

R6 直接写着 shared secret,可见对称是设计的起点,而不是疏忽。当年的目标设备是钥匙扣令牌、智能卡和 SIM 卡。这类芯片算一次 HMAC-SHA1 几乎不费电,做一次公钥签名则慢得多,也贵得多。这一点是推断,RFC 没有给出具体数字。

曾经做到过的“平台不持有秘密”

有意思的是,第一节的 Lamport 哈希链其实做到了这个性质:服务器只存链的末端 Hn(s)H^{n}(s),数据库泄露了,攻击者也算不出下一个密码,因为哈希不能倒推。它靠的不是公钥,而是单向函数;验证依然是“重算一次哈希再比较”,所以能让人输入。代价是链会用完,而且每个值有 64 位,S/KEY 只能把它编成 6 个英文短词让人敲进去。HOTP 用这个性质,换来了 6 位数字和不会用完的计数器。

解决:去掉中间的人

所以,要让平台不再持有秘密,前提是把“人抄写”这一环拿掉,让用户的设备和浏览器直接通信。USB、NFC、蓝牙,或者手机自带的安全芯片,都能把服务器给的随机挑战原样送进去,再把 64 字节的签名原样送回来。

这就是 2014 年 FIDO U2F 做的事,后来的 WebAuthn 和 passkey 也是一样:每个网站一对密钥,私钥只在用户设备里,平台只存公钥,数据库泄露了也算不出任何东西。它正是上面设想的那种架构。它没有出现在 HOTP 里,是因为 2005 年的令牌和服务器之间,只有一个靠人抄写的六位数通道。

换成签名以后,还多出了一个 HOTP 做不到的好处,这要从钓鱼说起。

八、钓鱼:证书和六位数之间缺的一环

现象:钓鱼站也有合法证书

地址栏有证书,手里有六位数,为什么还会被钓鱼?

钓鱼网站会做一个和 Google 登录页一模一样的页面,地址栏同样有一把小锁。申请证书只需要证明自己控制着这个域名,Let’s Encrypt 这样的机构可以免费、自动地签发。在 crt.sh 上搜索 accounts.google.com,就能看到 Let’s Encrypt 签给某个以 www.accounts.google.com------- 开头、实际挂在别人域名下的证书12。用户输入密码,再输入身份验证器上的六位数;钓鱼站在后台把这两样东西原样转交给真正的 Google,在 30 秒内完成登录。今天这类“实时中继”已经有现成的工具包。

原因:两者各管一半,中间断了一根线

证书证明的是“对面是证书上写的那个域名”,它不知道这是不是用户想去的网站。TOTP 证明的是“输入的人手里有密钥”,码里不包含任何域名信息:用户在 g00gle-login.example 上看到的码,和在 accounts.google.com 上看到的一模一样。

两者之间缺的那根线,是“我把码交给了哪个域名”。在 TOTP 里,这根线只能靠用户自己核对地址栏,而这恰恰是人最容易出错的地方。

解决:把证书验证过的域名签进去

上一节说的 U2F / WebAuthn(Google 和 Yubico 是 U2F 的主要推动者)把这根线也接上了。设备和浏览器直接通信以后,签名的内容不再受六位数的限制,可以多装一样东西:

  • 登录时服务器发一个随机挑战,设备对它签名。关键在于,被签名的数据里有浏览器自己填写的当前网址(origin),也就是浏览器刚刚通过证书验证确认过的那个域名,网页的脚本改不了它。
  • 浏览器还只会把某个网站的凭据交给那个网站:accounts.google.com 的凭据,在 g00gle-login.example 上根本不会被调出来。
同一次钓鱼,对 TOTP 码和 passkey 签名的结果 上半部分:钓鱼站 g00gle-login.example 持有签给自己域名的合法证书,用户在那里输入六位码 287082,钓鱼站原样转发给 accounts.google.com,码有效,攻击者登录成功。下半部分:使用 passkey 时,浏览器签名的 clientDataJSON 里 origin 是 https://g00gle-login.example,真站发现 origin 不符而拒绝;实际上浏览器根本不会在钓鱼站调出 google.com 的凭据。 TOTP:码里没有域名 用户 · 身份验证器 钓鱼站 g00gle-login.example accounts.google.com 输入 287082 原样转发 有小锁:证书签给它自己的域名 码有效,攻击者登录 Passkey / WebAuthn:浏览器把域名签进去 用户 · 浏览器 钓鱼站 g00gle-login.example accounts.google.com 签名 转发签名 clientDataJSON · 被签名的内容 由浏览器填写 challenge 服务器给的随机数 origin https://g00gle-login.example origin 不符,拒绝 而且浏览器根本不会 在这里调出 google.com 的凭据。
图 6 · 钓鱼站也有合法证书:TOTP 码原样转发就能用,passkey 的签名里却带着证书验证过的钓鱼站域名

到这里,证书和用户凭据终于在同一个签名里会合了:证书负责让浏览器确认“对面是哪个域名”,用户的私钥负责证明“我是我”,而签名把这两件事绑在一起,钓鱼站拿走签名也用不了。

有意思的是,passkey 用了公私钥,却不需要 CA 给用户签发证书。注册时网站直接记下这把公钥,信任在第一次注册时建立,有点像 SSH 第一次连接时记下服务器指纹。WebAuthn 里倒是有一种证书,叫认证证书(attestation certificate),由安全密钥的厂商签发,用来证明“这是某个型号的真硬件”。为了不让它变成追踪用户的标识,FIDO 要求同一张认证证书由一大批设备共用(通常至少 10 万个);同步到云端的 passkey 一般干脆不提供它13。

效果是很实在的。Google 从 2017 年初开始要求全体员工使用物理安全密钥,据 Google 对外的说法,此后八万五千多名员工中没有一个账号被钓鱼接管14。

passkey 是这条路的延续:把 FIDO 凭据存在手机或密码管理器里,并且可以跨设备同步,不再需要单独买一个 U 盘大小的硬件。Google 在 2023 年 5 月支持用 passkey 登录 Google 账号,同年 10 月把它设为默认选项15。

TOTP 还会存在下去吗

会存在很长时间。WebAuthn 需要浏览器和操作系统配合,网站要接入的东西也更多;而 TOTP 只需要服务器端几十行代码,不依赖任何平台,甚至可以在命令行和 SSH 里用。对大多数网站来说,它仍然是成本最低的第二因素。只是我们应该清楚它能防什么:它能防住密码被盗后的远程登录,防不住实时钓鱼。

九、动手:自己算一遍

下面这段 Python 只用了标准库,hotp 是图 2 的四步,totp 是在它外面套上图 3 的时间步。运行它会依次打印 RFC 4226 附录 D 的 10 个测试码、RFC 6238 附录 B 的 SHA1 测试码,以及当前时刻的 TOTP:

Python
import base64, hashlib, hmac, struct, time
def hotp(key: bytes, counter: int, digits: int = 6, algo=hashlib.sha1) -> str:
mac = hmac.new(key, struct.pack(">Q", counter), algo).digest()
offset = mac[-1] & 0x0F # 动态截断:最后 4 位当下标
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % 10 ** digits).zfill(digits)
def totp(secret_b32: str, t: float | None = None, step: int = 30, digits: int = 6) -> str:
key = base64.b32decode(secret_b32.upper() + "=" * (-len(secret_b32) % 8))
t = time.time() if t is None else t
return hotp(key, int(t // step), digits)
if __name__ == "__main__":
key = b"12345678901234567890"
# RFC 4226 附录 D
print([hotp(key, c) for c in range(10)])
# RFC 6238 附录 B(SHA1,8 位)
for t in (59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000):
print(t, hotp(key, t // 30, 8))
print(totp(base64.b32encode(key).decode()))

输出的前几行和 RFC 里的值逐个对得上:

['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489']
59 94287082
1111111109 07081804
1111111111 14050471
1234567890 89005924
2000000000 69279037
20000000000 65353130

最后一行 20000000000 是公元 2603 年,RFC 专门放进这个测试向量,是为了提醒实现者时间步要用 64 位整数,别在 2038 年溢出。

想和手机对一下,可以把密钥的 Base32 形式 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ 手动加进身份验证器,再运行下面这一行,两边会显示同一个数:

Python
print(totp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"))

小结

回到开头那个飞行模式下还在跳的六位数,它背后的问题链是这样的:

  1. 为什么需要一次性密码? 静态密码泄露一次就能被无限重放。Lamport 哈希链和 SecurID 都试过解决,但一个会用完,一个把种子握在厂商手里。
  2. 两边不通信,怎么算出同一个数? HOTP 让两边共享密钥 KK 和计数器 CC,算 HMAC-SHA1,再用动态截断从 20 个字节里取出 6 位数。
  3. 计数器失步怎么办? TOTP 用 30 秒一段的时间代替计数器,两边只要时钟对准就行;服务器多查一步来吸收时钟偏差,并且同一步的码只认一次。
  4. 它怎么普及的? Google 两步验证和身份验证器把它带给了普通用户,otpauth:// 二维码让任何网站都能接入,也让“SHA1 + 6 位 + 30 秒”成了事实标准。
  5. 扫码导入的是证书吗? 不是。那是两端共享的对称密钥,两端都可能泄露;证书背后是非对称密码,私钥只有一份,公开的是公钥加上 CA 的签名,U盾 和 HTTPS 的小锁用的都是它。
  6. HOTP 为什么不用非对称? 因为中间是一个靠人抄写的六位数通道。HMAC 的验证是重算,可以截断成 6 位;签名的验证要检查完整的 64 字节,截断就没法验证。再加上 2005 年令牌硬件的限制,RFC 4226 从一开始就选了共享密钥。
  7. 平台不持有秘密要怎么做到? 去掉中间的人,让设备和浏览器直接传签名,这就是 FIDO U2F、WebAuthn 和 passkey:私钥只在用户设备里,平台只存公钥。
  8. 为什么有证书还挡不住钓鱼? 证书证明“对面是哪个域名”,六位数证明“我手里有密钥”,中间缺了“我把码交给了哪个域名”。WebAuthn 把浏览器经证书验证过的域名签进签名里,补上了这一环。

参考资料

Footnotes

  1. Leslie Lamport. Password authentication with insecure communication. Communications of the ACM 24(11), 1981. ↩

  2. N. Haller. RFC 1760: The S/KEY One-Time Password System, 1995;N. Haller et al. RFC 2289: A One-Time Password System, 1998. ↩

  3. RSA 2011 年入侵事件及洛克希德·马丁攻击的报道,见 Wikipedia RSA SecurID 条目及其引用的新闻。 ↩

  4. M. Bellare, R. Canetti, H. Krawczyk. Keying hash functions for message authentication. CRYPTO 1996;M. Bellare. New proofs for NMAC and HMAC: security without collision-resistance. CRYPTO 2006. ↩

  5. D. M’Raihi, M. Bellare, F. Hoornaert, D. Naccache, O. Ranen. RFC 4226: HOTP: An HMAC-Based One-Time Password Algorithm, 2005。动态截断见 §5.3,限速见 §7.3,测试向量见附录 D。 ↩ ↩2 ↩3

  6. D. M’Raihi, S. Machani, M. Pei, J. Rydell. RFC 6238: TOTP: Time-Based One-Time Password Algorithm, 2011。时间步见 §4、§5.2,重放见 §5.2,测试向量见附录 B。 ↩ ↩2 ↩3 ↩4

  7. Google Security Blog. Advanced sign-in security for your Google account, 2011-02-10;Dark Reading. Google Bolsters Apps With Two-Step Verification, 2010-09-20. ↩

  8. google/google-authenticator-libpam. ↩

  9. Key Uri Format, google-authenticator wiki. ↩

  10. LWN. FreeOTP multi-factor authentication, 2014;Wikipedia Google Authenticator. ↩

  11. BleepingComputer. Google will add end-to-end encryption to Google Authenticator, 2023-04. ↩

  12. crt.sh 证书透明日志查询:accounts.google.com,2026-10-16 查询。文中引用的是 Not Before 为 2026-09-02、签发者为 C=US, O=Google Trust Services, CN=WR2 的那张。 ↩ ↩2

  13. W3C. Web Authentication: An API for accessing Public Key Credentials,attestation 相关章节;FIDO Alliance, FIDO Security Reference(关于认证证书由至少 10 万台设备共用的建议)。 ↩

  14. Krebs on Security. Google: Security Keys Neutralized Employee Phishing, 2018-07-23. ↩

  15. Google Blog. Passkeys 相关公告,2023-05-03 与 2023-10-10. ↩