動態密碼演算法介紹與實現

lvxiangan發表於2018-04-13

動態密碼,亦稱一次性密碼(One Time Password, 簡稱 OTP),是一種高效簡單又比較安全的密碼生成演算法,在我們的生活以及工作中隨處可見,身為開發者,也或多或少在自己的業務系統中整合了二步驗證機制,那麼,技術運用,既要知其然,更要知其所以然,動態密碼演算法是怎樣的?

讀前指引

  • 通過這篇文章,你可以瞭解以下知識:

    • 動態密碼的背景知識

    • 動態密碼的分類

    • 不同動態密碼的生成演算法,HOTP 以及 TOTP

    • HOTP 以及 TOTP 的簡單的 Ruby 程式語言的實現

    • 兩類演算法各自注意事項

  • 限於篇幅,我不會討論以下幾點,有興趣的同學可以參考我文章末尾給出的參考資料瞭解:

    • 不同動態密碼的安全性分析

    • 計時動態密碼如何確保有效期間內,密碼不被二次使用

動態密碼背景介紹

從我的角度理解,動態密碼是指隨著某一事件(密碼被使用、一定的時間流逝等)的發生而重新生成的密碼,因為動態密碼本身最大優點是防重複執行攻擊(replay attack),它能很好地避免類似靜態密碼可能被暴力破解等的缺陷,現實運用中,一般採用“靜態密碼+動態密碼”相結合的雙因素認證,我們也稱二步驗證。

而動態密碼其實很早就出現在我們的生活裡了,在移動支付發展起來之前,網銀是當時最為流行的線上支付渠道,當時銀行為了確保大家的網銀賬號支付安全,都會給網銀客戶配發動態密碼卡,比如中國銀行電子口令卡(按時間差定時生成新密碼,口令卡自帶電池,可保證連續使用幾年),或者工商銀行的電子銀行口令卡(網銀支付網頁每次生成不同的行列序號,使用者根據指定行列組合刮開密碼卡上的塗層獲取密碼,密碼使用後失效),又或者銀行強制要求的簡訊驗證碼,這些都可以納入動態密碼的範疇。



而隨著移動網際網路的發展以及移動裝置的智慧化的不斷提高,裝置間的同步能力大幅提升,以前依賴獨立裝置的動態密碼生成技術很快演變成了手機上的動態密碼生成軟體,以手機軟體的形式生成動態密碼的方式極大提高了動態密碼的便攜性,一個使用者一個手機就可以管理任意多個動態密碼的生成,這也使得在網站上推動二步驗證減少了很多阻力,因為以往客戶可能因為使用口令卡太麻煩,而拒絕開啟二步驗證機制,從而讓自己的賬號暴露在風險之下。最為知名的動態密碼生成軟體,當屬 Google 的 Authenticator APP。  

動態密碼演算法探索之旅

動態密碼的分類

一般來說,常見的動態密碼有兩類:

  • 計次使用:計次使用的OTP產出後,可在不限時間內使用,知道下次成功使用後,計數器加 1,生成新的密碼。用於實現計次使用動態密碼的演算法叫 HOTP,接下來會對這個演算法展開介紹;

  • 計時使用:計時使用的OTP則可設定密碼有效時間,從30秒到兩分鐘不等,而OTP在進行認證之後即廢棄不用,下次認證必須使用新的密碼。用於實現計時使用動態密碼的演算法叫 TOTP,接下來會對這個演算法展開介紹。

在真正開展演算法介紹之前,需要補充介紹的是:動態密碼的基本認證原理是在認證雙方共享金鑰,也稱種子金鑰,並使用的同一個種子金鑰對某一個事件計數、或時間值進行密碼演算法計算,使用的演算法有對稱演算法、HASH、HMAC等。記住這一點,這個是所有動態密碼演算法實現的基礎。

HOTP

HOTP 演算法,全稱是“An HMAC-Based One-Time Password Algorithm”,是一種基於事件計數的一次性密碼生成演算法,詳細的演算法介紹可以檢視 RFC 4226。其實演算法本身非常簡單,演算法本身可以用兩條簡短的表示式描述:

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))  
PWD(K,C,digit) = HOTP(K,C) mod 10^Digit

