開啟兩步驗證時,網站會給出一張二維碼。用 Google 身份驗證器(Google Authenticator)掃一下,App 裡就多了一行六位數,每 30 秒換一個。掃碼的那一刻,手機拿到的其實只是一行字:
otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example從這以後,手機再也沒有和伺服器說過話。切到飛航模式,數字照樣在變;可我們把它填進登入框,伺服器就是認。
這串 secret 到底是什麼?它是不是一張證書,和銀行發的 U盾 是一個路數嗎?手機和伺服器各算各的,憑什麼能對上?
帶著這幾個問題,本文從這行字開始,先看六位數是怎麼從它算出來的。等算清楚了,會發現一個讓人不太放心的地方,它和 U盾 的差別,也正藏在那裡。
一、一次性密碼的由來
現象:密碼一旦洩露,就能被無限次使用
普通密碼的問題在於它是靜態的。只要被人看到一次,無論是在網路上被竊聽、被鍵盤記錄器記下,還是從別的網站的洩露庫裡撞出來,對方就可以在任何時候、任何地點反覆使用它。伺服器分不清這次輸入是我們本人,還是一份錄下來的副本。
所以很早就有人想:能不能讓每次登入用的密碼都不一樣,用過一次就作廢? 這樣即使被看到,也只是一張過期的票。
第一次嘗試:Lamport 的雜湊鏈
1981 年,Leslie Lamport 提出了一個很漂亮的辦法1:拿一個秘密 反覆做雜湊,得到一條鏈 。伺服器只儲存鏈的最末端 。第一次登入時,使用者出示 ,伺服器把它再雜湊一次,看是否等於自己存的值;對上了就放行,並把存的值換成 。下一次使用者出示 ,依此類推。
這個方案的好處是伺服器只存一個“往後推”的值,就算伺服器被攻破,攻擊者也算不出下一個密碼,因為雜湊不能倒推。貝爾實驗室後來把它做成了 S/KEY,並在 1995 年寫成 RFC 1760,再演化成 RFC 2289 的 OTP 標準23。
它的不足在於鏈是有限的:用完 次就得重新初始化。而且每次要算出第 個值,要麼事先列印一張密碼錶隨身帶著,要麼在終端上輸入口令現場計算,都談不上方便。
第二次嘗試:硬體令牌
另一條路是 RSA 的 SecurID,從 1980 年代後期開始賣。它是一個鑰匙扣大小的硬體,螢幕上的數字每 60 秒變一次,看上去已經和今天的 App 很像了。
它的不足是封閉:演算法是專有的,令牌裡的種子(seed)由廠商生成,廠商自己也保留一份。企業要買硬體、買授權,還得信任廠商永遠不出事。2011 年 3 月,RSA 公司被入侵,與 SecurID 種子相關的資料被竊取,隨後美國軍工企業洛克希德·馬丁遭到了利用這批資料的攻擊,RSA 不得不為客戶更換大批令牌4。“種子在別人手裡”從理論風險變成了真實事故。
解決:一個開放的標準
到了 2004 年,VeriSign 等公司發起了 OATH(Initiative for Open Authentication),目標是做一個任何人都能免費實現的一次性密碼演算法:不靠專有硬體,不靠廠商保管種子,使用者和伺服器之間直接共享一個金鑰就行。它的第一個成果就是 2005 年釋出的 HOTP。
下面這張圖把這幾代方案和它們各自留下的問題串在一起,後面的每一節,基本上都是在回答圖右邊那一列。
那麼 HOTP 是怎麼做到“兩邊不通訊,卻算出同一個數”的?
二、HOTP:從金鑰和計數器到六位數字
分析:兩邊共享什麼
HOTP 的思路很直接。使用者的令牌和伺服器在一開始共享兩樣東西:一個金鑰 (通常 20 位元組隨機數),和一個計數器 (初始為 0)。每次需要密碼時,就用 對 算一個 HMAC-SHA1:
其中 編碼成 8 位元組大端整數。算完一次,兩邊的計數器各加一。
為什麼用 HMAC?因為它有兩個性質正好對口:沒有 的人算不出下一個值;而看到再多個歷史碼,也推不回 。這正是一次性密碼需要的。順帶一提,HMAC 的安全性並不依賴 SHA-1 的抗碰撞性,所以 SHA-1 在 2017 年被找到碰撞,並不影響 HOTP5。
現象:20 個位元組,怎麼變成 6 位數?
HMAC-SHA1 的輸出是 20 位元組、160 位。人沒法輸入 160 位,只能輸入幾位十進位制數字。那該從這 20 個位元組裡取哪一部分?
最直覺的辦法是取前 4 個位元組,轉成整數再取後 6 位。這樣做沒錯,但 RFC 4226 選了一個更巧的辦法,叫動態截斷(dynamic truncation):讓 HMAC 的最後一個位元組來決定從哪裡取。具體是:
- 取最後一個位元組的低 4 位,得到一個 0 到 15 的偏移量
offset; - 從第
offset個位元組開始連取 4 個位元組,拼成一個 32 位整數; - 把最高位清零,只留 31 位;
- 對 取餘,不足 6 位前面補 0。
下圖用 RFC 的測試金鑰 12345678901234567890(20 個 ASCII 字元)和計數器 走一遍,每一個位元組都是真實算出來的。
為什麼要清掉最高位? 不同語言對 32 位整數的處理不一樣,有的當有符號數,有的當無符號數。把最高位清零以後,結果在任何實現裡都是同一個非負整數,避免了 Java 和 C 算出不一樣的碼。
六位數夠安全嗎? 一次猜中的機率是百萬分之一。單看這個數字不算大,所以 RFC 4226 明確要求伺服器做限速:連續輸錯幾次就延遲或鎖定6。六位數的安全性,有一半是靠伺服器的限速來保證的。
新問題:計數器失步
HOTP 的令牌每按一次按鈕,計數器就加一。可是使用者按了按鈕卻沒去登入,或者小孩拿著令牌按著玩,令牌的計數器就會跑到伺服器前面去。
RFC 的辦法是讓伺服器往前多試幾個:不只比較 ,而是依次比較 ,在這個“前瞻視窗”裡對上了,就把伺服器的計數器直接推進到那個位置。超出視窗,就得讓使用者連續輸入兩個碼重新同步。
這能用,但不優雅:兩邊都得儲存並維護一個會變化的狀態。能不能找一個兩邊不用商量、天然就一致的計數器?
三、TOTP:用時間代替計數器
解決:時間本身就是計數器
答案是時間。手機和伺服器各自都有時鐘,只要都對準了世界時間,就不需要再同步任何東西。TOTP 把 Unix 時間(從 1970 年 1 月 1 日起的秒數)按 30 秒一段切開,用段號代替 HOTP 的計數器:
演算法的其餘部分和 HOTP 一模一樣。一個有趣的驗證是: 秒時,,所以 TOTP 在第 59 秒的 8 位碼 94287082,正好就是圖 2 裡 那個 31 位整數 1094287082 的後 8 位。RFC 6238 附錄裡的第一個測試向量就是它7。
這也回答了開頭的問題:飛航模式下照樣出碼,是因為算碼只需要兩樣東西,一個掃碼時就存進手機的金鑰,一個手機本來就有的時鐘。 伺服器那邊拿著同一個金鑰、看著同一個時間,自然算出同一個數。
為什麼是 30 秒
時間步越短,被偷看到的碼越快作廢,但使用者可能還沒輸完就過期了;越長則反過來。RFC 6238 推薦 30 秒,原文的說法就是“在安全性和可用性之間取一個平衡”7。
現象:手機時鐘慢了幾秒
時間當計數器,就帶來了新的問題:兩邊的時鐘不可能分毫不差,網路傳輸也要時間。使用者在一個時間步的最後一秒看到碼,等它提交到伺服器,伺服器那邊已經是下一步了。
RFC 6238 的建議是,伺服器除了當前步 ,最多再往回多查一步 ,用來容忍網路延遲;也可以記錄每個客戶端的時鐘偏差,下次自動校正7。下圖是一個具體的例子。
視窗也帶來一個副作用:同一個碼在 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。
那張二維碼裡裝了什麼
現在可以回頭看開頭那行字了。掃碼那一步,其實就是把共享金鑰 交給手機,二維碼裡編碼的就是那一行文字,格式是 Google 定義的 Key URI10:
otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Exampletotp表示基於時間(換成hotp就是計數器模式,此時還要帶counter引數);Example:alice@google.com是顯示在 App 裡的名字;secret就是金鑰 ,用 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 是對稱的:手機和伺服器手裡是同一個金鑰 。早期的 Google 身份驗證器把金鑰只存在手機本地,不備份。手機丟了、摔壞了、或者換新手機前忘了遷移,所有賬號的第二步就都進不去了,只能靠開啟時儲存的恢復碼,或者走各個網站的人工申訴。
嘗試:匯出與雲同步
Google 先在 2020 年加了“轉移賬號”功能,在舊手機上生成二維碼,用新手機掃。這個二維碼的內容是 otpauth-migration://offline?data=...,裡面是一串沒有加密的 protobuf,裝著所有賬號的金鑰。
2023 年 4 月,App 又加入了隨 Google 賬號雲同步。這解決了丟碼的問題,卻引出了一個新問題:安全研究者 Mysk 抓包發現,同步的資料不是端到端加密的,Google 的伺服器能讀到所有金鑰。換句話說,一旦 Google 賬號本身被盜,攻擊者就能拿到這個賬號保護的所有其他賬號的第二因素。Google 隨後表示會加入端到端加密13。
這件事背後是 TOTP 的一個根本性質:金鑰必須同時存在於兩端,所以無論放在哪裡,都多了一個可能洩露的地方。 伺服器那邊也一樣,網站的資料庫要是洩露了 TOTP 金鑰,攻擊者就能為所有使用者生成正確的碼,所以金鑰必須加密存放,不能像密碼那樣只存一個雜湊。
兩端共享同一個秘密,這是對稱密碼的天性。說到這裡,常有人問:掃碼匯入的這串金鑰,有的 App 和教程管它叫“令牌證書”;網銀的 U盾 也叫“數字證書”;瀏覽器位址列的小鎖背後,同樣是一張“證書”。它們是一回事嗎?
六、數字證書:另一種信任方式
分析:對稱和非對稱
不是一回事。二維碼裡裝的不是證書,而是前面一直在說的共享金鑰 。兩者的區別,在於雙方手裡各有什麼:
- TOTP 是對稱的。 手機和伺服器拿著同一串位元組,能驗證碼的一方,也一定能生成碼。所以 必須對所有其他人保密。
- 證書背後是非對稱密碼。 一對金鑰裡,私鑰只在持有者手裡,公鑰可以公開給任何人。私鑰用來簽名,公鑰用來驗證;能驗證簽名的人,造不出簽名。
- 證書本身,就是把一把公鑰、“這把公鑰屬於誰”和一個有效期寫在一起,再由一個上級機構(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 這把公鑰對應的私鑰。
下圖把兩種方式並排放在一起,標出了每一邊手裡的秘密。
所以二維碼裡裝的不是證書。 它沒有公鑰,不需要 CA,也絕不能公開。U盾 才是真正的證書:裡面存著使用者自己的私鑰,以及由 CFCA 等機構簽發的證書,轉賬時用私鑰對交易內容簽名。銀行同時代發的另一種小硬體“動態令牌”,一按就顯示六位數,那才是一次性密碼,和 Google 身份驗證器是同一類東西。
不過,把兩者放在一起,也引出了一個更尖銳的問題。
七、HOTP 為什麼不用非對稱密碼
問題:平臺也握著金鑰
回頭看圖 4 左邊,會發現一個讓人不安的事實:伺服器手裡也有一份 。網站資料庫一旦被攻破,攻擊者就能為每個使用者算出正確的碼,第二因素等於沒有。RFC 4226 自己也承認這一點,專門要求伺服器把共享金鑰放在隔離的安全區域,用防篡改硬體加密儲存,只在驗證的那一刻解密6。但這只是把 藏得更深, 仍然在平臺上。
照著證書的思路,似乎有一個更好的設計:私鑰只留在使用者的令牌裡,平臺只存公鑰,公鑰洩露了也無所謂。HOTP 為什麼不這麼做?
第一次嘗試:把 HMAC 換成簽名
設想令牌不再算 HMAC,而是用私鑰對計數器簽名,伺服器用公鑰驗證。問題馬上出現:簽名太長了。用 Ed25519 這種目前最短的常用簽名演算法之一,對計數器 1 簽名,得到 64 位元組;寫成十進位制有 154 位,寫成 Base64 也要 88 個字元,沒有人能把它抄進登入框。
那麼像 HOTP 一樣,截斷成 6 位行不行?不行,這正是關鍵所在。
- HMAC 能截斷,是因為驗證就是重算。 伺服器有 ,自己能算出完整的 20 位元組,按同樣的規則截出 6 位,比較一下就行。
- 簽名不能截斷,是因為驗證不是重算。 只有公鑰的一方算不出簽名,算得出就等於能偽造了。它只能拿到完整的簽名,用公鑰去檢查一個等式是否成立。少了任何一個位元組,這個等式都沒法檢查。
所以,“人看一眼、手抄六位數”這個通道,本身就把非對稱排除掉了。 只要中間是人,驗證的一方就必須能自己重算出這幾位數,也就必須擁有和令牌一樣的秘密。
原因:當年的設計約束
RFC 4226 開頭列出了六條設計要求6,幾乎每一條都指向同一個方向:
- 演算法應該能便宜地做進硬體,儘量少佔電池、按鍵、處理能力和螢幕(R2);
- 令牌可以沒有任何輸入鍵(R3),所以伺服器沒法先發一個挑戰給它;
- 顯示的值必須容易讀、容易輸入,至少 6 位,最好全是數字(R4);
- 使用一個足夠強的共享金鑰,至少 128 位(R6)。
R6 直接寫著 shared secret,可見對稱是設計的起點,而不是疏忽。當年的目標裝置是鑰匙扣令牌、智慧卡和 SIM 卡。這類晶片算一次 HMAC-SHA1 幾乎不費電,做一次公鑰簽名則慢得多,也貴得多。這一點是推斷,RFC 沒有給出具體數字。
曾經做到過的“平臺不持有秘密”
有意思的是,第一節的 Lamport 雜湊鏈其實做到了這個性質:伺服器只存鏈的末端 ,資料庫洩露了,攻擊者也算不出下一個密碼,因為雜湊不能倒推。它靠的不是公鑰,而是單向函式;驗證依然是“重算一次雜湊再比較”,所以能讓人輸入。代價是鏈會用完,而且每個值有 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上根本不會被調出來。
到這裡,證書和使用者憑據終於在同一個簽名裡會合了:證書負責讓瀏覽器確認“對面是哪個域名”,使用者的私鑰負責證明“我是我”,而簽名把這兩件事綁在一起,釣魚站拿走簽名也用不了。
有意思的是,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:
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 942870821111111109 070818041111111111 140504711234567890 890059242000000000 6927903720000000000 65353130最後一行 20000000000 是公元 2603 年,RFC 專門放進這個測試向量,是為了提醒實現者時間步要用 64 位整數,別在 2038 年溢位。
想和手機對一下,可以把金鑰的 Base32 形式 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ 手動加進身份驗證器,再執行下面這一行,兩邊會顯示同一個數:
print(totp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"))小結
回到開頭那個飛航模式下還在跳的六位數,它背後的問題鏈是這樣的:
- 為什麼需要一次性密碼? 靜態密碼洩露一次就能被無限重放。Lamport 雜湊鏈和 SecurID 都試過解決,但一個會用完,一個把種子握在廠商手裡。
- 兩邊不通訊,怎麼算出同一個數? HOTP 讓兩邊共享金鑰 和計數器 ,算 HMAC-SHA1,再用動態截斷從 20 個位元組裡取出 6 位數。
- 計數器失步怎麼辦? TOTP 用 30 秒一段的時間代替計數器,兩邊只要時鐘對準就行;伺服器多查一步來吸收時鐘偏差,並且同一步的碼只認一次。
- 它怎麼普及的? Google 兩步驗證和身份驗證器把它帶給了普通使用者,
otpauth://二維碼讓任何網站都能接入,也讓“SHA1 + 6 位 + 30 秒”成了事實標準。 - 掃碼匯入的是證書嗎? 不是。那是兩端共享的對稱金鑰,兩端都可能洩露;證書背後是非對稱密碼,私鑰只有一份,公開的是公鑰加上 CA 的簽名,U盾 和 HTTPS 的小鎖用的都是它。
- HOTP 為什麼不用非對稱? 因為中間是一個靠人抄寫的六位數通道。HMAC 的驗證是重算,可以截斷成 6 位;簽名的驗證要檢查完整的 64 位元組,截斷就沒法驗證。再加上 2005 年令牌硬體的限制,RFC 4226 從一開始就選了共享金鑰。
- 平臺不持有秘密要怎麼做到? 去掉中間的人,讓裝置和瀏覽器直接傳簽名,這就是 FIDO U2F、WebAuthn 和 passkey:私鑰只在使用者裝置裡,平臺只存公鑰。
- 為什麼有證書還擋不住釣魚? 證書證明“對面是哪個域名”,六位數證明“我手裡有金鑰”,中間缺了“我把碼交給了哪個域名”。WebAuthn 把瀏覽器經證書驗證過的域名籤進簽名裡,補上了這一環。
參考資料
參考資料
-
Password authentication with insecure communication24(11) ↩
-
RFC 1760: The S/KEY One-Time Password System收藏rfc-editor.orghttps://www.rfc-editor.org/rfc/rfc1760 ↩
-
RFC 2289: A One-Time Password System收藏rfc-editor.orghttps://www.rfc-editor.org/rfc/rfc2289 ↩
-
RSA SecurID收藏en.wikipedia.orgRSA 2011 年入侵事件及洛克希德·馬丁攻擊的報道,見該條目及其引用的新聞。https://en.wikipedia.org/wiki/RSA_SecurID ↩
-
Keying hash functions for message authentication另見 M. Bellare. New proofs for NMAC and HMAC: security without collision-resistance. CRYPTO 2006. ↩
-
RFC 4226: HOTP: An HMAC-Based One-Time Password Algorithm收藏rfc-editor.org動態截斷見 §5.3,限速見 §7.3,測試向量見附錄 D。https://www.rfc-editor.org/rfc/rfc4226 ↩ ↩2 ↩3
-
RFC 6238: TOTP: Time-Based One-Time Password Algorithm收藏rfc-editor.org時間步見 §4、§5.2,重放見 §5.2,測試向量見附錄 B。https://www.rfc-editor.org/rfc/rfc6238 ↩ ↩2 ↩3 ↩4
-
Advanced sign-in security for your Google account收藏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 ↩
-
google/google-authenticator-libpam收藏github.comhttps://github.com/google/google-authenticator-libpam ↩
-
Key Uri Format收藏github.comhttps://github.com/google/google-authenticator/wiki/Key-Uri-Format ↩
-
FreeOTP multi-factor authentication收藏lwn.nethttps://lwn.net/Articles/581086/ ↩
-
Google Authenticator收藏en.wikipedia.orghttps://en.wikipedia.org/wiki/Google_Authenticator ↩
-
Google will add end-to-end encryption to Google Authenticator收藏bleepingcomputer.comhttps://www.bleepingcomputer.com/news/google/google-will-add-end-to-end-encryption-to-google-authenticator ↩
-
accounts.google.comcrt.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 -
Web Authentication: An API for accessing Public Key Credentials收藏w3.orgattestation 相關章節;另見 FIDO Alliance, FIDO Security Reference(關於認證證書由至少 10 萬臺裝置共用的建議)。https://www.w3.org/TR/webauthn-2/ ↩
-
Google: Security Keys Neutralized Employee Phishing ↩
-
Passkeys 相關公告 ↩