很多开发者在选密码哈希算法的时候,都会在 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 攻击”的本质理解成“制造内存访问的障碍”,你自然就知道该怎么选了。