bcrypt 的 72 字节坑,和 Argon2id 的内存账:密码哈希参数到底怎么定

🔑 关键词:Argon2id 参数, bcrypt 72 字节, 密码哈希, OWASP 密码存储, scrypt 内存

📖 摘要:从 SHA-256 被做成 ASIC 这件事说起,讲清楚密码哈希函数真正的分界线是内存而不是迭代次数,附 OWASP 三档 Argon2id 参数、按登录 QPS 反推内存占用的小算盘,以及 bcrypt 那个没人告诉你的 72 字节截断坑。

先说个反直觉的事实:SHA-256 大概是人类历史上被优化得最狠的哈希函数,没有之一。

图片

比特大陆 S19 Pro 一块板子 110 TH/s,功耗 3250W。折算一下,每焦耳大约 3.4×10¹⁰ 次 SHA-256。这个效率比任何在通用 CPU 上跑 SHA-256 的实现高出六到八个数量级。而 SHA-256 本身到今天依然是安全的——问题不在数学,问题在于:当一种哈希函数被做到 7nm 专用电路里,攻击者和防御者用的是同一块芯片,成本曲线被彻底压平了。

密码哈希这一整个分支的设计史,说白了就是一场“怎么让攻击者用不上便宜芯片”的军备竞赛。搞不清这条主线,你在选参数的时候就是在瞎猜。

一、三种涨价方式,只有一种真的有用

PBKDF2 是 1999 年的产物,涨价方式很朴素:迭代次数。你设 600000 次,攻击者就得多算 600000 次。问题是这个涨价是线性的,而且 GPU 极其擅长干这种事——每个核心干同样的活,不需要互相说话。哈希猫(hashcat)在单张消费级显卡上跑 PBKDF2-HMAC-SHA256 能到每秒几百万次量级,迭代一百万次也不过是把有效速度砍到每秒几次,一个八位小写字母的密码空间几小时就扫完了。

bcrypt 往前走了一步。它基于 Blowfish 改了改,叫 Eksblowfish,有个 cost 参数,每加 1 耗时翻倍。而且它需要 4KB 左右的固定内存做 key schedule——注意这个数字,4KB。这在一九九九年算是不小的工作集,能把同时活跃的实例数压一压。但 4KB 今天连一个 L1 缓存行都填不满,现代 GPU 每张卡有几十 MB 的寄存器和 L1/L2,4KB 完全塞得进去,所以 bcrypt 在 GPU 上的性价比依然高得离谱。

scrypt(2009 年,RFC 7914,2016 年)第一次把“内存”写进了主参数。它的参数是 N、r、p,内存占用约等于 128 × N × r 字节。RFC 里给的参考值是 N=2¹⁷、r=8、p=1,算下来 134 MB。这个量级开始让 GPU 难受了——你没法在寄存器里放 134MB 的东西。

图片

Argon2 是 2015 年密码哈希竞赛(PHC)的冠军,2021 年进了 RFC 9106。它有三个变体,Argon2d 抗 GPU 但怕侧信道,Argon2i 抗侧信道但抗 GPU 弱一点,Argon2id 是两者混合,前一半时间用数据无关的访存模式,后面用数据相关。绝大多数场景用 Argon2id,别的不用考虑。

二、我的核心观点:迭代次数是线性涨价,内存是打断并行度

这两件事经常被混为一谈,其实完全不是一个量级的杠杆。

把迭代次数翻倍,攻击者的成本翻倍,你的成本也翻倍,双方在同一条曲线上赛跑。你把 t 从 2 调到 4,攻击者多花一倍电费,你也多花一倍 CPU 时间——如果攻击者用的是别人闲置的算力(比如一段被黑的云主机),他甚至感觉不到疼。

把内存从 19 MiB 调到 46 MiB,性质就变了。GPU 的优势来自几千个核心同时干活,一旦每个哈希实例要独占几十 MB 并且反复随机访存,瓶颈立刻从算力变成显存容量和带宽。一张 24GB 显存的卡,扣掉系统占用,最多同时跑几百个 46 MiB 的实例,并行度掉到和 CPU 一个数量级。ASIC 更惨,内存硬函数没法靠缩小面积取胜,你照样得焊 DRAM,成本降不下来——这就是为什么到今天也没人给 Argon2 做 ASIC,而 SHA-256 的 ASIC 已经迭代了十几代。

所以判断一个密码哈希方案好不好,我只看一个问题:它让攻击者多花的钱,是多花在算力上还是多花在内存和带宽上?后者才是真护城河。

三、具体参数:OWASP 给的三档,以及怎么按你的 QPS 反推

图片

OWASP 的 Password Storage Cheat Sheet 给了几组 Argon2id 参数,我按从紧到松列一下(注意 m 的单位是 KiB,不是 MB,这个坑很多人踩过):

配置 内存 迭代 t 并行度 p
A m=47104 46 MiB 1
B m=19456 19 MiB 2
C m=7168 7 MiB 5

注意 A 和 B 的取舍逻辑:总工作量(大致正比于 m × t)差不多,但 A 是“一口气吃 46MB”,B 是“分两口吃 19MB”。内存越大越抗 GPU,迭代越多越抗纯算力攻击,两个方向不完全等价。

现在算一笔真实的账。假设你的登录接口峰值 500 QPS,选 B 档(m=19456 KiB,t=2)。在主流服务器 CPU 上单次哈希大概 40 到 60 毫秒,取 50ms。那么任意时刻在算的哈希数量约为 500 × 0.05 = 25 个。内存开销 25 × 19 MiB ≈ 475 MiB。这个数字很温和,一台 8GB 的机器完全扛得住。

