密码存储是软件里少数几个真正存在标准答案的领域之一。答案早就公开了,一直有人维护,而且免费。可它同时又是最经常被做错的领域之一,原因很简单:那些错误的做法在某个年代确实是正确的,而后来没有人再回头看过一眼。
失败的样子很少稀奇。通常就是一套 2016 年按照 2012 年的建议搭起来的系统,到今天还在跑,还在接受登录请求,而自从当初写下那段哈希代码的人离职之后,就再也没有人打开过那个函数。它没出过故障,也没被审计过,所以从来不在任何人的待办清单上。
如果只带走一句话:用 Argon2id,实在不行就用 bcrypt。 本文其余部分都是细节。像 SHA-256 这样的通用哈希函数,无论你把它套用多少次,都不是密码哈希。它的快是设计目标,而快恰恰是你要从拿到数据库的攻击者手里夺走的东西。
密码存储只有一种正确的形态
保存一个由刻意放慢、且需要大量内存的函数生成的哈希值,为每个密码配一个唯一的随机盐,并使用针对你自己硬件调校过的参数。这三件事互为前提,缺了任何一件,另外两件的价值都会大打折扣。
OWASP 的密码存储指南把当前的推荐值写得非常明确,而这些推荐值值得当作数字来引用,而不是当作原则。
| 算法 | 推荐参数 | 何时使用 |
|---|---|---|
| Argon2id | 19 MiB 内存,2 次迭代,1 并行度 | 首选 |
| scrypt | N=2^17(128 MiB),r=8,p=1 | Argon2id 不可用时 |
| bcrypt | 工作因子 10 或更高,密码上限 72 byte | 遗留系统 |
| PBKDF2-HMAC-SHA256 | 600,000 次迭代 | 需要满足 FIPS-140 合规时 |
关于这张表有两点要补充。第一,Argon2id 的数值是下限,而且提高内存、减少迭代次数是一种等价交换,所以采用 47 MiB 内存加 1 次迭代的配置,安全性与基线相当。你可以根据自己能接受的响应时间,反推该往哪个方向加码。
第二,bcrypt 的 72 byte 不是一条校验规则,而是真实发生的截断。超过 72 byte 的部分会被悄悄丢掉,不会报错。如果你允许很长的口令短语,或者在交给 bcrypt 之前先对输入做了一次哈希,这一点就会变成实实在在的弱点。
另外,这些参数会随着硬件进步而向上修订。真正动手实现的时候,请读一手来源,而不是读这张表。
盐不是可选项,但也不够用
盐是与每个哈希值一起保存的、唯一的随机数据。它的作用范围很窄,但很重要:它保证两个使用相同密码的用户会得到不同的哈希值。这样一来,预先算好的彩虹表就失效了,攻击者也无法靠破解一个密码,就顺势拿下所有共用该密码的账号。
但盐做不到的事情是:它不会让任何人变慢。拿到你数据库的攻击者,同时也拿到了所有的盐,因为盐必须可读才能验证登录,它本来就不是秘密。所以,在一个快速哈希上只加盐,意味着攻击者会逐个攻击每一个密码,而且速度极快,这恰恰是你原本想避免的局面。
慢必须来自算法本身。这正是内存密集型函数存在的理由:它故意消耗资源,让配备专用硬件的攻击者所能获得的优势,远小于他们面对快速哈希时能拿到的优势。通用哈希可以在显卡或专用芯片上同时跑成千上万条流水线,而内存密集型函数的每一条流水线都要独占一整块内存,硬件成本因此被迫抬高,能同时开出的并行规模也就被压了下来。
胡椒是一个作用于所有密码、但保存在数据库之外的秘密值,它确实能再加一层防护,因为这时候仅仅拖走一份数据库已经不够用了。代价是运维负担:轮换胡椒需要在用户下次登录时重新哈希,而一旦丢失,所有账号都会被锁在门外。对高价值系统来说值得,对大多数系统来说可以跳过。
那些能活好几年的错误
加了盐的快速哈希。 给 SHA-256 加盐看起来很负责,其实不是。快本身就是漏洞。
用加密代替哈希。 加密在设计上就是可逆的,也就意味着存在一把密钥,而任何拿到这把密钥的人,就拿到了全部明文密码。密码永远不应该可还原,只应该可验证。
工作因子只设过一次,从未上调。 2015 年选定的 bcrypt 成本值,面对 2026 年的硬件已经明显偏弱。请在用户下一次成功登录时上调,那一刻明文短暂地在你手上,可以重新哈希。
截断或字符限制。 最大长度低于大约 64 个字符,或者禁止某些字符,通常说明密码是以一种在意其内容的方式被保存的。它不该在意,因为你终究是要把它哈希掉的。
会泄露时间的比较。 校验必须使用常数时间比较。标准库函数会这么做,手写的相等判断不会。
把密码写进日志。 它一旦流进错误日志、请求追踪或者监控系统,就等于同时出现在三个访问控制比数据库还弱的地方。
迁移一套已有系统
你无法重新哈希手里没有的东西,因为你保存的只有哈希值。可行的做法只有一种:在登录时升级。
当用户认证成功的那一瞬间,明文密码短暂地留在内存里。先用旧哈希验证,然后立刻用当前算法算出新的哈希值,替换掉已存储的值。几个月下来,绝大多数活跃账号都会在用户毫无察觉的情况下完成迁移。
按记录保存所用的算法,让新旧两种方式共存。实际落地时,往往只需要在每条记录上多存一个算法标识和参数版本号,登录时据此挑选校验方式,校验通过后再判断要不要顺手升级。同时定下一个日期:过了那天,剩余的旧哈希一律作废,相关用户必须重置密码。带着弱哈希沉睡的账号,恰恰是攻击者最想要的。
不要图省事,把旧哈希再用新算法包一层。这样确实能跑,但真正的安全边界仍然是那个弱哈希,只是外表看上去很现代而已。
比算法更重要的事
正确的哈希会在数据库泄露时保护你。但对更常见的那类攻击,也就是有人拿着从别处得来的真实密码直接登录,它一点作用都没有。从其他站点泄露的凭据被批量拿来试,而你的系统看到的只是一连串在语法和凭据上都完全合法的登录请求。
认证接口上的频率限制、可用的多因素认证、把新密码与已知泄露库比对,以及针对撞库行为模式的告警,处理的正是这件事,而在日常运维的尺度上,它们全都比在 Argon2id 和 bcrypt 之间怎么选更重要。API 安全方面的控制措施属于同一类问题。
两件事都值得做,而顺序值得说得坦白一些:哈希便宜又一劳永逸,先一次性做对;之后持续投入的精力应该放在登录路径上,因为攻击真正抵达的地方就在那里。Mecanik 在应用程序安全分析中会同时审查这两方面。
常见问题
2026 年最好的密码哈希算法是什么? Argon2id,至少配置 19 MiB 内存、2 次迭代和 1 并行度。如果用不了,scrypt 采用 N=2^17、r=8、p=1 是一种替代方案;工作因子 10 或更高的 bcrypt 适合遗留系统;而 PBKDF2-HMAC-SHA256 配 600,000 次迭代可以满足 FIPS-140 的要求。
给密码加盐就够了吗? 不够。盐保证相同的密码产生不同的哈希值,让彩虹表失效,也阻止一个被破解的密码打开所有共用它的账号。但它不会拖慢攻击者,因为验证登录时盐必须是可读的。慢必须来自一个刻意消耗大量内存的算法。
为什么不该用 SHA-256 存密码? 因为它的快是设计目标,而快恰恰是你要从握有你数据库的攻击者手里夺走的东西。通用哈希函数不是密码哈希,无论套用多少次,也无论有没有加盐。
bcrypt 对密码长度有限制吗? 有,72 byte,超出的部分会被悄悄截断而不是拒绝。如果你允许很长的口令短语,或者在交给 bcrypt 之前先对输入做了哈希,这一点就很重要,因为截断发生时不会有任何报错提示。
没有用户密码,怎么升级密码哈希? 在登录时做。用户认证的那一刻,明文短暂地在你手上,所以先用旧哈希验证,再立刻用当前算法重新哈希。按记录记录所用的算法,让新旧共存,并定下一个日期:过了那天,剩余的旧哈希作废,相关用户必须重置密码。
评论