跳到主内容
d.devtul.fun
EN
安全 · 2026-09-11

密码哈希做对了:bcrypt、scrypt 与 Argon2 开发者指南

如果你的用户表存的是 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)内存,单位 KiB19456(约 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,通常就在你自己的代码里。

继续阅读