2760 字
约 9 分钟
19
S27-X18 · 密码存储与加密:该用哪种原语

密码学在业务里的失败,多数不是算法被攻破,而是原语选错了位置:该用慢哈希的地方用了快哈希,该用随机 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. 自测

  1. 哈希与加密的本质区别是什么,"加密存密码、登录解密比对"错在哪?
  2. 为什么 SHA-256 加盐仍然不能用于密码存储?
  3. bcrypt 的 72 字节限制会带来什么实际后果,怎么绕开?
  4. GCM 的 nonce 复用为什么比 CBC 的 IV 复用更致命?
  5. 为什么 Encrypt-then-MAC 要先验 MAC 再解密?
  6. new Random() 和 SecureRandom 在生成 token 时的差别是什么?
S27-X18 · 密码存储与加密:该用哪种原语
https://www.clxhxhhr.top/posts/5922/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。