﻿---
lang: zh-TW
title: "六位數字與一張證書：Google 身份驗證器的演算法、證書和它們的關係"
description: "手機開著飛航模式，Google 身份驗證器照樣每 30
  秒換一個六位數，伺服器卻能認出它。順著這個現象往下追：六位數是怎麼從金鑰裡算出來的，為什麼用時間做計數器，掃碼匯入的那串東西是不是“證書”，真正的數字證書\
  又是什麼，平臺也握著金鑰時 HOTP 為什麼不改用非對稱密碼，以及證書和六位數為什麼都擋不住釣魚。"
pubDate: 2026-10-16
tags: [ "安全", "两步验证", "TOTP", "数字证书", "密码学", "Google" ]
clc: "TP309"
hero: "./hero.zh-TW.svg"
refs:
  - id: "1"
    title: "Password authentication with insecure communication"
    site: "Communications of the ACM"
    author: "Leslie Lamport"
    date: "1981"
    note: "24(11)"
  - id: "2"
    title: "RFC 1760: The S/KEY One-Time Password System"
    url: "https://www.rfc-editor.org/rfc/rfc1760"
    collect: "rfc-editor-rfc-rfc1760"
    author: "N. Haller"
    date: "1995"
  - id: "2b"
    title: "RFC 2289: A One-Time Password System"
    url: "https://www.rfc-editor.org/rfc/rfc2289"
    collect: "rfc-editor-rfc-rfc2289"
    author: "N. Haller et al."
    date: "1998"
  - id: "3"
    title: "RSA SecurID"
    url: "https://en.wikipedia.org/wiki/RSA_SecurID"
    collect: "wikipedia-rsa-securid"
    site: "Wikipedia"
    note: "RSA 2011 年入侵事件及洛克希德·馬丁攻擊的報道，見該條目及其引用的新聞。"
  - id: "4"
    title: "Keying hash functions for message authentication"
    site: "CRYPTO"
    author: "M. Bellare, R. Canetti, H. Krawczyk"
    date: "1996"
    note: "另見 M. Bellare. *New proofs for NMAC and HMAC: security without
      collision-resistance.* CRYPTO 2006."
  - id: "5"
    title: "RFC 4226: HOTP: An HMAC-Based One-Time Password Algorithm"
    url: "https://www.rfc-editor.org/rfc/rfc4226"
    collect: "rfc-editor-rfc-rfc4226"
    author: "D. M'Raihi, M. Bellare, F. Hoornaert, D. Naccache, O. Ranen"
    date: "2005"
    note: "動態截斷見 §5.3，限速見 §7.3，測試向量見附錄 D。"
  - id: "6"
    title: "RFC 6238: TOTP: Time-Based One-Time Password Algorithm"
    url: "https://www.rfc-editor.org/rfc/rfc6238"
    collect: "rfc-editor-rfc-rfc6238"
    author: "D. M'Raihi, S. Machani, M. Pei, J. Rydell"
    date: "2011"
    note: "時間步見 §4、§5.2，重放見 §5.2，測試向量見附錄 B。"
  - id: "7"
    title: "Advanced sign-in security for your Google account"
    url: "https://security.googleblog.com/2011/02/advanced-sign-in-security-for-you\
      r.html"
    collect: "googleblog-2011-02-advanced-sign-in-security-for"
    site: "Google Security Blog"
    date: "2011-02-10"
    note: "另見 Dark Reading. Google Bolsters Apps With Two-Step Verification,
      2010-09-20."
  - id: "8"
    title: "google/google-authenticator-libpam"
    url: "https://github.com/google/google-authenticator-libpam"
    collect: "github-google-google-authenticator-libpam"
  - id: "9"
    title: "Key Uri Format"
    url: "https://github.com/google/google-authenticator/wiki/Key-Uri-Format"
    collect: "github-google-google-authenticator-key-uri-forma"
    site: "google-authenticator wiki"
  - id: "10"
    title: "FreeOTP multi-factor authentication"
    url: "https://lwn.net/Articles/581086/"
    collect: "lwn-581086"
    site: "LWN"
    date: "2014"
  - id: "10b"
    title: "Google Authenticator"
    url: "https://en.wikipedia.org/wiki/Google_Authenticator"
    collect: "wikipedia-google-authenticator"
    site: "Wikipedia"
  - id: "11"
    title: "Google will add end-to-end encryption to Google Authenticator"
    url: "https://www.bleepingcomputer.com/news/google/google-will-add-end-to-end-e\
      ncryption-to-google-authenticator"
    collect: "bleepingcomputer-google-google-will-add-end-to-e"
    site: "BleepingComputer"
    date: "2023-04"
  - id: "12"
    title: "Google: Security Keys Neutralized Employee Phishing"
    site: "Krebs on Security"
    date: "2018-07-23"
  - id: "13"
    title: "Passkeys 相關公告"
    site: "Google Blog"
    date: "2023-05-03 与 2023-10-10"
  - id: "14"
    title: "accounts.google.com"
    url: "https://crt.sh/?q=accounts.google.com"
    site: "crt.sh"
    date: "2026-10-16"
    note: "證書透明日誌查詢，2026-10-16 查詢。文中引用的是 Not Before 為 2026-09-02、簽發者為 `C=US,
      O=Google Trust Services, CN=WR2` 的那張。"
  - id: "15"
    title: "Web Authentication: An API for accessing Public Key Credentials"
    url: "https://www.w3.org/TR/webauthn-2/"
    collect: "w3-tr-webauthn-2"
    site: "W3C"
    note: "attestation 相關章節；另見 FIDO Alliance, FIDO Security Reference（關於認證證書由至少 10
      萬臺裝置共用的建議）。"
---

開啟兩步驗證時，網站會給出一張二維碼。用 Google 身份驗證器（Google Authenticator）掃一下，App 裡就多了一行六位數，每 30 秒換一個。掃碼的那一刻，手機拿到的其實只是一行字：

```
otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
```

從這以後，手機再也沒有和伺服器說過話。切到飛航模式，數字照樣在變；可我們把它填進登入框，伺服器就是認。

這串 `secret` 到底是什麼？它是不是一張證書，和銀行發的 U盾 是一個路數嗎？手機和伺服器各算各的，憑什麼能對上？

帶著這幾個問題，本文從這行字開始，先看六位數是怎麼從它算出來的。等算清楚了，會發現一個讓人不太放心的地方，它和 U盾 的差別，也正藏在那裡。

---

## 一、一次性密碼的由來

### 現象：密碼一旦洩露，就能被無限次使用

普通密碼的問題在於它是靜態的。只要被人看到一次，無論是在網路上被竊聽、被鍵盤記錄器記下，還是從別的網站的洩露庫裡撞出來，對方就可以在任何時候、任何地點反覆使用它。伺服器分不清這次輸入是我們本人，還是一份錄下來的副本。

所以很早就有人想：**能不能讓每次登入用的密碼都不一樣，用過一次就作廢？** 這樣即使被看到，也只是一張過期的票。

### 第一次嘗試：Lamport 的雜湊鏈

1981 年，Leslie Lamport 提出了一個很漂亮的辦法[^1]：拿一個秘密 $s$ 反覆做雜湊，得到一條鏈 $H(s), H(H(s)), \dots, H^{n}(s)$。伺服器只儲存鏈的最末端 $H^{n}(s)$。第一次登入時，使用者出示 $H^{n-1}(s)$，伺服器把它再雜湊一次，看是否等於自己存的值；對上了就放行，並把存的值換成 $H^{n-1}(s)$。下一次使用者出示 $H^{n-2}(s)$，依此類推。

這個方案的好處是伺服器只存一個“往後推”的值，就算伺服器被攻破，攻擊者也算不出下一個密碼，因為雜湊不能倒推。貝爾實驗室後來把它做成了 S/KEY，並在 1995 年寫成 RFC 1760，再演化成 RFC 2289 的 OTP 標準[^2][^2b]。

**它的不足**在於鏈是有限的：用完 $n$ 次就得重新初始化。而且每次要算出第 $n-k$ 個值，要麼事先列印一張密碼錶隨身帶著，要麼在終端上輸入口令現場計算，都談不上方便。

### 第二次嘗試：硬體令牌

另一條路是 RSA 的 SecurID，從 1980 年代後期開始賣。它是一個鑰匙扣大小的硬體，螢幕上的數字每 60 秒變一次，看上去已經和今天的 App 很像了。

**它的不足**是封閉：演算法是專有的，令牌裡的種子（seed）由廠商生成，廠商自己也保留一份。企業要買硬體、買授權，還得信任廠商永遠不出事。2011 年 3 月，RSA 公司被入侵，與 SecurID 種子相關的資料被竊取，隨後美國軍工企業洛克希德·馬丁遭到了利用這批資料的攻擊，RSA 不得不為客戶更換大批令牌[^3]。“種子在別人手裡”從理論風險變成了真實事故。

### 解決：一個開放的標準

到了 2004 年，VeriSign 等公司發起了 OATH（Initiative for Open Authentication），目標是做一個任何人都能免費實現的一次性密碼演算法：不靠專有硬體，不靠廠商保管種子，使用者和伺服器之間直接共享一個金鑰就行。它的第一個成果就是 2005 年釋出的 HOTP。

下面這張圖把這幾代方案和它們各自留下的問題串在一起，後面的每一節，基本上都是在回答圖右邊那一列。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 416" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tt-hist-title tt-hist-desc">
<title id="tt-hist-title">一次性密碼的幾代方案和它們留下的問題</title>
<desc id="tt-hist-desc">1981 年 Lamport 雜湊鏈，鏈會用完；1980 年代後期 RSA SecurID，演算法和種子在廠商手裡；2005 年 HOTP，計數器會失步；2011 年 TOTP 和 Google 兩步驗證，換機會丟碼、碼可以被釣魚轉發；2014 年 FIDO U2F 安全金鑰，需要額外硬體；2022 到 2023 年 passkey 成為 Google 預設登入方式。</desc>
<rect class="dd-frame" width="800" height="416" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="384" rx="8"/>
<text class="t-zone" x="32" y="44">一次性密碼的幾代 · 每一代留下的問題</text>
<line class="dd-edge" x1="96" y1="84" x2="96" y2="364"/>
<rect class="dd-card" x="112" y="64" width="320" height="40" rx="6"/>
<text class="t-mono" x="32" y="89">1981</text>
<text class="t-name" x="128" y="89">Lamport 雜湊鏈 → S/KEY</text>
<text class="t-say" x="456" y="89">鏈會用完，需要重新初始化</text>
<rect class="dd-card" x="112" y="120" width="320" height="40" rx="6"/>
<text class="t-mono" x="32" y="145">1980s</text>
<text class="t-name" x="128" y="145">RSA SecurID 硬體令牌</text>
<text class="t-say" x="456" y="145">演算法專有，種子握在廠商手裡</text>
<rect class="dd-card" x="112" y="176" width="320" height="40" rx="6"/>
<text class="t-mono" x="32" y="201">2005</text>
<text class="t-name" x="128" y="201"><tspan class="t-acc">HOTP</tspan> · RFC 4226（OATH）</text>
<text class="t-say" x="456" y="201">計數器會失步</text>
<rect class="dd-card" x="112" y="232" width="320" height="40" rx="6"/>
<text class="t-mono" x="32" y="257">2011</text>
<text class="t-name" x="128" y="257"><tspan class="t-acc">TOTP</tspan> · RFC 6238，Google 兩步驗證</text>
<text class="t-say" x="456" y="257">換機丟碼；碼可以被釣魚站轉發</text>
<rect class="dd-card" x="112" y="288" width="320" height="40" rx="6"/>
<text class="t-mono" x="32" y="313">2014</text>
<text class="t-name" x="128" y="313">FIDO U2F 安全金鑰</text>
<text class="t-say" x="456" y="313">需要額外的硬體</text>
<rect class="dd-card" x="112" y="344" width="320" height="40" rx="6"/>
<text class="t-mono" x="32" y="369">2023</text>
<text class="t-name" x="128" y="369">Passkey 成為 Google 預設登入</text>
<text class="t-note" x="456" y="369">同步的公鑰憑據，見第七節</text>
</svg>
</div>
<figcaption>圖 1 · 每一代一次性密碼都在補上一代的缺口，Google 身份驗證器用的是中間那一段：HOTP 和 TOTP</figcaption>
</figure>

那麼 HOTP 是怎麼做到“兩邊不通訊，卻算出同一個數”的？

## 二、HOTP：從金鑰和計數器到六位數字

### 分析：兩邊共享什麼

HOTP 的思路很直接。使用者的令牌和伺服器在一開始共享兩樣東西：一個金鑰 $K$（通常 20 位元組隨機數），和一個計數器 $C$（初始為 0）。每次需要密碼時，就用 $K$ 對 $C$ 算一個 HMAC-SHA1：

$$
h = \text{HMAC-SHA1}(K, C)
$$

$$
\text{HOTP}(K, C) = \text{Truncate}(h) \bmod 10^{6}
$$

其中 $C$ 編碼成 8 位元組大端整數。算完一次，兩邊的計數器各加一。

為什麼用 HMAC？因為它有兩個性質正好對口：沒有 $K$ 的人算不出下一個值；而看到再多個歷史碼，也推不回 $K$。這正是一次性密碼需要的。順帶一提，HMAC 的安全性並不依賴 SHA-1 的抗碰撞性，所以 SHA-1 在 2017 年被找到碰撞，並不影響 HOTP[^4]。

### 現象：20 個位元組，怎麼變成 6 位數？

HMAC-SHA1 的輸出是 20 位元組、160 位。人沒法輸入 160 位，只能輸入幾位十進位制數字。**那該從這 20 個位元組裡取哪一部分？**

最直覺的辦法是取前 4 個位元組，轉成整數再取後 6 位。這樣做沒錯，但 RFC 4226 選了一個更巧的辦法，叫**動態截斷**（dynamic truncation）：讓 HMAC 的最後一個位元組來決定從哪裡取。具體是：

1. 取最後一個位元組的低 4 位，得到一個 0 到 15 的偏移量 `offset`；
2. 從第 `offset` 個位元組開始連取 4 個位元組，拼成一個 32 位整數；
3. 把最高位清零，只留 31 位；
4. 對 $10^6$ 取餘，不足 6 位前面補 0。

下圖用 RFC 的測試金鑰 `12345678901234567890`（20 個 ASCII 字元）和計數器 $C=1$ 走一遍，每一個位元組都是真實算出來的。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 356" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tt-trunc-title tt-trunc-desc">
<title id="tt-trunc-title">HOTP 的動態截斷</title>
<desc id="tt-trunc-desc">HMAC-SHA1(K, C=1) 的 20 個位元組是 75 a4 8a 19 d4 cb e1 00 64 4e 8a c1 39 7e ea 74 7a 2d 33 ab。最後一個位元組 ab 的低 4 位是 b，即偏移 11；取第 11 到 14 位元組 c1 39 7e ea，最高位清零得到 0x41397eea，即 1094287082，對一百萬取餘得到 287082。</desc>
<rect class="dd-frame" width="800" height="356" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="324" rx="8"/>
<text class="t-zone" x="32" y="44">HMAC-SHA1(K, C = 1) 的 20 個位元組</text>
<rect class="dd-card" x="40" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="57" y="85" text-anchor="middle">75</text>
<rect class="dd-card" x="76" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="93" y="85" text-anchor="middle">a4</text>
<rect class="dd-card" x="112" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="129" y="85" text-anchor="middle">8a</text>
<rect class="dd-card" x="148" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="165" y="85" text-anchor="middle">19</text>
<rect class="dd-card" x="184" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="201" y="85" text-anchor="middle">d4</text>
<rect class="dd-card" x="220" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="237" y="85" text-anchor="middle">cb</text>
<rect class="dd-card" x="256" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="273" y="85" text-anchor="middle">e1</text>
<rect class="dd-card" x="292" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="309" y="85" text-anchor="middle">00</text>
<rect class="dd-card" x="328" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="345" y="85" text-anchor="middle">64</text>
<rect class="dd-card" x="364" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="381" y="85" text-anchor="middle">4e</text>
<rect class="dd-card" x="400" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="417" y="85" text-anchor="middle">8a</text>
<rect class="dd-card" x="436" y="64" width="34" height="32" rx="4"/><text class="t-mono t-acc" x="453" y="85" text-anchor="middle">c1</text>
<rect class="dd-card" x="472" y="64" width="34" height="32" rx="4"/><text class="t-mono t-acc" x="489" y="85" text-anchor="middle">39</text>
<rect class="dd-card" x="508" y="64" width="34" height="32" rx="4"/><text class="t-mono t-acc" x="525" y="85" text-anchor="middle">7e</text>
<rect class="dd-card" x="544" y="64" width="34" height="32" rx="4"/><text class="t-mono t-acc" x="561" y="85" text-anchor="middle">ea</text>
<rect class="dd-card" x="580" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="597" y="85" text-anchor="middle">74</text>
<rect class="dd-card" x="616" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="633" y="85" text-anchor="middle">7a</text>
<rect class="dd-card" x="652" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="669" y="85" text-anchor="middle">2d</text>
<rect class="dd-card" x="688" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="705" y="85" text-anchor="middle">33</text>
<rect class="dd-card" x="724" y="64" width="34" height="32" rx="4"/><text class="t-mono" x="741" y="85" text-anchor="middle">a<tspan class="t-acc">b</tspan></text>
<text class="t-note" x="57" y="114" text-anchor="middle">0</text>
<text class="t-note" x="453" y="114" text-anchor="middle">11</text>
<text class="t-note" x="561" y="114" text-anchor="middle">14</text>
<text class="t-note" x="741" y="114" text-anchor="middle">19</text>
<path class="dd-edge-acc" d="M436 122 V128 H578 V122"/>
<text class="t-note" x="507" y="146" text-anchor="middle"><tspan class="t-acc">從第 11 位元組起取 4 個</tspan></text>
<text class="t-note" x="758" y="146" text-anchor="end">低 4 位 <tspan class="t-acc">b</tspan> = 11</text>
<rect class="dd-card" x="40" y="164" width="720" height="136" rx="6"/>
<path class="dd-card-head" d="M40.6 196 V170 Q40.6 164.6 46 164.6 H754 Q759.4 164.6 759.4 170 V196 Z"/>
<line class="dd-edge" x1="40" y1="196" x2="760" y2="196"/>
<text class="t-name" x="56" y="185">動態截斷</text>
<text class="t-note" x="744" y="185" text-anchor="end">RFC 4226 §5.3</text>
<line class="dd-hair" x1="56" y1="222" x2="744" y2="222"/>
<line class="dd-hair" x1="56" y1="248" x2="744" y2="248"/>
<line class="dd-hair" x1="56" y1="274" x2="744" y2="274"/>
<text class="t-mono" x="56" y="213">1 · 最後一位元組 &amp; 0x0f</text>
<text class="t-val" x="368" y="213">0xab &amp; 0x0f = <tspan class="t-acc">11</tspan></text>
<text class="t-mono" x="56" y="239">2 · 取第 11–14 位元組</text>
<text class="t-val" x="368" y="239"><tspan class="t-acc">0xc1397eea</tspan></text>
<text class="t-mono" x="56" y="265">3 · &amp; 0x7fffffff，清掉最高位</text>
<text class="t-val" x="368" y="265">0x41397eea = 1094287082</text>
<text class="t-mono" x="56" y="291">4 · mod 10^6</text>
<text class="t-val" x="368" y="291"><tspan class="t-acc">287082</tspan></text>
<text class="t-say" x="40" y="324">取哪 4 個位元組由 HMAC 自己決定，每個計數器值取的位置都不一樣。</text>
</svg>
</div>
<figcaption>圖 2 · 金鑰 12345678901234567890、計數器 1：HMAC 的最後半個位元組指定從哪裡取，最終得到 287082</figcaption>
</figure>

**為什麼要清掉最高位？** 不同語言對 32 位整數的處理不一樣，有的當有符號數，有的當無符號數。把最高位清零以後，結果在任何實現裡都是同一個非負整數，避免了 Java 和 C 算出不一樣的碼。

**六位數夠安全嗎？** 一次猜中的機率是百萬分之一。單看這個數字不算大，所以 RFC 4226 明確要求伺服器做限速：連續輸錯幾次就延遲或鎖定[^5]。六位數的安全性，有一半是靠伺服器的限速來保證的。

### 新問題：計數器失步

HOTP 的令牌每按一次按鈕，計數器就加一。可是使用者按了按鈕卻沒去登入，或者小孩拿著令牌按著玩，令牌的計數器就會跑到伺服器前面去。

RFC 的辦法是讓伺服器往前多試幾個：不只比較 $C$，而是依次比較 $C, C+1, \dots, C+s$，在這個“前瞻視窗”裡對上了，就把伺服器的計數器直接推進到那個位置。超出視窗，就得讓使用者連續輸入兩個碼重新同步。

這能用，但不優雅：兩邊都得儲存並維護一個會變化的狀態。**能不能找一個兩邊不用商量、天然就一致的計數器？**

## 三、TOTP：用時間代替計數器

### 解決：時間本身就是計數器

答案是時間。手機和伺服器各自都有時鐘，只要都對準了世界時間，就不需要再同步任何東西。TOTP 把 Unix 時間（從 1970 年 1 月 1 日起的秒數）按 30 秒一段切開，用段號代替 HOTP 的計數器：

$$
T = \left\lfloor \frac{t_{\text{unix}}}{30} \right\rfloor
$$

$$
\text{TOTP}(K) = \text{HOTP}(K, T)
$$

演算法的其餘部分和 HOTP 一模一樣。一個有趣的驗證是：$t = 59$ 秒時，$T = 1$，所以 TOTP 在第 59 秒的 8 位碼 `94287082`，正好就是圖 2 裡 $C = 1$ 那個 31 位整數 `1094287082` 的後 8 位。RFC 6238 附錄裡的第一個測試向量就是它[^6]。

這也回答了開頭的問題：**飛航模式下照樣出碼，是因為算碼只需要兩樣東西，一個掃碼時就存進手機的金鑰，一個手機本來就有的時鐘。** 伺服器那邊拿著同一個金鑰、看著同一個時間，自然算出同一個數。

### 為什麼是 30 秒

時間步越短，被偷看到的碼越快作廢，但使用者可能還沒輸完就過期了；越長則反過來。RFC 6238 推薦 30 秒，原文的說法就是“在安全性和可用性之間取一個平衡”[^6]。

### 現象：手機時鐘慢了幾秒

時間當計數器，就帶來了新的問題：兩邊的時鐘不可能分毫不差，網路傳輸也要時間。使用者在一個時間步的最後一秒看到碼，等它提交到伺服器，伺服器那邊已經是下一步了。

RFC 6238 的建議是，伺服器除了當前步 $T$，最多再往回多查一步 $T-1$，用來容忍網路延遲；也可以記錄每個客戶端的時鐘偏差，下次自動校正[^6]。下圖是一個具體的例子。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 288" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tt-win-title tt-win-desc">
<title id="tt-win-title">TOTP 的時間步和驗證視窗</title>
<desc id="tt-win-desc">以測試金鑰為例，時間步 41152262、41152263、41152264 分別覆蓋 Unix 時間 1234567860 起、1234567890 起和 1234567920 起的 30 秒，對應的六位碼是 980357、005924、590587。手機時鐘慢 5 秒，在 1234567885 算出 980357；伺服器此刻是 1234567890，當前步是 41152263，它同時檢查上一步，所以仍然接受 980357。</desc>
<defs>
<marker id="tt-win-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="288" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="256" rx="8"/>
<text class="t-zone" x="32" y="44">T = floor(t / 30) · 金鑰 12345678901234567890</text>
<line class="dd-edge-acc dd-dash" x1="233" y1="80" x2="233" y2="96"/>
<text class="t-note" x="227" y="76" text-anchor="end"><tspan class="t-acc">手機 1234567885</tspan></text>
<line class="dd-edge dd-dash" x1="280" y1="80" x2="280" y2="96"/>
<text class="t-note" x="286" y="76">伺服器 1234567890</text>
<rect class="dd-card" x="40" y="96" width="232" height="84" rx="6"/>
<path class="dd-card-head" d="M40.6 128 V102 Q40.6 96.6 46 96.6 H266 Q271.4 96.6 271.4 102 V128 Z"/>
<line class="dd-edge" x1="40" y1="128" x2="272" y2="128"/>
<text class="t-name" x="56" y="117">T − 1 = 41152262</text>
<text class="t-mono" x="56" y="145">t: 1234567860 … 889</text>
<line class="dd-hair" x1="56" y1="154" x2="256" y2="154"/>
<text class="t-mono" x="56" y="171">碼 <tspan class="t-acc">980357</tspan></text>
<rect class="dd-card" x="280" y="96" width="232" height="84" rx="6"/>
<path class="dd-card-head" d="M280.6 128 V102 Q280.6 96.6 286 96.6 H506 Q511.4 96.6 511.4 102 V128 Z"/>
<line class="dd-edge" x1="280" y1="128" x2="512" y2="128"/>
<text class="t-name" x="296" y="117">T = 41152263（當前）</text>
<text class="t-mono" x="296" y="145">t: 1234567890 … 919</text>
<line class="dd-hair" x1="296" y1="154" x2="496" y2="154"/>
<text class="t-mono" x="296" y="171">碼 005924</text>
<rect class="dd-card" x="520" y="96" width="232" height="84" rx="6"/>
<path class="dd-card-head" d="M520.6 128 V102 Q520.6 96.6 526 96.6 H746 Q751.4 96.6 751.4 102 V128 Z"/>
<line class="dd-edge" x1="520" y1="128" x2="752" y2="128"/>
<text class="t-name" x="536" y="117">T + 1 = 41152264</text>
<text class="t-mono" x="536" y="145">t: 1234567920 … 949</text>
<line class="dd-hair" x1="536" y1="154" x2="736" y2="154"/>
<text class="t-mono" x="536" y="171">碼 590587</text>
<path class="dd-edge-acc" d="M40 192 V198 H512 V192"/>
<text class="t-note" x="276" y="218" text-anchor="middle"><tspan class="t-acc">伺服器檢查 T − 1 和 T，980357 落在視窗裡</tspan></text>
<text class="t-note" x="636" y="218" text-anchor="middle">T + 1：有的服務也接受</text>
<text class="t-say" x="40" y="252">手機慢了 5 秒，算出的是上一步的碼；伺服器往回多查一步，就對上了。</text>
</svg>
</div>
<figcaption>圖 3 · Unix 時間 1234567890（2009-02-13 23:31:30 UTC）前後的三個時間步：驗證視窗吸收了幾秒的時鐘偏差</figcaption>
</figure>

視窗也帶來一個副作用：同一個碼在 30 秒甚至 60 秒內都有效。如果有人在我們身後看到了碼，馬上搶先輸入，伺服器是分不出來的。所以 RFC 6238 要求伺服器記住每個使用者最近一次成功使用的時間步，**同一步的碼只認一次**[^6]。

到這裡，演算法本身已經講完了。**它是怎麼從一份 RFC，變成幾乎每個網站都支援的東西的？**

## 四、Google 的推廣：兩步驗證與二維碼

### 經過：從企業版到所有使用者

2010 年 9 月，Google 先給付費版、政府版和教育版的 Google Apps 推出了兩步驗證；2011 年 2 月，開放給所有 Google 賬號，由使用者自願開啟[^7]。第二步的驗證碼有兩種拿法：用簡訊或語音接收，或者裝一個 Google 身份驗證器 App 自己算。這個 App 用的就是 TOTP。

為什麼是 TOTP，而不是自己發明一套，或者像銀行那樣發硬體令牌？Google 沒有專門解釋過，以下是根據當時的情況做的推斷：TOTP 是開放標準，不用付授權費；智慧手機已經普及，而且手機時鐘由運營商或 NTP 校準，正好滿足 TOTP 對時鐘的要求；離線可用，出國沒有訊號也不影響；而且任何網站都能照著 RFC 實現，不需要和 Google 籤任何協議。

Google 還把 App 連同一個 Linux PAM 模組一起開源了。PAM 模組至今還在維護，裝上以後，SSH 登入伺服器也能要求輸入身份驗證器上的六位數[^8]。

### 那張二維碼裡裝了什麼

現在可以回頭看開頭那行字了。掃碼那一步，其實就是把共享金鑰 $K$ 交給手機，二維碼裡編碼的就是那一行文字，格式是 Google 定義的 Key URI[^9]：

```
otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
```

- `totp` 表示基於時間（換成 `hotp` 就是計數器模式，此時還要帶 `counter` 引數）；
- `Example:alice@google.com` 是顯示在 App 裡的名字；
- `secret` 就是金鑰 $K$，用 Base32 編碼；
- 另外還可以帶 `algorithm`、`digits`、`period`，預設值分別是 `SHA1`、`6`、`30`。

**為什麼用 Base32，而不是更常見的 Base64？** 因為二維碼掃不了的時候，使用者要能手工輸入金鑰。Base32 只用 `A–Z` 和 `2–7`，不分大小寫，也沒有 `0/O`、`1/l` 這種容易看錯的字元。

這個格式的影響比演算法本身還大。Google 身份驗證器在相當長的時間裡會忽略 `algorithm`、`digits`、`period` 的非預設值，於是網站為了相容，幾乎都用 SHA1、6 位、30 秒。RFC 6238 裡寫的 SHA-256、SHA-512 很少有人用，**“SHA1 + 6 位 + 30 秒”就這樣成了事實標準**。GitHub、AWS、各大交易所，以及 Authy、1Password、微軟身份驗證器這些後來的 App，都按這個格式生成和讀取二維碼。

App 本身後來走了另一條路：Android 版在 2013 年前後轉為閉源，開源社群隨後做了 FreeOTP 等替代品[^10][^10b]。但由於格式是公開的，換 App 不影響任何網站。

TOTP 普及以後，它的短板也一個個暴露出來。**最先被使用者感受到的，是換手機。**

## 五、共享金鑰的代價

### 現象：換了手機，碼全沒了

TOTP 是對稱的：手機和伺服器手裡是同一個金鑰 $K$。早期的 Google 身份驗證器把金鑰只存在手機本地，不備份。手機丟了、摔壞了、或者換新手機前忘了遷移，所有賬號的第二步就都進不去了，只能靠開啟時儲存的恢復碼，或者走各個網站的人工申訴。

### 嘗試：匯出與雲同步

Google 先在 2020 年加了“轉移賬號”功能，在舊手機上生成二維碼，用新手機掃。這個二維碼的內容是 `otpauth-migration://offline?data=...`，裡面是一串沒有加密的 protobuf，裝著所有賬號的金鑰。

