bcrypt 报「密码不能超过 72 字节」?短密码也会中招
两个完全不同的问题会产生同一条报错,而其中只有一个跟你的密码有关。
如果你的密码确实超过了 bcrypt 的 72 字节上限,bcrypt 只读前 72 字节,其余全部丢弃。我们在固定 salt 下对两个前 72 字节相同的 82 字节密码做了哈希(hash),结果都是 $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S,并且 bcrypt.compareSync(p2, hash(p1)) 返回 true。也就是说,第二个密码能登录第一个密码的账号。
如果你的密码明明很短,却依然收到这条报错:
password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
(意为「密码不能超过 72 字节,必要时请手动截断」),那么这条消息对成因的描述就是错的。在 passlib 1.7.4 搭配 bcrypt 5.0.0 的组合下,一个 14 字节的密码照样会触发它。
真正的原因是 passlib 内部一个固定长度 255 字节的自检探针。它只在后端(backend)初始化时运行一次,那时你的密码根本还没走到哈希调用。bcrypt 5.0.0 拒绝了这个探针,异常向上冒泡,于是你读到的是一条关于「谁也没输入过的密码」的抱怨。
搜索这条报错时排在最前面的 __about__ monkey patch 修不好它。我们在干净进程里、把补丁放在 import passlib 之前重跑了一遍,ValueError 原封不动地再次出现。
30 秒分诊:你属于哪一种
| 你的密码 | 报错时机 | 根因 | 跳到 |
|---|---|---|---|
| 超过 72 字节 | 调用 hash 时 | 确实超长。bcrypt 5.0 抛错,bcrypt 4.x 静默截断 | 第 2、3 节 |
| 不到 72 字节,且用了 passlib | 进程内首次调用时 | passlib 的 255 字节探针。与你的密码无关 | 第 4 节 |
| 含中文、日文或 emoji | 看着短,其实不短 | 字符数不等于字节数 | 第 3 节 |
| 升级依赖之后才开始失败 | 部署之后 | bcrypt 5.0 的破坏性变更 | 第 4、5 节 |
如果你落在第二行,直接往后跳。接下来两节帮不上你,修法也完全不同。
bcrypt 的 72 字节上限对密码做了什么
bcrypt 为什么停在 72
bcrypt 建立在 Blowfish 之上,它把你的密码当作 Blowfish 的密钥送进去。Blowfish 会把密钥展开成一个 P 数组,共 18 个子密钥,每个 32 位宽。这就是 18 × 4 = 72 字节的密钥材料,展开循环填满 18 个槽位之后就绕回密钥开头。
所以这个天花板是结构性的。它既不是实现偷懒,也不是某人忘了调大的缓冲区。每一个符合规范的 bcrypt 实现、在每一个平台上都有同样的限制,这也是为什么你在 Python、Node、Go、Java 和 PHP 里看到的都是 72 这个数字。
两个不同的密码,同一个 hash
bcrypt 的密码截断是一个安全性质,比长度上的不便严重得多。
我们用 bcryptjs 3.0.3、固定 salt $2a$10$abcdefghijklmnopqrstuv,对两个各 82 字节的密码做哈希:
| 密码 | 值 | 字节数 |
|---|---|---|
| p1 | "A"×72 + "XXXXXXXXXX" | 82 |
| p2 | "A"×72 + "ZZZZZZZZZZ" | 82 |
两者产生了完全相同的摘要:
$2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S
两个不同的密码,一个 hash:true。于是:
bcrypt.compareSync(p2, hash(p1)) // true
只要攻击者知道一个长口令的前 72 字节,他可以在后面接任意内容并通过认证。边界之后的每一个字节,对存储 hash 的强度贡献都精确为零,无论用户挑选得多么用心。如果你手上已经有一个 hash,想拿一个候选密码去比对却不想临时写脚本,可以直接在浏览器里生成并校验 bcrypt hash,亲眼看看同样的行为。
边界究竟落在哪一字节
我们保持前缀相同、只改动其后的某一个字节,逐字节收窄了这个截断点:
| 前缀相同字节数 | 第 N+1 字节不同 | hash 相同? |
|---|---|---|
| 70 | 71 | false |
| 71 | 72 | false |
| 72 | 73 | true |
| 73 | 74 | true |
第 72 字节仍然算数,第 73 字节是第一个不算数的。中间没有渐弱地带。判定这么干脆,你在自己的库上本地复现一遍只要几分钟。
字符数不等于字节数
bcrypt 数的是 UTF-8 字节,而用户输入的是字符。对 ASCII 来说这两个数字恰好相等,所以团队一走出英语市场就会被它咬到。
| 字符类型 | 示例 | 每字符字节数 | 72 字节相当于 |
|---|---|---|---|
| ASCII 拉丁字母 | A | 1 | 72 个字符 |
| 中文汉字 | 密 | 3 | 24 个字符 |
| 日文假名 | あ | 3 | 24 个字符 |
| emoji | 🔒 | 4 | 18 个字符 |
| 西里尔字母 | я | 2 | 36 个字符 |
| 带变音符的德文 | ü | 2 | 36 个字符 |
两端我们都验证过:中文密码在第 24 个字符之后的差异被忽略(true),emoji 密码在第 18 个之后的差异同样被忽略(true)。
一个 25 个汉字的中文口令,在密码框里看着相当充裕,实际上已经越线了。选了 20 个 emoji 的用户,早在两个字符之前就超限了,而且永远不会被告知。
在自己的代码里量准字节长度
按字符数写的长度校验会顺利放行一个其实已经超长的值。要量字节:
# Python
len(pw.encode("utf-8"))
// Node.js
Buffer.byteLength(pw, "utf8")
// Go
len([]byte(pw))
在没有 Buffer 的浏览器环境里,new TextEncoder().encode(pw).length 给出同样的数字。把这个检查放在哈希调用之前,返回一条真正的校验提示,而不是凌晨三点让库替你决定。如果你顺手也在重新审视最小长度策略,密码强度到底是怎么度量的讲清了一条长度规则能买到什么、买不到什么。
短密码为什么也报错:passlib 的 255 字节探针
你的密码只有十四个字符,库却坚称它超过了 72 字节。把最多人送进搜索引擎的就是这一类。
复现
三行代码,环境是 Python 3.14.5、bcrypt 5.0.0、passlib 1.7.4:
from passlib.hash import bcrypt
bcrypt.hash("short-password") # 密码只有 14 字节
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
进去的是十四字节,出来的是关于 72 字节的抱怨。passlib 的这条 bcrypt 报错是真实存在的,但其中的数字描述的完全是另一回事。
完整调用栈
这不是推测。以下是在 passlib 1.7.4 里实际跟踪到的执行路径:
- 首次调用触发后端初始化:
_calc_checksum→_stub_requires_backend()→set_backend()。 _load_backend_mixin去读bcrypt.__about__.__version__。该属性并不存在,于是抛出AttributeError。passlib 把它吞掉,只打印(trapped) error reading bcrypt version。- 初始化继续进入
_finalize_backend_mixin(passlib/handlers/bcrypt.py:421),它调用detect_wrap_bug(IDENT_2A)。 detect_wrap_bug(同一文件,:378)会去校验一个固定的 255 字节探针。- bcrypt 5.0.0 对任何超过 72 字节的输入抛
ValueError,于是探针把自己炸了。 - 异常一路冒泡到你的调用点。你看到的是一条关于 72 字节、却从来与你的输入无关的报错。
整个过程每个进程只发生一次,就在第一次 hash 或 verify 时。所以这个故障复现得极其稳定,又对你传入什么完全不敏感。
探针长什么样
secret = (b"0123456789" * 26)[:255]
这个常量来自 Openwall 于 2012 年披露的 BSD bcrypt 绕回(wraparound)缺陷:长密钥会绕回开头,坍缩成更弱的 hash。passlib 在启动时检查刚加载的后端是否带有这个缺陷,并拒绝信任带缺陷的后端。
话要说准。detect_wrap_bug 不是 passlib 的 bug,它是一段防御代码,用一个十多年来一直有效的测试向量,做着它被写出来就该做的事。真正变了的是 bcrypt 5.0.0 现在把 255 字节的输入当作错误,而不是当作待哈希的数据,于是一个原本能通过的自检变成了一个无法捕获的失败。pyca/bcrypt 的 issue #1082 记录了这两个库之间的冲突。
__about__ 补丁为什么修不好
搜这条报错,你会被反复告知:bcrypt 移除了 __about__,把它补回去就能让 passlib 恢复正常。这两半话都是错的。实测如下:
| 版本 | hasattr(bcrypt, "__about__") | 打印 trapped 警告 | passlib 能否工作 |
|---|---|---|---|
| bcrypt 5.0.0 | False | 是 | 否(ValueError) |
| bcrypt 4.3.0 | False | 是 | 能 |
bcrypt 4.3.0 同样没有 __about__,同样打印那行 (trapped) error reading bcrypt version,而 passlib 在它上面运行毫无怨言。因此,属性缺失并不是「能用」与「不能用」的分界线,5.0.0 的 ValueError 行为变更才是。
所以那个流行补丁不可能奏效,跑一遍也确实没奏效:
import bcrypt, types
bcrypt.__about__ = types.SimpleNamespace(__version__=bcrypt.__version__) # 在 import passlib 之前
from passlib.hash import bcrypt as pl
pl.hash("short-password")
# 仍然 ValueError: password cannot be longer than 72 bytes, ...
我们在干净进程中运行了这段代码,并把补丁放在 import passlib 之前,就是为了让任何人都无法把失败归咎于导入顺序。它依然失败。补丁唯一的成果是消掉了一条无害的警告。第 4 步里那个 255 字节探针是一个独立环节,它从一开始就没查过 __about__,因此照炸不误。
bcrypt 5.0 到底改了什么
bcrypt 5.0 的这个破坏性变更只有一行行为,波及面却很大:
| 输入 | bcrypt 4.3.0 | bcrypt 5.0.0 |
|---|---|---|
| 72 字节 | 成功 | 成功 |
| 73 字节 | 成功(静默截断) | ValueError |
| 100 字节 | 成功(静默截断) | ValueError |
| 255 字节 | 成功(静默截断) | ValueError |
4.x 那一列里的「截断」不是修辞。在 4.3.0 下,用同一前缀构造的 hash(73 字节) 与 hash(100 字节) 结果相等:true。
所以在这件事上,bcrypt 5.0 是更正确的那个库。悄悄丢弃密钥材料比拒绝继续更糟糕,而当一个哈希库无法忠实处理拿到的输入时,拒绝继续正是它该做的。但这并不意味着升级不痛。多年来一直在静默丢字节的代码现在会抛异常;如果这段代码路径藏在 passlib 后面,它甚至在你的输入参与之前就抛了。
如果你直接调用 bcrypt,升级的影响是可见的:注册或登录时抛出异常,位置在你自己的代码里,栈回溯指向你自己的哈希调用。在它前面加一个字节长度检查,一个下午就能收工。
如果你走的是 passlib,升级的影响在爆发之前完全不可见,一爆就是全量。故障范围与「有多少用户用了长密码」无关,因为它压根不依赖用户输入。进程内每一次 hash、每一次 verify 都会失败,从第一次调用开始,而这套代码里与密码处理相关的部分一行都没改。它于是表现为一次部署事故,而不是一份缺陷报告,那句报错文本又把人引向完全错误的方向。
动手修
如果你能改代码
弃用 passlib,直接调用 bcrypt。passlib 最后一次发布是 1.7.4,项目已经沉寂很久,对一个只需要 bcrypt 的项目来说,这层封装带来的价值非常有限:
import bcrypt
password = "correct horse battery staple".encode("utf-8")
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
bcrypt.checkpw(password, hashed) # True
hashpw 和 checkpw 都接收字节,所以在边界处编码,其余代码继续用 str。这里没有后端探测,也没有自检探针,因此不会出现那种「报告一个你根本没提供过的密码」的失败模式。如果你想直接看一眼生成的 hash,或者校验一个应用产出的 hash,bcrypt 生成器完全在你的浏览器里运行。用 bcrypt 做 HTTP Basic Auth 的服务器面对的是同一个结构性约束,只是换了一种文件格式,htpasswd 指南把这条路走了一遍。
如果你今天改不了代码
把版本钉在 5 以下:
bcrypt<5
我们验证过 bcrypt 4.3.0 搭配 passlib 1.7.4 可以正常工作。但要清楚你买到的是什么。这是止血带,不是修复。你停留的这个版本,面对超长密码的行为正是静默丢弃字节,而这恰恰是 5.0 发布出来要制止的问题。给这次钉版本定一个期限,并把迁移排进计划。
如果你的用户真的会输入长口令
先用 SHA-256 哈希一次,把摘要做 base64 编码,再交给 bcrypt:
import base64, hashlib, bcrypt
def prehash(password: str) -> bytes:
return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
hashed = bcrypt.hashpw(prehash(pw), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(prehash(pw), hashed)
无论输入多长,输出恒为 44 字节,稳稳低于 72。它还恢复了被截断破坏掉的那个性质:开头那两个 82 字节的密码经过这一步之后,checkpw(prehash(p2), hash(prehash(p1))) = False,碰撞消失了。
base64 这一步是在干实事,别省。原始的 SHA-256 摘要是任意二进制,可能含有 NUL 字节,而各家 bcrypt 实现对 NUL 的处理并不一致。base64 给你的是一个不含 NUL、长度固定的 ASCII 字符串。注册和登录必须应用同一个函数,否则所有已存的 hash 都会校验失败。
不要做的事
__about__ 的 monkey patch 无效。第 4 节给出了实测。如果团队里有人正准备把它粘进去,上面那四行能替他省下一个下午。
自己用 pw[:72] 截断,比什么都不做还糟。它把一次响亮的失败又变回了无声的失败,并且在你自己的代码里重建了第 2 节的那个碰撞。你等于手工复刻了 bcrypt 5.0 发布出来要消灭的行为,而且和库版本不同的是,你这份永远不会警告任何人。如果你需要长密码真正生效,就做预哈希;如果不需要,就校验字节长度并给出清晰的拒绝提示。
数据库里已有的 hash 怎么办
哪些行受影响
只有那些当年用超过 72 字节的密码注册的账号。对多数面向消费者的产品来说这是一小撮人,纯 ASCII 场景下通常只有长口令爱好者。而对用户会输入中文、日文或 emoji 的产品,第 3 节适用,受影响的范围可能远大于一次不看字节的审计给出的估计。
你无法从 hash 本身识别出这些行。bcrypt 摘要是定长的,不携带任何关于输入有多长的记录。如果你在注册时记录过密码长度,那份日志是你唯一的清单。多数团队没记,事后也无法重建,所以规划时要按「不知道是哪些」来安排,而不是按一份名单。
无法批量重算
没有明文可供重新哈希,而这正是存储 hash 的全部意义。所以迁移只能是惰性的:在账号所有者下一次认证成功、你短暂地在内存中持有明文时,逐个升级。
def login(user, password: str) -> bool:
if not verify_legacy(password, user.password_hash):
return False
if needs_rehash(user.password_hash):
user.password_hash = hash_new_scheme(password)
save(user)
return True
先用旧方案校验,之后才重新哈希。把这两步颠倒过来,就会在确认密码正确之前改写已存的 hash。给每个 hash 旁边存一个方案标识,让 needs_rehash 是一次字段比较而不是一次猜测;同时要预料到会有一条长尾的沉睡账号永远不来登录,这些账号在重置密码时处理,不要强推。
什么时候值得做整体迁移
如果你反正要写惰性重哈希这条路径,那就是你能等到的、更换底层算法成本最低的时刻。Argon2id 不存在 72 字节这个天花板,Argon2id 与 bcrypt 的深度对比讲清了什么时候切换划算、什么时候留在 bcrypt 才是对的选择。OWASP 密码存储备忘单则是核对参数时该查的参考。
不要仅仅因为这条报错就启动一次迁移。如果你的密码长度稳稳在 72 字节以下,bcrypt 依然是一个稳妥的选择,而第 6 节已经把你的问题解决了。
常见问题
我的密码很短,bcrypt 为什么说它超过 72 字节?
因为这条消息说的是 passlib 的内部探针,不是你的密码。首次调用时,passlib 会用一个固定的 255 字节测试串运行 detect_wrap_bug。bcrypt 5.0.0 对任何超过 72 字节的输入抛 ValueError,于是探针失败,报错浮现在你的调用点。一个 14 字节的密码就足以触发它。
bcrypt 真的会忽略 72 字节之后的所有内容吗?
是的,bcrypt 完全忽略 72 字节之后的所有内容。两个前 72 字节相同的 82 字节密码产生完全一致的 hash $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S,而且各自都能通过对方 hash 的校验。边界是精确的:第 72 字节上的差异会改变 hash,第 73 字节上的不会。
72 字节上限算是安全问题吗?
bcrypt 的 72 字节上限对长口令来说算安全问题。任何人只要知道前 72 字节,就能追加任意字节并通过认证,所以超出上限的每个字节都毫无贡献。对不到 72 字节的密码则毫无影响。如果长输入必须完整计入,用 SHA-256 做预哈希可以消除这个暴露面。
72 字节等于多少个字符?
72 字节等于 72 个 ASCII 字母,但取决于编码:36 个西里尔字母或带变音符字母、24 个中文汉字、24 个日文假名,或 18 个 emoji。bcrypt 数的是 UTF-8 字节而非字符,所以在 Python 里用 len(pw.encode("utf-8"))、在 Node 里用 Buffer.byteLength(pw, "utf8") 来量。
打上 __about__ 补丁能修好 passlib 的报错吗?
不能,__about__ 补丁修不好 passlib 的这条报错。我们在干净进程里把补丁放在 import passlib 之前,ValueError 依旧触发。bcrypt 4.3.0 同样没有 __about__ 却能与 passlib 正常配合,这证明属性缺失不是成因。补丁只是消掉了那条 (trapped) error reading bcrypt version 警告。
我该把 bcrypt 降级到 5.0 以下吗?
作为应急手段可以:bcrypt 4.3.0 配 passlib 1.7.4 能正常工作。但 4.x 会静默截断 72 字节之后的内容,而这正是 5.0 发布出来要制止的行为,所以把钉版本当成临时措施,尽快改为直接调用 bcrypt。
我能不能自己把密码截断到 72 字节?
不要自己把密码截断到 72 字节。pw[:72] 会在你自己的代码里悄悄重建上文描述的那个碰撞,而且没有任何库警告可供捕获。要么用 SHA-256 加 base64 做预哈希,让长输入保持可区分;要么在前面校验字节长度,并给出清晰的错误提示后拒绝。
在我修好之前已经哈希入库的密码会怎样?
已入库的 bcrypt hash 照常校验通过,因为你的校验路径和当初的哈希路径以同样的方式截断。只有用超过 72 字节密码注册的账号被削弱了,而且没有明文就无法重算。在下一次登录成功时惰性重哈希,沉睡账号则在重置密码时处理。