哈希与密码存储——bcrypt、Argon2、盐值与彩虹表

开门见山:一秒钟的选型结论

新项目一律用 Argon2id,推荐参数 m=46 MiB、t=1、p=1(或 m=19 MiB、t=2、p=1),盐值 16 字节随机生成;遗留系统用 bcrypt,cost factor 至少 12,注意 72 字节截断问题;SHA-256/MD5/SHA-1 在密码存储场景全部视为"裸奔",任何情况下都不要直接用;在 Argon2id/bcrypt 之外再叠加一层 HMAC-SHA256 的 pepper(密钥放 KMS),你的密码存储可以做到工业级。

如果你赶时间,把上面这段话贴到代码评审模板里就够了。如果你接着往下读,这篇文章会回答一个更本质的问题:为什么 2026 年了,密码泄露事件里还能看到成千上万的 MD5 哈希被秒破?为什么 LinkedIn 一个 2012 年的漏洞,到 2016 年才发现实际影响 1.17 亿账号? 答案几乎从来不在"哈希算法不够新",而在工程师对密码存储的本质 ------它不是"完整性校验",而是一个算力军备竞赛------的理解偏差上。

我会沿着四条主线展开:为什么 MD5/SHA-256 这类"通用哈希"不能用来存密码 (速度就是原罪)、盐值与彩虹表背后的数学博弈bcrypt 与 Argon2 的设计哲学与实战参数 、以及从存量数据迁移、pepper 加固、哈希升级到完整防御体系。每一节都配真实泄密事件、可复现的代码、hashcat 基准数据,以及一份可以直接贴在工位上的七层防坑清单。

一、先看清本质:密码存储不是哈希,是一场算力军备竞赛

1.1 通用哈希函数的三个"优点",恰恰是密码存储的三个"死穴"

SHA-256、MD5、SHA-1 这些哈希函数的设计目标是快速校验数据完整性 ------GB 级文件算哈希要毫秒级完成。这个"快",正是它们在密码存储场景下的死刑判决书。

我们来看一组 2026 年的真实基准数据。Hashcat 官方测试显示,单张 NVIDIA RTX 4090 显卡每秒可以计算超过 800 亿(8×10^10)次 MD5 哈希;顶级服务器 CPU 也只能做到每秒 54 亿次------差距约 15 倍。换算成攻击成本:

算法 单张 RTX 4090 吞吐 暴力穷举 8 位小写密码(约 2×10^11 组合) 成本估算
MD5 ~80,000,000,000 H/s ~3 秒 几乎为零
SHA-1(裸哈希) ~50,000,000,000 H/s ~5 秒 几乎为零
SHA-256(裸哈希) ~15,000,000,000 H/s ~15 秒 几乎为零
PBKDF2-SHA256(100 万轮) ~250,000 H/s ~9 天 数千美元电费
bcrypt (cost=12) ~50,000 H/s ~50 天 数万美元
Argon2id (m=64MiB, t=3) ~30 H/s(受内存限制) 数百年 数百万美元

注意最后一行那个 "~30 H/s" ------这不是笔误。单张 4090 面对 Argon2id 每秒只能算 30 次左右,比它算 MD5 慢了 26 亿倍 。这就是"内存困难"设计的威力。

这意味着什么 :如果你用 MD5 存密码,攻击者拿到数据库后,一台消费级显卡一晚上就能穷举整个字典;如果你用 Argon2id,同样一张显卡要花几个世纪。两者之间的差距,不是"安全性更好一点",而是**"完全沦陷"和"基本安全"的差距**。

1.2 一次泄密如何演变成四年悲剧:LinkedIn 的教训

这个真实事件值得每个工程师看一遍,因为它展示了"用错哈希"的代价有多夸张。

2012 年 6 月 ,黑客在俄罗斯论坛张贴出 650 万条 LinkedIn 用户密码的 SHA-1 哈希 。关键问题是------这些哈希没有加盐 。Poul-Henning Kamp 在 ACM Queue 上写下了那句著名的评论:"无盐的哈希密码,和明文密码之间只有无穷小的差距"

攻击者用"字典攻击 + 暴力穷举"在几小时内破解了大量哈希。破解者甚至注意到,有异常高比例的密码里包含 "linkedin" 字样 ------这是典型的用户行为模式,字典攻击的天然肥肉。LinkedIn 当时的应对是强制重置了这 650 万账号的密码 ,然后就......翻篇了。

但故事没完2016 年 5 月 ,黑客 "Peace" 在暗网以 5 个比特币(约 2200 美元) 的价格挂出完整的泄露数据------实际规模是 1.17 亿条账号记录 ,是当年公开数量的 18 倍。LinkedIn 不得不再次强制重置大量用户密码。从 2012 到 2016,整整四年,1.17 亿用户的密码哈希在暗网流通 ------因为是无盐 SHA-1,攻击者可以慢慢啃,每啃出一个就多一个可撞库的账号。

同样的剧本一再上演 :2012 年的 MySpace 泄密(3.6 亿账号,用的是更古老的 SHA-1 截断版 )、2013 年的 Adobe(1.5 亿账号,用 3DES 加密 + 弱提示)、2019 年的 Collection #1-5(累计 77 亿条邮箱-密码组合,成为后续撞库攻击的"弹药库")。

LinkedIn 事件的核心教训

  1. 无盐哈希 = 给攻击者递字典------650 万条无盐 SHA-1 在几小时内被大量破解;
  2. 泄密范围往往比第一时间公告的大得多------1.17 亿 vs 650 万,意味着你的应急响应必须基于"全量"假设;
  3. 哈希方案弱的代价会随时间放大------四年间攻击者有无限时间慢慢破解;
  4. 泄露的密码会进入"撞库弹药库"------用户在各平台复用密码,你的弱哈希泄露会反过来拖垮其他平台的安全。

1.3 那么密码存储到底需要什么?