换成 A 档(46 MiB,t=1),单次耗时可能到 80 到 120ms(内存分配和清零本身要时间),并发数变成 500 × 0.1 = 50,内存 50 × 46 MiB = 2.3 GB。机器上其他服务还活不活了?

再夸张一点,很多人看网上教程直接上 m=1048576(1 GiB)、t=4,美其名曰“更安全”。算一下:如果你的登录峰值是 200 QPS,t=4 单次可能一两秒,并发 200 到 400 个,内存需求直接冲到 200 到 400 GB。这不是安全,这是自己给自己做拒绝服务。

图片

所以正确的定参顺序是反的,不是“先选安全参数再看机器扛不扛得住”,而是:

  1. 先确定你的登录接口能容忍的最大延迟(用户体验红线,一般 250ms 到 500ms 之间);
  2. 用这个延迟反推单次哈希的最大耗时;
  3. 在耗时约束内,把 m 拉到最大,再调 t;
  4. 最后算内存:峰值 QPS × 单次耗时(秒)× m,确认不超过机器可用内存的 50%;
  5. 参数写进数据库或者配置中心,别硬编码——以后 CPU 换了要能平滑调。

第 5 条特别重要。密码哈希参数是应该随用户记录一起存的,每个用户的哈希串里本身就带着参数(PHC 字符串格式长这样:$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$...),所以老用户的旧参数不影响新用户用新参数,登录时验证成功再顺手重新哈希一遍就行。

四、bcrypt 那个 72 字节的坑,我来回踩过两次

如果你现在还在用 bcrypt(不丢人,很多成熟系统就是这么运行的),有个细节必须知道:bcrypt 的设计决定了它最多只吃 72 字节的输入,超出的部分会被静默丢弃。不是报错,是静默丢弃。

原因在实现里——密码会先做一次 base64 编码再喂给 Blowfish 的密钥调度,密钥长度上限卡死在 72 字节。

图片

这意味着什么?如果你的产品鼓励用户用长密码短语(passphrase),比如“correct horse battery staple 加一段只有我记得的歌词”,而用户输入了 100 个字符,那么第 73 个字符往后完全不起作用。两个密码只要前 72 字节一样,哈希就一样。

我第一次踩这个坑是在一个多语言项目里,用户可以用 emoji 当密码。UTF-8 下一个 emoji 占 4 字节,18 个 emoji 就到 72 字节了,后面的全白写。用户以为自己密码很长很安全,其实有效长度只有 18 个字符的熵。

补救办法有两个:一是干脆换 Argon2id,它没有这个限制;二是如果必须留 bcrypt,就先对密码做一次 SHA-512 再 base64,把结果(86 个字符)作为 bcrypt 的输入。第二种做法有个副作用——SHA-512 里的空字节会让某些老实现的 bcrypt 提前截断,所以务必用你语言里维护活跃的那个库,别用上古版本。

顺便说,bcrypt 的 cost 也别忘了调。cost=10 在 2015 年的机器上大约 50 到 100ms,放到今天的服务器上可能只要 20ms 不到。建议按目标耗时反推:跑个基准测试,找到耗时落在 200 到 300ms 的那个 cost 值,一般落在 11 到 13 之间。

五、几个真实事故,比任何理论都有说服力

2012 年 LinkedIn 泄露,最初流出 650 万条密码哈希,用的是不带盐的 SHA-1。不带盐意味着什么?意味着攻击者可以一次算一遍整个字典,然后拿结果去撞所有人的记录。几天之内绝大部分密码就被还原了,包括不少所谓“复杂密码”。

2013 年 Adobe 那件事更离谱,1.53 亿条记录。密码用的是 3DES 的 ECB 模式加密——ECB 意味着相同的明文块产生相同的密文块,也就是相同密码的用户在密文里长得一模一样;更糟的是密码提示(password hint)以明文存在同一个库里,还有一大批用户把密码直接写在了提示栏里。这个案例我经常拿来跟人解释:加密算法本身是“合格”的,但整个方案从模式选择到数据组织全是窟窿。

图片

这两个案例的共性是:出问题的从来不是那个数学难题,而是参数、模式、数据组织方式这些“工程细节”。密码学里真正难的部分,从来都不在密码学论文里。

六、如果你现在就要动手

给一个最小可执行的清单:

  • 新项目直接用 Argon2id,库选 libsodium(crypto_pwhash_str)或者你自己语言里绑定到 Argon2 参考实现的那个,参数按本文第三节的算法反推;
  • 老项目用 bcrypt 的,先确认库支持 72 字节以上的输入处理方式,再把 cost 调到耗时 200ms 左右;
  • 还在用 SHA-256(password + salt) 或者 MD5 的,不用犹豫,排期改掉。这不是“以后再说”的事;
  • 所有密码哈希操作都要限流。哈希函数再硬,也扛不住攻击者拿你的登录接口当免费的哈希计算器刷。这个跟参数选择同等重要,但十个人里九个人不做。

最后回到开头那句话。密码学里的“强度”这个词其实挺误导人的,它暗示有一个客观的、可比较的数值。实际上不存在这种东西,存在的只有成本——你花多少 CPU 时间、多少内存、多少电,攻击者花多少。参数选择就是在这个账本上做加减法,你唯一要争取的,是让对方的账本上多出一个他没法用便宜硬件摊销掉的开支项。

内存,目前就是那个项。也许十年后不是了,但至少现在还是。

🏷️ 标签: