密码哈希:从 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==
这样的乱码,看起来安全多了。
然而,这种方式存在两大致命缺陷:
彩虹表攻击 (Rainbow Table Attack):黑客可以预先计算好大量常见密码(如
123456
,password
,admin
等)的 MD5 哈希值,并存成一张巨大的“彩虹表”。当黑客窃取数据库后,只需比对PasswordHash
字段的值,就能快速查出原始密码。哈希碰撞 (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;
}
}
运作方式:
注册时:为新用户产生一个随机盐值,将密码与盐值结合后进行 SHA-256 哈希,最后将
PasswordHash
和Salt
一同存入数据库。登录时:根据用户名取出
PasswordHash
和Salt
。将用户输入的密码与取出的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. 总结与建议
密码安全的演进是一场永不停歇的攻防战。下表是对以上所有哈希算法的总结与对比。
在现代应用程序开发中,BCrypt 因其足够的安全性和易用性,成为了大多数项目的首选。若项目对安全性有更高的要求,那么可以使用 Argon2id 。
- 0
- 0
-
分享