AI 数据通道:JSON·Markdown

一、元认知:哈希为什么不能”快” #

存密码这件事,反直觉的地方在于:哈希越快越危险

MD5 每秒能算十几亿次,SHA-256 每秒几亿次。这些”快”在验证文件完整性时是优点——但在存密码时是致命伤。攻击者拿到泄露的哈希库后,用 GPU 每秒尝试几十亿个密码组合,快哈希让暴力破解变得可行。

密码哈希要的不是”快”,是故意慢——慢到合法用户登录时多等 200 毫秒无所谓,但攻击者暴力破解 10 亿个密码时要花几百年。

这就是 BCrypt 要解决的根本矛盾:如何让哈希速度可调,且只能往慢调,不能被攻击者往快调。

彩虹表的威胁 #

在 BCrypt 之前,密码存储的主要威胁是彩虹表——预先算好的哈希→密码对照表。一个 120GB 的彩虹表能在几秒内破解绝大多数常见密码的 MD5 哈希。

防御彩虹表的标准手段是加盐(salt):每个密码加一段随机字符串再哈希,让预计算表失效。但光加盐不够——如果哈希本身太快(如 MD5+salt),攻击者仍能用 GPU 高速尝试每个盐对应的密码。

BCrypt 的贡献不是”加了盐”(它内置 128-bit 盐),而是让哈希本身变慢,且慢的程度可调

二、搭积木:BCrypt 怎么工作的 #

从 Blowfish 到 Eksblowfish #

BCrypt 由 Niels Provos 和 David Mazières 在 1999 年 USENIX 会议上提出,论文标题是 “A Future-Adaptable Password Scheme”(一个面向未来可适应的密码方案)。名字里的 “B” 来自它所基于的对称加密算法——Blowfish,由 Bruce Schneier 于 1993 年设计。

Blowfish 是一个分组密码,核心是密钥扩展(key schedule):用密钥初始化 18 个 32-bit 子密钥(P 数组)和 4 个 8×32 的 S 盒。这个过程有一次性的初始化成本。

Provos 和 Mazières 的关键改造是 Eksblowfish(Expensive Key Schedule Blowfish):

1
2
3
4
5
标准 Blowfish 密钥扩展:
初始化 P/S-box → 用密钥执行一次扩展 → 结束

Eksblowfish 密钥扩展:
初始化 P/S-box → 用盐+密钥执行 N 轮扩展 → 结束(N = 2^cost)

区别在于:标准 Blowfish 的密钥扩展只跑一遍,Eksblowfish 跑 2^cost 遍。而且第一遍之后的状态被”冻结”——后续的加密过程都用这个昂贵的初始化状态。

这个设计的关键在于:盐也参与密钥扩展,且扩展轮数可调。攻击者无法预计算(因为每个盐不同),也无法加速(因为轮数由防御者设定)。

Cost Factor:自适应的慢 #

BCrypt 的 cost 参数控制 Eksblowfish 的迭代轮数:

1
2
3
cost = 10  →  2^10 = 1,024 轮
cost = 12 → 2^12 = 4,096 轮
cost = 14 → 2^14 = 16,384 轮

cost 每加 1,计算时间翻倍。范围是 4-31(但 31 会让单次哈希耗时达到数年,实际没人用)。

这就是 “Future-Adaptable” 的含义:1999 年用 cost=6(约 30ms),2025 年用 cost=12(约 300ms)——硬件变快了,就把 cost 调高,让哈希时间始终维持在”用户无感但攻击者痛苦”的水平。这是 BCrypt 最前瞻的设计。

输出格式 #

一个 bcrypt 哈希长这样:

1
$2b$12$nKj8Vp3T5r2YwE8mF1qK2O5xQ9zL8aB3cD4eF5gH6iJ7kL8mN9oP0

拆解:

含义
$2b$算法版本
12cost factor
nKj8Vp3T5r2YwE8mF1qK2O22 字符的 Base64 编码盐(128 bit)
5xQ9zL8aB3cD4eF5gH6iJ7kL8mN9oP031 字符的 Base64 编码哈希(184 bit)

盐和哈希编码在同一字符串里——不需要单独存盐。这是 bcrypt 的工程友好设计:一个字符串包含验证密码所需的全部信息。

