4. bcrypt 与 scrypt:内存硬函数的设计思想、参数调优、与 PBKDF2 的对比

聊完了 PBKDF2,咱们得面对一个现实——它老了。不是说它不能用,而是摩尔定律和专用硬件(ASIC、GPU)让它的暴力破解成本降得太低。你想想看,PBKDF2 的核心就是反复做 HMAC,这玩意儿在 GPU 上跑得飞快。我见过一个团队用 8 块 RTX 4090 组了个小集群,跑 PBKDF2-SHA256 的速率能到每秒几十亿次迭代。这谁顶得住?

所以业界开始琢磨:能不能设计一种函数,让攻击者就算有再强的硬件也快不起来?答案就是——内存硬(Memory-Hard)。bcrypt 和 scrypt 就是这条路上的两个里程碑。今天咱们就掰开揉碎了聊聊它们的设计思想、参数怎么调,以及跟 PBKDF2 到底差在哪。

4.1 为什么需要内存硬?——从「算力硬」到「内存硬」

PBKDF2 的问题说白了就是:它只消耗 CPU 时间,不消耗内存。攻击者可以用 GPU 的几千个核心并行计算,每个核心只占一点点内存。结果就是,破解速度跟核心数几乎线性增长。

内存硬函数的思路很直接:让计算过程必须占用大量内存,而且内存访问模式是随机的,没法被缓存优化。这样一来,GPU 的几千个核心虽然多,但每个核心需要独占几 MB 甚至几十 MB 的内存,显存根本装不下。攻击者要么减少并行数,要么换更贵的硬件(比如大内存的 FPGA 或专用芯片)。

核心思想: 用「内存带宽」和「内存容量」作为瓶颈,替代「CPU 算力」作为瓶颈。让攻击者的成本从「买更多 GPU」变成「买更大内存」,而大内存的并行扩展性很差。

我个人习惯把这类函数分成两代:bcrypt 是第一代尝试,scrypt 是第二代成熟方案。咱们一个一个看。

4.2 bcrypt:Blowfish 密码算法的巧妙复用

bcrypt 是 1999 年由 Niels Provos 和 David Mazières 设计的。它没有从头造轮子,而是借用了 Blowfish 分组密码的密钥扩展(Key Schedule)过程。说白了,它把 Blowfish 的密钥初始化阶段改成了一个可调迭代次数的慢速哈希。

4.2.1 设计思想:EksBlowfish

bcrypt 的核心是一个叫 EksBlowfish(Expensive Key Schedule Blowfish)的算法。它跟普通 Blowfish 的区别在于:

  • 普通 Blowfish:密钥扩展很快,只需要 4KB 的 S-box 和 18 个 32 位 P-box。
  • EksBlowfish:密钥扩展被故意做慢,通过反复迭代来填充和更新这 4KB 的 S-box。

这 4KB 就是 bcrypt 的内存占用。虽然现在看来 4KB 很小,但在 1999 年,这已经能让当时的 GPU(如果有的话)感到吃力了。而且 bcrypt 的 S-box 更新过程是数据依赖的——每次更新都依赖前一次的结果,没法并行。

一个小细节: bcrypt 的输出格式里直接包含了盐(salt)和成本因子(cost factor)。比如 $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy,其中 10 就是成本因子,表示迭代次数为 2^10 = 1024 轮。盐和哈希值都在后面。这种自包含的设计让部署变得非常简单——你不需要额外存盐。

4.2.2 参数调优:成本因子(Cost Factor)

bcrypt 只有一个可调参数:成本因子(通常用 rounds 或 cost 表示)。迭代次数 = 2^cost。cost 的取值范围通常是 4 到 31。

怎么选?我个人的经验是:

  • 2024 年的推荐值:cost = 10 到 12。对应 1024 到 4096 轮迭代。
  • 在普通服务器 CPU 上,cost=10 大约需要 50-80ms,cost=12 需要 200-300ms。
  • 对于用户登录场景,我建议控制在 250ms 以内。超过 500ms 用户就能感觉到卡顿。
  • 对于密码存储(比如管理员密码),可以适当提高到 cost=13 或 14。

我曾经踩过的坑: 有一次我把 cost 设成了 15,结果在低配云服务器上每次登录要等 1.2 秒。用户投诉说「密码输对了还要转半天圈」。后来我改成 cost=11,时间降到 80ms,体验好了很多。所以调参一定要结合你的硬件环境,别盲目追求高成本。

4.2.3 bcrypt 的局限性

bcrypt 虽然比 PBKDF2 强,但它有两个硬伤:

  1. 内存占用太小:只有 4KB。现在的 GPU 显存动辄 8GB、16GB,4KB 的占用几乎可以忽略不计。攻击者可以在一个 GPU 上并行跑几千个 bcrypt 实例。
  2. 无法灵活增加内存:bcrypt 的内存占用是固定的,没法通过参数调大。你只能增加迭代次数,但迭代次数增加的是 CPU 时间,不是内存压力。

所以,bcrypt 只能算「半内存硬」。它比 PBKDF2 好,但还不够好。真正把内存硬做到极致的是 scrypt。

4.3 scrypt:内存硬函数的标杆

scrypt 是 2009 年由 Colin Percival 为 Tarsnap 在线备份服务设计的。它的目标很明确:让计算过程必须占用大量内存,而且内存访问模式完全随机,无法被缓存。

4.3.1 设计思想:ROMix 与 SMix