上式中:

  • K 代表我們在認證伺服器端以及密碼生成端(客戶裝置)之間共享的金鑰,在 RFC 4226 中,作者要求共享金鑰最小長度是 128 位,而作者本身推薦使用 160 位長度的金鑰

  • C 表示事件計數的值,8 位元組的整數,稱為移動因子(moving factor),需要注意的是,這裡的 C 的整數值需要用二進位制的字串表達,比如某個事件計數為 3,則C是 "11"(此處省略了前面的二進位制的數字0)

  • HMAC-SHA-1 表示對共享金鑰以及移動因子進行 HMAC 的 SHA1 演算法加密,得到 160 位長度(20位元組)的加密結果

  • Truncate 即截斷函式,後面會詳述

  • digit 指定動態密碼長度,比如我們常見的都是 6 位長度的動態密碼

Truncate 截斷函式

由於 SHA-1 演算法是既有演算法,不是我們討論重點,故而 Truncate 函式就是整個演算法中最為關鍵的部分了。以下引用 Truncate 函式的步驟說明:

DT(String) // String = String[0]...String[19]

Let OffsetBits be the low-order 4 bits of String[19]
Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
Let P = String[OffSet]...String[OffSet+3]
Return the Last 31 bits of P

結合上面的公式理解,大概的描述就是:

  1. 先從第一步通過 SHA-1 演算法加密得到的 20 位元組長度的結果中選取最後一個位元組的低位元組位的 4 位(注意:動態密碼演算法中採用的大端(big-endian)儲存);

  2. 將這 4 位的二進位制值轉換為無標點數的整數值,得到 0 到 15(包含 0 和 15)之間的一個數,這個數字作為 20 個位元組中從 0 開始的偏移量;

  3. 接著從指定偏移位開始,連續擷取 4 個位元組(32 位),最後返回 32 位中的後面 31 位。

回到演算法本身,在獲得 31 位的截斷結果之後,我們將其又轉換為無標點的大端表示的整數值,這個值的取值範圍是 0 ~ 2^31,也即 0 ~ 2.147483648E9,最後我們將這個數對10的乘方(digit 指數範圍 1-10)取模,得到一個餘值,對其前面補0得到指定位數的字串。

程式碼示例

以下程式碼示例也可訪問 Gist點選預覽 獲取。

require 'openssl'

def hotp(secret, counter, digits = 6)
  hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), secret, int_to_bytestring(counter))  # SHA-1 演算法加密
  "%0#{digits}i" % (truncate(hash) % 10**digits)  # 取模獲取指定長度數字密碼
end

def truncate(string)
  offset = string.bytes.last & 0xf           # 取最後一個位元組
  partial = string.bytes[offset..offset+3]   # 從偏移量開始,連續取 4 個位元組
  partial.pack("C*").unpack("N").first & 0x7fffffff    # 取後面 31 位結果後得到整數
end

def int_to_bytestring(int, padding = 8)
  result = []
  until int == 0
    result << (int & 0xFF).chr
    int >>= 8
  end
  result.reverse.join.rjust(padding, 0.chr)
end

上面的演算法實現程式碼量很少,核心都是按照演算法描述進行多個掩碼運算跟位操作而已。

密碼失效機制

從上面的分析可以看到,一個動態密碼的生成,取決於共享金鑰以及移動因子的值,而共享金鑰是保持不變的,最終就只有移動因子決定了密碼的生成結果。所以在 HOTP 演算法中,要求每次密碼驗證成功後,認證伺服器端以及密碼生成器(客戶端)都要將計數器的值加1,已確保得到新的密碼

但是在這裡就會引入一個問題,假如認證伺服器端與密碼生成器之間由於通訊故障或者其他意外情況,導致兩邊計數器的值不同步了,那麼就會導致兩邊生成的密碼無法正確匹配。為了解決這個問題,演算法在分析中建議認證伺服器端在驗證密碼失敗後,可以主動嘗試計數器減1之後重新生成的新密碼是否與客戶端提交密碼一致,如果是,則可以認定是客戶端計數器未同步導致,這種情況下可以通過驗證,並且要求客戶端重新同步計數器的值。

出了上面提到的計數器不同步的問題,我另外想的是,如果客戶有多個密碼生成器(假設 iPad 和 iPhone)為同個賬號生成密碼,那麼計數器在多個裝置間的同步可能就需要另外考慮的方案了。

