SM4 对称加密实战:ECB/CBC 模式与密钥安全
国密改造里用得最多的对称加密算法就是 SM4。你想想看,大部分业务数据传输都需要加密,而 SM4 的效率和安全性在国产环境里算是很稳的。不过,我发现在很多项目中,大家把注意力全放在算法调用上,密钥的管理反而成了最薄弱的环节。这一章咱们就动手实现 SM4 的 ECB 和 CBC 模式,再重点聊聊密钥怎么生成、怎么安全存。
4.1 SM4 算法快速回顾
SM4 分组长度 128 位,密钥长度也是 128 位。说白了和 AES 的结构很像,但算法细节完全不同。在 Java 里,我习惯用 BouncyCastle 或者 Hutool 来封装,省去自己写底层的麻烦。
4.2 ECB 和 CBC 模式:差异与选型
很多新手上来就问:用哪个模式好?我建议你先搞懂它们的区别。
| 对比维度 | ECB 模式 | CBC 模式 |
|---|---|---|
| 加密方式 | 每个分组独立加密 | 前一个密文参与当前分组加密 |
| 是否需要 IV | 不需要 | 需要,且每次加密必须随机 |
| 并行处理 | 支持并行 | 不支持并行 |
| 相同明文结果 | 相同密钥下结果完全一样 | 每次 IV 不同,结果不同 |
| 安全风险 | 易受重放攻击和模式识别 | 相对安全,但 IV 不能重复 |
我记得早年在一个内网项目里,团队为了简单直接用了 ECB,结果安全审计时发现多个设备发送的相同数据加密后密文一模一样,很容易被统计分析。后来我们全线改成了 CBC 模式。
4.3 密钥生成实战
密钥生成看起来就一行代码,但坑特别多。看代码:
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.KeyGenerator;
import java.security.Key;
import java.security.SecureRandom;
import java.security.Security;
Security.addProvider(new BouncyCastleProvider());
KeyGenerator keyGen = KeyGenerator.getInstance("SM4", "BC");
// 生成 128 位密钥
keyGen.init(128, new SecureRandom());
Key secretKey = keyGen.generateKey();
byte[] keyBytes = secretKey.getEncoded();
这里有两个重点:
- 随机数质量:一定要用
SecureRandom,别用Random。SecureRandom 在 Linux 下默认取/dev/random,高安全场景可以用SecureRandom.getInstanceStrong()。 - 密钥长度:SM4 固定 128 位,如果传了 256 位会抛异常。
在实际项目中,我还见过有人每次启动都新生成密钥,导致之前加密的数据全部无法解密。所以密钥生成后必须持久化存储,并且要保证唯一性。
4.4 密钥安全存储方案
我经常说:密钥暴露是最大的灾难。密钥不能硬编码,更不能明文放在配置文件中。给大家推荐几种我亲自用过的方案:
- Java KeyStore(JKS/PKCS12):适合单体应用或非容器化部署。把 SM4 密钥导入 keystore,应用启动时通过密码加载。
- 配置中心加密存储:比如 Apollo 或 Nacos,开启配置加密插件,密钥以密文形式存储在配置中心,应用启动时解密加载。
- 硬件安全模块(HSM)或 KMS:金融项目必选。密钥不出设备,应用只调用加密接口。
我曾经犯过一个低级的错:为了调试方便,把 SM4 密钥写死在 properties 文件里,并且不小心提交到了 Git。结果整个分支的密钥泄露,不得不紧急重置所有敏感数据。从那以后,我强制要求团队所有密钥必须走密钥管理服务。
下面是用 KeyStore 加载 SM4 密钥的示例(PKCS12):
// 加载 keystore
KeyStore ks = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream("sm4-keys.p12")) {
ks.load(fis, "storepass".toCharArray());
}
// 读取 SM4 密钥
Key key = ks.getKey("sm4-key-alias", "keypass".toCharArray());
// 还原为 SecretKeySpec
SecretKeySpec sm4Key = new SecretKeySpec(key.getEncoded(), "SM4");
4.5 ECB 和 CBC 模式完整实现
这里我直接给出基于 BouncyCastle 的通用加解密方法,同时兼容两种模式:
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
public class Sm4Util {
private static final String ALGORITHM = "SM4";
private static final String ECB_PADDING = "SM4/ECB/PKCS7Padding";
private static final String CBC_PADDING = "SM4/CBC/PKCS7Padding";
// ECB 加密
public static byte[] encryptEcb(byte[] data, byte[] key) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
Cipher cipher = Cipher.getInstance(ECB_PADDING, "BC");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return cipher.doFinal(data);
}
// CBC 加密(IV 随机生成)
public static byte[] encryptCbc(byte[] data, byte[] key, byte[] iv) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
Cipher cipher = Cipher.getInstance(CBC_PADDING, "BC");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
return cipher.doFinal(data);
}
// 生成随机 IV(16 字节)
public static byte[] generateIv() {
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);
return iv;
}
}
使用 CBC 时,务必将 IV 和密文一起传输(IV 不需要加密,但不能被篡改)。我习惯把 IV 拼接在密文前面,放到同一个字段里传递给接收方。
4.6 典型流程:密钥分发与加解密
下面这张图展示了在 Spring Cloud 架构中,如何通过密钥管理服务分发 SM4 密钥,并在服务间进行加密通信:
4.7 常见问题与避坑
- 填充方式:SM4 标准推荐 PKCS7 填充,但有些旧 SDK 默认无填充,需要显式指定。
- IV 管理:每次加密必须使用不同的随机 IV。我习惯把 IV 拼接在密文前 16 字节,解密时直接截取。
- 密钥轮换:定期更换密钥。在 Spring Cloud 中可以通过配置中心的动态刷新来热更新密钥。
- 性能问题:ECB 虽然可以并行加密,但若数据长度不是 16 字节整数倍,仍需要串行处理补齐。