一文搞懂 OTP 雙因素認證

小得盈滿發表於2023-09-18

GitHub 在 2023 年 3 月推出了雙因素認證(two-factor authentication)簡稱 2FA,並且承諾所有在 GitHub 上貢獻的開發者在 2023 年底前啟用雙因素認證。因此最近在訪問 GitHub 時如果注意的話經常會看到提示讓在 2023 年 10 月 12 日之前開啟,否則可能影響賬戶的使用:

如果之前沒了解過這個概念,我們在開啟之前不禁會想啥是雙因素認證呢?如果你也有同樣的疑問,那麼耐心看完本文就能比較好的理解原理和使用了。so,讓我們開始吧。

1.什麼是雙因素認證(2FA)

回想一下我們通常登入網站的時候通常輸入使用者名稱、密碼就可以登入了,也就是說網站是透過使用者名稱和密碼來確認你的身份,假如其他人或者駭客透過一些手段獲取到你的密碼,那麼他們就可以冒充你直接登入網站獲取和你同等的服務,而網站對這一切並不知情,所以這個時候就需要藉助於使用者名稱和密碼之外的其他方式來驗證你的身份,這種例子也非常多。

例如我們首次登入支付寶、微信這類應用時需要填寫手機驗證碼。早期在銀行支付時需要使用銀行下發給你的 U 盾或動態口令牌來確保只有你自己才可以支付,直到現在的指紋、刷臉支付其實都是在透過你持有的裝置或你本身具有的生理特徵來確保你就是你。

所以,所謂雙因素認證(2FA)就是在使用者名稱和密碼認證之外再新增一種確認身份的方式,加強對賬戶的保護,至於具體的方式就有非常多了。包括我們上面說的最常用的簡訊驗證碼、指紋、刷臉,還有硬體方式的 U 盾、口令牌等,還有我們今天要說的 OTP (One-Time Password),也就是一次性密碼的意思。上面這些方式都屬於雙因素認證,所以這個概念聽起來很牛,其實是比較好理解的。

2.什麼是 OTP

上面說了 OTP 全稱 One-Time Password,也就是一次性密碼,那麼下面我們介紹下 OTP 的實現原理。

OTP 的實現主要有兩種:

  1. HOTP(HMAC-Based One-Time Password Algorithm):基於 HMAC 的一次性密碼。
  2. TOTP(Time-Based One-Time Password Algorithm):基於時間戳的一次性密碼。

其中 HOTP 可以參考 RFC4226,TOTP 可以參考 RFC6238

HOTP 出現的比較早,是基於 HMAC 實現的,其實 HMAC 就是 Hash + 訊息或者說是 Hash + 鹽(salt)來實現的,其中 Hash 函式通常採用 MD5、SHA 系列(SHA1/SHA256/SHA512 等)的單向訊息摘要演算法來實現。在 HOTP 中,Hash 函式採用 SHA1,在訊息或者鹽值部分放的是一個計數器,也就是一個大小為 8 位元組的數字,主要的生成公式如下:

\[HOTP = Truncate(HMAC_{SHA1}(key, counter)) \]

其中,key 表示對訊息加密的金鑰,counter 就是計數器的值。然後兩者透過基於 SHA1 的 HMAC 演算法計算出結果,所以結果長度是 20 位元組,如果用 16 進製表示長度就是 40。由於結果太長了顯然不利於使用者輸入,因此透過 Truncate 函式進行處理,處理成 6-8 位的數字,就和簡訊驗證碼一樣,這樣使用者就可以很快的輸入並進行驗證。

上面只是計算 HOTP 結果主要的概括,至於具體的細節,例如 key 是如何編碼的、counter 是怎麼處理的以及至關重要的 Truncate 又是怎麼工作的,這些下面會將,目前先不用關心,我們先有個整體的認識就可以了,如何計算並不影響具體的互動方式。

首先認證之前客戶端和服務端都需要約定相同的 key 也就是金鑰,而且這個金鑰決不能洩露,否則也就是失去了 2FA 的意義,同時客戶端和服務端也需要從相同的計數器值開始輪轉,比如都從 1 開始,要增加一塊增加,必須保證一致。也就是說 key 和 counter 必須都對起來,否則會驗證失敗,這個過程可以總結如下:

其實這個過程我們可以看出來一個比較明顯的問題,就是計數器值需要保持一致。假如起始的計數器值是同步的,每次服務端需要使用者輸入的時候都自增一次,這個時候客戶端開啟生成 OTP 的客戶端也自增一次,那麼只要這個時候不小心重新整理了瀏覽器或者觸碰了手機的重新整理,都會導致計數器不一致,從而動態密碼失效。但是如果每次伺服器都返回一個計數器值,使用者還需要在手機端手動輸入一次,操作起來比較麻煩。

那麼這個時候我們會想,如何才能保證伺服器和客戶端沒有任何互動就能拿到一個相同的計數器值呢?

