3. 密码存储与验证实战

密码存储的核心目标只有一个:即使数据库被完整拖走,攻击者也无法在可接受的时间内把哈希还原成明文。要做到这一点,必须同时满足三个条件——每个密码使用独立随机盐、使用专门为密码设计的慢哈希算法、参数强度随硬件发展可持续调高。本节围绕 bcrypt、scrypt、Argon2 三种主流算法,讲清选型依据、参数含义,并给出一套可落地的注册与验证实现。

一、为什么不能用 MD5/SHA-256 存密码

通用哈希算法(MD5、SHA-1、SHA-256)的设计目标是“快”,而密码存储需要的恰恰是“慢”。一张消费级显卡每秒可以计算数十亿次 SHA-256,配合预计算的彩虹表,弱密码几乎瞬间被还原。密码哈希算法通过三个手段对抗这种暴力破解:

  • 加盐(Salt):为每个用户生成一段随机值,与密码一起参与哈希。相同密码因盐不同而得到不同结果,彩虹表失效,也无法通过“撞库比对”批量识别相同密码。
  • 迭代/工作因子(Cost):把哈希过程重复成千上万次,人为拉高单次计算成本,使离线爆破的边际成本急剧上升。
  • 内存硬度(Memory-hardness):scrypt 与 Argon2 强制占用大量内存,削弱 GPU/ASIC 并行优势,这是它们相对 bcrypt 的关键升级。

二、三种算法的定位与选型

bcrypt 诞生于 1999 年,成熟稳定、库支持极广,工作因子 cost 每 +1,计算量翻倍。它的短板是内存占用固定且很小(约 4KB),对 GPU 并行爆破的抵抗力弱于新一代算法,且输入长度被截断到 72 字节。

scrypt 引入可配置的内存参数 N、块大小 r、并行度 p,内存占用约为 128·N·r 字节。它显著提高了硬件门槛,但参数组合复杂,配置不当(如 N 过大)容易造成服务端内存耗尽。

Argon2 是 2015 年密码哈希竞赛冠军,也是当前 OWASP 首推方案。它有 Argon2d(抗 GPU)、Argon2i(抗侧信道)、Argon2id(混合,推荐默认)三种变体,参数为内存 m、迭代 t、并行度 p,兼顾内存硬度与侧信道防护。

算法核心参数内存特性推荐场景
bcryptcost(10~12)固定约 4KB存量系统、库受限环境
scryptN、r、p可调,128·N·r需要内存硬度但无 Argon2 库
Argon2idm、t、p可调,m 直接指定新系统首选

选型结论:新项目优先 Argon2id;若运行环境缺少可靠实现,退而选 bcrypt(cost≥10);scrypt 适合已有生态或特定合规要求。三者都远优于任何通用哈希。

三、参数强度与验证流程

参数不是越大越好,而是要在“单次验证耗时”和“抗爆破强度”之间取平衡。工程经验是:让单次哈希在目标服务器上耗时约 100~500 毫秒。OWASP 给出的参考下限为:bcrypt cost≥10;Argon2id m=19MiB、t=2、p=1;scrypt N=2^17、r=8、p=1。这些是示例基线,实际应结合压测调整。

验证流程的关键在于:盐和参数必须与哈希一起存储,验证时从存储串中解析出来复用,而不是重新生成。下面这张图展示了从注册到验证的完整数据流与存储结构。

密码注册与验证数据流(以 Argon2id 为例) 注册阶段 明文密码 随机盐 16B crypto.randomBytes Argon2id(m=19MiB, t=2, p=1) 输出 32B 哈希 数据库存储串(自描述格式) $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash> 算法·版本·参数·盐·哈希 全部内嵌 验证阶段 用户输入密码 读取存储串 解析盐与参数 用相同盐+参数重算哈希 Argon2id(输入, 解析出的盐, 参数) 恒定时间比较 timingSafeEqual(重算值, 存储哈希) 一致→通过 不一致→拒绝 关键约束 ① 盐必须每用户独立随机,绝不复用;② 参数随存储串保存,验证时复用而非重生成; ③ 比较必须恒定时间,避免时序侧信道;④ 登录失败信息统一,不泄露“用户是否存在”。

四、可落地的实现示例

下面以 Node.js 的 argon2 库为例,给出注册与验证的核心代码。注意存储串是自描述的,验证时直接把它交给库即可,无需手动拆解盐和参数。

const argon2 = require('argon2');

// 注册:生成哈希并入库
async function register(password) {
  const hash = await argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 19456,   // 19 MiB
    timeCost: 2,         // 迭代 2 次
    parallelism: 1,
    hashLength: 32
  });
  // hash 形如 $argon2id$v=19$m=19456,t=2,p=1$c2FsdA$...
  return hash;
}

// 验证:从存储串解析参数并重算
async function verify(password, storedHash) {
  try {
    return await argon2.verify(storedHash, password);
  } catch (e) {
    return false; // 存储串损坏或格式错误
  }
}

若使用 bcrypt,注册时用 bcrypt.hash(password, 12),验证用 bcrypt.compare(password, storedHash)。bcrypt 的 cost 建议从 10 起步,按压测结果逐步上调,直到单次耗时接近 250ms。

五、密码策略与参数升级

算法选对只是基础,策略落地决定实际防护上限。建议同时落实以下几点:

  • 长度优先于复杂度:强制最小长度 12 位,允许长密码与空格,不强制“大小写+数字+符号”的机械组合。
  • 弱密码黑名单:拦截常见弱口令、已泄露密码库中的条目,比复杂度规则更有效。
  • 参数可升级:存储串中已记录参数,登录验证成功时可检测当前参数是否低于最新基线,若偏低则用新参数重新哈希并更新数据库,实现“透明升级”。
  • 限速与锁定:对同一账号或 IP 的失败登录做指数退避,防止在线爆破。
参数升级的触发点应放在“验证成功之后”,因为只有此时才持有明文密码,可以安全地重新哈希。切勿在验证失败时重算,否则会放大攻击成本。
不要把盐、参数或哈希算法版本硬编码在业务代码里。它们必须随每条记录存储,否则一旦调整全局参数,所有历史密码将无法验证。

最后给出一个可直接对照的参数基线表,作为上线前的检查依据。表中数值为示例基线,实际部署前应在本机压测确认单次耗时落在 100~500ms 区间。

检查项基线要求判断依据
算法Argon2id / bcrypt(cost≥10)存储串前缀可识别
盐每用户独立,≥16 字节随机相同密码哈希不同
单次耗时100~500ms压测 P95 耗时
比较方式恒定时间代码审查 timingSafeEqual
升级机制登录成功时按需重哈希参数版本比对逻辑

把算法、参数、盐、比较方式和升级路径这五件事同时做对,密码存储才算真正落地。任何一环缺失,都会让前面的努力被绕过。