Skip to content
返回博客
安全

bcrypt 报「密码不能超过 72 字节」?短密码也会中招

bcrypt 5.0 对 14 字节的密码也抛出「password cannot be longer than 72 bytes」。真凶是 passlib 的 255 字节探针。附免费在线 bcrypt 工具。

12 分钟

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 相同?
7071false
7172false
7273true
7374true

第 72 字节仍然算数,第 73 字节是第一个不算数的。中间没有渐弱地带。判定这么干脆,你在自己的库上本地复现一遍只要几分钟。

字符数不等于字节数

bcrypt 数的是 UTF-8 字节,而用户输入的是字符。对 ASCII 来说这两个数字恰好相等,所以团队一走出英语市场就会被它咬到。

字符类型示例每字符字节数72 字节相当于
ASCII 拉丁字母A172 个字符
中文汉字324 个字符
日文假名324 个字符
emoji🔒418 个字符
西里尔字母я236 个字符
带变音符的德文ü236 个字符

两端我们都验证过:中文密码在第 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 里实际跟踪到的执行路径:

  1. 首次调用触发后端初始化:_calc_checksum_stub_requires_backend()set_backend()
  2. _load_backend_mixin 去读 bcrypt.__about__.__version__。该属性并不存在,于是抛出 AttributeError。passlib 把它吞掉,只打印 (trapped) error reading bcrypt version
  3. 初始化继续进入 _finalize_backend_mixinpasslib/handlers/bcrypt.py:421),它调用 detect_wrap_bug(IDENT_2A)
  4. detect_wrap_bug(同一文件,:378)会去校验一个固定的 255 字节探针。
  5. bcrypt 5.0.0 对任何超过 72 字节的输入抛 ValueError,于是探针把自己炸了。
  6. 异常一路冒泡到你的调用点。你看到的是一条关于 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.0False(ValueError)
bcrypt 4.3.0False

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.0bcrypt 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

hashpwcheckpw 都接收字节,所以在边界处编码,其余代码继续用 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 字节密码注册的账号被削弱了,而且没有明文就无法重算。在下一次登录成功时惰性重哈希,沉睡账号则在重置密码时处理。

标签: bcrypt password-hashing passlib python debugging security