从 LinkedIn 的教训反推,一个合格的密码存储方案需要四个性质:

  1. :单次计算要 100 毫秒到 1 秒级,让暴力穷举变得不经济;
  2. :每个用户的哈希都不同,让"预计算彩虹表"失效,让"批量破解"变成"逐个破解";
  3. 抗硬件加速 :让 GPU/ASIC 的并行优势被抵消(内存困难 是关键武器);
  4. 可调节 :算力在增长,参数必须能随时间调强。
    MD5/SHA-256 一个都不满足。bcrypt 满足前两条和部分第三条。Argon2id 四条全满足。这就是本文的主角。

二、盐值与彩虹表:一场围绕"预计算"的攻防

在讲 bcrypt 和 Argon2 之前,必须先把讲透------因为这是理解"为什么要专门的密码哈希算法"的钥匙。

2.1 无盐哈希的世界:彩虹表的乐园

假设你用 SHA-256 存密码,无盐。攻击者拿到数据库后看到:

text 复制代码
user1@example.com  → 5e884898da28047151d0e56f8dc629...(这是 "password")
user2@example.com  → 5e884898da28047151d0e56f8dc629...(也是 "password"!)
user3@example.com  → ef92b778bafe771e89245b89ecbc08...(这是 "letmein123")

注意前两个哈希完全相同 ------这是无盐哈希的致命特征:相同明文永远产生相同哈希 。攻击者不需要解密,只需要建立一张"明文 → 哈希"的对照表,然后逐行比对。

朴素查表法 的问题是:要覆盖 8 位小写字母密码(约 2×10^11 组合),哈希表本身就大到不可存储。彩虹表(Rainbow Table)是对这个问题的精妙优化。

2.2 彩虹表的数学原理:链式存储 + 归约函数

彩虹表的核心思想是用时间换空间 ,通过 的方式,把海量明文-哈希对压缩到极小的存储空间。

构造过程

  1. 选定一个起始明文 P1(如 "aaaaaa");
  2. P1 做哈希得到 H1 = H(P1)
  3. 用一个归约函数 R(Reduction Function)把 H1 映射回一个"新明文" P2 = R(H1)------注意 R 不是哈希的逆,只是一个确定性的映射(比如取哈希前 6 个字节转成 6 个字母);
  4. P2 再哈希,再归约,再哈希......重复 N 次(如 N=10000)形成一条链;
  5. 只存储链的两端 :起点 P1 和终点 H_N
    一条链覆盖了 10001 个明文,但只需要存 2 个值------空间压缩比是 5000:1 。如果你构造 M 条不同的链(用不同的归约函数 R1, R2, ..., 避免链合并),就能覆盖 M × N 个明文。
    查询过程
    给定一个目标哈希 H_target,攻击者这样查找:
  6. 先猜它在链的最后一步 :计算 R(H_target),看是否等于某条链的终点------如果等于,起点就是那条链的起点,从头重放该链即可找到匹配的明文;
  7. 如果不是,猜它在倒数第二步 :计算 R(H(R(H_target))),再比对终点;
  8. 依此类推,向前遍历最多 N 步。
    代价是查询时间从 O(1) 变成 O(N) ------但 N=10000 的查询在现代 CPU 上是毫秒级,完全可接受。
    彩虹表的实战规模 :2000 年代后期,攻击者可以下载或购买覆盖整个 Windows NTLM 密码空间的彩虹表,14 位以内的字母数字组合几乎全覆盖,单张表几个 TB。FreeRainbowTables、Objectif Sécurité 等网站曾公开提供在线查询。

2.3 盐如何让彩虹表彻底失效

现在给每个用户的密码加上一个随机盐。关键:盐是明文存储的,不需要保密,只需要随机

text 复制代码
user1: salt=a3f2, hash=H("password" + "a3f2") = X1
user2: salt=9c71, hash=H("password" + "9c71") = X2

注意 X1 ≠ X2 ,即使两个用户用同一个密码 "password"。

这给攻击者带来什么?

(1)彩虹表失效 。彩虹表的价值在于"一次预计算,攻击所有同哈希函数的系统"。但加盐后,预计算的输入空间从 H(p) 变成了 H(p + salt)------每个用户的盐都不同 。攻击者要预计算"针对盐 a3f2 的彩虹表",对 user1 有效但对 user2 无效。如果盐的长度是 16 字节(128 位),为每个盐构造彩虹表的代价等价于直接暴力穷举 ------彩虹表的时间-空间优势彻底归零。

(2)批量破解变成逐个破解 。即使攻击者放弃彩虹表,改用普通字典攻击,每个用户都要独立跑一遍完整字典 ------因为"密码 + 盐"是不同的输入。破解 100 万个用户需要的时间,是无盐场景的 100 万倍。

(3)"相同密码"的模式被掩盖。攻击者无法再通过"两个哈希相同"来推断"这两个用户用同一个密码",用户画像和针对性攻击难度大幅上升。

2.4 工程上的盐使用规范

项目 规范
盐的长度 至少 16 字节(128 位)随机,bcrypt/Argon2 内部都这么处理
生成方式 os.urandom(16) / crypto_rng绝不用时间戳、用户名、自增 ID
存储位置 明文与哈希一起存储(数据库同一行),不需要保密
是否每用户不同 必须------同一系统内不同用户的盐必须独立随机生成
是否需要第二把盐("pepper") 见第六节------pepper 和 salt 是两回事,作用不同

常见的盐使用错误

  • 用用户名当盐------同一用户在不同系统(如 "admin@example.com")会用相同盐,彩虹表仍然可预计算;
  • 用固定全局盐------所有用户同一个盐,等于没加盐(彩虹表只需构造一次);
  • 用短盐------如果盐只有 4 字节(16^4 = 65536 种可能),攻击者可以预计算"每个可能盐值的彩虹表",65536 张表仍然可承受;
  • 盐用 CSPRNG 之外的方式生成 ------用 Math.random() 或时间戳,熵不足,可被枚举。
    好消息是bcrypt、Argon2id、scrypt、PBKDF2 都在算法内部自动处理盐 ------你调用 API 时根本接触不到盐的细节,库会自动生成、自动存储在输出字符串里、自动在验证时使用。你自己"手工加盐"的场景,只出现在"直接用 SHA-256"这种本来就不该用的情况。这也是为什么 OWASP 直接说"用专用密码哈希库,不要自己拼装"。

