密码存储是个"写对了一次就不用再管、写错了会长期裸奔"的模块。这篇用框架的实现讲清楚四件事:为什么不能明文/单次哈希、Salt 有什么用、迭代次数怎么定、存量密码怎么平滑升级。
一、为什么是 PBKDF2
密码存储的目标不是"无法解密",而是让暴力破解足够慢。
常见错误的演进路线:
text
存明文 → 一次拖库全部账号沦陷
存 MD5 / SHA1 → 撞库表一秒查出来
存 SHA256(password) → 彩虹表 + GPU 每秒几十亿次
存 SHA256(salt + password) → 单次哈希依然太快,8 位密码几小时可破
PBKDF2 / bcrypt / Argon2 → 每次都做几万到几十万轮,破解成本显著上升
PBKDF2 是"可调工作量的哈希",属于 NIST 认可的做法。框架用的是 PBKDF2-HMAC-SHA256:
csharp
private static byte[] Derive(string password, byte[] salt, int iterationCount, int numBytesRequested)
{
return KeyDerivation.Pbkdf2(
password: password,
salt: salt,
prf: KeyDerivationPrf.HMACSHA256,
iterationCount: iterationCount,
numBytesRequested: numBytesRequested);
}
KeyDerivation 来自 Microsoft.AspNetCore.Cryptography.KeyDerivation------ASP.NET Core 生态里的标准实现,不需要额外引第三方库。
二、存储格式:把参数写进哈希串
这是全文最重要的一点:哈希串里必须带算法和参数。
csharp
/// <summary>新格式的算法标识(含版本与 PRF 信息)。</summary>
public const string Algorithm = "pbkdf2-sha256";
/// <summary>当前默认迭代次数(OWASP 对 PBKDF2-HMAC-SHA256 的建议值)。</summary>
public const int DefaultIterationCount = 600000;
/// <summary>旧格式使用的迭代次数,仅用于兼容已有数据。</summary>
public const int LegacyIterationCount = 10000;
public static string HashPassword(string password, int iterationCount = DefaultIterationCount)
{
if (iterationCount <= 0 || iterationCount > MaxIterationCount)
{
iterationCount = DefaultIterationCount;
}
var salt = new byte[SaltSizeInBytes];
RandomNumberGenerator.Fill(salt);
var hash = Derive(password, salt, iterationCount, HashSizeInBytes);
return $"{Algorithm}${iterationCount}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
存进数据库的字符串长这样:
text
pbkdf2-sha256$600000$k3Jv...(salt,Base64)$9Qm2...(hash,Base64)
四个部分:算法标识 / 迭代次数 / Salt / 哈希值。这样做的好处是将来调参数不需要迁移数据:老串里写着 10000,新串里写着 600000,验证时各按各的参数算。
Salt:防彩虹表与"相同密码相同结果"
csharp
var salt = new byte[SaltSizeInBytes]; // 16 字节
RandomNumberGenerator.Fill(salt);
Salt 必须是每条记录独立、随机生成 的(用 RandomNumberGenerator,不是 Random)。测试里直接锁定了这一点:
csharp
[Fact]
public void HashPassword_SamePassword_ProducesDifferentHashes()
{
var a = PBKDF2Encrypt.HashPassword("same-password");
var b = PBKDF2Encrypt.HashPassword("same-password");
a.Should().NotBe(b, "每次都必须使用新的随机盐");
}
两个用户密码相同,哈希串完全不同 → 彩虹表失效,也看不出"这两个人用了同一个密码"。
迭代次数:600000 是怎么来的
csharp
public const int DefaultIterationCount = 600000;
源码注释写得很直接:"OWASP 对 PBKDF2-HMAC-SHA256 的建议值"。测试里还有一条断言防退化:
csharp
PBKDF2Encrypt.DefaultIterationCount.Should().BeGreaterThan(10000, "迭代次数必须高于旧值");
迭代次数不是一个固定真理,它随硬件发展上调。工程上的取舍是:
| 迭代次数 | 单次校验耗时(量级) | 适用 |
|---|---|---|
| 10,000(旧值) | 几毫秒 | 已不合适,仅兼容存量 |
| 600,000(当前) | 几十到上百毫秒 | 登录场景可接受 |
| 更高 | 秒级 | 会明显拖慢登录,需要配合限流与体验优化 |
注意这是"每次登录都要付出"的成本:用户登录时服务端要算一次,所以不能无限调高。600k 的量级下,正常登录体验无感,但让离线爆破的成本上升两三个数量级。
上限保护:防伪造串拖垮服务
csharp
/// <summary>迭代次数上限,防止被伪造的超大迭代次数拖垮服务(DoS)。</summary>
private const int MaxIterationCount = 10_000_000;
解析时也会校验:
csharp
if (!int.TryParse(parts[1], out iterationCount) ||
iterationCount <= 0 ||
iterationCount > MaxIterationCount)
{
return false;
}
如果没有这个上限,一条形如 pbkdf2-sha256$999999999$... 的记录就能让登录接口把 CPU 打满。测试里用了两个极端值验证:
csharp
[InlineData("pbkdf2-sha256$0$c2FsdA==$aGFzaA==")]
[InlineData("pbkdf2-sha256$999999999999$c2FsdA==$aGFzaA==")]
public void VerifyPassword_InvalidInput_ReturnsFalse(string hashed)
三、验证:必须用固定时间比较
csharp
public static bool VerifyPassword(string password, string hashedPassword)
{
if (string.IsNullOrEmpty(hashedPassword)) return false;
try
{
if (TryParseCurrentFormat(hashedPassword, out var iterationCount, out var salt, out var expectedHash))
{
var actual = Derive(password, salt, iterationCount, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
}
if (TryParseLegacyFormat(hashedPassword, out salt, out expectedHash))
{
var actual = Derive(password, salt, LegacyIterationCount, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
}
}
catch (FormatException) { return false; }
catch (ArgumentException) { return false; }
return false;
}
三个要点:
CryptographicOperations.FixedTimeEquals,不是==或SequenceEqual。普通比较会在第一个不同字节处提前返回,理论上可被计时攻击逐字节猜出哈希。- 解析失败一律返回 false ,不抛异常(
FormatException/ArgumentException都被捕获)------脏数据不应该让登录接口 500。 - 按存储串里的参数派生 (
iterationCount/expectedHash.Length),所以新旧格式能共存。
四、平滑升级:不能让存量密码失效
升级参数时最怕"一刀切":要么强制所有人改密码(用户流失),要么写一个一次性迁移脚本(要拿到明文密码,做不到)。
框架的做法是登录成功后透明升级:
csharp
/// <summary>
/// 判断存储的哈希是否需要升级到当前新格式(旧格式或迭代次数偏低)。
/// </summary>
public static bool NeedsUpgrade(string? hashedPassword)
{
if (string.IsNullOrEmpty(hashedPassword)) return false;
return !TryParseCurrentFormat(hashedPassword, out var iterationCount, out _, out _)
|| iterationCount < DefaultIterationCount;
}
/// <summary>
/// 校验密码并在需要时返回升级后的新格式哈希。
/// 用于登录成功后透明升级旧密码,避免强制所有用户改密。
/// </summary>
public static bool VerifyAndUpgrade(string password, string hashedPassword, out string? upgradedHash)
{
upgradedHash = null;
if (!VerifyPassword(password, hashedPassword)) return false;
if (NeedsUpgrade(hashedPassword))
{
upgradedHash = HashPassword(password);
}
return true;
}
调用方的流程:
text
用户提交密码
↓ VerifyAndUpgrade(password, storedHash, out var upgraded)
↓ 验证失败 → 返回登录失败
↓ 验证成功
├─ upgraded == null(已经是当前格式)→ 什么都不做
└─ upgraded != null(旧格式/迭代偏低)→ 把新哈希写回数据库
这个方案的关键性质:
| 性质 | 说明 |
|---|---|
| 零强制改密 | 用户完全无感,登录即完成升级 |
| 只升一次 | 升级后 NeedsUpgrade 返回 false,不会反复重写 |
| 密码错误不升级 | VerifyAndUpgrade 先验证再判断,避免把错误密码写成新哈希 |
| 只用到明文一次 | 明文本来就是这次登录的输入,不需要额外采集 |
测试把四种情况都锁死了:
csharp
[Fact] public void VerifyAndUpgrade_LegacyHash_ReturnsNewHash()
[Fact] public void VerifyAndUpgrade_CurrentHash_DoesNotUpgradeAgain()
[Fact] public void VerifyAndUpgrade_WrongPassword_DoesNotUpgrade()
[Fact] public void VerifyPassword_LegacyFormat_StillWorks()
其中 VerifyPassword_LegacyFormat_StillWorks 用的是手工构造的旧格式串 (salt:hash,10000 次迭代),而不是从旧版本复制数据------测试自己就能生成兼容样本。
五、还有哪些地方用到这套哈希
| 场景 | 位置 | 说明 |
|---|---|---|
| 初始化管理员 | SeedData.cs |
PBKDF2Encrypt.HashPassword("123yyq") |
| 新建租户管理员 | Pages/Tenant.razor |
PBKDF2Encrypt.HashPassword($"{e.Item.Code}123") |
| 用户改密码 | AdminContext.User.UpdatePassword |
先 VerifyPassword 校验旧密码,再写入新哈希 |
改密码这段也值得看一眼:
csharp
public async Task<bool> UpdatePassword(SysUser user, UserPassword model)
{
var entity = await Orm.Select<SysUser>().Where(a => a.Id == user.Id).FirstAsync();
if (entity != null && PBKDF2Encrypt.VerifyPassword(model.OldPassword, entity.Password))
{
user.Password = PBKDF2Encrypt.HashPassword(model.NewPassword);
return await Orm.Update<SysUser>()
.Where(a => a.Id == user.Id)
.Set(a => a.Password, user.Password)
.Set(a => a.ForcePasswordChange, false)
.ExecuteAffrowsAsync() > 0;
}
return false;
}
改密码必须先验旧密码 (否则会话被劫持后可以直接改密码),并且顺手把 ForcePasswordChange 置为 false。
六、这套实现不覆盖的部分
安全方案要说清边界:
- 不防在线暴力破解。 PBKDF2 只让离线破解变慢。在线撞库要靠登录失败锁定 + 验证码 + 限流------框架里有
SecurityOptions.LoginFailureLimit(默认 5 次)与LoginLockout(默认 15 分钟),以及EnableLoginCaptcha。 - 不检查密码强度。 是否要求大小写/长度/不在常见密码库,属于产品策略,需要自己加校验。
- 不管理"同一密码重复使用"。 要禁止近期密码复用,得自己存历史哈希。
- 不替代 HTTPS。 密码在网络上是明文传输的,必须靠 TLS 保护。
- 600k 是当前值,不是永久值。 随着硬件发展应该上调;好在格式里带了迭代次数,上调不需要迁移数据。
七、小结
| 关注点 | 做法 |
|---|---|
| 算法 | PBKDF2-HMAC-SHA256(KeyDerivation.Pbkdf2) |
| 格式 | 算法$迭代次数$salt$hash,参数随哈希一起存 |
| Salt | 每条 16 字节随机(RandomNumberGenerator) |
| 迭代次数 | 默认 600000,上限 1000 万防 DoS |
| 比较 | CryptographicOperations.FixedTimeEquals |
| 升级 | 登录成功后 VerifyAndUpgrade 透明升级 |
| 边界 | 在线爆破、密码强度、历史密码需要另行处理 |
密码安全的关键不是"用了哪个算法",而是"参数能不能升级、存量能不能平滑迁移"。 把算法和迭代次数写进哈希串,是让这套机制长期可用的前提。
如果你正在用 .NET 10 + Blazor 做后台,密码存储是必须一次性做对的部分。EasyAdminBlazor 的 PBKDF2 实现和升级测试都可以直接对照使用。