Math.random 產生的密碼看起來夠亂,為什麼它不是密碼學安全亂數

頁面上剛吐出一串大小寫、數字和符號混在一起的字元,觀感上很「強」。這只能說明字元種類可能夠多,證明不了下一串不可被預測。核對方法不是相信「亂數」「本地」兩個詞,而是開啟開發者工具:產生路徑應走 crypto.getRandomValues,剛產生的口令也不該作為業務資料出現在 Network 裡。

先記結論:Math.random() 是普通偽隨機數,MDN 繁中說明寫明它不提供密碼學上安全的亂數,不要用它處理與安全性有關的事情。登入口令、一次性權杖和金鑰必須改用 Web Crypto 的 crypto.getRandomValues()。看起來夠亂、斷線還能點、網址列有鎖,這三件事各自證明別的邊界,都替代不了「產生函式讀的是哪一個 API」。

一句話先回答搜尋意圖

MDN 對 Math.random() 的邊界寫得很硬:它回傳 [0, 1) 上大致均勻的浮點偽隨機數,不提供密碼學安全的亂數;安全相關用途應改用 Crypto.getRandomValues()。密碼「看起來夠亂」只描述輸出的字元形態,不描述產生器的內部狀態能不能被還原。

這篇文章給要評審「線上密碼產生器」的開發者和維運,以及被同事丟過來一個產生頁、想自己看一眼再決定用不用的人。它不代替 密碼產生器 的操作說明,也不重複「明文有沒有離開分頁」那一套 Network 總論。你要帶走的是:普通偽隨機和 CSPRNG 差在哪一層、取模為什麼會讓好介面變偏,以及從點擊「產生密碼」往下讀原始碼的具體步驟。

看起來夠亂,只能說明字元可能混雜

人眼判斷「亂」非常便宜,也非常不準。一串 aB3$kP9x 和一串 Tr0ub4dor&3 看起來都像密碼;前者可能來自夠大的字元集,後者可能只是一句口令做了常見替換。觀感回答的是「像不像密碼」,不是「下一個位元能不能被算出來」。

即便字元集真的包含大小寫、數字和符號,只要下標來自可預測的序列,攻擊者就不需要窮舉整個空間。他可以重現同一套產生器,或在觀察到若干輸出後還原內部狀態,再枚舉「還沒被用掉的那些候選」。對登入口令來說,不可預測比「看起來有特殊符號」更先要滿足。

長度仍然重要。同一套安全亂數來源,6 字元和 16 字元的窮舉成本差幾個數量級;低於 8 字元時,即使用對了 CSPRNG,空間也偏小。FastPwd 亂數模式允許 6–128 字元、預設 16 字元,低於 8 字元會提示安全性較低——那是長度警告,不是「亂數演算法變弱了」。兩件事要分開看:演算法解決不可預測,長度解決搜尋空間。

Math.random 是哪一類亂數

ECMAScript 把 Math.random() 寫成:回傳一個正號、大於等於 0 且小於 1 的 Number,用實作自行定義的演算法或策略近似均勻地抽取。規範沒有要求它抗預測,也沒有要求種子來自作業系統的熵池。引擎可以選統計性質很好、但狀態可逆的偽隨機數產生器(PRNG)。

Chrome 所用的 V8 引擎在 2015 年底之前用 MWC1616。V8 自己的說明寫過:該演算法內部狀態只有 64 位元,品質偏差,在 TestU01 套件裡會失敗。從 V8 4.9.41(隨 Chrome 49 發布)起,改成 xorshift128+:128 位元狀態,週期 2^128 - 1,並通過 TestU01。Firefox 與 Safari 後來也切到同一類演算法。細節見 V8 對 Math.random 的說明。

同一篇文章把邊界寫死了:xorshift128+ 比舊實作好很多,仍然不是密碼學安全的。雜湊、簽章、加解密這類用途,普通 PRNG 不適用;應使用 Web Cryptography API 的 window.crypto.getRandomValues。統計上「夠亂」和「看見若干輸出之後無法反推後續輸出」,是兩套標準。口令需要的是後者。

公開的還原實作也把這層差距寫得很具體:xorshift128+ 是線性的,蒐集到足夠連續輸出後,可用約束求解器還原 128 位元狀態,再算出還沒被吐出的下一批數字。這不是理論題。因此,一個產生器如果只呼叫 Math.random() 再對應到字元表,它可能通過「目測很亂」和簡單的頻率檢查,卻過不了「假設有人讀到原始碼或截獲若干輸出」這一關。評審時不要把「用了 Math.random,但字元集很寬」寫成已經合格。

getRandomValues 具體換了哪一層

