密码必须使用专门的慢哈希算法,例如 Argon2id、scrypt 或 bcrypt。SHA-256 设计目标是快,GPU 每秒可以尝试数十亿次。不要存密码的 SHA-256 摘要,也不要用可逆加密保存密码。
- SHA-256、MD5 等通用哈希太快,不适合密码存储。
- bcrypt 自带盐和成本因子;Argon2id 还增加内存硬度,是新系统首选。
- 每个密码使用唯一盐,校验时使用常量时间比较。
- 密码哈希不是加密,任何时候都不应该能还原原始密码。
- 还要配合限流、MFA、泄露监控和独立存储的 pepper。
密码存储是少数“快”反而是缺陷的场景。一个毫秒级完成的哈希,对登录接口很方便,对拿到数据库的攻击者同样方便。
这篇文章从威胁模型出发,比较常见算法,并说明哈希之外还需要哪些控制。
威胁模型
假设攻击者已经拿到用户表。他能做什么?
- 存明文:所有密码直接泄露。
- 存 SHA-256:GPU 每秒可以尝试数十亿个候选密码。
- 存慢速加盐哈希:每次猜测都需要显著的时间和内存。
- 加密密码:密钥泄露后全部可解密。
差距不是一个小常数。具体速率取决于硬件和实现,但数量级才是关键:
| 存储形式 | 离线猜测速度(约) | 实际含义 |
|---|---|---|
| 明文或可逆加密 | 无上限 | 根本不需要破解 |
| MD5 或 SHA-1 | 数百亿次/秒 | 常见密码空间很快被穷举 |
| SHA-256 | 数十亿到数百亿次/秒 | 一张 GPU 每秒能试很长的候选列表 |
| bcrypt,cost 12 | 数千次/秒 | 每次猜测都要花 CPU 和内存带宽 |
| Argon2id,19 MiB,t=2 | 数百到数千次/秒 | 每次猜测都要分配内存,并行度受限 |
这张表是规划用的,不是 benchmark。重点是:快哈希把“拿到数据库”变成几个小时的离线工作,慢哈希把它变成长期、昂贵的工作。攻击者每秒能试 100 亿个 SHA-256 候选时,一个符合常见模式的 8 位密码很快就会被攻破;同一个数据库换成 cost 12 的 bcrypt,速率会下降六到七个数量级。
密码哈希不能阻止泄露,但能限制泄露后的损失。
为什么 SHA-256 是错误工具
SHA-256 是为完整性和速度设计的。现代 GPU 每秒可以计算海量 SHA-256。加盐可以防预计算彩虹表,但不能阻止针对性的暴力猜测。
SHA-256: 快、确定性、没有内置盐
bcrypt: 设计上就慢,自带盐和成本因子
Argon2: 慢且内存硬,参数可调
用 SHA-256 存密码,就像用一把“试够次数就能瞬间打开”的锁。
一个具体向量能让问题直观看出来。SHA-256("password") 是 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8,而一张 GPU 每秒可以对这个候选算出数十亿次。加盐改变了输入,但没有改变速度:SHA-256(salt + password) 仍然只多一次压缩函数。hashcat 里裸 SHA-256 是模式 1400,加盐 SHA-256 是模式 1420,两者都针对吞吐优化;bcrypt 是模式 3200,每次猜测慢好几个数量级。针对某个账号,攻击者不需要彩虹表,只需要字典、规则集和几个小时。
pepper 也救不了快哈希。pepper 和数据库放一起,攻击者直接拿到;放在别处,它只提高“第一次猜测”的成本,一旦泄露,后续每次猜测的速度不变。带 pepper 的 HMAC-SHA-256 仍然是快函数。
SHA-256 在别的场景是正确的:文件完整性、内容寻址、文档签名、从高熵密钥派生。规则针对的是输入,不是算法本身:密码是低熵、可猜的,所以函数必须慢;128 位随机 API Key 没有可猜结构,用快哈希可以接受,前提是常量时间比较,并且永远不记录 Key 本身。
bcrypt 怎么工作
bcrypt 从密码、盐和成本因子派生哈希。成本因子是指数的,每加 1,工作量翻倍。
$2b$12$Fh8sK2... <- 算法、成本、盐、哈希
关键特性:
- 每个密码生成唯一盐。
- 成本因子可以随硬件升级而提高。
- 输出里包含算法、成本和盐,校验时只需要存储字符串。
- 输入限制为 72 字节;更长的密码需要预哈希或明确拒绝。
校验必须使用库提供的常量时间比较:
import bcrypt from "bcryptjs";
const hash = await bcrypt.hash(password, 12);
const valid = await bcrypt.compare(candidate, hash);
不要自己用 === 比较哈希字符串。库的比较函数设计上会避免时序泄露。
存储串是自描述的:$2b$12$ 是版本和成本,接下来 22 个字符是 bcrypt 专用 Base64 编码的 128 位盐,最后 31 个字符是哈希,完整记录 60 个字符。$2a$ 和 $2y$ 出现在旧记录或 PHP 生成的记录里,多数库三种都接受,但跨语言迁移时应该拿一个已知哈希实际验证,而不是假设兼容。成本是指数级的:cost 10 是 1,024 次迭代,cost 12 是 4,096 次,每加 1 工作量翻倍,校验耗时也大致翻倍。
72 字节限制是真实的正确性 bug,不是理论脚注。用 bcryptjs 时,把 "a".repeat(72) + "X" 哈希,再用 "a".repeat(72) 去比较会返回 true,因为末尾的 X 被静默截断了。用户选了一个 73 字节的长密码,后来只改了最后一个字符,仍然能用旧值登录;两个不同的长密码也可能共享同一个哈希。修复方式有两种:先用 SHA-256 或 HMAC-SHA-256 预哈希,把摘要用 Base64 或十六进制编码后交给 bcrypt;或者明确拒绝超过 72 字节的输入并给出清晰提示。预哈希也有坑:不要把含 NUL 字节的原始摘要交给把 NUL 当终止符的实现,Base64 或十六进制可以避开。方案要写进文档,因为以后一改,所有存量哈希都会失效。
成本选择是延迟预算问题。要在生产硬件、峰值并发下测量真实的 compare 耗时,而不是在笔记本上测。如果 cost 12 要 250 毫秒,登录接口每个实例允许 40 个并发尝试,CPU 会在数据库之前先饱和;排队和限流属于设计的一部分。硬件升级后要重新 benchmark,成本已经写在哈希里,旧记录可以在登录时升级。
Argon2id 怎么工作
Argon2id 是内存硬密码哈希函数,赢得过 Password Hashing Competition,OWASP 推荐新系统使用。
参数:
- 内存:每次哈希使用多少 RAM。
- 迭代次数:在内存上跑几遍。
- 并行度:可以并行多少条通道。
import argon2 from "argon2";
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // KiB
timeCost: 2,
parallelism: 1
});
const valid = await argon2.verify(hash, candidate);
内存硬度会提高 GPU 和 ASIC 攻击成本,因为每次猜测都需要大块内存。具体参数取决于硬件和延迟预算,应该实测,不要盲目复制。
存储格式是 PHC 字符串,例如 $argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$...。参数就嵌在记录里,校验时直接从字符串读取,不需要再去查配置。OWASP 对 Argon2id 的最低建议是 19 MiB 内存、2 次迭代、1 条并行通道(m=19456,t=2,p=1);延迟预算允许时,64 MiB、3 次迭代更强。parallelism 是并行通道数,不是“分配几个 CPU 核”,调高会让多核攻击者也变快,除非实测过取舍,否则保持适度。
Argon2id 是混合模式:第一轮的前半段与数据无关(类似 Argon2i,抗侧信道),其余部分与数据相关(类似 Argon2d,抗时间-内存权衡)。这也是推荐 id 变体、而不是纯 i 或纯 d 的原因。
内存成本带来一个 bcrypt 没有的可用性风险。每次哈希 19 MiB,100 个并发登录就是约 1.9 GiB;一波撞库请求可能在限流生效之前就耗尽进程内存或触发 OOM killer。要用信号量或队列限制并发哈希数,队列满时返回 429 Too Many Requests 和 Retry-After,并监控分配失败。安全但一压就挂的密码哈希,不是成功的设计。
scrypt
scrypt 也是内存硬函数,生态支持广。没有 Argon2id 时是合理选择。参数包括 N(CPU/内存成本)、r(块大小)、p(并行度)。同样需要实测并记录参数。
内存占用大约是 128 * N * r 字节,N=2^17, r=8, p=1 需要约 128 MiB,这是 OWASP 对 scrypt 的最低建议,远高于大多数默认配置。Node.js 的 crypto.scrypt 默认 maxmem 是 32 MiB,直接传这组参数会抛 RangeError [ERR_CRYPTO_INVALID_SCRYPT_PARAMS]: Invalid scrypt params: ... memory limit exceeded。调高 maxmem 能解决眼前的报错,但会加剧并发和 OOM 问题,所以先把并发池设计好再调。$scrypt$ln=17,r=8,p=1$... 这样的 PHC 记录会把参数和哈希放在一起;如果你的库用了别的编码,要写进文档。
scrypt 比 Argon2 更早,也没有 Argon2id 的混合抗侧信道设计,但它被分析了很多年,在 Argon2 不可用或没有打包的环境里是站得住脚的选择。不要自己用 SHA-256 加循环发明 KDF,用经过评审的实现。
盐、pepper 和密钥
盐是每个密码唯一的,和哈希一起存储,用于防止相同密码产生相同哈希,并防预计算表。
pepper 是应用于所有密码的秘密值,存储在数据库之外,例如环境变量或 KMS。它提供纵深防御:只拿到数据库的攻击者得不到 pepper。pepper 不能替代慢哈希,轮换 pepper 时需要在登录时重新哈希。
存储值 = slow_hash(password + pepper, unique_salt)
不要把 pepper 和哈希存在同一个数据库里。
细节决定 pepper 到底有没有用。盐至少 16 字节,来自密码学安全随机数生成器,每个密码独立生成,和哈希一起存;用邮箱派生的确定性盐比全局常量好,但仍然允许针对已知用户列表做预计算。pepper 的作用方式:bcrypt 之前先算 HMAC-SHA-256(pepper, password);或者用 Argon2 专门的 secret 参数,它本来就为此设计,会混入计算且不会写进 PHC 字符串。如果只是把 pepper 当普通文本拼在前面(pepper + password),更容易犯长度扩展或编码错误;HMAC 或内置 secret 参数更干净。
轮换需要版本号。每条哈希记录存 pepper_version,过渡期同时保留当前和上一版 pepper,先试当前再试旧的,成功登录时重新哈希。没有双读支持的 pepper 轮换会让所有用户都收到“密码错误”,支持团队还无法把它和真实泄露区分开。动线上密钥之前,先用接近生产的数据在预发环境演练一遍。
校验和迁移
把算法和参数和哈希一起存储。校验时读取算法标识并分派:
type StoredPassword =
| { algorithm: "bcrypt"; hash: string }
| { algorithm: "argon2id"; hash: string }
| { algorithm: "sha256-legacy"; hash: string };
用户登录成功时,如果存储的是旧算法或弱成本,立即用当前策略重新哈希并更新记录。这样可以在不强制重置密码的情况下完成迁移。
并发会让更新比看起来复杂。两个登录同时用旧哈希校验成功,各自用不同的盐算出新哈希,然后都写入;后写的赢,这没关系,因为两个新哈希都能验证同一个密码。更糟的竞态是改密请求落在“校验”和“重新哈希”之间:重新哈希会用旧密码覆盖刚设置的新密码。用条件更新,例如 UPDATE users SET password_hash = $new WHERE id = $id AND password_hash = $old;影响行数为 0 就放弃这次重哈希,等下次登录再试。多数库有 needsRehash 或 getRounds,把策略集中在一处,不要散落在各种比较里。
迁移救不了从不登录的用户。按算法统计存量旧哈希的数量并设截止日期;到期后对剩余账号强制重置,而不是让快哈希无限期留着。一个“临时”保留了三年 SHA-256 的列,就是长期负债。
校验路径本身要有测试:正确密码返回 true,错误密码返回 false,旧成本触发重哈希,未知算法安全失败而不是抛 500,73 字节密码的行为符合文档。
同样重要的运营控制
强哈希不能弥补薄弱的认证流程:
- 按账号和 IP 限制登录尝试。
- 敏感账号启用 MFA。
- 监控泄露密码库,命中后强制重置。
- 全站使用 HTTPS。
- 不要记录密码、重置 Token 或会话标识。
- 重置 Token 短期有效且只能使用一次。
- 未知用户和错误密码使用接近的响应时间,减少账号枚举。
即使代码看起来正确,时序也很容易出错。未知邮箱因为没算哈希 5 毫秒返回,已存在邮箱配错密码因为跑了 bcrypt 250 毫秒返回,攻击者拿秒表就能枚举账号。修复方式是用户不存在时也拿一个固定的 dummy hash 验证,让两条路径付同样的成本,并返回同样的通用错误和状态码。真实原因记服务端日志,不要放进响应。
限流本身也有取舍。按账号锁定能挡住定向攻击,但攻击者也能借此把受害者锁在门外;按 IP 限制对分布式僵尸网络效果有限;全局限流保护了服务,却保护不了单个账号。应该叠加信号:按账号指数退避、IP 信誉、设备指纹,超过阈值后要求 CAPTCHA 或 MFA。返回 429 和 Retry-After,而不是笼统的 403,同时确保限流器本身不会变成未认证的拒绝服务入口。
MFA 和泄露监控处理的是哈希管不了的攻击。一个正确哈希的密码,如果在别的泄露站点复用过,仍然是有效凭证;新密码要拿泄露密码库比对(用 k-anonymity,避免把完整密码发出去),命中就强制重置。这些控制不能替代慢哈希,慢哈希也不能替代它们。
常见错误
| 错误 | 风险 | 修复 |
|---|---|---|
| 用 SHA-256 或 MD5 存密码 | 离线快速破解 | 使用 Argon2id、scrypt 或 bcrypt |
| 所有用户共用一个盐 | 彩虹表和批量破解 | 每个密码生成唯一盐 |
| 加密密码 | 密钥泄露导致全部暴露 | 哈希,不加密 |
用 === 比较哈希 | 时序侧信道 | 使用库的 verify 函数 |
| 忽略 bcrypt 的 72 字节限制 | 静默截断 | 预哈希或明确限制长度 |
| pepper 存在数据库里 | 没有纵深防御 | 存到密钥管理系统 |
| 没有迁移路径 | 旧快速哈希长期存在 | 登录成功时重新哈希 |
| 预哈希方案没有文档 | 以后无法复现校验 | 方案版本化并做跨语言测试 |
| Argon2 内存参数太低 | GPU 攻击仍然便宜 | 用 OWASP 最低值并实测 |
| 内存硬哈希没有并发上限 | 登录突刺时 OOM | 排队并返回 429 |
| 用 bcrypt 哈希高熵 API Key | 每次请求浪费 CPU | 用 HMAC-SHA-256 + 常量时间比较 |
| 没有 pepper 版本字段 | 轮换后所有用户被锁 | 存版本并双读 |
测试哈希
密码哈希生成工具 可以生成 bcrypt 等哈希用于开发。bcrypt 校验工具 和 scrypt 校验工具 可以确认存储哈希是否匹配候选密码,不需要写脚本。SHA-256 生成工具 适合完整性校验,不适合密码。
只使用测试值。不要把生产密码哈希粘贴到公开工具里,也不要把浏览器工具放进生产认证链路。
本地检查可以这样做:用库允许的最低成本快速生成一个哈希,验证正确密码,验证错误密码,确认错误情况返回 false 而不是抛异常;再用一个 73 字节以上的长密码重复,观察代码是截断、拒绝还是预哈希。跨语言时,在一个运行时生成 $2b$ 哈希,在另一个运行时验证;PHP 的 password_hash 输出 $2y$,有的库需要显式兼容标志。Argon2 要确认 PHC 字符串的版本和参数经过数据库往返后没有变化;TEXT 列太短会静默截断哈希,之后每次登录都报格式错误,看起来却像密码错误。站内的 bcrypt 和 scrypt 校验工具适合用测试值做这些检查,不要拿生产记录去试。
建议
新系统使用 Argon2id,参数在本机硬件上实测。平台或合规要求倾向 bcrypt 时,选择能让校验耗时落在延迟预算内的成本因子,并显式处理 72 字节限制。旧快速哈希在登录时迁移,把 pepper 放在数据库之外,并把认证当作一个系统:哈希只是其中一层,不是全部防线。
合规要求使用 FIPS 140 验证过的密码模块时,这套建议不适用。Argon2id 和 bcrypt 都不是 FIPS 批准的密码哈希函数;验证过的部署通常使用 PBKDF2-HMAC-SHA-256,迭代次数要足够高(OWASP 给 PBKDF2-HMAC-SHA-256 的底线是 600,000 次),并在验证边界内运行,或者使用厂商提供的密码哈希服务。具体要求要找合规负责人确认,不要假设;运行时开了“FIPS 模式”并不会自动让未批准的算法合规。
上线前确认五件事:每个密码都有唯一盐;算法和成本写在记录里;校验走库提供的常量时间比较;登录时重哈希的路径有测试,旧哈希有截止日期;登录、重置、注册共用同一套限流和防枚举措施。任何一项缺失,bcrypt 和 Argon2id 选哪个都不是最重要的问题。
常见问题
SHA-256 加盐可以用来存密码吗?
加盐只能避免相同密码产生相同哈希,但 SHA-256 仍然太快。攻击者用 GPU 可以尝试海量加盐猜测。应该使用可调成本的密码哈希算法,例如 Argon2id、scrypt 或 bcrypt。
bcrypt 现在还安全吗?
在成本因子足够、并且正确处理 72 字节输入限制的前提下,bcrypt 仍然可以使用。新系统一般更推荐 Argon2id,因为它增加内存硬度,能提高 GPU 和 ASIC 攻击成本。
应该加密密码而不是哈希吗?
不应该。加密可逆,密钥泄露会暴露所有密码。密码校验只需要比较派生值,所以哈希才是正确设计。
bcrypt 成本因子应该设多少?
在认证服务的延迟预算内,选择服务器能承受的最高成本,并随硬件升级重新评估。常见起点是 12,但正确值取决于流量和硬件,应该实测校验耗时。
怎么从 SHA-256 迁移到 bcrypt 或 Argon2?
用户下次登录成功时,先用旧哈希校验,再用新算法重新哈希并替换存储值。存储记录里标记算法,校验时按算法分派。长期不登录的旧哈希可以逐步过期。