最近在好几个平台配置 2FA 时,它们都会让我打开 Google Authenticator 扫一个二维码。扫完以后,手机上就出现了一串六位验证码,每隔一小段时间自动刷新。把验证码填回平台,就能通过校验。一直没有仔细研究过这是为什么。
更奇怪的是,生成验证码时手机甚至不需要联网。它到底怎么知道服务器此刻想要哪一串数字?如果双方各算各的,时间难道不会慢慢漂移吗?
这场景让我想起了以前银行发的 U 盾和动态口令牌。不过先掰扯清楚一个容易搞混的点:传统 U 盾里存的一般是数字证书和私钥,负责给交易签名;那种屏幕上数字不停跳的,更接近“动态口令牌”。它们都是在证明“你手里有这个东西”,但肚子里用的技术不一定是同一套。
Google Authenticator 里这种跟着时间变化的验证码,叫做 TOTP(Time-Based One-Time Password,基于时间的一次性密码)。常见配置是六位数字、每 30 秒刷新一次,但位数和时间步长本身都是系统参数。
扫描二维码时,到底扫进去了什么?
第一次绑定的时候,平台会生成一份随机密钥,然后干两件事:
- 服务端保存一份,生产环境中应当对它进行加密或其他形式的安全保护;
- 把同一份密钥塞进二维码,让 Authenticator 扫进去。
Google Authenticator 扫出来的二维码,还原之后大概长这样:
| |
这里面真正要紧的是 secret,它就是平台和手机共同把守、外人碰不得的共享密钥。剩下的平台名、账号、验证码位数、刷新周期,都是帮助 Authenticator 正确展示和计算的信息。
所以这个二维码可不是一张普通的“绑定图片”,更像是平台隔着屏幕塞给手机的一把钥匙。
这里得特别提醒一句:别随便截图、转发或者保存这个二维码。 谁拿到它,谁就能在另一台设备上复制出一模一样的验证码——而且不是只抄走当前这一组,是往后每一组都能自己算出来。
手机不联网,为什么还能和服务器算出同一个数?
答案很简单:它们压根不需要实时商量。
绑定的那一刻,平台和手机就已经把三样东西对好了:
- 同一份共享密钥;
- 同一套计算方法;
- 同一个时间分段规则。
TOTP 的做法是:把当前的 Unix 时间按固定长度切成一格一格,默认每格 30 秒:
| |
floor 就是向下取整。时间每跨过 30 秒,“时间格”的编号就加 1,算出来的验证码自然跟着换。
HOTP 这一步可以粗略理解成:
| |
当然,它不是把密钥和时间简单加起来就完事。HMAC 是一种带密钥的哈希算法:输入差一丁点,输出就天差地别;不知道密钥的人,盯着前面几组验证码也推不出下一组。
所以当你的手机显示 123456 时,服务器并不是拿着一张“答案表”来对号,而是自己撸起袖子也算一遍:
| |
输入一样,算法一样,结果当然一样。整个过程不用访问 Google,也不用手机和服务器每 30 秒通一次话——各算各的,答案自然对得上。
顺带一提,那个倒计时不是从你打开 App 才开始走的。30 秒的格子是按 Unix 时间统一切好的,所以你打开 Authenticator 时,经常会发现只剩 7 秒就刷新了。
手机和服务器的时间真的不会漂移吗?
会。
TOTP 的标准文档 RFC 6238 专门用了一整节聊时钟漂移和重新同步。它的思路不是把漂移消灭掉,而是给漂移留一个有限的容错窗口。
假设服务器当前在第 100 格,它可以不只计算这一格,还顺手计算相邻的时间格:
| |
比如你卡着倒计时最后一秒抄下验证码,手输加网络又耗掉两三秒,等请求到了服务器,那边其实已经翻篇进下一格了。只要服务器还接受上一格的验证码,这点正常的磨蹭就不会把你挡在门外。
RFC 6238 推荐默认使用 30 秒步长,这是安全性和易用性之间的折中;对单纯的传输延迟,标准建议最多放宽一个时间步。真遇到设备时钟本身跑偏,验证器也可以在预先设定的范围内向前或向后检查几个时间格,验证成功后还可以记住“这个令牌大概偏了几格”,下次按这个偏移量校验。
当然,窗口不能无限放宽。检查的时间格越多,服务器一次接受的候选验证码就越多,攻击者暴力猜中的概率也跟着上涨。说白了,容错窗口就是在“好用”和“安全”之间做取舍。
手机一般没这个烦恼,它会通过运营商或网络时间服务自动对时。时区也搅不浑这锅水:TOTP 用的是 Unix 时间,上海晚上八点和伦敦中午十二点,指向的是同一个时间点。真正让验证码一直报错的,是设备时钟本身快得或慢得太离谱,超出了平台容忍的窗口。
老式硬件令牌就比较容易积累漂移了——它里面靠一颗时钟芯片自己走,用久了误差越攒越多。偏差还在窗口内,服务器能自动识别并记下来;一旦超出去,就得走额外验证,重新同步或者干脆重新绑定。
TOTP 的“一次性”体现在哪里?
同一个 30 秒时间格里,手机算来算去都是同一个验证码。所以“一次性”不是说它在你面前晃一眼就没了。
RFC 6238 要求:一组验证码一旦验证成功,验证器就不能再接受同一组验证码的第二次提交。再配上短有效期、登录密码和失败次数限制,这几位数字才算凑成一套完整的验证机制。
这也是为什么验证码不能简单代替登录密码。“你知道的密码”加上“你持有的密钥”,两样凑齐了,才构成大家常说的双因素认证。严格来说,TOTP 只是其中的“持有因素”,具体系统是否真的构成双因素,还要看另一个验证步骤使用了什么因素。
最后总结
说到底,Google Authenticator 能离线生成正确的验证码,靠的不是服务器在背后远程指挥,而是一套很朴素的“提前对暗号”:
| |
二维码负责把共享密钥塞进手机;30 秒的时间格让验证码不断翻新;服务器多检查有限的相邻时间格,把网络延迟和轻微的时钟漂移兜住。
所以它不是没有误差,而是把误差圈在了一个可控的范围里。
下次再盯着那串不停跳动的验证码,可以想象这个画面:手机和服务器各自揣着同一份秘密,同时抬头看了一眼钟,然后算出了同一个答案。




