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 强,但它有两个硬伤:
- 内存占用太小:只有 4KB。现在的 GPU 显存动辄 8GB、16GB,4KB 的占用几乎可以忽略不计。攻击者可以在一个 GPU 上并行跑几千个 bcrypt 实例。
- 无法灵活增加内存: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 的工作流程大致如下:
- 初始化一个大数组:生成一个长度为 N 的伪随机数组(每个元素 128 字节或 256 字节)。这个数组的大小就是内存占用的主要来源。
- 顺序写入:用 PBKDF2 或类似函数生成初始值,然后按顺序填充整个数组。这一步是顺序的,但写入了大量数据。
- 随机读取:从数组中随机取一个位置,用该位置的值做哈希,然后根据哈希结果决定下一个读取位置。如此反复多次。
- 最终输出:把最后一次读取的值作为输出。
关键点在于第 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 知识体系总览
下面这张图帮你理清本章的核心逻辑:
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 当盐,结果两个用户密码相同的时候哈希值也相同——这等于没加盐。盐的随机性直接决定了彩虹表攻击的难度,别在这上面偷懒。