4. bcrypt 与 scrypt:内存硬函数的优势
聊完了 PBKDF2,咱们得面对一个现实问题——它太容易被暴力破解了。为什么?因为 PBKDF2 对硬件太友好。你想想看,GPU 和 ASIC 芯片可以并行跑成千上万个迭代,攻击者用一块显卡就能把密码表翻个底朝天。
这时候就需要「内存硬函数」登场了。bcrypt 和 scrypt 就是这类函数的代表。它们不光要算,还要吃内存。攻击者想并行?行啊,先给我备好足够的内存再说。
4.1 bcrypt:老牌抗 GPU 选手
bcrypt 是 1999 年诞生的算法,比很多读者的年龄都大。但它至今仍在广泛使用,原因很简单——它设计时就考虑了抗 GPU 攻击。
bcrypt 的核心思路是:把计算和内存访问深度绑定。它内部使用 Blowfish 加密算法的密钥扩展过程,反复对一块 4KB 的内存进行读写。这块内存不大,但关键在于访问模式是随机的,GPU 的缓存机制在这里完全失效。
bcrypt 的工作因子(cost factor)
bcrypt 通过一个参数控制计算强度,通常记作 cost 或 rounds。迭代次数 = 2^cost。cost 每增加 1,计算时间翻倍。
- 推荐值:cost = 10(约 1024 轮),现代硬件上大约耗时 50-100ms
- 安全底线:cost ≥ 10,低于这个值就别用了
- 上限:cost ≤ 31,但实际没人设这么高——cost = 20 就已经要算好几分钟了
我个人习惯把 cost 设成 12。为什么?因为我在一个老项目里见过 cost = 6 的配置,结果用户密码被撞库撞了个精光。从那以后,我对 cost 参数就特别敏感。
来看一段 Go 语言中使用 bcrypt 的代码:
import "golang.org/x/crypto/bcrypt"
// 哈希密码,cost = 12
hash, err := bcrypt.GenerateFromPassword([]byte("myPassword"), 12)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(hash))
// 输出示例:$2a$12$LJ3m4ys3Lk0TSwHn9jQv7e5x8Yz9AbCdEfGhIjKlMnOpQrStUvWx
// 验证密码
err = bcrypt.CompareHashAndPassword(hash, []byte("myPassword"))
if err != nil {
fmt.Println("密码错误")
} else {
fmt.Println("密码正确")
}
注意输出格式:$2a$12$...。其中 2a 是算法版本,12 就是 cost 值。这个格式是自描述的,验证时不需要额外存储 cost 参数。
我曾经踩过的坑:bcrypt 对密码长度有限制——最多 72 字节。如果用户密码超过 72 字节,后面的字符会被静默截断。我曾经在一个项目中没注意这个限制,结果用户用超长密码时,实际只校验了前 72 位。解决方案:先对密码做 SHA-256 哈希,再用 bcrypt 处理哈希值。
4.2 scrypt:内存消耗的精确控制
scrypt 是 2012 年由 Colin Percival 设计的,目的很明确——让暴力破解的成本高到攻击者无法承受。它比 bcrypt 更进一步,不光控制 CPU 计算量,还精确控制内存使用量。
scrypt 有三个核心参数:
| 参数 | 含义 | 典型值 | 影响 |
|---|---|---|---|
N |
CPU/内存成本参数 | 16384 (2^14) | 必须是 2 的幂,控制迭代次数和内存大小 |
r |
块大小参数 | 8 | 影响内存访问粒度和计算量 |
p |
并行化参数 | 1 | 控制并行计算的程度,通常设为 1 |
内存消耗的计算公式:内存 ≈ 128 × N × r(字节)。
举个例子:N=16384, r=8 时,内存消耗 = 128 × 16384 × 8 = 16,777,216 字节 ≈ 16 MB。这个量级对普通服务器来说不算什么,但对 GPU 来说——16 MB 的片上缓存?想都别想。攻击者每并行一个线程,就得额外分配 16 MB 内存。
参数选择建议:
- N = 16384 (2^14),这是目前的安全底线
- r = 8,标准推荐值
- p = 1,不要轻易改大,否则可能被并行攻击利用
如果对性能有更高要求,可以适当降低 N,但不要低于 2^12(4096)。
来看 Python 中使用 scrypt 的示例:
import hashlib
import base64
# 参数设置
password = b"mySecurePassword"
salt = b"random_salt_value"
N = 16384 # CPU/内存成本
r = 8 # 块大小
p = 1 # 并行参数
dklen = 32 # 派生密钥长度(字节)
# 计算 scrypt 哈希
derived_key = hashlib.scrypt(
password=password,
salt=salt,
n=N,
r=r,
p=p,
dklen=dklen
)
# 输出 Base64 编码的结果
print(base64.b64encode(derived_key).decode())
# 输出示例:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
为什么 scrypt 比 bcrypt 更安全?说白了,bcrypt 只用了 4KB 内存,而 scrypt 可以轻松用到几十 MB。攻击者想并行破解 scrypt,内存开销是线性增长的。你想想看,一块 GPU 有几千个核心,每个核心要 16 MB 内存——那得配多大的显存?
4.3 bcrypt vs scrypt:怎么选?
这个问题我经常被问到。直接给结论:
| 维度 | bcrypt | scrypt |
|---|---|---|
| 内存消耗 | 固定 4KB | 可配置,通常 16MB+ |
| 抗 GPU 攻击 | 中等 | 强 |
| 抗 ASIC 攻击 | 中等 | 强 |
| 参数配置 | 简单(1 个参数) | 复杂(3 个参数) |
| 标准支持 | 广泛(几乎所有语言都有库) | 较广(但部分旧平台不支持) |
| 推荐场景 | 密码哈希、一般安全需求 | 高安全需求、密钥派生 |
我的建议是:
- 密码存储:优先用 bcrypt,简单可靠,社区支持好
- 密钥派生:用 scrypt,内存硬特性更适合生成加密密钥
- 高安全场景:直接上 scrypt,参数设高一点
注意:scrypt 的参数配置比 bcrypt 复杂,容易配错。我曾经见过有人把 p 设成 100,结果服务器 CPU 直接飙到 100%。如果你不确定怎么配,先用 bcrypt 保底,等熟悉了再切 scrypt。
4.4 核心逻辑图:内存硬函数的工作流程
下面这张图展示了 bcrypt 和 scrypt 的核心工作流程对比。你可以看到,bcrypt 在固定大小的内存块上反复读写,而 scrypt 先生成一个大内存数组,然后通过随机访问来消耗内存带宽。
从图上你能看到,scrypt 比 bcrypt 多了一个「生成大内存数组」的步骤。这个步骤就是它的杀手锏——攻击者想并行破解,就得为每个线程分配同样大小的内存。而 bcrypt 的 4KB 内存,GPU 的 L1 缓存就能装下,并行起来毫无压力。
4.5 避坑指南
最后分享几个我踩过的坑:
- 别用 bcrypt 做密钥派生:bcrypt 输出只有 184 位(23 字节),不适合做 AES 密钥。密钥派生请用 scrypt 或 PBKDF2。
- scrypt 的 N 必须是 2 的幂:设成 10000 会直接报错。我见过有人因为这个 bug 在生产环境挂了半小时。
- 注意性能测试:scrypt 的参数调高后,单次调用可能耗时几百毫秒。上线前一定要压测,别让用户登录等太久。
- 版本兼容性:bcrypt 有
$2a$、$2b$、$2y$三种前缀,不同库的实现略有差异。跨语言使用时要注意。
嗯,bcrypt 和 scrypt 就聊到这儿。这两个算法是内存硬函数的代表,理解了它们的设计思路,你就能明白为什么现代密码存储不能只用简单的哈希了。