MD5 太快了,快到黑客可以每秒尝试 1000 亿次。Argon2 反其道而行之——故意让自己变慢。
一个反直觉的想法
在大多数场景下,我们追求的是"快":
- 网页加载要快
- 数据库查询要快
- 文件传输要快
但密码哈希是个例外。这里我们需要"慢"。
为什么慢反而是好事?
想象两种锁:
快锁(MD5):
尝试一把钥匙只需 0.00000001 秒小偷每秒能试 1000 亿把钥匙 6 位密码锁:0.00001 秒破解
慢锁(Argon2):
尝试一把钥匙需要 0.5 秒小偷每秒只能试 2 把钥匙 6 位密码锁:6 天破解
对于正常用户:
- 登录时等 0.5 秒?完全可以接受
- 反正只输一次密码
对于黑客:
- 每次尝试都要 0.5 秒?灾难!
- 原本 1 秒破解的密码,现在要几个月
这就是"慢哈希"的精髓:让暴力破解变得不经济。
Argon2:专为密码设计
Argon2 是 2015 年"密码哈希竞赛"(Password Hashing Competition)的冠军。这场比赛的目的就是找出最适合存储密码的算法。
为什么不用 bcrypt?
在 Argon2 之前,bcrypt(1999 年)是最流行的密码哈希算法。它已经够慢了,为什么还需要 Argon2?
答案是:GPU 和专用硬件(ASIC)。
bcrypt 的问题
bcrypt 设计于 1999 年,那时候:
- 个人电脑只有 CPU
- 没有 GPU 并行计算
- 没有专用破解硬件 (ASIC)
今天的问题:
- bcrypt 虽然需要 4KB 内存,但相比 Argon2 仍然很少
- GPU 可以通过并行运行多个 bcrypt 实例来加速(虽然 bcrypt 有一定抗 GPU 设计,加速比远不如 MD5 那么夸张,但仍有优势)
- ASIC 可以针对 bcrypt 进行专门优化
- 结果:攻击者用专用硬件可以获得一定优势
⚠️ bcrypt 的"慢"优势被硬件发展抵消了
Argon2 的秘密武器:吃内存
Argon2 的核心创新是:不仅消耗时间,还消耗大量内存。
为什么内存是关键?
| 硬件 | 特点 | 内存情况 |
|---|---|---|
| CPU | 2-4 核 | 大内存 32GB+ |
| GPU | 数千小核心 | 共享有限内存 (数 GB 共享) |
| ASIC | 极多核心 | 内存极贵 (成本爆炸) |
不同算法的硬件优势对比
| 算法 | CPU | GPU | ASIC |
|---|---|---|---|
| MD5 (极快) | 1x | 10000x+ ⚠️ | 100000x+ ⚠️ |
| bcrypt (慢+4KB内存) | 1x | 5-50x ⚠️ | 数十倍 ⚠️ |
| Argon2 (慢+大内存) | 1x | 2-5x ✓ | 2-5x ✓ |
注:Argon2 的 GPU/ASIC 优势取决于内存配置。使用推荐的 64MB+ 内存时,GPU 难以大规模并行;但低内存配置下仍有加速空间。数据为估算值,具体取决于硬件和参数配置。
GPU 每个核心都需要独立的大内存,成本剧增。ASIC 上放大内存比放逻辑电路贵得多。Argon2 大幅缩小了硬件差距。
Argon2 通过"吃内存"让 GPU 和 ASIC 的优势大幅降低,使攻击成本显著提高。
Argon2 的三种口味
Argon2 有三个变体,针对不同场景:
| 变体 | 特点 | 适用场景 |
|---|---|---|
| Argon2d | 抗 GPU/ASIC,但可能受侧信道攻击 | 加密货币挖矿等 |
| Argon2i | 抗侧信道攻击,但抗 GPU 稍弱 | 前端/加密密钥派生 |
| Argon2id | 前两者的混合,平衡安全性 | 通用推荐(OWASP 推荐) |
简单原则:不确定用哪个?选 Argon2id。
Argon2 的三个旋钮
Argon2 让你自己调节"有多慢":
三个关键参数
| 参数 | 含义 | 推荐值 |
|---|---|---|
time_cost | 迭代次数,越大越慢 | 3-5 |
memory_cost | 使用的内存大小 (KB) | 65536 (64MB) 或更高 |
parallelism | 使用的线程数 | CPU 核心数 |
配置示例
$argon2id$v=19$m=65536,t=3,p=4$salt$hash
| 参数 | 值 | 含义 |
|---|---|---|
| m | 65536 | 64MB 内存 |
| t | 3 | 3 次迭代 |
| p | 4 | 4 线程 |
实际代码长什么样?
Python 示例
from argon2 import PasswordHasher
# 创建哈希器(使用默认的安全配置)
ph = PasswordHasher(
time_cost=3, # 迭代次数
memory_cost=65536, # 64MB 内存
parallelism=4 # 4 线程
)
# 注册时:哈希密码
password = "用户输入的密码"
hash = ph.hash(password)
# 存储 hash 到数据库
# 登录时:验证密码
try:
ph.verify(hash, password)
print("密码正确!")
except:
print("密码错误!")
Rust 示例
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2
};
// 注册时:哈希密码
let password = b"user_password";
let salt = SaltString::generate(&mut rand::thread_rng());
let argon2 = Argon2::default();
let hash = argon2.hash_password(password, &salt)?.to_string();
// 存储 hash 到数据库
// 登录时:验证密码
let parsed_hash = PasswordHash::new(&hash)?;
argon2.verify_password(password, &parsed_hash)?;
一张图看懂 Argon2 的内部
Argon2 内存矩阵工作原理
- 输入:密码 + 盐 + 参数
- Round 1 - Fill (填充阶段):生成内存块
[B0] [B1] [B2] ... [Bn] - Round 2 - Mix (混合阶段):块之间交叉依赖,生成
[B0'] [B1'] [B2'] ... [Bn'] - 重复 N 轮混合操作
- 输出:最终哈希值
关键点:每个块都依赖其他块,你不能只算一小部分。必须把整个内存矩阵都填满、都保留。这就是为什么它"吃内存"。
Argon2 vs 其他算法
| 算法 | 发明年份 | 内存需求 | 抗 GPU | 抗 ASIC | 推荐程度 |
|---|---|---|---|---|---|
| MD5 | 1991 | 极低 | ❌ | ❌ | ⛔ 禁止 |
| SHA-256 | 2001 | 极低 | ❌ | ❌ | ⛔ 禁止 |
| bcrypt | 1999 | 4KB | ⚠️ 一般 | ⚠️ 一般 | ✅ 可用 |
| scrypt | 2009 | 可配置 | ✅ 好 | ✅ 好 | ✅ 推荐 |
| Argon2 | 2015 | 可配置 | ✅ 极好 | ✅ 极好 | ✅ 首选 |
常见问题
Q:Argon2 会不会让我的服务器变慢?
A:会,但这是值得的。
场景分析
| 登录请求 | 密码验证开销 | 影响 |
|---|---|---|
| 100/分钟 | 0.1 秒/次 | 10 秒/分钟 CPU,可接受 ✓ |
| 1000/分钟 | 0.1 秒/次 | 100 秒/分钟 CPU,需要考虑限流 ⚠️ |
| 10000/分钟 | 0.1 秒/次 | 可能需要专用验证服务 ⚠️ |
优化建议
- 登录失败时增加延迟(防暴力破解)
- 使用验证码减少无效请求
- 大规模系统可考虑专用认证服务
Q:应该用多大的内存?
A:在你的服务器能承受的范围内,尽量大。
内存配置决策
| 服务器内存 | 推荐 memory_cost |
|---|---|
| < 4GB | 32768 (32MB) |
| 4-16GB | 65536 (64MB) |
| > 16GB | 131072+ (128MB+) |
然后测试
- 单次哈希应在 0.1-0.5 秒
- 太快?增加
time_cost或memory_cost - 太慢?减少参数(但别低于推荐值)
推荐最低配置
memory_cost= 65536 (64MB)time_cost= 3parallelism= 4
Q:旧密码用 MD5 存的,怎么迁移?
A:渐进式迁移。
步骤 1:双哈希过渡
旧用户登录时:
- 输入密码 → MD5 → 比对成功?
- 用原始密码重新计算 Argon2
- 更新数据库,标记为"已迁移"
步骤 2:数据库结构
| username | hash_type | password_hash | migrated |
|---|---|---|---|
| alice | argon2id | $argon2id$… | true |
| bob | md5 | e10adc… | false |
步骤 3:验证逻辑
if user.migrated:
verify_argon2(password, hash)
else:
if verify_md5(password, hash):
new_hash = argon2_hash(password)
update_user(new_hash, migrated=True)
用户无感知,渐进完成迁移。
Argon2 的局限性
虽然 Argon2 是目前最好的密码哈希算法,但它仍有局限:
Argon2 的局限性
- ❌ 不能防止弱密码:
123456用 Argon2 哈希后,暴力破解仍然只是时间问题(只是从毫秒变成小时/天) - ❌ 密码仍然要发送到服务器:服务器在验证时会"看到"明文密码(即使只是短暂的一瞬间),如果服务器被攻破,密码仍可能泄露
- ❌ 数据库泄露后仍可离线破解:攻击者拿到哈希值后,可以慢慢暴力破解,只是需要更长时间(对于弱密码可能仍然可行)
- ❌ 需要服务器资源:每次验证都需要 CPU 和内存,可能成为 DDoS 攻击的放大点
解决方案:结合强密码策略 + 多因素认证。更进一步:SRP / OPAQUE(本系列后续文章)。
总结
密码哈希进化
| 算法 | 年份 | 速度 | 内存 | GPU 抵抗 | 推荐 |
|---|---|---|---|---|---|
| MD5 | 1991 | 极快 | 无 | 秒破 | ⛔ |
| bcrypt | 1999 | 慢 | 少 (4KB) | 可破 | ✓ |
| Argon2 | 2015 | 慢 | 大 (可配置) | 无效 | ✓✓ |
Argon2:2015 密码哈希竞赛冠军 | RFC 9106 | 行业标准
Argon2 通过两个维度增加破解成本:
- 时间成本:每次计算需要更长时间
- 内存成本:每次计算需要大量内存,让 GPU/ASIC 失去优势
如果你正在开发一个需要存储密码的系统,请使用 Argon2id。这是当前的行业最佳实践。
但是,即使用了 Argon2,密码仍然要传到服务器上,服务器仍然会"见到"你的密码(即使只是短暂地)。有没有办法让服务器永远不知道你的密码呢?
这就是我们下一篇的主题:SRP 协议——一种让服务器永远不知道你密码的认证方案。
上一篇:MD5:一部血泪史 下一篇:SRP:证明你知道密码,却不说出密码
本系列:
- MD5:一部血泪史
- Argon2:慢哈希的艺术(本篇)
- SRP:证明你知道密码却不说出密码
- OPAQUE:防离线破解的终极方案