国密实战(三):SM2+SM4 数字信封完整实现(对接国企实战)
作者:林 | 系列:国密实战 | 适合读者:Java 后端/架构师
一、背景与问题
在国企、央企、政府项目的接口对接中,数据传输的安全性要求通常采用数字信封(Digital Envelope)机制:使用非对称算法加密对称密钥,再用对称密钥加密业务数据。这既保证了密钥传输的安全性,又兼顾了大数据量加密的性能。
在国密体系中,这个组合就是 SM2 + SM4:
- SM4:对称加密算法,负责加密业务数据(高效)
- SM2:非对称加密算法,负责加密 SM4 密钥(安全)
1.1 为什么需要数字信封?
| 方案 | 安全性 | 性能 | 适用场景 |
|---|---|---|---|
| 直接 SM2 加密数据 | ✅ 高 | ❌ 差(数据量受限) | 小数据(如密钥) |
| 直接 SM4 加密数据 | ❌ 密钥如何安全传输? | ✅ 好 | 需要解决密钥分发问题 |
| SM2+SM4 数字信封 | ✅ 高 | ✅ 好 | 大数据量 + 高安全性 |
SM2 加密的数据长度受限,上限明文长度约为 256 字节(受椭圆曲线参数限制)。不适合直接加密业务数据。而 SM4 虽然性能好,但密钥需要安全传输。数字信封完美结合了两者的优势。
SM2 加密数据长度计算:密文长度 = 65(C1,临时公钥点)+ 32(C3,SM3 哈希)+ n = 97 + n bytes。其中 n 为明文长度。加密 16 字节 SM4 密钥时,密文为 97 + 16 = 113 字节。
1.2 典型业务场景
某国企要求接口数据采用国密数字信封加密传输:
发送方 接收方
│ │
│ 1. 生成随机 SM4 密钥 K │
│ 2. SM4(K) 加密业务数据 → 密文 C │
│ 3. SM2(接收方公钥) 加密 K → 加密密钥 E│
│ 4. DER 编码 (E + C) │
│ 5. 发送 ─────────────────────────→│
│ 6. ASN.1 解码 │
│ 7. SM2(接收方私钥) 解密 E → K │
│ 8. SM4(K) 解密 C → 明文 │
│ │1.3 SM4 算法简介
SM4 是国家密码管理局发布的分组密码算法,标准编号 GM/T 0002:
| 属性 | 值 |
|---|---|
| 分组长度 | 128 bit(16 字节) |
| 密钥长度 | 128 bit(16 字节) |
| 轮数 | 32 轮 |
| 结构 | 非平衡 Feistel 网络 |
| 安全性 | 约等价于 AES-128 |
SM4 密钥长度固定:128 bits = 16 bytes = 32 hex chars
二、方案分析
2.1 加密流程详解
步骤1: 生成 SM4 密钥
K = SecureRandom(16 字节) // 每次加密重新生成
步骤2: SM4 加密业务数据
C = SM4_ECB_Encrypt(plaintext, K)
注:ECB 模式简单,适合单块数据;大文件建议 CBC 模式
步骤3: SM2 加密 SM4 密钥
E = SM2_Encrypt(K, receiverPublicKey)
密文格式: C1C3C2(65 + 32 + 16 = 113 字节)
步骤4: ASN.1 DER 编码
将 E 和 C 封装为 DER 结构2.2 解密流程详解
步骤1: ASN.1 DER 解码
从 DER 编码中提取 E(加密的 SM4 密钥)和 C(加密数据)
步骤2: SM2 解密得到 SM4 密钥
K = SM2_Decrypt(E, receiverPrivateKey)
步骤3: SM4 解密业务数据
plaintext = SM4_ECB_Decrypt(C, K)2.3 ASN.1 DER 编码结构
数字信封的 DER 编码结构:
DigitalEnvelope ::= SEQUENCE {
encryptedKey OCTET STRING, -- SM2 加密后的 SM4 密钥(113 字节)
encryptedData OCTET STRING -- SM4 加密后的业务数据
}对应 DER 编码示意:
30 xx -- SEQUENCE(外层容器)
04 71 [113字节] -- OCTET STRING: SM2 密文(加密的 SM4 密钥)
04 xx [N字节] -- OCTET STRING: SM4 密文(加密的业务数据)DER 编码长度计算:外层 SEQUENCE 长度 = 2 + (2 + 113) + (2 + len(C))。其中每个 04 xx 标签 + 长度占 2 字节,外层 30 xx 占 2 字节。
三、完整实现代码
3.1 Maven 依赖
<dependencies>
<!-- BouncyCastle 核心加密库(推荐 jdk18on) -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.84</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.84</version>
</dependency>
</dependencies>gmhelper 是一个简化国密操作的第三方库,但它更新较慢且可能与新版 BC 冲突。本文方案纯用 BouncyCastle 实现,无额外依赖,更可控。3.2 SM4 加密工具类
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.Security;
/**
* SM4 对称加密工具类
*
* 支持 ECB/CBC 模式,默认 PKCS5Padding 填充。
* SM4 密钥固定 128 位(16 字节),分组长度 128 位。
*/
public class Sm4Util {
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
/** SM4 算法名称 */
private static final String ALGORITHM = "SM4";
/** ECB 模式变换(适合单块小数据) */
private static final String ECB_TRANSFORMATION = "SM4/ECB/PKCS5Padding";
/** CBC 模式变换(适合大数据,需要 IV) */
private static final String CBC_TRANSFORMATION = "SM4/CBC/PKCS5Padding";
/**
* SM4 ECB 加密
*
* @param plaintext 明文字节数组
* @param key SM4 密钥(必须 16 字节)
* @return 密文字节数组
* @throws Exception 加密异常
*/
public static byte[] encryptEcb(byte[] plaintext, byte[] key) throws Exception {
validateKey(key);
SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
Cipher cipher = Cipher.getInstance(ECB_TRANSFORMATION, "BC");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return cipher.doFinal(plaintext);
}
/**
* SM4 ECB 解密
*
* @param ciphertext 密文字节数组
* @param key SM4 密钥(必须 16 字节)
* @return 明文字节数组
* @throws Exception 解密异常
*/
public static byte[] decryptEcb(byte[] ciphertext, byte[] key) throws Exception {
validateKey(key);
SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
Cipher cipher = Cipher.getInstance(ECB_TRANSFORMATION, "BC");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
return cipher.doFinal(ciphertext);
}
/**
* SM4 CBC 加密(需要 IV)
*
* @param plaintext 明文
* @param key SM4 密钥(16 字节)
* @param iv 初始化向量(16 字节)
* @return 密文
*/
public static byte[] encryptCbc(byte[] plaintext, byte[] key, byte[] iv) throws Exception {
validateKey(key);
if (iv == null || iv.length != 16) {
throw new IllegalArgumentException("IV 必须为 16 字节");
}
SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance(CBC_TRANSFORMATION, "BC");
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
return cipher.doFinal(plaintext);
}
/**
* SM4 CBC 解密
*/
public static byte[] decryptCbc(byte[] ciphertext, byte[] key, byte[] iv) throws Exception {
validateKey(key);
SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance(CBC_TRANSFORMATION, "BC");
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
return cipher.doFinal(ciphertext);
}
/**
* 生成随机 SM4 密钥(16 字节)
*
* @return 16 字节随机密钥
*/
public static byte[] generateKey() {
byte[] key = new byte[16];
new SecureRandom().nextBytes(key);
return key;
}
/**
* 生成随机 IV(16 字节,CBC 模式使用)
*/
public static byte[] generateIv() {
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);
return iv;
}
/**
* 校验密钥长度
*/
private static void validateKey(byte[] key) {
if (key == null || key.length != 16) {
throw new IllegalArgumentException(
"SM4 密钥必须为 16 字节,当前: " + (key == null ? "null" : key.length + " 字节"));
}
}
}3.3 SM2 加密工具类
import org.bouncycastle.crypto.InvalidCipherTextException;
import org.bouncycastle.crypto.engines.SM2Engine;
import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.crypto.params.ParametersWithRandom;
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey;
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
import java.security.SecureRandom;
/**
* SM2 加密解密工具类
*
* 使用 BouncyCastle SM2Engine,固定 C1C3C2 模式(新国密标准推荐)。
* SM2 加密数据长度受限,通常只用于加密 SM4 密钥等小数据。
*/
public class Sm2CryptoUtil {
/**
* SM2 加密(C1C3C2 模式)
*
* @param data 待加密数据(通常为 16 字节 SM4 密钥)
* @param publicKey SM2 公钥(BCECPublicKey)
* @return SM2 密文字节数组(C1C3C2 格式)
* @throws InvalidCipherTextException 加密异常
*/
public static byte[] encrypt(byte[] data, BCECPublicKey publicKey) throws InvalidCipherTextException {
// 从 JCE 公钥对象提取 BC 加密引擎所需的参数
ECPublicKeyParameters pubKeyParams = new ECPublicKeyParameters(
publicKey.getQ(),
new ECDomainParameters(
publicKey.getParameters().getCurve(),
publicKey.getParameters().getG(),
publicKey.getParameters().getN(),
publicKey.getParameters().getH()
)
);
// 使用 C1C3C2 模式(新国密标准推荐,ISO/IEC 18033-2 兼容)
SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
// 必须使用 ParametersWithRandom 包装,确保每次加密使用新的随机数
engine.init(true, new ParametersWithRandom(pubKeyParams, new SecureRandom()));
return engine.processBlock(data, 0, data.length);
}
/**
* SM2 解密(C1C3C2 模式)
*
* @param cipherData SM2 密文(C1C3C2 格式,通常 113 字节)
* @param privateKey SM2 私钥(BCECPrivateKey)
* @return 解密后的数据(通常为 16 字节 SM4 密钥)
* @throws InvalidCipherTextException 解密异常
*/
public static byte[] decrypt(byte[] cipherData, BCECPrivateKey privateKey) throws InvalidCipherTextException {
ECPrivateKeyParameters privKeyParams = new ECPrivateKeyParameters(
privateKey.getD(),
new ECDomainParameters(
privateKey.getParameters().getCurve(),
privateKey.getParameters().getG(),
privateKey.getParameters().getN(),
privateKey.getParameters().getH()
)
);
SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
engine.init(false, privKeyParams);
return engine.processBlock(cipherData, 0, cipherData.length);
}
}3.4 数字信封核心实现
import org.bouncycastle.asn1.*;
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey;
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.spec.ECParameterSpec;
import org.bouncycastle.jce.spec.ECPrivateKeySpec;
import org.bouncycastle.jce.spec.ECPublicKeySpec;
import org.bouncycastle.math.ec.ECPoint;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.Security;
/**
* SM2+SM4 数字信封
*
* 加密流程:
* 1. 生成随机 SM4 密钥(16 字节)
* 2. SM4 加密业务数据
* 3. SM2 加密 SM4 密钥(使用接收方公钥)
* 4. ASN.1 DER 编码(加密密钥 + 加密数据)
*
* 解密流程:
* 1. ASN.1 DER 解码(提取加密密钥和加密数据)
* 2. SM2 解密得到 SM4 密钥(使用接收方私钥)
* 3. SM4 解密业务数据
*
* DER 编码结构:
* SEQUENCE {
* encryptedKey OCTET STRING, -- SM2 加密的 SM4 密钥(113 字节)
* encryptedData OCTET STRING -- SM4 加密的业务数据
* }
*/
public class DigitalEnvelope {
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
// ============ 加密 ============
/**
* 创建数字信封(加密)
*
* @param plaintext 明文数据
* @param receiverPubKeyHex 接收方 SM2 公钥 hex(130 字符,04 开头)
* @return DER 编码的数字信封字节数组
* @throws Exception 加密或编码异常
*/
public static byte[] seal(byte[] plaintext, String receiverPubKeyHex) throws Exception {
// 1. 生成随机 SM4 密钥(每次加密必须重新生成)
byte[] sm4Key = Sm4Util.generateKey();
// 2. SM4 加密业务数据
byte[] encryptedData = Sm4Util.encryptEcb(plaintext, sm4Key);
// 3. SM2 加密 SM4 密钥(使用接收方公钥)
BCECPublicKey receiverPubKey = buildPublicKey(receiverPubKeyHex);
byte[] encryptedKey = Sm2CryptoUtil.encrypt(sm4Key, receiverPubKey);
// 4. ASN.1 DER 编码
return derEncode(encryptedKey, encryptedData);
}
/**
* 创建数字信封(加密)- 字符串版本
*
* @param plaintext 明文字符串(UTF-8 编码)
* @param receiverPubKeyHex 接收方公钥 hex
* @return DER 编码的 hex 字符串(适合网络传输)
*/
public static String sealToHex(String plaintext, String receiverPubKeyHex) throws Exception {
byte[] derBytes = seal(plaintext.getBytes(StandardCharsets.UTF_8), receiverPubKeyHex);
return bytesToHex(derBytes);
}
// ============ 解密 ============
/**
* 打开数字信封(解密)
*
* @param derEnvelope DER 编码的数字信封
* @param receiverPrivKeyHex 接收方 SM2 私钥 hex(64 字符)
* @return 解密后的明文数据
* @throws Exception 解码或解密异常
*/
public static byte[] open(byte[] derEnvelope, String receiverPrivKeyHex) throws Exception {
// 1. ASN.1 DER 解码
Asn1Data asn1Data = derDecode(derEnvelope);
// 2. SM2 解密得到 SM4 密钥
BCECPrivateKey receiverPrivKey = buildPrivateKey(receiverPrivKeyHex);
byte[] sm4Key = Sm2CryptoUtil.decrypt(asn1Data.encryptedKey, receiverPrivKey);
// 3. SM4 解密业务数据
return Sm4Util.decryptEcb(asn1Data.encryptedData, sm4Key);
}
/**
* 打开数字信封(解密)- 字符串版本
*
* @param derHex DER 编码的 hex 字符串
* @param receiverPrivKeyHex 接收方私钥 hex
* @return 解密后的明文字符串
*/
public static String openFromHex(String derHex, String receiverPrivKeyHex) throws Exception {
byte[] derBytes = hexToBytes(derHex);
byte[] plaintext = open(derBytes, receiverPrivKeyHex);
return new String(plaintext, StandardCharsets.UTF_8);
}
// ============ ASN.1 DER 编码/解码 ============
/**
* DER 编码:将加密密钥和加密数据封装为 ASN.1 SEQUENCE
*
* 结构:
* SEQUENCE {
* encryptedKey OCTET STRING, -- SM2 加密的 SM4 密钥
* encryptedData OCTET STRING -- SM4 加密的业务数据
* }
*/
private static byte[] derEncode(byte[] encryptedKey, byte[] encryptedData) throws IOException {
ASN1EncodableVector vector = new ASN1EncodableVector();
vector.add(new DEROctetString(encryptedKey));
vector.add(new DEROctetString(encryptedData));
DERSequence sequence = new DERSequence(vector);
return sequence.getEncoded();
}
/**
* DER 解码:从 ASN.1 结构中提取加密密钥和加密数据
*/
private static Asn1Data derDecode(byte[] derBytes) throws IOException {
ASN1InputStream ais = new ASN1InputStream(derBytes);
try {
// 读取外层 SEQUENCE
ASN1Sequence sequence = (ASN1Sequence) ais.readObject();
// 提取加密密钥(第一个元素)
DEROctetString encKeyOctets = (DEROctetString) sequence.getObjectAt(0);
byte[] encryptedKey = encKeyOctets.getOctets();
// 提取加密数据(第二个元素)
DEROctetString encDataOctets = (DEROctetString) sequence.getObjectAt(1);
byte[] encryptedData = encDataOctets.getOctets();
return new Asn1Data(encryptedKey, encryptedData);
} finally {
ais.close();
}
}
// ============ 密钥构建辅助方法 ============
/**
* 从 hex 构建 SM2 公钥
*
* @param hexKey 130 字符 hex 公钥(04 开头,可省略 04)
* @return BCECPublicKey 对象
*/
private static BCECPublicKey buildPublicKey(String hexKey) throws Exception {
String fullHex = hexKey;
if (!fullHex.startsWith("04")) {
fullHex = "04" + fullHex;
}
ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
ECPoint point = ecSpec.getCurve().decodePoint(hexToBytes(fullHex));
ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, ecSpec);
KeyFactory kf = KeyFactory.getInstance("EC", "BC");
return (BCECPublicKey) kf.generatePublic(pubSpec);
}
/**
* 从 hex 构建 SM2 私钥
*
* @param hexKey 64 字符 hex 私钥
* @return BCECPrivateKey 对象
*/
private static BCECPrivateKey buildPrivateKey(String hexKey) throws Exception {
String padded = String.format("%064s", hexKey).replace(' ', '0');
BigInteger d = new BigInteger(padded, 16);
ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
ECPrivateKeySpec privSpec = new ECPrivateKeySpec(d, ecSpec);
KeyFactory kf = KeyFactory.getInstance("EC", "BC");
return (BCECPrivateKey) kf.generatePrivate(privSpec);
}
// ============ 工具方法 ============
/** 字节数组转 hex 字符串 */
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
}
/** hex 字符串转字节数组 */
private static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
/**
* ASN.1 解码结果内部类
*/
private static class Asn1Data {
final byte[] encryptedKey;
final byte[] encryptedData;
Asn1Data(byte[] encryptedKey, byte[] encryptedData) {
this.encryptedKey = encryptedKey;
this.encryptedData = encryptedData;
}
}
}3.5 完整测试代码
import java.nio.charset.StandardCharsets;
/**
* SM2+SM4 数字信封完整测试
*/
public class DigitalEnvelopeTest {
public static void main(String[] args) throws Exception {
// ============ 准备密钥对 ============
// 实际项目中从证书/PFX 中提取,参考本系列第一篇文章
String receiverPrivKey = "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344";
String receiverPubKey = "04"
+ "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
+ "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344";
// 业务数据
String plaintext = "{\"orderId\":\"20240101001\",\"amount\":9999.00,\"merchant\":\"国企测试商户\"}";
System.out.println("原始明文: " + plaintext);
System.out.println("明文长度: " + plaintext.getBytes(StandardCharsets.UTF_8).length + " 字节");
// ============ 加密(发送方) ============
System.out.println("\n===== 加密 =====");
// 方式1:字节数组版本
byte[] derEnvelope = DigitalEnvelope.seal(
plaintext.getBytes(StandardCharsets.UTF_8),
receiverPubKey
);
System.out.println("DER 编码长度: " + derEnvelope.length + " 字节");
// 方式2:hex 字符串版本(适合网络传输)
String derHex = DigitalEnvelope.sealToHex(plaintext, receiverPubKey);
System.out.println("DER hex 长度: " + derHex.length() + " 字符");
System.out.println("DER hex 前 100 字符: " + derHex.substring(0, Math.min(100, derHex.length())) + "...");
// ============ 解密(接收方) ============
System.out.println("\n===== 解密 =====");
// 方式1:字节数组版本
byte[] decrypted = DigitalEnvelope.open(derEnvelope, receiverPrivKey);
String decryptedText = new String(decrypted, StandardCharsets.UTF_8);
System.out.println("解密结果: " + decryptedText);
// 方式2:hex 字符串版本
String decryptedFromHex = DigitalEnvelope.openFromHex(derHex, receiverPrivKey);
System.out.println("解密结果(hex): " + decryptedFromHex);
// 验证
boolean match = plaintext.equals(decryptedText);
System.out.println("\n解密验证: " + (match ? "✅ 成功" : "❌ 失败"));
// ============ 大数据测试 ============
System.out.println("\n===== 大数据测试 =====");
StringBuilder largeData = new StringBuilder();
for (int i = 0; i < 1000; i++) {
largeData.append("测试数据行").append(i).append("\n");
}
String largePlaintext = largeData.toString();
int largeSize = largePlaintext.getBytes(StandardCharsets.UTF_8).length;
System.out.println("大数据长度: " + largeSize + " 字节 (" + String.format("%.2f", largeSize / 1024.0) + " KB)");
byte[] largeEnvelope = DigitalEnvelope.seal(
largePlaintext.getBytes(StandardCharsets.UTF_8),
receiverPubKey
);
byte[] largeDecrypted = DigitalEnvelope.open(largeEnvelope, receiverPrivKey);
boolean largeMatch = largePlaintext.equals(new String(largeDecrypted, StandardCharsets.UTF_8));
System.out.println("大数据解密验证: " + (largeMatch ? "✅ 成功" : "❌ 失败"));
}
}四、加密过程注意事项
4.1 SM2 密文的 04 前缀
SM2 加密输出的原始密文是 C1C3C2 格式的字节数组,其中 C1 部分以 04 开头(未压缩点格式)。在进行 ASN.1 DER 编码时,直接使用 SM2Engine 输出的原始字节数组即可,不需要去掉 04 前缀。
但如果你遇到对方系统要求去掉 04 前缀再编码的情况:
// 如果对方要求去掉 04 前缀
byte[] sm2Cipher = Sm2CryptoUtil.encrypt(sm4Key, pubKey);
// 去掉第一个字节 04
byte[] cipherWithoutPrefix = new byte[sm2Cipher.length - 1];
System.arraycopy(sm2Cipher, 1, cipherWithoutPrefix, 0, cipherWithoutPrefix.length);
// 再进行 DER 编码
byte[] der = derEncode(cipherWithoutPrefix, encryptedData);Invalid point encoding 错误。4.2 SM4 模式选择
| 模式 | 特点 | 适用场景 | 安全性 |
|---|---|---|---|
| ECB | 简单,相同明文产生相同密文,无需 IV | 单块小数据(如数字信封中的业务报文) | ⭐⭐ |
| CBC | 需要 IV,相同明文产生不同密文 | 大文件、流式数据 | ⭐⭐⭐ |
| GCM | 认证加密(AEAD),同时保证机密性和完整性 | 高安全要求场景 | ⭐⭐⭐⭐ |
本文示例使用 ECB 模式(简单场景),生产环境建议:
- 大数据量使用 CBC 模式,IV 随每次加密随机生成并附在密文前面
- 高安全要求使用 GCM 模式,同时提供加密和认证
4.3 SM4 密钥必须是 16 字节
SM4 密钥固定 128 位(16 字节),不能类似 AES 一样用 256 位密钥。确保生成的随机密钥长度正确。
// ❌ 错误:密钥长度不对
byte[] key = new byte[32]; // SM4 不支持 32 字节密钥
// ✅ 正确:SM4 密钥固定 16 字节
byte[] key = Sm4Util.generateKey(); // 16 字节五、解密过程注意事项
5.1 先 DER 解码再 SM2 解密
接收到 DER 编码的数字信封后,必须先进行 ASN.1 解码,提取出 SM2 密文和 SM4 密文,再分别解密。
// ❌ 错误:直接把 DER 字节丢给 SM2 解密
byte[] result = Sm2CryptoUtil.decrypt(derBytes, privKey); // 报错!
// ✅ 正确:先 DER 解码
Asn1Data data = derDecode(derBytes);
byte[] sm4Key = Sm2CryptoUtil.decrypt(data.encryptedKey, privKey);
byte[] plaintext = Sm4Util.decryptEcb(data.encryptedData, sm4Key);5.2 SM2 密文格式必须匹配
加密时使用 C1C3C2 模式,解密时也必须使用 C1C3C2 模式。如果格式不匹配,解密会报错或得到乱码。
// 加密和解密都必须指定相同的模式
SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); // 两端一致5.3 异常处理与排查
数字信封解密可能遇到的异常:
| 异常 | 原因 | 解决方案 |
|---|---|---|
InvalidCipherTextException | SM2 密文格式错误或密钥不匹配 | 检查密文和密钥是否配对 |
IOException / ASN1Exception | DER 编码格式错误 | 检查 ASN.1 结构是否完整 |
IllegalArgumentException | 公钥点不在曲线上 | 检查公钥格式(04 前缀) |
BadPaddingException | SM4 填充方式不匹配 | 两端统一使用 PKCS5Padding |
| 中文乱码 | 字符编码不一致 | 加密前和解密后都显式使用 UTF-8 |
六、踩坑总结
坑 1:ASN.1 编码问题
现象:ASN1Exception: corrupted stream - malformed integer
原因:SM2 密文中的字节被错误地当作有符号整数处理,某些字节值(> 127)被解释为负数。
解决方案:使用 DEROctetString 而不是 DERInteger 来封装密文字节。
// ❌ 错误:用 INTEGER 封装密文字节
vector.add(new ASN1Integer(encryptedKey)); // 可能报错
// ✅ 正确:用 OCTET STRING 封装
vector.add(new DEROctetString(encryptedKey));坑 2:Invalid point encoding 0x30
现象:Invalid point encoding 0x30
原因:把 DER 编码的密文(以 0x30 SEQUENCE 开头)当作原始 SM2 密文来解码。
排查步骤:
| 步骤 | 检查项 |
|---|---|
| 1 | 确认输入是原始 SM2 密文还是 DER 编码 |
| 2 | 如果是 DER,先 ASN.1 解码提取 SM2 密文 |
| 3 | 检查 hex 长度:SM2 密文应为 226 hex 字符(113 字节) |
| 4 | 检查首字节:原始密文以 04 开头,DER 以 30 开头 |
坑 3:BigInteger 前导零问题
现象:SM2 解密时 InvalidCipherTextException
原因:私钥 hex 不足 64 字符,转 BigInteger 后丢失前导零。
解决方案:始终补齐到 64 字符。
String padded = String.format("%064s", hexKey).replace(' ', '0');
BigInteger d = new BigInteger(padded, 16);坑 4:SM4 填充方式不匹配
现象:SM4 解密后末尾有多余字节,或报 BadPaddingException
原因:加密和解密使用了不同的填充方式。
解决方案:两端统一使用 SM4/ECB/PKCS5Padding。
坑 5:字符编码不一致
现象:中文解密后乱码
原因:加密时和解密后的字符串编码不一致。
解决方案:加密前和解密后都显式指定 UTF-8 编码。
// 加密
byte[] plaintext = message.getBytes(StandardCharsets.UTF_8);
// 解密
String result = new String(decrypted, StandardCharsets.UTF_8);坑 6:SM4 密钥复用
现象:安全性下降(相同明文产生相同密文)
原因:多次加密使用了相同的 SM4 密钥。
解决方案:每次加密都必须生成新的随机 SM4 密钥。
// ❌ 错误:复用密钥
byte[] sm4Key = Sm4Util.generateKey();
byte[] cipher1 = Sm4Util.encryptEcb(data1, sm4Key);
byte[] cipher2 = Sm4Util.encryptEcb(data2, sm4Key); // 安全隐患!
// ✅ 正确:每次重新生成
byte[] sm4Key1 = Sm4Util.generateKey();
byte[] sm4Key2 = Sm4Util.generateKey();七、前后端联调实战
7.1 接口对接流程
某国企接口规范示例:
请求格式:
POST /api/data/exchange
Content-Type: application/json
{
"appId": "YOUR_APP_ID",
"timestamp": 1704067200000,
"data": "<DER编码的hex字符串>",
"sign": "<SM2签名的hex字符串>"
}
响应格式:
{
"code": "0000",
"message": "success",
"data": "<DER编码的hex字符串>"
}7.2 发送方代码示例
/**
* 构造加密请求
*/
public Map<String, String> buildEncryptedRequest(String bizData, String partnerPubKey) throws Exception {
// 1. 用数字信封加密业务数据
String encryptedData = DigitalEnvelope.sealToHex(bizData, partnerPubKey);
// 2. 构造待签名内容(按字典序拼接参数)
long timestamp = System.currentTimeMillis();
String appId = "YOUR_APP_ID";
String signContent = "appId=" + appId + "&data=" + encryptedData + "×tamp=" + timestamp;
// 3. SM2 签名
String sign = Sm2SignUtil.sign(ownPrivateKey, signContent);
// 4. 组装请求
Map<String, String> request = new HashMap<>();
request.put("appId", appId);
request.put("timestamp", String.valueOf(timestamp));
request.put("data", encryptedData);
request.put("sign", sign);
return request;
}7.3 接收方代码示例
/**
* 解密并验证请求
*/
public String processEncryptedRequest(Map<String, String> request, String partnerPubKey) throws Exception {
// 1. 验签
String appId = request.get("appId");
String data = request.get("data");
String timestamp = request.get("timestamp");
String sign = request.get("sign");
String signContent = "appId=" + appId + "&data=" + data + "×tamp=" + timestamp;
boolean valid = Sm2SignUtil.verify(partnerPubKey, signContent, sign);
if (!valid) {
throw new SecurityException("签名验证失败");
}
// 2. 检查时间戳(5 分钟内有效)
long reqTime = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - reqTime) > 5 * 60 * 1000) {
throw new SecurityException("请求已过期");
}
// 3. 解密数字信封
return DigitalEnvelope.openFromHex(data, ownPrivateKey);
}八、性能验证
数字信封基准应拆分随机数生成、SM4 数据加密、SM2 密钥封装、DER 编解码和 Base64/Hex 转换。分别测试短消息延迟、大消息吞吐、并发下的 P99 与内存分配,并记录密码提供者、曲线参数、模式、填充、硬件和 JDK。应用层还要把 KMS 或 HSM 取钥延迟计算在内,不能用本地纯 CPU 的平均值推断生产耗时。
九、安全建议
- SM4 密钥必须随机生成:每次加密都应生成新的随机 SM4 密钥,不能复用
- 密钥不能硬编码:SM4 密钥是临时的,随每次加密随机生成,用完即弃
- SM2 密钥对要安全存储:私钥应存储在 HSM 或加密的 KeyStore 中,不能明文保存
- 传输使用 HTTPS:数字信封提供应用层加密,但传输层仍建议使用 TLS
- 记录操作日志:加密解密操作应记录审计日志(脱敏),便于问题排查
- 防重放攻击:请求中加入时间戳 + nonce,后端校验时效性
- CBC 模式 IV 管理:使用 CBC 模式时,IV 必须随机生成且不可预测,建议将 IV 附在密文前面一起传输
十、总结与成熟实践
10.1 技术选型建议
| 组件 | 推荐方案 | 备注 |
|---|---|---|
| SM2 加密/解密 | BouncyCastle SM2Engine | 成熟稳定,支持 C1C3C2 |
| SM4 加密/解密 | BouncyCastle JCE Provider | 标准 JCE 接口 |
| ASN.1 编码 | BouncyCastle ASN1 库 | 功能完整,API 友好 |
| 简化封装 | 本文代码(纯 BC 实现) | 无额外依赖,可控 |
10.2 核心检查清单
- SM4 密钥每次加密随机生成
- SM2 密文格式统一使用 C1C3C2
- SM4 填充方式统一使用 PKCS5Padding
- 字符编码统一使用 UTF-8
- DER 编码使用 DEROctetString(非 ASN1Integer)
- 私钥 hex 补齐 64 字符
- 公钥 hex 包含 04 前缀
- BouncyCastle Provider 已注册
- BC 版本统一(推荐 jdk18on 1.84+)
十一、参考资料
- GM/T 0003.4-2012 SM2 公钥加密算法
- GM/T 0002-2012 SM4 分组密码算法
- BouncyCastle SM2 文档
- RFC 8998 - SM2 Encryption and Signature Algorithms
- ASN.1 编码详解
系列文章: