基于密码的密钥生成¶
本文你会学到:
- 为什么用户密码不能直接用作加密密钥
- PBKDF2 如何通过迭代哈希把"弱密码"变成"强密钥"
- SCRYPT 为什么比 PBKDF2 更抗 GPU 暴力破解
- Argon2、bcrypt 等其他密码哈希算法的特点
- 密钥分割(Shamir's Secret Sharing)如何将一个密钥安全地分给多人保管
- 密钥管理最佳实践:轮换策略、安全存储与安全销毁
🤔 为什么不能直接用密码做密钥?¶
你已经知道,对称加密需要一个高质量的密钥——比如 AES-256 要求 256 位的随机密钥。但现实中,用户输入的密码往往只有 8-12 个字符,大多来自英文字母 + 数字 + 少量符号的组合。
想象这样一个场景:你想用 AES 加密一个文件,让用户输入密码来保护。如果你直接把用户的密码当作 AES 密钥,会面临两个致命问题:
- 密码太短:8 个字符的英文密码最多只有约 52 bit 熵(小写字母 + 数字),远低于 AES-128 所需的 128 bit 安全强度
- 密码可预测:大多数人的密码来自常见词表("password123"、"qwerty"),攻击者可以用字典攻击在几分钟内试遍所有常见密码
把密码直接当密钥,就像用纸糊的锁——看着像锁,一推就开。
密码和密钥是两个不同的概念:密码(password) 是给人记的,需要简短好记;密钥(key) 是给算法用的,需要足够长且随机。两者之间需要一座桥梁——这就是 基于密码的密钥派生函数(Password-Based Key Derivation Function, PBKDF)。
PBKDF 的核心思想很简单:用一个可配置的计算过程,把任意长度的密码"拉伸"成固定长度的、看起来随机的密钥。同时,通过增加计算成本,让攻击者每猜一次密码都要付出巨大代价。
密码熵与攻击模型¶
要把"密码太弱"这个问题讲清楚,需要先用一个量化工具来度量密码的不可预测性——Shannon 熵。
对于均匀分布的随机变量 X,其 Shannon 熵为:
当所有取值等概率时,H(X) 就等于 \(\log_2 |X|\)(位)。比如 8 位随机字母(26 个小写字母),熵为 \(8 \times \log_2 26 \approx 37.6\) bit。加上数字后(36 个字符),熵为 \(8 \times \log_2 36 \approx 41.4\) bit。
但真实密码不是均匀随机分布的。人们倾向于使用常见单词、姓名、日期和简单变换。这意味着**实际熵远低于理论最大熵**。一个 8 字符的英文密码可能只有 20-30 bit 的实际熵——攻击者利用密码频率分布可以将搜索空间缩小几个数量级。
离线攻击 vs 在线攻击¶
密码系统面临的攻击分为两种本质不同的场景:
graph TD
A["密码系统"] --> B{"攻击者能做什么?"}
B --> C["在线攻击<br/>只能尝试登录"]
B --> D["离线攻击<br/>已获得哈希输出"]
C --> E["限制:登录次数<br/>验证码、账户锁定"]
D --> F["无限制:本地暴力枚举<br/>攻击速度 = 硬件算力"]
classDef safe fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:1px
classDef danger fill:transparent,stroke:#d32f2f,color:#adbac7,stroke-width:2px
class C,E safe
class D,F danger
- 在线攻击:攻击者每次猜测都需要和服务端交互——服务端可以限制尝试频率、加验证码、锁定账户。这种场景下,即使密码很弱,攻击者也很难成功
- 离线攻击:这才是真正致命的场景。当攻击者拿到了哈希输出(比如数据库泄露、截获的密文),他可以在自己的机器上**无限制地**尝试猜测密码。此时攻击速度只取决于硬件算力,与服务端的防护无关
PBKDF 解决的就是离线攻击场景:通过增加每次猜测的计算成本,降低攻击者的尝试速率。
熵放大:密钥派生的理论直觉¶
密钥派生的本质是一种 熵放大(entropy amplification):用计算成本换取安全强度。
假设密码的实际熵为 λ bit(比如 λ = 30),攻击者需要平均尝试 \(2^{\lambda-1}\) 次才能猜中。如果 PBKDF 每次评估需要 T 次哈希运算,那么攻击者的总计算量为 \(2^{\lambda-1} \times T\)。对于诚实用户来说,T 的代价只承受一次;对于攻击者来说,T 的代价要乘以 \(2^{\lambda-1}\) 次。
这就是为什么迭代次数越高越安全——T 每增大 1000 倍,攻击者的总工作量就增大 1000 倍,而诚实用户只多等了几百毫秒。
⚠️ 关键认识:PBKDF 不能增加密码本身的熵,它只是放大了攻击者的计算代价。如果密码熵太低(比如 λ < 20),即使迭代次数设得很高,GPU 集群仍然可能在可接受的时间内破解。
为什么不该直接哈希密码——彩虹表攻击¶
你可能已经注意到上一节提到"一次哈希计算太快了",但还有一个更深层的问题:即使你用 SHA-256 哈希了密码,攻击者也不需要在线暴力破解——他可以**提前把所有常见密码的哈希值算好,存成一张巨大的表**,然后拿着泄露的数据库来"查表"。这就是 彩虹表攻击(Rainbow Table Attack)。
想象一个黑客提前制作了一本字典:左列是密码原文("password123"、"qwerty"、"123456"……),右列是对应的 SHA-256 哈希值。拿到你的数据库后,只需查表,瞬间还原密码——不需要做任何计算。
彩虹表的规模¶
以 8 位小写字母 + 数字组合为例:
- 字符集大小:36(a-z + 0-9)
- 可能密码数量:\(36^8 \approx 28\) 亿
- 每条
SHA-256记录 32 字节,总计约 90 GB
90 GB 对现代攻击者来说微不足道——这些表早已被预先计算并公开发布,使用压缩技术的真正彩虹表可压缩到几 GB。
盐值(Salt)如何破解彩虹表¶
解决方案出人意料地简单:给每个密码加一个**每用户独立的随机字节串**(盐值),再哈希:
因为每个用户的盐值不同,攻击者的彩虹表完全失效——他不可能为每种盐值都预先计算一张表。正如《Real-World Cryptography》第 2 章所述,盐值相当于"为每个用户创建一个不同的哈希函数实例",使得攻击者无法用同一张预计算表同时攻击所有用户。
盐值不需要保密,但**必须唯一**。建议每用户生成 16–32 字节随机盐值,与哈希值一起存储。
⚠️ 即使加了盐值,普通哈希函数(SHA-256、MD5)对密码存储仍然不够——它们计算太快,GPU 每秒仍能尝试数十亿次。这就引出了下一个问题:我们需要**专门为密码设计的慢速哈希函数**。
密码哈希 vs 密码学哈希:两个截然不同的用途¶
这两个"哈希"在中文里看起来很像,但用途和设计目标完全不同——搞混它们会带来严重的安全漏洞。
| 特性 | 密码学哈希(SHA-256 等) |
密码哈希(Argon2、bcrypt 等) |
|---|---|---|
| 设计目标 | 尽可能快(文件校验、数字签名) | 尽可能慢(抵御暴力破解) |
| 内存消耗 | 极小(几 KB) | 有意消耗大量内存(MB 级别) |
| 盐值 | 不内置,需手动处理 | 通常内置自动生成 |
| 输出格式 | 原始字节 | 自包含字符串(含盐值和参数) |
| 适用场景 | 文件完整性、数字签名、MAC | 用户密码存储、密钥派生 |
用错了会怎样?¶
- ❌ 用密码学哈希存储密码:
SHA-256("password") 在 GPU 上每秒能计算 100 亿次,几分钟内暴力破解常见密码 - ❌ 用密码哈希做文件校验:
Argon2故意很慢,给 1 GB 文件做哈希可能需要等数分钟,完全不实用 - ✅ 正确用法:密码存储 →
Argon2id;文件完整性 →SHA-256;数字签名内部 →SHA-2/SHA-3
graph LR
A["你要做什么?"] --> B{"哈希的对象"}
B --> C["文件 / 消息<br/>需要速度"]
B --> D["用户密码<br/>需要慢速"]
C --> E["SHA-2 / SHA-3<br/>(密码学哈希)"]
D --> F["Argon2id / bcrypt<br/>(密码哈希)"]
classDef fast fill:transparent,stroke:#0288d1,color:#adbac7,stroke-width:1px
classDef slow fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:2px
class C,E fast
class D,F slow
更多关于 SHA-2、SHA-3 的设计原理,见「散列函数与完整性保护」。
🔑 PBKDF2(PKCS#5 Scheme 2)¶
PBKDF2 的工作原理¶
当你需要从密码生成密钥时,最直接的想法是对密码做一次哈希就行了。但一次哈希计算太快了——现代 GPU 每秒能做数十亿次 SHA-256 计算,攻击者可以轻松暴力枚举。
PBKDF2 的解决方案是**迭代**:对密码反复哈希 N 次。迭代次数越高,攻击者每猜一次密码的代价就越大。
PBKDF2 的输入有三个:
- 密码:用户输入的保密信息(密钥熵的唯一来源)
- 盐值(salt):公开的随机字节串,用于防止彩虹表攻击
- 迭代次数:公开的整数,控制计算成本
它的工作流程如下:
graph LR
A[密码] --> D[HMAC]
B[盐值] --> D
D --> E[第 1 轮结果]
E --> D
D --> F[第 2 轮结果]
F --> D
D --> G[...]
G --> D
D --> H[第 N 轮结果]
H --> I[派生密钥]
classDef input fill:transparent,stroke:#0288d1,color:#adbac7,stroke-width:1px
classDef process fill:transparent,stroke:#f57c00,color:#adbac7,stroke-width:2px
classDef output fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:2px
class A,B input
class D,E,F,G,H process
class I output
每一轮的计算都是 HMAC(密码, 盐值 ‖ 上轮结果),第 N 轮的结果就是最终输出的密钥。即使攻击者预先计算了大量密码的哈希值(彩虹表),盐值的存在也会让这些预计算全部作废——因为每个用户的盐值都不同。
⚠️ 盐值不需要保密,它只需要是唯一的。通常将盐值与密文一起存储。盐值的长度建议至少与底层哈希函数的输出长度相同(如 SHA-256 用 32 字节盐值)。
迭代次数怎么选?¶
迭代次数是安全性和性能之间的权衡:
| 年代 | 建议迭代次数 | 说明 |
|---|---|---|
| 2000 年 | 1,000 | 当时觉得够用了 |
| 2020 年 | 10,000 | OWASP 最低推荐 |
| 当前 | 600,000+ | OWASP 2023 推荐 |
NIST SP 800-132 建议至少 10,000 次,但这个标准已经有些过时了。实际应用中应该根据硬件能力选择"用户等 0.5 秒觉得还行,但攻击者很痛苦"的值。
PBKDF2 的安全性论证¶
PBKDF2 的安全性可以在 随机预言模型(Random Oracle Model, ROM)下进行形式化分析。我们先明确安全目标,再看为什么 PBKDF2 能达到这个目标。
安全目标¶
PBKDF2 的安全目标可以形式化为:对任何概率多项式时间(PPT)的攻击者 A,给定 PBKDF2 的输出和盐值,A 区分输出与真正随机串的优势可忽略。
形式化地说,定义攻击者 A 的优势为:
其中 \(A^{F(\cdot,\cdot)}\) 表示攻击者 A 可以向函数 F 提交查询并获取响应,\(1^\lambda\) 是安全参数。在实际场景中,攻击者已知盐值(公开),只能对固定盐值查询随机选择的密码输入——这更准确地对应 弱 PRF(weak PRF)安全目标。如果对于所有 PPT 攻击者 A,这个优势都是 \(\text{negl}(\lambda)\),就说 PBKDF2 是一个 伪随机函数(PRF)。
随机预言模型下的论证¶
PBKDF2 在 ROM 下的安全性论证分两步:
- 底层 HMAC 是 PRF:HMAC 在 ROM 下已被证明是 PRF(Bellare et al., 1996),即给定 HMAC 的输入输出,PPT 攻击者无法区分它与真随机函数
- 迭代 PRF 仍然是 PRF:PBKDF2 的本质是将 PRF 迭代 c 次——\(U_1 = \text{PRF}(pwd, salt), U_i = \text{PRF}(pwd, U_{i-1})\)。在 ROM 下,每次迭代的输出在攻击者看来都是均匀随机的,因此迭代 c 次后的输出仍然与真随机不可区分
直觉上,每次迭代都在"随机化"上一次的输出,就像反复洗牌——无论洗多少次,牌序看起来都是随机的。
盐值的作用:形式化视角¶
从安全性证明的角度看,盐值解决了两个问题:
- 防止多目标攻击:假设攻击者获得了 n 个用户的 PBKDF2 输出,如果没有盐值,攻击者可以尝试一个密码,同时对所有 n 个输出验证——即**摊薄攻击成本**。盐值使得每个用户的哈希函数实例不同,攻击者必须对每个用户单独尝试
- 保证 ROM 的实例独立性:在 ROM 中,随机预言是全局的。盐值确保不同用户的密钥派生使用不同的预言"输入域",防止跨实例的信息泄露
形式化地说,没有盐值时,攻击 n 个用户的总代价约 \(2^\lambda\)(攻击者猜对一个密码,所有用户同时验证);有盐值时,总代价约 \(n \times 2^\lambda\)。
局限性:顺序成本 vs 并行成本¶
PBKDF2 的迭代结构保证了**顺序计算代价**——诚实用户必须串行执行 c 次 HMAC。但这个代价是**单维度的**:只消耗 CPU,不消耗内存。
攻击者可以用高度并行的硬件(GPU 有数千个核心,ASIC 可以定制电路)来同时尝试大量密码。即使每个实例都要串行迭代 c 次,攻击者同时运行 c 个并行实例的总体吞吐量仍然很高。
这种不对称性是 PBKDF2 的根本缺陷,也是 SCRYPT 和 Argon2 被设计出来的动机。
使用 JCE API 生成密钥¶
Java 提供了 SecretKeyFactory 来执行 PBKDF2。Bouncy Castle 作为 JCE Provider,支持指定底层哈希算法:
算法名称 PBKDF2WithHmacSHA256 指定了 PBKDF2 使用 SHA-256 作为底层 HMAC 的哈希函数。早期 PBKDF2 默认使用 SHA-1,现在已经不推荐了。
💡 密码使用 char[] 而不是 String——因为 char[] 可以在用完后手动清零(Arrays.fill(password, '\0')),而 String 不可变,可能长期驻留在内存中。
使用 Bouncy Castle 低级 API¶
如果你不想走 JCE 路线,Bouncy Castle 的低级 API 同样支持 PBKDF2:
| 使用 BC 低级 API 执行 PBKDF2 | |
|---|---|
构造函数传入的 SHA256Digest 决定了底层 HMAC 使用的哈希算法。PKCS5PasswordToUTF8Bytes() 负责将 char[] 转为 UTF-8 字节数组——这一点值得注意:不同 KDF 对密码编码的处理方式不同,有些会将字符当作 16 位处理,有些当作 7 位 ASCII,使用非 ASCII 字符时务必确认编码行为。
💪 SCRYPT¶
为什么需要 SCRYPT?¶
PBKDF2 解决了"计算太简单"的问题,但它只消耗 CPU。攻击者可以用 GPU 或定制 ASIC(专用集成电路)来并行计算大量 PBKDF2——单个 GPU 核心可能比 CPU 慢,但 GPU 上有几千个核心同时跑,总体上攻击速度反而比你的服务器快得多。
打个比方:PBKDF2 就像让攻击者做 10000 道数学题。他一个人做很慢,但如果他雇了 10000 个小学生(GPU 核心)同时做,10000 道题瞬间就做完了。
SCRYPT(由 Colin Percival 于 2009 年提出,2016 年标准化为 RFC 7914)的解决思路是:不仅消耗 CPU,还强制消耗大量内存。GPU 虽然核心多,但每个核心的可用内存有限,无法高效并行运行大量 SCRYPT 实例。
内存硬函数(Memory-Hard Function)¶
SCRYPT 的安全性基础是一个重要的理论概念——内存硬函数。要理解为什么 SCRYPT 能抵御 GPU/ASIC,需要先形式化地定义什么是"内存硬"。
形式化定义¶
一个函数 f 是 (T, S)-memory-hard 的,如果**任何使用 ≤ S bit 内存的电路**计算 f 都需要至少 T 步。换句话说,想用更少的内存,就必须付出更多的时间,反之亦然。
内存硬函数分为两种(Alwen & Serbinenko, 2015):
顺序内存硬(sequentially memory-hard):任何顺序算法(每步只能执行一次基本操作)计算 f 都至少需要 S bit 的内存计算内存硬(computationally memory-hard):攻击者的 AT 成本(面积 × 时间,见下方)至少为 T × S,意味着无法同时用小面积和短时间完成计算
SCRYPT 的 ROMIX 函数是**顺序内存硬**的——它要求按伪随机顺序反复读取一个大数组,无法提前预知访问模式,也无法用少量内存和更多时间来替代。
AT 面积-时间成本模型¶
评估攻击者的硬件成本时,密码学理论使用 AT 成本模型(Area-Time cost):
- Area(面积):攻击电路所需的硅片面积,大致正比于内存大小
- Time(时间):计算所需的时间步数
这个模型的直觉是:芯片面积越小越便宜,运行时间越短越好。攻击者要最小化总成本 Area × Time。
对于 PBKDF2,攻击者的 AT 成本很低——因为不需要内存,Area 可以很小(单个 HMAC 单元),只需要增加 Time(更多并行核心)。而 SCRYPT 迫使攻击者要么:
- 为每个并行实例分配 S 字节内存 → Area 线性增长
- 或者用少量内存多次读外部存储 → Time 大幅增加
无论哪种方式,AT 成本都远高于 PBKDF2。
ROMIX 的顺序内存硬论证¶
SCRYPT 第二步的 ROMIX 函数通过以下机制实现顺序内存硬:
- 首先顺序写入 N 个 128r 字节的块到内存中(填充阶段)
- 然后对每个块,根据前一个块的伪随机值决定读取哪个块,将结果混入当前块(混合阶段)
混合阶段的关键:读取顺序取决于前一步的计算结果,攻击者**无法提前预知**整个访问序列。这意味着:
- 不能预先将数据分片到多个小内存中(访问模式不可预测)
- 不能用时间换空间(每个块被随机访问多次,重复计算代价太大)
⚠️ 理论注意:SCRYPT 的内存硬度论证依赖于 随机预言模型,并且在实践中存在一些理论上的微妙问题(比如 Percival 原始论文中的论证并不完全严谨)。不过,这并不影响其在实际应用中的安全有效性——密码学界广泛认可 SCRYPT 的设计思路是正确的。
SCRYPT 的三步流程¶
SCRYPT 的内部结构比 PBKDF2 复杂,分为三个步骤:
graph TD
A[密码 + 盐值] --> B["第一步<br/>PBKDF2 生成大数组 B"]
B --> C["第二步<br/>ROMIX 混淆<br/>(内存密集型)"]
C --> D["第三步<br/>PBKDF2 生成最终密钥<br/>(以 B 为盐值)"]
D --> E[派生密钥]
classDef input fill:transparent,stroke:#0288d1,color:#adbac7,stroke-width:1px
classDef process fill:transparent,stroke:#f57c00,color:#adbac7,stroke-width:2px
classDef output fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:2px
class A input
class B,C,D process
class E output
- 第一步:用 PBKDF2-HMAC-SHA256 反复哈希密码,生成一个大数组 B,每个块的大小为 128 × r 字节
- 第二步:对数组 B 的每个块执行
ROMIX函数(基于 Salsa20/ChaCha20 核心函数),这就是"内存硬度"的来源——需要反复随机访问大数组中的数据 - 第三步:再次用 PBKDF2,这次以数组 B 作为盐值,生成最终密钥
关键在于第二步:ROMIX 需要在内存中保持整个数组 B,并且以伪随机顺序访问它。这使得缓存命中率极低,GPU 的高并行优势被内存带宽瓶颈抵消。
参数说明¶
SCRYPT 有三个关键参数:
| 参数 | 含义 | 内存消耗 | 建议值 |
|---|---|---|---|
| N(CPU/内存成本) | 决定 ROMIX 数组的迭代轮数 | ≈ 128 × N × r 字节 | 16384(2^14)或更高 |
| r(块大小) | 每个内存块的大小因子 | 与 N 成正比 | 8 |
| p(并行因子) | 并行执行的 SCRYPT 实例数 | ≈ p × 128 × N × r 字节 | 1 |
内存消耗公式为 128 × N × r × p 字节。例如 N=16384, r=8, p=1 时,需要约 16MB 内存。攻击者如果想用 GPU 并行跑 1000 个 SCRYPT,就需要 16GB 显存——这对大多数消费级 GPU 来说已经很勉强了。
使用 SCRYPT 生成密钥¶
在 Bouncy Castle 中,SCRYPT 可以通过 JCE 或低级 API 两种方式使用:
| 使用 SCRYPT 生成密钥 | |
|---|---|
JCE 方式则需要使用 Bouncy Castle 专有的 ScryptKeySpec:
| 通过 JCE API 使用 SCRYPT | |
|---|---|
⚠️ 生产环境中,N 值至少应该设为 16384(2^14)。上面的 N=1024 仅用于演示。N 值必须为 2 的幂次方。
PBKDF2 vs SCRYPT 对比¶
| 特性 | PBKDF2 | SCRYPT |
|---|---|---|
| 消耗资源 | 仅 CPU | CPU + 内存 |
| 抗 GPU 攻击 | 弱(GPU 可大规模并行) | 强(内存带宽瓶颈) |
| 抗 ASIC 攻击 | 弱 | 较强 |
| 计算速度 | 较快 | 较慢 |
| 标准化 | PKCS#5 / NIST SP 800-132 | RFC 7914 |
| 适用场景 | 兼容性要求高的系统 | 新系统、密码存储 |
🔧 其他 PBKDF¶
Argon2:密码哈希的现代标准¶
2013–2015 年,密码学社区举办了一场"密码哈希竞赛"(Password Hashing Competition),目的是选出比 PBKDF2 和 scrypt 更安全的密码哈希算法。最终冠军是 Argon2——如《Real-World Cryptography》第 2 章所述,它是目前密码哈希的最佳选择(state-of-the-art)。
三种变体¶
Argon2 有三个变体,核心区别在于内存访问方式:
| 变体 | 内存访问方式 | 抗 GPU | 抗侧信道 | 推荐场景 |
|---|---|---|---|---|
Argon2d |
数据依赖型访问 | ✅ 最强 | ❌ 易受侧信道 | 加密货币、无侧信道威胁场景 |
Argon2i |
数据无关型访问 | ⚠️ 中等 | ✅ 最强 | 密码管理器、高侧信道风险场景 |
Argon2id |
混合型(前半 2i,后半 2d) | ✅ 强 | ✅ 较强 | 通用密码存储(首选) |
🎯 RFC 9106(2021 年正式发布)明确推荐:在不确定的情况下,始终选择
Argon2id。
内存硬度原理¶
与 PBKDF2 只消耗 CPU 不同,Argon2 强制消耗大量内存(memory-hard)。原理类似 scrypt 的 ROMIX,但设计更简洁:
- 将整个工作内存分成若干 1 KB 大小的块(block)
- 按伪随机顺序反复读写这些块(对
Argon2id:前半程按顺序,后半程按随机顺序) - 每次写入都依赖之前某个块的内容,形成数据依赖链
攻击者若想并行运行 1000 个 Argon2id 实例(内存参数 64 MB),需要 64 GB 显存——超过绝大多数高端 GPU 的显存上限。
三个参数¶
Argon2 通过三个维度控制计算成本:
| 参数 | 含义 | OWASP 2023 最低建议 |
|---|---|---|
m(内存,KiB) |
工作内存大小 | 19,456 KiB(≈19 MB) |
t(迭代次数) |
算法执行轮数 | 2 |
p(并行度) |
并行线程数 | 1 |
Java Bouncy Castle 示例¶
Bouncy Castle 1.80+ 提供内部 API Argon2BytesGenerator(位于 org.bouncycastle.crypto.generators),尚未注册为标准 JCA Provider 接口:
💡 生产环境也可使用 de.mkammerer:argon2-jvm 库,它封装了原生 Argon2 实现,性能更优且接口更简洁。
bcrypt¶
bcrypt 是 1999 年由 Niels Provos 和 David Mazières 设计的经典密码哈希算法,广泛用于 Unix 系统和 Web 应用。它的特点:
- 基于 Blowfish 加密算法,内置盐值
- 通过
cost factor(成本因子)控制迭代次数,通常为 10-12 - 密码最大长度为 72 字节
- 自动截断超过 72 字节的输入,这一点需要注意
bcrypt 在实践中经过了 25 年的考验,至今仍然是密码存储的主流选择之一。不过相比 Argon2,它不支持显式调节内存消耗,抗 GPU/ASIC 能力不如 SCRYPT 和 Argon2。
算法选择建议¶
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 新项目的密码存储 | Argon2id | 密码哈希竞赛冠军,三维度成本控制 |
| 需要兼容旧系统 | bcrypt | 经过长期实践验证,生态成熟 |
| 密钥派生(非密码存储) | PBKDF2-HMAC-SHA256 | 标准化程度高,JCE 原生支持 |
| 高安全要求的密钥派生 | SCRYPT | 内存硬度,抗硬件加速攻击 |
| Java KeyStore 内部使用 | PBKDF2 | JKS/PKCS12 标准内置 |
密钥拉伸:从口令到高质量密钥¶
密钥拉伸(Key Stretching)是 PBKDF 最重要的应用场景之一:将一个低熵的用户口令"拉伸"成满足加密算法要求的高质量密钥。
正如《Real-World Cryptography》第 8 章所述,KDF(Key Derivation Function)和 PRNG 虽然都能从一个秘密派生出更多秘密,但用途不同——KDF 专门处理"输入熵不够均匀"的场景,比如口令。
PBKDF2 vs Argon2 密钥派生对比¶
| 特性 | PBKDF2 |
Argon2id |
|---|---|---|
| 底层原语 | HMAC(可选 SHA-256/SHA-512) | Blake2b + 自定义混合 |
| 抗 GPU 并行 | ❌ 弱(纯 CPU,可大规模并行) | ✅ 强(内存硬,显存受限) |
| 参数灵活性 | 仅迭代次数 | 内存 + 迭代 + 并行三维调节 |
| 标准化状态 | PKCS#5 / NIST SP 800-132 | RFC 9106(2021) |
| 适合密钥派生 | ✅ 兼容性好,JCE 原生支持 | ✅ 安全性更高,推荐新项目 |
用 Argon2id 派生 AES 密钥¶
💡 与「随机性与密钥派生」中的 HKDF 不同——HKDF 适合从已有的高熵秘密(如 ECDH 输出)派生子密钥;Argon2 / PBKDF2 适合从低熵口令起步。两者常配合使用:先 Argon2 把口令提升到高熵,再 HKDF 派生多个子密钥。
✂️ 密钥分割(Secret Sharing)¶
为什么需要密钥分割?¶
假设你的公司有一把加密私钥,用来签发所有客户的证书。如果这把密钥只交给一个人保管:
- 他离职了、生病了或出意外了 → 密钥丢失,业务瘫痪
- 他被收买了或账户被盗了 → 密钥泄露,客户受损
把密钥复制多份分给多人也不行——副本越多,泄露概率越高。
你需要的是一种机制:把一个密钥拆成 N 份分给 N 个人,任意 K 个人联合就能恢复完整密钥,但少于 K 个人即使凑在一起也得不到任何有用信息。这就是 Shamir's Secret Sharing(沙米尔密钥共享方案),由 Adi Shamir 于 1979 年提出。
银行金库就是现实中的类似场景:5 个高管各持一把不同的钥匙,至少 3 把同时插入才能打开金库。少了任何一把都不行,丢了 1-2 把也没关系。
工作原理¶
Shamir's Secret Sharing 基于多项式插值。核心思想出人意料地简单:
- 选择一个足够大的素数 p(作为有限域)
- 构造一个 K-1 次多项式 f(x),其中 f(0) = 秘密值 S,其他系数随机生成
- 计算每个持有者的份额:Si = f(i) mod p,其中 i 是持有者的编号
- 恢复时,任意 K 个持有者用
拉格朗日插值就能还原多项式,从而得到 f(0) = S
为什么 K-1 个份额无法恢复秘密?因为通过 K-1 个点只能确定一个 K-2 次多项式,而原多项式是 K-1 次的——K-2 次多项式的常数项(即 f(0))和原多项式的常数项几乎肯定不同。
💡 把它想象成拼图:你有 5 块拼图碎片,需要 3 块才能看出完整的图案。2 块碎片只能拼出一小片,给你提供不了有用的信息。
信息论安全性:为什么 K-1 个份额"什么都不知道"¶
Shamir's Secret Sharing 有一个比"计算上安全"更强的性质——它是**信息论安全**(information-theoretically secure)的。
"计算安全"意味着攻击者有足够计算能力就能破解(比如 RSA 的安全性依赖于大数分解的难度)。但信息论安全意味着:无论攻击者有多少计算能力,K-1 个份额都不会泄露关于秘密的任何信息。
这可以从两个角度理解:
直觉论证:给定 K-1 个点,有无数个 K-1 次多项式可以通过这些点,且每个多项式有不同的常数项 f(0)。也就是说,秘密 S 可以是有限域中的**任何值**,且每个值出现的概率相同。既然所有可能的秘密都是等概率的,K-1 个份额就没有提供任何信息。
形式化论证:使用条件熵来表述。设秘密 S 在有限域上**均匀随机选取**,份额为 \((S_1, ..., S_{k-1})\),则:
这意味着知道 K-1 个份额后,关于秘密 S 的不确定性完全没有减少。这个条件熵等式等价于:秘密 S 与任意 k-1 个份额**统计独立**,即 \(P(S=s | S_1=s_1, ..., S_{k-1}=s_{k-1}) = P(S=s)\) 对所有可能的值成立。这就是"完美隐瞒"(perfect hiding)的精确定义。
对比其他密码学原语:数字签名的 EUF-CMA 安全是"计算安全"(依赖于离散对数问题的难度),而 Shamir 方案是"信息论安全"(不依赖任何计算假设)。这也是为什么密钥分割经常用于保护最高价值的密钥——比如 CA 根密钥。
简化的密钥分割示例¶
下面是一个基于 XOR 的简化密钥分割演示(2-of-3 方案)。虽然不是完整的 Shamir 方案(不能实现任意的 K-of-N),但能帮助理解核心思想:
这个简化方案的局限在于它只实现了 2-of-N,无法做到任意的 K-of-N 阈值。完整的 Shamir 方案需要构造 K-1 次多项式,再通过拉格朗日插值恢复——数学上不复杂,但实现代码较长。
应用场景¶
密钥分割在实际系统中有重要用途:
- CA 根密钥保护:证书颁发机构的根私钥是整个 PKI 信任链的根基,通常需要多名管理员同时在场才能使用
- 加密货币钱包:多重签名和社交恢复的底层原理之一
- 数据库加密主密钥:企业级数据库加密的主密钥通常由多个安全负责人分担
💡 密钥管理最佳实践¶
密钥轮换策略¶
无论密钥多么安全,长期使用同一个密钥都会增加被破解的风险。密钥轮换(Key Rotation)的核心原则:
- 对称加密密钥:建议每 90 天轮换一次,或者每次加密会话使用新密钥
- 密钥加密密钥(KEK):轮换频率可以低一些(如每年),但必须轮换
- 一旦怀疑密钥泄露:立即轮换所有相关密钥,不要等"确认"泄露后再行动
密钥存储安全¶
密钥生成出来后,怎么存放同样关键。几个基本规则:
- 不要硬编码密钥:代码提交到 Git 后,密钥就永远公开了
- 使用 KeyStore:Java 的
KeyStore(JKS/PKCS12/BCFKS)提供了密码保护的密钥存储,至少比裸文件安全 - 密钥与数据分离:密钥和加密数据不要存在同一个地方
- 最小权限原则:只有需要使用密钥的组件才能访问密钥
密钥存储的最佳实践¶
理解了"不能做什么"之后,再看"应该用什么"。按安全等级从高到低:
硬件安全模块(HSM)¶
HSM(Hardware Security Module)是专门用于密钥管理的硬件设备。密钥在 HSM 内部生成,永远不会以明文形式离开 HSM。所有加密操作(签名、加密)都在 HSM 内部执行,外部只能看到输出结果。
适用场景:CA 根密钥、支付系统私钥、代码签名密钥等极高价值密钥。
正如《Real-World Cryptography》第 8 章所述,HSM 提供了防提取保护——即使物理攻击也难以提取密钥。
操作系统密钥库(OS Keystore)¶
现代操作系统内置了密钥存储机制:
| 平台 | 密钥存储 | Java 访问方式 |
|---|---|---|
| Windows | Windows Credential Store / CNG | KeyStore.getInstance("Windows-MY") |
| macOS | Keychain | KeyStore.getInstance("KeychainStore") |
| Linux | TPM / Secret Service | 第三方库 |
| Android | Android Keystore | KeyStore.getInstance("AndroidKeyStore") |
| 通用 | PKCS12 文件 | KeyStore.getInstance("PKCS12") |
OS 密钥库的优势是密钥与应用代码分离,且通常受到 OS 权限控制。
环境变量 vs 硬编码:明确的分界线¶
graph LR
A["密钥来源"] --> B{"存储方式"}
B --> C["硬编码在代码中<br/>❌ 提交 Git 永久暴露"]
B --> D["配置文件(无加密)<br/>⚠️ 文件权限依赖"]
B --> E["环境变量<br/>✅ 与代码分离"]
B --> F["密钥管理服务<br/>✅ 最佳实践"]
classDef bad fill:transparent,stroke:#d32f2f,color:#adbac7,stroke-width:2px
classDef warn fill:transparent,stroke:#f57c00,color:#adbac7,stroke-width:1px
classDef good fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:2px
class C bad
class D warn
class E,F good
云环境密钥管理服务(如 AWS KMS、Azure Key Vault、GCP Cloud KMS)提供了更高级的委托管理——应用不持有密钥本身,只能请求服务执行特定的加密操作。这样即使应用被攻击,攻击者也无法直接提取密钥材料。
⚠️ 正如《Real-World Cryptography》第 8 章所警告的:"没有银弹,即使使用了密钥管理服务,如果应用本身被攻击,攻击者仍然可以以应用的名义向服务发出任意请求"——所以还需要配合访问控制和异常检测。
密钥销毁¶
密钥生命周期不止"生成"和"使用",还包括"销毁"。当密钥不再需要时:
- 清除内存中的密钥:对
byte[]和char[]手动填充零值 - 删除磁盘上的密钥文件:使用安全擦除工具,而非简单的文件删除
- 撤销相关证书(如果密钥关联了数字证书)
⚠️ Java 的 String 是不可变对象,无法安全清除。这也是为什么密码输入应该使用 char[] 而非 String 的另一个原因。
PBKDF 小结¶
| 算法 | 类型 | 安全性 | 速度 | 推荐场景 |
|---|---|---|---|---|
| PBKDF2 | CPU 密集型 | 中(抗暴力破解) | 较快 | 密钥派生、兼容性优先 |
| SCRYPT | CPU + 内存密集型 | 高(抗 GPU/ASIC) | 较慢 | 高安全密钥派生 |
| Argon2 | CPU + 内存 + 并行 | 最高(竞赛冠军) | 可调 | 新项目密码存储 |
| bcrypt | CPU 密集型 | 中高(经典验证) | 中 | 传统密码存储 |
选择算法时,最重要的原则是:永远不要自己设计密码哈希方案。使用经过密码学社区广泛审查的标准算法,正确设置参数,你就已经走在大多数人前面了。
📚 参考来源(本笔记增强部分)¶
- David Wong, Real-World Cryptography (Manning, 2021), Chapter 2 "Hash functions" & Chapter 8 "Randomness and secrets"
- 章节文本:会话工作区
files/rwc-chapters/ch02.txt、ch08.txt - OWASP Password Storage Cheat Sheet (2023): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- RFC 9106 — Argon2 Memory-Hard Function for Password Hashing (2021): https://datatracker.ietf.org/doc/rfc9106/
- RFC 7914 — The scrypt Password-Based Key Derivation Function (2016): https://datatracker.ietf.org/doc/rfc7914/