bcrypt 的 72 字节是截断点:Go 报错、PHP 静默截断、WordPress 先 HMAC 再哈希
bcrypt 只把密码的前 72 字节送进 Blowfish 密钥调度,第 73 字节起不参与任何运算。所以 72 字节不是「上限」,是截断点。现代实现对此有三种态度:
- 拒绝:Go 的
GenerateFromPassword返回ErrPasswordTooLong; - 静默截断:PHP 的 bcrypt 实现不检查长度,超长部分交给
crypt()截; - 预哈希绕开:WordPress 6.8 先对密码做 HMAC-SHA384 再 base64,压到 64 字符后喂给 bcrypt。
选哪种,决定了以后换算法、迁系统时,长密码用户会不会在登录口炸。前两种实现之间可以互验(都取前 72 字节),WordPress 的 $wp 前缀必须带着 HMAC 逻辑一起搬,否则标准 bcrypt 库一个都验不过。
涉及的源码与文档:golang/crypto、php-src、wordpress-develop、OWASP Password Storage Cheat Sheet。
72 字节从哪来
bcrypt 出自 Provos & Mazières, USENIX 1999,做法是把密码当 Blowfish 密钥,用 salt 做密钥调度,再对字符串 OrpheanBeholderScryDoubt 加密 64 轮。
Go 实现里能直接看到边界:
// expensiveBlowfishSetup
ckey := append(key[:len(key):len(key)], 0)
c, err := blowfish.NewSaltedCipher(ckey, csalt)
切片表达式限制了容量,append 一个 0 后进密钥调度;Blowfish 子密钥是 18 个 uint32,加上结尾 NUL,正好 72 字节。常说的「Blowfish 密钥 56 字节」是另一回事,bcrypt 实现取 72。
输出固定 60 字节:$2a$ + 两位 cost + $ + 22 字符 salt + 31 字符 hash。Go 里 minHashSize = 59,maxCryptedHashSize = 23(24 字节只编码 23,为兼容 C 实现的旧 bug)。
Go:写入拒绝,校验放行
GenerateFromPassword 第一行就拦:
if len(password) > 72 {
return nil, ErrPasswordTooLong
}
CompareHashAndPassword 没有这一步。它解析出 cost 和 salt 后直接调 bcrypt(password, p.cost, p.salt),前 72 字节一致就通过。这个不对称可以复现:
package main
import (
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
func main() {
// 80 字节密码:GenerateFromPassword 直接拒绝
long := []byte(strings.Repeat("a", 80))
_, err := bcrypt.GenerateFromPassword(long, bcrypt.DefaultCost)
fmt.Println(err)
// bcrypt: password length exceeds 72 bytes
// 用前 72 字节生成一个 hash
hash, err := bcrypt.GenerateFromPassword([]byte(strings.Repeat("a", 72)), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
// 用 100 字节的密码去比对:CompareHashAndPassword 不检查长度,
// 前 72 字节相同,返回 nil
fmt.Println(bcrypt.CompareHashAndPassword(hash, []byte(strings.Repeat("a", 100))))
//
}
同一份输入,写入路径报错、校验路径放行。迁移时先炸的往往是注册和改密接口,登录反而没事。
常量定义:MinCost = 4、MaxCost = 31、DefaultCost = 10。cost 小于 4 不报错,newFromPassword 里 if cost 72 判断。cost 默认 PHP_PASSWORD_BCRYPT_COST = 10,不在 4..31 范围抛 Invalid bcrypt cost parameter specified: %d。格式固定 $2y$%02d$,salt 22 字符,输出 60 字节。超长密码进入 crypt() 后被截断,PHP 层不报错。
校验是 php_crypt 加 php_safe_bcmp 常量时间比较。php_password_bcrypt_valid 要求长度正好 60、前缀 $2y$、cost 在 4..31 内,否则视为非法 hash。
php_password_bcrypt_needs_rehash 只比较 old_cost != new_cost,不比较算法族。所以从别的算法族换到 bcrypt 时,不能指望这个函数单独完成判断。
// 注册
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// 形如 $2y$12$,共 60 字节
// 登录
if (password_verify($password, $row['password_hash'])) {
if (password_needs_rehash($row['password_hash'], PASSWORD_BCRYPT, ['cost' => 12])) {
$new = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// 更新库里的 hash
}
}
截断行为可以直接验证:
$stored = password_hash(str_repeat('a', 72), PASSWORD_BCRYPT);
var_dump(password_verify(str_repeat('a', 72) . '任意后缀', $stored)); // bool(true)
Argon2id 走另一套:PHP_PASSWORD_ARGON2_MEMORY_COST、TIME_COST、THREADS,salt 16 字节。自定义 salt 选项已被忽略并告警:The "salt" option has been ignored。salt 由库生成,调用方不用管。
WordPress 6.8:先 HMAC-SHA384,再 bcrypt
6.8.0 起默认 bcrypt,替代 phpass(2^8 = 256 轮 MD5,8 作为参数)。上限是 4096 字节:
if ( strlen( $password ) > 4096 ) {
return '*';
}
返回 * 是个不可校验的占位符,等于拒绝。
算法可被 wp_hash_password_algorithm 过滤器改(默认 PASSWORD_BCRYPT,也可 PASSWORD_ARGON2I / ARGON2ID / PASSWORD_DEFAULT),options 由 wp_hash_password_options 过滤。非 bcrypt 分支直接走 password_hash();bcrypt 分支多做一步:
$password_to_hash = base64_encode( hash_hmac( 'sha384', trim( $password ), 'wp-sha384', true ) );
return '$wp' . password_hash( $password_to_hash, $algorithm, $options );
源码注释写得很直白:Use SHA-384 to retain entropy from a password that's longer than 72 bytes, and a wp-sha384 key for domain separation.
SHA-384 输出 48 字节,base64 后 64 字符,加上固定前缀也远小于 72,截断点永远不会触发。结果形如 $wp$2y$10$...,标准 bcrypt 库看到 $wp 前缀会判定为非法 hash——外部系统要校验 WordPress 密码,得自己先做同样的 HMAC + base64,再剥掉 $wp 交给 password_verify,或者把这段逻辑原样搬过去。
选型与运维
OWASP Password Storage Cheat Sheet 当前的推荐值:
- 首选 Argon2id:memory 19 MiB、iterations 2、parallelism 1;
- 其次 scrypt:CPU/memory cost 2^17、block size 8(1024 字节)、parallelization 1;
- 遗留 bcrypt:work factor ≥ 10,密码上限 72 字节;
- FIPS-140 场景用 PBKDF2:HMAC-SHA-256,work factor ≥ 600000;
- 一次哈希控制在 1 秒以内。
升级在登录成功时做:Go 用 bcrypt.Cost(hashed) 读旧 cost,PHP 用 password_needs_rehash,命中就重哈希写回。
cost 提高要按 CPU 预算算,不能只跑一次觉得「还行」。一次 hash 时间 × 登录 QPS ≈ 常驻 CPU 占用,cost 每加 1 直接翻倍;登录接口本来就有突发流量,cost 定高了会把登录口变成 DoS 面。
pepper 用 post-hash 方案:先算 hash,再对 hash 做 HMAC,pepper 当 HMAC key。pepper 不能和 hash 存一起(放 vault 或 HSM);换 pepper 必须强制重置密码,否则旧 hash 全部无法验证。pre-hash pepper(先加 pepper 再哈希)同样有人用,但要清楚它进了 bcrypt 的 72 字节预算。
盐不用手写,现代实现自动生成。用户输入密码加长度上限(比如 4096 字节),配合拒绝超长请求,防的是拿超长字符串打哈希的请求。
还没对齐的:多字节与前后端校验
72 是按字节算的。UTF-8 中文 3 字节一个字,72 字节约 24 个汉字;emoji 4 字节,约 18 个。
前端 maxlength 多数按字符(或 UTF-16 code unit)算,后端 strlen 按字节算。两边不对齐时,会出现前端放行、后端静默截断的情况:用户以为改了 30 个汉字的密码,实际只有前 24 个字进哈希。上线前把前端校验单位、后端字节截断、以及注册/改密/登录三条路径的校验方式对齐一遍,这项要用实际请求验证,不能只看文档。