对称加密:AES 与 SM4 模式选择指南
作者:林 | 系列:密码学 | 适合读者:后端开发 / 安全工程师
一、对称加密基础
对称加密使用同一把密钥进行加密和解密。当前主流的两个分组密码算法:
| 属性 | AES | SM4 |
|---|---|---|
| 全称 | Advanced Encryption Standard | 国密分组密码算法 |
| 标准 | NIST FIPS 197 (2001) | GM/T 0002 (2012) |
| 分组长度 | 128 bit | 128 bit |
| 密钥长度 | 128 / 192 / 256 bit | 128 bit |
| 轮数 | 10 / 12 / 14 | 32 |
| 结构 | SPN (代换-置换网络) | 非平衡 Feistel |
| 安全性 | 无已知实际攻击 | 无已知实际攻击 |
两者安全性等价(AES-128 ≈ SM4),区别在于合规要求:信创项目用 SM4,其他场景用 AES。
二、加密模式:比算法本身更重要
分组密码一次只能处理一个固定长度的数据块(16 字节)。当明文超过 16 字节时,加密模式决定了如何处理多个分组。
模式选错,算法再强也白搭。
2.1 ECB:千万别用
ECB(Electronic Codebook)每个分组独立加密:
明文: [Block1] [Block2] [Block3] [Block4]
│ │ │ │
▼ ▼ ▼ ▼
E(K) E(K) E(K) E(K)
│ │ │ │
▼ ▼ ▼ ▼
密文: [Cipher1] [Cipher2] [Cipher3] [Cipher4]致命问题:相同的明文块产生相同的密文块。
经典案例:ECB 模式加密图片
原图:一只企鹅,轮廓清晰
ECB加密后:轮廓依然可见(相同颜色区域加密结果相同)
CBC加密后:完全看不出原图内容// ❌ 绝对不要在生产环境使用 ECB
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");唯一合理用途:加密单个分组(恰好 16 字节)的场景,比如加密一个 128 bit 的密钥。
2.2 CBC:能用但有坑
CBC(Cipher Block Chaining)前一块密文参与下一块加密,引入 IV 打破相同明文的规律:
IV ──┐
▼
[P1]─⊕──▶ E(K) ──▶ [C1]─┐
▼
[P2]──────────────────────⊕──▶ E(K) ──▶ [C2]─┐
▼
[P3]──────────────────────────────────────────⊕──▶ E(K) ──▶ [C3]import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
public class AesCbc {
public static byte[] encrypt(byte[] plaintext, byte[] key) throws Exception {
// IV 必须随机生成,每次加密不同
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, "AES"),
new IvParameterSpec(iv));
byte[] ciphertext = cipher.doFinal(plaintext);
// IV 需要和密文一起传输(IV 不需要保密)
byte[] result = new byte[16 + ciphertext.length];
System.arraycopy(iv, 0, result, 0, 16);
System.arraycopy(ciphertext, 0, result, 16, ciphertext.length);
return result;
}
public static byte[] decrypt(byte[] ivAndCiphertext, byte[] key) throws Exception {
byte[] iv = new byte[16];
System.arraycopy(ivAndCiphertext, 0, iv, 0, 16);
byte[] ciphertext = new byte[ivAndCiphertext.length - 16];
System.arraycopy(ivAndCiphertext, 16, ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "AES"),
new IvParameterSpec(iv));
return cipher.doFinal(ciphertext);
}
}CBC 的坑:Padding Oracle 攻击
CBC + PKCS5Padding 组合存在一个经典漏洞:如果服务端在解密失败时返回了不同的错误信息(“padding 错误” vs “业务错误”),攻击者可以逐字节猜测出明文。
攻击原理:
1. 攻击者篡改密文的某个字节
2. 服务端解密,如果 padding 不对 → 返回 "解密失败"
3. 如果 padding 正确但业务校验不过 → 返回 "业务错误"
4. 两种不同的响应形成了"预言机"(Oracle)
5. 攻击者最多 256×N 次请求就能还原整个明文防御:使用 GCM 模式(自带认证),或者加密后附加 HMAC(Encrypt-then-MAC)。
2.3 CTR:计数器模式
CTR(Counter)把分组密码变成流密码,加密计数器值后与明文 XOR:
Nonce+Counter=0 Nonce+Counter=1 Nonce+Counter=2
│ │ │
▼ ▼ ▼
E(K) E(K) E(K)
│ │ │
▼ ▼ ▼
[P1]──⊕──▶[C1] [P2]──⊕──▶[C2] [P3]──⊕──▶[C3]优点:
- 可并行加密(各分组独立)
- 不需要 padding(XOR 操作,密文与明文等长)
- 加密解密是同一操作
缺点:
- Nonce 绝对不能重复(重复 = 明文 XOR 泄露)
- 不提供完整性保护(密文可被篡改)
2.4 GCM:首选方案
GCM(Galois/Counter Mode)= CTR 加密 + GHASH 认证。既加密又认证,属于 AEAD(Authenticated Encryption with Associated Data)。
┌───────────────────────────────────────────────┐
│ AES-GCM │
│ │
│ 输入:明文 P, 密钥 K, Nonce(12字节), AAD │
│ 输出:密文 C, 认证标签 Tag(16字节) │
│ │
│ CTR 模式加密 → 得到密文 C │
│ GHASH(AAD, C) → 得到认证标签 Tag │
│ │
│ 解密时先验证 Tag,不通过直接拒绝(不输出明文) │
└───────────────────────────────────────────────┘import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
public class AesGcm {
private static final int GCM_TAG_LENGTH = 128; // 认证标签 128 bit
private static final int GCM_NONCE_LENGTH = 12; // Nonce 12 字节
public static byte[] encrypt(byte[] plaintext, byte[] key, byte[] aad)
throws Exception {
byte[] nonce = new byte[GCM_NONCE_LENGTH];
new SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, "AES"),
new GCMParameterSpec(GCM_TAG_LENGTH, nonce));
// AAD:附加认证数据(不加密,但参与认证)
if (aad != null) {
cipher.updateAAD(aad);
}
byte[] ciphertext = cipher.doFinal(plaintext);
// ciphertext 末尾已包含 16 字节 Tag
// 输出格式:nonce(12) + ciphertext + tag
byte[] result = new byte[GCM_NONCE_LENGTH + ciphertext.length];
System.arraycopy(nonce, 0, result, 0, GCM_NONCE_LENGTH);
System.arraycopy(ciphertext, 0, result, GCM_NONCE_LENGTH, ciphertext.length);
return result;
}
public static byte[] decrypt(byte[] nonceAndCiphertext, byte[] key, byte[] aad)
throws Exception {
byte[] nonce = new byte[GCM_NONCE_LENGTH];
System.arraycopy(nonceAndCiphertext, 0, nonce, 0, GCM_NONCE_LENGTH);
byte[] ciphertext = new byte[nonceAndCiphertext.length - GCM_NONCE_LENGTH];
System.arraycopy(nonceAndCiphertext, GCM_NONCE_LENGTH,
ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "AES"),
new GCMParameterSpec(GCM_TAG_LENGTH, nonce));
if (aad != null) {
cipher.updateAAD(aad);
}
// 如果 Tag 验证失败,会抛出 AEADBadTagException
return cipher.doFinal(ciphertext);
}
}GCM 的优势:
| 特性 | CBC | GCM |
|---|---|---|
| 加密 | ✅ | ✅ |
| 完整性认证 | ❌ 需额外 HMAC | ✅ 内置 Tag |
| 防篡改 | ❌ | ✅ |
| 并行加密 | ❌ | ✅ |
| 需要 Padding | ✅ | ❌ |
| 密文膨胀 | 上限 +16 字节 | +12(nonce) + 16(tag) 字节 |
2.5 模式选型总结
| 模式 | 推荐 | 适用场景 | 注意事项 |
|---|---|---|---|
| ECB | ❌ 禁用 | 仅单分组加密 | 相同明文→相同密文 |
| CBC | ⚠️ 遗留系统 | 兼容老系统 | 必须配合 HMAC,注意 Padding Oracle |
| CTR | ✅ | 需要并行、流式加密 | Nonce 不可重复 |
| GCM | ✅ 首选 | 新项目默认选择 | Nonce 不可重复,单密钥加密上限 2^32 条消息 |
三、IV 和 Nonce:容易出事的地方
3.1 基本概念
| 术语 | 全称 | 用途 | 长度 |
|---|---|---|---|
| IV | Initialization Vector | CBC 模式初始向量 | 16 字节(= 分组长度) |
| Nonce | Number used once | GCM/CTR 模式 | 12 字节(GCM 推荐) |
3.2 IV/Nonce 的核心规则
规则 1:同一密钥下,IV/Nonce 绝不可重复
规则 2:CBC 的 IV 必须随机且不可预测
规则 3:GCM 的 Nonce 可以用计数器(只要不重复)
规则 4:IV/Nonce 不需要保密,可以明文传输3.3 Nonce 重复的后果
GCM 模式下 Nonce 重复:
C1 = P1 ⊕ E(K, Nonce||Counter)
C2 = P2 ⊕ E(K, Nonce||Counter) ← 同样的 Nonce
C1 ⊕ C2 = P1 ⊕ P2 ← 两个明文的 XOR 直接泄露!
而且 GHASH 密钥被恢复,认证标签可以伪造
结论:Nonce 重复 = 加密彻底失效3.4 Nonce 生成策略
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 随机生成 | SecureRandom.nextBytes(12) | 通用场景,加密量 < 2^32 条 |
| 计数器 | 自增整数(需持久化) | 高吞吐,能保证不丢失状态 |
| 时间戳+序号 | timestamp(8) + counter(4) | 分布式系统 |
AES-GCM 使用 12 字节 Nonce 时,随机生成的碰撞概率在 2^32(约 43 亿)条消息后达到危险水平(生日悖论)。
如果单个密钥需要加密超过 2^32 条消息,必须:
- 轮换密钥
- 或使用 AES-GCM-SIV(nonce-misuse resistant)
- 或切换到 XChaCha20-Poly1305(24 字节 nonce,上限 2^64)
四、SM4 实战
SM4 的加密模式和 AES 完全一样(ECB/CBC/GCM 等),只是底层分组算法不同。使用 BouncyCastle 库:
4.1 SM4-GCM
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.Security;
public class Sm4Gcm {
static {
Security.addProvider(new BouncyCastleProvider());
}
private static final int GCM_TAG_LENGTH = 128;
private static final int GCM_NONCE_LENGTH = 12;
public static byte[] encrypt(byte[] plaintext, byte[] key) throws Exception {
byte[] nonce = new byte[GCM_NONCE_LENGTH];
new java.security.SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("SM4/GCM/NoPadding", "BC");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new GCMParameterSpec(GCM_TAG_LENGTH, nonce));
byte[] ciphertext = cipher.doFinal(plaintext);
byte[] result = new byte[GCM_NONCE_LENGTH + ciphertext.length];
System.arraycopy(nonce, 0, result, 0, GCM_NONCE_LENGTH);
System.arraycopy(ciphertext, 0, result, GCM_NONCE_LENGTH, ciphertext.length);
return result;
}
public static byte[] decrypt(byte[] nonceAndCiphertext, byte[] key) throws Exception {
byte[] nonce = new byte[GCM_NONCE_LENGTH];
System.arraycopy(nonceAndCiphertext, 0, nonce, 0, GCM_NONCE_LENGTH);
byte[] ciphertext = new byte[nonceAndCiphertext.length - GCM_NONCE_LENGTH];
System.arraycopy(nonceAndCiphertext, GCM_NONCE_LENGTH,
ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("SM4/GCM/NoPadding", "BC");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new GCMParameterSpec(GCM_TAG_LENGTH, nonce));
return cipher.doFinal(ciphertext);
}
}4.2 SM4-CBC(兼容旧系统)
public class Sm4Cbc {
static {
Security.addProvider(new BouncyCastleProvider());
}
public static byte[] encrypt(byte[] plaintext, byte[] key) throws Exception {
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new IvParameterSpec(iv));
byte[] ciphertext = cipher.doFinal(plaintext);
byte[] result = new byte[16 + ciphertext.length];
System.arraycopy(iv, 0, result, 0, 16);
System.arraycopy(ciphertext, 0, result, 16, ciphertext.length);
return result;
}
public static byte[] decrypt(byte[] ivAndCiphertext, byte[] key) throws Exception {
byte[] iv = new byte[16];
System.arraycopy(ivAndCiphertext, 0, iv, 0, 16);
byte[] ciphertext = new byte[ivAndCiphertext.length - 16];
System.arraycopy(ivAndCiphertext, 16, ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new IvParameterSpec(iv));
return cipher.doFinal(ciphertext);
}
}五、Padding 详解
CBC 模式要求明文长度是分组长度(16 字节)的整数倍,不足时需要填充:
5.1 PKCS#7 / PKCS#5
PKCS#7 规则:填充 N 个值为 N 的字节
明文 11 字节 → 需要填充 5 字节 → 填充 05 05 05 05 05
明文 16 字节 → 需要填充 16 字节 → 填充 10×16(整块填充)
明文 15 字节 → 需要填充 1 字节 → 填充 01
示例:
明文(hex): 48 65 6C 6C 6F ("Hello",5字节)
填充后: 48 65 6C 6C 6F 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B
↑ 11个 0x0B(16-5=11)PKCS5Padding 实际上执行的是 PKCS#7。PKCS#5 严格来说只定义了 8 字节分组的填充,但 Java 命名沿用了旧称。对 AES/SM4(16 字节分组)来说,写 PKCS5Padding 和 PKCS7Padding 效果一样。5.2 GCM 不需要 Padding
GCM 基于 CTR 模式,是流式加密,密文长度 = 明文长度。所以用 NoPadding:
Cipher.getInstance("AES/GCM/NoPadding"); // GCM 必须用 NoPadding
Cipher.getInstance("AES/CBC/PKCS5Padding"); // CBC 必须用 Padding六、密钥管理
6.1 密钥生成
import javax.crypto.KeyGenerator;
import java.security.SecureRandom;
// AES-256 密钥生成
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256, new SecureRandom());
SecretKey key = keyGen.generateKey();
// SM4 密钥生成(BouncyCastle)
KeyGenerator sm4KeyGen = KeyGenerator.getInstance("SM4", "BC");
sm4KeyGen.init(128, new SecureRandom());
SecretKey sm4Key = sm4KeyGen.generateKey();6.2 密钥存储原则
| 做法 | 评价 |
|---|---|
| 硬编码在代码中 | ❌ 风险很高,代码泄露 = 密钥泄露 |
| 配置文件明文 | ❌ 差,配置泄露 = 密钥泄露 |
| 环境变量 | ⚠️ 可接受,但进程级可见 |
| 加密配置文件(Jasypt) | ✅ 中等,主密钥仍需保护 |
| KMS(密钥管理服务) | ✅ 推荐,密钥不出 HSM |
| HSM(硬件安全模块) | ✅ 优先,物理隔离 |
6.3 密钥轮换
密钥轮换策略:
1. 新数据用新密钥加密
2. 旧数据在访问时用旧密钥解密,可选择性地重新加密
3. 密文旁边存储密钥版本号(key_version)
4. 旧密钥保留到所有旧数据迁移完成
数据库存储示例:
| id | data_enc | key_version |
| 1 | a3f8... | v1 | ← 用 key_v1 解密
| 2 | b7c2... | v2 | ← 用 key_v2 解密七、常见踩坑
坑 1:ECB 模式加密多分组数据
// ❌ 上游给的示例代码
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
// 相同明文块 → 相同密文块,模式泄露
// ✅ 至少用 CBC,推荐 GCM
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");坑 2:IV/Nonce 固定或用常量
// ❌ IV 写死
byte[] iv = "1234567890123456".getBytes(); // 每次加密都一样
// ✅ 每次随机生成
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);坑 3:密钥长度不对
// ❌ 直接用字符串当密钥
String key = "mypassword"; // 10 字节,不是 16/24/32
new SecretKeySpec(key.getBytes(), "AES"); // 报错或截断
// ✅ 用 KDF 从密码派生正确长度的密钥
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(password, salt, 310000, 256);
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
SecretKeySpec aesKey = new SecretKeySpec(keyBytes, "AES");坑 4:GCM 解密不检查 Tag
Java 的 GCM 实现会自动验证 Tag,如果 Tag 不匹配会抛 AEADBadTagException。不要 catch 后忽略这个异常:
try {
byte[] plaintext = cipher.doFinal(ciphertext);
} catch (AEADBadTagException e) {
// ❌ 不要忽略!说明数据被篡改
// ✅ 直接拒绝,记录安全日志
throw new SecurityException("密文已被篡改", e);
}坑 5:CBC 加密后没有 MAC
CBC 只提供机密性,不提供完整性。攻击者可以:
1. 翻转密文的某个 bit
2. 解密后对应明文的某个 bit 也会翻转
3. 如果服务端不校验完整性,攻击者可以可控地篡改明文
解决方案:
方案 A:Encrypt-then-MAC(先加密,再对密文算 HMAC)
方案 B:直接用 GCM(内置认证)八、性能对比方法
对称加密基准需要固定密码提供者、CPU 指令集、消息大小、AAD 大小、缓冲方式和并发度。小字段关注单次延迟与对象分配,大文件关注稳定吞吐和内存复制。AES、SM4 与 ChaCha20-Poly1305 在不同处理器上的硬件支持不同,必须读取目标 CPU 能力并在部署镜像中实测。
九、完整工具类
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.security.Security;
import java.util.Base64;
/**
* 对称加密工具类
* 支持 AES-GCM 和 SM4-GCM
*/
public class SymmetricCryptoUtil {
private static final int GCM_TAG_BITS = 128;
private static final int GCM_NONCE_BYTES = 12;
static {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
public enum Algorithm {
AES("AES", 256),
SM4("SM4", 128);
final String name;
final int keyBits;
Algorithm(String name, int keyBits) {
this.name = name;
this.keyBits = keyBits;
}
}
// 生成密钥
public static byte[] generateKey(Algorithm alg) throws Exception {
KeyGenerator keyGen = KeyGenerator.getInstance(alg.name,
alg == Algorithm.SM4 ? "BC" : null);
keyGen.init(alg.keyBits, new SecureRandom());
return keyGen.generateKey().getEncoded();
}
// GCM 加密,返回 Base64
public static String encrypt(String plaintext, byte[] key, Algorithm alg)
throws Exception {
byte[] nonce = new byte[GCM_NONCE_BYTES];
new SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance(alg.name + "/GCM/NoPadding",
alg == Algorithm.SM4 ? "BC" : null);
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, alg.name),
new GCMParameterSpec(GCM_TAG_BITS, nonce));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));
byte[] result = new byte[GCM_NONCE_BYTES + ciphertext.length];
System.arraycopy(nonce, 0, result, 0, GCM_NONCE_BYTES);
System.arraycopy(ciphertext, 0, result, GCM_NONCE_BYTES, ciphertext.length);
return Base64.getEncoder().encodeToString(result);
}
// GCM 解密
public static String decrypt(String base64Ciphertext, byte[] key, Algorithm alg)
throws Exception {
byte[] data = Base64.getDecoder().decode(base64Ciphertext);
byte[] nonce = new byte[GCM_NONCE_BYTES];
System.arraycopy(data, 0, nonce, 0, GCM_NONCE_BYTES);
byte[] ciphertext = new byte[data.length - GCM_NONCE_BYTES];
System.arraycopy(data, GCM_NONCE_BYTES, ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance(alg.name + "/GCM/NoPadding",
alg == Algorithm.SM4 ? "BC" : null);
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, alg.name),
new GCMParameterSpec(GCM_TAG_BITS, nonce));
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, "UTF-8");
}
}使用示例:
// AES-256-GCM
byte[] aesKey = SymmetricCryptoUtil.generateKey(Algorithm.AES);
String encrypted = SymmetricCryptoUtil.encrypt("敏感数据", aesKey, Algorithm.AES);
String decrypted = SymmetricCryptoUtil.decrypt(encrypted, aesKey, Algorithm.AES);
// SM4-GCM
byte[] sm4Key = SymmetricCryptoUtil.generateKey(Algorithm.SM4);
String encrypted = SymmetricCryptoUtil.encrypt("敏感数据", sm4Key, Algorithm.SM4);
String decrypted = SymmetricCryptoUtil.decrypt(encrypted, sm4Key, Algorithm.SM4);十、总结
| 要点 | 建议 |
|---|---|
| 模式选择 | 新项目用 GCM,老系统用 CBC + HMAC |
| IV/Nonce | 每次加密必须不同,随机生成更安全 |
| 密钥管理 | 不要硬编码,用 KMS 或环境变量 |
| AES vs SM4 | 信创用 SM4,其他用 AES |
| 密钥长度 | AES-256 或 SM4-128 |
| Padding | GCM 用 NoPadding,CBC 用 PKCS5Padding |