2023 年 4 月，App 又加入了隨 Google 賬號雲同步。這解決了丟碼的問題，卻引出了一個新問題：安全研究者 Mysk 抓包發現，同步的資料不是端到端加密的，Google 的伺服器能讀到所有金鑰。換句話說，一旦 Google 賬號本身被盜，攻擊者就能拿到這個賬號保護的所有其他賬號的第二因素。Google 隨後表示會加入端到端加密[^11]。

這件事背後是 TOTP 的一個根本性質：**金鑰必須同時存在於兩端，所以無論放在哪裡，都多了一個可能洩露的地方。** 伺服器那邊也一樣，網站的資料庫要是洩露了 TOTP 金鑰，攻擊者就能為所有使用者生成正確的碼，所以金鑰必須加密存放，不能像密碼那樣只存一個雜湊。

兩端共享同一個秘密，這是對稱密碼的天性。說到這裡，常有人問：掃碼匯入的這串金鑰，有的 App 和教程管它叫“令牌證書”；網銀的 U盾 也叫“數字證書”；瀏覽器位址列的小鎖背後，同樣是一張“證書”。**它們是一回事嗎？**

## 六、數字證書：另一種信任方式

### 分析：對稱和非對稱

不是一回事。二維碼裡裝的不是證書，而是前面一直在說的共享金鑰 $K$。兩者的區別，在於雙方手裡各有什麼：

