← 返回

国密实战(三):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?
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);
坑点:去掉 04 前缀后解密方也要做对应处理
如果发送方去掉了 04 前缀,接收方在 DER 解码后、SM2 解密前,需要补回 04 前缀,否则解密会报 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 异常处理与排查

数字信封解密可能遇到的异常:

异常原因解决方案
InvalidCipherTextExceptionSM2 密文格式错误或密钥不匹配检查密文和密钥是否配对
IOException / ASN1ExceptionDER 编码格式错误检查 ASN.1 结构是否完整
IllegalArgumentException公钥点不在曲线上检查公钥格式(04 前缀)
BadPaddingExceptionSM4 填充方式不匹配两端统一使用 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

坑点:PKCS5Padding 与 PKCS7Padding 的区别
在 SM4(128 位分组)场景下,PKCS5Padding 和 PKCS7Padding 的行为完全相同(都是填充 1-16 字节)。但不同库可能使用不同名称,确保两端配置一致即可。

坑 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 + "&timestamp=" + 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 + "&timestamp=" + 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 的平均值推断生产耗时。


九、安全建议

  1. SM4 密钥必须随机生成:每次加密都应生成新的随机 SM4 密钥,不能复用
  2. 密钥不能硬编码:SM4 密钥是临时的,随每次加密随机生成,用完即弃
  3. SM2 密钥对要安全存储:私钥应存储在 HSM 或加密的 KeyStore 中,不能明文保存
  4. 传输使用 HTTPS:数字信封提供应用层加密,但传输层仍建议使用 TLS
  5. 记录操作日志:加密解密操作应记录审计日志(脱敏),便于问题排查
  6. 防重放攻击:请求中加入时间戳 + nonce,后端校验时效性
  7. 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+)

十一、参考资料


系列文章