可能我們這個時候很自然地會想到用時間,沒錯,時間就是一個天然的計數器,只要客戶端和伺服器能在時間上基本一致就可以,這個目前是比較容易保證的。事實上,TOTP 就是這麼來的,它將計數器替換為時鐘值,從而提供短暫的一次性密碼,而且安全性也比較理想,所以目前被網際網路廣泛採用,TOTP 的演算法和 HOTP 完全一樣,只是將計數器換成了時間因子,其餘的並沒有變化,所以可以把 TOTP 看成 HOTP 的一個變體,上面的公式可以修改如下:

\[TOTP = Truncate(HMAC_{SHA1}(key, T)) \]

其中 T 就是時間因子,注意我們這裡說的是時間因子,可不可以直接用 Unix 時間戳呢?我們想一想,假如使用時間戳,單位是 s,就算服務端和客戶端時間嚴格一致,那麼我們輸入一次性密碼總歸需要幾秒的時間,這樣一來受限於人的大腦和肢體反應速度,幾乎是不可能登入成功的。所以如果只要能保證一次性密碼在很小的一段時間內不變就可以,這個時間能比較充足的保證認證過程即可,在 TOTP 的 RFC 中是使用了步長(X)的概念,其預設值是 30s:

\[\begin{aligned} &X = 30 \\ &T = \frac{UnixTime}{X} \end{aligned}\]

這樣相當於以 30s 為一個自然時間視窗,時間因子就表示當前是從 1970 年 1 月 1 日以來第幾個 30s,所以在每一個視窗內,時間因子是固定不變的,這樣就給使用者留出充足的時間來輸入一次性密碼,同時對伺服器和客戶端的時間同步性要求也不會特別嚴格,時間差個幾秒也不太會影響認證,最多也就是多輸入一次,這樣就比較好地解決了 HOTP 帶來的計數器同步問題,所以結果換成了這樣:

那麼現在還有一個問題就是金鑰(key)是怎麼同步的,通常都是伺服器生成一個唯一的金鑰,客戶端儲存這個金鑰,之後雙方都是用這個金鑰來生成一次性金鑰。

首先在傳輸過程中肯定不能明文傳輸金鑰,目前大部分網站都支援 HTTPS 訪問,因此傳輸過程是安全的。其次就是在開啟雙因素認證時,必須是使用者本人操作,假如被別人冒充,那麼以後自己就再也無法登入自己的賬戶了,所以網站有個前提就是認為在開啟雙因素認證時,一定是使用者本人操作的,為了提高安全性,也可以在開啟一種認證方式是採用其他的方式進行認證,比如開啟 HOTP 之前先驗證手機或者郵箱,這樣安全性會更高一些。

3.OTP 的計算過程

首先我們需要有一個金鑰,這個是由伺服器生成,金鑰可以是一段隨機生成的位元組流,由於二進位制不方便觀察和輸入,所以伺服器會對這段二進位制進行 base32 編碼然後輸出,當前主流的客戶端都是支援以 base32 編碼作為金鑰輸入的,我們下面用 Python 來生成一段隨機金鑰:

import os
import base64

# 由於 base32 需要 8 位元組對齊,長度是原字串的 8/5,為了不浪費空間金鑰長度儘量是 5 的倍數
key = os.urandom(10)
encoded_key = base64.b32encode(key)
print(encoded_key)

這樣我們就得到了一串金鑰,假如編碼後是:X2QEGOZX5PUBOQ52,然後將這段金鑰給到客戶端。

然後客戶端要開始認證了,首先客戶端要拿到時間因子,步長按照 30s 來算,可以按照下面的方式得到:

import time
# Python 3 整數相除 //
T = int(time.time()) // 30
print(T)

這樣我們就得到了時間因子,假如當前是:56499675,然後我們根據上面的公式,我們需要來計算基於 SHA1 演算法的 HMAC 結果,計算如下:

import struct
import base64
import hmac

key = base64.b32decode(encoded_key)
msg = struct.pack('>Q', T)
h = hmac.new(key, msg, digestmod='sha1')
value = h.digest()

金鑰在進行計算時,仍然需要進行 base32 解碼,然後使用原始的金鑰參與計算,同時時間因子或者計數器的數字需要轉換為原始的位元組,按照要求是 8 個位元組,按照大端(big-endian)的方式排列,所以我們使用 >Q 來編碼,最後生成的 value 就是長度為 20 位元組的摘要值。

然後我們要進行比較重要的一步 Truncate 操作,最終得到一次性密碼,按照標準的定義實現如下:

  1. 取摘要結果最後的 4 位作為偏移(offset),範圍一定在:0~15。
  2. 在摘要結果中從 offset 位置擷取 4 個位元組,去除符號位得到一個 31 位的整數,假設為 P。
  3. 設我們一次性密碼的數字位數是:digit,我們只需要用上面的數字對 10 的 digit 次方取餘便可得到一次性密碼,也就是:P mod 10^digit