- **TOTP 是對稱的。** 手機和伺服器拿著同一串位元組，能驗證碼的一方，也一定能生成碼。所以 $K$ 必須對所有其他人保密。
- **證書背後是非對稱密碼。** 一對金鑰裡，私鑰只在持有者手裡，公鑰可以公開給任何人。私鑰用來簽名，公鑰用來驗證；能驗證簽名的人，造不出簽名。
- **證書本身**，就是把一把公鑰、“這把公鑰屬於誰”和一個有效期寫在一起，再由一個上級機構（CA，證書頒發機構）用它自己的私鑰簽名作保。

拿 Google 登入頁自己的證書來看。在證書透明日誌 crt.sh 上可以查到，`accounts.google.com` 的一張證書由 Google Trust Services 的中間證書 `WR2` 簽發，有效期從 2026-09-02 到 2026-11-25，只有 84 天[^14]。`WR2` 又由根證書 `GTS Root R1` 簽發，而這張根證書預裝在作業系統和瀏覽器裡。瀏覽器順著這條鏈一級一級驗證簽名，一直驗到自己信任的根，就能確認一件事：**對面這臺伺服器，握有 `accounts.google.com` 這把公鑰對應的私鑰。**

下圖把兩種方式並排放在一起，標出了每一邊手裡的秘密。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tt-keys-title tt-keys-desc">
<title id="tt-keys-title">TOTP 共享金鑰與數字證書的對照</title>
<desc id="tt-keys-desc">左邊是 TOTP：手機和伺服器各存一份相同的 secret GEZDGNBV…QOJQ，掃碼時整份複製一次，任一邊洩露都能算出碼。右邊是 accounts.google.com 的證書：Subject 為 accounts.google.com，Issuer 為 Google Trust Services WR2，有效期 2026-09-02 到 2026-11-25，公鑰公開，簽名由 WR2 的私鑰做出；對應的私鑰只在 Google 伺服器上；瀏覽器用內建的根證書 GTS Root R1 驗證這條鏈。</desc>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="348" rx="8"/>
<text class="t-zone" x="32" y="44">TOTP · 對稱：兩邊是同一個秘密</text>
<rect class="dd-zone" x="408" y="16" width="376" height="348" rx="8"/>
<text class="t-zone" x="424" y="44">證書 · 非對稱：私鑰不出門</text>
<line class="dd-edge dd-dash" x1="204" y1="148" x2="204" y2="196"/>
<rect class="dd-card" x="32" y="64" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H370 Q375.4 64.6 375.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="376" y2="96"/>
<text class="t-name" x="48" y="85">手機 · 身份驗證器</text>
<text class="t-mono" x="48" y="113">secret</text>
<text class="t-val" x="128" y="113"><tspan class="t-acc">GEZDGNBV…QOJQ</tspan></text>
<line class="dd-hair" x1="48" y1="122" x2="360" y2="122"/>
<text class="t-mono" x="48" y="139">演算法</text>
<text class="t-val" x="128" y="139">HMAC-SHA1 · 6 位 · 30 秒</text>
<text class="t-note" x="212" y="176">掃碼時整份複製一次</text>
<rect class="dd-card" x="32" y="196" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 228 V202 Q32.6 196.6 38 196.6 H370 Q375.4 196.6 375.4 202 V228 Z"/>
<line class="dd-edge" x1="32" y1="228" x2="376" y2="228"/>
<text class="t-name" x="48" y="217">伺服器 · 賬號資料庫</text>
<text class="t-mono" x="48" y="245">secret</text>
<text class="t-val" x="128" y="245"><tspan class="t-acc">GEZDGNBV…QOJQ</tspan></text>
<line class="dd-hair" x1="48" y1="254" x2="360" y2="254"/>
<text class="t-mono" x="48" y="271">last_T</text>
<text class="t-val" x="128" y="271">41152263</text>
<text class="t-say" x="32" y="316">兩邊是同一串位元組：</text>
<text class="t-say" x="32" y="338">任何一邊洩露，別人都能算出碼。</text>
<rect class="dd-card" x="424" y="64" width="344" height="162" rx="6"/>
<path class="dd-card-head" d="M424.6 96 V70 Q424.6 64.6 430 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="424" y1="96" x2="768" y2="96"/>
<text class="t-name" x="440" y="85">證書</text>
<text class="t-note" x="752" y="85" text-anchor="end">公開</text>
<text class="t-mono" x="440" y="113">Subject</text>
<text class="t-val" x="520" y="113">accounts.google.com</text>
<line class="dd-hair" x1="440" y1="122" x2="752" y2="122"/>
<text class="t-mono" x="440" y="139">Issuer</text>
<text class="t-val" x="520" y="139">Google Trust Services WR2</text>
<line class="dd-hair" x1="440" y1="148" x2="752" y2="148"/>
<text class="t-mono" x="440" y="165">有效期</text>
<text class="t-val" x="520" y="165">2026-09-02 → 2026-11-25</text>
<line class="dd-hair" x1="440" y1="174" x2="752" y2="174"/>
<text class="t-mono" x="440" y="191">公鑰</text>
<text class="t-val" x="520" y="191">隨證書發給每一個訪客</text>
<line class="dd-hair" x1="440" y1="200" x2="752" y2="200"/>
<text class="t-mono" x="440" y="217">簽名</text>
<text class="t-val" x="520" y="217">由 WR2 的私鑰做出</text>
<rect class="dd-card" x="424" y="242" width="344" height="36" rx="6"/>
<text class="t-name" x="440" y="265"><tspan class="t-acc">私鑰</tspan> · 只在 Google 的伺服器上</text>
<rect class="dd-card" x="424" y="294" width="344" height="36" rx="6"/>
<text class="t-name" x="440" y="317">瀏覽器內建根證書 · GTS Root R1</text>
<text class="t-say" x="424" y="352">證書可以公開，拿到它也冒充不了網站。</text>
</svg>
</div>
<figcaption>圖 4 · TOTP 的秘密兩邊各存一份；證書的秘密只有一份，公開出去的是能驗證它的那一半</figcaption>
</figure>

