数据库只存 SHA-256(密码),不存密码原文。泄漏后,攻击者是不是就无法还原用户密码了?如果密码是 book123 这样的小字典成员,攻击者拿到摘要,离线把常见猜测逐个 SHA-256 后比较即可;不需要攻击 SHA-256 的原像困难假设。输入空间太小,找目标输入并不贵。换一把更长的密码当然有帮助,但服务端存储方案仍必须考虑泄漏后的离线猜测成本、多用户复用猜测和参数演进。

前面 03 篇用 SHA-256 处理公开订单字节,这是合适的教学摘要任务。密码不是随机的高熵消息,不能把安全哈希函数对一般原像的困难性直接套在用户选择的短密码上。本篇用真实 Argon2id 库对照 SHA-256 的有限字典:同一个合成弱密码,两种方案最终都能猜到;区别在于 Argon2id 允许服务端设置验证所需的内存、遍数等成本,而不是承诺“弱密码永远猜不出来”。

攻击者读到什么,谁持有秘密

注册时服务端得到密码,生成一份各条记录独立的 salt,记录盐、算法版本、成本参数与密码哈希输出。登录时用这条记录的参数再算一次,按安全接口比较。数据库一旦泄漏,攻击者可离线读取盐、算法及参数,还能对候选密码重复计算;盐不必保密,密码和可能的额外秘密才需要保密。

1
2
3
4
5
6
注册:用户密码 + 公开 salt + (Argon2id, m, t, p) -> 输出 tag
|
数据库: salt + 算法/成本参数 + tag --+--> 攻击者泄漏后可见

登录:输入密码 + 保存的同一 salt/参数 ------> 重新得到 tag 并比较
离线猜测:猜测列表 + 公开 salt/参数 -------> 一次一次试错

这里的 salt 主要阻止不同记录无差别复用同一份预计算结果,不能阻止针对某一条记录做字典猜测;如果密码本身可猜中,攻击者依然能成功。成本参数要放进记录或可正确恢复的配置:验证时无意改掉盐、内存或遍数,得到不同 tag 是预期现象,不能把它当成“用户换了密码”。密码哈希的目标是增加离线尝试代价,线上登录接口还需独立做账号/设备风险控制与速率限制。合成例子没有实现这些生产逻辑。

Argon2id 是本篇选用的密码哈希算法;salt 是按记录区分输入的公开随机值;离线猜测 是攻击者拿到数据库后无需每次向服务端发请求就能枚举候选。这三个术语足以解释眼前问题。Argon2id 还支持其他输入,RFC 9106 的公开测试向量有 secret 与关联数据;普通密码存储并不因出现这两个字段就自动有了额外的服务器秘密。

确定密码是否落在字典里

演示密码固定为公开合成值 book123,猜测列表是 password、book123、book124。先算存储用的 SHA-256,再逐项快速比较:第二项命中。用 Argon2id 的同一盐和演示参数对同样三项逐一计算:第二项同样命中。这不是 Argon2id 实现失败,而是演示“有界字典中存在这个弱密码时,算法不会改变事实”;设置成本影响的是每次尝试的资源需求,不能用“结果不相等”替代性能评估。

RFC 9106 介绍 Argon2 的内存成本、迭代和并行度,并提供 Argon2id 测试向量。本次合成字典使用 8 MiB、2 遍、1 lane,仅为了在现有机子上执行,不是部署参数建议,也没有测量现实攻击者成本;实际参数要根据服务器可用资源、预期并发和威胁模型测量后决定。标准向量使用 32 KiB 小内存专供校验算法输出,也不能拿来部署。正因为这两个数容易误抄,此处刻意把“向量参数”和“业务建议”分开。

官方向量与参数失败对照

examples/cryptography/08_passwords.py 通过 argon2-cffi 的低层绑定调用成熟 Argon2 实现,没有自己实现 Argon2 的内存填充或混合算法。它先按 RFC 9106 §5.3 构造完整公开向量:32 字节 01 密码,16 字节 02 salt,8 字节 03 secret,12 字节 04 关联数据,32 KiB 内存、3 遍、4 lanes。应输出 0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659。这些可写进证据,因为均为规范公布的测试字节;绝不能把现实用户密码或密钥写进文章或 Git。

先按仓库内 examples/cryptography/README.md 完成环境准备;以下命令从仓库根目录执行。

1
2
python3 examples/cryptography/08_passwords.py
python3 -m unittest discover -s examples/cryptography -p 'test_08*.py' -v

2026-10-06 UTC,两条命令退出码均为 0,3 项测试通过;输出与官方 Argon2id 向量相同。对合成密码的固定 16 字节公开 salt lab-salt-demo-01,两种有限字典均找到 book123。把 salt 改为 lab-salt-demo-02,或单独调高内存/遍数后与原记录 tag 比较,均返回 false。失败路径说明保存参数是验证契约的一部分,不能证明调高成本就有某个固定的性能提升倍率。本章没有记录运行耗时、现实用户记录或攻击算力。

云端写作时曾用系统 libargon2 跑同一公开向量;当前可重建入口改为 requirements.txt 中固定的 argon2-cffi。两者都只用于隔离实验,生产项目还要处理内存里的敏感值、参数迁移和运维基准,不照搬演示代码或固定盐。

删去一步会发生什么

删去按记录生成独立 salt,两个相同密码的记录可能出现相同可比对输出,攻击者也更方便批量复用计算。删去成本参数而用 SHA-256 单次摘要,有限字典可用很便宜的逐项摘要计算尝试;这里不伪造具体每秒猜测量。删去对账号登录行为的在线限制,则即使数据库不泄漏,攻击者仍可能向服务端反复试猜;Argon2id 的离线抵抗不能代替登录流程设计。删去“正确记录参数”却保持算法名,合法用户可能无法验证自己的密码。

密码一旦用于派生加密密钥,还必须考虑盐、成本、密钥分离与恢复路径;HKDF 不能把 book123 变成不可猜测的高熵输入。11 篇专门解释从已有秘密派生不同用途的密钥,不能拿它顶替本篇的低熵密码处理。

两道带答案的练习

推导题。 两个账户设置相同密码,如果各用不同 salt,数据库泄漏后攻击者能否从“两个哈希不相同”得出密码不同?salt 对单独攻击其中一个账户的影响又是什么?

可核对答案: 不同 salt 导致同密码的输出也可能不同,因此不能凭 tag 不同断言密码不同。攻击者知道 salt 仍可针对目标记录枚举候选、按该记录成本逐次验证;盐阻碍跨记录预计算复用,不会给弱密码制造不可预测的熵。

变更题。 把 08_passwords.py 的第二条猜测 book123 去掉,再跑脚本与测试;哪些断言失败?若只改存储成本却忘了在登录时使用相同参数,用户密码实际上变了吗?

可核对答案: 两种字典命中项将成为 None,脚本和 test_known_weak_password_is_in_both_dictionaries 的命中断言失败;改成本后旧 tag 不匹配,不说明用户改过密码,而是服务端把验证所需参数弄错了。实际升级成本要在有原始密码输入的成功登录等可控时机重新生成并更新记录,不是单方面修改旧记录的参数字段。

资料与导航