我們用程式碼表示如下:

offset = value[-1] & 0xf
P = (
    (value[offset] & 0x7f) << 24 
    | (value[offset+1] & 0xff) << 16 
    | (value[offset+2] & 0xff) << 8 
    | (value[offset+3] & 0xff)
)
digit = 6
code = P % 10**digit
# 408713
print(code)

這樣我們就得到了 6 位數的驗證碼,當然上面的方法也有點小問題就是如果數字 P 比較小,那麼結果可能不足 6 位,需要我們在前面補上 0,不過這不影響我們對演算法本身的理解。

使用者將得到的這個 code 輸入之後,服務端會按照上面同樣的過程進行計算,得到結果之後和客戶端的輸入進行比較,如果一致就驗證透過了。

上面我們是用原生語言和標準庫來實現的,事實上不需要這麼麻煩,大多數程式語言都有自己的庫,我們在開發時只需要呼叫庫來計算就可以了,比如:

  1. Python:pyotp
  2. Java:java-totp
  3. Go:https://github.com/pquerna/otp

以 Python 的 pyotp 為例,首先需要簡單安裝一下:

pip3 install pyotp

然後就可以直接使用了,例如:

import pyotp

totp = pyotp.TOTP('X2QEGOZX5PUBOQ52')
code = totp.now()
print(code)
# 驗證
assert totp.verify(code)

只需要簡單兩步就生成的一次性的密碼,而且還直接提供了驗證方法,非常方便。

4.使用場景案例

我們一般作為使用者不需要自己寫程式碼生成一次性密碼,可以藉助於各種第三方的 APP 幫助我們管理,常見的有 Google Authenticator、Microsoft Authenticator 等,我們以 GitHub 為例,看看是怎麼開啟的吧。

首先根據 GitHub 的提示我們點選 Enable 2FA,然後會跳轉到新的頁面:

頁面上提示我們可以使用 1Password、Authy、Microsoft Authenticator 等 APP 或者瀏覽器外掛來生成一次性密碼,通常使用手機 APP 更方便,這個時候我們以 Google Authenticator 為例來操作一下。

還是先安裝 Google Authenticator,安裝後開啟,跳過登入 Google 賬號這一步,點選右下角的加號會出現兩個選項,分別是:掃描二維碼、輸入設定金鑰。

由於應用做了限制不允許截圖,所以這裡先不放圖了,當然操作也比較簡單,我們直接選掃描二維碼然後用手機掃描 GitHub 頁面上這個二維碼,就可以彈出一次性密碼了,並且右側會有倒數計時錶示當前一次性密碼的過期時間,我們直接將密碼填入輸入框中,頁面會跳到下一步。

這個時候 GitHub 會給我們一些恢復程式碼,防止因為手機丟失或者我們不小心誤刪了 APP 中的條目導致無法登入賬戶,這個時候需要將這些恢復程式碼儲存好,以備不時之需。否則,萬一手機出現故障那麼就無法登入賬戶了。

當然我們也可以把上面二維碼的內容儲存下來,我們可以先解析出來上面的二維碼,結果其實就是一段文字,例如:

otpauth://totp/GitHub:monchickey?secret=EPKCRBXZHDPHOOZH&issuer=GitHub

前面的 otpauth 表示協議,totp 表示採用基於時間的一次性密碼,GitHub 說明發行者是 GitHub,後面的格式就是:<使用者名稱>?secret=<金鑰 base32 值>&issuer=<發行者>。

Note: 順便說一下儘量不要用微信、瀏覽器或其他線上工具解析二維碼,個人感覺是存在風險的,最好用離線的工具或者乾脆自己用程式碼解析一下都可以,比如可以用 CairoSVG 將 SVG 圖片轉換為 PNG,然後使用 ZBar 解析內容。

瞭解了這段文字的含義,我們就可以提前將上面這段文字安全地儲存到其他地方,之後就算手機出現問題,我們也可以重新在 APP 中選擇以金鑰新增,仍然可以正常生成一次性密碼。所以為了安全起見,所有涉及到雙因素認證的一次性密碼場景,我們有條件最好都儲存下金鑰,前提金鑰要儲存到安全的地方。

擴充一下如果我們作為服務端的開發者,也可以直接生成上面格式的文字,還是以 pyotp 為例:

import os
import base64
import pyotp

key = os.urandom(10)
encoded_key = base64.b32encode(key)

totp = pyotp.TOTP(encoded_key)
auth_text = totp.provisioning_uri(name='alice', issuer_name='Orange')

然後我們將生成的認證文字轉換成二維碼輸出就可以了。

最後一步,我們就成功地開啟了 GitHub 的 2FA:

相信到這裡,我們都應該理解 2FA 和 OTP 的概念和原理了,如果你的 GitHub 還沒有開啟,就趕快操作開啟吧~

相關文章