**所以二維碼裡裝的不是證書。** 它沒有公鑰，不需要 CA，也絕不能公開。U盾 才是真正的證書：裡面存著使用者自己的私鑰，以及由 CFCA 等機構簽發的證書，轉賬時用私鑰對交易內容簽名。銀行同時代發的另一種小硬體“動態令牌”，一按就顯示六位數，那才是一次性密碼，和 Google 身份驗證器是同一類東西。

不過，把兩者放在一起，也引出了一個更尖銳的問題。

## 七、HOTP 為什麼不用非對稱密碼

### 問題：平臺也握著金鑰

回頭看圖 4 左邊，會發現一個讓人不安的事實：伺服器手裡也有一份 $K$。網站資料庫一旦被攻破，攻擊者就能為每個使用者算出正確的碼，第二因素等於沒有。RFC 4226 自己也承認這一點，專門要求伺服器把共享金鑰放在隔離的安全區域，用防篡改硬體加密儲存，只在驗證的那一刻解密[^5]。但這只是把 $K$ 藏得更深，$K$ 仍然在平臺上。

照著證書的思路，似乎有一個更好的設計：私鑰只留在使用者的令牌裡，平臺只存公鑰，公鑰洩露了也無所謂。**HOTP 為什麼不這麼做？**

### 第一次嘗試：把 HMAC 換成簽名

