3、密码安全策略:哈希算法(bcrypt/argon2),加盐与密钥派生

密码存储,是权限系统的第一道防线。

很多人觉得,把密码加密一下存进数据库就完事了。嗯,我以前也这么想。直到有一次做安全审计,看到某个老系统里存的密码还是 MD5 的……说实话,那感觉就像看到自家大门用的是塑料锁。

这一章,我们就把密码存储这件事彻底讲透。从哈希算法到加盐,再到现代的密钥派生函数,一步步把防线筑牢。

3.1 为什么不能直接存密码?

直接存明文密码,等于把用户的家底全亮出来。一旦数据库泄露,所有账号瞬间沦陷。

那用 MD5 或 SHA-1 哈希一下总行了吧?

也不行。你想想看,现在彩虹表满天飞,GPU 跑 MD5 每秒能算几十亿次。我见过一个项目,用的就是 SHA-256 直接哈希密码,结果被撞库撞得干干净净。

⚠️ 核心原则: 密码存储必须使用慢速、加盐、自适应的哈希算法。MD5、SHA-1、SHA-256 这些快速哈希,不能用来存密码。

3.2 加盐:让每个密码都不一样

加盐,说白了就是在密码后面拼一段随机字符串,然后再做哈希。

为什么要加盐?

  • 防止彩虹表预计算攻击
  • 让相同密码产生不同哈希值
  • 增加破解成本

盐的要求很简单:每个用户、每次密码变更,都用新的随机盐。长度至少 16 字节,用密码学安全的随机数生成器。

💡 我的习惯: 盐和哈希值一起存,不用单独保护。盐的作用是让预计算失效,不是保密。

3.3 哈希算法对比:bcrypt vs argon2

目前业界主流的密码哈希算法,就两个:bcrypt 和 argon2。

特性 bcrypt argon2
诞生时间 1999 年 2015 年(PHC 冠军)
抗 GPU 攻击 强(内存需求固定) 极强(可调内存/并行度)
抗 ASIC 攻击 中等 强
参数调节 cost factor(2^n 轮) 时间、内存、并行度
输出格式 自带盐和版本 自带盐和参数
推荐场景 通用场景,兼容性好 高安全场景,新项目首选

我个人建议:新项目直接上 argon2。如果项目依赖老框架只能用 bcrypt,那就把 cost 调到能接受的最高值。

3.4 密钥派生:从密码到密钥

有时候,我们不只要验证密码,还要用密码派生出一个加密密钥。比如做端到端加密、本地数据加密。

这时候就需要密钥派生函数(KDF)。

常见的 KDF 有:

  • PBKDF2:老牌,但抗 GPU 能力弱
  • scrypt:增加了内存消耗,比 PBKDF2 强
  • argon2id:推荐,同时抗 GPU 和 ASIC
📌 关键区别: 密码哈希(Password Hashing)用于验证密码,输出固定长度哈希;密钥派生(Key Derivation)用于生成加密密钥,输出指定长度的密钥材料。但 argon2 可以同时胜任这两个角色。

3.5 实战:用 argon2 存储密码

下面是一个 Node.js 的实现示例。我用的是 argon2 这个 npm 包。

const argon2 = require('argon2');

// 注册:哈希密码
async function hashPassword(password) {
  try {
    const hash = await argon2.hash(password, {
      type: argon2.argon2id,   // 推荐类型
      memoryCost: 65536,       // 64 MB 内存
      timeCost: 3,             // 3 次迭代
      parallelism: 4,          // 4 个并行线程
      saltLength: 16           // 16 字节随机盐
    });
    return hash;  // 输出包含盐和参数的完整字符串
  } catch (err) {
    throw new Error('密码哈希失败');
  }
}

// 登录:验证密码
async function verifyPassword(hash, password) {
  try {
    return await argon2.verify(hash, password);
  } catch (err) {
    return false;
  }
}

// 使用示例
const storedHash = await hashPassword('MySecureP@ss123');
const isValid = await verifyPassword(storedHash, 'MySecureP@ss123');
console.log(isValid); // true

注意看,argon2.hash() 返回的字符串里已经包含了盐、参数和哈希值。你不需要自己管理盐,直接存这个字符串就行。

⚠️ 我曾经踩过的坑: 有一次我把 memoryCost 设成了 131072(128 MB),结果在低配服务器上,并发登录时直接 OOM 了。调参数一定要考虑生产环境的硬件限制。

3.6 密码策略的完整流程

一个完整的密码安全策略,不只是哈希算法。它应该包含:

  1. 密码强度校验:长度、复杂度、常见密码黑名单
  2. 加盐哈希存储:使用 argon2 或 bcrypt
  3. 密码历史记录:防止重复使用旧密码
  4. 登录限速:防止暴力破解
  5. 密码过期策略:定期更换(可选)

下面这张图,把整个流程串起来了。

密码安全策略完整流程 用户注册/改密 密码强度校验 拒绝:提示用户 生成随机盐(16B) argon2id 哈希 存储哈希值 用户登录 argon2.verify() 登录成功 登录失败 关键:盐自动包含在哈希输出中,无需单独存储;每次哈希都生成新盐

3.7 避坑指南

这些年我见过不少密码存储翻车的案例。总结几个最常见的坑:

  • 不要自己实现哈希算法:用标准库,别造轮子。我曾经见过有人自己写了个“加强版 MD5”,结果安全性还不如原版。
  • 不要截断哈希输出:argon2 的输出长度是固定的,别为了省空间截断。截断等于降低安全性。
  • 不要复用盐:每个用户、每次密码变更,都必须生成新盐。全局固定盐等于没加盐。
  • 不要忽略参数调优:生产环境要压测,找到安全性和性能的平衡点。我一般从 memoryCost=65536、timeCost=3 开始调。
  • 不要忘记密码历史:用户改密码时,要检查新密码是否和最近 5 次历史密码相同。
💡 我的建议: 如果团队对密码安全不太熟悉,直接用 argon2 的默认参数。默认值虽然保守,但至少不会出大问题。等跑顺了再慢慢调优。

密码存储这件事,说难不难,说简单也不简单。核心就一句话:用慢速哈希、加随机盐、调好参数。做到这三点,你的系统在密码存储这块就及格了。