crypto.getRandomValues(typedArray) 會用密碼學意義上足夠強的亂數填滿你傳入的整型陣列,例如 Uint8Array、Uint32Array。MDN 與 Web Cryptography Level 2 都寫明:為了效能,實作通常不是「真隨機硬體每次直接吐位元」,而是把作業系統熵源(如 Unix 的 /dev/urandom、Windows 的系統亂數介面)種進適合密碼學用途的 PRNG。演算法可隨瀏覽器變化,但設計目標是:不能從已公開的輸出廉價還原內部狀態。

它和 Math.random 不在同一份語言規範裡。後者屬於 ECMA-262;前者屬於 Web Cryptography。一次請求超過 65536 位元組會拋 QuotaExceededError,產生一條 16 字元密碼遠遠到不了這個上限。另外,getRandomValues 是 Crypto 介面裡少數能在非安全內容呼叫的方法;金鑰衍生和 subtle.encrypt 仍要求 HTTPS 安全內容。正式環境的產生頁本來就應該是 HTTPS,不要用「HTTP 裡也能呼叫 getRandomValues」當成可以關掉 TLS 的理由。

還要把「本機抽亂數」和「伺服器代抽再傳回來」分開。後者即使伺服器用了高品質 CSPRNG,口令也已經以明文或可逆形式經過對方的記憶體、日誌和這次 HTTP。網址列小鎖只說明瀏覽器到 TLS 終點之間的傳輸被加密,不說明終點看不到正文。若頁面聲稱「本地產生」,產生這一下來就不該出現把口令 POST 出去再回顯的介面。

你看見的現象 它能支持的結論 它支持不了的結論
輸出看起來很亂 字元種類可能夠多 產生過程不可預測
原始碼裡有 Math.random 頁面用了普通偽隨機 一定拿它來產生口令
原始碼裡有 getRandomValues 頁面引入了 CSPRNG 介面 你點的這個按鈕一定走了它
斷線後仍能產生 不依賴即時代抽介面 用的不是 Math.random
Network 搜不到口令 這次沒當業務資料上傳 亂數演算法合格

取模偏差:CSPRNG 用錯了也會偏

常見的第二處失誤發生在「已經呼叫了 getRandomValues」之後。指令碼拿到一個位元組 0–255,再寫 charset[byte % charset.length]。若字元集長度不能整除 256,靠前的那些字元會多佔一截餘數,出現頻率略高。對 16 字元口令,偏差通常很小,但它說明實作沒有把「均勻」做到位;字元集更怪、或用 8 位元去對應很大的詞表時,偏差會更明顯。

拒絕取樣的做法是:先算出不超過 256(或 2^32)且能被字元集大小整除的上限,抽出的整數大於等於該上限就丟掉重抽,再用取模。FastPwd 的亂數字元和好記口令都走 crypto.getRandomValues,用 Uint32Array(1) 抽 32 位元整數,上限寫成 Math.floor(0x100000000 / max) * max,超限則迴圈再抽。缺少 getRandomValues 時直接拒絕產生,而不是退回 Math.random。你在別的網站評審時,也應沿著「抽整數 → 是否丟棄尾部 → 再對應字元」往下讀,而不是搜到函式名就停。

不要用真實登入口令、正式環境 API Key 或未遮蔽工單做實驗。產生一串可丟棄的測試口令,用完即棄。核對的是產生路徑和流量,不是把新口令再暴露一遍。

當場核對:原始碼、主控台、Network

口號不能當證據。準備一枚不會出現在真實業務裡的標記,例如把產生長度調到 16,產生後把整串當作金絲雀。下面三步可以在目前分頁做完,不需要帳號。

  1. 開啟開發者工具的 Sources,或對頁面使用「檢視網頁原始碼」。分別搜尋 Math.random 和 getRandomValues。記下命中檔案和函式名,先不要下結論。
  2. 從「產生密碼」按鈕的點擊處理函式往下讀,直到字元被拼進結果字串。這一路徑上應出現 crypto.getRandomValues;若只有 Math.random()、Date.now() 或固定種子,這一次產生不能當成 CSPRNG。
  3. 切到 Network,勾選 Preserve log,過濾先用 All。再點一次產生,把剛得到的整串貼進搜尋框。請求列、查詢字串、請求體和分析上報裡都不該出現這串口令。

主控台只證明介面在不在

在 Console 裡執行 typeof crypto.getRandomValues === "function",得到 true 只說明目前文件的瀏覽器用得了這個介面。它證明不了頁面指令碼呼叫了它。有人會在主控台自己跑一行 getRandomValues,然後把頁面產生的口令也當成「已經核過」——那是兩段不相干的程式。

斷線複測只證明沒有代抽

先讓頁面完整載入,再在 Network 裡開啟 Offline,或關掉系統網路,然後產生。若結果仍然出現,說明這一次不依賴即時介面。這是「本地產生」的強訊號,但 Math.random 同樣不需要網路。所以斷線要通過,原始碼路徑和金絲雀搜尋仍要做。

