← 返回

数字信封与密钥协商:SM2+SM4、ECDH、TLS

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


一、问题:对称密钥怎么安全传递

对称加密速度快,但面临一个核心问题:通信双方怎么共享同一把密钥?

方案问题
线下交换不可能每次通信都见面
明文传输密钥等于没加密
预共享密钥不灵活,N 方通信需要 N×(N-1)/2 个密钥

解决方案有两种:

  • 数字信封:用非对称加密传递对称密钥
  • 密钥协商:双方各出一部分,协商出共享密钥(不传输密钥本身)

二、数字信封

2.1 原理

发送方                                   接收方
  │                                       │
  │ 1. 随机生成对称密钥 K                   │
  │ 2. 用 K 加密业务数据:C = Enc(K, Data) │
  │ 3. 用接收方公钥加密 K:EK = Enc(PubB, K)│
  │ 4. 发送 (EK, C) ─────────────────────→│
  │                                       │
  │                 5. 用私钥解密:K = Dec(PriB, EK)
  │                 6. 用 K 解密数据:Data = Dec(K, C)
  │                                       │

好处:

  • 非对称加密只处理 16 字节的密钥(快)
  • 对称加密处理任意大小的数据(快)
  • 每次通信生成新的对称密钥(前向安全)

2.2 SM2 + SM4 数字信封实现

这是国企/央企项目常见的加密传输方案:

import cn.hutool.crypto.SmUtil;
import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.asymmetric.SM2;
import cn.hutool.crypto.symmetric.SM4;
import cn.hutool.core.util.HexUtil;
import cn.hutool.core.util.RandomUtil;

/**
 * SM2+SM4 数字信封
 */
public class DigitalEnvelope {

    /**
     * 封装(加密)
     * @param plaintext 明文数据
     * @param receiverPublicKeyHex 接收方 SM2 公钥(130 字符 hex)
     * @return EnvelopeData 包含加密后的密钥和数据
     */
    public static EnvelopeData seal(byte[] plaintext,
                                    String receiverPublicKeyHex) {
        // 1. 生成随机 SM4 密钥(16 字节)
        byte[] sm4Key = RandomUtil.randomBytes(16);

        // 2. SM4 加密业务数据
        SM4 sm4 = SmUtil.sm4(sm4Key);
        byte[] encryptedData = sm4.encrypt(plaintext);

        // 3. SM2 加密 SM4 密钥
        SM2 sm2 = SmUtil.sm2(null, receiverPublicKeyHex);
        byte[] encryptedKey = sm2.encrypt(sm4Key, KeyType.PublicKey);

        return new EnvelopeData(encryptedKey, encryptedData);
    }

    /**
     * 拆封(解密)
     * @param envelope 信封数据
     * @param receiverPrivateKeyHex 接收方 SM2 私钥(64 字符 hex)
     * @return 解密后的明文
     */
    public static byte[] open(EnvelopeData envelope,
                              String receiverPrivateKeyHex) {
        // 1. SM2 解密得到 SM4 密钥
        SM2 sm2 = SmUtil.sm2(receiverPrivateKeyHex, null);
        byte[] sm4Key = sm2.decrypt(envelope.encryptedKey, KeyType.PrivateKey);

        // 2. SM4 解密业务数据
        SM4 sm4 = SmUtil.sm4(sm4Key);
        return sm4.decrypt(envelope.encryptedData);
    }

    public static class EnvelopeData {
        public final byte[] encryptedKey;   // SM2 加密后的 SM4 密钥
        public final byte[] encryptedData;  // SM4 加密后的业务数据

        public EnvelopeData(byte[] encryptedKey, byte[] encryptedData) {
            this.encryptedKey = encryptedKey;
            this.encryptedData = encryptedData;
        }
    }
}

2.3 ASN.1 DER 编码封装

实际项目中,数字信封通常需要用 ASN.1 DER 格式封装后传输:

EnvelopedData ::= SEQUENCE {
    version          INTEGER (0),
    encryptedKey     OCTET STRING,   -- SM2 加密后的对称密钥
    encryptedData    OCTET STRING    -- SM4 加密后的业务数据
}
import org.bouncycastle.asn1.*;

public class EnvelopeDer {

