如何儲存密碼攸關資訊安全,將密碼以明文寫進資料庫跟酒駕同罪,是零容忍的犯行,將密碼加密後儲存也不應發生(如果某系統按「忘記密碼」會把你設定的密碼寄給你,別用,塊陶!!),使用夠安全的雜湊演算法是唯一正解。因此,若你手邊的系統需要儲存密碼,請先學習如何正確將密碼轉成雜湊再動手,要丟給 AI 寫也成,但至少該有判斷程式好壞的鑑賞力,別上線一套有漏洞的系統而不自知。

如果你對密碼雜湊議題還不熟悉,推薦兩篇舊文:密碼要怎麼儲存才安全?該加多少鹽?-科普角度儲存密碼該用什麼雜湊演算法?

最近剛好手邊的專案需要儲存密碼(註:我一直認為要自己管理密碼是下策,請優先考量直接使用 AD 帳號驗證或是整合 Google/Microsoft/Github/LINE/FB 帳號登入,但總有時侯得自己來),我決定寫一個符合 OWASP 要求的密碼雜湊公用函式,方便日後專案共用,會比每次叫 AI Coding 現場生一套更穩定可靠。

依據上回的評比,Argon2id 應是現階段的首選。Argon2id 有幾個決定安全強度的參數包含 DegreeOfParallelism、MemorySize、Iterations,依據 OWASP 的建議

Use Argon2id with a minimum configuration of 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism.

我設計了一個可設定不同強度的 Argon2id 雜湊計算公用函數 Argon2idPasswordHasher,有個 Argon2idStrength 列舉共有 Default、High、Highest 三種參數組合,Default 為 OWASP 建議值,另外有 High、Highest 兩組值。依社群討論,增加 Memory 比 Iteration 更有效,至於 DegreeOfParallelism 可依系統主機實際處理器核心數調整,可設為 4。(OWASP 建議值為 1 是考量許多網站部署在 VPS 只有單核) 我沒找到什麼公認的強度建議,High、Highest 的參數值是隨便抓的,一般應用我覺得用 OWASP 建議的預設值就夠了。

強度MemorySizeIterationsDegreeOfParallelism
Default19M21
High64M21
Highest128M21

為了保留切換不同強度參數的彈性,雜湊結果會包名現在使用的參數組(我用列舉對映數字代表,0 = Default, 1 = High,...),在應用時,可自動使用相同參數組執行 Argon2id 運算,另外雜湊結果需保存隨機 Salt 值(一般建議 16 Bytes 以上),搭配密碼內容可產生相同雜湊值,故雜湊結果除了雜湊值外,還要保含參數組代碼及 Salt,格式為 <param-set-id>$<salt>$<hash-value>,實際範例如 0$CGoH7XZ506QO4AdBITYQWg==$YBy9gEr5sN6dlWhy6EX9xpDni+fhmGO/Ad21h7osGcI=,長度為 1 + 1 + 24 (Math.Ceil(16/3)*4) + 1 + 44 (Math.Ceil(32/3)*4) = 71 chars

以下是簡單的公用 Argon2id 雜湊函式範例:

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

public static class Argon2idPasswordHasher
{
    // Argon2id 參數組,預設值來自 OWASP 建議值
    class Argon2idParamSet
    {
        public int MemorySize { get; set; } = 19 * 1024;
        public int Iterations { get; set; } = 2;
        public int DegreeOfParallelism { get; set; } = 1;
    }
    public enum Argon2idStrength
    {
        Default = 0,
        High = 1,
        Highest = 2
    }
    static Dictionary<Argon2idStrength, Argon2idParamSet> _paramSets = new()
    {
        { Argon2idStrength.Default, new Argon2idParamSet() },
        {
            Argon2idStrength.High, 
            new Argon2idParamSet 
            {
                MemorySize = 64 * 1024,
                Iterations = 2,
                DegreeOfParallelism = 1
            }
        },
        {
            Argon2idStrength.Highest, 
            new Argon2idParamSet 
            {
                MemorySize = 128 * 1024,
                Iterations = 3,
                DegreeOfParallelism = 1
            }
        }
    };
    private const int SaltSize = 16;
    private const int HashSize = 32;
    public static async Task<string> HashPasswordAsync(string password, Argon2idStrength strength = Argon2idStrength.Default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(password);
        // 產生隨機 salt
        var salt = RandomNumberGenerator.GetBytes(SaltSize);
        var hash = await HashPasswordAsync(password, salt, strength);

        return string.Join(
            '$',
            ((int)strength).ToString(), 
            Convert.ToBase64String(salt),
            Convert.ToBase64String(hash));
    }

    public static async Task<bool> VerifyPasswordAsync(string password, string encodedHash)
    {
        if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(encodedHash))
            return false;

        var parts = encodedHash.Split('$');
        if (parts.Length != 3) 
            throw new FormatException("Invalid encoded hash format.");

        try
        {
            var strength = (Argon2idStrength)int.Parse(parts[0]);
            var salt = Convert.FromBase64String(parts[1]);
            var expectedHash = Convert.FromBase64String(parts[2]);
            var actualHash = await HashPasswordAsync(password, salt, strength);
            return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
        }
        catch (FormatException)
        {
            return false;
        }
    }

    private static async Task<byte[]> HashPasswordAsync(
        string password,
        byte[] salt,
        Argon2idStrength strength = Argon2idStrength.Default
    )
    {
        var paramSet = _paramSets[strength];
        var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
        {
            Salt = salt,
            MemorySize = paramSet.MemorySize,
            Iterations = paramSet.Iterations,
            DegreeOfParallelism = paramSet.DegreeOfParallelism
        };

        return await argon2.GetBytesAsync(HashSize);
    }
}

實測用 Default、High、Highest 三種參數計算雜湊並驗證:

最後,簡單測試不同參數強度下,連續執行 100 次雜湊計算與比對並測量耗費時間,稍微感受暴力攻擊不同強度 Argon2id 雜湊密碼所需的成本:

var password = "P99";
var pwdPool = Enumerable.Range(0, 100).Select(i => $"P{i:D2}").ToArray();
for (var i = 0; i < 3; i++)
{
    var hash = await Argon2idPasswordHasher.HashPasswordAsync(password, 
        (Argon2idPasswordHasher.Argon2idStrength)i);
    var sw = new System.Diagnostics.Stopwatch();
    sw.Start();
    foreach (var pwd in pwdPool)
    {
        var isValid = await Argon2idPasswordHasher.VerifyPasswordAsync(pwd, hash);
        if (isValid)
        {
            Console.WriteLine($"找到密碼: {pwd}");
        }
    }
    sw.Stop();
    Console.WriteLine($"強度:{(Argon2idPasswordHasher.Argon2idStrength)i} 耗時: {sw.ElapsedMilliseconds:n0} ms");
}

OWASP 建議的 19M 記憶體大小,計算一次約 0.06s、64M 約 0.21s、128M 0.62s,若想平行運算加速,主機記憶體大小會限制可執行的 Thread 數上限,增加攻擊難度。

收入工具箱備用。

Introduces a reusable .NET Argon2id password hasher with OWASP-based parameters, salt storage format, strength presets, verification logic, and benchmark results, emphasizing secure password storage and the cost impact of memory-hard hashing.


Comments

# by 小黑

謝哥

# by Latishaaaaaaaaaaa

如何儲存密碼悠關資訊安全 誤字「悠」,應為「攸」 目前還在普通加密階段,看看這篇學個新知識w

# by Jeffrey

to Latishaaaaaaaaaaa,已改正,謝~

Post a comment