三、bcrypt:老兵不死,但局限明显

3.1 bcrypt 的设计:EksBlowfish 的"可调慢"

bcrypt 由 Niels Provos 和 David Mazières 在 1999 年提出,基于 Blowfish 分组密码改造而来,核心是 EksBlowfish(Expensive Key Schedule Blowfish)

它的核心机制:

  • cost factor(成本因子) :控制密钥调度阶段的迭代次数,cost 每加 1,时间翻倍。cost=10 大约 100ms,cost=12 大约 250ms,cost=14 大约 1 秒(具体取决于硬件);
  • 内置随机盐:每次哈希自动生成 16 字节盐;
  • 输出格式自描述 :把算法版本、cost、盐、哈希打包成一个字符串,方便验证和后续升级。
    bcrypt 输出长这样(注意所有信息都嵌在里面):
text 复制代码
$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
 ↑   ↑  ↑________________________↑↑______________________________↑
 │   │         22 字节盐(base64)      31 字节哈希(base64)
 │   cost=12
 版本 2b

这种"自描述"设计是 bcrypt 的工程亮点 :验证函数 bcrypt.checkpw(password, hash) 自动从哈希字符串里解析出盐和 cost,无需在数据库单独存盐字段;cost 升级时只需重新生成新哈希,新旧哈希可以共存。

3.2 bcrypt 的硬伤:72 字节截断

bcrypt 的输入密码最多只能用前 72 字节 ,超过的部分被静默忽略

这意味着:"A" × 71 + "B""A" × 71 + "C" 这两个完全不同的密码 ,在 bcrypt 眼里是同一个密码

这在 2026 年是个真实的安全问题 ,原因有两个:

(1)Passphrase(密码短语)的兴起 。NIST SP 800-63B 推荐使用长密码短语(如 correct-horse-battery-staple),但 72 字节其实很早就到了------一段 30 个英文单词的 passphrase 轻松超 72 字节。用户以为自己的超长密码很安全,实际只有前 72 字节起作用。

(2)Passlib 5.0 兼容性事故 。2025 年 9 月,Python 的 bcrypt 库升级到 5.0.0 后不再静默截断超长密码,而是直接抛异常 ,这导致依赖 passlib 的 Ansible、FreshRSS 等大量项目报错。事故揭示了一个事实:很多系统多年来一直在静默丢弃用户的超长密码 ------用户以为自己设了 200 字节的密码,实际上只有前 72 字节有效。

工程对策(按推荐度排序):

  1. 改用 Argon2id,没有 72 字节限制;
  2. 如果必须用 bcrypt,在应用层做 pre-hash :先用 SHA-256 算密码的哈希,再 base64 编码后送 bcrypt------这样无论原密码多长,bcrypt 收到的都是 44 字节的 base64 字符串。注意这里不需要 pepper,纯粹是为了绕过 72 字节限制
  3. 应用层限制密码长度到 72 字节以内并明确提示用户------但 NIST 不推荐限制密码长度上限(至少应允许 64 字节以上)。

3.3 bcrypt 的第二个硬伤:对 GPU 的抵抗力有限

bcrypt 虽然慢,但它不消耗大量内存 ------单次计算只需要 4KB 左右的工作内存。这个特点让 GPU 仍然能大规模并行:

一张 RTX 4090 跑 bcrypt (cost=12) 的实测速度约 50,000-70,000 H/s (hashcat mode 3200)。对比之下,同一张卡跑 Argon2id (m=64MiB) 只能跑到 30-100 H/s ------差了三个数量级

原因很简单:4090 有 16,384 个 CUDA 核心,bcrypt 只需要 4KB 内存,每个核心都能独立跑一次 bcrypt,16384 个核心完全并行 ;而 Argon2id 需要 64MB 内存,24GB 显存最多同时跑 89 个核心 (24GB / 64MB ≈ 375,但受限于其他资源实际约 89-150),其余 16000 多个核心全部闲置。

这就是"内存困难"设计的本质 ------GPU 的算力是无限的,但显存是有限的。把密码哈希的瓶颈从"计算"转移到"内存",就能让 GPU 的 16384 个核心大部分闲置。

3.4 bcrypt 在 2026 年还安全吗?

答案是:"足够安全,但不是最优"

OWASP Password Storage Cheat Sheet 的官方立场

  • 首选:Argon2id;
  • 备选:bcrypt(cost ≥ 10,2026 年建议 ≥ 12)、scrypt;
  • 兼容场景 :PBKDF2-HMAC-SHA256(600,000 次迭代)------用于必须兼容 FIPS 认证库的场景。
    bcrypt 的问题不在算法被破解------至今没有针对 bcrypt 的实质攻击------而在于:
  1. 它的"算力优势"随着 GPU 显存越来越大而持续被侵蚀(4090 已有 24GB,下一代 5090 已 32GB);
  2. 72 字节截断的工程陷阱仍在;
  3. 没有像 Argon2 那样能通过增加内存参数来"未来证明"。
    实际建议
  • 遗留系统用 bcrypt (cost ≥ 12) 完全可以接受,不要因为"Argon2 更新"就慌着迁移;
  • 新项目直接 Argon2id
  • 不要再用 PBKDF2 除非 FIPS 强制------PBKDF2 是纯计算密集型,没有内存困难,GPU 优势极大(RTX 4090 跑 PBKDF2-SHA256 100 万轮约 250,000 H/s,比 Argon2id 快 8000 倍)。

3.5 bcrypt Python 实战代码

