验证码必须联网同步?
离线也能对上暗号
秘密藏在时间里
共享密钥 · 时间窗口 · 二步验证
TOTP AUTHENTICATION
📦 4 Parts + Conclusion
👉 滑动
PART 01
二维码里
藏着什么
PART 02
离线同算
同一个答案
PART 03
时间漂移
有限容错
PART ///
最后总结
提前对暗号
手机甚至没有联网
它到底怎么知道服务器想要哪一串数字?
最近使用好多个平台配置 2FA 时,它都让我打开身份认证软件扫描二维码,我使用的是 Google Authenticator。扫完以后,手机上就出现了一串六位验证码,每隔一小段时间自动刷新。把验证码填回平台,就能通过校验。一直没有仔细研究过这个为什么
更奇怪的是,生成验证码时手机甚至不需要联网。它到底怎么知道服务器此刻想要哪一串数字?如果双方各算各的,时间难道不会慢慢漂移吗?
这场景让我想起了以前银行发的 U 盾和动态口令牌。不过先掰扯清楚一个容易搞混的点:传统 U 盾里存的一般是数字证书和私钥,负责给交易签名;那种屏幕上数字不停跳的,更接近“动态口令牌”。它们都是在证明“你手里有这个东西”,但肚子里用的技术不一定是同一套。
Google Authenticator 里这种跟着时间变的验证码,行话叫 TOTP(Time-Based One-Time Password,基于时间的一次性密码)。

— U 盾、动态口令牌和手机 TOTP 的原理对比
01
PART
扫描二维码时,到底扫进去了什么?
SECRET · 密钥交付
第一次绑定的时候,平台会生成一份随机密钥,然后干两件事:
自己留一份;
把同一份密钥塞进二维码,让 Authenticator 扫进去。
Google Authenticator 扫出来的二维码,还原之后大概长这样:
otpauth://totp/Example:ray@example.com
?secret=JBSWY3DPEHPK3PXP
&issuer=Example
&digits=6
&period=30
这里面真正要紧的是 secret,它就是平台和手机共同把守、外人碰不得的共享密钥。剩下的平台名、账号、验证码位数、刷新周期,都只是顺带捎上的信息。
所以这个二维码可不是一张普通的“绑定图片”,更像是平台隔着屏幕塞给手机的一把钥匙。
这里得特别提醒一句:别随便截图、转发或者保存这个二维码。 谁拿到它,谁就能在另一台设备上复制出一模一样的验证码——而且不是只抄走当前这一组,是往后每一组都能自己算出来。

— 平台通过二维码把共享密钥交给手机,二维码泄露后另一台手机也能生成相同验证码
02
PART
手机不联网,为什么还能和服务器算出同一个数?
OFFLINE · 独立计算
答案很简单:它们压根不需要实时商量。
绑定的那一刻,平台和手机就已经把三样东西对好了:
同一份共享密钥;
同一套计算方法;
同一个时间分段规则。
TOTP 的做法是:把当前的 Unix 时间按固定长度切成一格一格,默认每格 30 秒:
时间格 = floor(当前 Unix 时间 ÷ 30)
验证码 = HOTP(共享密钥, 时间格)
floor 就是向下取整。时间每跨过 30 秒,“时间格”的编号就加 1,算出来的验证码自然跟着换。
HOTP 这步可以粗略理解成:
把“共享密钥 + 时间格编号”放进 HMAC 算法
→ 得到一长串难以预测的结果
→ 截取并转换成 6 位数字
当然,它不是把密钥和时间简单加起来就完事。HMAC 是一种带密钥的哈希算法:输入差一丁点,输出就天差地别;不知道密钥的人,盯着前面几组验证码也猜不出下一组。
所以当你的手机显示 123456 时,服务器并不是拿着一张“答案表”来对号,而是自己撸起袖子也算一遍:
手机:同一密钥 + 当前时间格 → 123456
服务端:同一密钥 + 当前时间格 → 123456
输入一样,算法一样,结果当然一样。整个过程不用访问 Google,也不用手机和服务器每 30 秒通一次话——各算各的,答案自然对得上。
顺带一提,那个倒计时不是从你打开 App 才开始走的。30 秒的格子是按 Unix 时间统一切好的,所以你打开 Authenticator 的时候,经常会发现只剩 7 秒就刷新了。

