打开一张用户表,密码字段里存的哈希串开头五花八门:有的写 $2a$,有的写 $2b$,偶尔还能看到 $2y$。如果系统做过语言迁移或者框架升级,很可能同一张表里几种前缀并存。这些前缀不是随机生成的标记,它们记录着 Bcrypt 实现史上的几次修正。
前缀记录的是「哪个版本的实现」
Bcrypt 的编码串格式是固定的:$前缀$cost$盐$摘要。前缀部分用来标识算法版本,长度为 3 个字符,形如 2a。
不同前缀之间的差异,通常不是算法本身变了,而是某个实现的 bug 被修正后,需要一个标记来区分新旧行为。这一点很关键——因为同一个密码、同样的盐和 cost,在不同前缀下算出的结果可能不同。
| 前缀 | 说明 |
|---|---|
$2$ | 最早的版本,存在已知缺陷,基本已淘汰 |
$2a$ | 修正了长度处理问题,是目前最常见的版本 |
$2b$ | 修正了 72 字节边界处的溢出缺陷,新实现多输出这个版本 |
$2x$ | PHP 早期实现的兼容标记,用于标记有缺陷的行为 |
$2y$ | crypt_blowfish 的兼容标记,用于标记修正后的行为 |
2a 和 2b 的实际差别
$2a$ 与 $2b$ 的差别出现在 72 字节边界这个细节上。
早期实现在处理长度接近或超过 72 字节的输入时,存在一个溢出问题,导致某些边界情况下的计算结果与预期不符。OpenBSD 的维护者修正了这个缺陷,并用 $2b$ 作为新行为的标记,以便老哈希仍能按旧规则校验。
所以差异只在密码长度接近 72 字节时才会体现出来。对于普通的短密码,$2a$ 和 $2b$ 算出的结果是一致的。
这也解释了为什么很多新库默认输出 $2b$,而老系统的数据里全是 $2a$——两者的哈希可以共存,校验函数会根据前缀选择对应的行为。
2x 和 2y 的来历
这两个前缀的出现与 PHP 生态有关。
PHP 早期的一个 Bcrypt 实现存在缺陷,会对特定长度的密码产生错误的哈希。后来缺陷被修正,但已经存进数据库的旧哈希不能作废,于是用 $2x$ 标记「按旧的有缺陷行为计算」,用 $2y$ 标记「按修正后的行为计算」。
这样一来,校验时读取前缀就知道该用哪套规则,历史数据不需要重置密码就能继续使用。
现代的实现一般不再输出这两个前缀,但它们仍然可能出现在老系统的数据里。写校验逻辑时不要假设前缀一定是 2a 或 2b。
实践中的注意事项
第一,校验必须用完整的哈希串。不能自己提取盐和 cost 再手工拼一个前缀出来,因为前缀决定了计算行为。正确的做法是调用算法提供的验证函数,把存储的整串传进去。
第二,同一个密码两次生成的结果不同是正常的。每次生成都会取新的随机盐,盐写在串里,所以校验仍然能正确匹配。不要试图通过「重新生成一次再比较字符串」来验证密码,那样永远不相等。
第三,跨语言校验要注意实现差异。不同语言、不同库对 Bcrypt 的实现细节可能有细微差别,尤其是前缀的兼容范围。迁移系统时,建议用一批已知的测试向量验证两边的结果一致。
第四,升级前缀不需要用户改密码。如果需要把 $2a$ 统一成 $2b$,做法是在用户登录成功后用新实现重新计算一遍并覆盖存储。因为前缀和参数都写在串里,旧数据在升级前始终能正常校验。
检查清单
- 前缀标识的是实现版本,不是算法名,格式为
$2a$、$2b$、$2y$等 $2a$与$2b$的差别只在密码长度接近 72 字节时体现$2x$与$2y$是 PHP 与 crypt_blowfish 的历史兼容标记- 校验必须传入完整哈希串,让算法按前缀选择计算行为
- 同一密码两次生成的哈希不同是正常现象,不要用字符串比较来验证
- 跨语言迁移时用已知测试向量验证两边结果一致,升级前缀可在登录时顺带完成