python 复制代码
import bcrypt
# ============ 密码哈希 ============
def hash_password(plaintext: str) -> bytes:
    # bcrypt 自动生成 16 字节盐,cost=12
    return bcrypt.hashpw(
        plaintext.encode('utf-8'),
        bcrypt.gensalt(rounds=12)   # cost=12,2026 年推荐值
    )
# ============ 密码验证 ============
def verify_password(plaintext: str, stored_hash: bytes) -> bool:
    try:
        return bcrypt.checkpw(
            plaintext.encode('utf-8'),
            stored_hash
        )
        # checkpw 自动从 stored_hash 解析盐和 cost
    except Exception:
        return False   # 统一错误,不区分原因
# ============ 处理 72 字节限制(pre-hash 方案)============
import hashlib
import base64
def hash_password_long_input(plaintext: str) -> bytes:
    """支持任意长度密码的 bcrypt 哈希(pre-hash 绕过 72 字节)"""
    # SHA-256 → base64,保证送入 bcrypt 的是 44 字节
    prehashed = base64.b64encode(
        hashlib.sha256(plaintext.encode('utf-8')).digest()
    )
    return bcrypt.hashpw(prehashed, bcrypt.gensalt(rounds=12))
# ============ 使用示例 ============
stored = hash_password("MySecretPassword123!")
print(stored)  
# $2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
print(verify_password("MySecretPassword123!", stored))  # True
print(verify_password("wrong", stored))                  # False

注意事项

  • rounds=12 是 2026 年的服务器基准------具体取值要让单次哈希在你的服务器上耗时 200-500ms(用基准测试调整);
  • bcrypt 输出已经包含盐和 cost,不要拆开存储,整串存数据库;
  • checkpw 抛异常时统一返回 False,绝不向调用方区分"密码错"和"哈希格式错"------防止侧信道。

四、Argon2id:2015 年至今的密码哈希冠军

4.1 Argon2 的诞生与三个变体

Argon2 由 Alex Biryukov、Daniel Dinu、Dmitry Khovratovich 在 2013 年设计,2015 年赢得 Password Hashing Competition(密码哈希竞赛)冠军 ,2020 年成为 RFC 9106 标准,2022 年起被 OWASP 列为密码存储首选。

Argon2 有三个变体,对应不同的威胁模型:

变体 设计目标 抗 GPU 抗侧信道 推荐度
Argon2d 最大化抗 GPU 极强 ❌ 弱(数据依赖内存访问) 不推荐
Argon2i 抗侧信道攻击 中等 ✅ 强 特定场景
Argon2id 混合方案 默认推荐

Argon2id 是 d 和 i 的混合体 :前几次遍历用数据独立访问(抗侧信道),后续遍历用数据依赖访问(抗 GPU)。2026 年几乎所有场景都用 Argon2id

4.2 三个参数:内存、时间、并行度

Argon2 的可调参数比 bcrypt 丰富得多,正确配置是关键:

参数 含义 单位 OWASP 推荐(2026)
m(memory cost) 占用内存大小 KiB / MiB 46 MiB(或 19 MiB)
t(time cost / iterations) 迭代次数 1 (m=46MiB 时)或 2(m=19MiB 时)
p(parallelism) 并行线程数 1(服务器 CPU 核够用时可用 2-4)
hLen 输出哈希长度 字节 32
saltLen 盐长度 字节 16

OWASP 的两套推荐配置(任选其一):

  • 方案 Am=46 MiB, t=1, p=1------单次约 50ms,适合高 QPS 系统;
  • 方案 Bm=19 MiB, t=2, p=1------单次约 50ms,内存压力更小,适合内存受限的环境。
    RFC 9106 的官方推荐(更保守):
  • 第一推荐m=2 GiB, t=1, p=4------单次约 1 秒,适合"最高安全要求";
  • 第二推荐m=64 MiB, t=3, p=4------单次约 500ms,平衡安全和性能。
    参数怎么选?核心原则
  1. 先定内存 m------这是抗 GPU 的关键,越大越好,但要考虑登录 QPS;
  2. 再定 t------保证单次哈希在你生产服务器的 CPU 上耗时 100-500ms;
  3. p 视服务器 CPU 而定 ------服务器 8 核以上可以用 p=2-4,容器环境内存受限就 p=1。
    一个常见误区 :很多人把 p 调得很大以为"更快更强"。实际上 p 越大,每个线程分到的内存越少 ,抗 GPU 优势会下降。Bitwarden 社区的实测显示,m=256 MiB, p=1m=512 MiB, p=2 在用户设备上耗时相同,但对攻击者的 GPU 攻击效果差不多------关键是 m×p 的总内存

4.3 Argon2id 为什么能压制 GPU:内存困难的数学

这是 Argon2 的核心创新,也是它对比 bcrypt 的本质优势。

Argon2 的内存填充过程

  • 它会填满 m 大小的整个内存块 ,每个块的内容依赖前序若干个块的哈希
  • Argon2d/i 的访问模式不同:d 用数据依赖(最快但暴露内存访问模式,有侧信道风险),i 用数据独立(恒定访问模式但抗 GPU 弱一些),id 是混合;
  • 要算完一次 Argon2id,必须真的占用 m 那么大的内存 ,无法通过"跳过某些块"来加速。
    这个设计对 GPU 的杀伤力 :GPU 的优势是算力(核心数) ,但它的显存总量有限(4090 是 24GB)。当 Argon2id 的 m=64 MiB 时:
text 复制代码
24 GB 显存 ÷ 64 MiB/次 ≈ 375 个并发实例
实际考虑上下文开销,约 89-150 个并发实例
其余 16,234 个 CUDA 核心全部闲置

对比 bcrypt 的 4KB 内存需求

text 复制代码
24 GB 显存 ÷ 4 KB/次 ≈ 6,000,000 个并发实例
GPU 16384 个核心全部满载

这就是"三个数量级"差距的来源

