C#密码安全存储:从MD5哈希、加盐到PBKDF2/BCrypt的演进与实践 1. 项目概述为什么我们还在用MD5加密密码在C#开发中尤其是涉及到用户登录、注册功能的Web应用、桌面软件或者移动端后台密码的安全存储是一个绕不开的基石问题。你可能经常看到类似“密码加密后存储”的要求而MD5往往是第一个被提及的方案。这个项目标题“C# 基于MD5实现密码加密功能附源码”直指一个非常经典且实用的场景。但作为有经验的开发者我们必须清醒地认识到在今天单纯地使用MD5进行密码加密已经是一种过时且存在安全隐患的做法。那为什么还要讨论它因为理解MD5是理解现代密码安全体系的起点。很多遗留系统仍在用它更重要的是通过实现它我们能透彻理解“哈希”、“盐值”、“彩虹表”这些安全核心概念为后续采用更安全的方案如PBKDF2、BCrypt打下坚实基础。简单来说这个项目就是教你如何在C#中使用System.Security.Cryptography命名空间下的MD5类将用户输入的明文密码转换为一串固定长度的、看似随机的哈希值Hash并将这串哈希值存储到数据库中。当用户再次登录时用同样的算法处理其输入的密码比较两次生成的哈希值是否一致。整个过程是单向的理论上无法从哈希值反推出原始密码。接下来我会带你从最基础的实现开始逐步深入到安全性增强、代码优化以及在现代开发中的正确实践。2. 核心原理与安全性深度剖析2.1 MD5算法本质它到底是什么MD5Message-Digest Algorithm 5是一种被广泛使用的密码散列函数可以产生出一个128位16字节的散列值。在C#中我们最终看到的通常是这个128位值转换成的32位十六进制字符串每个字节用两个十六进制字符表示所以是32位。它的核心特性是单向性从输入到输出容易但从输出反推输入在计算上不可行。这是它被用于密码“加密”更准确说是“哈希”或“摘要”的理论基础。雪崩效应输入密码哪怕只改变一个字符例如从“password”改为“passworD”产生的MD5哈希值也会发生巨大、看似无规律的变化。固定长度无论输入是“1”还是一整本书输出的MD5哈希值永远是128位。在C#中我们通过MD5.Create()方法获取MD5算法的实例然后调用ComputeHash(byte[] buffer)方法来完成计算。这个过程本身不涉及“密钥”是一个确定性的公开算法。2.2 为什么“单纯MD5”已经不安全这是本项目的关键认知升级点。虽然MD5算法本身是单向的但它在密码存储场景下存在几个致命缺陷彩虹表攻击由于算法公开且确定攻击者可以预先计算海量常用密码及其对应的MD5哈希值做成一个庞大的“密码-哈希值”映射表这就是彩虹表。当攻击者拿到数据库泄露的MD5哈希值时只需在这个表里一查就能立刻得到对应的原始密码。网上有大量在线的MD5解密网站其原理就是背后有一个巨大的彩虹表数据库。碰撞漏洞从密码学角度MD5的抗碰撞性已被攻破。这意味着攻击者可以找到两个不同的输入产生相同的MD5哈希值。虽然直接用于伪造特定密码有难度但这严重动摇了其作为安全基石的可信度。速度过快现代计算机和GPU可以每秒进行数十亿次MD5计算。这使得暴力破解尝试所有可能组合和彩虹表攻击的效率极高。重要提示因此在任何新的、对安全有要求的项目中绝对不应该直接使用明文MD5哈希来存储密码。我们接下来的实现重点在于理解原理和掌握向更安全方案过渡的方法。2.3 安全性增强的核心加盐为了抵御彩虹表攻击“加盐”是必须的步骤。盐是一个随机生成的、足够长的字符串或字节数组。安全流程如下为用户注册时系统生成一个唯一的随机盐值。将用户密码和这个盐值拼接起来例如password salt或salt password然后对拼接后的字符串计算MD5哈希。将计算得到的哈希值和盐值一起存储到数据库的用户记录中。验证时从数据库取出该用户的哈希值和盐值。将用户输入的登录密码与取出的盐值拼接计算MD5。比较计算出的哈希值与数据库中存储的哈希值是否一致。加盐为什么有效彩虹表是针对“密码-哈希”的映射。加盐后实际哈希的是“密码随机盐”。这个随机盐使得预计算的彩虹表完全失效因为攻击者不可能为每一个可能的盐值都预计算一个彩虹表那将需要天文数字般的存储空间。攻击者必须针对每个用户单独进行暴力破解成本和时间呈指数级增长。3. C#实现MD5哈希与加盐的完整代码解析下面我将提供一个生产环境中更健壮、更安全的实现示例它包含了加盐、多次迭代等增强措施并解释了每一步的考量。3.1 基础工具类MD5哈希生成首先我们创建一个静态工具类提供核心的哈希方法。注意这里我们直接使用.NET框架推荐的MD5.Create()而不是已过时的MD5CryptoServiceProvider。using System.Security.Cryptography; using System.Text; namespace PasswordSecurity.Utils { /// summary /// 密码哈希工具类演示用途生产环境建议使用更安全的算法如PBKDF2或BCrypt /// /summary public static class PasswordHasher { /// summary /// 为密码生成一个随机的盐值 /// /summary /// param namesize盐值的字节长度推荐16字节128位或以上/param /// returnsBase64编码的盐值字符串/returns public static string GenerateSalt(int size 16) { // 使用密码学安全的随机数生成器 using (var rng RandomNumberGenerator.Create()) { byte[] saltBytes new byte[size]; rng.GetBytes(saltBytes); // 用随机字节填充数组 return Convert.ToBase64String(saltBytes); } } /// summary /// 使用MD5和盐值对密码进行哈希 /// /summary /// param namepassword明文密码/param /// param namesalt盐值Base64字符串/param /// param nameiterations迭代次数增加破解成本默认为1MD5本身不支持内置迭代这里模拟流程/param /// returnsBase64编码的哈希值字符串/returns public static string HashPasswordWithMD5(string password, string salt, int iterations 1) { if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException(密码不能为空, nameof(password)); if (string.IsNullOrWhiteSpace(salt)) throw new ArgumentException(盐值不能为空, nameof(salt)); byte[] saltBytes Convert.FromBase64String(salt); byte[] passwordBytes Encoding.UTF8.GetBytes(password); // 将密码和盐拼接 byte[] saltedPassword new byte[passwordBytes.Length saltBytes.Length]; Buffer.BlockCopy(passwordBytes, 0, saltedPassword, 0, passwordBytes.Length); Buffer.BlockCopy(saltBytes, 0, saltedPassword, passwordBytes.Length, saltBytes.Length); byte[] hashBytes saltedPassword; using (var md5 MD5.Create()) { // 模拟迭代多次哈希以增加计算成本实际MD5.Create()每次都是新的这里仅为演示概念 for (int i 0; i iterations; i) { // 注意简单串联迭代对MD5安全性提升有限专业算法如PBKDF2有更科学的迭代机制。 hashBytes md5.ComputeHash(hashBytes); } } return Convert.ToBase64String(hashBytes); } /// summary /// 验证密码是否匹配 /// /summary /// param namepassword待验证的明文密码/param /// param namestoredHash数据库中存储的Base64哈希值/param /// param namestoredSalt数据库中存储的Base64盐值/param /// param nameiterations哈希时使用的迭代次数必须与创建时一致/param /// returns验证通过返回true否则返回false/returns public static bool VerifyPassword(string password, string storedHash, string storedSalt, int iterations 1) { string computedHash HashPasswordWithMD5(password, storedSalt, iterations); // 使用固定时间比较算法防止时序攻击简单场景下直接比较也可此处展示最佳实践 return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(computedHash), Encoding.UTF8.GetBytes(storedHash) ); } } }代码关键点解析GenerateSalt方法使用RandomNumberGenerator.Create()而非Random类来生成盐。Random是伪随机可能被预测而RandomNumberGenerator是密码学安全的随机数生成器CSPRNG能产生高质量的随机数这是安全盐的基石。盐的存储盐不需要保密但必须唯一且随机。我们将其转换为Base64字符串便于存储在数据库的VARCHAR或TEXT字段中。拼接方式代码中使用了Buffer.BlockCopy将密码字节数组和盐字节数组拼接。顺序密码在前还是盐在前需要固定验证时必须使用相同的顺序。迭代次数参数iterations用于模拟“密钥延伸”概念。通过多次哈希故意增加计算一个哈希值所需的时间从而大幅提高暴力破解的成本。对于MD5这只是概念演示因为每次MD5.Create()都是新实例。在生产中应使用原生支持迭代的算法如Rfc2898DeriveBytesPBKDF2。验证方法VerifyPassword方法重新计算哈希并进行比较。注意使用了CryptographicOperations.FixedTimeEquals方法进行比较。普通的字符串比较或.Equals在发现第一个不匹配的字符时会立即返回这可能导致“时序攻击”——攻击者通过测量验证耗时可以逐步推测出正确的哈希值。固定时间比较算法确保无论匹配与否比较耗时都基本相同堵住这个细微但真实存在的安全漏洞。3.2 用户注册与登录的完整流程示例假设我们有一个简单的用户实体和数据库上下文。// 用户实体类 public class User { public int Id { get; set; } public string Username { get; set; } public string PasswordHash { get; set; } // 存储哈希值 public string PasswordSalt { get; set; } // 存储盐值 public int HashIterations { get; set; } 1; // 存储迭代次数为未来升级算法留字段 } // 用户服务类 public class UserService { public (bool Success, string Message, User User) Register(string username, string password) { // 1. 检查用户名是否存在略 // 2. 生成盐 string salt PasswordHasher.GenerateSalt(); // 3. 设置迭代次数此处为演示实际MD5迭代意义不大 int iterations 1000; // 假设我们迭代1000次 // 4. 计算密码哈希 string hash PasswordHasher.HashPasswordWithMD5(password, salt, iterations); // 5. 创建用户对象并保存这里模拟保存 var newUser new User { Username username, PasswordHash hash, PasswordSalt salt, HashIterations iterations }; // _dbContext.Users.Add(newUser); // _dbContext.SaveChanges(); Console.WriteLine($用户注册成功用户名{username} 盐{salt} 哈希{hash}); return (true, 注册成功, newUser); } public (bool Success, string Message, User User) Login(string username, string password) { // 1. 根据用户名从数据库获取用户模拟 // var user _dbContext.Users.FirstOrDefault(u u.Username username); User user GetUserFromDatabase(username); // 假设这个方法能返回用户 if (user null) { return (false, 用户名或密码错误, null); // 统一提示避免暴露用户是否存在 } // 2. 使用存储的盐和迭代次数验证密码 bool isPasswordCorrect PasswordHasher.VerifyPassword(password, user.PasswordHash, user.PasswordSalt, user.HashIterations); if (isPasswordCorrect) { return (true, 登录成功, user); } else { return (false, 用户名或密码错误, null); } } private User GetUserFromDatabase(string username) { // 模拟数据库查询返回一个测试用户 // 实际应从数据库读取 return new User { Id 1, Username testuser, PasswordHash 某个之前计算好的哈希值, // 这里需要替换为真实哈希 PasswordSalt 某个之前生成的盐值, // 这里需要替换为真实盐 HashIterations 1000 }; } }流程要点注册盐生成 - 计算哈希 - 存储用户名、哈希、盐、迭代次数。登录根据用户名取回用户记录含盐和哈希- 用输入密码和存储的盐计算哈希 - 比较哈希值。安全提示登录失败时返回统一的错误信息“用户名或密码错误”而不要提示“用户不存在”或“密码错误”。这可以防止攻击者通过反馈信息枚举出有效的用户名。4. 从MD5升级到现代密码哈希方案理解了MD5加盐的原理后我们必须向前看。.NET提供了远比手动MD5加盐更安全、更专业的API。4.1 使用PBKDF2Rfc2898DeriveBytesPBKDF2Password-Based Key Derivation Function 2是当前推荐的标准之一它内置了加盐和多次迭代的概念。using System.Security.Cryptography; public static class SecurePasswordHasher { // 定义哈希算法、迭代次数和输出长度 private const int SaltSize 16; // 128位盐 private const int HashSize 32; // 256位哈希输出SHA256 private const int Iterations 100000; // 迭代次数应根据硬件性能调整2024年推荐10万次以上 /// summary /// 创建密码哈希 /// /summary public static string Hash(string password) { // 生成密码学安全的随机盐 using (var rng RandomNumberGenerator.Create()) { byte[] salt new byte[SaltSize]; rng.GetBytes(salt); // 使用PBKDF2派生密钥 using (var pbkdf2 new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256)) { byte[] hash pbkdf2.GetBytes(HashSize); // 组合格式迭代次数:盐:哈希 return ${Iterations}:{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}; } } } /// summary /// 验证密码 /// /summary public static bool Verify(string password, string hashedPassword) { // 解析存储的字符串 var parts hashedPassword.Split(:); if (parts.Length ! 3) throw new FormatException(哈希密码格式不正确); int iterations int.Parse(parts[0]); byte[] salt Convert.FromBase64String(parts[1]); byte[] storedHash Convert.FromBase64String(parts[2]); // 使用相同的参数计算输入密码的哈希 using (var pbkdf2 new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256)) { byte[] computedHash pbkdf2.GetBytes(storedHash.Length); // 固定时间比较 return CryptographicOperations.FixedTimeEquals(computedHash, storedHash); } } }PBKDF2的优势标准化是NIST等标准机构认可的算法。可配置迭代次数可以通过增加迭代次数来对抗计算能力的提升而无需修改算法。内置盐管理Rfc2898DeriveBytes类简化了流程。更安全的哈希函数默认使用SHA-1但推荐指定更安全的SHA-256或SHA-512。4.2 使用BCrypt通过第三方库BCrypt是专门为密码哈希设计的算法它自动处理盐的生成和嵌入并且输出字符串包含了算法版本、成本因子迭代次数的对数、盐和哈希值全部整合在一个字符串里使用非常方便。你需要通过NuGet安装类似BCrypt.Net-Next的库。using BCrypt.Net; // 需要安装 BCrypt.Net-Next 包 public static class BcryptPasswordHasher { // 工作因子Work Factor控制计算成本每增加1耗时约翻倍。推荐值10-12。 private const int WorkFactor 11; public static string Hash(string password) { // 自动生成盐并返回包含所有信息的哈希字符串 return BCrypt.HashPassword(password, WorkFactor); } public static bool Verify(string password, string hashedPassword) { // 直接从哈希字符串中提取盐并进行验证 return BCrypt.Verify(password, hashedPassword); } }BCrypt的优势一体化哈希字符串自包含所有信息算法、盐、成本因子存储和验证极其简单。自适应慢哈希算法本身设计得较慢且成本因子可调能有效抵抗硬件破解。广泛认可在安全社区中被广泛认为是存储密码的最佳实践之一。5. 数据库设计与存储实践无论使用哪种哈希方案数据库表的设计都至关重要。CREATE TABLE Users ( Id INT PRIMARY KEY IDENTITY(1,1), Username NVARCHAR(100) NOT NULL UNIQUE, -- 方案1使用MD5/PBKDF2需分开存储 PasswordHash NVARCHAR(500) NOT NULL, -- 存储Base64编码的哈希值 PasswordSalt NVARCHAR(500) NOT NULL, -- 存储Base64编码的盐值 HashAlgorithm NVARCHAR(50) NOT NULL DEFAULT PBKDF2-SHA256, -- 记录使用的算法便于未来升级 HashIterations INT NOT NULL DEFAULT 100000, -- 记录迭代次数 -- 方案2使用BCrypt只需一个字段 -- PasswordHash NVARCHAR(500) NOT NULL, -- 存储BCrypt的完整哈希字符串 CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), LastLoginAt DATETIME2 NULL );字段设计说明PasswordHash存储最终的哈希结果。如果使用BCrypt这个字段就存储其输出的完整字符串如$2a$11$r9xSZh...。如果使用PBKDF2或自定义方案可以存储Base64编码的哈希字节。PasswordSalt如果方案需要单独存盐如PBKDF2或我们自定义的MD5则需要此字段。BCrypt不需要。HashAlgorithm和HashIterations这是非常重要的元数据字段。它们记录了密码是用什么算法和参数哈希的。当未来你需要升级哈希算法比如从MD5迁移到PBKDF2时可以在用户下次成功登录时用新算法重新哈希其密码并更新这些字段。这实现了算法的无缝升级。长度NVARCHAR(500)提供了足够的空间即使对于BCrypt的长输出字符串也绰绰有余。6. 常见问题、陷阱与实战经验6.1 编码问题为什么我的MD5哈希和别人算的不一样这是新手最常见的坑。MD5计算的是字节数组的哈希而不是字符串的哈希。字符串转换为字节数组需要编码。string password hello世界; byte[] bytes1 Encoding.UTF8.GetBytes(password); // 使用UTF-8编码 byte[] bytes2 Encoding.Default.GetBytes(password); // 使用系统默认编码在中文Windows上是GBK byte[] bytes3 Encoding.ASCII.GetBytes(password); // ASCII无法编码中文会丢失信息 // bytes1, bytes2, bytes3 是不同的数组计算出的MD5也完全不同。黄金法则在整个应用的生命周期中必须使用同一种编码。强烈推荐使用Encoding.UTF8因为它是跨平台、跨语言的标准。在HashPasswordWithMD5和VerifyPassword方法中必须使用相同的编码我们在工具类中固定使用了Encoding.UTF8。6.2 哈希输出格式十六进制还是Base64MD5的128位输出16字节通常表示为32位的十六进制字符串每个字节变成两个十六进制字符。但也可以转换为Base64字符串。十六进制可读性好BitConverter.ToString(hashBytes).Replace(-, )即可得到。长度固定32字符。Base64更紧凑Convert.ToBase64String(hashBytes)长度约为24字符。更适合存储在数据库或传输中。选择建议在代码内部处理时使用字节数组byte[]最直接。需要存储或传输时转换为Base64。如果为了调试或与某些旧系统兼容可以输出十六进制。在我们的工具类中为了存储方便统一使用了Base64。6.3 关于“解密MD5”的误解必须再次强调MD5是单向哈希函数不是加密算法。加密如AES、DES是可逆的有密钥即可解密。哈希是不可逆的。网上所谓的“MD5解密”网站实质是彩虹表查询服务。如果你的密码是“123456”它的MD5哈希是公开的当然能“解”出来。但如果你的密码是“MySuperStrongPassw0rd!2024”并且加了随机盐那么任何网站都无法“解密”。6.4 性能考量迭代次数设置多少合适对于PBKDF2迭代次数的目标是使得在你的服务器上计算一次哈希需要大约100到500毫秒。这个延迟对用户登录体验影响微乎其微一次登录只验证一次但足以让攻击者进行大规模暴力破解的速度变得无法接受。你可以写一个简单的基准测试程序来调整迭代次数public static int FindOptimalIterationTime() { string testPassword TestPassword123!; byte[] salt new byte[16]; using (var rng RandomNumberGenerator.Create()) rng.GetBytes(salt); int iterations 10000; var stopwatch new System.Diagnostics.Stopwatch(); while (true) { stopwatch.Restart(); using (var pbkdf2 new Rfc2898DeriveBytes(testPassword, salt, iterations, HashAlgorithmName.SHA256)) { _ pbkdf2.GetBytes(32); } stopwatch.Stop(); if (stopwatch.ElapsedMilliseconds 100) // 目标100ms { Console.WriteLine($达到100ms的迭代次数约为: {iterations}); return iterations; } iterations 10000; // 增加迭代次数 } }在2024年主流服务器的CPU上PBKDF2-SHA256的迭代次数通常在10万到50万次之间才能达到100-500ms。这个值需要定期例如每两年重新评估并增加。6.5 密码策略与前端传输密码复杂度强制要求用户使用足够强度的密码长度、大小写字母、数字、符号。但这只是第一道防线。前端传输永远不要在前端用JavaScript计算密码哈希然后传输。这会让哈希值成为事实上的“密码”如果传输过程不是HTTPS哈希值被截获同样可以用于登录这称为“哈希传递”攻击。正确的做法是前端通过HTTPS传输明文密码到后端由后端进行哈希处理。HTTPS保证了传输过程的安全。密码散列对于特别敏感的系统可以考虑在客户端先进行一次哈希使用固定盐或方案然后在服务端再次加盐哈希。这可以防止原始密码明文在服务端内存中短暂出现尽管在HTTPS下风险已很低并能在服务端数据库泄露时提供额外保护。但这增加了复杂性需谨慎设计。7. 完整可运行的控制台示例与测试最后我们用一个完整的控制台程序来演示整个流程从注册到登录验证。using System; using System.Security.Cryptography; using System.Text; namespace MD5PasswordDemo { class Program { static void Main(string[] args) { Console.WriteLine( C# 密码哈希与验证演示 \n); // 模拟用户注册 string username zhangsan; string rawPassword MySecurePassw0rd!; Console.WriteLine($用户注册); Console.WriteLine($ 用户名: {username}); Console.WriteLine($ 明文密码: {rawPassword}); // 1. 生成盐 string salt GenerateSalt(); Console.WriteLine($ 生成盐: {salt}); // 2. 使用MD5加盐哈希演示用不推荐生产 string md5Hash HashWithMD5AndSalt(rawPassword, salt); Console.WriteLine($ MD5加盐哈希: {md5Hash}); // 3. 使用PBKDF2哈希推荐 string pbkdf2Hash HashWithPBKDF2(rawPassword); Console.WriteLine($ PBKDF2哈希: {pbkdf2Hash}); Console.WriteLine(\n--- 模拟数据库存储 ---); Console.WriteLine($ 存储的用户记录); Console.WriteLine($ Username: {username}); Console.WriteLine($ Salt (for MD5): {salt}); Console.WriteLine($ MD5_Hash: {md5Hash}); Console.WriteLine($ PBKDF2_Hash: {pbkdf2Hash}); // 模拟用户登录 Console.WriteLine(\n 用户登录验证 \n); TestLogin(username, MySecurePassw0rd!, salt, md5Hash, pbkdf2Hash); // 正确密码 TestLogin(username, WrongPassword, salt, md5Hash, pbkdf2Hash); // 错误密码 Console.WriteLine(\n演示结束。); } static string GenerateSalt(int size 16) { using (var rng RandomNumberGenerator.Create()) { byte[] saltBytes new byte[size]; rng.GetBytes(saltBytes); return Convert.ToBase64String(saltBytes); } } static string HashWithMD5AndSalt(string password, string salt) { byte[] saltBytes Convert.FromBase64String(salt); byte[] passwordBytes Encoding.UTF8.GetBytes(password); byte[] combined new byte[passwordBytes.Length saltBytes.Length]; Buffer.BlockCopy(passwordBytes, 0, combined, 0, passwordBytes.Length); Buffer.BlockCopy(saltBytes, 0, combined, passwordBytes.Length, saltBytes.Length); using (var md5 MD5.Create()) { byte[] hashBytes md5.ComputeHash(combined); return Convert.ToBase64String(hashBytes); } } static bool VerifyMD5Hash(string password, string salt, string storedHash) { string computedHash HashWithMD5AndSalt(password, salt); // 简单比较生产环境应用FixedTimeEquals return computedHash storedHash; } static string HashWithPBKDF2(string password, int iterations 100000) { byte[] salt new byte[16]; using (var rng RandomNumberGenerator.Create()) rng.GetBytes(salt); using (var pbkdf2 new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256)) { byte[] hash pbkdf2.GetBytes(32); // 256-bit output return ${iterations}:{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}; } } static bool VerifyPBKDF2Hash(string password, string hashedPassword) { var parts hashedPassword.Split(:); if (parts.Length ! 3) return false; int iterations int.Parse(parts[0]); byte[] salt Convert.FromBase64String(parts[1]); byte[] storedHash Convert.FromBase64String(parts[2]); using (var pbkdf2 new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256)) { byte[] computedHash pbkdf2.GetBytes(storedHash.Length); return CryptographicOperations.FixedTimeEquals(computedHash, storedHash); } } static void TestLogin(string username, string inputPassword, string storedSalt, string storedMD5Hash, string storedPBKDF2Hash) { Console.WriteLine($\n 尝试登录用户 {username}输入密码: {inputPassword}); bool md5Valid VerifyMD5Hash(inputPassword, storedSalt, storedMD5Hash); bool pbkdf2Valid VerifyPBKDF2Hash(inputPassword, storedPBKDF2Hash); Console.WriteLine($ MD5验证结果: {(md5Valid ? 成功 : 失败)}); Console.WriteLine($ PBKDF2验证结果: {(pbkdf2Valid ? 成功 : 失败)}); } } }运行这个程序你会看到完整的流程输出直观地理解盐的生成、哈希的计算以及验证过程。通过这个项目你不仅学会了如何在C#中使用MD5更重要的是理解了密码安全存储的核心原则并掌握了向PBKDF2等更安全方案迁移的路径。记住在安全问题上使用经过充分验证的、标准的库和算法永远比自己从头实现更可靠。