← 首页 / 从 otpauth:// 到 6 位码 待审核
otpauth · HMAC · TOTP / HOTP

从 otpauth:// 到 6 位码

启用两步验证时,扫码后手机 authenticator 中会出现一行每 30 秒刷新的 6 位数字。它不联网、也不需要任何一方传输该码,却能与服务器匹配。本页分三步拆解:扫码导入的那行 otpauth:// URI 的结构;这 6 位码如何从一个 secret 与一个计数器算出;以及 TOTP 与 HOTP 的区别与双端对账。全程不依赖第三方库:URI 拆解使用浏览器内置 URL,HMAC 使用原生 crypto.subtle,算出的码与 Google Authenticator / 1Password 一致。

1 · otpauth:// 拆解——扫码导入的内容

用于「扫码绑定 Google Authenticator」的二维码,内容就是一行文本 URI:otpauth://TYPE/LABEL?PARAMETERS。authenticator 扫码的过程,就是解析这行字符串、把 secret 存下来。这行字符串符合通用 URI 语法,因此无需自行编写正则——直接交给浏览器内置的 URL / URLSearchParams 拆解:TYPE 对应 hostLABEL 对应 pathnamePARAMETERSsearchParams

下面这行 otpauth URI 经 new URL(...) 解析。点 URI 里任意一段(或下方属性行)看它的含义;也可以直接修改输入(或点预设),实时查看 URL 把它拆成哪些字段。几处要点:TYPEurl.host(只能是 totp/hotp);LABELurl.pathname,在 URI 中经过 percent-encoding(%3A=:%40=@),读取时需 decodeURIComponent;其余参数从 url.searchParams 按 key 取。

LABEL = issuer:account。冒号前是发行方(服务名,用于显示),冒号后通常是账号(邮箱/用户名)。规范建议同时提供一个 issuer 参数——两处都写且一致,部分旧版 authenticator 才能正确区分同一服务下的多个账号。本节会在两处不一致时给出提示。

secret 是其中唯一真正的秘密。 它是 Base32 编码的共享密钥,客户端和服务器各存一份、必须完全一致。其余参数(algorithm/digits/period…)只是双方对「如何计算」的约定,泄露影响有限;但 secret 一旦泄露,等同于交出了整个码生成器——不应随意分享二维码截图。

2 · secret + 计数器 如何生成 6 位码

本节使用的 secret 是 Base32 编码的共享密钥,其来源与字段含义见上文 otpauth:// 拆解。TOTP / HOTP 的核心是同一个函数——HOTP(secret, counter),分两步:用 HMAC(key = secret,message = counter)算出一串哈希字节;用动态截取 (Dynamic Truncation) 把这串字节压成固定 digits 位数字。

HMAC = Hash-based Message Authentication Code,一种对称密钥算法:只要两端 secret 一致、输入的 counter 一致、用同一个哈希,算出的字节就逐位相同——这正是「客户端和服务器各自计算却能得到同一个码」的原因。本节的 HMAC 由浏览器原生 crypto.subtle 实际计算,得到的码与 Google Authenticator 一致。

为什么要「动态」截取? 如果永远取固定位置的字节,攻击者就能针对那几位做预计算。取最后一字节的低 4 位当 offset,让每次截取的起点随哈希结果浮动,4 个字节的位置不可预测——再 & 0x7f 抹掉最高位是为了避开有符号整数的麻烦(统一成 31 位非负数,跨语言一致)。

HOTP 与 TOTP 的唯一区别在于 counter 的来源:HOTP 用一个显式计数器(每用一次 +1);TOTP 用时间片 floor(now / period) 作为 counter——把上面的 counter 理解为「当前是第几个 30 秒」即可。下文 TOTP vs HOTP 一节详细展开。

3 · TOTP vs HOTP,以及双端为什么能对上

上文给出的核心函数是 HOTP(secret, counter)。TOTPHOTP 的唯一区别,就在这个 counter 的来源:

  • HOTP(HMAC-based)——counter 是一个显式计数器,每用一次 +1,客户端和服务器各自记录「已用到第几个」。
  • TOTP(Time-based)——counter = floor(unix秒 / period),即**「当前是第几个 30 秒」。它不随使用递增,而是随时间自动递增**,因此每 30 秒刷新一次。

两端能匹配的前提是三项一致:secret 一致 · 时间(或计数器)一致 · 算法一致

3.1 · 两端对账:服务器为何能验证手机上的码

手机和服务器都不向对方传输该码——双方持有同一个 secret、各自读取本地时钟、各自计算。只要两边时间落在同一个 30 秒窗口,算出的码就相同。拖动「服务器时钟偏移」模拟两边时间不同步:

时钟稍有误差很常见,所以服务器通常多验几个相邻时间片(这里是 ±1,即上一个/当前/下一个,共 ±30 秒容错)。代价是「一个码有效的时间」变长一点——安全与可用性的折中。RFC 6238 把这叫 validation window

3.2 · HOTP:计数器必须保持同步

HOTP 不依赖时间,只有一个计数器。客户端每生成一次,counter +1;服务器每验证通过一次,也 +1。问题在于:若客户端多生成了几次(码未提交),它的计数器就领先于服务器。因此服务器验证时会向前多试几个 (look-ahead window),一旦匹配就把自己的 counter 设为该位置 +1 重新同步。点击按钮演示:

look-ahead 窗口不宜过大:窗口越宽,攻击者随机命中的概率越高。RFC 4226 建议 s 取个位数(如 3~10)。若客户端领先过多、超出窗口,就需要重新扫码绑定。这也是 HOTP 的普及程度低于 TOTP 的原因之一。

相关链接