← 返回

对称加密:AES 与 SM4 模式选择指南

作者:林 | 系列:密码学 | 适合读者:后端开发 / 安全工程师


一、对称加密基础

对称加密使用同一把密钥进行加密和解密。当前主流的两个分组密码算法:

属性AESSM4
全称Advanced Encryption Standard国密分组密码算法
标准NIST FIPS 197 (2001)GM/T 0002 (2012)
分组长度128 bit128 bit
密钥长度128 / 192 / 256 bit128 bit
轮数10 / 12 / 1432
结构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 的优势

特性CBCGCM
加密
完整性认证❌ 需额外 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 基本概念

术语全称用途长度
IVInitialization VectorCBC 模式初始向量16 字节(= 分组长度)
NonceNumber used onceGCM/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)分布式系统
GCM 的加密条数限制

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)
PKCS#5 vs PKCS#7
Java 中的 PKCS5Padding 实际上执行的是 PKCS#7。PKCS#5 严格来说只定义了 8 字节分组的填充,但 Java 命名沿用了旧称。对 AES/SM4(16 字节分组)来说,写 PKCS5PaddingPKCS7Padding 效果一样。

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 能力并在部署镜像中实测。

SM4 性能说明
部分处理器和密码库提供 SM4 指令或优化实现,另一些环境只能使用通用软件路径。大文件与高并发场景要把加密、认证、内存复制和密钥获取一起纳入压测;数据库短字段也要观察 P99 和连接池占用,不能预先断言其不会成为瓶颈。

九、完整工具类

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
PaddingGCM 用 NoPadding,CBC 用 PKCS5Padding

上一篇散列函数:MD5、SHA、SM3 与 HMAC

下一篇非对称加密与签名:RSA、ECC、SM2