对 ASIC 的效果更夸张 。ASIC 是专门定制的芯片,算力可以达到 GPU 的百倍以上(如比特币矿机),但它的内存制造成本远高于算力成本------要做一个能跑 Argon2id (m=2GiB) 的 ASIC,每"核心"都需要 2GB 内存,物理上不可能做出比特币矿机那种规模 。Stack Exchange 上的讨论指出:对 2021 年的 GPU,128 MiB 已经"相当安全";对 ASIC,需要 GB 级的内存才能有效对抗

2026 年的实际测试 (SpecopsSoft 研究)显示:即使用 NVIDIA H200 这种 AI 加速卡跑 Argon2id,也没有显著优势------因为 Argon2id 的瓶颈在内存带宽而非算力,AI GPU 的算力优势用不上。

4.4 Argon2id 的"抗破解账本"

我们用一张表对比不同算法在攻击者视角下的破解成本。假设攻击者拿到 100 万条密码哈希,目标是破解其中 10%。

算法与参数 单张 RTX 4090 吞吐 攻击 100 万哈希的字典攻击(10 亿候选)时间 估算电力成本
MD5(裸哈希) 8×10^10 H/s ~13 秒 < 1 美分
SHA-1(无盐) 5×10^10 H/s ~20 秒 < 1 美分
SHA-256(无盐) 1.5×10^10 H/s ~70 秒 < 1 美分
PBKDF2-SHA256(10 万轮) 2.5×10^6 H/s ~7 分钟 < 1 美元
PBKDF2-SHA256(OWASP 60 万轮) 4×10^5 H/s ~40 分钟 < 5 美元
bcrypt (cost=10) 2×10^5 H/s ~80 分钟 < 10 美元
bcrypt (cost=12) 5×10^4 H/s ~5.5 小时 < 50 美元
bcrypt (cost=14) 1.3×10^4 H/s ~21 小时 < 200 美元
Argon2id (m=19MiB, t=2) 150 H/s ~77 天 ~5000 美元
Argon2id (m=64MiB, t=3) 30 H/s ~1.3 年 ~10 万美元
Argon2id (m=2GiB, t=1) ~2 H/s ~16 年 数百万美元

看清楚这个数字的工程含义

  • 用 MD5,攻击者点个外卖的时间就能破解你 100 万用户的常用密码;
  • 用 Argon2id (m=2GiB),一个国家级行为者的 GPU 集群 也要花数年------而这种攻击在经济上完全不再划算。
    arXiv 上的研究 (2024)进一步验证:OWASP 推荐的 Argon2id 46 MiB 配置,在相同的攻击预算下,破解率比 SHA-256 低 42.5%------即使只算强密码子集。

4.5 Argon2id Python 实战代码

python 复制代码
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, HashingError
# ============ 推荐参数(OWASP 2026 方案 A)============
ph = PasswordHasher(
    memory_cost=47104,       # 46 MiB = 46 × 1024 KiB
    time_cost=1,             # t=1
    parallelism=1,           # p=1
    hash_len=32,             # 输出 32 字节
    salt_len=16,             # 盐 16 字节
    type=3                   # 3 = Argon2id(argon2-cffi 默认就是 id)
)
# ============ 备选方案 B(内存受限场景)============
ph_low_memory = PasswordHasher(
    memory_cost=19456,       # 19 MiB
    time_cost=2,             # t=2
    parallelism=1,
    hash_len=32,
    salt_len=16,
)
# ============ 密码哈希 ============
def hash_password(plaintext: str) -> str:
    return ph.hash(plaintext)
    # 自动生成 16 字节随机盐,输出格式自描述
# ============ 密码验证 ============
def verify_password(plaintext: str, stored_hash: str) -> bool:
    try:
        # verify 自动解析盐和参数
        ph.verify(stored_hash, plaintext)
        # 关键!check_needs_rehash 用于参数升级
        if ph.check_needs_rehash(stored_hash):
            mark_for_rehash(user_id)   # 异步标记重新哈希
        return True
    except VerifyMismatchError:
        return False
    except Exception:
        return False   # 统一错误,不区分原因
# ============ 输出格式示例 ============
# $argon2id$v=19$m=47104,t=1,p=1$c29tZXNhbHRlY3NhbHRl$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
#  ↑         ↑   ↑____________↑↑     ↑_______________________↑↑_____________________________↑
# 算法      版本  m, t, p 参数        base64 盐                          base64 哈希

argon2-cffi 的工程亮点

  • check_needs_rehash() 是一个杀手级 API------它检查已存储的哈希是否用了旧参数(比如你两年前 m=19MiB,现在升级到 m=46MiB),如果需要升级就返回 True。配合"用户登录时透明升级"策略(见第七节),可以实现零停机的参数升级;
  • 输出格式完全符合 PHC 字符串标准(Password Hashing Competition),算法、参数、盐、哈希全部嵌在字符串里,不需要在数据库单独存字段
  • 默认就是 Argon2id,无需手动指定变体。

4.6 scrypt 和 PBKDF2:简短评价

scrypt (Colin Percival, 2009):也是内存困难算法,比 Argon2 早六年。可用但不如 Argon2id ------内存访问模式设计较老,参数调优空间不如 Argon2 灵活,OWASP 列为第二梯队备选。

PBKDF2-HMAC-SHA256 :纯计算密集型,没有内存困难唯一的优势是 FIPS 140-2/140-3 认证库普遍支持 (如一些政府、金融合规场景强制要求 FIPS 库)。OWASP 推荐迭代次数:PBKDF2-HMAC-SHA256 ≥ 600,000 次,PBKDF2-HMAC-SHA512 ≥ 210,000 次能用 Argon2id 就不要用 PBKDF2

五、bcrypt vs Argon2id:一张总对比表

