很多开发者在选密码哈希算法的时候,都会在 PBKDF2、bcrypt、Argon2 之间来回纠结。今天不说虚的,直接讲清楚它们面对 GPU 破解时到底谁更能扛,以及你为什么会出现这种选择困难。
一、先把“抗 GPU 攻击”这几个字拆开看
1.1 密码不是你的钥匙,而是你的签名
想象一下,你每次登录网站,其实不是在“出示钥匙”,而是在“签字”。网站并没有保存你的密码原文,而是保存一份用数学方法算出来的“签名”。下次登录时,你再签一次,网站把两份签名比对,能对上就放你进去。
这个“签名”过程,就是哈希算法。它有一个特点:从签名推不回原密码,而且原密码只要改一个字符,签名就完全不一样。
1.2 为什么要“慢”
如果签名过程只需要 0.0001 秒,那么一个坏人哪怕拿到了数据库,每秒能试一万次密码,纯靠猜也能把简单密码猜个七七八八。所以安全做法是:把签名过程故意变慢,比如让一次签名需要 0.1 秒。这样同样时间内能尝试的次数就会剧减。
但这里有个前提:慢,要慢在“让攻击者也无能为力”的地方,而不是只让普通 CPU 变慢。GPU 就是那个可能让普通 CPU 白忙一场的东西。
二、三位主角的真实面目
2.1 PBKDF2:CPU 里的铁人三项
PBKDF2 是最老牌、应用最广的一个。它的核心思路特别简单:把哈希函数反复执行成千上万次。比如你先算一次 SHA-256,得到结果后再算一次,再算一次……直到完成你设定的次数。
它最大的优点是“兼容性极好”。几乎所有语言、所有加密库都支持它,你甚至可以用系统自带的 OpenSSL 直接生成。但它有一个致命短板:它只需要 CPU 计算,不需要多少内存。CPU 算一次要 0.1 秒,GPU 照样可以开几千个并行线程,把那 0.1 秒变成一秒钟几万次尝试。
2.2 bcrypt:加了记忆的守门员
bcrypt 是专门为密码存储设计的。它自带盐,不需要你自己准备随机串,而且它有一个可调的“成本因子”。成本因子越高,计算越慢。
更重要的是,bcrypt 的计算过程会反复访问一块大约 4KB 的内存。这块内存虽然很小,但对于 GPU 来说,每个线程都要占一块自己的内存,这会让并行效率明显下降。相比 PBKDF2,bcrypt 的抗 GPU 能力已经提升了一个档次。不过它只占用 4KB 内存,放在今天的 GPU 面前,依然不算真正的“铜墙铁壁”。
2.3 Argon2:用内存砌墙的艺术家
Argon2 是 2015 年密码哈希竞赛的冠军,也是最年轻的算法。它有两种主要变体:Argon2i 偏向防 GPU,Argon2d 偏向防侧信道攻击,推荐使用的是 Argon2id,两者兼顾。
Argon2 的核心特点叫“内存硬”。你可以指定它使用 64MB 甚至 1GB 内存。它会把一块大内存填满数据,然后在计算过程中反复地从随机位置读写。注意,GPU 的硬伤就是“并行线很多,但每根线访问内存时容易互相撞车”。当每个线程都需要频繁访问自己的几十 MB 内存时,GPU 的并行威力会被严重削弱。
三、GPU 为什么可怕,CPU 又为什么慢
3.1 GPU 的并行原理
CPU 这个人是“全能型员工”,什么活都接,但一次只能干几件事。GPU 则是“流水线工厂”,它有几千个员工,虽然每个人只能算简单的算术,但架不住人多。只要你派发的是大量互不依赖的计算,GPU 的吞吐量就会逆天。
PBKDF2 就是典型的“互不依赖计算”。每一次哈希都是一个独立任务,张三算张三的,李四算李四的,互不打扰。GPU 可以一股脑开几千个任务同时算,所以 PBKDF2 在 GPU 面前特别吃亏。
3.2 内存硬度的意义
Argon2 的设计思路就是破坏这种“互不依赖”。在计算过程中,后一步的数据依赖前一步的结果,而且前一步的结果可能在内存的任何一个位置。线程之间还会因为访问同样几块内存区域产生排队等待。GPU 的计算单元越多,大家挤在一起抢内存的现象反而越严重,最终导致速度优势缩水一大截。这也是大家都说 Argon2“抗 GPU”的原因。
四、实例跑一遍:三种算法的基准表现
光讲道理不够,咱们直接看代码。这里用 Node.js 18+ 实现一份完整的演示,包含哈希和校验两个步骤。
// 技术栈:Node.js 18+
// 用 async/await 统一处理异步接口
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const argon2 = require('argon2');
// -------------------- PBKDF2 封装 --------------------
// 生成 PBKDF2 哈希,返回自定义格式字符串
async function hashWithPbkdf2(password) {
// 随机生成 16 字节盐
const salt = crypto.randomBytes(16);
// 派生 32 字节密钥,迭代次数 310000,哈希算法 SHA-256
const derivedKey = await new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 310000, 32, 'sha256', (err, key) => {
if (err) return reject(err);
resolve(key);
});
});
// 把盐和密钥都转成十六进制,用 $ 符号拼接
return `pbkdf2$sha256$310000$${salt.toString('hex')}$${derivedKey.toString('hex')}`;
}
// 校验 PBKDF2 哈希
async function verifyWithPbkdf2(password, stored) {
// 拆解自定义格式
const parts = stored.split('$');
const salt = Buffer.from(parts[4], 'hex');
const expectedKey = Buffer.from(parts[5], 'hex');
// 重新计算派生密钥
const actualKey = await new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 310000, 32, 'sha256', (err, key) => {
if (err) return reject(err);
resolve(key);
});
});
// 常量时间比较,避免侧信道攻击
return crypto.timingSafeEqual(expectedKey, actualKey);
}
// -------------------- bcrypt 封装 --------------------
async function hashWithBcrypt(password) {
// 成本因子 12,内部会自己生成随机盐
const hash = await bcrypt.hash(password, 12);
return hash;
}
async function verifyWithBcrypt(password, hash) {
// bcrypt.compare 会把 password 和 hash 中的盐取出来重新计算
return bcrypt.compare(password, hash);
}
// -------------------- Argon2 封装 --------------------
async function hashWithArgon2(password) {
// 使用 Argon2id
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB 内存,单位是 KiB
timeCost: 3, // 迭代 3 次
parallelism: 2 // 使用 2 条并行线程
});
}
async function verifyWithArgon2(password, hash) {
// argon2.verify 会自动读取 hash 里保存的参数
return argon2.verify(hash, password);
}
// -------------------- 跑一遍看看 --------------------
(async () => {
const password = 'P@ssw0rd-2024';
// 测试 PBKDF2
const pbkdf2Hash = await hashWithPbkdf2(password);
console.log('PBKDF2 哈希:', pbkdf2Hash);
console.log('PBKDF2 校验:', await verifyWithPbkdf2(password, pbkdf2Hash));
console.log('');
// 测试 bcrypt
const bcryptHash = await hashWithBcrypt(password);
console.log('bcrypt 哈希:', bcryptHash);
console.log('bcrypt 校验:', await verifyWithBcrypt(password, bcryptHash));
console.log('');
// 测试 Argon2
const argon2Hash = await hashWithArgon2(password);
console.log('Argon2 哈希:', argon2Hash);
console.log('Argon2 校验:', await verifyWithArgon2(password, argon2Hash));
})();
在实际跑不同机器时,你能明显感受到 Argon2 的“内存压力”。同样的算法不同参数,耗时差异也很大。比如把 memoryCost 从 65536 提高到 1048576,也就是 1GB 内存时,普通开发机可能要等好几秒。这种“一加大内存就明显卡住”的感觉,正是它在用内存消耗来压制 GPU。
五、按场景选,别按信仰选
5.1 快速登录场景
如果你的系统只有普通登录,没有高并发要求,而且你也不在乎用库带来的额外依赖,那么 Argon2id 是最优选。推荐参数:内存 64MB 起步,迭代 3 次,并行度 2。这个参数在普通服务器上大约耗时 0.1 到 0.3 秒,既不影响体验,又能有效抗 GPU。
5.2 高并发或老系统场景
有些老系统跑了几十年,数据库里已经存了大量 PBKDF2 或者 bcrypt 哈希。这时候你不可能一夜之间让所有用户重新设置密码。最现实的做法是保留旧哈希,在下次用户成功登录时,用新算法重新生成哈希并替换旧记录。这个方案叫“渐进式迁移”。
如果你面对的是高并发接口,比如每次请求都要校验登录态,那你还需要配合缓存服务。哈希校验本身还是异步去算,不要阻塞请求线程,否则压力一上来系统就崩了。
5.3 兼容与迁移场景
PBKDF2 也不是完全没价值。它的优势是“系统自带,零依赖”,很多编程语言的标准库直接提供。如果你正在写一个非常轻量级的工具脚本,连第三方库都不想装,PBKDF2 作为兜底方案完全没问题。
但如果是从零起步的新项目,没有历史包袱,直接上 Argon2id。因为它把“慢”这个成本更多作用到了攻击者身上,而你自己的开销反而更容易接受。
六、实现时的注意事项
6.1 盐和秘密
无论你选哪个算法,盐都必须随机且每个用户不同。盐的长度至少 16 字节。另外,不要自己发明“加盐法”。直接使用算法库提供的标准封装,比如 bcrypt.hash,内部已经帮你处理好了盐的生成和拼接。
如果你想再增加一层防护,可以在哈希之后再用一个全局密钥做 HMAC。但请注意,这个密钥一旦泄露,所有哈希都会受到影响。所以不要把它硬编码在代码里,有条件的话放到密钥管理服务中。
6.2 升级策略
选型不是一锤子买卖。过几年显卡性能翻倍,你再回头看当初的参数可能就不够用了。你完全可以在系统里支持多种哈希算法,然后根据用户记录的参数自动识别调用。这样某一天你想把 Argon2 的内存从 64MB 调到 128MB,或者从 Argon2 迁移到更新的算法,都不需要重建整张表。
6.3 避免踩坑
第一,不要用绝对路径存密码,错误消息也不要泄露是哪一步认证失败,防止攻击者猜测用户名是否存在。第二,如果你用的是 Node.js,不要在主线程里同步计算 Argon2,否则会卡死事件循环。上面示例里全部用了 async 接口,就是这个原因。第三,自己实现的“加密”算法永远不要在生产环境用,哪怕你觉得自己很有创意。
七、总结
PBKDF2 像是跑步,你只能通过增加圈数来拖延时间。bcrypt 像是让每个攻击者都得带一个 4KB 的背包,负担不大但至少有点拖累。Argon2 像是让攻击者每个人都得背一张 64MB 的大桌子,在过道里挤来挤去。三种算法没有绝对的谁不好,只有适不适合你的场景。
如果你正在写新项目,没有兼容包袱,也无脑推荐 Argon2id。如果你的老系统已经跑了很多年,也不必急着推倒重来,用渐进迁移平滑过渡。真正的“选型纠结”,往往是因为我们没有认真对比过它们面对 GPU 时各自的代价。把“抗 GPU 攻击”的本质理解成“制造内存访问的障碍”,你自然就知道该怎么选了。
评论
围绕“PBKDF2、bcrypt、Argon2选型纠结不如先搞懂各自抗GPU攻击的真实差异”参与讨论