数字信封与密钥协商: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 个密码套件:
| 套件 | AEAD | Hash | 状态 |
|---|---|---|---|
| TLS_AES_128_GCM_SHA256 | AES-128-GCM | SHA-256 | 必须支持 |
| TLS_AES_256_GCM_SHA384 | AES-256-GCM | SHA-384 | 推荐 |
| TLS_CHACHA20_POLY1305_SHA256 | ChaCha20-Poly1305 | SHA-256 | 移动端推荐 |
| TLS_AES_128_CCM_SHA256 | AES-128-CCM | SHA-256 | IoT |
| TLS_AES_128_CCM_8_SHA256 | AES-128-CCM-8 | SHA-256 | IoT |
所有套件都强制使用 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.3 | TLCP |
|---|---|
| ECDHE (P-256) | SM2 密钥交换 |
| AES-128-GCM | SM4-GCM |
| SHA-256 | SM3 |
| ECDSA/RSA 证书 | SM2 证书(双证书体系) |
TLCP 的特殊之处:使用双证书体系,签名证书和加密证书分离,加密证书的私钥可由 CA 托管(满足监管要求)。
五、前向安全(Forward Secrecy)
5.1 什么是前向安全
非前向安全(RSA 密钥交换):
客户端用服务器 RSA 公钥加密 PreMasterSecret 发给服务端
↓
攻击者录制所有流量
↓
若干年后服务器私钥泄露
↓
攻击者解密 PreMasterSecret → 解密所有历史流量 ❌
前向安全(ECDHE 密钥交换):
每次连接生成新的临时密钥对
共享密钥 = ECDH(临时私钥A, 临时公钥B)
临时私钥用完即销毁
↓
即使长期私钥泄露,攻击者也无法得到临时私钥
↓
历史流量仍然安全 ✅5.2 实现前向安全的关键
- 使用临时密钥对(Ephemeral),不复用
- 会话密钥不从长期密钥直接派生
- 临时私钥用完马上从内存中擦除
// 擦除敏感数据
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 数据加密 |