首頁 > 後端開發 > Golang > 揭秘 OTP:離線產生代幣背後的邏輯

揭秘 OTP:離線產生代幣背後的邏輯

Mary-Kate Olsen
發布: 2024-12-12 22:23:14
原創
372 人瀏覽過

你好!另一個晚上,在回家的路上,我決定檢查一下郵箱。我指的不是我的電子郵件收件箱,而是郵差放置實際信件的老式實際盒子。令我驚訝的是,我在那裡發現了一個信封,裡面裝著一些東西!當我打開它時,我花了一些時間希望這是來自霍格華茲的遲來了幾十年的信。但當我注意到這是一封來自銀行的無聊的「成人」信時,我不得不回到現實。我瀏覽了一下文字,意識到我的「酷孩子純數字」銀行已被當地市場上最大的玩家收購。作為新開始的象徵,他們在信封上添加了以下內容:

Demystifying OTPs: the logic behind the offline generation of tokens

以及如何使用它的說明。

如果你像我一樣,從來沒有遇到過這樣的技術創新,讓我分享一下我從信中學到的東西:新所有者決定執行他們公司的安全策略,這意味著所有用戶從現在開始,帳戶將啟用MFA(順便說一句,這是值得稱讚的)。您在上面看到的裝置會產生 6 位數字長的一次性令牌,在登入您的銀行帳戶時用作第二因素。基本上,與 Authy、Google Authenticator 或 2FAS 等應用程式的工作方式相同,但採用物理形狀。

所以,我嘗試了一下,登入過程很順利:設備向我顯示了一個 6 位元代碼,我在我的銀行應用程式中輸入了它,這讓我進入了。萬歲!但後來我突然意識到:這東西是如何運作的?它無法以某種方式連接到互聯網,但它設法產生我的銀行伺服器接受的正確代碼。嗯...裡面有SIM卡或類似的東西嗎?沒辦法!

意識到我的生活將永遠不一樣,我開始想知道我上面提到的應用程式(Authy 和朋友)?我內心的研究員被喚醒了,所以我將手機切換到飛行模式,令我大吃一驚的是,我意識到它們在離線狀態下工作得非常好:它們不斷生成被應用程式伺服器接受的程式碼。有趣!

不確定你的看法,但我一直認為一次性代幣流是理所當然的,並且從未真正認真考慮過它(特別是因為現在我的手機很少沒有互聯網,除非我正在做一些戶外探險),所以這是我驚訝的根本原因。否則,從安全的角度來看,以這種方式工作是完全有意義的,因為生成過程純粹是本地的,因此對於外部參與者來說是安全的。但它是如何運作的呢?

嗯,像 Google 或 ChatGPT 這樣的現代技術可以讓您輕鬆找到答案。但這個技術問題對我來說似乎很有趣,所以我決定先嘗試一下並自己解決。

要求

讓我們從我們擁有的開始:

  • 可產生 6 位元代碼的離線設備
  • 伺服器接受這些程式碼,驗證它們,如果正確則發出綠色訊號

伺服器驗證部分提示伺服器必須能夠產生與離線設備相同的程式碼來比較它們。嗯..這會很有幫助。

對我的新「玩具」的進一步觀察帶來了更多發現:

  • 如果我將其關閉然後再關閉,通常我可以看到與之前相同的程式碼
  • 然而,偶爾,它會改變

我能想到的唯一邏輯解釋是這些程式碼有一定的生命週期。我想講述一個我試圖以“1-2-3-...-N”方式計算它的持續時間的故事,但這不會是真的:我從像這樣的應用程式中得到了一個很大的提示Authy and Co,我在那裡看到了30 秒的TTL。很好的發現,讓我們將其添加到已知事實列表中。

讓我們總結一下到目前為止我們的要求:

  • 以 6 位數字格式產生可預測(而非隨機)的代碼
  • 生成邏輯應該是可重現的,這樣無論平台如何,都可以得到相同的結果
  • 程式碼生命週期為 30 秒,這表示在這段時間內產生演算法會產生相同的值

大問題

好吧,但主要問題仍然沒有得到解答:離線應用程式如何產生與其他應用程式中的值相符的值?他們有什麼共同點?

如果您喜歡《魔戒》宇宙,您可能還記得比爾博如何與咕嚕玩謎語遊戲,並解決了這個問題:

這個萬物吞噬之物:
鳥、獸、樹、花;
啃鐵咬鋼;
將堅硬的石頭磨成粉;
殺死國王,摧毀城鎮,
並擊敗高山。

