从 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 对应 host、LABEL 对应 pathname、PARAMETERS 即 searchParams。
下面这行 otpauth URI 经 new URL(...) 解析。点 URI 里任意一段(或下方属性行)看它的含义;也可以直接修改输入(或点预设),实时查看 URL 把它拆成哪些字段。几处要点:TYPE 是 url.host(只能是 totp/hotp);LABEL 是
url.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)。TOTP 与 HOTP 的唯一区别,就在这个 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 的原因之一。
相关链接
-
RFC 6238 · TOTP: Time-Based One-Time Password
IETF
TOTP 规范:counter =
floor((now − T0) / period)、validation window(多验相邻时间片)、附录里那组官方测试向量(本系列用它核对实现)。 -
RFC 4226 · HOTP: HMAC-Based One-Time Password
IETF
HOTP 原始规范:HMAC-SHA1 + 动态截取 (Dynamic Truncation) 算法、counter 同步与 look-ahead window
s的取值建议。 -
RFC 2104 · HMAC: Keyed-Hashing for Message Authentication
IETF
HMAC 本身的定义(
H(key ⊕ opad ‖ H(key ⊕ ipad ‖ msg)))。OTP 把它当「对称密钥下的确定性函数」用。 -
Key Uri Format
google-authenticator wiki
对应「otpauth:// 拆解」节:
otpauth://TYPE/LABEL?PARAMETERS各字段(secret / issuer / algorithm / digits / period / counter)的事实标准。 -
MDN · SubtleCrypto.sign (HMAC)
developer.mozilla.org
对应「HMAC + 动态截取」「TOTP vs HOTP」节:用
crypto.subtle.importKey+sign('HMAC', …)在浏览器里真算 HMAC。