— 手机和服务器使用相同密钥、时间格和算法,离线生成相同验证码
03
PART
但时间真的不会漂移吗?
DRIFT · 时间容错
会。
TOTP 的标准文档 RFC 6238 专门用了一整节聊时钟漂移和重新同步。它的思路不是把漂移消灭掉,而是给漂移留个有限的容错窗口。
假设服务器当前在第 100 格,它通常不会只算这一格,还会顺手把相邻的格子也算出来:
第 99 格:上一组验证码
第 100 格:当前验证码
第 101 格:下一组验证码
比如你卡着倒计时最后一秒抄下验证码,手输加网络又耗掉两三秒,等请求到了服务器,那边其实已经翻篇进下一格了。只要服务器还认上一格的验证码,这点正常的磨蹭就不会把你挡在门外。
标准默认把步长定在 30 秒,算是安全性和易用性之间的折中;网络延迟这块,建议最多放宽一个步长。真遇到设备时钟本身跑偏,服务器也可以设定向前向后各容忍几格,验证成功后还会记住“这个令牌大概偏了几格”,下次直接按这个偏移量来校。
当然,窗口不能无限放宽。检查的格子越多,服务器一次接受的候选验证码就越多,攻击者瞎猫碰上死耗子的概率也跟着涨。说白了,容错窗口就是在“好用”和“安全”之间做取舍。
手机一般没这个烦恼,它会通过运营商或网络时间服务自动对时。时区也搅不浑这锅水:TOTP 用的是 Unix 时间,上海晚上八点和伦敦中午十二点,指向的是同一个时间点。真正让验证码一直报错的,是设备时钟本身快得或慢得太离谱,超出了平台容忍的窗口。
老式硬件令牌就比较容易积累漂移了——它里面靠一颗时钟芯片自己走,用久了误差越攒越多。偏差还在窗口内,服务器能自动识别并记下来;一旦超出去,就得走额外验证,重新同步或者干脆重新绑定。

— 服务端检查相邻时间窗口,并在安全性与可用性之间控制容错范围
04
PART
“一次性”又体现在哪里?
ONE TIME · 双因素
同一个 30 秒格子里,手机算来算去都是同一个验证码。所以“一次性”不是说它在你面前晃一眼就没了。
RFC 6238 的要求是:一组验证码一旦验证成功过,服务器就不能再放行第二次。再配上短有效期、登录密码和失败次数限制,这六位数字才算凑成一套完整的验证机制。
这也是为什么验证码永远代替不了登录密码。“你知道的密码”加上“你手里的密钥”,两样凑齐了,才是大家常说的双因素认证。

— 验证码成功使用后不能再次通过,登录需要密码和持有设备两种证明
///
LAST
最后总结
SUMMARY · 提前对暗号
说到底,Google Authenticator 能离线生成正确的验证码,靠的不是服务器在背后远程指挥,而是一套很朴素的“提前对暗号”:
共享密钥 + 当前时间格 + 统一算法 = 此刻的验证码
二维码负责把共享密钥塞进手机;30 秒的时间格让验证码不停翻新;服务器多检查相邻的一两格,把网络延迟和轻微的时钟漂移都兜住。
所以它不是没有误差,而是把误差圈在了一个可控的范围里。
下次再盯着那串不停跳动的六位数字,你可以想象这个画面:手机和服务器各自揣着同一份秘密,同时抬头看了一眼钟,然后算出了同一个答案。
参考资料
RFC 6238:TOTP——基于时间的一次性密码算法 https://www.rfc-editor.org/rfc/rfc6238
RFC 6238 第 5.2 节:验证与时间步长 https://www.rfc-editor.org/rfc/rfc6238#section-5.2
RFC 6238 第 6 节:重新同步与时钟漂移 https://www.rfc-editor.org/rfc/rfc6238#section-6
RFC 4226:HOTP——基于 HMAC 的一次性密码算法 https://www.rfc-editor.org/rfc/rfc4226
Google Authenticator:Key URI Format https://github.com/google/google-authenticator/wiki/Key-Uri-Format
本文封面及配图均由 gpt-image-2 生成
我是安落滢,各个技术领域纯野生研究员|持续踩坑,持续记录,反向加载。
如果你觉得今天这篇有收获,欢迎点赞、转发、推荐,我们下篇见。
THANKS FOR READING
夜雨聆风