TOTP 工作原理详解:验证器验证码背后的算法
你每周都会从验证器(authenticator)应用里点开几次 6 位验证码。手机上显示的那个码和服务端期待的码一致,可两边从不通信,这本身就有点反直觉。同一个共享密钥每隔 30 秒生成一个全新的数字,靠的是一个小巧的确定性算法,两端各自独立跑一遍就得到同一个结果。没有任何验证码经过网络传输,也没有哪台中心服务器派发这个数字。
TOTP(Time-based One-Time Password,基于时间的一次性密码)定义于 RFC 6238。它把一个共享密钥和当前时间拼到一起,对时间做 HMAC 运算再截断结果,得出一个简短的数字验证码。双因素认证(2FA)的关键就是双方不交换数值也能算出同一个值,所以这个算法本身就是整套信任模型。
下面我会带着具体数字把算法从头到尾走一遍,然后补上大多数科普文章略过的那一半:服务端到底怎么验证一个验证码,以及 2FA 能拦住什么、拦不住什么。你可以边读边在我们的 TOTP 生成器里算出一个实时验证码。
TOTP 究竟是什么?
说白了,TOTP 就是一个把共享密钥和当前时间结合起来、生成按固定间隔轮换的简短验证码的算法(定义见 RFC 6238)。验证器应用和服务端持有同一个密钥、读同一份时钟、跑同一套运算,谁也不用把验证码发给对方,就能得出相同的结果。
这一点值得多想一下。配置阶段从来不发送验证码,发送的只是密钥本身,之后每一端都各自把验证码算出来。线路上能被拦截的,只有注册时的那个密钥,以及登录时用户输入的那 6 位数字。可以把它理解成三个输入坍缩成一个输出:
| 输入 | 作用 | 典型取值 |
|---|---|---|
| 共享密钥 | 长期有效的密钥,注册时一次性约定 | JBSWY3DPEHPK3PXP(Base32) |
| 时间步长(time step) | 不断向前推进的计数器 | 30 秒窗口 |
| 输出 | 由前两者推算出的简短验证码 | 324550 |
密钥几乎都用 Base32(字母 A–Z 和数字 2–7)来写,因为这套字母表大小写不敏感,打印、键入或塞进二维码之后都不会出错。注册密钥有两种方式:扫描一个 otpauth:// URI(它可以渲染成一个验证器二维码),或者手动键入那串 Base32 字符串。
TOTP vs HOTP vs SMS vs Passkey:2FA 全景图
TOTP 只是众多选项之一,要选得明智就得看清整片版图。一句话概括:TOTP 就是把 HOTP 的计数器换成自 Unix 纪元(epoch)以来经过的时间步长数。剩下的差异,都是在抗钓鱼能力、便利性和所需基础设施之间做取舍。
| 机制 | 驱动因素 | 验证码寿命 | 抗钓鱼? | 需要网络? | 典型用途 |
|---|---|---|---|---|---|
| HOTP(RFC 4226) | 递增计数器 | 直到被使用 | 否 | 否 | 硬件令牌、遗留系统 |
| TOTP(RFC 6238) | 当前时间 | 约 30 秒 | 否 | 否(注册后) | 验证器应用 |
| SMS OTP | 服务端下发验证码 | 几分钟 | 否 | 是(蜂窝网络) | 面向消费者的兜底方案 |
| 推送批准 | 服务端向设备发起提示 | 每次请求 | 部分 | 是 | 基于应用的 2FA |
| Passkey / FIDO2 | 公钥挑战 | 每次请求 | 是(绑定源 origin) | 是 | 现代账户 |
从这张表里能看出几条规律。TOTP 和 HOTP 注册完成后就能离线运行,既稳妥又私密,但单独用时都挡不住钓鱼:一个足够逼真的假页面照样能向用户索要验证码再转发出去。SMS 多了一条网络通道,也就多了一片攻击面。Passkey 把凭据绑定到站点的源(origin),从根上堵住了钓鱼缺口,整个行业也在往这个方向走。TOTP 则在够稳、普及、免费这几点上都还说得过去,所以到今天仍然随处可见。
TOTP 算法分步详解
完整算法就这四步。下面用 RFC 测试密钥 JBSWY3DPEHPK3PXP 和一个固定的 Unix 时间 1700000000 把每一步都跑一遍,这样你能自己复现每个数字。
- 解码 Base32 密钥,还原成原始密钥字节。
- 计算时间步长计数器,依据当前 Unix 时间得出。
- 对计数器做 HMAC,使用密钥作为密钥。
- 截断摘要,缩减为一个 6 位验证码。
第 1 步 —— 把 Base32 密钥解码为字节
Base32 每个字符携带 5 个比特,解码器要把这些字符重新归组成 8 比特的字节。密钥 JBSWY3DPEHPK3PXP 解码后得到 10 个原始字节 48 65 6c 6c 6f 21 de ad be ef。给 HMAC 当密钥用的是这个字节数组,不是那串可打印的字符串。
第 2 步 —— 计算时间步长计数器
计数器就是从某个起点到现在过去了多少个完整的时间步长:T = floor((unixTime − T0) / period)。RFC 的默认值是 T0 = 0(Unix 纪元)和 period = 30。代入 unixTime = 1700000000,得到 T = floor(1700000000 / 30) = 56666666。这个整数接着被编码成一个 8 字节大端(big-endian)值:00 00 00 00 03 60 aa 2a。计数器只在新的 30 秒窗口开始时才加一,所以每个验证码在一个窗口内一直不变,到点再跳。
第 3 步 —— 用密钥对计数器做 HMAC
算法对那 8 字节的计数器跑 HMAC-SHA1,密钥用的就是前面那串密钥字节。HMAC 是一个带密钥的单向函数:没有密钥,你既逆推不出摘要,也伪造不出一个有效摘要,验证码无法被伪造正是这个道理。对于这里的输入,摘要是这 20 个字节 1d 70 6e 94 1a c7 6b 6d 4a 46 dd 6f af a4 5f e3 35 11 bf 86。
第 4 步 —— 动态截断为 6 位验证码(RFC 4226)
20 字节的摘要太长,没法直接输入,所以 RFC 4226 的动态截断会从里面抠出一个数字。先取最后一个字节的低半字节(nibble)当偏移量:最后一个字节是 0x86,低半字节是 6,于是偏移量为 6。从这个偏移量起读 4 个字节(6b 6d 4a 46),把第一个字节的最高位掩掉好让数字保持为正,得到整数 1802324550。对 10^6 取模再补零:1802324550 % 1000000 = 324550。这就是你的应用此刻为这个密钥显示的验证码。
下面这份零依赖的 JavaScript 实现用的是浏览器原生 Web Crypto API。每段注释都标出对应的是上面四步中的哪一步:
// TOTP per RFC 6238 — SHA-1, 6 digits, 30s period (the defaults).
async function generateTotp(base32Secret, unixTime = Date.now() / 1000) {
// Step 1: decode the Base32 secret (A-Z, 2-7) to raw key bytes.
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const ch of base32Secret.replace(/=+$/, '').toUpperCase()) {
bits += alpha.indexOf(ch).toString(2).padStart(5, '0');
}
const keyBytes = new Uint8Array(
bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
// Step 2: counter = number of 30s steps since the epoch (8-byte big-endian).
let counter = Math.floor(unixTime / 30);
const msg = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
msg[i] = counter & 0xff;
counter = Math.floor(counter / 256);
}
// Step 3: HMAC-SHA1 the counter with the secret key.
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
// Step 4: dynamic truncation (RFC 4226) -> 6-digit code.
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) |
(hmac[offset + 2] << 8) | hmac[offset + 3];
return (binary % 1_000_000).toString().padStart(6, '0');
}
const code = await generateTotp('JBSWY3DPEHPK3PXP', 1700000000);
console.log(code); // -> "324550"
同一个算法的 Python 版本,只用到标准库(hmac 和 struct):
import base64, hmac, hashlib, struct, time
def totp(secret, for_time=None, period=30, digits=6, digest='sha1'):
if for_time is None:
for_time = time.time()
# Step 1: Base32-decode the secret to raw key bytes.
key = base64.b32decode(secret.upper())
# Step 2: counter = number of time steps since the epoch (8-byte big-endian).
counter = int(for_time // period)
msg = struct.pack(">Q", counter)
# Step 3: HMAC the counter with the secret.
h = hmac.new(key, msg, digest).digest()
# Step 4: dynamic truncation (RFC 4226) -> N-digit code.
offset = h[-1] & 0x0f
binary = ((h[offset] & 0x7f) << 24 |
(h[offset + 1] & 0xff) << 16 |
(h[offset + 2] & 0xff) << 8 |
(h[offset + 3] & 0xff))
return str(binary % (10 ** digits)).zfill(digits)
print(totp('JBSWY3DPEHPK3PXP', 1700000000)) # -> 324550
两份实现在这个固定时间下都会打印 324550,也都能复现 RFC 6238 的官方测试向量(比如 T = 59 处的 SHA-1 向量得出 94287082)。如果你把 SHA-1 换成 SHA-256 或 SHA-512,或者改了位数,那么验证一端必须用上完全相同的设置,否则两边的验证码对不上。
在服务端验证 TOTP 验证码
生成验证码只是整套系统的一半。另一半是服务端要判断该不该接受用户刚输入的那 6 位数字,几乎所有跟安全相关的取舍都集中在这一步。
服务端不存验证码,它存的是密钥。登录时它用这个密钥和当前时间重新算出期待的验证码,再做比对。麻烦出在时钟漂移:用户设备和服务端很少能精确到秒地对齐,严格的相等检查会把窗口边界附近的验证码全都挡掉。办法是开一个小小的验证窗口(validation window):除了当前步长,左右各放一步,也就是连 T−1、T、T+1 三个计数器对应的验证码一起检查。窗口越宽,越能容忍漂移,但能被猜中的验证码也跟着变多,所以取窗口为 1(约 ±30 秒的容差)是常见的折中。工具的「Verify」标签页里就能看到这种±1 步容差。
import { createHmac, timingSafeEqual } from 'crypto';
function verifyTotp(secret, code, { window = 1, period = 30, digits = 6 } = {}) {
const counter = Math.floor(Date.now() / 1000 / period);
const submitted = Buffer.from(code);
// Check the current step and ±window steps for clock drift.
for (let i = -window; i <= window; i++) {
const expected = Buffer.from(totpAt(secret, counter + i, digits));
// Constant-time compare so timing can't leak a partial match.
if (expected.length === submitted.length &&
timingSafeEqual(expected, submitted)) {
return counter + i; // matched step — store it to block replay
}
}
return false;
}
还有两个细节,决定了这套实现是「能跑」还是真的「安全」。第一是防重放(replay prevention):给每个用户存下你接受过的最近一个计数器,凡是步长小于等于它的验证码一律拒绝,这样被嗅探到一次的验证码就没法在同一窗口里再用一遍。verifyTotp 返回匹配的步长而不是一个干巴巴的 true,就是为了这个。第二是速率限制(rate limiting):6 位验证码只有一百万种取值,而 ±1 的窗口让任一时刻同时有三个有效,不限流的话攻击者迟早能把这个空间暴力穷举出来。所以失败几次之后就锁账户或者加退避(backoff)。还有一点别忘了,密钥是长期有效的,要对它做静态加密、别提交进源码仓库,整个当成密码来对待。配置时顺手生成一批够强的恢复码,留着哪天设备丢了应急。
TOTP 能防住什么,又防不住什么
比起只用密码,TOTP 是一次实打实的升级,但它不是万能的,营销页面又常常对它的缺口语焉不详。下面把账算清楚。
| TOTP 能防住 | TOTP 防不住 |
|---|---|
| 泄露或重复使用的密码 | 实时钓鱼 / 中间人对手(adversary-in-the-middle) |
| 撞库(credential stuffing) | 在设备上读取密钥的恶意软件 |
| 远程密码暴力破解 | 跳过 2FA 的薄弱账户恢复流程 |
| 仅暴露密码哈希的数据库泄露 | (这些需要其他防御手段) |
好处是看得见的。现在登录得有一个只有密钥才能算出的验证码,光拿到泄露的密码已经登不进去,撞库和远程暴力破解就此被堵死。哪怕你的数据库泄露了,只要 TOTP 密钥做了静态加密,攻击者还是变不出验证码来。
缺口也一样真实。一个实时钓鱼代理(也就是中间人对手页面)能给用户摆出一个一模一样的页面,把那个实时验证码截下来,趁同一个窗口转发到真站点。TOTP 分不清这个验证码是不是被填到了错误的地方。在设备上把密钥偷走的恶意软件会彻底打穿它,一个草率的「忘了我的 2FA」恢复流程也能把它整个绕过去。这里有个常被搞混的点要澄清:SIM 卡交换(SIM-swap)攻击打的是 SMS 一次性验证码,不是 TOTP,因为 TOTP 压根没有手机号通道,攻击者没东西可以重定向。
再往后呢?Passkey 和 FIDO2/WebAuthn 把凭据绑定到源(origin),天生抗钓鱼:凭据干脆拒绝向错误的域名做认证。把 TOTP 看成一次相对纯密码的稳妥升级、到哪都能用,但别把它当终点。它和你认证体系的其它部分本来就配套:会话令牌层骑在已经验证过的登录之上,可以读 JWT 安全最佳实践;2FA 在它之上补强的那层静态密码,可以读密码哈希(bcrypt vs Argon2)。
实现 TOTP 时的常见陷阱
大多数 TOTP 缺陷都不在算法本身,毕竟它早被 RFC 钉死了,问题出在它周边的接线。下面这几条最容易把实现者坑进去。
- 服务端时钟漂移。 服务端要是不跑 NTP,它对「现在」的理解就会和用户设备越偏越远,结果谁的验证码都对不上。每台主机都把网络时间同步开起来。
- 明文密钥或者密钥被提交进仓库。 一个签进 git 的配置文件里躺着密钥,就等于留了一道永久后门。把它加密放进密钥管理服务,绝不进源码仓库。
- 没做防重放。 接受了一个验证码却不记下它匹配的步长,那么这个验证码在它的窗口内还能再用一次。给每个用户存下最近用过的步长,重复的一律拒绝。
- 窗口过宽或过窄。 太宽会成倍放大能被猜中的验证码、削弱安全;太窄又会在轻微漂移时把合法用户挡在外面。一般取窗口为 1。
- 参数对不上。 注册时在
otpauth://URI 里写的是 SHA-256 加 8 位数,验证方却默认 SHA-1 加 6 位数,那就没有一个验证码能过。从 URI 里读出算法、位数和周期,两端都照着用。 - 没有备份或恢复码。 手机丢了之后,唯一回得去的路就是一条恢复途径。配置时就把恢复码发出来,安全级别要配得上这个账户,密码熵背后那套逻辑同样适用于恢复用的密钥。
FAQ
TOTP 能彻底防住钓鱼吗?
不能。TOTP 挡得住泄露的密码和远程暴力破解,但实时钓鱼代理能摆出一个假登录页,把那个实时验证码截下来,在同一个 30 秒窗口内转发到真站点。要抗钓鱼得换 Passkey 和 FIDO2,它们会把凭据绑定到站点的源(origin)。
TOTP 比 SMS 2FA 更安全吗?
是的。SMS 验证码走蜂窝网络传输,可能被 SIM 卡交换或 SS7 攻击拦下,而且要看运营商的安全水平。TOTP 没有手机号通道,也根本不传验证码,传输途中没什么可拦的。密钥只在配置时交换那一次。
如果我弄丢了手机或验证器应用怎么办?
你得提前准备一份备份。几种选择:配置 2FA 时存下来的恢复码、一台用同一密钥注册的备用设备,或者把原始的 Base32 密钥放在某个安全的地方。这三样一个都没有,那设备一丢你就被锁在账户外面了。
服务端如何验证一个 TOTP 验证码?
它用共享密钥和当前时间重新算出期待的验证码,再拿提交上来的验证码跟当前时间步长以及左右各一步做比对,这样能容忍时钟漂移。它还会记下是哪一步匹配的,让同一个验证码没法被重放,并对尝试做速率限制来挡住猜测。
为什么 TOTP 验证码每 30 秒就刷新一次?
30 秒是 RFC 6238 的默认周期:长到够你从容读完再输进去,又短到让攻击者截获的验证码几乎立刻作废。有些系统用 60 秒周期,otpauth:// URI 会把它记下来,好让验证方对得上。
两台设备能共用一个 TOTP 密钥吗?
可以。算法是确定性的,所以任何持有同一 Base32 密钥、时钟又同步的设备都会算出相同的验证码。多设备验证器备份就是这么做的。反过来说,这也是密钥必须保密的原因:谁复制了它,谁就能算出今后的每一个验证码。
TOTP 和 Google Authenticator 是一回事吗?
不是。TOTP 是 RFC 6238 里定义的开放算法,Google Authenticator、Authy 和 1Password 是实现了它的应用。标准是公共的,所以任何兼容应用都能跟任何用 TOTP 的服务搭配,不会被某个厂商锁死。
结语
几条要点短到能直接记进脑子:
- TOTP 用 HMAC 加截断,把共享密钥和当前时间变成一个验证码。
- 两端各自把验证码算出来,它从不经网络发送。
- 验证时用 ±1 步窗口,再配上防重放和速率限制。
- 它挡得住密码攻击,挡不住实时钓鱼,那个缺口得靠 Passkey 来补。
- 让服务端时钟跟 NTP 同步,密钥保持加密和私密。
想亲眼看着这个算法跑出真实数字、再试试你自己的验证窗口?打开 TOTP / 2FA 生成器,计算、配置、验证全在你的浏览器里完成,密钥绝不离开你的设备。