劇透警告,但巴金斯先生很幸運,意外地得出了正確答案 - “時間!”。不管你相信與否,這也正是我們謎題的答案:任何 2 個(或更多)應用程式都可以存取同一時間,只要它們內部有嵌入式時鐘。如今,後者已不再是問題,而且所討論的設備足夠大,可以容納它。環顧四周,你的手錶、手機、電視、烤箱和牆上的時鐘上的時間很可能是相同的。我們在這裡很感興趣,似乎我們已經找到了 OTP(一次性密碼)計算的基礎!

挑戰

依賴時間有其自身的一系列挑戰:

  • 時區 - 使用哪一個?
  • 時鐘往往會不同步,這對分散式系統來說是一個巨大的挑戰

讓我們一一解決:

  • 時區:這裡最簡單的解決方案是確保所有設備都依賴同一區域,並且 UTC 可以是一個很好的與位置無關的候選
  • 至於時鐘不同步:實際上,我們甚至可能不需要解決它,而是接受不可避免的事情,只要漂移在一兩秒之內,考慮到 30 秒的 TTL,這可能是可以容忍的。設備的硬體生產商應該能夠預測何時會實現這種漂移,因此設備將使用它作為其到期日期,而銀行將簡單地用新的替換它們,或者將有辦法連接它們到網路來校準時鐘。至少,這是我的想法。

執行

好的,這件事已經解決了,所以讓我們嘗試以時間為基礎來實現我們演算法的第一個版本。由於我們對 6 位數字結果感興趣,因此依賴時間戳而不是人類可讀的日期聽起來是一個明智的選擇。讓我們從這裡開始:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

根據 Go 文檔,.Unix() 返回

自 UTC 1970 年 1 月 1 日以來經過的秒數。

這是終端列印的內容:

Current timestamp:  1733691162
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

這是一個好的開始,但是如果我們重新運行該程式碼,時間戳值將會改變,而我們希望保持它穩定 30 秒。好吧,小菜一碟,我們將其除以 30 並使用該值作為基數:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

讓我們運行它:

Current timestamp:  1733691545
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

再說一次:

Current timestamp:  1733691552
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

基礎值維持不變。讓我們稍等一下,然後再次運行:

Current timestamp:  1733691571
Base:  57789719
登入後複製
登入後複製
登入後複製
登入後複製

隨著 30 秒視窗的過去,基礎值已更改 - 幹得好!

如果「除以30」的邏輯沒有意義,讓我用一個簡單的例子來解釋:

  • 想像一下我們的時間戳回 1
  • 如果我們將 1 除以 30,結果將為 0,就像當我們使用嚴格類型程式語言時,整數除以整數會傳回另一個整數,該整數與浮點部分無關
  • 這意味著在接下來的 30 秒內,當時間戳介於 0 到 29 之間時,我們將得到 0
  • 一旦時間戳達到30,除法的結果就是1,直到60(變成2),依此類推

我希望它現在更有意義。

但是,還沒有滿足所有要求,因為我們需要 6 位數字的結果,而當前基數截至今天是 8 位數字,但在未來的某個時間點可能會達到 9 位數字點,依此類推。好吧,讓我們使用另一個簡單的數學技巧:將基數除以1 000 000,並得到餘數,餘數始終為6 位數字,因為提醒可以是0 到999 999 之間的任何數字,但不能更大:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

fmt.Sprintf(" d", code) 部分會附加前導零,以防我們的程式碼值少於 6 位元。例如,1234 將轉換為 001234。
這篇文章的完整程式碼可以在這裡找到。

讓我們執行此程式碼:

Current timestamp:  1733691162
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

好的,我們得到了 6 位元程式碼,萬歲!但這裡感覺有些不對勁,不是嗎?如果我給您這個程式碼,並且您將與我同時運行它,您將獲得與我相同的程式碼。這並不意味著它是一個安全的一次性密碼,對吧?新的要求來了:

  • 不同使用者的結果應該不同

當然,如果我們的用戶超過 100 萬,一些衝突是不可避免的,因為這是每 6 位數字的最大可能唯一值。但這些都是罕見的,技術上不可避免的碰撞,而不是像我們現在這樣的演算法設計缺陷。

我認為任何聰明的數學技巧本身都不會幫助我們:如果我們需要每個使用者單獨的結果,我們需要一個特定於使用者的狀態來實現它。作為工程師,同時也是許多服務的用戶,我們知道要授予對其 API 的存取權限,服務依賴私鑰,而私鑰對於每個用戶來說都是唯一的。讓我們為我們的用例引入一個私鑰,以區分用戶。

私鑰

產生 1 000 000 到 999 999 999 之間整數的私鑰的簡單邏輯:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