72 字节限制 #

BCrypt 的密码输入上限是 72 字节。原因在 P 数组:18 个 32-bit 子密钥 = 72 字节,密码循环填充这 72 字节后,多余部分被忽略。

这个限制的后果:

  • 多数实现静默截断——第 73 字节起的密码被丢弃,用户以为输了个长密码,实际只用了一部分
  • 全 4 字节 UTF-8 字符(如 emoji)时,72 字节仅 18 个字符——密码长度感知严重失真
  • 2024 年 Okta 因此出过漏洞:用户名+密码拼接后过 bcrypt,当用户名超过 52 字符时,密码部分被完全截断丢弃,任意密码都能登录

不同实现对超长密码的处理不一致:

实现行为
OpenBSD 原始实现静默截断
Python bcrypt抛异常
Node.js bcrypt静默截断
Rails has_secure_password显式限制 72 字符

实践建议:如果用 bcrypt,要么在应用层强制限制密码长度 ≤72 字节,要么先用 SHA-256 预哈希再过 bcrypt(但预哈希引入新的”脚枪”问题——预哈希泄露后彩虹表攻击可行,需加 HMAC)。

三、案例即原理:两个 bug 和版本史 #

BCrypt 的版本号($2$$2a$$2b$)不是功能迭代,而是两个独立 bug 的修复记录。这两个 bug 恰好揭示了密码哈希实现中最容易出错的两个维度:字符编码和整数溢出。

Bug 1:$2a$ 的 8-bit 字符问题(2011) #

2011 年 6 月,crypt_blowfish(PHP 用的 bcrypt 实现)被发现有一个 bug:当密码中包含第 8 位为 1 的字符(即字节值 >127 的非 ASCII 字符)时,处理逻辑有误。

具体来说,$2a$ 规范要求密码以 UTF-8 编码并包含 null 终止符,但 crypt_blowfish 的实现对高位字节的处理与规范不一致。这导致同一个密码在不同实现下可能产生不同的哈希——在安全领域,这叫”哈希不一致”,意味着迁移系统时密码可能失效,或者更糟,某些密码的哈希碰巧变弱。

修复方式不是改 $2a$ 规范,而是新增两个版本标记:

  • $2x$:标记”已知有问题的旧哈希”,用于识别已存储的弱哈希
  • $2y$:标记”修复后的算法”

重要$2x$$2y$crypt_blowfish/PHP 专有的。OpenBSD(bcrypt 的原始实现)和 Canonical(Ubuntu/glibc)从未采纳这两个版本标记。”Nobody else, including Canonical and OpenBSD, adopted the idea of 2x/2y.”

Bug 2:$2b$ 的 wrap-around(2014) #

2014 年 2 月,OpenBSD 发现自己的 bcrypt 实现有一个更严重的 bug:密码长度用 unsigned 8-bit 值存储。当密码超过 255 字节时,长度值取 length mod 256——比如 260 字节的密码被当成 4 字节处理。

这意味着一个 260 字节的密码和前 4 字节相同的一个短密码,产生完全相同的哈希。虽然 bcrypt 本身截断到 72 字节,但这个 wrap-around bug 在截断之前就发生了,导致长度校验完全失效。

OpenBSD 修复后将版本改为 $2b$。这是当前推荐使用的版本

两个 bug 的启示 #

维度Bug 1($2x/$2y$)Bug 2($2b$)
出错点字符编码处理整数溢出
影响范围crypt_blowfish 专有OpenBSD 原始实现
发现年份20112014
修复方式新增 $2y$ 标记新增 $2b$ 版本

密码哈希实现是最不容错的代码——一个 off-by-one 或编码错误就可能让安全性归零。这也是为什么 OWASP 强调”不要自己实现密码哈希,用经过审计的库”。

四、衍生:从 CPU-hard 到 memory-hard #

BCrypt 的定位:cache-hard #

BCrypt 的 Eksblowfish 在计算时访问约 4KB 的数据(P 数组 + S 盒),这个大小恰好** fits in CPU L1 cache**。攻击者用 GPU 加速时,GPU 的每个核心没有独立的 L1 cache,4KB 的随机访问在 GPU 上变成全局内存访问,慢一个数量级。