設想令牌不再算 HMAC，而是用私鑰對計數器簽名，伺服器用公鑰驗證。問題馬上出現：簽名太長了。用 Ed25519 這種目前最短的常用簽名演算法之一，對計數器 1 簽名，得到 64 位元組；寫成十進位制有 154 位，寫成 Base64 也要 88 個字元，沒有人能把它抄進登入框。

那麼像 HOTP 一樣，截斷成 6 位行不行？不行，這正是關鍵所在。

- **HMAC 能截斷，是因為驗證就是重算。** 伺服器有 $K$，自己能算出完整的 20 位元組，按同樣的規則截出 6 位，比較一下就行。
- **簽名不能截斷，是因為驗證不是重算。** 只有公鑰的一方算不出簽名，算得出就等於能偽造了。它只能拿到完整的簽名，用公鑰去檢查一個等式是否成立。少了任何一個位元組，這個等式都沒法檢查。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 356" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tt-sig-title tt-sig-desc">
<title id="tt-sig-title">HMAC 可以截斷成 6 位，簽名不能</title>
<desc id="tt-sig-desc">左邊：令牌算 HMAC-SHA1(K, 1) 得到 20 位元組 75a48a19…7a2d33ab，截斷為 287082，使用者抄寫 6 位；伺服器用同一個 K 重算出同樣的 20 位元組和 287082，比較相等。右邊：令牌用 Ed25519 私鑰對計數器 1 簽名，得到 64 位元組 4d835fad 83ee9486 b7a2cb9a…，十進位制 154 位、Base64 88 個字元；如果截成 6 位，伺服器只有公鑰 1157567e…799d8e，算不出簽名，也就無法比較，驗證做不成。</desc>
<defs>
<marker id="tt-sig-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="356" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="324" rx="8"/>
<text class="t-zone" x="32" y="44">對稱 · 驗證就是重算</text>
<rect class="dd-zone" x="408" y="16" width="376" height="324" rx="8"/>
<text class="t-zone" x="424" y="44">非對稱 · 驗證要完整簽名</text>
<path class="dd-edge-acc" d="M204 148 V194" marker-end="url(#tt-sig-arrow-acc)"/>
<path class="dd-edge-acc dd-dash" d="M596 174 V206" marker-end="url(#tt-sig-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H370 Q375.4 64.6 375.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="376" y2="96"/>
<text class="t-name" x="48" y="85">令牌 · HMAC-SHA1(K, 1)</text>
<text class="t-mono" x="48" y="113">20 位元組</text>
<text class="t-val" x="120" y="113">75a48a19…7a2d33ab</text>
<line class="dd-hair" x1="48" y1="122" x2="360" y2="122"/>
<text class="t-mono" x="48" y="139">截斷</text>
<text class="t-val" x="120" y="139"><tspan class="t-acc">287082</tspan></text>
<text class="t-note" x="212" y="176"><tspan class="t-acc">人抄寫 6 位</tspan></text>
<rect class="dd-card" x="32" y="196" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 228 V202 Q32.6 196.6 38 196.6 H370 Q375.4 196.6 375.4 202 V228 Z"/>
<line class="dd-edge" x1="32" y1="228" x2="376" y2="228"/>
<text class="t-name" x="48" y="217">伺服器 · HMAC-SHA1(K, 1)</text>
<text class="t-mono" x="48" y="245">重算</text>
<text class="t-val" x="120" y="245">75a48a19…7a2d33ab → 287082</text>
<line class="dd-hair" x1="48" y1="254" x2="360" y2="254"/>
<text class="t-mono" x="48" y="271">比較</text>
<text class="t-val" x="120" y="271"><tspan class="t-acc">287082</tspan> = 287082，透過</text>
<text class="t-say" x="32" y="312">伺服器有同一個 K，</text>
<text class="t-say" x="32" y="332">能自己算出完整結果，只比較 6 位就夠了。</text>
<rect class="dd-card" x="424" y="64" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 96 V70 Q424.6 64.6 430 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="424" y1="96" x2="768" y2="96"/>
<text class="t-name" x="440" y="85">令牌 · Ed25519 Sign(sk, 1)</text>
<text class="t-mono" x="440" y="113">簽名</text>
<text class="t-val" x="504" y="113">4d835fad 83ee9486 b7a2cb9a …</text>
<line class="dd-hair" x1="440" y1="122" x2="752" y2="122"/>
<text class="t-mono" x="440" y="139">長度</text>
<text class="t-val" x="504" y="139">64 位元組 · 十進位制 154 位</text>
<line class="dd-hair" x1="440" y1="148" x2="752" y2="148"/>
<text class="t-mono" x="440" y="165">Base64</text>
<text class="t-val" x="504" y="165">88 個字元</text>
<text class="t-note" x="604" y="194"><tspan class="t-acc">截成 6 位？</tspan></text>
<rect class="dd-derived" x="424" y="208" width="344" height="84" rx="6"/>
<text class="t-name" x="440" y="233">伺服器 · Verify(pk, 1, sig)</text>
<text class="t-mono" x="440" y="259">公鑰</text>
<text class="t-val" x="504" y="259">1157567e…ed8799d8e</text>
<text class="t-mono" x="440" y="281">結果</text>
<text class="t-val" x="504" y="281">只收到 6 位數字，無法驗證</text>
<text class="t-say" x="424" y="312">虛線框：截斷以後，這一步做不成。</text>
<text class="t-say" x="424" y="332">公鑰算不出簽名，只能檢查完整的簽名。</text>
</svg>
</div>
<figcaption>圖 5 · 同樣是計數器 1：HMAC 的結果伺服器能自己重算，截成 6 位照樣能比；簽名只能整段檢查，截斷就沒法驗證</figcaption>
</figure>