维度 bcrypt Argon2id
诞生年份 1999 2015(RFC 9106: 2020)
设计基础 EksBlowfish 内存填充 + Argon2i/d 混合
内存困难 ❌ 无(4KB) 核心优势(可调 m)
抗 GPU 中等(~50,000 H/s @ RTX 4090) 极强(~30-150 H/s @ RTX 4090)
抗 ASIC 弱(ASIC 可以堆算力) 极强(ASIC 需要堆内存,物理上不经济)
抗侧信道 中等 强(id 变体专门设计)
参数 cost(1 个) m, t, p(3 个),调优空间大
密码长度限制 72 字节截断 无(任意长度)
OWASP 2026 推荐 备选(遗留系统) 首选
FIPS 140-3 支持 部分库支持 较少(这是 PBKDF2 的场景)
生态成熟度 极成熟(25+ 年) 成熟(10 年,主流库全支持)
2026 年适用性 可用,不推荐新项目 工业界事实标准

一个有意思的对比 :Pilcrow 的研究(2026 年 3 月)发现,当 Argon2id 内存低于 64 MiB 时,它对老款 GTX 1080 的抑制效果与 bcrypt 相当;但对新一代 RTX 5090,Argon2id 仍然有效而 bcrypt 优势消失。这说明 bcrypt 的"算力瓶颈"正在被硬件进步持续侵蚀,而 Argon2id 的"内存瓶颈"则相对稳定(显存增长远慢于算力增长)。

所以选型结论很清晰

  • 新项目一律 Argon2id,没有任何理由选 bcrypt;
  • 遗留系统用 bcrypt (cost ≥ 12) 可以继续用,按"用户登录时透明升级到 Argon2id"的策略渐进迁移;
  • 永远不要 用 MD5/SHA-1/SHA-256 直接存密码,永远不要用 PBKDF2 除非 FIPS 强制。

六、Pepper:被低估的"第二把钥匙"

6.1 Salt 和 Pepper 的本质区别

很多人把 salt 和 pepper 混为一谈,其实它们的作用机制完全不同:

维度 Salt(盐) Pepper(胡椒)
是否随机 每用户独立随机 全系统共享同一个(或少数几个)
是否需要保密 明文存储(与哈希同库) 绝不能与哈希同库(KMS/HSM/独立密钥库)
作用 让彩虹表失效、让批量破解变成逐个破解 数据库泄露后的哈希仍然不可破解
RFC 9106 提及 是(IETF draft 中明确区分)
缺失后果 彩虹表可行、批量破解高效 数据库泄露后哈希可被离线破解

Pepper 的核心价值 :应对"只有数据库被拖走 "这种最常见的泄露场景。大多数密码泄露是 SQL 注入、备份泄露、内部人员导出------只有数据库本身泄露,应用服务器的密钥仍然安全 。这种情况下,pepper 让攻击者拿到哈希也无法做任何离线破解 ,因为 pepper 根本不在泄露的数据里。

一个具体的例子 :LinkedIn 2012 事件里,如果当年在 SHA-1 之外还加了一个存放在应用服务器上的 pepper(比如 HMAC-SHA256(pepper, password) 再 SHA-1),攻击者拿到的 1.17 亿条哈希就是无意义的随机数,直到今天都破解不了。

6.2 Pepper 的正确实现方式:HMAC,不是简单拼接

RFC 9106 和 IETF draft 明确推荐:pepper 必须通过 HMAC 而非字符串拼接的方式引入

为什么不直接拼接 ?比如 Argon2id(pepper + password, salt)

  • Argon2 内部对输入做 BLAKE2b 哈希,pepper 拼接后可能被"长度扩展"或其他结构问题影响(虽然 Argon2 本身对长度扩展免疫,但拼接方式不规范);
  • HMAC 是经过严格安全性证明的标准结构(Bellare-Canetti-Krawczyk 1996),输入"密钥 + 消息",适合"pepper 作密钥、password 作消息"的语义;
  • HMAC 的输出长度固定,可以安全地作为 Argon2id 的输入。
    正确实现
text 复制代码
stored_hash = Argon2id(HMAC-SHA256(pepper_key, password), salt)
其中:
- pepper_key:32 字节随机密钥,从 KMS/HSM 获取,绝不入库
- password:用户输入的明文密码
- salt:bcrypt/Argon2 内部的随机盐

6.3 Pepper Python 实战代码

python 复制代码
import hmac
import hashlib
import os
from argon2 import PasswordHasher
# ============ Pepper 密钥管理 ============
class PepperManager:
    """生产环境从 KMS/HSM 获取,这里用环境变量演示"""
    
    def __init__(self):
        # 生产环境:AWS KMS / HashiCorp Vault / HSM
        # 永远不要硬编码在源码或配置文件里
        self.pepper = os.environ.get('PASSWORD_PEPPER')
        if not self.pepper:
            raise RuntimeError("PASSWORD_PEPPER 未设置")
        self.pepper_bytes = bytes.fromhex(self.pepper)  # 32 字节 hex
    
    def hmac_password(self, password: str) -> bytes:
        """用 HMAC-SHA256 把 pepper 应用到密码上"""
        return hmac.new(
            self.pepper_bytes,
            password.encode('utf-8'),
            hashlib.sha256
        ).digest()
    
    def hmac_password_b64(self, password: str) -> str:
        """base64 编码,便于送入 Argon2(避免二进制处理歧义)"""
        import base64
        return base64.b64encode(self.hmac_password(password)).decode()
# ============ 集成 Argon2id ============
pepper_mgr = PepperManager()
ph = PasswordHasher(
    memory_cost=47104, time_cost=1, parallelism=1,
    hash_len=32, salt_len=16
)
def hash_password(password: str) -> str:
    """带 pepper 的密码哈希"""
    prehashed = pepper_mgr.hmac_password_b64(password)
    return ph.hash(prehashed)
def verify_password(password: str, stored_hash: str) -> bool:
    from argon2.exceptions import VerifyMismatchError
    try:
        prehashed = pepper_mgr.hmac_password_b64(password)
        ph.verify(stored_hash, prehashed)
        return True
    except VerifyMismatchError:
        return False
    except Exception:
        return False

6.4 Pepper 的密钥管理与轮换