这种特性叫 cache-hard——利用 CPU 缓存层次结构让 GPU/ASIC 不划算。BCrypt 是最早的 cache-hard 方案,虽然 1999 年提出时还没有这个术语。

scrypt(2009):memory-hard 的开端 #

Colin Percival 在 2009 年为 Tarsnap 在线备份服务设计了 scrypt(RFC 7914)。核心思想不同:不只用 CPU 时间,还用大量内存

scrypt 要求在计算过程中维护一个大的内存缓冲区(默认几 MB 到几十 MB),且访问模式是随机的。这意味着:

  • CPU 计算快但内存少的设备(如 ASIC)无法加速——因为没有足够的内存并行计算
  • 即使有内存,随机访问模式让内存带宽成为瓶颈
1
2
bcrypt:  CPU 时间 × 4KB cache → GPU 不划算(cache-hard)
scrypt: CPU 时间 × N MB 内存 → ASIC 不划算(memory-hard)

scrypt 的攻击者成本从”买更多 GPU”变成了”买更多 GPU + 买更多内存”,成本指数级上升。

Argon2(2015):PHC 冠军 #

2013-2015 年的 Password Hashing Competition(PHC)收到了 24 个提案,最终 Argon2 胜出(Alex Biryukov、Daniel Dinu、Dmitry Khovratovich,卢森堡大学),2015 年公布,2021 年成为 RFC 9106。

Argon2 有三个变体:

变体内存访问模式抗侧信道适用场景
Argon2d依赖密码(data-dependent)加密货币、不怕侧信道的场景
Argon2i独立于密码(data-independent)密码哈希、抗侧信道
Argon2id混合(前半趟 i,后半趟 d)RFC 9106 推荐默认

Argon2id 是 OWASP 当前推荐的首选密码哈希算法。相比 scrypt,它增加了并行度参数(p)和可调内存/时间的独立控制,且抗侧信道攻击。

PBKDF2(2000):最老但最兼容 #

PBKDF2(Password-Based Key Derivation Function 2,RFC 2898 / PKCS #5 v2.0)是 2000 年的方案,比 bcrypt 还早一年。它的原理最简单:把密码和盐用 HMAC 重复迭代 N 次。

PBKDF2 的问题在于纯 CPU-hard,没有内存硬度。它可以用 GPU 高效并行加速——一块 RTX 4090 每秒能算几百万次 PBKDF2-SHA256。

但 PBKDF2 有一个 bcrypt/Argon2 没有的优势:FIPS 140-2 合规。美国联邦信息系统要求 FIPS 认证的算法,Argon2 和 bcrypt 不在 FIPS 列表中,PBKDF2 是唯一合规的密码哈希方案。

四代哈希的演化轴 #

1
2
3
4
第一代:MD5/SHA + salt      →  快,GPU 可暴力破解
第二代:bcrypt (1999) → cache-hard,GPU 不划算
第三代:scrypt (2009) → memory-hard,ASIC 不划算
第四代:Argon2id (2015) → memory-hard + 并行 + 抗侧信道

演化的核心轴是提高攻击者的硬件成本:从纯 CPU 时间(MD5),到 CPU 缓存(bcrypt),到大内存(scrypt),到可调内存+并行+抗侧信道(Argon2)。

五、前沿:cache-hard vs memory-hard 之争 #

OWASP 的推荐与现实的落差 #

OWASP 2024/2025 Password Storage Cheat Sheet 的推荐顺序:

优先级算法参数场景
1Argon2id19 MiB, t=2, p=1新项目首选
2scryptN=2^17, r=8, p=1Argon2id 不可用时
3bcryptcost≥10仅遗留系统
4PBKDF2SHA-256, ≥600k 迭代FIPS 合规场景

注意 bcrypt 已被降级为”legacy only”。但现实是:

系统默认算法实际推荐
PHP password_hash()bcrypt (cost=12)Argon2 需编译启用,非默认
Ruby on Railsbcrypt显式限制 72 字符
DjangoPBKDF2-SHA256Argon2 需第三方库
WordPressPHPass (MD5-based)2024 年仍在讨论迁移 bcrypt
Linux shadowSHA-512 / yescryptFedora/Arch 已转 yescrypt

OWASP 推荐 Argon2id,但现实中大量系统仍用 bcrypt 甚至更弱的算法。迁移有成本——已有密码库要平滑过渡,不能要求所有用户改密码。

2024 前沿:cache-hard 被重新评估 #

2024 年 11 月,密码学家 Soatok 发表《Beyond Bcrypt》,引发了一场重要讨论:PHC 过度强调了 memory-hardness。

论点核心:

  • 密码认证场景的运行时间通常 <1 秒。在这个时间预算内,memory-hard 函数(scrypt/Argon2)能用的内存有限(几十 MB),攻击者用高端 GPU 仍能并行多个实例
  • cache-hard 函数(如 bcrypt)在 <1 秒时,利用 L1 cache 的速度优势,每秒能做更多次有效计算——但攻击者的 GPU 没有 L1 cache,每秒有效计算反而更少
  • bcrypt wiki 甚至声称:运行时间 <1 秒时 bcrypt 比 Argon2 更强(标注 citation needed,但有逻辑支撑)

讽刺的是:bcrypt 是 cache-hard,而 scrypt 和大多数人用的 Argon2 配置不是 cache-hard(它们的内存访问模式是随机的,不利用 cache 层次)。

Pufferfish2 和 bscrypt:cache-hard 的新尝试 #

沿着 cache-hard 方向的新探索:

  • Pufferfish2:基于 PHC 决赛入围方案 Pufferfish 改进,只在 CPU 核 L2 缓存内操作(非随机访问 RAM),比 scrypt/Argon2 更难用 GPU/ASIC 实现
  • bscrypt:Sc00bz 设计的 cache-hard 方案,论证在短运行时下 cache-hard 优于 memory-hard
  • Balloon Hashing:NIST SP 800-63B 点名推荐,可证明 space-hard,数据独立访问(抗侧信道),但不是 cache-hard

这些方案目前都未成为主流标准,但代表了一个重要的方向:密码认证场景可能不需要”大内存”,而是需要”高效利用 CPU 缓存层次”

量子计算的影响 #

Grover 算法对无结构搜索给出平方加速——2^N 次暴力搜索变成 2^(N/2) 次。对密码哈希的影响:

  • 不”破解”哈希——Grover 不攻击哈希函数本身,只加速暴力搜索
  • 缓解方法简单:约翻倍 work factor(cost +1 即可,因为 bcrypt 的 cost 是 2 的幂)
  • 真正的瓶颈是密码熵——如果用户用 8 位弱密码,不管哈希多慢,搜索空间只有 2^52(考虑字符集),量子计算下 2^26 次搜索就能破

量子计算对密码哈希的威胁远小于对公钥密码(RSA/ECC)的威胁。Shor 算法能分解大整数,直接破解 RSA——但 Shor 不适用于哈希函数。密码哈希的量子安全性主要取决于密码熵,而非哈希算法选择。

六、总结:密码哈希的本质 #

回到最初的问题:BCrypt 是什么?

BCrypt 是第一个”可调速度”的密码哈希方案。 在它之前,哈希速度由算法固定(MD5 永远那么快);在它之后,防御者可以随着硬件升级调高 cost,让哈希始终保持”用户无感但攻击者痛苦”的速度。

它的两个核心设计——Eksblowfish 的可调迭代内置盐——至今仍是密码哈希的基本范式。scrypt 和 Argon2 改进的是”攻击者的硬件成本”维度(从 CPU 时间到内存),但没有改变这个基本范式。

选型决策 #

场景推荐理由
新项目,无合规限制Argon2idOWASP 首选,memory-hard + 抗侧信道
遗留系统用 bcrypt保持 bcrypt,cost≥12迁移成本 > 安全收益,调高 cost 即可
FIPS 合规(美国联邦)PBKDF2-SHA256, ≥600k 迭代唯一合规选项
极端安全需求(<1s 预算)bcrypt cost=14+ 或 Pufferfish2cache-hard 在短运行时下可能更优

一句话 #

密码哈希的本质不是”加密”,而是让暴力破解的成本高到不可行。bcrypt 用可调的慢做到了这一点,scrypt/Argon2 用内存硬度做到了更多,而 2024 年的 cache-hard 争论提醒我们:在密码认证这个特定场景下,”慢”不只有一种正确的方式。


参考资料 #