所以，**“人看一眼、手抄六位數”這個通道，本身就把非對稱排除掉了。** 只要中間是人，驗證的一方就必須能自己重算出這幾位數，也就必須擁有和令牌一樣的秘密。

### 原因：當年的設計約束

RFC 4226 開頭列出了六條設計要求[^5]，幾乎每一條都指向同一個方向：

- 演算法應該能便宜地做進硬體，儘量少佔電池、按鍵、處理能力和螢幕（R2）；
- 令牌可以沒有任何輸入鍵（R3），所以伺服器沒法先發一個挑戰給它；
- 顯示的值必須容易讀、容易輸入，至少 6 位，最好全是數字（R4）；
- 使用一個足夠強的共享金鑰，至少 128 位（R6）。

R6 直接寫著 shared secret，可見對稱是設計的起點，而不是疏忽。當年的目標裝置是鑰匙扣令牌、智慧卡和 SIM 卡。這類晶片算一次 HMAC-SHA1 幾乎不費電，做一次公鑰簽名則慢得多，也貴得多。這一點是推斷，RFC 沒有給出具體數字。

### 曾經做到過的“平臺不持有秘密”

有意思的是，第一節的 Lamport 雜湊鏈其實做到了這個性質：伺服器只存鏈的末端 $H^{n}(s)$，資料庫洩露了，攻擊者也算不出下一個密碼，因為雜湊不能倒推。它靠的不是公鑰，而是單向函式；驗證依然是“重算一次雜湊再比較”，所以能讓人輸入。代價是鏈會用完，而且每個值有 64 位，S/KEY 只能把它編成 6 個英文短詞讓人敲進去。HOTP 用這個性質，換來了 6 位數字和不會用完的計數器。