Pepper 密钥的存储选项(按推荐度):

方案 安全性 复杂度
HSM(硬件安全模块) 极高 高(需要硬件)
AWS KMS / GCP Cloud KMS / Azure Key Vault
HashiCorp Vault
环境变量(应用服务器)
配置文件 ❌ 低
硬编码 ❌ 极低

Pepper 轮换:pepper 可以轮换,但需要配合"多 pepper 共存"策略:

  1. 数据库存一个 pepper_id 字段标识该哈希用的哪个 pepper 版本;
  2. 新用户用新 pepper(pepper_id = 2),老用户的哈希保持 pepper_id = 1;
  3. 老用户登录时(用旧 pepper 验证通过后),用新 pepper 重新哈希并更新 pepper_id;
  4. 所有用户都升级后,废弃旧 pepper。
    IETF draft 明确允许这种"多 pepper 共存"设计。
    Pepper 的局限 :如果攻击者同时拖走了数据库和应用服务器 (比如通过 RCE),pepper 就没用了。pepper 是"深度防御"的一部分,不是万能药------基础仍然是强 Argon2id 参数。

七、存量迁移与哈希升级:零停机的工程实践

这一节回答一个工程师最常问的问题:"我们系统现在用 MD5/SHA-256,怎么安全迁移到 Argon2id?"

7.1 四种迁移策略对比

策略 用户体验 实施复杂度 推荐度
强制所有用户重置密码 ❌ 极差 不推荐(大用户量时业务会爆炸)
一次性批量迁移(需要明文) - - 不可行(你已经只有哈希了)
登录时透明升级 ✅ 完全无感 工业界标准做法
离线字典攻击自升级(用已知泄露字典尝试破解旧哈希,破解出的升级) ✅ 无感 ✅ 适合高价值账号

7.2 推荐方案:登录时透明升级

这是 Milan Jovanović 在 2026 年的文章里详细讲解的标准做法,核心逻辑:

text 复制代码
1. 数据库存两个版本:
   - legacy_hash:旧算法(如 MD5/SHA-256)的哈希
   - password_hash:新算法(Argon2id)的哈希(初始为 NULL)
2. 用户登录时:
   - 如果 password_hash 非空 → 用 Argon2id 验证
   - 如果 password_hash 为空 → 用旧算法验证 legacy_hash
     - 验证通过 → 立即用同一明文密码计算 Argon2id 哈希
                  → 写入 password_hash,删除 legacy_hash
                  → 用户无感知,但下次就用 Argon2id 了
     - 验证失败 → 返回密码错误

Python 实战代码

python 复制代码
import hashlib
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(memory_cost=47104, time_cost=1, parallelism=1)
# ============ 数据模型 ============
# users 表结构:
#   id           INT PRIMARY KEY
#   email        VARCHAR
#   legacy_hash  VARCHAR NULL  -- 旧 MD5/SHA-256 哈希
#   argon2_hash  VARCHAR NULL  -- 新 Argon2id 哈希
#   hash_version TINYINT       -- 0=legacy, 1=argon2id
# ============ 登录逻辑 ============
def login(email: str, password: str) -> bool:
    user = db.query("SELECT * FROM users WHERE email = ?", email)
    if not user:
        # 防用户枚举:即使不存在也执行一次 dummy 哈希
        ph.dummy_verify()
        return False
    
    if user.argon2_hash:
        # 已升级到 Argon2id
        try:
            ph.verify(user.argon2_hash, password)
            return True
        except VerifyMismatchError:
            return False
    elif user.legacy_hash:
        # 还在旧算法,验证后透明升级
        legacy_input = hashlib.sha256(password.encode()).hexdigest()  
        # 假设旧系统是 SHA-256(如果是 MD5 就换成 md5)
        if legacy_input == user.legacy_hash:
            # ★ 关键:立即用同一明文升级到 Argon2id
            new_hash = ph.hash(password)
            db.execute(
                "UPDATE users SET argon2_hash = ?, legacy_hash = NULL, "
                "hash_version = 1 WHERE id = ?",
                new_hash, user.id
            )
            return True
        return False
    
    return False
# ============ 后台批量升级(可选,加速迁移)============
def batch_upgrade_by_dictionary():
    """用已知泄露字典尝试破解旧哈希,破解出的账号立即升级"""
    # 加载 SecLists / HaveIBeenPwned 字典
    for candidate in load_common_passwords():
        for user in db.query("SELECT * FROM users WHERE hash_version = 0 LIMIT 10000"):
            if hashlib.sha256(candidate.encode()).hexdigest() == user.legacy_hash:
                new_hash = ph.hash(candidate)
                db.execute(
                    "UPDATE users SET argon2_hash = ?, legacy_hash = NULL, "
                    "hash_version = 1 WHERE id = ?",
                    new_hash, user.id
                )

透明升级的关键设计点

  1. 必须在验证通过后立即升级------这是唯一拿到明文密码的时机;
  2. 升级必须原子(UPDATE 同时清空 legacy_hash),避免重复升级;
  3. 登录接口的响应时间 会因为"升级"而稍长(多算一次 Argon2id),用户体验上完全可接受
  4. 监控升级进度 ------SELECT COUNT(*) WHERE hash_version = 0,目标是 90 天内降到 1% 以下;
  5. 对于长期未登录用户 (如 2 年未登录),他们的旧哈希会一直存在------可以考虑:强制重置直接禁用账号(安全上更优,但需要业务平衡)。

7.3 参数升级:用 check_needs_rehash 自动跟进

即使已经是 Argon2id,参数(m、t)也需要随硬件进步而升级。argon2-cffi 的 check_needs_rehash 让这件事变得极其简单:

