斷網也對得上的六位數 左邊一部開著飛航模式的手機顯示六位數 492 039 和 30 秒倒計時圈;中間的網路訊號被劃掉,只靠同一把金鑰和同一個時鐘;右邊伺服器螢幕上亮著同樣的六格數字並打勾。 Google 492 039 17s

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

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

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

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

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


一、一次性密碼的由來

現象:密碼一旦洩露,就能被無限次使用

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

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

第一次嘗試:Lamport 的雜湊鏈

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

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

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

第二次嘗試:硬體令牌

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

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

解決:一個開放的標準

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

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

一次性密碼的幾代方案和它們留下的問題 1981 年 Lamport 雜湊鏈,鏈會用完;1980 年代後期 RSA SecurID,演算法和種子在廠商手裡;2005 年 HOTP,計數器會失步;2011 年 TOTP 和 Google 兩步驗證,換機會丟碼、碼可以被釣魚轉發;2014 年 FIDO U2F 安全金鑰,需要額外硬體;2022 到 2023 年 passkey 成為 Google 預設登入方式。 一次性密碼的幾代 · 每一代留下的問題 1981 Lamport 雜湊鏈 → S/KEY 鏈會用完,需要重新初始化 1980s RSA SecurID 硬體令牌 演算法專有,種子握在廠商手裡 2005 HOTP · RFC 4226(OATH) 計數器會失步 2011 TOTP · RFC 6238,Google 兩步驗證 換機丟碼;碼可以被釣魚站轉發 2014 FIDO U2F 安全金鑰 需要額外的硬體 2023 Passkey 成為 Google 預設登入 同步的公鑰憑據,見第七節
圖 1 · 每一代一次性密碼都在補上一代的缺口,Google 身份驗證器用的是中間那一段:HOTP 和 TOTP

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

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

分析:兩邊共享什麼

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

h=HMAC-SHA1(K,C)h = \text{HMAC-SHA1}(K, C) HOTP(K,C)=Truncate(h) mod 106\text{HOTP}(K, C) = \text{Truncate}(h) \bmod 10^{6}

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

為什麼用 HMAC?因為它有兩個性質正好對口:沒有 KK 的人算不出下一個值;而看到再多個歷史碼,也推不回 KK。這正是一次性密碼需要的。順帶一提,HMAC 的安全性並不依賴 SHA-1 的抗碰撞性,所以 SHA-1 在 2017 年被找到碰撞,並不影響 HOTP5。

現象: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. 對 10610^6 取餘,不足 6 位前面補 0。

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

HOTP 的動態截斷 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。 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 0 11 14 19 從第 11 位元組起取 4 個 低 4 位 b = 11 動態截斷 RFC 4226 §5.3 1 · 最後一位元組 & 0x0f 0xab & 0x0f = 11 2 · 取第 11–14 位元組 0xc1397eea 3 · & 0x7fffffff,清掉最高位 0x41397eea = 1094287082 4 · mod 10^6 287082 取哪 4 個位元組由 HMAC 自己決定,每個計數器值取的位置都不一樣。
圖 2 · 金鑰 12345678901234567890、計數器 1:HMAC 的最後半個位元組指定從哪裡取,最終得到 287082

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

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

新問題:計數器失步

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

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

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

三、TOTP:用時間代替計數器

解決:時間本身就是計數器

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

T=⌊tunix30⌋T = \left\lfloor \frac{t_{\text{unix}}}{30} \right\rfloor TOTP(K)=HOTP(K,T)\text{TOTP}(K) = \text{HOTP}(K, T)

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

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

為什麼是 30 秒

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

現象:手機時鐘慢了幾秒

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

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

TOTP 的時間步和驗證視窗 以測試金鑰為例,時間步 41152262、41152263、41152264 分別覆蓋 Unix 時間 1234567860 起、1234567890 起和 1234567920 起的 30 秒,對應的六位碼是 980357、005924、590587。手機時鐘慢 5 秒,在 1234567885 算出 980357;伺服器此刻是 1234567890,當前步是 41152263,它同時檢查上一步,所以仍然接受 980357。 T = floor(t / 30) · 金鑰 12345678901234567890 手機 1234567885 伺服器 1234567890 T − 1 = 41152262 t: 1234567860 … 889 碼 980357 T = 41152263(當前) t: 1234567890 … 919 碼 005924 T + 1 = 41152264 t: 1234567920 … 949 碼 590587 伺服器檢查 T − 1 和 T,980357 落在視窗裡 T + 1:有的服務也接受 手機慢了 5 秒,算出的是上一步的碼;伺服器往回多查一步,就對上了。
圖 3 · Unix 時間 1234567890(2009-02-13 23:31:30 UTC)前後的三個時間步:驗證視窗吸收了幾秒的時鐘偏差

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

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

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

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

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

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

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

那張二維碼裡裝了什麼

現在可以回頭看開頭那行字了。掃碼那一步,其實就是把共享金鑰 KK 交給手機,二維碼裡編碼的就是那一行文字,格式是 Google 定義的 Key URI10:

otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
  • totp 表示基於時間(換成 hotp 就是計數器模式,此時還要帶 counter 引數);
  • Example:alice@google.com 是顯示在 App 裡的名字;
  • secret 就是金鑰 KK,用 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 等替代品1112。但由於格式是公開的,換 App 不影響任何網站。

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

五、共享金鑰的代價

現象:換了手機,碼全沒了

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

嘗試:匯出與雲同步

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

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

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

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

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

分析:對稱和非對稱

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

  • TOTP 是對稱的。 手機和伺服器拿著同一串位元組,能驗證碼的一方,也一定能生成碼。所以 KK 必須對所有其他人保密。
  • 證書背後是非對稱密碼。 一對金鑰裡,私鑰只在持有者手裡,公鑰可以公開給任何人。私鑰用來簽名,公鑰用來驗證;能驗證簽名的人,造不出簽名。
  • 證書本身,就是把一把公鑰、“這把公鑰屬於誰”和一個有效期寫在一起,再由一個上級機構(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 這把公鑰對應的私鑰。

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

TOTP 共享金鑰與數字證書的對照 左邊是 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 驗證這條鏈。 TOTP · 對稱:兩邊是同一個秘密 證書 · 非對稱:私鑰不出門 手機 · 身份驗證器 secret GEZDGNBV…QOJQ 演算法 HMAC-SHA1 · 6 位 · 30 秒 掃碼時整份複製一次 伺服器 · 賬號資料庫 secret GEZDGNBV…QOJQ last_T 41152263 兩邊是同一串位元組: 任何一邊洩露,別人都能算出碼。 證書 公開 Subject accounts.google.com Issuer Google Trust Services WR2 有效期 2026-09-02 → 2026-11-25 公鑰 隨證書發給每一個訪客 簽名 由 WR2 的私鑰做出 私鑰 · 只在 Google 的伺服器上 瀏覽器內建根證書 · GTS Root R1 證書可以公開,拿到它也冒充不了網站。
圖 4 · TOTP 的秘密兩邊各存一份;證書的秘密只有一份,公開出去的是能驗證它的那一半

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

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

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

問題:平臺也握著金鑰

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

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

第一次嘗試:把 HMAC 換成簽名

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

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

  • HMAC 能截斷,是因為驗證就是重算。 伺服器有 KK,自己能算出完整的 20 位元組,按同樣的規則截出 6 位,比較一下就行。
  • 簽名不能截斷,是因為驗證不是重算。 只有公鑰的一方算不出簽名,算得出就等於能偽造了。它只能拿到完整的簽名,用公鑰去檢查一個等式是否成立。少了任何一個位元組,這個等式都沒法檢查。
HMAC 可以截斷成 6 位,簽名不能 左邊:令牌算 HMAC-SHA1(K, 1) 得到 20 位元組 75a48a19…7a2d33ab,截斷為 287082,使用者抄寫 6 位;伺服器用同一個 K 重算出同樣的 20 位元組和 287082,比較相等。右邊:令牌用 Ed25519 私鑰對計數器 1 簽名,得到 64 位元組 4d835fad 83ee9486 b7a2cb9a…,十進位制 154 位、Base64 88 個字元;如果截成 6 位,伺服器只有公鑰 1157567e…799d8e,算不出簽名,也就無法比較,驗證做不成。 對稱 · 驗證就是重算 非對稱 · 驗證要完整簽名 令牌 · HMAC-SHA1(K, 1) 20 位元組 75a48a19…7a2d33ab 截斷 287082 人抄寫 6 位 伺服器 · HMAC-SHA1(K, 1) 重算 75a48a19…7a2d33ab → 287082 比較 287082 = 287082,透過 伺服器有同一個 K, 能自己算出完整結果,只比較 6 位就夠了。 令牌 · Ed25519 Sign(sk, 1) 簽名 4d835fad 83ee9486 b7a2cb9a … 長度 64 位元組 · 十進位制 154 位 Base64 88 個字元 截成 6 位? 伺服器 · Verify(pk, 1, sig) 公鑰 1157567e…ed8799d8e 結果 只收到 6 位數字,無法驗證 虛線框:截斷以後,這一步做不成。 公鑰算不出簽名,只能檢查完整的簽名。
圖 5 · 同樣是計數器 1:HMAC 的結果伺服器能自己重算,截成 6 位照樣能比;簽名只能整段檢查,截斷就沒法驗證

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

原因:當年的設計約束

RFC 4226 開頭列出了六條設計要求6,幾乎每一條都指向同一個方向:

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

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

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

有意思的是,第一節的 Lamport 雜湊鏈其實做到了這個性質:伺服器只存鏈的末端 Hn(s)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 上根本不會被調出來。
同一次釣魚,對 TOTP 碼和 passkey 簽名的結果 上半部分:釣魚站 g00gle-login.example 持有籤給自己域名的合法證書,使用者在那裡輸入六位碼 287082,釣魚站原樣轉發給 accounts.google.com,碼有效,攻擊者登入成功。下半部分:使用 passkey 時,瀏覽器簽名的 clientDataJSON 裡 origin 是 https://g00gle-login.example,真站發現 origin 不符而拒絕;實際上瀏覽器根本不會在釣魚站調出 google.com 的憑據。 TOTP:碼裡沒有域名 使用者 · 身份驗證器 釣魚站 g00gle-login.example accounts.google.com 輸入 287082 原樣轉發 有小鎖:證書籤給它自己的域名 碼有效,攻擊者登入 Passkey / WebAuthn:瀏覽器把域名籤進去 使用者 · 瀏覽器 釣魚站 g00gle-login.example accounts.google.com 簽名 轉發簽名 clientDataJSON · 被簽名的內容 由瀏覽器填寫 challenge 伺服器給的隨機數 origin https://g00gle-login.example origin 不符,拒絕 而且瀏覽器根本不會 在這裡調出 google.com 的憑據。
圖 6 · 釣魚站也有合法證書:TOTP 碼原樣轉發就能用,passkey 的簽名裡卻帶著證書驗證過的釣魚站域名

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

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

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

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

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 讓兩邊共享金鑰 KK 和計數器 CC,算 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 把瀏覽器經證書驗證過的域名籤進簽名裡,補上了這一環。

參考資料

參考資料

  1. Password authentication with insecure communicationLeslie Lamport · Communications of the ACM · 198124(11) ↩

  2. RFC 1760: The S/KEY One-Time Password SystemN. Haller · 1995收藏rfc-editor.orghttps://www.rfc-editor.org/rfc/rfc1760 ↩

  3. RFC 2289: A One-Time Password SystemN. Haller et al. · 1998收藏rfc-editor.orghttps://www.rfc-editor.org/rfc/rfc2289 ↩

  4. RSA SecurIDWikipedia收藏en.wikipedia.orgRSA 2011 年入侵事件及洛克希德·馬丁攻擊的報道,見該條目及其引用的新聞。https://en.wikipedia.org/wiki/RSA_SecurID ↩

  5. Keying hash functions for message authenticationM. Bellare, R. Canetti, H. Krawczyk · CRYPTO · 1996另見 M. Bellare. New proofs for NMAC and HMAC: security without collision-resistance. CRYPTO 2006. ↩

  6. RFC 4226: HOTP: An HMAC-Based One-Time Password AlgorithmD. M'Raihi, M. Bellare, F. Hoornaert, D. Naccache, O. Ranen · 2005收藏rfc-editor.org動態截斷見 §5.3,限速見 §7.3,測試向量見附錄 D。https://www.rfc-editor.org/rfc/rfc4226 ↩ ↩2 ↩3

  7. RFC 6238: TOTP: Time-Based One-Time Password AlgorithmD. M'Raihi, S. Machani, M. Pei, J. Rydell · 2011收藏rfc-editor.org時間步見 §4、§5.2,重放見 §5.2,測試向量見附錄 B。https://www.rfc-editor.org/rfc/rfc6238 ↩ ↩2 ↩3 ↩4

  8. Advanced sign-in security for your Google accountGoogle Security Blog · 2011-02-10收藏security.googleblog.com另見 Dark Reading. Google Bolsters Apps With Two-Step Verification, 2010-09-20.https://security.googleblog.com/2011/02/advanced-sign-in-security-for-your.html ↩

  9. google/google-authenticator-libpam收藏github.comhttps://github.com/google/google-authenticator-libpam ↩

  10. Key Uri Formatgoogle-authenticator wiki收藏github.comhttps://github.com/google/google-authenticator/wiki/Key-Uri-Format ↩

  11. FreeOTP multi-factor authenticationLWN · 2014收藏lwn.nethttps://lwn.net/Articles/581086/ ↩

  12. Google AuthenticatorWikipedia收藏en.wikipedia.orghttps://en.wikipedia.org/wiki/Google_Authenticator ↩

  13. Google will add end-to-end encryption to Google AuthenticatorBleepingComputer · 2023-04收藏bleepingcomputer.comhttps://www.bleepingcomputer.com/news/google/google-will-add-end-to-end-encryption-to-google-authenticator ↩

  14. accounts.google.comcrt.sh · 2026-10-16crt.sh ↗證書透明日誌查詢,2026-10-16 查詢。文中引用的是 Not Before 為 2026-09-02、簽發者為 C=US, O=Google Trust Services, CN=WR2 的那張。https://crt.sh/?q=accounts.google.com ↩ ↩2

  15. Web Authentication: An API for accessing Public Key CredentialsW3C收藏w3.orgattestation 相關章節;另見 FIDO Alliance, FIDO Security Reference(關於認證證書由至少 10 萬臺裝置共用的建議)。https://www.w3.org/TR/webauthn-2/ ↩

  16. Google: Security Keys Neutralized Employee PhishingKrebs on Security · 2018-07-23 ↩

  17. Passkeys 相關公告Google Blog · 2023-05-03 与 2023-10-10 ↩