密码学在业务里的失败,多数不是算法被攻破,而是原语选错了位置:该用慢哈希的地方用了快哈希,该用随机 IV 用了固定值,该用 AEAD 只做了加密。算法都是好的,用错就全废。
先把三件事分清,后面所有细节都挂在这个分工上。
1. 三种原语的分工
| 需求 | 原语 | 特性 | 拿错了会怎样 |
|---|---|---|---|
| 校验(密码、token 摘要) | 哈希(单向) | 不可逆,同一输入同输出 | 能反推原文 → 库泄露即明文泄露 |
| 可逆保护(身份证、手机号) | 对称加密 | 可解密,需保管密钥 | 用哈希 → 业务查不回;用明文 → 泄露即事故 |
| 交换与签名(密钥协商、签名、证书) | 非对称 | 公私钥分离 | 用它加密大块数据 → 慢几个量级且长度受限 |
一条底线:哈希不是加密。哈希没有密钥(HMAC 除外),不能解密,也不能"加密后取回原文"。凡是代码里出现"把密码加密存起来,登录时解密比对"的需求,方向就已经错了。
2. 密码为什么必须用慢哈希
密码的威胁模型不是"算法被破",而是"库被拖走后离线爆破":拿到哈希库后按 GPU 速度穷举候选。快哈希(SHA-256、MD5)在这个场景下等于明文——加盐只防彩虹表,不防逐个爆破。
慢哈希的目标就是让每次尝试都昂贵,同时让合法登录的一次计算可接受。
| 算法 | 可调参数 | 内存硬 | 抗 GPU/ASIC | 备注 |
|---|---|---|---|---|
bcrypt |
cost(2 的幂,常用 10–12) |
否 | 中等 | 输入截断到 72 字节,超长密码要先摘要 |
scrypt |
N(CPU/内存)、r、p |
是 | 较好 | 参数组合多,N 与 r 要一起调 |
argon2id |
memory、iterations、parallelism |
是 | 最好 | 密码哈希竞赛胜出,新系统首选 |
PBKDF2 |
迭代次数 | 否 | 弱 | 合规场景(FIPS)仍在用,迭代要足够高 |
SHA-256 + salt |
无 | 否 | 无 | 不应用于密码 |
// 新系统:argon2id,参数按机器实测调整(目标单次几十到几百毫秒量级)
PasswordEncoder enc = new Argon2PasswordEncoder(
16, // saltLength
32, // hashLength
1, // parallelism
1 << 16, // memory(KB),约 64 MB
3); // iterations
String hash = enc.encode(rawPassword); // 结果自带参数与盐,形如 $argon2id$v=19$m=65536,t=3,p=1$...
bcrypt 不是"过时",它在大量存量系统里仍是合理选择,但要知道 72 字节截断这个坑:超过 72 字节的部分被静默丢弃,导致"超长密码只有前 72 字节有效"。修法是先做一次 SHA-256 再喂给 bcrypt(并记录这个约定)。
3. 盐:每个用户独立,且由库生成
盐解决"同密码同哈希",阻断预计算表与"一次命中全库"。
| 做法 | 是否可接受 | 原因 |
|---|---|---|
| 每个用户一个随机盐,随哈希一起存 | 正确 | 爆破成本按用户线性叠加 |
| 全局共用一个盐 | 错误 | 相同密码仍同哈希,一次命中全库 |
| 盐 = 用户名 | 弱 | 可预测,且改名后失效 |
| 盐保密(当作第二密钥) | 加分但非必需 | 盐的价值在于唯一性,不在于保密 |
现代库的输出串已内嵌算法、参数、盐,不需要额外字段:
$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
校验时解析这个串,用其中记录的参数重算,因此同一张表里可以并存不同参数的哈希——这就是平滑升级的基础。
4. 工作因子的调整与 rehash 升级
参数随硬件过时。无法"一次性升全库"(要所有明文),只能登录时按需重算:
public boolean login(String username, String raw) {
User u = repo.findByUsername(username);
if (u == null) { encoder.encode(raw); return false; } // 防用户名枚举的时序差异
if (!encoder.matches(raw, u.getPasswordHash())) return false;
if (encoder.upgradeEncoding(u.getPasswordHash())) { // 参数低于当前策略
u.setPasswordHash(encoder.encode(raw)); // 此刻有明文,正好重算
repo.save(u);
}
return true;
}
迁移进度看监控里"未升级哈希登录次数",归零才算完成。
5. 密钥管理的层次
密钥泄露等于加密白做,且比明文泄露更隐蔽(可静默解密很久)。
| 层次 | 做法 | 反面 |
|---|---|---|
| 不落代码 | 环境变量 / 挂载文件 / KMS 拉取 | 硬编码常量、写进 application.yml 提交到仓库 |
| 分环境 | dev / staging / prod 各自独立密钥 | 一套密钥打通全部环境 |
| 可轮换 | 密文带 keyId,支持双密钥并存解密 |
换密钥必须停机重刷全表 |
| 最小权限 | 只有加解密服务能取密钥 | 每个服务都能读主密钥 |
| 有审计 | 记录密钥使用与变更 | 谁都能改,出事无法定位 |
// 密文前缀带 keyId,轮换时新旧共存
String sealed = "v2:" + Base64.getEncoder().encodeToString(nonce) + ":" + Base64.getEncoder().encodeToString(ct);
// 从 KMS / 环境读取,启动时校验存在性,失败即拒绝启动
byte[] key = keyProvider.get("v2"); // 不在代码里,不在配置文件里
6. 对称加密的两个易错点:模式与 IV
| 模式 | 需要 IV/Nonce | 是否认证 | 并行 | 评价 |
|---|---|---|---|---|
ECB |
无 | 否 | 是 | 禁用:相同明文块产生相同密文块,图像轮廓可见 |
CBC |
是,随机且不可预测 | 否 | 解密可并行 | 需额外 MAC,且有 padding oracle 历史问题 |
CTR |
是,绝不可复用 | 否 | 是 | 复用 nonce 直接泄露明文异或 |
GCM |
是,12 字节 nonce,绝不可复用 | 是(AEAD) | 否(串行) | 首选:一次搞定加密 + 完整性 |
IV 的两个要求必须同时满足:随机(不可预测,防 CBC 的选择明文攻击)与不复用(同一密钥下 nonce 重复,GCM 会直接泄露认证密钥)。
// 正确:AES-256-GCM,随机 12 字节 nonce,AAD 绑定业务上下文
public String encrypt(byte[] plain, String aad) throws GeneralSecurityException {
byte[] nonce = new byte[12];
SecureRandom.getInstanceStrong().nextBytes(nonce); // 每次加密都新生成
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce));
c.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
byte[] ct = c.doFinal(plain);
return Base64.getEncoder().encodeToString(nonce) + "." + Base64.getEncoder().encodeToString(ct);
}
AAD 用来绑定"这段密文属于哪个用户、哪个字段",防止把 A 用户的密文挪到 B 用户行下仍然解密的"密文搬运"。
7. 完整性与加密的关系
只加密不认证,攻击者读不到明文但能篡改密文,让解密产生可控的垃圾(padding oracle、比特翻转)。三个组合的结论很明确:
| 组合 | 安全性 | 说明 |
|---|---|---|
| Encrypt-and-MAC | 不推荐 | MAC 覆盖明文,语义不清 |
| MAC-then-Encrypt | 有历史漏洞 | padding oracle 的温床 |
| Encrypt-then-MAC | 可用 | MAC 覆盖密文与 IV,验证后再解密 |
| AEAD(GCM/ChaCha20-Poly1305) | 首选 | 一个原语同时给机密性与完整性 |
如果用的库只提供 CBC,那就必须自己做 Encrypt-then-MAC(HMAC-SHA256,独立密钥),并且先验 MAC 再解密,顺序错了等于把 oracle 送给对方。
8. 非对称的边界、随机数与字段级加密
非对称加密(RSA、ECIES)只适合加密很短的东西:一个对称密钥、一段签名、一个摘要。用它加密业务数据有两个硬问题——慢若干数量级、明文长度受模数限制。标准做法是混合加密:非对称传对称密钥,数据走 AES-GCM。
随机数来源是另一个高频错误:
// 错误:可预测,种子确定时输出序列可复现
String token = Long.toHexString(new Random().nextLong());
// 正确:密码学安全随机
SecureRandom sr = new SecureRandom();
byte[] buf = new byte[32];
sr.nextBytes(buf);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
Random 是线性同余/伪随机,拿到少量输出即可推后续序列;Math.random() 同理。凡是 token、盐、IV、重置码、邀请码,一律 SecureRandom。
字段级加密(手机号、身份证)适用场景:按字段授权、防范"有库权限无业务权限"的人。代价很实在:
| 代价 | 表现 |
|---|---|
| 无法索引/排序/模糊查询 | 只能等值查询(配合盲索引 HMAC)或全表解密 |
| 密钥轮换复杂 | 要重刷历史数据,或长期保留旧 keyId |
9. 排错:六个不该出现的写法
| 写法 | 问题 | 改法 |
|---|---|---|
MD5(password) 存密码 |
快哈希,彩虹表秒破 | 换 bcrypt/argon2id |
| 全库共用一个盐 | 相同密码同哈希 | 每用户随机盐,交给库生成 |
密钥写在 application.yml 并提交 |
仓库即密钥泄露 | KMS/环境变量,配置文件只留引用 |
new Random() 生成 token |
可预测 | SecureRandom |
AES/ECB/PKCS5Padding |
相同块相同密文,结构泄露 | AES/GCM/NoPadding |
| 加密无完整性校验 | 密文可被篡改,解密出可控数据 | AEAD 或 Encrypt-then-MAC |
另一条易忽略的:"加密后仍可被重放"。加密解决机密性,不解决"是否已执行过"。防重放要 nonce + 时间窗 + 服务端去重,与加密是两件事。
10. 边界:什么时候不需要自己写这些
| 场景 | 建议 |
|---|---|
| 用户密码 | 用框架的 PasswordEncoder,不要自己组合算法 |
| 存储第三方凭证(如支付密钥) | 交给 KMS/密钥托管,应用只拿句柄 |
| 传输机密性 | TLS 已覆盖,不要再叠一层自研加密 |
| 需要"可搜索加密" | 先确认业务是否真需要,多数情况用盲索引 + 精确匹配即可 |
| 合规要求特定算法 | 按规范选(如 PBKDF2/FIPS),并把参数写进文档 |
一句话收口:密码用慢哈希(带独立盐与可升级参数),数据用 AEAD(随机 nonce),密钥不落代码且能轮换,随机数只用 SecureRandom。
11. 自测
- 哈希与加密的本质区别是什么,"加密存密码、登录解密比对"错在哪?
- 为什么 SHA-256 加盐仍然不能用于密码存储?
bcrypt的 72 字节限制会带来什么实际后果,怎么绕开?- GCM 的 nonce 复用为什么比 CBC 的 IV 复用更致命?
- 为什么 Encrypt-then-MAC 要先验 MAC 再解密?
new Random()和SecureRandom在生成 token 时的差别是什么?