大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
密码学
这一课你将能做到
- 说清对称加密、非对称加密、哈希各自解决什么问题,并能判断一个场景该用哪个
- 在 Spring Boot 中用 BCrypt 安全地存储用户密码,说清为什么不能 MD5/SHA-256 裸存
- 用 Java 标准库完成 AES 加密解密、RSA 签名验签、SHA-256 摘要,并理解密钥生命周期管理
为什么要学
密码学不是数学游戏,是后端工程师的日常:用户密码怎么存?JWT 怎么签名?HTTPS 证书凭什么可信?数据库里的手机号要不要加密?答错任何一题,就是一次数据泄露事故。
人话:对称加密是「同一把钥匙开锁和上锁」——快,但钥匙怎么给对方是个问题;非对称是「信箱」——公钥是投信口人人可用,私钥是开箱钥匙只有你有,慢但解决了分发问题;哈希是「指纹」——不可逆,用来校验「东西没被动过」。
先建立图像
三种原语的分工:
┌────────────┬──────────────┬────────────┬─────────────────┐
│ │ 可逆? │ 密钥 │ 本站用途 │
├────────────┼──────────────┼────────────┼─────────────────┤
│ 对称 AES │ 可逆 │ 一把密钥 │ 敏感字段加密存储 │
│ 非对称 RSA │ 可逆 │ 公钥+私钥 │ JWT 签名、密钥交换│
│ 哈希 SHA │ 不可逆 │ 无 │ 文件完整性校验 │
│ 密码哈希 │ 不可逆(慢) │ 盐+cost │ 用户密码存储 │
│ BCrypt │ │ │ │
└────────────┴──────────────┴────────────┴─────────────────┘
一次登录的密码学全景:
用户输入密码
│
├─ HTTPS (TLS: RSA/ECDHE 协商出 AES 会话密钥) ──→ 服务器
│
└─ 服务器: BCrypt.matches(输入, 数据库存的哈希)
│ 不存明文,不存可逆加密
▼
登录成功 → 签发 JWT (用 RSA 私钥签名)
客户端后续请求带 JWT
服务器用公钥验签
跟着做
步骤 1:用户密码用 BCrypt 存(本站做法)
Spring Security 内置支持,先加依赖(spring-boot-starter-security 自带 crypto):
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class PasswordService {
// strength=12 表示 2^12 次迭代,单次哈希约 200ms+
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
public String hash(String rawPassword) {
// 自动生成随机盐,输出形如 $2a$12$N9qo8uLOickgx2ZMRZoMye...
return encoder.encode(rawPassword);
}
public boolean verify(String rawPassword, String storedHash) {
return encoder.matches(rawPassword, storedHash);
}
}
关键点:永远不要自己实现「SHA-256(密码+盐)」。BCrypt 的慢是特性——攻击者拿到数据库后,暴力破解每个密码要算 2^12 次,而 MD5 一秒能试几十亿个。
步骤 2:AES 加密敏感字段(如手机号)
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AesGcmService {
private static final int IV_LEN = 12; // GCM 推荐 96 位 IV
private final SecretKeySpec key;
public AesGcmService(byte[] keyBytes) { // 32 字节 = AES-256
this.key = new SecretKeySpec(keyBytes, "AES");
}
public String encrypt(String plaintext) throws Exception {
byte[] iv = new byte[IV_LEN];
new SecureRandom().nextBytes(iv); // 每次随机 IV,禁止复用
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
byte[] ct = cipher.doFinal(plaintext.getBytes("UTF-8"));
// 存储格式: base64(iv) + ":" + base64(密文含认证标签)
return Base64.getEncoder().encodeToString(iv) + ":"
+ Base64.getEncoder().encodeToString(ct);
}
public String decrypt(String stored) throws Exception {
String[] parts = stored.split(":");
byte[] iv = Base64.getDecoder().decode(parts[0]);
byte[] ct = Base64.getDecoder().decode(parts[1]);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
return new String(cipher.doFinal(ct), "UTF-8");
}
}
用 GCM 模式而不是 ECB/CBC:GCM 自带完整性校验(AEAD),密文被篡改会直接解密失败抛异常。
步骤 3:哈希校验文件完整性
# 发布文件时附上 SHA-256,下载方校验防篡改
certutil -hashfile app.jar SHA256 # Windows
sha256sum app.jar # Linux
Java 里算摘要:
import java.security.MessageDigest;
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(data);
步骤 4:理解密钥管理(别把密钥写死在代码里)
错误示范(无数生产事故的来源):
// 千万别这么干!密钥进 git 历史 = 已泄漏
private static final byte[] KEY = "hardcoded-key-1234".getBytes();
正确分层:
开发环境 → 环境变量 / .env (gitignore)
测试/生产 → 密钥管理服务: 阿里云 KMS / AWS KMS / HashiCorp Vault
密钥本身 → 定期轮换 (rotation),旧密钥保留一段时间用于解密历史数据
Spring Boot 从环境变量读:
# application.yml
security:
aes-key: ${AES_KEY} # 启动时从环境变量注入,不落盘不进 git
讲清楚要点
-
密码存储专用算法:BCrypt、scrypt、Argon2。它们的共同点是「故意慢+加盐」。SHA-256 再快也不是为密码设计的——快恰恰是缺点。登录接口配合限流(防在线爆破)+ BCrypt(防离线爆破)才完整。
-
加盐(Salt)防彩虹表:相同密码 + 不同盐 = 不同哈希。攻击者无法预计算。BCrypt 自动处理盐并把它存在输出里,你不需要单独存盐字段。
-
AES 模式选择:ECB 禁用(相同明文块产生相同密文块,企鹅图梗),CBC 需小心填充预言攻击,GCM 是现代首选(加密+认证一体)。IV 绝不能复用,随机生成即可(GCM 下 IV 唯一性即可,不需保密)。
-
非对称的典型用途不是加密数据:RSA 加解密慢且有长度限制。实际用法是「加密对称密钥」(数字信封)和「签名」(私钥签、公钥验,证明身份+防篡改)。JWT 的 RS256 就是 RSA 签名。
-
密钥生命周期:生成(真随机源 SecureRandom)→ 分发(绝不走明文凭据)→ 存储(KMS/HSM,不硬编码)→ 轮换(定期更换)→ 销毁。泄漏应急:立刻轮换,旧密钥标记为「仅解密」。
你可能会懵的地方
-
「哈希」和「加密」到底什么区别? 加密可逆(有密钥能还原),哈希不可逆。所以密码存哈希(连你自己都不知道用户密码),手机号存加密(业务上还要展示和拨打,必须能还原)。
-
Base64 是加密吗? 不是,是编码,任何人都能解码。把密码 Base64 后存库等于明文存储,这是真实面试翻车点和真实生产事故。
-
BCrypt 慢会不会拖垮登录接口? 单次约 100-300ms,正常登录无感。要防的是攻击者用登录接口在线爆破——用限流(同 IP 每分钟 5 次)+ 验证码 + 账号锁定解决,而不是把 BCrypt 的 cost 调低。
-
JWT 的 secret 放哪? HS256(对称):secret 放环境变量/KMS,至少 256 位随机。RS256(非对称):私钥 KMS 保管,公钥可以写在配置里分发。微服务架构推荐 RS256——验签服务不需要拿到私钥。
-
HTTPS 都加密了,数据库里手机号为什么还要加密? HTTPS 只保护传输链路。数据库加密防的是:DBA 拖库、备份文件泄漏、SQL 注入读到明文。层不同,防的人不同。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
MD5(密码) 存库 | 彩虹表秒破 | BCrypt(12) / Argon2 |
SHA-256(密码) 存库 | GPU 每秒数十亿次爆破 | BCrypt 故意慢 |
| Base64「加密」 | 编码非加密,人人可解 | AES-GCM 或 BCrypt |
| AES-ECB 模式 | 相同明文块泄露模式 | AES-GCM + 随机 IV |
| 密钥硬编码进代码/git | 仓库泄漏=密钥泄漏 | 环境变量 + KMS + 定期轮换 |
用 new Random() 生成密钥/IV | 伪随机可预测 | SecureRandom |
卡点急救
-
解密报
BadPaddingException/AEADBadTagException:三种可能——密钥错了、iv 和密文的拼接格式和加密端不一致、密文被截断/篡改。打印双方 base64 后的字节长度逐一比对。 -
跨语言 AES 对不上(Java 加密 Python 解不开):逐项核对四个参数必须完全一致:模式(GCM)、填充(NoPadding)、IV 长度(12)、认证标签长度(128 bit)。GCM 标签在 Java 里是附加在密文末尾的,别的语言可能要拆开来传。
-
BCrypt 校验偶发失败:检查密码前后是否被 trim/URL 编码改了;BCrypt 只认密码前 72 字节(超长密码会被截断,前后端行为可能不一致);确认数据库字段够长(BCrypt 输出 60 字符,列宽至少 VARCHAR(60))。
面试常问
-
密码为什么不能 MD5 存?该用什么? MD5/SHA 系列是「快哈希」,为完整性校验设计,GPU 每秒可算数十亿次,配合彩虹表可秒破常见密码。密码存储要用慢哈希:BCrypt/scrypt/Argon2,特点是可调 cost 因子(迭代次数)+ 自动加盐,把单次哈希成本提到百毫秒级,让暴力破解在经济上不可行。
-
对称和非对称加密的区别,各举个例子? 对称(AES):同一把密钥加解密,快,适合大数据量,难点是密钥分发。非对称(RSA/ECC):公钥加密私钥解密(机密性),私钥签名公钥验签(身份认证),慢,适合小数据。实际系统混用:TLS 用非对称协商出对称会话密钥,之后用对称传数据——结合两者优点。
-
JWT 安全吗?注意什么? JWT 本身只是「签名防篡改」,payload 是 Base64 可读的——别放敏感信息。注意点:算法用 RS256 而非 HS256(多服务场景);必须校验签名和 exp;密钥要够长且保密;敏感操作配合短 TTL + refresh token 轮换;禁用
alg=none和算法降级。
自测三问
- BCrypt 的「慢」为什么是优点而不是缺点?
- 什么数据用哈希存、什么数据用加密存、什么数据可以明文存?各举本站一例。
- AES-GCM 里的 IV 能复用吗?为什么?
小结一句话
密码学的工程纪律只有几条:密码用 BCrypt 慢哈希、敏感字段用 AES-GCM、签名用 RSA/ECC、密钥永不硬编码——剩下的都是细节。
延伸
- OWASP Password Storage Cheat Sheet
- 下一课:身份与访问——RBAC、最小权限、Zero Trust,以及本站 Spring Security 的权限模型设计。