Refined

Refined

密码哈希:从 MD5 到 Argon2

3
2025-04-02
密码哈希:从 MD5 到 Argon2

在 .NET 和 C# 中,大多数加密算法都可以通过 CLI 标准库和 BouncyCastle.Cryptography 实现。

我们常说,密码不应以明文形式存储在数据库中,而应当经过哈希(Hash)处理,并且最好还要加盐(Salt)。这样做的目的与必要性究竟是什么?在 C# 中又该如何实现?

1. 明文存储

最危险的起点

让我们先从最不安全的方式看起:直接将用户密码以明文存储。

这里我们假设一个 User 模型:

class User
{
    public int Id { get; set; } 
    public string Username { get; set; }
    public string Password { get; set; } 
}

// 创建一个用户
var user = new User
{
    Username = "admin",
    Password = "123456"
};

此时,数据库中存储的就是 123456 这个字符串。这种方式极度危险,无论是本地端还是远程数据库,都面临着巨大的风险:

  • 本地数据库:若有人能访问到数据库文件(如 SQLite),或通过反编译程序获取到连接字符串,就能直接看到所有用户的密码。

  • 远程数据库:可通过 SQL 注入、SSH 密钥泄露、数据库备份文件外泄或其他服务器漏洞等方式窃取数据。

一旦明文密码泄露,其后果不堪设想。它不仅可能暴露用户的隐私,黑客还能利用这些密码进行撞库攻击,造成连锁性的泄露事件。因此,在任何情况下,都应坚决避免明文存储密码。

2. 早期哈希算法 (MD5 / SHA-1)

看似安全,实则脆弱

为了解决明文存储的问题,我们引入哈希函数 (Hash Function)。哈希函数能将任意长度的输入(密码原文)转换成固定长度的输出(哈希值)。这个过程是单向的,理论上无法从哈希值反推出原文。

用早期的 MD5 算法来升级代码:

class User
{
    public int Id { get; set; } 
    public string Username { get; set; }
    public string PasswordHash { get; set; }
}

class PasswordHelper
{
    // 计算密码的 MD5 哈希值
    public static string HashPassword(string password)
    {
        using var md5 = System.Security.Cryptography.MD5.Create();
        var hashBytes = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(password));
        return Convert.ToBase64String(hashBytes);
    }

    // 验证密码
    public static bool VerifyPassword(string password, string passwordHash)
    {
        return HashPassword(password) == passwordHash;
    }
}

现在,密码 123456 存储到数据库时会变成像 ISMvKXpXpadDiUoOSoAfww== 这样的乱码,看起来安全多了。

然而,这种方式存在两大致命缺陷:

  1. 彩虹表攻击 (Rainbow Table Attack):黑客可以预先计算好大量常见密码(如 123456, password, admin 等)的 MD5 哈希值,并存成一张巨大的“彩虹表”。当黑客窃取数据库后,只需比对 PasswordHash 字段的值,就能快速查出原始密码。

  2. 哈希碰撞 (Collision):MD5 和 SHA-1 等早期算法已被证明存在碰撞漏洞,即两个不同的输入可以产生完全相同的哈希值。黑客虽然不知道你的原始密码是 admin,但他可能找到另一个字符串 qwerty,其哈希值与 admin 的完全一样。由于服务器只比对哈希值,黑客便能用 qwerty 成功登录。

因此,仅使用 MD5 或 SHA-1 进行哈希,对于今日的黑客技术而言,破解成本是极低的。

3. 加盐防御 (SHA-256 + Salt)

对抗彩虹表的利器

为了抵御彩虹表攻击,我们引入了盐 (Salt)。盐是一个为每个用户随机产生的独特字符串,在进行哈希运算前,先将它与原始密码结合。

class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    public string PasswordHash { get; set; } // 加盐后的密码哈希
    public string Salt { get; set; } // 盐值 (Base64 格式)
}

class PasswordHelper
{
    // 产生一个 16 字节的随机盐值
    public static byte[] GenerateSalt()
    {
        return System.Security.Cryptography.RandomNumberGenerator.GetBytes(16);
    }

