如果你的用户表存的是 md5(password) 或者 sha256(password),别读下去了,先去规划迁移。你根本没有在安全地存密码,你是在用一种小偷几分钟就能反推的形式存它们。这是生产代码里最常见、也最该避免的密码学错误,值得把话说直白点。
为什么快的哈希是错的工具
GPU 不会"思考"你的密码,它只是拼命地猜。一块现代 GPU 每秒能算出上百亿个 SHA-256 哈希。偷到你表的攻击者不是在解密什么——他们是在猜,而在这个速度下,"Tr0ub4dor&3" 撑不过你读完它所需的时间。
彩虹表让情况更糟。它们是预先算好、把哈希映射到密码的表。如果不加盐,攻击者把 "password123" 哈希一次、建一次表,就能对互联网上每一个数据库复用。加盐能挡住这个,因为它强迫攻击者给每个哈希单独建表——但前提是底层哈希本身也足够慢,让建哪怕一张表都肉疼。
盐解决了什么,又没解决什么
盐是存在哈希旁边的随机字节。它保证两个密码相同的用户拿到不同的摘要,也逼着攻击者只能逐个哈希地攻击,而不是用一张共享的表。盐是必需的,但它 alone 并不够——因为如果底层哈希很快,逐个攻击仍然便宜得可笑。
缺的那一块就是:你需要哈希慢,而且最好吃内存,这样每次猜测都让攻击者付出真实的时间和真实的内存。这正是 bcrypt、scrypt 和 Argon2 的全部设计目标。
bcrypt:老牌可靠
bcrypt 十几年里都是默认推荐。它有一个出了名的坑:
import bcrypt
pw = b"correct horse battery staple"
# bcrypt 会在 72 字节处静默截断!
hashed = bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12))
print(hashed) # $2b$12$... —— 代价因子已经写进串里
那个 72 字节截断意味着第 72 字节之后的内容会被忽略。如果你允许用户设很长的密码,攻击者只需要暴力破解前 72 字节。解决办法是对超长输入先做一遍哈希,或者更简单地——设一个合理的上限并写进文档。rounds 参数(代价因子)正是让它慢的原因;12 是 2026 年一个合理的默认值,而且随着硬件变快你该往上加。
scrypt:加上内存硬度
scrypt 抬高成本的手段是要求大量内存,而不只是 CPU 时间。这能打击 GPU 和 ASIC 攻击者——他们擅长并行计算,却不擅长给每次猜测便宜地提供数 GB 的高速内存。
import hashlib
import os
salt = os.urandom(16)
dk = hashlib.scrypt(
b"correct horse battery staple",
salt=salt,
n=2**14, # CPU/内存代价
r=8,
p=1,
dklen=32,
)
# 把 salt + dk 一起存,比如 n:r:p:salt:dk
n 参数是主导成本的旋钮。更大的 n 意味着指数级更多的内存和时间。选你的服务器在登录时能承受的最大值。
Argon2id:当下的最佳实践
Argon2 在 2015 年赢了密码哈希竞赛,而它的 Argon2id 变体(同时抵抗 GPU 和侧信道攻击)现在是头号推荐。它调三个独立的旋钮:
| 参数 | 含义 | OWASP 2023 默认值 |
|---|---|---|
| time_cost (t) | 遍历次数 | 2 |
| memory_cost (m) | 内存,单位 KiB | 19456(约 19 MiB) |
| parallelism (p) | 每次哈希的线程数 | 1 |
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=2,
memory_cost=19456,
parallelism=1,
hash_len=32,
type=argon2.Type.ID,
)
stored = ph.hash("correct horse battery staple")
# 后面登录时:
try:
ph.verify(stored, "correct horse battery staple")
except argon2.exceptions.VerifyMismatchError:
# 拒绝
pass
# 如果配置的参数变了,重新哈希:
if ph.check_needs_rehash(stored):
stored = ph.hash(password)
这个库会把参数编码进存下来的字符串里,所以你以后可以调高成本,并通过 check_needs_rehash 在下次登录时静默重哈希。
密码哈希不是密钥派生
PBKDF2 值得提一句,但它属于另一类。它是个密钥派生函数:把一个密码拉伸成一把供后续使用的密码学密钥(比如用来加密文件)。它用哈希的多轮迭代来拖慢速度,但它不吃内存,所以在 GPU 农场面前老化得比 scrypt 或 Argon2 快。对存登录密码来说,Argon2id 或 bcrypt 是更好的选择;对从口令派生加密密钥来说,PBKDF2、scrypt、Argon2 都可以,取决于你的威胁模型。
迁移老哈希格式
你没法批量重哈希现有密码,因为你手里没有明文。在每次成功登录时懒加载地升级,就跟 MD5 那篇说的一样:
def verify_and_migrate(user, raw):
stored = user.password_hash
if stored.startswith("$legacy$"):
if legacy_md5_check(raw, stored):
user.password_hash = bcrypt.hashpw(
raw.encode(), bcrypt.gensalt(rounds=12)
)
user.save()
return True
return False
return bcrypt.checkpw(raw.encode(), stored)
活跃用户在一个会话内就升级了;沉睡的账号就强制他们重置。明文从不需要被存下来或传出去。
一个你会在野外部署里见到的错误
如果你审计过足够多的代码库,会发现一种"看起来已经哈希了"、于是被假定安全的密码存储。其实不然:
# 错:快、不加盐、GPU 几秒就能破
import hashlib
stored = hashlib.sha256(password).hexdigest()
# 对:慢、由库加盐、专为抵抗 GPU 设计
import bcrypt
stored = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
第一行同时犯了三个错:它快、它没有按用户加盐、它不吃内存。第二行把这三点全修好了,因为它把活儿交给了专门设计的密码哈希。没有任何聪明的理由去偏好第一种写法,它只是披着"哈希"外衣的一个 bug。
实用检查清单
- 绝不用 MD5、SHA-1 或裸 SHA-256 存密码。
- 用 Argon2id(首选)或 bcrypt;如果内存硬度最重要,考虑 scrypt。
- 让库去处理加盐——别自己发明。
- 如果你允许长密码,留意 bcrypt 的 72 字节截断。
- 把代价调到一次登录在你的硬件上大约花 100–300 毫秒,然后每年再审视一次。
- 在登录时迁移老哈希,而不是做一次危险的批量任务。
选一个库、设好合理的参数,然后别再手搓了。上线前记得实测一次登录耗时,确认代价参数在你的硬件上落在预期区间,而不是抄一个别人博客里的数字就完事。密码学原语已经解决好了;剩下唯一的 bug,通常就在你自己的代码里。