我們使用 pkDb 映射作為防止私鑰之間重複的方法,如果檢測到重複,我們將再次運行生成邏輯,直到獲得唯一的結果。

讓我們執行此程式碼來取得私鑰範例:

Current timestamp:  1733691545
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

讓我們在程式碼產生邏輯中使用此私鑰,以確保每個私鑰得到不同的結果。由於我們的私鑰是整數類型,所以我們能做的最簡單的事情就是將其添加到基值上,並保持其餘演算法不變:

Current timestamp:  1733691552
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

讓我們確保它對於不同的私鑰產生不同的結果:

Current timestamp:  1733691571
Base:  57789719
登入後複製
登入後複製
登入後複製
登入後複製

結果看起來符合我們的期望:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds:
base := current / 30
fmt.Println("Base: ", base)

// makes sure it has only 6 digits:
code := base % 1_000_000

// adds leading zeros if necessary:
formattedCode := fmt.Sprintf("%06d", code)
fmt.Println("Code: ", formattedCode)
登入後複製
登入後複製
登入後複製

魅力十足!這意味著私鑰應該先註入生成程式碼的裝置中,然後再發送給像我這樣的用戶:這對銀行來說根本不應該是問題。

我們現在完成了嗎?好吧,前提是我們對我們使用的人工場景感到滿意。如果您曾經為您擁有帳戶的任何服務/網站啟用過 MFA,您可能會注意到網路資源要求您使用您選擇的第二因素應用程式(Authy、Google Authenticator、2FAS 等)掃描 QR 碼。 ),它將把密碼輸入到您的應用程式中,並從那時起開始產生 6 位數字的代碼。或者,也可以手動輸入代碼。

我提出這個是為了一睹業界使用的真實私鑰的格式。它們通常是 16-32 個字元長的 Base32 編碼字串,如下所示:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

如您所見,這與我們使用的整數私鑰有很大不同,如果我們要切換到這種格式,我們演算法的當前實作將無法運作。我們如何調整我們的邏輯?

私鑰作為字串

讓我們從一個簡單的方法開始:我們的程式碼將無法編譯,因為這一行:

Current timestamp:  1733691162
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

因為 pk 從現在開始是字串型。那我們為什麼不把它轉換成整數呢?雖然有更優雅和更有效率的方法可以做到這一點,但這是我想到的最簡單的方法:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

這很大程度上是受到 String 資料類型的 Java hashCode() 實作的啟發,這使得它足以滿足我們的場景。

調整後的邏輯如下:

Current timestamp:  1733691545
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

這是終端輸出:

Current timestamp:  1733691552
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

很好,有 6 位元程式碼,幹得好。讓我們等待到達下一個時間窗口並再次運行它:

Current timestamp:  1733691571
Base:  57789719
登入後複製
登入後複製
登入後複製
登入後複製

嗯...它可以工作,但是程式碼基本上是前一個值的增量,這不好,因為這樣OTP 是可預測的,並且擁有它的值並知道它屬於什麼時間,這是非常好的無需知道私鑰即可開始產生相同的值。這是此駭客攻擊的簡單偽代碼:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds:
base := current / 30
fmt.Println("Base: ", base)

// makes sure it has only 6 digits:
code := base % 1_000_000

// adds leading zeros if necessary:
formattedCode := fmt.Sprintf("%06d", code)
fmt.Println("Code: ", formattedCode)
登入後複製
登入後複製
登入後複製

其中 keepWithinSixDigits 將確保在 999 999 之後下一個值是 000 000 等等,以將該值保持在 6 位數字的限制範圍內。

如您所見,這是一個嚴重的安全漏洞。為什麼會發生這種情況?如果我們看一下基本計算邏輯,我們會發現它依賴兩個因素:

  • 當前時間戳記除以 30
  • 私鑰的雜湊值

雜湊對於相同的鍵產生相同的值,因此它的值是恆定的。至於當前的 / 30 ,它在 30 秒內具有相同的值,但是一旦視窗過去,下一個值將是前一個值的增量。然後 base % 1_000_000 的行為就像我們看到的那樣。我們之前的實作(使用私鑰作為整數)也有同樣的漏洞,但我們沒有註意到這一點 - 缺乏測試。

我們需要將 current / 30 轉換成某些東西,使其值的變化更加明顯。

分佈式 OTP 值

有多種方法可以實現這一點,並且存在一些很酷的數學技巧,但出於教育目的,讓我們優先考慮我們將採用的解決方案的可讀性:讓我們將current / 30 提取到一個單獨的變數基數中並包含進入雜湊計算邏輯:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