    // 使用指定的盐值对密码进行 SHA256 哈希
    public static string HashPassword(string password, byte[] salt)
    {
        var passwordBytes = System.Text.Encoding.UTF8.GetBytes(password);
        // 将密码和盐值合并
        var combinedBytes = new byte[passwordBytes.Length + salt.Length];
        Buffer.BlockCopy(passwordBytes, 0, combinedBytes, 0, passwordBytes.Length);
        Buffer.BlockCopy(salt, 0, combinedBytes, passwordBytes.Length, salt.Length);
        // 使用 SHA256 进行哈希
        var hashBytes = System.Security.Cryptography.SHA256.HashData(combinedBytes);
        return Convert.ToBase64String(hashBytes);
    }

    public static bool VerifyPassword(string password, string passwordHash, byte[] salt)
    {
        var newHash = HashPassword(password, salt);
        return newHash == passwordHash;
    }
}

运作方式:

  1. 注册时:为新用户产生一个随机盐值,将密码与盐值结合后进行 SHA-256 哈希,最后将 PasswordHashSalt 一同存入数据库。

  2. 登录时:根据用户名取出 PasswordHashSalt。将用户输入的密码与取出的 Salt 结合,进行相同的哈希运算,再比对结果是否与数据库中的 PasswordHash 一致。

由于每个用户的盐值都不同,即使两个用户设置了相同的密码 123456,他们最终的哈希值也会完全不同。这使得黑客的彩虹表失效,因为他无法再预先计算哈希值。

盐值需要保密吗? 不需要。盐值的作用是确保哈希值的唯一性,即使黑客同时获取了哈希和盐值,他依然无法利用彩虹表,只能对每个用户进行独立的暴力破解。

4. 增加破解成本 (PBKDF2):

用时间换取安全

SHA-256 加盐虽然安全,但如果黑客算力强大,依然可以针对单一用户进行暴力破解。为了应对这种情况,我们需要一种方法来刻意增加哈希运算的耗时。PBKDF2 (Password-Based Key Derivation Function 2) 就是为此而生的算法。它允许我们指定一个迭代次数,将核心的哈希算法重复执行成千上万次。

在 C# 中,可以使用 Rfc2898DeriveBytes 类来实现:

class PasswordHelper
{
    // ...
    public static string HashPassword(string password, byte[] salt, int iterations = 10000)
    {
        using var pbkdf2 = new System.Security.Cryptography.Rfc2898DeriveBytes(
            password, 
            salt, 
            iterations, 
            System.Security.Cryptography.HashAlgorithmName.SHA256
        );
        // 产生一个 32 字节(256位)的哈希值
        var hashBytes = pbkdf2.GetBytes(32); 
        return Convert.ToBase64String(hashBytes);
    }
    // ...
}

通过设置 10000 次迭代,我们让单次密码验证的计算时间显著增加。这对正常用户登录可能只增加几毫秒的延迟,几乎无感;但对于每秒需要尝试数十亿次密码的黑客来说,破解成本被放大了成千上万倍,使其难以在短时间内得手。

5. 现代密码哈希 (BCrypt 与 Argon2)

尽管 PBKDF2 通过增加时间成本提高了安全性,但它在对抗专用硬件(如 GPU、FPGA)时仍有不足,因为这些硬件拥有强大的并行计算能力。为此,密码学家们设计了更能抵抗这类攻击的算法:BCrypt 和 Argon2。

BCrypt:内置盐值与成本因子

BCrypt 是一个专为密码哈希设计的算法,它不仅慢,而且需要较多的内存。在 C# 中,我们可以通过安装 BCrypt.Net-Next 这个 NuGet 包来使用它。

class PasswordHelper
{
    // BCrypt 会自动处理盐值,只需提供密码
    public static string HashPassword(string password)
    {
        // 第二个参数是工作因子(Work Factor),决定计算成本,建议 10-12
        return BCrypt.Net.BCrypt.HashPassword(password, 12);
    }

