開發系統時,偶爾需要隨機產生符合複雜度要求(包含大小寫英數字加符號... 等)密碼的需求,例如:新帳號初始密碼、重設密碼... 等,這篇來聊聊這類程式寫法。

以 .NET 為例,.NET Framework 與 .NET 8+ 都內建 API 能直接實現。

.NET Framwork 有 System.Web.Security.Membership.GeneratePassword(Int32, Int32)

.NET 8+ 則可使用 System.Security.Cryptography.RandomNumberGenerator.GetString(ReadOnlySpan<char> choices, int length) 從一串字元陣列中隨機挑選組成指定長度字串:

var availableCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
string password = System.Security.Cryptography.RandomNumberGenerator.GetString(availableCharacters, length: 16);
Console.WriteLine(password);  // 例如: "9k@#mL2$xQ7!vW5&"

為什麼不用 System.Random 產生亂數就好,要用專屬隨機數產生器?

在資訊安全中,隨機數的品質往往決定其密碼學安全性,一旦不夠隨機或可被預測,代表攻擊者只需嘗試可能出現的組合,破解難度會降低。System.Random 並不符合密碼學對隨機性的嚴格要求,不信的話請看以下示範,以 .NET Framework 為例,Random 預設是用 Environment.TickCount 當種子,故同時用多 Thread 跑 new Random() 的種子相同,產生的隨機數也相同:

.NET Core 的 Random 有所改良,使用 OS 的偽隨機數生成器 PRNG (BCrypt on Windows, OpenSSL on *nix),但仍存可被預測問題,也就是「使用相同種子產生的一連串隨機數都是固定的」,因此透過破解程式資料或猜測種子可預測其產生的隨機數。(對這個議題有興趣,這篇文章有示範:Randomness in .NET)

為獲得完全隨機性,密碼學用的 PRNG 需要一個稱為隨機數生成器 (RNG) 的「熵 (entropy)」來源 (通常來自環境) 以及一個加密演算法 (PRNG) 產生高品質隨機位元。.NET 提供的最新解決方案便是 RandomNumberGenerator (.NET Framework 則是用 RNGCryptoServiceProvider,它在 .NET 8+ 已棄用)。這些隨機數生成 API 基於 Windows BCryptGenRandom (舊版 Windows 為 CryptGenRandom) 及 Linux OpenSSL RAND_bytes,使用 CTR_DRBG 及 OpenSSL DRBG 演算法,符合 NIST SP 800-90A、FIPS 140-2/3 標準,可安心服用。

回到正題,若我們要隨機生成一個符合指定複雜度的密碼,該怎麼做?

  1. 選用符合資安要求的隨機數生成器
  2. 決定密碼長度
  3. 決定密碼字元的組成,要有幾個大寫、幾個小寫、幾個符號,餘下的位數用數字填補
  4. 至於要選用哪些英文字母跟符號,這裡面是有學問的 (如果你在意使用者體驗的話... 資安:那是啥?能吃嗎?)

昨天我有在 FB 分享一個有趣問題:隨機生成複雜密碼 + 不適當無襯線字體 = 整人遊戲

若這類包含英文跟符號的高複雜隨機密碼會透過 Email 或其他電子形式傳送,而使用者是使用肉眼識別再手工輸入方式,如何顯示密碼文字由客戶端軟體決定,若用到不適當字體,便會發生分不清數字 1、大寫 I 、小寫 L 及直線 | 符號,混淆數字 0 與大寫 O 的狀況,明明給了密碼,使用者卻得本著暴力破解精神嘗試不同可能。因此,我主張隨機生成密碼時,避開易混淆的字元,目前想到的有 1, l, I, |,O 及 0。RandomNumberGenerator.GetString() 允許我們提供選字元清單讓隨機組合,因此實作起來一點都不難。

以下是我想到的簡單實作,排除易混淆字元,指定總長度及大寫、小寫、符號的字元數,生成一段指定長度及指定複雜度,隨機性符合 NIST 標準的隨機密碼:

using System.Security.Cryptography;
using System.Text;

for (int i = 0; i < 100; i++)
{
    Console.Write(GenComplexPasswd(8, 1, 1, 1) + "  ");
    if (i % 5 == 4)
        Console.WriteLine();
}

// 移除大寫 I、大寫 O
const string upCasePool = "ABCDEFGHJKLMNPQRSTUVWXYZ"; 
// 移除小寫 l
const string lowCasePool = "abcdefghijkmnopqrstuvwxyz";
// 選取十個形狀差異較大的符號
const string symbolPool = "!@#$%^&+=?";
// 移除數字 1 跟 0
const string numberPool = "23456789";

string GenComplexPasswd(int length, int minUpCaseLen, int minLowCaseLen, int minSymbolLen)
{
    var pwd = new StringBuilder();
    if (minUpCaseLen > 0)
        pwd.Append(RandomNumberGenerator.GetString(upCasePool, minUpCaseLen));
    if (minLowCaseLen > 0)
        pwd.Append(RandomNumberGenerator.GetString(lowCasePool, minLowCaseLen));
    if (minSymbolLen > 0)
        pwd.Append(RandomNumberGenerator.GetString(symbolPool, minSymbolLen));
    pwd.Append(RandomNumberGenerator.GetString(numberPool, length - pwd.Length));
    return new string(pwd.ToString().ToCharArray().OrderBy(c => Guid.NewGuid()).ToArray());
}

生成的密碼會像這樣:

以上做法的長度與複雜度可以自由調整,在安全性與友善度間取得平衡。想友善一點,就讓數字比重高一點或只加大寫字元;不然可增加大小寫與符號位數,辛苦使用者來讓天秤向資安傾斜。我個人覺得,在有加上時效性與錯誤次數上限的前題下,這類隨機密碼的性質會接近簡訊驗證碼,複雜度要求可以放寬。但實務上該怎麼做,通常也要參酌資安/稽查人員的看法,沒有標準答案。

Explains how to generate secure, complex passwords in .NET using cryptographic RNGs, avoiding predictable randomness and confusing characters, balancing security and usability with practical, customizable implementations.


Comments

# by Tony

黑大,範例程式的 upCasePool 沒有移除大寫O XD

# by Jeffrey

to Tony, 謝~ 已修改,範例圖就算了(自暴自棄中)

Post a comment