ASP.NET Core 密码安全实践:PBKDF2、Salt、迭代次数与旧密码平滑升级

密码存储是个"写对了一次就不用再管、写错了会长期裸奔"的模块。这篇用框架的实现讲清楚四件事:为什么不能明文/单次哈希、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;
}

三个要点:

  1. CryptographicOperations.FixedTimeEquals ,不是 == 或 SequenceEqual。普通比较会在第一个不同字节处提前返回,理论上可被计时攻击逐字节猜出哈希。
  2. 解析失败一律返回 false ,不抛异常(FormatException / ArgumentException 都被捕获)------脏数据不应该让登录接口 500。
  3. 按存储串里的参数派生 (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。


六、这套实现不覆盖的部分

安全方案要说清边界:

  1. 不防在线暴力破解。 PBKDF2 只让离线破解变慢。在线撞库要靠登录失败锁定 + 验证码 + 限流------框架里有 SecurityOptions.LoginFailureLimit(默认 5 次)与 LoginLockout(默认 15 分钟),以及 EnableLoginCaptcha。
  2. 不检查密码强度。 是否要求大小写/长度/不在常见密码库,属于产品策略,需要自己加校验。
  3. 不管理"同一密码重复使用"。 要禁止近期密码复用,得自己存历史哈希。
  4. 不替代 HTTPS。 密码在网络上是明文传输的,必须靠 TLS 保护。
  5. 600k 是当前值,不是永久值。 随着硬件发展应该上调;好在格式里带了迭代次数,上调不需要迁移数据。

七、小结

关注点 做法
算法 PBKDF2-HMAC-SHA256(KeyDerivation.Pbkdf2)
格式 算法$迭代次数$salt$hash,参数随哈希一起存
Salt 每条 16 字节随机(RandomNumberGenerator)
迭代次数 默认 600000,上限 1000 万防 DoS
比较 CryptographicOperations.FixedTimeEquals
升级 登录成功后 VerifyAndUpgrade 透明升级
边界 在线爆破、密码强度、历史密码需要另行处理

密码安全的关键不是"用了哪个算法",而是"参数能不能升级、存量能不能平滑迁移"。 把算法和迭代次数写进哈希串,是让这套机制长期可用的前提。


如果你正在用 .NET 10 + Blazor 做后台,密码存储是必须一次性做对的部分。EasyAdminBlazor 的 PBKDF2 实现和升级测试都可以直接对照使用。

相关推荐
gudufy21 小时前
ASP.NET Core 文件上传安全:Content-Type 为什么不能证明文件类型?
imagesharp·admin blazor·easyadminblazor·blazor安全·文件上传安全·magic number
gudufy1 天前
Blazor 安全设计:一个 Blazor Admin 后台需要防哪些攻击?
csrf·ssrf·越权·easyadminblazor·blazor安全
gudufy4 天前
EasyAdminBlazor 审批模块实战:从提交、审批到最终完成
工作流·blazor审批·admin blazor·blazor admin·easyadminblazor