    // 编码为 DER
    public static byte[] encode(byte[] encryptedKey, byte[] encryptedData)
            throws Exception {
        ASN1EncodableVector v = new ASN1EncodableVector();
        v.add(new ASN1Integer(0)); // version
        v.add(new DEROctetString(encryptedKey));
        v.add(new DEROctetString(encryptedData));
        return new DERSequence(v).getEncoded();
    }

    // 从 DER 解码
    public static EnvelopeData decode(byte[] der) throws Exception {
        ASN1Sequence seq = ASN1Sequence.getInstance(der);
        // seq[0] = version, 跳过
        byte[] encryptedKey = ASN1OctetString.getInstance(
            seq.getObjectAt(1)).getOctets();
        byte[] encryptedData = ASN1OctetString.getInstance(
            seq.getObjectAt(2)).getOctets();
        return new EnvelopeData(encryptedKey, encryptedData);
    }
}

2.4 RSA 数字信封

原理相同,把 SM2 换成 RSA:

public class RsaEnvelope {

    public static EnvelopeData seal(byte[] plaintext,
                                    PublicKey rsaPublicKey) throws Exception {
        // 1. 生成 AES-256 密钥
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(256, new SecureRandom());
        SecretKey aesKey = keyGen.generateKey();

        // 2. AES-GCM 加密数据
        byte[] nonce = new byte[12];
        new SecureRandom().nextBytes(nonce);
        Cipher aesCipher = Cipher.getInstance("AES/GCM/NoPadding");
        aesCipher.init(Cipher.ENCRYPT_MODE, aesKey,
            new GCMParameterSpec(128, nonce));
        byte[] encryptedData = aesCipher.doFinal(plaintext);
        // nonce + ciphertext
        byte[] dataWithNonce = new byte[12 + encryptedData.length];
        System.arraycopy(nonce, 0, dataWithNonce, 0, 12);
        System.arraycopy(encryptedData, 0, dataWithNonce, 12,
            encryptedData.length);

        // 3. RSA-OAEP 加密 AES 密钥
        Cipher rsaCipher = Cipher.getInstance(
            "RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
        rsaCipher.init(Cipher.ENCRYPT_MODE, rsaPublicKey);
        byte[] encryptedKey = rsaCipher.doFinal(aesKey.getEncoded());

        return new EnvelopeData(encryptedKey, dataWithNonce);
    }

    public static byte[] open(EnvelopeData envelope,
                              PrivateKey rsaPrivateKey) throws Exception {
        // 1. RSA 解密 AES 密钥
        Cipher rsaCipher = Cipher.getInstance(
            "RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
        rsaCipher.init(Cipher.DECRYPT_MODE, rsaPrivateKey);
        byte[] aesKeyBytes = rsaCipher.doFinal(envelope.encryptedKey);
        SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES");

        // 2. AES-GCM 解密数据
        byte[] nonce = new byte[12];
        System.arraycopy(envelope.encryptedData, 0, nonce, 0, 12);
        byte[] ciphertext = new byte[envelope.encryptedData.length - 12];
        System.arraycopy(envelope.encryptedData, 12, ciphertext, 0,
            ciphertext.length);

        Cipher aesCipher = Cipher.getInstance("AES/GCM/NoPadding");
        aesCipher.init(Cipher.DECRYPT_MODE, aesKey,
            new GCMParameterSpec(128, nonce));
        return aesCipher.doFinal(ciphertext);
    }
}

三、ECDH 密钥协商

3.1 原理

ECDH(Elliptic Curve Diffie-Hellman)让双方在不传输密钥的情况下协商出相同的共享密钥:

Alice                                    Bob
  │                                       │
  │ 生成临时密钥对:                        │ 生成临时密钥对:
  │   a (私钥), A = a×G (公钥)            │   b (私钥), B = b×G (公钥)
  │                                       │
  │ ──── 发送公钥 A ────────────────────→ │
  │ ←──── 发送公钥 B ─────────────────── │
  │                                       │
  │ 计算共享密钥:                          │ 计算共享密钥:
  │   S = a × B = a × (b×G)              │   S = b × A = b × (a×G)
  │      = ab×G                           │      = ab×G
  │                                       │
  │     两边算出的 S 相同!                 │
  │                                       │
  │ 用 KDF(S) 派生会话密钥                 │ 用 KDF(S) 派生会话密钥

中间人只能看到 A 和 B(公钥),无法算出 ab×G(椭圆曲线离散对数难题)。

3.2 Java 实现 ECDH

import javax.crypto.KeyAgreement;
import java.security.*;
import java.security.spec.ECGenParameterSpec;

public class EcdhExample {

    // 生成临时密钥对
    public static KeyPair generateEphemeralKeyPair() throws Exception {
        KeyPairGenerator gen = KeyPairGenerator.getInstance("EC");
        gen.initialize(new ECGenParameterSpec("secp256r1"), new SecureRandom());
        return gen.generateKeyPair();
    }

    // 执行 ECDH 密钥协商
    public static byte[] deriveSharedSecret(PrivateKey myPrivateKey,
                                            PublicKey peerPublicKey)
            throws Exception {
        KeyAgreement ka = KeyAgreement.getInstance("ECDH");
        ka.init(myPrivateKey);
        ka.doPhase(peerPublicKey, true);
        return ka.generateSecret();
    }

    // 完整流程
    public static void main(String[] args) throws Exception {
        // Alice 生成临时密钥对
        KeyPair aliceKp = generateEphemeralKeyPair();
        // Bob 生成临时密钥对
        KeyPair bobKp = generateEphemeralKeyPair();

        // 交换公钥后各自计算共享密钥
        byte[] aliceSecret = deriveSharedSecret(
            aliceKp.getPrivate(), bobKp.getPublic());
        byte[] bobSecret = deriveSharedSecret(
            bobKp.getPrivate(), aliceKp.getPublic());

        // 两边的共享密钥相同
        System.out.println(Arrays.equals(aliceSecret, bobSecret)); // true

        // 用 HKDF 从共享密钥派生 AES 会话密钥
        byte[] sessionKey = HKDF.expand(
            HKDF.extract("tls13".getBytes(), aliceSecret),
            "session key".getBytes(), 32);
    }
}

3.3 ECDHE:临时密钥的重要性

方案前向安全说明
静态 ECDH长期密钥泄露 → 所有历史通信被解密
ECDHE(Ephemeral)每次握手生成新密钥,泄露长期密钥不影响历史通信

ECDHE 的 “E” 代表 Ephemeral(临时):每次连接生成新的临时密钥对,用完即销毁。即使攻击者未来获取了服务器长期私钥,也无法解密之前录制的流量。


四、TLS 1.3 握手拆解

TLS 1.3 是目前当前公开的传输层安全协议,相比 TLS 1.2 更快更安全。

4.1 握手流程

Client                                          Server
  │                                               │
  │ ─── ClientHello ────────────────────────────→ │
  │     · 支持的密码套件列表                        │
  │     · 客户端临时公钥(ECDHE)                   │
  │     · 支持的签名算法                            │
  │                                               │
  │ ←── ServerHello + EncryptedExtensions ─────── │
  │     · 选定的密码套件                            │
  │     · 服务端临时公钥(ECDHE)                   │
  │     · 证书 + CertificateVerify                │
  │     · Finished                                │
  │                                               │
  │     此时双方已协商出会话密钥                     │
  │                                               │
  │ ─── Finished ───────────────────────────────→ │
  │                                               │
  │ ←─────── 加密的应用数据 ──────────────────→    │
  │                                               │

TLS 1.3 只需要 1-RTT(一次往返)就能完成握手,TLS 1.2 需要 2-RTT。

4.2 密码套件

TLS 1.3 只保留了 5 个密码套件:

套件AEADHash状态
TLS_AES_128_GCM_SHA256AES-128-GCMSHA-256必须支持
TLS_AES_256_GCM_SHA384AES-256-GCMSHA-384推荐
TLS_CHACHA20_POLY1305_SHA256ChaCha20-Poly1305SHA-256移动端推荐
TLS_AES_128_CCM_SHA256AES-128-CCMSHA-256IoT
TLS_AES_128_CCM_8_SHA256AES-128-CCM-8SHA-256IoT

所有套件都强制使用 ECDHE 密钥交换(前向安全),不再支持 RSA 密钥交换。

4.3 密钥派生过程

TLS 1.3 使用 HKDF 逐步派生出多个密钥:

ECDH 共享密钥
    ▼ HKDF-Extract
Early Secret
    ▼ Derive-Secret
    ▼ HKDF-Extract (+ ECDHE shared secret)
Handshake Secret ──→ client_handshake_traffic_secret
    │                  server_handshake_traffic_secret
    ▼ HKDF-Extract
Master Secret ──→ client_application_traffic_secret
                   server_application_traffic_secret
                   exporter_master_secret
                   resumption_master_secret

每个方向(客户端→服务端、服务端→客户端)使用不同的密钥,进一步限制了密钥泄露的影响范围。

4.4 国密 TLS(TLCP)

国密 TLS 协议(GB/T 38636-2020,也叫 GMSSL)使用国密算法替代国际算法:

TLS 1.3TLCP
ECDHE (P-256)SM2 密钥交换
AES-128-GCMSM4-GCM
SHA-256SM3
ECDSA/RSA 证书SM2 证书(双证书体系)

TLCP 的特殊之处:使用双证书体系,签名证书和加密证书分离,加密证书的私钥可由 CA 托管(满足监管要求)。


五、前向安全(Forward Secrecy)

5.1 什么是前向安全

非前向安全(RSA 密钥交换):
  客户端用服务器 RSA 公钥加密 PreMasterSecret 发给服务端
  攻击者录制所有流量
  若干年后服务器私钥泄露
  攻击者解密 PreMasterSecret → 解密所有历史流量 ❌

前向安全(ECDHE 密钥交换):
  每次连接生成新的临时密钥对
  共享密钥 = ECDH(临时私钥A, 临时公钥B)
  临时私钥用完即销毁
  即使长期私钥泄露,攻击者也无法得到临时私钥
  历史流量仍然安全 ✅

5.2 实现前向安全的关键

  1. 使用临时密钥对(Ephemeral),不复用
  2. 会话密钥不从长期密钥直接派生
  3. 临时私钥用完马上从内存中擦除
// 擦除敏感数据
public static void wipeBytes(byte[] data) {
    if (data != null) {
        Arrays.fill(data, (byte) 0);
    }
}

// 使用完密钥后立即擦除
byte[] sharedSecret = deriveSharedSecret(myKey, peerKey);
byte[] sessionKey = hkdf(sharedSecret);
wipeBytes(sharedSecret); // 立即擦除中间密钥
// 使用 sessionKey...
wipeBytes(sessionKey);   // 使用完也擦除

六、实战:完整的加密通信方案

结合数字信封和签名,实现一个完整的加密 + 认证通信方案:

/**
 * 安全通信方案:签名 + 数字信封
 *
 * 发送方:
 *   1. 生成随机 SM4 密钥
 *   2. SM4 加密业务数据
 *   3. SM2(接收方公钥) 加密 SM4 密钥
 *   4. SM2(发送方私钥) 对明文签名
 *   5. 打包发送:加密密钥 + 密文 + 签名
 *
 * 接收方:
 *   1. SM2(接收方私钥) 解密 SM4 密钥
 *   2. SM4 解密数据
 *   3. SM2(发送方公钥) 验签
 */
public class SecureChannel {

    public static class SecureMessage {
        public String encryptedKey;   // SM2 加密的 SM4 密钥(hex)
        public String encryptedData;  // SM4 加密的业务数据(hex)
        public String signature;      // SM2 签名(hex)
        public long timestamp;        // 时间戳
    }

    // 发送方:加密 + 签名
    public static SecureMessage send(String plaintext,
                                     String senderPrivateKeyHex,
                                     String receiverPublicKeyHex) {
        SecureMessage msg = new SecureMessage();
        msg.timestamp = System.currentTimeMillis();

        // 1. 生成随机 SM4 密钥
        byte[] sm4Key = RandomUtil.randomBytes(16);

        // 2. SM4 加密数据
        SM4 sm4 = SmUtil.sm4(sm4Key);
        msg.encryptedData = HexUtil.encodeHexStr(
            sm4.encrypt(plaintext.getBytes(StandardCharsets.UTF_8)));

        // 3. SM2 加密 SM4 密钥
        SM2 sm2Enc = SmUtil.sm2(null, receiverPublicKeyHex);
        msg.encryptedKey = HexUtil.encodeHexStr(
            sm2Enc.encrypt(sm4Key, KeyType.PublicKey));

        // 4. SM2 签名(对明文 + 时间戳签名)
        String signContent = plaintext + "|" + msg.timestamp;
        SM2 sm2Sign = SmUtil.sm2(senderPrivateKeyHex, null);
        msg.signature = HexUtil.encodeHexStr(
            sm2Sign.sign(signContent.getBytes(StandardCharsets.UTF_8)));

        return msg;
    }

    // 接收方:解密 + 验签
    public static String receive(SecureMessage msg,
                                 String receiverPrivateKeyHex,
                                 String senderPublicKeyHex) {
        // 时间戳检查(5 分钟有效)
        if (Math.abs(System.currentTimeMillis() - msg.timestamp) > 300_000) {
            throw new SecurityException("消息已过期");
        }

        // 1. SM2 解密 SM4 密钥
        SM2 sm2Dec = SmUtil.sm2(receiverPrivateKeyHex, null);
        byte[] sm4Key = sm2Dec.decrypt(
            HexUtil.decodeHex(msg.encryptedKey), KeyType.PrivateKey);

        // 2. SM4 解密数据
        SM4 sm4 = SmUtil.sm4(sm4Key);
        byte[] plainBytes = sm4.decrypt(HexUtil.decodeHex(msg.encryptedData));
        String plaintext = new String(plainBytes, StandardCharsets.UTF_8);

        // 3. 验签
        String signContent = plaintext + "|" + msg.timestamp;
        SM2 sm2Verify = SmUtil.sm2(null, senderPublicKeyHex);
        boolean valid = sm2Verify.verify(
            signContent.getBytes(StandardCharsets.UTF_8),
            HexUtil.decodeHex(msg.signature));

        if (!valid) {
            throw new SecurityException("签名验证失败");
        }

        return plaintext;
    }
}

七、密钥协商 vs 数字信封对比

特性数字信封ECDH 密钥协商
密钥传输加密后传输对称密钥不传输密钥,各自计算
前向安全取决于是否每次生成新密钥天然支持(临时密钥)
交互次数单向(发送方 → 接收方)双向(需要交换公钥)
适用场景离线消息、文件加密实时通信、TLS
认证需要额外签名可结合证书认证
国密方案SM2 加密 SM4 密钥SM2 密钥交换协议

八、常见踩坑

坑 1:SM4 密钥长度错误

// ❌ SM4 密钥必须 16 字节
byte[] key = "12345678".getBytes(); // 8 字节,报错

// ✅ 正确:16 字节
byte[] key = new byte[16];
new SecureRandom().nextBytes(key);

坑 2:SM2 密文格式不匹配

SM2 密文有两种排列:
  C1C2C3(旧标准)
  C1C3C2(新标准,BouncyCastle 默认)

如果加密方用 C1C3C2,解密方按 C1C2C3 解读 → 解密失败
确保双方约定一致,推荐统一用 C1C3C2

坑 3:ECDH 不验证对方公钥

// ❌ 直接用对方公钥做 ECDH,不验证来源
byte[] shared = ecdh(myKey, untrustedPeerKey);

// ✅ 先验证对方公钥的证书签名,再做 ECDH
verifyCertificate(peerCert, caCert);
PublicKey trustedKey = peerCert.getPublicKey();
byte[] shared = ecdh(myKey, trustedKey);

不验证公钥来源会导致中间人攻击:攻击者用自己的公钥替换真实公钥。

坑 4:数字信封不签名

数字信封只提供机密性(只有接收方能解密),不提供认证。
攻击者可以用接收方公钥封装一个恶意消息。

解决:发送方对明文做签名,接收方解密后验签。

九、总结

要点建议
数字信封非对称加密对称密钥 + 对称加密数据
密钥协商ECDHE,每次连接新密钥(前向安全)
TLS新系统用 TLS 1.3,国密用 TLCP
前向安全临时密钥用完即毁,不复用
认证信封 + 签名 = 完整的安全通信
国密方案SM2 密钥交换/加密 + SM4 数据加密

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

下一篇可搜索加密:加密数据的模糊检索方案