scrypt 的核心是一个叫 ROMix 的算法,它又依赖一个叫 SMix(Sequential Memory-hard Mixing)的子过程。SMix 的工作流程大致如下:

  1. 初始化一个大数组:生成一个长度为 N 的伪随机数组(每个元素 128 字节或 256 字节)。这个数组的大小就是内存占用的主要来源。
  2. 顺序写入:用 PBKDF2 或类似函数生成初始值,然后按顺序填充整个数组。这一步是顺序的,但写入了大量数据。
  3. 随机读取:从数组中随机取一个位置,用该位置的值做哈希,然后根据哈希结果决定下一个读取位置。如此反复多次。
  4. 最终输出:把最后一次读取的值作为输出。

关键点在于第 3 步:随机读取。因为读取位置是哈希结果决定的,攻击者没法预判下一个要读哪里。这就意味着:

  • 你不能只存一部分数组——因为你不知道下次会读哪个位置。
  • 你不能用缓存加速——因为访问模式是随机的,缓存命中率极低。
  • 你必须把整个数组都放在内存里,而且每次读取都要走内存带宽。

这就是内存硬的核心: 用「随机内存访问」替代「顺序计算」,让攻击者的瓶颈从「算力」变成「内存带宽」和「内存容量」。而内存带宽的扩展性远不如算力——你加再多的 GPU,内存带宽也受限于物理总线。

4.3.2 参数调优:N、r、p

scrypt 有三个可调参数,比 bcrypt 复杂一些:

参数 含义 影响 推荐范围(2024)
N 内存占用因子(必须是 2 的幂) 内存大小 = 128 × N × r 字节 2^14 (16384) 到 2^20 (1048576)
r 块大小因子 影响内存带宽和计算量 8 到 16
p 并行度因子 影响 CPU 时间,不影响内存 1(除非你有多核并行需求)

内存计算公式:Memory = 128 × N × r 字节。

举个例子:N=16384, r=8,内存 = 128 × 16384 × 8 = 16,777,216 字节 ≈ 16 MB。这个大小已经能让 GPU 的并行数大打折扣了——一块 8GB 显存的 GPU,最多只能同时跑 500 个实例(8GB / 16MB = 512)。而同样的 GPU 跑 PBKDF2 可以跑几万个实例。

我个人习惯的调参策略:

  • 普通 Web 应用:N=16384, r=8, p=1。内存约 16MB,时间约 50-100ms。
  • 高安全场景:N=65536, r=8, p=1。内存约 64MB,时间约 200-400ms。
  • 密码管理器或密钥派生:N=262144, r=8, p=1。内存约 256MB,时间约 1-2 秒。

注意: 不要随意增大 r 值。r 太大反而会降低安全性,因为内部算法会退化成更易被优化的形式。我一般固定 r=8,只调 N。p 通常保持为 1,除非你明确需要多核并行。

4.4 与 PBKDF2 的对比:一张表说清楚

咱们用一张表来总结三者的核心差异:

特性 PBKDF2 bcrypt scrypt
设计年份 2000 1999 2009
核心算法 HMAC 反复迭代 Blowfish 密钥扩展 ROMix + SMix
内存占用 极小(几 KB) 固定 4KB 可调(几 MB 到几 GB)
内存硬程度 无 弱(4KB 太小) 强(随机访问大数组)
GPU 抗性 差 中等 强
ASIC 抗性 差 中等 强(内存带宽瓶颈)
参数可调性 迭代次数 成本因子(2^cost) N, r, p
输出格式 需自行管理盐 自包含(含盐和成本) 自包含(含盐和参数)
典型应用 旧系统、兼容性要求高 Web 应用密码存储 密码管理器、加密货币钱包

从这张表能看出来:scrypt 在安全性上全面领先,但代价是计算资源消耗更大。bcrypt 是一个不错的折中方案——比 PBKDF2 安全,又比 scrypt 轻量。PBKDF2 现在基本只用于兼容旧系统,或者作为 scrypt 内部的一个子步骤。

4.5 知识体系总览

下面这张图帮你理清本章的核心逻辑:

bcrypt 与 scrypt 知识体系 内存硬函数:bcrypt 与 scrypt 知识体系 问题:PBKDF2 在 GPU/ASIC 上太容易被并行加速 核心思想:用「内存带宽/容量」替代「CPU 算力」作为瓶颈 bcrypt:EksBlowfish 内存占用:固定 4KB 参数:cost(2^cost 轮迭代) 局限:内存太小,GPU 仍可并行 scrypt:ROMix + SMix 内存占用:可调(几 MB ~ 几 GB) 参数:N(内存大小)、r、p 优势:随机内存访问,抗 GPU/ASIC 选型建议:新系统优先 scrypt,兼容旧系统用 bcrypt

4.6 实战选型建议

说了这么多理论,最后给点实在的建议。如果你现在要设计一个新系统,该怎么选?

  • 优先用 scrypt:如果你的运行环境内存充足(比如服务器有 8GB+ 内存),scrypt 是当前最安全的选择。密码管理器(如 LastPass、1Password)和加密货币钱包(如 Litecoin)都在用 scrypt。
  • bcrypt 作为备选:如果对性能要求极高,或者需要兼容老设备(比如嵌入式系统),bcrypt 是很好的折中。它的 4KB 内存占用在任何设备上都不是问题。
  • PBKDF2 只用于兼容:除非你要对接一个只支持 PBKDF2 的旧系统,否则别用它。它的安全性在 2024 年已经不够看了。
  • 别自己造轮子:不要尝试自己实现这些算法。用标准库——Python 的 bcrypt 和 hashlib.scrypt,Go 的 golang.org/x/crypto,Java 的 Bouncy Castle。我见过太多自己实现结果搞出漏洞的案例了。

最后提醒一句: 不管用哪个函数,盐(salt)一定要随机生成,长度至少 16 字节。我见过有人用用户 ID 当盐,结果两个用户密码相同的时候哈希值也相同——这等于没加盐。盐的随机性直接决定了彩虹表攻击的难度,别在这上面偷懒。