Skip to content
返回博客
安全

TOTP 工作原理详解:2FA 验证码背后的算法

面向开发者的 TOTP 工作原理详解:逐步拆解 RFC 6238 算法,讲清楚服务端如何验证一次性验证码,以及 2FA 能防住什么、防不住什么。

12 分钟

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 把每一步都跑一遍,这样你能自己复现每个数字。

  1. 解码 Base32 密钥,还原成原始密钥字节。
  2. 计算时间步长计数器,依据当前 Unix 时间得出。
  3. 对计数器做 HMAC,使用密钥作为密钥。
  4. 截断摘要,缩减为一个 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 版本,只用到标准库(hmacstruct):

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−1TT+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 生成器,计算、配置、验证全在你的浏览器里完成,密钥绝不离开你的设备。

标签: Two-Factor Authentication TOTP Security Authentication 2FA