    public static bool VerifyPassword(string password, string passwordHash)
    {
        return BCrypt.Net.BCrypt.Verify(password, passwordHash);
    }
}

使用 BCrypt 的最大好处是简洁。我们不再需要自己管理 Salt 字段。BCrypt 会自动产生盐值,并将算法版本、工作因子、盐值和哈希值全部整合在一个字符串中,例如:$2a$12$lraBT1/lH3RiFXjQbywREutDElnBFaolPOEsDAvo1sjK2iRjwCAUi

我们只需要在数据库中存储这一个字符串即可,验证时库会自动解析所有信息。

Argon2:新一代密码哈希

Argon2 是 2015 年密码哈希竞赛的冠军,被公认为目前最安全的密码哈希算法之一。它在设计上考虑得更为周全,可以同时调整三个维度的破解成本:

  • 时间成本 (Time Cost):类似 PBKDF2 的迭代次数。

  • 内存成本 (Memory Cost):需要消耗大量内存,可以有效对抗 GPU 攻击。

  • 并行度 (Parallelism):控制可用于计算的核心数量。

Argon2 有三种变体:

  • Argon2d:更侧重于抵抗 GPU 攻击。

  • Argon2i:抵抗侧信道攻击。

  • Argon2id:为以上两者的结合,为通用场景下的最佳选择。

在 C# 中,可以使用 Konscious.Security.Cryptography.Argon2 等库。其使用方式与 BCrypt 类似,提供了更顶级的安全性。

6. 其他值得关注的哈希函数

除了上述主流的密码哈希方案,还有一些特定场景下非常有用的哈希函数。

BLAKE2:速度与安全的极致平衡

BLAKE2 是一个性能极高的加密哈希函数,其速度甚至比 MD5 更快,但安全性与 SHA-3 处于同一级别。它非常适合用于文件完整性校验、数据流验证等对性能要求极高的场景。

它的主要特点是充分利用了现代 CPU 的多核与 SIMD 指令集,计算效率极高。在 C# 中,可以通过 BouncyCastle 等库来使用。

using Org.BouncyCastle.Crypto.Digests;
using System.Text;

// ...
byte[] data = Encoding.UTF8.GetBytes("your-data-to-hash");
var digest = new Blake2bDigest();
digest.BlockUpdate(data, 0, data.Length);
var hash = new byte[digest.GetDigestSize()];
digest.DoFinal(hash, 0);

SM3:中国国家商用密码标准

SM3 是由中国国家密码管理局发布的商用密码哈希算法标准。其设计目标是提供自主可控的、安全可靠的哈希算法,以保障国家信息安全。

SM3 生成 256 位的哈希值,其安全性与效率与 SHA-256 相当。作为中国的国家标准,SM3 在国内的金融、政府、军工等关键信息基础设施领域得到了广泛应用。

SM3 可以直接在 BouncyCastle 库中调用。

7. 总结与建议

密码安全的演进是一场永不停歇的攻防战。下表是对以上所有哈希算法的总结与对比。

算法

优点

缺点

适用场景

明文

-

极度不安全

绝对禁止

MD5 / SHA-1

速度快

易受彩虹表和碰撞攻击

绝对禁止用于密码存储,仅可用于文件校验等非安全场景

SHA-256 + Salt

实现简单,能防彩虹表

无法有效抵抗暴力破解

旧系统或安全性要求不高的项目

PBKDF2

可调整迭代次数,增加破解时间

易受 GPU 加速攻击

需要符合 FIPS 等特定规范的场合

BCrypt

内置盐值,使用方便,抗 GPU

-

通用选择,兼具安全与便利

Argon2

目前最安全,可配置性强

库相对较少

对安全性有极高要求的应用,如金融、政府系统

BLAKE2

速度极快,安全性高

不专为密码哈希设计

文件校验、流处理等高性能场景

SM3

中国国家标准,安全可靠

国际上应用较少

信创,国内金融、政府等需要符合国家标准的领域

在现代应用程序开发中,BCrypt 因其足够的安全性和易用性,成为了大多数项目的首选。若项目对安全性有更高的要求,那么可以使用 Argon2id