### 解決：去掉中間的人

所以，要讓平臺不再持有秘密，前提是把“人抄寫”這一環拿掉，讓使用者的裝置和瀏覽器直接通訊。USB、NFC、藍芽，或者手機自帶的安全晶片，都能把伺服器給的隨機挑戰原樣送進去，再把 64 位元組的簽名原樣送回來。

這就是 2014 年 FIDO U2F 做的事，後來的 WebAuthn 和 passkey 也是一樣：每個網站一對金鑰，私鑰只在使用者裝置裡，平臺只存公鑰，資料庫洩露了也算不出任何東西。它正是上面設想的那種架構。它沒有出現在 HOTP 裡，是因為 2005 年的令牌和伺服器之間，只有一個靠人抄寫的六位數通道。

換成簽名以後，還多出了一個 HOTP 做不到的好處，這要從釣魚說起。

## 八、釣魚：證書和六位數之間缺的一環

### 現象：釣魚站也有合法證書

位址列有證書，手裡有六位數，為什麼還會被釣魚？

釣魚網站會做一個和 Google 登入頁一模一樣的頁面，位址列同樣有一把小鎖。申請證書只需要證明自己控制著這個域名，Let's Encrypt 這樣的機構可以免費、自動地簽發。在 crt.sh 上搜尋 `accounts.google.com`，就能看到 Let's Encrypt 籤給某個以 `www.accounts.google.com-------` 開頭、實際掛在別人域名下的證書[^14]。使用者輸入密碼，再輸入身份驗證器上的六位數；釣魚站在後臺把這兩樣東西原樣轉交給真正的 Google，在 30 秒內完成登入。今天這類“實時中繼”已經有現成的工具包。

### 原因：兩者各管一半，中間斷了一根線

證書證明的是“對面是證書上寫的那個域名”，它不知道這是不是使用者想去的網站。TOTP 證明的是“輸入的人手裡有金鑰”，碼裡不包含任何域名資訊：使用者在 `g00gle-login.example` 上看到的碼，和在 `accounts.google.com` 上看到的一模一樣。

兩者之間缺的那根線，是“我把碼交給了哪個域名”。在 TOTP 裡，這根線只能靠使用者自己核對位址列，而這恰恰是人最容易出錯的地方。

### 解決：把證書驗證過的域名籤進去

上一節說的 U2F / WebAuthn（Google 和 Yubico 是 U2F 的主要推動者）把這根線也接上了。裝置和瀏覽器直接通訊以後，簽名的內容不再受六位數的限制，可以多裝一樣東西：

- 登入時伺服器發一個隨機挑戰，裝置對它簽名。關鍵在於，**被簽名的資料裡有瀏覽器自己填寫的當前網址（origin）**，也就是瀏覽器剛剛透過證書驗證確認過的那個域名，網頁的指令碼改不了它。
- 瀏覽器還只會把某個網站的憑據交給那個網站：`accounts.google.com` 的憑據，在 `g00gle-login.example` 上根本不會被調出來。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 392" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tt-phish-title tt-phish-desc">
<title id="tt-phish-title">同一次釣魚，對 TOTP 碼和 passkey 簽名的結果</title>
<desc id="tt-phish-desc">上半部分：釣魚站 g00gle-login.example 持有籤給自己域名的合法證書，使用者在那裡輸入六位碼 287082，釣魚站原樣轉發給 accounts.google.com，碼有效，攻擊者登入成功。下半部分：使用 passkey 時，瀏覽器簽名的 clientDataJSON 裡 origin 是 https://g00gle-login.example，真站發現 origin 不符而拒絕；實際上瀏覽器根本不會在釣魚站調出 google.com 的憑據。</desc>
<defs>
<marker id="tt-phish-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="392" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="140" rx="8"/>
<text class="t-zone" x="32" y="44">TOTP：碼裡沒有域名</text>
<path class="dd-edge" d="M192 90 H286" marker-end="url(#tt-phish-arrow)"/>
<path class="dd-edge" d="M512 90 H606" marker-end="url(#tt-phish-arrow)"/>
<rect class="dd-card" x="32" y="72" width="160" height="36" rx="6"/>
<text class="t-name" x="48" y="95">使用者 · 身份驗證器</text>
<rect class="dd-card" x="288" y="72" width="224" height="36" rx="6"/>
<text class="t-mono" x="304" y="95">釣魚站 <tspan class="t-acc">g00gle-login.example</tspan></text>
<rect class="dd-card" x="608" y="72" width="160" height="36" rx="6"/>
<text class="t-mono" x="624" y="95">accounts.google.com</text>
<text class="t-note" x="240" y="82" text-anchor="middle">輸入 287082</text>
<text class="t-note" x="560" y="82" text-anchor="middle">原樣轉發</text>
<text class="t-note" x="288" y="132">有小鎖：證書籤給它自己的域名</text>
<text class="t-note" x="608" y="132">碼有效，攻擊者登入</text>
<rect class="dd-zone" x="16" y="172" width="768" height="204" rx="8"/>
<text class="t-zone" x="32" y="200">Passkey / WebAuthn：瀏覽器把域名籤進去</text>
<path class="dd-edge" d="M192 246 H286" marker-end="url(#tt-phish-arrow)"/>
<path class="dd-edge" d="M512 246 H606" marker-end="url(#tt-phish-arrow)"/>
<rect class="dd-card" x="32" y="228" width="160" height="36" rx="6"/>
<text class="t-name" x="48" y="251">使用者 · 瀏覽器</text>
<rect class="dd-card" x="288" y="228" width="224" height="36" rx="6"/>
<text class="t-mono" x="304" y="251">釣魚站 <tspan class="t-acc">g00gle-login.example</tspan></text>
<rect class="dd-card" x="608" y="228" width="160" height="36" rx="6"/>
<text class="t-mono" x="624" y="251">accounts.google.com</text>
<text class="t-note" x="240" y="238" text-anchor="middle">簽名</text>
<text class="t-note" x="560" y="238" text-anchor="middle">轉發簽名</text>
<rect class="dd-card" x="32" y="280" width="480" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 312 V286 Q32.6 280.6 38 280.6 H506 Q511.4 280.6 511.4 286 V312 Z"/>
<line class="dd-edge" x1="32" y1="312" x2="512" y2="312"/>
<text class="t-name" x="48" y="301">clientDataJSON · 被簽名的內容</text>
<text class="t-note" x="496" y="301" text-anchor="end">由瀏覽器填寫</text>
<text class="t-mono" x="48" y="329">challenge</text>
<text class="t-val" x="160" y="329">伺服器給的隨機數</text>
<line class="dd-hair" x1="48" y1="338" x2="496" y2="338"/>
<text class="t-mono" x="48" y="355">origin</text>
<text class="t-val" x="160" y="355">https://<tspan class="t-acc">g00gle-login.example</tspan></text>
<text class="t-note" x="608" y="292">origin 不符，拒絕</text>
<text class="t-say" x="608" y="320">而且瀏覽器根本不會</text>
<text class="t-say" x="608" y="340">在這裡調出 google.com</text>
<text class="t-say" x="608" y="360">的憑據。</text>
</svg>
</div>
<figcaption>圖 6 · 釣魚站也有合法證書：TOTP 碼原樣轉發就能用，passkey 的簽名裡卻帶著證書驗證過的釣魚站域名</figcaption>
</figure>