python 复制代码
def login_with_rehash_check(email, password):
    user = get_user(email)
    try:
        ph.verify(user.argon2_hash, password)
        # ★ 验证通过后检查是否需要升级参数
        if ph.check_needs_rehash(user.argon2_hash):
            # 当前哈希用的是旧参数(如 m=19MiB)
            # 重新用新参数哈希一次
            new_hash = ph.hash(password)
            db.execute("UPDATE users SET argon2_hash = ? WHERE id = ?",
                       new_hash, user.id)
        return True
    except VerifyMismatchError:
        return False

这就是 Argon2 生态比 bcrypt 优雅的地方------参数升级完全自动化,十年后你的密码存储仍然是业界最强水平。

八、FAQ:最常见的 5 个问题

Q1:我已经用了 SHA-256 + 盐,这够安全吗?

不够。SHA-256 即使加盐也只是让彩虹表失效,但暴力穷举的速度仍然是每秒几十亿次 ------一张 RTX 4090 一晚上可以穷举所有 8 位字母数字组合。LinkedIn 2012 事件用的就是这种思路,结果是 1.17 亿账号长期裸奔。加好的盐只是"及格",慢哈希才是"安全" 。立刻用"登录时透明升级"方案迁移到 Argon2id。

Q2:Argon2id 的参数应该选多大?

OWASP 2026 年推荐两套标准配置:方案 A(m=46MiB, t=1, p=1)方案 B(m=19MiB, t=2, p=1) 。判断标准是单次哈希在你的生产服务器上耗时 100-500ms 。如果你跑在高 QPS 服务上,偏向下限;如果是后台低频操作,可以更大。内存 m 是抗 GPU 的关键,越大越好 ------前提是你的服务器内存扛得住(46MiB × 100 并发 = 4.6GB,需要算清楚)。

Q3:bcrypt 的 72 字节截断,我应该担心吗?

应该 。Passphrase 时代,超过 72 字节的密码越来越常见。如果你的用户设置了 200 字节的超长密码,实际只有前 72 字节起作用 ------这是静默发生的,用户完全不知情。2025 年 9 月的 bcrypt 5.0.0 / passlib 兼容性事故就是这个问题暴露出来的标志。对策 :迁移到 Argon2id(首选),或者应用层做 pre-hash(SHA-256 → base64 → bcrypt)。

Q4:Pepper 真的有必要吗?Argon2id 不就够了吗?

有必要,但它是"深度防御"的第二层,不是替代品 。Argon2id 抗的是"哈希被拿到后离线破解";pepper 抗的是"数据库单独泄露(最常见的泄露模式)"这种场景------攻击者拿到哈希但没有应用服务器的密钥,连离线破解都做不了 。Stack Exchange 和 IETF draft 都认为 pepper 是低成本高收益的加固。实现要点 :用 HMAC(不是字符串拼接),密钥放 KMS,定期轮换。

Q5:怎么检测我的系统是否有这些漏洞?

工具化检查:

  • 代码审计 :全局搜索 MD5SHA1sha256digest()hashlib.md5 等关键词,定位所有"直接哈希密码"的代码;
  • 数据库审计:检查密码字段长度------MD5=32、SHA-1=40、SHA-256=64、bcrypt=60、Argon2id=95+;如果看到 64 字符的"哈希+盐"组合,就是有问题;
  • 弱密码测试:把测试账号密码设为 "password123",导出数据库后用 hashcat 测试能否在 10 秒内破解;
  • 渗透测试:让红队模拟"数据库泄露"场景,评估攻击者实际破解难度;
  • OWASP ASVS 检查:对照 ASVS 2.4(Credential Storage)逐项审查。

九、结语:慢一点,才是真正的安全

写到这里,你可能已经注意到本文反复出现的一个反直觉主题------

在密码存储这个领域,"快"是原罪,"慢"才是美德。 MD5、SHA-1、SHA-256 这些函数之所以"不能用于密码",不是因为有数学漏洞,而恰恰因为它们太快 。bcrypt 和 Argon2id 的设计目标,就是用尽一切手段让计算变慢 ------bcrypt 用密钥调度迭代,Argon2 用内存填充;一个耗算力,一个耗内存。它们的全部价值,都建立在这两个字上:慢、贵

更深一层的工程智慧是:密码存储是一个"对抗算力增长"的长期博弈 。bcrypt 在 1999 年设计时足够慢,但 25 年后 GPU 算力增长了百万倍,它的 4KB 内存需求让它逐渐失去优势;Argon2 在 2015 年设计时就考虑了"通过调参跟随硬件进步",m 可以随显存增长而增加,它的"未来适应性"才是它能成为工业标准的核心原因

密码工程的第一铁律 :不要相信"我哈希了所以安全"。哈希只是第一步,参数选择、盐值处理、pepper 加固、迁移策略、错误处理------每一项都决定你的密码存储是工业级还是裸奔。 而 LinkedIn 2012 年那次 1.17 亿账号的泄露,提醒我们所有工程师:今天你写下的每一行密码存储代码,可能在十年后决定一亿用户的安全。

相关推荐
sbjdhjd1 小时前
从 assert 动态调用报错到临时文件链路:PHP 5.6 与 7.3 授权靶场中的短 payload 复盘 | (17字符绕过)
安全·网络安全·数据挖掘·开源·云计算·php·字符绕过
sbjdhjd1 小时前
从 7 字符文件名拼接到二维数组取值:PHP 无参函数限制下的受控靶场复盘 | (7字符绕过)
网络·nginx·安全·网络安全·云计算·php·apache
haon11221 小时前
树模型在信贷风控怎么用——从决策树到 XGBoost
大数据·人工智能·算法·决策树·机器学习·数据挖掘
wordbaby1 小时前
BM25 是什么?手把手拆解搜索引擎的核心算法
人工智能·算法
新时代牛马1 小时前
Linux 网络配置:iproute2、DNS 与连通性排障
linux·服务器·网络
新时代牛马1 小时前
Linux systemd 服务管理:从unit 文件到systemctl 启停与排障
linux·服务器·网络
阳明山水1 小时前
从相关到因果:预测科学的因果转向与可识别性挑战
人工智能·深度学习·算法·机器学习·架构