3. bcrypt算法:算法原理、Blowfish密码核心、成本因子、与PBKDF2的对比
bcrypt 这玩意儿,说实话,是我个人在存储密码时用得最多的算法。为什么?因为它设计得足够“笨”,笨到让暴力破解的成本高得离谱。你想想看,一个算法如果跑得飞快,那攻击者也能飞快地试密码。bcrypt 偏不,它故意让自己慢下来。
我记得 2012 年那会儿,有个项目需要迁移用户密码哈希。当时团队里有人提议用 SHA-256 直接哈希,我赶紧拦住了。我说:“咱们用 bcrypt 吧,虽然迁移过程会慢一点,但用户数据安全多了。”后来事实证明,这个决定救了我们一命——几个月后隔壁公司就因为用简单哈希被脱裤了。
3.1 算法原理:bcrypt 到底在干什么?
bcrypt 的核心思路其实不复杂。它把 Blowfish 密码算法改造成了一个“慢速”的密钥派生函数。说白了,就是拿你的密码去反复加密一个固定字符串,一遍又一遍,直到你设定的次数跑完。
整个流程可以概括为三步:
- 初始化:用 Blowfish 的 EksBlowfishSetup 函数,把密码和盐(salt)一起喂进去,生成一组子密钥。
- 迭代:对这组子密钥进行 2^cost 轮迭代。每一轮都会重新加密并更新子密钥。
- 输出:用最终的子密钥加密一个固定字符串("OrpheanBeholderScryDoubt"),输出 192 位的哈希值。
嗯,这里要注意:bcrypt 的输出格式是自包含的。它把算法版本、成本因子、盐和哈希值都打包在一个字符串里。比如:
$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
│ │ │
│ │ └─ 盐 (22 字符)
│ └──── 成本因子 (10 = 2^10 轮)
└──────── 算法版本 (2b)
这种设计有个好处——你不需要额外存储盐和成本因子,一个字段全搞定。我在做数据库设计时特别喜欢这一点,少一个字段就少一份出错的可能。
3.2 Blowfish 密码核心:为什么选它?
Blowfish 本身是个对称加密算法,但 bcrypt 用的不是它的加密功能,而是它的 密钥调度 过程。这个过程叫 EksBlowfishSetup,其中 "Eks" 代表 "Expensive Key Schedule"——昂贵的密钥调度。
为什么说它昂贵?因为 Blowfish 的密钥调度需要:
- 初始化 4 个 S 盒(每个 256 项,每项 32 位)
- 初始化 18 个 P 数组(每个 32 位)
- 用密码和盐反复异或、加密、替换这些值
这个初始化过程本身就比普通哈希慢得多。再加上 bcrypt 让它重复 2^cost 次,速度就彻底降下来了。
核心要点:bcrypt 的慢,不是靠简单的循环,而是靠 Blowfish 密钥调度本身的高计算量。每次迭代都在做真正的加密操作,不是无意义的空转。
我曾经做过一个测试:在同样的硬件上,计算一次 SHA-256 只需要几微秒,而计算一次 bcrypt(成本因子 10)需要大约 80 毫秒。差了四个数量级。攻击者想暴力破解 bcrypt?那得准备比破解 SHA-256 多一万倍的时间和电费。
3.3 成本因子:调参的艺术
成本因子(cost factor)是 bcrypt 里唯一需要你手动配置的参数。它决定了算法要跑多少轮:轮数 = 2^cost。
常见的成本因子取值:
| 成本因子 | 轮数 | 大约耗时(2015年硬件) | 大约耗时(2024年硬件) | 推荐场景 |
|---|---|---|---|---|
| 4 | 16 | < 1 ms | < 0.1 ms | 不推荐,太弱 |
| 8 | 256 | ~5 ms | ~1 ms | 低安全场景 |
| 10 | 1024 | ~80 ms | ~10 ms | 通用推荐 |
| 12 | 4096 | ~320 ms | ~40 ms | 高安全场景 |
| 14 | 16384 | ~1.3 s | ~160 ms | 敏感数据 |
我的建议:选成本因子时,先在你的目标服务器上跑个基准测试。让 bcrypt 耗时控制在 50-100 毫秒之间。别盲目追求高成本,用户登录等太久会骂娘的。我一般从 10 开始测,如果服务器性能好就升到 11 或 12。
另外,成本因子是可以升级的。用户登录时,如果发现他的哈希用的是旧成本因子,你可以用新因子重新计算并更新数据库。这叫“渐进式升级”,我在生产环境用过很多次,效果很好。
3.4 与 PBKDF2 的对比:谁更胜一筹?
PBKDF2 是另一个常见的密码派生算法。它俩经常被放在一起比较。我直接说结论:在密码存储场景下,bcrypt 通常优于 PBKDF2。
为什么?看这张对比表:
| 对比维度 | bcrypt | PBKDF2 |
|---|---|---|
| 核心算法 | Blowfish 密钥调度 | HMAC + 底层哈希(如 SHA-256) |
| 内存消耗 | 约 4 KB(固定) | 极小(可忽略) |
| 抗 GPU 攻击 | 较强(Blowfish 的 S 盒访问模式不利于 GPU) | 较弱(HMAC 在 GPU 上并行度高) |
| 抗 ASIC 攻击 | 较强(需要大量片上内存) | 较弱(专用芯片可以高效计算) |
| 输出格式 | 自包含(版本+成本+盐+哈希) | 需自行管理盐和迭代次数 |
| 参数调整 | 成本因子(指数级增长) | 迭代次数(线性增长) |
| 标准化 | 事实标准(广泛使用) | RFC 2898(官方标准) |
说白了,PBKDF2 的设计初衷是“通用密钥派生”,不是专门为密码存储优化的。而 bcrypt 从一开始就是为密码哈希设计的。它内置了盐、内置了成本因子、还故意用 Blowfish 的 S 盒来增加 GPU 和 ASIC 的攻击难度。
避坑指南:我曾经见过有人把 PBKDF2 的迭代次数设成 1000 次,以为够了。结果呢?2019 年的 GPU 一秒钟能算几百万次。如果你非要用 PBKDF2,迭代次数至少 100 万起步。但说实话,我建议你直接用 bcrypt 或 scrypt,省心多了。
不过 PBKDF2 也有它的优势——它是 NIST 标准,在一些合规要求严格的场景(比如政府项目)里,你必须用它。这时候我建议配合高迭代次数和足够长的盐(32 字节以上),至少能弥补一些硬件加速的劣势。
3.5 bcrypt 的局限性
bcrypt 也不是完美的。它有两个明显的短板:
- 密码长度限制:bcrypt 只处理前 72 字节的密码。如果你的用户喜欢用超长密码(比如一段话),后面的字符会被截断。我遇到过这种情况,用户说“我的密码明明是对的为什么登不上”,查了半天发现是截断问题。
- 内存消耗固定:bcrypt 只用了约 4 KB 内存,不像 scrypt 和 Argon2 那样可以调节内存使用量。这意味着攻击者可以用低成本硬件并行计算大量 bcrypt 实例。
对于第一个问题,我的做法是在应用层先对密码做一次 SHA-256,再把哈希值喂给 bcrypt。这样既解决了长度限制,又不会降低安全性。代码大概是这样:
import hashlib
import bcrypt
password = "用户输入的超级长密码..."
# 先哈希,再 bcrypt
digest = hashlib.sha256(password.encode()).digest()
hashed = bcrypt.hashpw(digest, bcrypt.gensalt(rounds=10))
对于第二个问题,如果你的场景对内存消耗有要求(比如防御大规模并行攻击),可以考虑 scrypt 或 Argon2。但大多数 Web 应用场景下,bcrypt 已经足够好了。
3.6 知识结构总览
下面这张图把 bcrypt 的核心知识点串起来了。你可以看到它从 Blowfish 密码出发,经过 EksBlowfishSetup 和成本因子的迭代,最终输出自包含的哈希字符串。同时我也把 PBKDF2 的对比维度放了进去,方便你对照理解。
好了,bcrypt 的核心内容就这些。从算法原理到 Blowfish 核心,再到成本因子的调参,最后和 PBKDF2 做了个对比。我个人觉得,bcrypt 是密码存储领域最稳妥的选择之一。它可能不是最先进的(Argon2 才是),但它的成熟度和广泛使用让它在生产环境中非常可靠。