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,兼顾内存硬度与侧信道防护。
| 算法 | 核心参数 | 内存特性 | 推荐场景 |
|---|---|---|---|
| bcrypt | cost(10~12) | 固定约 4KB | 存量系统、库受限环境 |
| scrypt | N、r、p | 可调,128·N·r | 需要内存硬度但无 Argon2 库 |
| Argon2id | m、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。这些是示例基线,实际应结合压测调整。
验证流程的关键在于:盐和参数必须与哈希一起存储,验证时从存储串中解析出来复用,而不是重新生成。下面这张图展示了从注册到验证的完整数据流与存储结构。
四、可落地的实现示例
下面以 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 |
| 升级机制 | 登录成功时按需重哈希 | 参数版本比对逻辑 |
把算法、参数、盐、比较方式和升级路径这五件事同时做对,密码存储才算真正落地。任何一环缺失,都会让前面的努力被绕过。