你选择的哈希不是给数据库看的,而是给偷走数据库的那方看的。专用的密码哈希增加泄露后离线猜测的成本,但不能承诺弱口令不会被恢复。
快不是正确的目标
通用哈希如 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 中通过,毫秒数随机器而变。演示为了保持简短而调低迭代次数,不能证明生产参数、显卡集群的真实破解成本,也未覆盖线上限流与过载预算,调高工作因子前,请用文档中的成本参数和登录洪水预算在真实校验器上再测一遍。
延伸阅读
来源
- OWASP password storage,算法选择、盐与工作因子升级,已核验 2026-10-15。
- Python hashlib,PBKDF2 参数与派生,已核验 2026-10-15。
- Python hmac,比较接口行为与时序边界,已核验 2026-10-15。