這樣,即使底數每 30 秒變化 1,但在 hash() 函數邏輯中使用後,diff 的權重會因為一系列乘法的執行而增加。

這是更新後的程式碼範例:

Current timestamp:  1733691162
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

讓我們運行它:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

繁榮!我們怎麼會在這裡得到負值呢?好吧,看起來我們已經超出了 int64 範圍,因此它將值限制為負數並重新開始 - 我的 Java 同事透過 hashCode() 行為對此很熟悉。 解決方法很簡單:讓我們從結果中取絕對值,這樣減號就被忽略:

Current timestamp:  1733691545
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

這是修復後的完整程式碼範例:

Current timestamp:  1733691552
Base:  57789718
登入後複製
登入後複製
登入後複製
登入後複製

讓我們運行它:

Current timestamp:  1733691571
Base:  57789719
登入後複製
登入後複製
登入後複製
登入後複製

讓我們再次運行它以確保 OTP 值現在已分發:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds:
base := current / 30
fmt.Println("Base: ", base)

// makes sure it has only 6 digits:
code := base % 1_000_000

// adds leading zeros if necessary:
formattedCode := fmt.Sprintf("%06d", code)
fmt.Println("Code: ", formattedCode)
登入後複製
登入後複製
登入後複製

很好,終於有一個不錯的解決方案了!

實際上,那是我停止手動實施過程的那一刻,因為我享受到了樂趣並學到了新東西。然而,這既不是最好的解決方案,也不是我願意接受的解決方案。除此之外,它還有一個很大的缺陷:如您所見,由於雜湊邏輯和時間戳記值,我們的邏輯總是處理大數字,這意味著我們不太可能產生以1 或1 開頭的結果。更多零:例如 012345 、 001234 等,即使它們完全有效。因此,我們還缺少 100 000 個可能的值,這比演算法的可能結果數量少了 10% - 這樣碰撞的可能性會更高。不酷!

從這裡到哪裡去

我不會深入探討實際應用程式中使用的實現,但對於那些好奇的人,我將分享兩個值得一看的 RFC:

  • HOTP:一種基於 HMAC 的一次性密碼演算法
  • TOTP:基於時間的一次性密碼演算法

這是偽代碼實現,它將根據上述 RFC 以預期方式工作:

Current timestamp:  1733692423
Base:  57789747
Code:  789747
登入後複製

如您所見,我們已經非常接近了,但原始演算法使用更高級的雜湊(本例中為 HMAC-SHA1),並執行一些位元運算來標準化輸出。

安全考慮:重複使用而不是自己構建

但是,在我們結束之前我還想講一件事:安全性。我強烈建議您不要自行實作產生 OTP 的邏輯,因為有許多函式庫已經為我們完成了這項工作。犯錯的空間是巨大的,而且離不良行為者發現和利用的漏洞只有很短的距離。

即使您的生成邏輯正確並通過測試覆蓋它,也可能會出現其他問題。例如,您認為暴力破解 6 位數代碼需要花費多少時間?讓我們來實驗一下:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

讓我們執行此程式碼:

Current timestamp:  1733691162
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

再一次:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)
登入後複製
登入後複製
登入後複製
登入後複製
登入後複製

如您所見,透過簡單的暴力 for 迴圈猜測程式碼大約需要 70 毫秒。這比 OTP 的壽命快 400 倍!使用 OTP 機制的應用程式/網站的伺服器需要透過例如在 3 次失敗嘗試後的接下來 5 或 10 秒內不接受新程式碼來防止這種情況。這樣,攻擊者在 30 秒的視窗內只能相應地獲得 18 或 9 次嘗試,這對於 100 萬個可能值的池來說是不夠的。

還有其他類似的事情很容易被忽略。所以,讓我再說一次:不要從頭開始建立它,而是依賴現有的解決方案。

無論如何,我希望你今天學到了一些新東西,從現在開始 OTP 邏輯對你來說不再是一個謎。此外,如果在生活中的某個時刻,您需要使用可重現的演算法讓離線設備產生一些值,那麼您很清楚從哪裡開始。

感謝您花時間閱讀這篇文章,祝您玩得開心! =)

P.S.我發布新帖子後會收到一封電子郵件 - 在此訂閱

P.P.S.和其他酷孩子一樣,我最近創建了一個 Bluesky 帳戶,所以請幫助我讓我的 Feed 變得更有趣 =)

以上是揭秘 OTP:離線產生代幣背後的邏輯的詳細內容。更多資訊請關注PHP中文網其他相關文章!

來源:dev.to
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
作者最新文章
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板