打包後的指令碼可能把名字壓成單字母。搜不到 getRandomValues 時,再搜 crypto 和 Uint32Array,並對照點擊「產生密碼」之後有沒有新的業務 POST。壓縮不是免檢聲明,只是搜尋要多繞一步。

三種把「亂數」讀過頭的情況

第一,把網址列小鎖當成產生演算法合格。HTTPS / TLS 保護的是瀏覽器到目前 TLS 終點的傳輸,常見區分是「傳輸加密 ≠ 端到端、終點看不到正文」。伺服器用 HTTPS 接收你 POST 上去的口令,小鎖仍然是亮的。小鎖不回答 Math.random 還是 getRandomValues。

第二,把「原始碼裡出現過 getRandomValues」當成按鈕已走 CSPRNG。同一份打包檔案可能給檔案加密、閱後即焚或示範用了 Web Crypto,密碼產生卻仍走 Math.random。反向同樣成立:廣告或動畫裡的 Math.random 不能用來否定口令路徑。必須以點擊產生的呼叫堆疊為準。

第三,把「沒上傳」當成「不可預測」。Network 乾淨只說明這次觀察裡,口令沒有作為 HTTP 業務欄位離開分頁。擴充功能仍可能讀 DOM,作業系統剪貼簿歷史仍可能留下你複製的那一串。那是另一篇文章的邊界;本文只要求你先分清:亂數品質、是否上傳、複製之後還在不在,是三件不同的事。

常見問題

密碼看起來很亂,是不是就可以當登入口令?

不能只憑觀感。看起來混雜只說明字元種類可能夠多,證明不了產生過程不可預測。MDN 寫明 Math.random 不能用於安全場景。還要看長度是否足夠、有沒有在多個站重複使用,以及產生路徑是不是 crypto.getRandomValues。

斷線後還能產生,是不是亂數就安全?

只能證明這一次產生不依賴即時介面,伺服器沒有代抽亂數。Math.random 同樣能在斷線後執行。斷線要通過,原始碼裡仍要看到產生路徑走 getRandomValues,並且 Network 裡不應出現剛產生的口令。

原始碼裡同時出現 Math.random 和 getRandomValues,還能用嗎?

能不能用取決於「產生密碼」按鈕綁到哪一段。動畫、打亂展示順序可能用 Math.random;口令、權杖、金鑰必須走 getRandomValues。開啟 Sources,從點擊處理函式往下讀,不要只看全站搜尋的命中次數。

FastPwd 的密碼產生器用哪種亂數?會不會上傳?

亂數字元和好記口令都走 crypto.getRandomValues,並把 32 位元整數對應到字元集時做拒絕取樣,避免取模偏差。缺少該介面時會拒絕產生。長度 6–128 字元、預設 16 字元;低於 8 字元會提示安全性較低。產生結果預設不上傳。開啟即用,無需帳號。

把同一串測試口令用開啟即用的工具對照一次

若你希望用一個把亂數邊界寫清楚的頁面來練習,開啟 FastPwd 的 密碼產生器 即可,無需註冊。亂數模式先用預設 16 字元,產生一串可丟棄的口令。按第五節做完:Sources 裡沿「產生密碼」讀到 getRandomValues 和拒絕取樣迴圈;Network 裡用整串搜尋,請求列和 body 不應命中。

產生之後,若還要判斷這串像不像已被廣泛濫用的密碼,把另一串可丟棄的測試字元拿到 密碼檢測 裡對照公開高頻名單。待測密碼不上傳。未命中不能證明全網沒見過,命中只說明太常見;那是弱密碼名單的邊界,不是 CSPRNG 的邊界。不要把剛要啟用的真口令貼進任何線上檢測框。

下次開啟陌生產生頁之前記住三件事。第一,看起來夠亂證明不了不可預測。第二,Math.random 即使統計性質已經很好,仍不是密碼學安全亂數。第三,getRandomValues 出現在原始碼裡還不夠,必須讀到你點擊的那條路徑,並用金絲雀確認口令沒有作為業務資料出站。能重複的觀察,才值得寫進你們自己的評審清單。

延伸閱讀

「本地加密、不上傳」不能當證據,要用金絲雀搜 Network,見 瀏覽器本地加密怎麼核對明文沒有離開本機。本地弱密碼名單能抓住常見弱密碼、不能當成全網撞庫,見 本地弱密碼名單能證明什麼,為什麼它不是全網撞庫。若產生之後還要把口令一次性交給同事,井號後面為什麼預設不進 HTTP,見 URL 的井號後面為什麼適合放金鑰,以及什麼時候會失效。