密码哈希需要慢一点的代价
你选择的哈希不是给数据库看的,而是给偷走数据库的那方看的。专用的密码哈希增加泄露后离线猜测的成本,但不能承诺弱口令不会被恢复。 快不是正确的目标 通用哈希如 SHA-256 刻意追求速度,使离线攻击者能够高效测试猜测;实际速度取决于算法与硬件,不能套用固定的显卡吞吐。专用密码算法引入可调成本,并使用独立随机盐,使每个猜测付出工作,同一口令在不同盐下产生不同存值。优先考虑适用的内存困难方案,并在真正承担登录验证的硬件上选择可接受成本。慢是防御的一部分,却不是越慢越好,也不能替代登录限流和可用性预算。 为被拷贝的那天设计 应把数据库泄露作为需要提前规划的威胁,而不是为它编造确定破解期限。弱口令即使用强方案仍可能很快被猜中;强口令也不因为使用快哈希就必然立即恢复。独立随机盐可以阻止预计算表复用,并要求攻击者按不同盐分别付出工作。盐通常与校验值一起公开存储,不是加密密钥,也不增加口令本身的熵。哈希提高猜测成本,却不会让泄露自动无害,更不能代替事件响应和受影响账号的保护。 成本可调带来灵活 把算法与成本的选择放进一个可切换的封装,并预见到成本会随硬件与算法演进。当你把 work factor 调高、或迁移到更强的方案时,已入库的旧哈希仍带着旧的成本;稳妥的做法是在登录成功那一刻顺手把该用户的重哈希做掉并写回。这样可以逐步更新回来登录的账号,而不必立即要求全体用户重置密码。必须先使用旧记录保存的算法、盐和参数验证。成功以后,再按当前策略判断是否需要用新参数重新派生。直接用最新参数验证旧值,会拒绝合法口令。长期不登录的账号不会自动迁移,也需要明确的迁移或重置计划,不能把渐进更新说成所有账号已经完成升级。 坏掉的样子 只保存一遍快哈希,会让每次离线猜测变得便宜;最终恢复比例仍取决于口令强度与攻击资源,不能承诺整张表必然在几小时内破解。没有盐会让情况更糟:相同明文得到完全相同的摘要,一次猜中就能撞开所有用了同一口令的账号,彩虹表可以在不同泄露之间复用,光是摘要相等本身,就已经泄露了谁和谁用了同一个口令。全局固定盐只挡住现成彩虹表,一次猜测仍可同时检验文件里所有账号,摊到每个账号的成本并没有真正涨上去。 第二类失败是成本因子被冻住。很多年前够用的 bcrypt 代价 04,或者只迭代几千次的 PBKDF2,放到今天的硬件上早就变薄,而入库的老哈希还带着当年参数,攻击者拿着旧备份用新机器重跑,当年余量就消失了。比较方式和可用性是接下来会断的地方。应使用密码库提供的验证器,或对派生值使用合适的抗时序比较;普通相等判断不提供密码学时序保证。反过来,若把无上限的慢哈希放在未鉴权登录入口,每次伪造请求都会变成由你买单的 CPU 开销,大量错误登录就能把服务打满,因此必须给最大工作量设上限,对登录做限流和计量,还要警惕静默截断,例如 bcrypt 超长截断的旧坑,或编码不一致让不同口令验出同一结果。 快速验证 把下面代码存为临时目录里的 hash_cost_demo.py,运行 python3 hash_cost_demo.py。它只用标准库(hashlib、hmac、os、time),不联网,不写磁盘。脚本把单次快哈希和拉伸哈希放在一起对比,证明同一口令在两个随机盐下得到不同摘要,并用恒定时间比较完成校验。 """Fast digest vs stretched password hash (stdlib only).""" import hashlib import hmac import os import time def check(ok, message): if not ok: raise AssertionError(message) def main(): password = b"correct-horse-123" wrong = b"correct-horse-124" # 1. Fast hashes are cheap: time thousands of single SHA-256 digests. rounds = 5000 start = time.perf_counter() for _ in range(rounds): hashlib.sha256(password).digest() fast_secs = time.perf_counter() - start print(f"fast: {rounds}x SHA-256 in {fast_secs*1000:.2f} ms") # 2. Stretching adds a tunable cost: one PBKDF2-HMAC-SHA256 derivation. # Demo uses a reduced 100k iterations for a short exercise; production # guidance asks for much more (see OWASP Password Storage). salt1 = os.urandom(16) salt2 = os.urandom(16) check(salt1 != salt2, "salts must differ") start = time.perf_counter() stored = hashlib.pbkdf2_hmac("sha256", password, salt1, 100_000) slow_secs = time.perf_counter() - start print(f"slow: 1x PBKDF2-HMAC-SHA256(100k) in {slow_secs*1000:.2f} ms") # 3. Random per-user salt: same password, different salts, different digests. other = hashlib.pbkdf2_hmac("sha256", password, salt2, 100_000) check(stored != other, "random salts must separate identical passwords") print(f"salt separates: {stored.hex()[:16]}... != {other.hex()[:16]}...") # 4. Negative control: a global salt keeps identical passwords identical. global_salt = b"site-wide-salt-01!" alice = hashlib.pbkdf2_hmac("sha256", password, global_salt, 100_000) bob = hashlib.pbkdf2_hmac("sha256", password, global_salt, 100_000) check(alice == bob, "global salt must link identical passwords") print("global salt links identical passwords (do not do this)") # 5. Verify by re-deriving with the stored salt, using constant-time compare. again = hashlib.pbkdf2_hmac("sha256", password, salt1, 100_000) check(hmac.compare_digest(stored, again), "same password+salt must verify") bad = hashlib.pbkdf2_hmac("sha256", wrong, salt1, 100_000) check(not hmac.compare_digest(stored, bad), "wrong password must not verify") check(hmac.compare_digest(stored, stored), "compare_digest self-check") check(not hmac.compare_digest(stored, other), "compare_digest mismatch-check") print("verify: correct password passes, wrong password fails") print(f"ratio: one stretched hash costs ~{(slow_secs / (fast_secs / rounds)):.0f}x one fast digest") print("PASS: per-salt derivation, global-salt control, and password verification checks") if __name__ == "__main__": main() 输出先给出数千次快哈希的总耗时,再给出一次拉伸哈希的耗时,具体比值由本次运行决定;随后证明同盐重算能复现存值,换盐则结果不同,错误口令无法通过校验,恒定时间比较只在输入完全相等时返回真。早先 macOS 运行属于历史记录,本轮两份完整示例在 Python 3.12.14 中通过,毫秒数随机器而变。演示为了保持简短而调低迭代次数,不能证明生产参数、显卡集群的真实破解成本,也未覆盖线上限流与过载预算,调高工作因子前,请用文档中的成本参数和登录洪水预算在真实校验器上再测一遍。 ...