到這裡，證書和使用者憑據終於在同一個簽名裡會合了：證書負責讓瀏覽器確認“對面是哪個域名”，使用者的私鑰負責證明“我是我”，而簽名把這兩件事綁在一起，釣魚站拿走簽名也用不了。

有意思的是，passkey 用了公私鑰，卻不需要 CA 給使用者簽發證書。註冊時網站直接記下這把公鑰，信任在第一次註冊時建立，有點像 SSH 第一次連線時記下伺服器指紋。WebAuthn 裡倒是有一種證書，叫認證證書（attestation certificate），由安全金鑰的廠商簽發，用來證明“這是某個型號的真硬體”。為了不讓它變成追蹤使用者的標識，FIDO 要求同一張認證證書由一大批裝置共用（通常至少 10 萬個）；同步到雲端的 passkey 一般乾脆不提供它[^15]。

效果是很實在的。Google 從 2017 年初開始要求全體員工使用物理安全金鑰，據 Google 對外的說法，此後八萬五千多名員工中沒有一個賬號被釣魚接管[^12]。

**passkey** 是這條路的延續：把 FIDO 憑據存在手機或密碼管理器裡，並且可以跨裝置同步，不再需要單獨買一個 U 盤大小的硬體。Google 在 2023 年 5 月支援用 passkey 登入 Google 賬號，同年 10 月把它設為預設選項[^13]。

### TOTP 還會存在下去嗎

會存在很長時間。WebAuthn 需要瀏覽器和作業系統配合，網站要接入的東西也更多；而 TOTP 只需要伺服器端幾十行程式碼，不依賴任何平臺，甚至可以在命令列和 SSH 裡用。對大多數網站來說，它仍然是成本最低的第二因素。只是我們應該清楚它能防什麼：它能防住密碼被盜後的遠端登入，防不住實時釣魚。

## 九、動手：自己算一遍

下面這段 Python 只用了標準庫，`hotp` 是圖 2 的四步，`totp` 是在它外面套上圖 3 的時間步。執行它會依次列印 RFC 4226 附錄 D 的 10 個測試碼、RFC 6238 附錄 B 的 SHA1 測試碼，以及當前時刻的 TOTP：

```python
import base64, hashlib, hmac, struct, time

def hotp(key: bytes, counter: int, digits: int = 6, algo=hashlib.sha1) -> str:
    mac = hmac.new(key, struct.pack(">Q", counter), algo).digest()
    offset = mac[-1] & 0x0F                                   # 动态截断：最后 4 位当下标
    code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
    return str(code % 10 ** digits).zfill(digits)

def totp(secret_b32: str, t: float | None = None, step: int = 30, digits: int = 6) -> str:
    key = base64.b32decode(secret_b32.upper() + "=" * (-len(secret_b32) % 8))
    t = time.time() if t is None else t
    return hotp(key, int(t // step), digits)

if __name__ == "__main__":
    key = b"12345678901234567890"
    # RFC 4226 附录 D
    print([hotp(key, c) for c in range(10)])
    # RFC 6238 附录 B（SHA1，8 位）
    for t in (59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000):
        print(t, hotp(key, t // 30, 8))
    print(totp(base64.b32encode(key).decode()))
```

輸出的前幾行和 RFC 裡的值逐個對得上：

```
['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489']
59 94287082
1111111109 07081804
1111111111 14050471
1234567890 89005924
2000000000 69279037
20000000000 65353130
```

最後一行 `20000000000` 是公元 2603 年，RFC 專門放進這個測試向量，是為了提醒實現者時間步要用 64 位整數，別在 2038 年溢位。

想和手機對一下，可以把金鑰的 Base32 形式 `GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ` 手動加進身份驗證器，再執行下面這一行，兩邊會顯示同一個數：

```python
print(totp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"))
```

## 小結

回到開頭那個飛航模式下還在跳的六位數，它背後的問題鏈是這樣的：

1. **為什麼需要一次性密碼？** 靜態密碼洩露一次就能被無限重放。Lamport 雜湊鏈和 SecurID 都試過解決，但一個會用完，一個把種子握在廠商手裡。
2. **兩邊不通訊，怎麼算出同一個數？** HOTP 讓兩邊共享金鑰 $K$ 和計數器 $C$，算 HMAC-SHA1，再用動態截斷從 20 個位元組裡取出 6 位數。
3. **計數器失步怎麼辦？** TOTP 用 30 秒一段的時間代替計數器，兩邊只要時鐘對準就行；伺服器多查一步來吸收時鐘偏差，並且同一步的碼只認一次。
4. **它怎麼普及的？** Google 兩步驗證和身份驗證器把它帶給了普通使用者，`otpauth://` 二維碼讓任何網站都能接入，也讓“SHA1 + 6 位 + 30 秒”成了事實標準。
5. **掃碼匯入的是證書嗎？** 不是。那是兩端共享的對稱金鑰，兩端都可能洩露；證書背後是非對稱密碼，私鑰只有一份，公開的是公鑰加上 CA 的簽名，U盾 和 HTTPS 的小鎖用的都是它。
6. **HOTP 為什麼不用非對稱？** 因為中間是一個靠人抄寫的六位數通道。HMAC 的驗證是重算，可以截斷成 6 位；簽名的驗證要檢查完整的 64 位元組，截斷就沒法驗證。再加上 2005 年令牌硬體的限制，RFC 4226 從一開始就選了共享金鑰。
7. **平臺不持有秘密要怎麼做到？** 去掉中間的人，讓裝置和瀏覽器直接傳簽名，這就是 FIDO U2F、WebAuthn 和 passkey：私鑰只在使用者裝置裡，平臺只存公鑰。
8. **為什麼有證書還擋不住釣魚？** 證書證明“對面是哪個域名”，六位數證明“我手裡有金鑰”，中間缺了“我把碼交給了哪個域名”。WebAuthn 把瀏覽器經證書驗證過的域名籤進簽名裡，補上了這一環。

## 參考資料
