开门见山:一秒钟的选型结论
新项目一律用 Argon2id,推荐参数 m=46 MiB、t=1、p=1(或 m=19 MiB、t=2、p=1),盐值 16 字节随机生成;遗留系统用 bcrypt,cost factor 至少 12,注意 72 字节截断问题;SHA-256/MD5/SHA-1 在密码存储场景全部视为"裸奔",任何情况下都不要直接用;在 Argon2id/bcrypt 之外再叠加一层 HMAC-SHA256 的 pepper(密钥放 KMS),你的密码存储可以做到工业级。
如果你赶时间,把上面这段话贴到代码评审模板里就够了。如果你接着往下读,这篇文章会回答一个更本质的问题:为什么 2026 年了,密码泄露事件里还能看到成千上万的 MD5 哈希被秒破?为什么 LinkedIn 一个 2012 年的漏洞,到 2016 年才发现实际影响 1.17 亿账号? 答案几乎从来不在"哈希算法不够新",而在工程师对密码存储的本质 ------它不是"完整性校验",而是一个算力军备竞赛------的理解偏差上。
我会沿着四条主线展开:为什么 MD5/SHA-256 这类"通用哈希"不能用来存密码 (速度就是原罪)、盐值与彩虹表背后的数学博弈 、bcrypt 与 Argon2 的设计哲学与实战参数 、以及从存量数据迁移、pepper 加固、哈希升级到完整防御体系。每一节都配真实泄密事件、可复现的代码、hashcat 基准数据,以及一份可以直接贴在工位上的七层防坑清单。
一、先看清本质:密码存储不是哈希,是一场算力军备竞赛
1.1 通用哈希函数的三个"优点",恰恰是密码存储的三个"死穴"
SHA-256、MD5、SHA-1 这些哈希函数的设计目标是快速校验数据完整性 ------GB 级文件算哈希要毫秒级完成。这个"快",正是它们在密码存储场景下的死刑判决书。
我们来看一组 2026 年的真实基准数据。Hashcat 官方测试显示,单张 NVIDIA RTX 4090 显卡每秒可以计算超过 800 亿(8×10^10)次 MD5 哈希;顶级服务器 CPU 也只能做到每秒 54 亿次------差距约 15 倍。换算成攻击成本:
| 算法 | 单张 RTX 4090 吞吐 | 暴力穷举 8 位小写密码(约 2×10^11 组合) | 成本估算 |
|---|---|---|---|
| MD5 | ~80,000,000,000 H/s | ~3 秒 | 几乎为零 |
| SHA-1(裸哈希) | ~50,000,000,000 H/s | ~5 秒 | 几乎为零 |
| SHA-256(裸哈希) | ~15,000,000,000 H/s | ~15 秒 | 几乎为零 |
| PBKDF2-SHA256(100 万轮) | ~250,000 H/s | ~9 天 | 数千美元电费 |
| bcrypt (cost=12) | ~50,000 H/s | ~50 天 | 数万美元 |
| Argon2id (m=64MiB, t=3) | ~30 H/s(受内存限制) | 数百年 | 数百万美元 |
注意最后一行那个 "~30 H/s" ------这不是笔误。单张 4090 面对 Argon2id 每秒只能算 30 次左右,比它算 MD5 慢了 26 亿倍 。这就是"内存困难"设计的威力。
这意味着什么 :如果你用 MD5 存密码,攻击者拿到数据库后,一台消费级显卡一晚上就能穷举整个字典;如果你用 Argon2id,同样一张显卡要花几个世纪。两者之间的差距,不是"安全性更好一点",而是**"完全沦陷"和"基本安全"的差距**。
1.2 一次泄密如何演变成四年悲剧:LinkedIn 的教训
这个真实事件值得每个工程师看一遍,因为它展示了"用错哈希"的代价有多夸张。
2012 年 6 月 ,黑客在俄罗斯论坛张贴出 650 万条 LinkedIn 用户密码的 SHA-1 哈希 。关键问题是------这些哈希没有加盐 。Poul-Henning Kamp 在 ACM Queue 上写下了那句著名的评论:"无盐的哈希密码,和明文密码之间只有无穷小的差距" 。
攻击者用"字典攻击 + 暴力穷举"在几小时内破解了大量哈希。破解者甚至注意到,有异常高比例的密码里包含 "linkedin" 字样 ------这是典型的用户行为模式,字典攻击的天然肥肉。LinkedIn 当时的应对是强制重置了这 650 万账号的密码 ,然后就......翻篇了。
但故事没完 。2016 年 5 月 ,黑客 "Peace" 在暗网以 5 个比特币(约 2200 美元) 的价格挂出完整的泄露数据------实际规模是 1.17 亿条账号记录 ,是当年公开数量的 18 倍。LinkedIn 不得不再次强制重置大量用户密码。从 2012 到 2016,整整四年,1.17 亿用户的密码哈希在暗网流通 ------因为是无盐 SHA-1,攻击者可以慢慢啃,每啃出一个就多一个可撞库的账号。
同样的剧本一再上演 :2012 年的 MySpace 泄密(3.6 亿账号,用的是更古老的 SHA-1 截断版 )、2013 年的 Adobe(1.5 亿账号,用 3DES 加密 + 弱提示)、2019 年的 Collection #1-5(累计 77 亿条邮箱-密码组合,成为后续撞库攻击的"弹药库")。
LinkedIn 事件的核心教训:
- 无盐哈希 = 给攻击者递字典------650 万条无盐 SHA-1 在几小时内被大量破解;
- 泄密范围往往比第一时间公告的大得多------1.17 亿 vs 650 万,意味着你的应急响应必须基于"全量"假设;
- 哈希方案弱的代价会随时间放大------四年间攻击者有无限时间慢慢破解;
- 泄露的密码会进入"撞库弹药库"------用户在各平台复用密码,你的弱哈希泄露会反过来拖垮其他平台的安全。
1.3 那么密码存储到底需要什么?
从 LinkedIn 的教训反推,一个合格的密码存储方案需要四个性质:
- 慢:单次计算要 100 毫秒到 1 秒级,让暴力穷举变得不经济;
- 盐:每个用户的哈希都不同,让"预计算彩虹表"失效,让"批量破解"变成"逐个破解";
- 抗硬件加速 :让 GPU/ASIC 的并行优势被抵消(内存困难 是关键武器);
- 可调节 :算力在增长,参数必须能随时间调强。
MD5/SHA-256 一个都不满足。bcrypt 满足前两条和部分第三条。Argon2id 四条全满足。这就是本文的主角。
二、盐值与彩虹表:一场围绕"预计算"的攻防
在讲 bcrypt 和 Argon2 之前,必须先把盐讲透------因为这是理解"为什么要专门的密码哈希算法"的钥匙。
2.1 无盐哈希的世界:彩虹表的乐园
假设你用 SHA-256 存密码,无盐。攻击者拿到数据库后看到:
text
user1@example.com → 5e884898da28047151d0e56f8dc629...(这是 "password")
user2@example.com → 5e884898da28047151d0e56f8dc629...(也是 "password"!)
user3@example.com → ef92b778bafe771e89245b89ecbc08...(这是 "letmein123")
注意前两个哈希完全相同 ------这是无盐哈希的致命特征:相同明文永远产生相同哈希 。攻击者不需要解密,只需要建立一张"明文 → 哈希"的对照表,然后逐行比对。
朴素查表法 的问题是:要覆盖 8 位小写字母密码(约 2×10^11 组合),哈希表本身就大到不可存储。彩虹表(Rainbow Table)是对这个问题的精妙优化。
2.2 彩虹表的数学原理:链式存储 + 归约函数
彩虹表的核心思想是用时间换空间 ,通过链 的方式,把海量明文-哈希对压缩到极小的存储空间。
构造过程:
- 选定一个起始明文
P1(如 "aaaaaa"); - 对
P1做哈希得到H1 = H(P1); - 用一个归约函数
R(Reduction Function)把H1映射回一个"新明文"P2 = R(H1)------注意 R 不是哈希的逆,只是一个确定性的映射(比如取哈希前 6 个字节转成 6 个字母); - 对
P2再哈希,再归约,再哈希......重复 N 次(如 N=10000)形成一条链; - 只存储链的两端 :起点
P1和终点H_N。
一条链覆盖了 10001 个明文,但只需要存 2 个值------空间压缩比是 5000:1 。如果你构造 M 条不同的链(用不同的归约函数 R1, R2, ..., 避免链合并),就能覆盖 M × N 个明文。
查询过程 :
给定一个目标哈希H_target,攻击者这样查找: - 先猜它在链的最后一步 :计算
R(H_target),看是否等于某条链的终点------如果等于,起点就是那条链的起点,从头重放该链即可找到匹配的明文; - 如果不是,猜它在倒数第二步 :计算
R(H(R(H_target))),再比对终点; - 依此类推,向前遍历最多 N 步。
代价是查询时间从 O(1) 变成 O(N) ------但 N=10000 的查询在现代 CPU 上是毫秒级,完全可接受。
彩虹表的实战规模 :2000 年代后期,攻击者可以下载或购买覆盖整个 Windows NTLM 密码空间的彩虹表,14 位以内的字母数字组合几乎全覆盖,单张表几个 TB。FreeRainbowTables、Objectif Sécurité 等网站曾公开提供在线查询。
2.3 盐如何让彩虹表彻底失效
现在给每个用户的密码加上一个随机盐。关键:盐是明文存储的,不需要保密,只需要随机。
text
user1: salt=a3f2, hash=H("password" + "a3f2") = X1
user2: salt=9c71, hash=H("password" + "9c71") = X2
注意 X1 ≠ X2 ,即使两个用户用同一个密码 "password"。
这给攻击者带来什么?
(1)彩虹表失效 。彩虹表的价值在于"一次预计算,攻击所有同哈希函数的系统"。但加盐后,预计算的输入空间从 H(p) 变成了 H(p + salt)------每个用户的盐都不同 。攻击者要预计算"针对盐 a3f2 的彩虹表",对 user1 有效但对 user2 无效。如果盐的长度是 16 字节(128 位),为每个盐构造彩虹表的代价等价于直接暴力穷举 ------彩虹表的时间-空间优势彻底归零。
(2)批量破解变成逐个破解 。即使攻击者放弃彩虹表,改用普通字典攻击,每个用户都要独立跑一遍完整字典 ------因为"密码 + 盐"是不同的输入。破解 100 万个用户需要的时间,是无盐场景的 100 万倍。
(3)"相同密码"的模式被掩盖。攻击者无法再通过"两个哈希相同"来推断"这两个用户用同一个密码",用户画像和针对性攻击难度大幅上升。
2.4 工程上的盐使用规范
| 项目 | 规范 |
|---|---|
| 盐的长度 | 至少 16 字节(128 位)随机,bcrypt/Argon2 内部都这么处理 |
| 生成方式 | os.urandom(16) / crypto_rng,绝不用时间戳、用户名、自增 ID |
| 存储位置 | 明文与哈希一起存储(数据库同一行),不需要保密 |
| 是否每用户不同 | 必须------同一系统内不同用户的盐必须独立随机生成 |
| 是否需要第二把盐("pepper") | 见第六节------pepper 和 salt 是两回事,作用不同 |
常见的盐使用错误:
- 用用户名当盐------同一用户在不同系统(如 "admin@example.com")会用相同盐,彩虹表仍然可预计算;
- 用固定全局盐------所有用户同一个盐,等于没加盐(彩虹表只需构造一次);
- 用短盐------如果盐只有 4 字节(16^4 = 65536 种可能),攻击者可以预计算"每个可能盐值的彩虹表",65536 张表仍然可承受;
- 盐用 CSPRNG 之外的方式生成 ------用
Math.random()或时间戳,熵不足,可被枚举。
好消息是 :bcrypt、Argon2id、scrypt、PBKDF2 都在算法内部自动处理盐 ------你调用 API 时根本接触不到盐的细节,库会自动生成、自动存储在输出字符串里、自动在验证时使用。你自己"手工加盐"的场景,只出现在"直接用 SHA-256"这种本来就不该用的情况。这也是为什么 OWASP 直接说"用专用密码哈希库,不要自己拼装"。
三、bcrypt:老兵不死,但局限明显
3.1 bcrypt 的设计:EksBlowfish 的"可调慢"
bcrypt 由 Niels Provos 和 David Mazières 在 1999 年提出,基于 Blowfish 分组密码改造而来,核心是 EksBlowfish(Expensive Key Schedule Blowfish) 。
它的核心机制:
- cost factor(成本因子) :控制密钥调度阶段的迭代次数,cost 每加 1,时间翻倍。cost=10 大约 100ms,cost=12 大约 250ms,cost=14 大约 1 秒(具体取决于硬件);
- 内置随机盐:每次哈希自动生成 16 字节盐;
- 输出格式自描述 :把算法版本、cost、盐、哈希打包成一个字符串,方便验证和后续升级。
bcrypt 输出长这样(注意所有信息都嵌在里面):
text
$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
↑ ↑ ↑________________________↑↑______________________________↑
│ │ 22 字节盐(base64) 31 字节哈希(base64)
│ cost=12
版本 2b
这种"自描述"设计是 bcrypt 的工程亮点 :验证函数 bcrypt.checkpw(password, hash) 自动从哈希字符串里解析出盐和 cost,无需在数据库单独存盐字段;cost 升级时只需重新生成新哈希,新旧哈希可以共存。
3.2 bcrypt 的硬伤:72 字节截断
bcrypt 的输入密码最多只能用前 72 字节 ,超过的部分被静默忽略 。
这意味着:"A" × 71 + "B" 和 "A" × 71 + "C" 这两个完全不同的密码 ,在 bcrypt 眼里是同一个密码 。
这在 2026 年是个真实的安全问题 ,原因有两个:
(1)Passphrase(密码短语)的兴起 。NIST SP 800-63B 推荐使用长密码短语(如 correct-horse-battery-staple),但 72 字节其实很早就到了------一段 30 个英文单词的 passphrase 轻松超 72 字节。用户以为自己的超长密码很安全,实际只有前 72 字节起作用。
(2)Passlib 5.0 兼容性事故 。2025 年 9 月,Python 的 bcrypt 库升级到 5.0.0 后不再静默截断超长密码,而是直接抛异常 ,这导致依赖 passlib 的 Ansible、FreshRSS 等大量项目报错。事故揭示了一个事实:很多系统多年来一直在静默丢弃用户的超长密码 ------用户以为自己设了 200 字节的密码,实际上只有前 72 字节有效。
工程对策(按推荐度排序):
- 改用 Argon2id,没有 72 字节限制;
- 如果必须用 bcrypt,在应用层做 pre-hash :先用 SHA-256 算密码的哈希,再 base64 编码后送 bcrypt------这样无论原密码多长,bcrypt 收到的都是 44 字节的 base64 字符串。注意这里不需要 pepper,纯粹是为了绕过 72 字节限制;
- 应用层限制密码长度到 72 字节以内并明确提示用户------但 NIST 不推荐限制密码长度上限(至少应允许 64 字节以上)。
3.3 bcrypt 的第二个硬伤:对 GPU 的抵抗力有限
bcrypt 虽然慢,但它不消耗大量内存 ------单次计算只需要 4KB 左右的工作内存。这个特点让 GPU 仍然能大规模并行:
一张 RTX 4090 跑 bcrypt (cost=12) 的实测速度约 50,000-70,000 H/s (hashcat mode 3200)。对比之下,同一张卡跑 Argon2id (m=64MiB) 只能跑到 30-100 H/s ------差了三个数量级 。
原因很简单:4090 有 16,384 个 CUDA 核心,bcrypt 只需要 4KB 内存,每个核心都能独立跑一次 bcrypt,16384 个核心完全并行 ;而 Argon2id 需要 64MB 内存,24GB 显存最多同时跑 89 个核心 (24GB / 64MB ≈ 375,但受限于其他资源实际约 89-150),其余 16000 多个核心全部闲置。
这就是"内存困难"设计的本质 ------GPU 的算力是无限的,但显存是有限的。把密码哈希的瓶颈从"计算"转移到"内存",就能让 GPU 的 16384 个核心大部分闲置。
3.4 bcrypt 在 2026 年还安全吗?
答案是:"足够安全,但不是最优" 。
OWASP Password Storage Cheat Sheet 的官方立场:
- 首选:Argon2id;
- 备选:bcrypt(cost ≥ 10,2026 年建议 ≥ 12)、scrypt;
- 兼容场景 :PBKDF2-HMAC-SHA256(600,000 次迭代)------用于必须兼容 FIPS 认证库的场景。
bcrypt 的问题不在算法被破解------至今没有针对 bcrypt 的实质攻击------而在于:
- 它的"算力优势"随着 GPU 显存越来越大而持续被侵蚀(4090 已有 24GB,下一代 5090 已 32GB);
- 72 字节截断的工程陷阱仍在;
- 没有像 Argon2 那样能通过增加内存参数来"未来证明"。
实际建议:
- 遗留系统用 bcrypt (cost ≥ 12) 完全可以接受,不要因为"Argon2 更新"就慌着迁移;
- 新项目直接 Argon2id;
- 不要再用 PBKDF2 除非 FIPS 强制------PBKDF2 是纯计算密集型,没有内存困难,GPU 优势极大(RTX 4090 跑 PBKDF2-SHA256 100 万轮约 250,000 H/s,比 Argon2id 快 8000 倍)。
3.5 bcrypt Python 实战代码
python
import bcrypt
# ============ 密码哈希 ============
def hash_password(plaintext: str) -> bytes:
# bcrypt 自动生成 16 字节盐,cost=12
return bcrypt.hashpw(
plaintext.encode('utf-8'),
bcrypt.gensalt(rounds=12) # cost=12,2026 年推荐值
)
# ============ 密码验证 ============
def verify_password(plaintext: str, stored_hash: bytes) -> bool:
try:
return bcrypt.checkpw(
plaintext.encode('utf-8'),
stored_hash
)
# checkpw 自动从 stored_hash 解析盐和 cost
except Exception:
return False # 统一错误,不区分原因
# ============ 处理 72 字节限制(pre-hash 方案)============
import hashlib
import base64
def hash_password_long_input(plaintext: str) -> bytes:
"""支持任意长度密码的 bcrypt 哈希(pre-hash 绕过 72 字节)"""
# SHA-256 → base64,保证送入 bcrypt 的是 44 字节
prehashed = base64.b64encode(
hashlib.sha256(plaintext.encode('utf-8')).digest()
)
return bcrypt.hashpw(prehashed, bcrypt.gensalt(rounds=12))
# ============ 使用示例 ============
stored = hash_password("MySecretPassword123!")
print(stored)
# $2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
print(verify_password("MySecretPassword123!", stored)) # True
print(verify_password("wrong", stored)) # False
注意事项:
rounds=12是 2026 年的服务器基准------具体取值要让单次哈希在你的服务器上耗时 200-500ms(用基准测试调整);- bcrypt 输出已经包含盐和 cost,不要拆开存储,整串存数据库;
checkpw抛异常时统一返回 False,绝不向调用方区分"密码错"和"哈希格式错"------防止侧信道。
四、Argon2id:2015 年至今的密码哈希冠军
4.1 Argon2 的诞生与三个变体
Argon2 由 Alex Biryukov、Daniel Dinu、Dmitry Khovratovich 在 2013 年设计,2015 年赢得 Password Hashing Competition(密码哈希竞赛)冠军 ,2020 年成为 RFC 9106 标准,2022 年起被 OWASP 列为密码存储首选。
Argon2 有三个变体,对应不同的威胁模型:
| 变体 | 设计目标 | 抗 GPU | 抗侧信道 | 推荐度 |
|---|---|---|---|---|
| Argon2d | 最大化抗 GPU | 极强 | ❌ 弱(数据依赖内存访问) | 不推荐 |
| Argon2i | 抗侧信道攻击 | 中等 | ✅ 强 | 特定场景 |
| Argon2id | 混合方案 | 强 | 强 | 默认推荐 |
Argon2id 是 d 和 i 的混合体 :前几次遍历用数据独立访问(抗侧信道),后续遍历用数据依赖访问(抗 GPU)。2026 年几乎所有场景都用 Argon2id。
4.2 三个参数:内存、时间、并行度
Argon2 的可调参数比 bcrypt 丰富得多,正确配置是关键:
| 参数 | 含义 | 单位 | OWASP 推荐(2026) |
|---|---|---|---|
| m(memory cost) | 占用内存大小 | KiB / MiB | 46 MiB(或 19 MiB) |
| t(time cost / iterations) | 迭代次数 | 次 | 1 (m=46MiB 时)或 2(m=19MiB 时) |
| p(parallelism) | 并行线程数 | 个 | 1(服务器 CPU 核够用时可用 2-4) |
| hLen | 输出哈希长度 | 字节 | 32 |
| saltLen | 盐长度 | 字节 | 16 |
OWASP 的两套推荐配置(任选其一):
- 方案 A :
m=46 MiB, t=1, p=1------单次约 50ms,适合高 QPS 系统; - 方案 B :
m=19 MiB, t=2, p=1------单次约 50ms,内存压力更小,适合内存受限的环境。
RFC 9106 的官方推荐(更保守): - 第一推荐 :
m=2 GiB, t=1, p=4------单次约 1 秒,适合"最高安全要求"; - 第二推荐 :
m=64 MiB, t=3, p=4------单次约 500ms,平衡安全和性能。
参数怎么选?核心原则:
- 先定内存 m------这是抗 GPU 的关键,越大越好,但要考虑登录 QPS;
- 再定 t------保证单次哈希在你生产服务器的 CPU 上耗时 100-500ms;
- p 视服务器 CPU 而定 ------服务器 8 核以上可以用 p=2-4,容器环境内存受限就 p=1。
一个常见误区 :很多人把 p 调得很大以为"更快更强"。实际上 p 越大,每个线程分到的内存越少 ,抗 GPU 优势会下降。Bitwarden 社区的实测显示,m=256 MiB, p=1和m=512 MiB, p=2在用户设备上耗时相同,但对攻击者的 GPU 攻击效果差不多------关键是 m×p 的总内存。
4.3 Argon2id 为什么能压制 GPU:内存困难的数学
这是 Argon2 的核心创新,也是它对比 bcrypt 的本质优势。
Argon2 的内存填充过程:
- 它会填满 m 大小的整个内存块 ,每个块的内容依赖前序若干个块的哈希;
- Argon2d/i 的访问模式不同:d 用数据依赖(最快但暴露内存访问模式,有侧信道风险),i 用数据独立(恒定访问模式但抗 GPU 弱一些),id 是混合;
- 要算完一次 Argon2id,必须真的占用 m 那么大的内存 ,无法通过"跳过某些块"来加速。
这个设计对 GPU 的杀伤力 :GPU 的优势是算力(核心数) ,但它的显存总量有限(4090 是 24GB)。当 Argon2id 的 m=64 MiB 时:
text
24 GB 显存 ÷ 64 MiB/次 ≈ 375 个并发实例
实际考虑上下文开销,约 89-150 个并发实例
其余 16,234 个 CUDA 核心全部闲置
对比 bcrypt 的 4KB 内存需求:
text
24 GB 显存 ÷ 4 KB/次 ≈ 6,000,000 个并发实例
GPU 16384 个核心全部满载
这就是"三个数量级"差距的来源 。
对 ASIC 的效果更夸张 。ASIC 是专门定制的芯片,算力可以达到 GPU 的百倍以上(如比特币矿机),但它的内存制造成本远高于算力成本------要做一个能跑 Argon2id (m=2GiB) 的 ASIC,每"核心"都需要 2GB 内存,物理上不可能做出比特币矿机那种规模 。Stack Exchange 上的讨论指出:对 2021 年的 GPU,128 MiB 已经"相当安全";对 ASIC,需要 GB 级的内存才能有效对抗 。
2026 年的实际测试 (SpecopsSoft 研究)显示:即使用 NVIDIA H200 这种 AI 加速卡跑 Argon2id,也没有显著优势------因为 Argon2id 的瓶颈在内存带宽而非算力,AI GPU 的算力优势用不上。
4.4 Argon2id 的"抗破解账本"
我们用一张表对比不同算法在攻击者视角下的破解成本。假设攻击者拿到 100 万条密码哈希,目标是破解其中 10%。
| 算法与参数 | 单张 RTX 4090 吞吐 | 攻击 100 万哈希的字典攻击(10 亿候选)时间 | 估算电力成本 |
|---|---|---|---|
| MD5(裸哈希) | 8×10^10 H/s | ~13 秒 | < 1 美分 |
| SHA-1(无盐) | 5×10^10 H/s | ~20 秒 | < 1 美分 |
| SHA-256(无盐) | 1.5×10^10 H/s | ~70 秒 | < 1 美分 |
| PBKDF2-SHA256(10 万轮) | 2.5×10^6 H/s | ~7 分钟 | < 1 美元 |
| PBKDF2-SHA256(OWASP 60 万轮) | 4×10^5 H/s | ~40 分钟 | < 5 美元 |
| bcrypt (cost=10) | 2×10^5 H/s | ~80 分钟 | < 10 美元 |
| bcrypt (cost=12) | 5×10^4 H/s | ~5.5 小时 | < 50 美元 |
| bcrypt (cost=14) | 1.3×10^4 H/s | ~21 小时 | < 200 美元 |
| Argon2id (m=19MiB, t=2) | 150 H/s | ~77 天 | ~5000 美元 |
| Argon2id (m=64MiB, t=3) | 30 H/s | ~1.3 年 | ~10 万美元 |
| Argon2id (m=2GiB, t=1) | ~2 H/s | ~16 年 | 数百万美元 |
看清楚这个数字的工程含义:
- 用 MD5,攻击者点个外卖的时间就能破解你 100 万用户的常用密码;
- 用 Argon2id (m=2GiB),一个国家级行为者的 GPU 集群 也要花数年------而这种攻击在经济上完全不再划算。
arXiv 上的研究 (2024)进一步验证:OWASP 推荐的 Argon2id 46 MiB 配置,在相同的攻击预算下,破解率比 SHA-256 低 42.5%------即使只算强密码子集。
4.5 Argon2id Python 实战代码
python
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, HashingError
# ============ 推荐参数(OWASP 2026 方案 A)============
ph = PasswordHasher(
memory_cost=47104, # 46 MiB = 46 × 1024 KiB
time_cost=1, # t=1
parallelism=1, # p=1
hash_len=32, # 输出 32 字节
salt_len=16, # 盐 16 字节
type=3 # 3 = Argon2id(argon2-cffi 默认就是 id)
)
# ============ 备选方案 B(内存受限场景)============
ph_low_memory = PasswordHasher(
memory_cost=19456, # 19 MiB
time_cost=2, # t=2
parallelism=1,
hash_len=32,
salt_len=16,
)
# ============ 密码哈希 ============
def hash_password(plaintext: str) -> str:
return ph.hash(plaintext)
# 自动生成 16 字节随机盐,输出格式自描述
# ============ 密码验证 ============
def verify_password(plaintext: str, stored_hash: str) -> bool:
try:
# verify 自动解析盐和参数
ph.verify(stored_hash, plaintext)
# 关键!check_needs_rehash 用于参数升级
if ph.check_needs_rehash(stored_hash):
mark_for_rehash(user_id) # 异步标记重新哈希
return True
except VerifyMismatchError:
return False
except Exception:
return False # 统一错误,不区分原因
# ============ 输出格式示例 ============
# $argon2id$v=19$m=47104,t=1,p=1$c29tZXNhbHRlY3NhbHRl$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
# ↑ ↑ ↑____________↑↑ ↑_______________________↑↑_____________________________↑
# 算法 版本 m, t, p 参数 base64 盐 base64 哈希
argon2-cffi 的工程亮点:
check_needs_rehash()是一个杀手级 API------它检查已存储的哈希是否用了旧参数(比如你两年前 m=19MiB,现在升级到 m=46MiB),如果需要升级就返回 True。配合"用户登录时透明升级"策略(见第七节),可以实现零停机的参数升级;- 输出格式完全符合 PHC 字符串标准(Password Hashing Competition),算法、参数、盐、哈希全部嵌在字符串里,不需要在数据库单独存字段;
- 默认就是 Argon2id,无需手动指定变体。
4.6 scrypt 和 PBKDF2:简短评价
scrypt (Colin Percival, 2009):也是内存困难算法,比 Argon2 早六年。可用但不如 Argon2id ------内存访问模式设计较老,参数调优空间不如 Argon2 灵活,OWASP 列为第二梯队备选。
PBKDF2-HMAC-SHA256 :纯计算密集型,没有内存困难 。唯一的优势是 FIPS 140-2/140-3 认证库普遍支持 (如一些政府、金融合规场景强制要求 FIPS 库)。OWASP 推荐迭代次数:PBKDF2-HMAC-SHA256 ≥ 600,000 次,PBKDF2-HMAC-SHA512 ≥ 210,000 次 。能用 Argon2id 就不要用 PBKDF2。
五、bcrypt vs Argon2id:一张总对比表
| 维度 | bcrypt | Argon2id |
|---|---|---|
| 诞生年份 | 1999 | 2015(RFC 9106: 2020) |
| 设计基础 | EksBlowfish | 内存填充 + Argon2i/d 混合 |
| 内存困难 | ❌ 无(4KB) | ✅ 核心优势(可调 m) |
| 抗 GPU | 中等(~50,000 H/s @ RTX 4090) | 极强(~30-150 H/s @ RTX 4090) |
| 抗 ASIC | 弱(ASIC 可以堆算力) | 极强(ASIC 需要堆内存,物理上不经济) |
| 抗侧信道 | 中等 | 强(id 变体专门设计) |
| 参数 | cost(1 个) | m, t, p(3 个),调优空间大 |
| 密码长度限制 | 72 字节截断 | 无(任意长度) |
| OWASP 2026 推荐 | 备选(遗留系统) | 首选 |
| FIPS 140-3 支持 | 部分库支持 | 较少(这是 PBKDF2 的场景) |
| 生态成熟度 | 极成熟(25+ 年) | 成熟(10 年,主流库全支持) |
| 2026 年适用性 | 可用,不推荐新项目 | 工业界事实标准 |
一个有意思的对比 :Pilcrow 的研究(2026 年 3 月)发现,当 Argon2id 内存低于 64 MiB 时,它对老款 GTX 1080 的抑制效果与 bcrypt 相当;但对新一代 RTX 5090,Argon2id 仍然有效而 bcrypt 优势消失。这说明 bcrypt 的"算力瓶颈"正在被硬件进步持续侵蚀,而 Argon2id 的"内存瓶颈"则相对稳定(显存增长远慢于算力增长)。
所以选型结论很清晰:
- 新项目一律 Argon2id,没有任何理由选 bcrypt;
- 遗留系统用 bcrypt (cost ≥ 12) 可以继续用,按"用户登录时透明升级到 Argon2id"的策略渐进迁移;
- 永远不要 用 MD5/SHA-1/SHA-256 直接存密码,永远不要用 PBKDF2 除非 FIPS 强制。
六、Pepper:被低估的"第二把钥匙"
6.1 Salt 和 Pepper 的本质区别
很多人把 salt 和 pepper 混为一谈,其实它们的作用机制完全不同:
| 维度 | Salt(盐) | Pepper(胡椒) |
|---|---|---|
| 是否随机 | 每用户独立随机 | 全系统共享同一个(或少数几个) |
| 是否需要保密 | ❌ 明文存储(与哈希同库) | ✅ 绝不能与哈希同库(KMS/HSM/独立密钥库) |
| 作用 | 让彩虹表失效、让批量破解变成逐个破解 | 让数据库泄露后的哈希仍然不可破解 |
| RFC 9106 提及 | 是 | 是(IETF draft 中明确区分) |
| 缺失后果 | 彩虹表可行、批量破解高效 | 数据库泄露后哈希可被离线破解 |
Pepper 的核心价值 :应对"只有数据库被拖走 "这种最常见的泄露场景。大多数密码泄露是 SQL 注入、备份泄露、内部人员导出------只有数据库本身泄露,应用服务器的密钥仍然安全 。这种情况下,pepper 让攻击者拿到哈希也无法做任何离线破解 ,因为 pepper 根本不在泄露的数据里。
一个具体的例子 :LinkedIn 2012 事件里,如果当年在 SHA-1 之外还加了一个存放在应用服务器上的 pepper(比如 HMAC-SHA256(pepper, password) 再 SHA-1),攻击者拿到的 1.17 亿条哈希就是无意义的随机数,直到今天都破解不了。
6.2 Pepper 的正确实现方式:HMAC,不是简单拼接
RFC 9106 和 IETF draft 明确推荐:pepper 必须通过 HMAC 而非字符串拼接的方式引入 。
为什么不直接拼接 ?比如 Argon2id(pepper + password, salt):
- Argon2 内部对输入做 BLAKE2b 哈希,pepper 拼接后可能被"长度扩展"或其他结构问题影响(虽然 Argon2 本身对长度扩展免疫,但拼接方式不规范);
- HMAC 是经过严格安全性证明的标准结构(Bellare-Canetti-Krawczyk 1996),输入"密钥 + 消息",适合"pepper 作密钥、password 作消息"的语义;
- HMAC 的输出长度固定,可以安全地作为 Argon2id 的输入。
正确实现:
text
stored_hash = Argon2id(HMAC-SHA256(pepper_key, password), salt)
其中:
- pepper_key:32 字节随机密钥,从 KMS/HSM 获取,绝不入库
- password:用户输入的明文密码
- salt:bcrypt/Argon2 内部的随机盐
6.3 Pepper Python 实战代码
python
import hmac
import hashlib
import os
from argon2 import PasswordHasher
# ============ Pepper 密钥管理 ============
class PepperManager:
"""生产环境从 KMS/HSM 获取,这里用环境变量演示"""
def __init__(self):
# 生产环境:AWS KMS / HashiCorp Vault / HSM
# 永远不要硬编码在源码或配置文件里
self.pepper = os.environ.get('PASSWORD_PEPPER')
if not self.pepper:
raise RuntimeError("PASSWORD_PEPPER 未设置")
self.pepper_bytes = bytes.fromhex(self.pepper) # 32 字节 hex
def hmac_password(self, password: str) -> bytes:
"""用 HMAC-SHA256 把 pepper 应用到密码上"""
return hmac.new(
self.pepper_bytes,
password.encode('utf-8'),
hashlib.sha256
).digest()
def hmac_password_b64(self, password: str) -> str:
"""base64 编码,便于送入 Argon2(避免二进制处理歧义)"""
import base64
return base64.b64encode(self.hmac_password(password)).decode()
# ============ 集成 Argon2id ============
pepper_mgr = PepperManager()
ph = PasswordHasher(
memory_cost=47104, time_cost=1, parallelism=1,
hash_len=32, salt_len=16
)
def hash_password(password: str) -> str:
"""带 pepper 的密码哈希"""
prehashed = pepper_mgr.hmac_password_b64(password)
return ph.hash(prehashed)
def verify_password(password: str, stored_hash: str) -> bool:
from argon2.exceptions import VerifyMismatchError
try:
prehashed = pepper_mgr.hmac_password_b64(password)
ph.verify(stored_hash, prehashed)
return True
except VerifyMismatchError:
return False
except Exception:
return False
6.4 Pepper 的密钥管理与轮换
Pepper 密钥的存储选项(按推荐度):
| 方案 | 安全性 | 复杂度 |
|---|---|---|
| HSM(硬件安全模块) | 极高 | 高(需要硬件) |
| AWS KMS / GCP Cloud KMS / Azure Key Vault | 高 | 中 |
| HashiCorp Vault | 高 | 中 |
| 环境变量(应用服务器) | 中 | 低 |
| 配置文件 | ❌ 低 | 低 |
| 硬编码 | ❌ 极低 | 低 |
Pepper 轮换:pepper 可以轮换,但需要配合"多 pepper 共存"策略:
- 数据库存一个
pepper_id字段标识该哈希用的哪个 pepper 版本; - 新用户用新 pepper(pepper_id = 2),老用户的哈希保持 pepper_id = 1;
- 老用户登录时(用旧 pepper 验证通过后),用新 pepper 重新哈希并更新 pepper_id;
- 所有用户都升级后,废弃旧 pepper。
IETF draft 明确允许这种"多 pepper 共存"设计。
Pepper 的局限 :如果攻击者同时拖走了数据库和应用服务器 (比如通过 RCE),pepper 就没用了。pepper 是"深度防御"的一部分,不是万能药------基础仍然是强 Argon2id 参数。
七、存量迁移与哈希升级:零停机的工程实践
这一节回答一个工程师最常问的问题:"我们系统现在用 MD5/SHA-256,怎么安全迁移到 Argon2id?"
7.1 四种迁移策略对比
| 策略 | 用户体验 | 实施复杂度 | 推荐度 |
|---|---|---|---|
| 强制所有用户重置密码 | ❌ 极差 | 低 | 不推荐(大用户量时业务会爆炸) |
| 一次性批量迁移(需要明文) | - | - | ❌ 不可行(你已经只有哈希了) |
| 登录时透明升级 | ✅ 完全无感 | 中 | ✅ 工业界标准做法 |
| 离线字典攻击自升级(用已知泄露字典尝试破解旧哈希,破解出的升级) | ✅ 无感 | 高 | ✅ 适合高价值账号 |
7.2 推荐方案:登录时透明升级
这是 Milan Jovanović 在 2026 年的文章里详细讲解的标准做法,核心逻辑:
text
1. 数据库存两个版本:
- legacy_hash:旧算法(如 MD5/SHA-256)的哈希
- password_hash:新算法(Argon2id)的哈希(初始为 NULL)
2. 用户登录时:
- 如果 password_hash 非空 → 用 Argon2id 验证
- 如果 password_hash 为空 → 用旧算法验证 legacy_hash
- 验证通过 → 立即用同一明文密码计算 Argon2id 哈希
→ 写入 password_hash,删除 legacy_hash
→ 用户无感知,但下次就用 Argon2id 了
- 验证失败 → 返回密码错误
Python 实战代码:
python
import hashlib
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(memory_cost=47104, time_cost=1, parallelism=1)
# ============ 数据模型 ============
# users 表结构:
# id INT PRIMARY KEY
# email VARCHAR
# legacy_hash VARCHAR NULL -- 旧 MD5/SHA-256 哈希
# argon2_hash VARCHAR NULL -- 新 Argon2id 哈希
# hash_version TINYINT -- 0=legacy, 1=argon2id
# ============ 登录逻辑 ============
def login(email: str, password: str) -> bool:
user = db.query("SELECT * FROM users WHERE email = ?", email)
if not user:
# 防用户枚举:即使不存在也执行一次 dummy 哈希
ph.dummy_verify()
return False
if user.argon2_hash:
# 已升级到 Argon2id
try:
ph.verify(user.argon2_hash, password)
return True
except VerifyMismatchError:
return False
elif user.legacy_hash:
# 还在旧算法,验证后透明升级
legacy_input = hashlib.sha256(password.encode()).hexdigest()
# 假设旧系统是 SHA-256(如果是 MD5 就换成 md5)
if legacy_input == user.legacy_hash:
# ★ 关键:立即用同一明文升级到 Argon2id
new_hash = ph.hash(password)
db.execute(
"UPDATE users SET argon2_hash = ?, legacy_hash = NULL, "
"hash_version = 1 WHERE id = ?",
new_hash, user.id
)
return True
return False
return False
# ============ 后台批量升级(可选,加速迁移)============
def batch_upgrade_by_dictionary():
"""用已知泄露字典尝试破解旧哈希,破解出的账号立即升级"""
# 加载 SecLists / HaveIBeenPwned 字典
for candidate in load_common_passwords():
for user in db.query("SELECT * FROM users WHERE hash_version = 0 LIMIT 10000"):
if hashlib.sha256(candidate.encode()).hexdigest() == user.legacy_hash:
new_hash = ph.hash(candidate)
db.execute(
"UPDATE users SET argon2_hash = ?, legacy_hash = NULL, "
"hash_version = 1 WHERE id = ?",
new_hash, user.id
)
透明升级的关键设计点:
- 必须在验证通过后立即升级------这是唯一拿到明文密码的时机;
- 升级必须原子(UPDATE 同时清空 legacy_hash),避免重复升级;
- 登录接口的响应时间 会因为"升级"而稍长(多算一次 Argon2id),用户体验上完全可接受;
- 监控升级进度 ------
SELECT COUNT(*) WHERE hash_version = 0,目标是 90 天内降到 1% 以下; - 对于长期未登录用户 (如 2 年未登录),他们的旧哈希会一直存在------可以考虑:强制重置 或直接禁用账号(安全上更优,但需要业务平衡)。
7.3 参数升级:用 check_needs_rehash 自动跟进
即使已经是 Argon2id,参数(m、t)也需要随硬件进步而升级。argon2-cffi 的 check_needs_rehash 让这件事变得极其简单:
python
def login_with_rehash_check(email, password):
user = get_user(email)
try:
ph.verify(user.argon2_hash, password)
# ★ 验证通过后检查是否需要升级参数
if ph.check_needs_rehash(user.argon2_hash):
# 当前哈希用的是旧参数(如 m=19MiB)
# 重新用新参数哈希一次
new_hash = ph.hash(password)
db.execute("UPDATE users SET argon2_hash = ? WHERE id = ?",
new_hash, user.id)
return True
except VerifyMismatchError:
return False
这就是 Argon2 生态比 bcrypt 优雅的地方------参数升级完全自动化,十年后你的密码存储仍然是业界最强水平。
八、FAQ:最常见的 5 个问题
Q1:我已经用了 SHA-256 + 盐,这够安全吗?
不够。SHA-256 即使加盐也只是让彩虹表失效,但暴力穷举的速度仍然是每秒几十亿次 ------一张 RTX 4090 一晚上可以穷举所有 8 位字母数字组合。LinkedIn 2012 事件用的就是这种思路,结果是 1.17 亿账号长期裸奔。加好的盐只是"及格",慢哈希才是"安全" 。立刻用"登录时透明升级"方案迁移到 Argon2id。
Q2:Argon2id 的参数应该选多大?
OWASP 2026 年推荐两套标准配置:方案 A(m=46MiB, t=1, p=1) 或 方案 B(m=19MiB, t=2, p=1) 。判断标准是单次哈希在你的生产服务器上耗时 100-500ms 。如果你跑在高 QPS 服务上,偏向下限;如果是后台低频操作,可以更大。内存 m 是抗 GPU 的关键,越大越好 ------前提是你的服务器内存扛得住(46MiB × 100 并发 = 4.6GB,需要算清楚)。
Q3:bcrypt 的 72 字节截断,我应该担心吗?
应该 。Passphrase 时代,超过 72 字节的密码越来越常见。如果你的用户设置了 200 字节的超长密码,实际只有前 72 字节起作用 ------这是静默发生的,用户完全不知情。2025 年 9 月的 bcrypt 5.0.0 / passlib 兼容性事故就是这个问题暴露出来的标志。对策 :迁移到 Argon2id(首选),或者应用层做 pre-hash(SHA-256 → base64 → bcrypt)。
Q4:Pepper 真的有必要吗?Argon2id 不就够了吗?
有必要,但它是"深度防御"的第二层,不是替代品 。Argon2id 抗的是"哈希被拿到后离线破解";pepper 抗的是"数据库单独泄露(最常见的泄露模式)"这种场景------攻击者拿到哈希但没有应用服务器的密钥,连离线破解都做不了 。Stack Exchange 和 IETF draft 都认为 pepper 是低成本高收益的加固。实现要点 :用 HMAC(不是字符串拼接),密钥放 KMS,定期轮换。
Q5:怎么检测我的系统是否有这些漏洞?
工具化检查:
- 代码审计 :全局搜索
MD5、SHA1、sha256、digest()、hashlib.md5等关键词,定位所有"直接哈希密码"的代码; - 数据库审计:检查密码字段长度------MD5=32、SHA-1=40、SHA-256=64、bcrypt=60、Argon2id=95+;如果看到 64 字符的"哈希+盐"组合,就是有问题;
- 弱密码测试:把测试账号密码设为 "password123",导出数据库后用 hashcat 测试能否在 10 秒内破解;
- 渗透测试:让红队模拟"数据库泄露"场景,评估攻击者实际破解难度;
- OWASP ASVS 检查:对照 ASVS 2.4(Credential Storage)逐项审查。
九、结语:慢一点,才是真正的安全
写到这里,你可能已经注意到本文反复出现的一个反直觉主题------
在密码存储这个领域,"快"是原罪,"慢"才是美德。 MD5、SHA-1、SHA-256 这些函数之所以"不能用于密码",不是因为有数学漏洞,而恰恰因为它们太快 。bcrypt 和 Argon2id 的设计目标,就是用尽一切手段让计算变慢 ------bcrypt 用密钥调度迭代,Argon2 用内存填充;一个耗算力,一个耗内存。它们的全部价值,都建立在这两个字上:慢、贵 。
更深一层的工程智慧是:密码存储是一个"对抗算力增长"的长期博弈 。bcrypt 在 1999 年设计时足够慢,但 25 年后 GPU 算力增长了百万倍,它的 4KB 内存需求让它逐渐失去优势;Argon2 在 2015 年设计时就考虑了"通过调参跟随硬件进步",m 可以随显存增长而增加,它的"未来适应性"才是它能成为工业标准的核心原因 。
密码工程的第一铁律 :不要相信"我哈希了所以安全"。哈希只是第一步,参数选择、盐值处理、pepper 加固、迁移策略、错误处理------每一项都决定你的密码存储是工业级还是裸奔。 而 LinkedIn 2012 年那次 1.17 亿账号的泄露,提醒我们所有工程师:今天你写下的每一行密码存储代码,可能在十年后决定一亿用户的安全。