一条密码哈希路径:用慢而昂贵的函数替代快函数。

密码哈希需要慢一点的代价

你选择的哈希不是给数据库看的,而是给偷走数据库的那方看的。专用的密码哈希用慢速换来韧性,让一次泄露不会变成密码工厂。 快不是正确的目标 快,在这里恰恰是要避免的。SHA、MD5 这类通用哈希设计成纳秒级,几乎不费任何力气,所以防御对攻击者最友好:一秒能做几十亿次猜测。而专用密码算法(bcrypt、scrypt、Argon2id)引入一个可调的成本因子,并为每个用户加随机盐,让每一次尝试都要付出真实代价(总量在几十毫秒到几秒之间),也让同一明文在不同账号下得到完全不同的摘要。「慢」不是缺陷,而是设计:它让一份下载到的哈希表从「几秒全破」变成「贵到不值得逐个试下去」,这才是散列价格的本质。 为被拷贝的那天设计 请按「哈希文件总有一天会被拷走」来设计,因为那天迟早来。如果存储上用的是快不休且无盐的哈希,攻击者几分钟就能全量破解;用上慢且带盐的哈希后,即使文件泄漏,攻击者也只撬得动一小撮弱口令,而且一个明文相同但盐不同的哈希,无法用同一个错误破解去对撞别的账号。「慢 + 每账号一个随机盐」是泄漏之后剩下的防线:文件一旦出去就收不回来,所以这层防御,只能提前建在哈希本身,而不是指望日志误删。 成本可调带来灵活 把算法与成本的选择放进一个可切换的封装,并预见到成本会随硬件与算法演进。当你把 work factor 调高、或迁移到更强的方案时,已入库的旧哈希仍带着旧的成本;稳妥的做法是在登录成功那一刻顺手把该用户的重哈希做掉并写回。这样每个账号都能跟上最新成本,却不必逼全体用户重置密码。接口里应明确写出这层策略——用最新参数验证、成功时迁移、旧值绝不回退——把「旧哈希->新哈希」写成一个代码分支,而不是一条手工运维命令。 坏掉的样子 最典型的失败是只存一遍快哈希。SHA-256 或 MD5 一次只要几微秒,拖库之后攻击者可以用字典加掩码在每块显卡上每秒尝试几十亿次,弱口令几小时就倒一片,而且整张表是一起倒的,因为每一次猜测几乎没有成本。没有盐会让情况更糟:相同明文得到完全相同的摘要,一次猜中就能撞开所有用了同一口令的账号,彩虹表可以在不同泄露之间复用,光是摘要相等本身,就已经泄露了谁和谁用了同一个口令。全局固定盐只挡住现成彩虹表,一次猜测仍可同时检验文件里所有账号,摊到每个账号的成本并没有真正涨上去。 第二类失败是成本因子被冻住。很多年前够用的 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 100k iterations so it finishes instantly; 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: slow salted stretching beats a single fast digest") if __name__ == "__main__": main() 输出先给出数千次快哈希的总耗时,再给出一次拉伸哈希的耗时,两者差出数量级;随后证明同盐重算能复现存值,换盐则结果不同,错误口令无法通过校验,恒定时间比较只在输入完全相等时返回真。这些断言已在 macOS、Python 3.12 下通过,毫秒数随机器而变。演示为瞬间跑完调小了迭代次数,不能证明生产参数、显卡集群的真实破解成本,也未覆盖线上限流与过载预算,调高工作因子前,请用文档中的成本参数和登录洪水预算在真实校验器上再测一遍。 ...

六月 10, 2027 · 2 分钟 · XB Field Notes