小結

其實 HOTP 的演算法比我在閱讀演算法前所想象的要簡潔得多,而且仍然足夠強健。演算法本身巧妙利用了加密演算法對共享金鑰和計數器進行加密,確保這兩個動態密碼生成因子不被篡改,接著通過一個 truncate 函式隨機得到一個最長 10 位的 10 進位制整數,最終實現對 1 - 10 位長度動態密碼的支援。演算法本身的簡潔也確保了演算法本身可以在各種裝置上實現。

TOTP

TOTP 演算法,全稱是 TOTP: Time-Based One-Time Password Algorithm,其基於 HOTP 演算法實現,核心是將移動因子從 HOTP 中的事件計數改為時間差。完整的 TOTP 演算法的說明可以檢視 RFC 6238,其公式描述也非常簡單:

TOTP = HOTP(K, T) // T is an integer
and represents the number of time steps between the initial counter
time T0 and the current Unix time

More specifically, T = (Current Unix time - T0) / X, where the
default floor function is used in the computation.

通常來說,TOTP 中所使用的時間差都是當前時間戳,TOTP 將時間差除以時間視窗(密碼有效期,預設 30 秒)得到時間視窗計數,以此作為動態密碼演算法的移動因子,這樣基於 HOTP 演算法就能方便得到基於時間的動態密碼了。

程式碼示例

以下程式碼示例也可訪問 Gist點選預覽 獲取。

require 'hotp'

def totp(secret, digits = 6, step = 30, initial_time = 0)
  steps = (Time.now.to_i - initial_time) / step

  hotp(secret, steps, digits)
end

看到了吧,極其簡短的實現程式碼!一個時間計數的動態密碼演算法就此誕生,如此簡單的演算法,卻是支撐多少業務系統安全運作的基石,頗有四兩撥千斤的快感!

問題探討

  1. ROTP 演算法中的主要問題是計數器的同步,而 TOTP 也不例外,只是問題在於伺服器端與客戶端之間時間的同步,由於現在網際網路的發達,加上移動裝置一般都會按照網路時間設定裝置時間,基本上時間的相對同步都不是問題;

  2. 時間同步的另一個問題其實是邊界問題,假如客戶端生成密碼的時間剛好是第 29 秒,而由於網路延遲等原因,伺服器受理驗證時剛好是下一個時間視窗的第 1 秒,這個時候會導致密碼驗證失效。於是,TOTP 演算法在其演算法討論中,也建議伺服器在驗證密碼失敗之後,可以嘗試將自身的時間視窗值減 1 之後重新生成密碼比對,如果驗證通過,說明驗證不通過是時間視窗的邊界問題導致,這個時候可以認為密碼驗證通過。

  3. 基於時間的動態密碼的另一個好處是避免了基於計數器的多裝置間的計數器同步問題,因為每臺裝置以及服務端都可以自行與網路時間(共同時間標準)校準,而無需依賴服務端的時間。

Google Authenticator

在 Google Authenticator 的開源專案的 README 裡有明確提到:

These implementations support the HMAC-Based One-time Password (HOTP) algorithm specified in RFC 4226 and the Time-based One-time Password (TOTP) algorithm specified in RFC 6238.

也就是說,至此,我們也明白了,其實 Google Authenticator 演算法核心也是 HOTP 以及 TOTP ,在明白了整個動態密碼演算法的核心之後,有沒有一種豁然開朗的感覺呢?既知其然,又知其所以然了,對吧?每次看著應用定時生成密碼,

總結

這篇文章簡單介紹了兩類常見的動態密碼的生成演算法,演算法本身簡潔不復雜,效率並且足夠強健。這篇文章的目的是方便跟我一樣希望瞭解演算法核心的小夥伴,而在 RFC 文件中,仍有大量關於演算法本身的安全性方面的探討,有興趣的小夥伴可以去看一下。

參考資料

  1. Wikipedia: 一次性密碼

  2. github: rotp,HOTP 以及 TOTP 的演算法實現以及其他封裝

  3. 動態口令(OTP)認證技術概覽

  4. RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm

  5. RFC 6238 - TOTP: Time-Based One-Time Password Algorithm

示例原始碼

Gist: OTP algorithms in Ruby

轉自:https://segmentfault.com/a/1190000008394200

相關文章