TLS 1.3 标准握手用的是 X25519 + AES-128-GCM + SHA-256。如果我们把这些全换成 SM2 + SM4-GCM + SM3——也就是中国的国密算法——握手流程要改多少?
答案是:比你想的多。
不只是”把算法换一下”这么简单。密码套件的 ID 不同、曲线参数不同、密钥交换的点格式不同、密钥调度函数里的哈希输出长度不同、甚至证书都从一张变成了两张。如果你之前读过我写的不到 500 行 C 实现 TLS 1.3 握手,你会发现,几乎每一步都有改动。
这篇文章把标准 TLS 1.3 和国密 TLS(RFC 8998)的握手流程放在一起,从 ClientHello 的第一个字节对比到 Finished 的最后一个字节。
一、RFC 8998 概览
RFC 8998 的全称是 ShangMi (SM) Cipher Suites for TLS 1.3,发布于 2021 年 3 月。它的目标很明确:在 TLS 1.3 的框架中加入国密算法,而不是另起炉灶。
这意味着整体的握手流程——1-RTT 握手、0-RTT early data、PSK 恢复——都和标准 TLS 1.3 一样。改变的是框架里填入的”算法积木”。
新增了什么
| 类型 | 标准 TLS 1.3 | 国密 TLS(RFC 8998) |
|---|---|---|
| 密码套件 | TLS_AES_128_GCM_SHA256 (0x1301) |
TLS_SM4_GCM_SM3 (0x00C6) |
| 签名算法 | ecdsa_secp256r1_sha256 (0x0403) |
sm2sig_sm3 (0x0708) |
| 命名曲线 | x25519 (29) / secp256r1
(23) |
curveSM2 (41) |
| 哈希算法 | SHA-256(256-bit 输出) | SM3(256-bit 输出) |
| 对称加密 | AES-128-GCM(128-bit key, 128-bit tag) | SM4-GCM(128-bit key, 128-bit tag) |
| 密钥交换 | X25519 ECDHE 或 P-256 ECDHE | SM2 ECDHE |
SM3 和 SHA-256 输出长度都是 32 字节,SM4 和 AES-128 密钥长度都是 16 字节——这让很多数据结构的长度字段碰巧没有变化。但”碰巧”不等于”一样”,底层计算完全不同。
关于算法本身的对比,可以看这几篇文章: - SM3 vs SHA-256 - SM4 vs AES - SM2 vs ECDSA/X25519
不变的部分
- 协议版本号:仍然是 TLS 1.3(legacy_version = 0x0303,supported_versions 扩展中 = 0x0304)
- 握手消息结构:HandshakeType、长度字段、扩展框架完全一致
- 记录层格式:ContentType、legacy_record_version 不变
- 密钥调度的逻辑:仍然是 HKDF-Extract → Derive-Secret → HKDF-Expand-Label,只是底层哈希换了
- 状态机:握手消息的发送顺序和条件不变
二、握手流程逐步对比
下图展示了标准 TLS 1.3 和国密 TLS 的握手流程对比:
下面我们逐条消息分析。
ClientHello 差异
ClientHello 是握手的第一条消息,客户端在这里列出自己支持的所有算法。差异集中在三个扩展里:
标准 TLS 1.3 的 ClientHello(关键字段):
// cipher_suites
0x13, 0x01, // TLS_AES_128_GCM_SHA256
// Extension: supported_groups (0x000a)
0x00, 0x1d, // x25519 (29)
// Extension: signature_algorithms (0x000d)
0x04, 0x03, // ecdsa_secp256r1_sha256
// Extension: key_share (0x0033)
// x25519 公钥: 32 字节
0x00, 0x1d, // group: x25519
0x00, 0x20, // key_exchange 长度: 32
// ...32 字节的 Montgomery u 坐标...国密 TLS 的 ClientHello(关键字段):
// cipher_suites
0x00, 0xC6, // TLS_SM4_GCM_SM3 ← 不同!
// Extension: supported_groups (0x000a)
0x00, 0x29, // curveSM2 (41) ← 不同!
// Extension: signature_algorithms (0x000d)
0x07, 0x08, // sm2sig_sm3 ← 不同!
// Extension: key_share (0x0033)
// SM2 公钥: 65 字节(未压缩点格式)
0x00, 0x29, // group: curveSM2
0x00, 0x41, // key_exchange 长度: 65 ← 不同!长了一倍
0x04, // 未压缩点标志
// ...32 字节 x 坐标 + 32 字节 y 坐标...注意 key_share 的长度差异:X25519 的公钥只有 32
字节(Montgomery 形式的 u 坐标),而 SM2 的公钥是 65
字节(04 || x || y 的未压缩 Weierstrass
点格式)。这直接导致 ClientHello 的总长度增加了 33
字节。
实际部署时,客户端通常会同时携带 x25519 和 curveSM2 两个 key_share,以便服务端选择。这样 ClientHello 就同时包含两个公钥,还要长。
ServerHello 差异
服务端从 ClientHello 里选择一个密码套件和一个 key_share 回复:
// 标准 TLS 1.3
cipher_suite: 0x13, 0x01 // TLS_AES_128_GCM_SHA256
key_share group: 0x00, 0x1d // x25519
key_share length: 0x00, 0x20 // 32 字节
// 国密 TLS
cipher_suite: 0x00, 0xC6 // TLS_SM4_GCM_SM3 ← 不同
key_share group: 0x00, 0x29 // curveSM2 ← 不同
key_share length: 0x00, 0x41 // 65 字节 ← 不同从 ServerHello 开始,双方就确定了用哪套算法。后续所有密钥推导、加密、签名都由这个选择决定。
密钥交换:SM2 ECDHE vs X25519 ECDHE
密钥交换是握手安全的核心。两种方案都是 Diffie-Hellman,但底层曲线截然不同:
| 特性 | X25519 | SM2 ECDHE |
|---|---|---|
| 曲线类型 | Montgomery 曲线 \(y^2 = x^3 + 486662x^2 + x\) | Weierstrass 曲线 \(y^2 = x^3 + ax + b\) |
| 素数域 | \(p = 2^{255} - 19\) | \(p\) = 一个 256-bit 素数(GM/T 0003 定义) |
| 公钥格式 | 32 字节 u 坐标 | 65 字节 04 \|\| x \|\| y |
| 共享密钥 | 32 字节 | 32 字节(取 ECDH 结果的 x 坐标) |
| 常量时间实现 | 天然适合(Montgomery ladder) | 需要额外注意(标准 double-and-add 有侧信道风险) |
密钥交换的伪代码对比:
// === X25519 ECDHE ===
// 客户端
uint8_t client_priv[32], client_pub[32];
x25519_keygen(client_priv, client_pub);
// client_pub 放入 ClientHello key_share
// 服务端
uint8_t server_priv[32], server_pub[32];
x25519_keygen(server_priv, server_pub);
// server_pub 放入 ServerHello key_share
// 双方各自计算共享密钥
uint8_t shared_secret[32];
x25519(shared_secret, my_priv, peer_pub);
// shared_secret 作为 HKDF-Extract 的 IKM
// === SM2 ECDHE ===
// 客户端
uint8_t client_priv[32];
uint8_t client_pub[65]; // 04 || x(32) || y(32)
sm2_keygen(client_priv, client_pub);
// client_pub 放入 ClientHello key_share
// 服务端
uint8_t server_priv[32];
uint8_t server_pub[65]; // 04 || x(32) || y(32)
sm2_keygen(server_priv, server_pub);
// server_pub 放入 ServerHello key_share
// 双方各自计算共享密钥
SM2_POINT shared_point;
sm2_ecdh(shared_point, my_priv, peer_pub);
// 取 shared_point.x 的 32 字节作为 HKDF-Extract 的 IKM
uint8_t shared_secret[32];
memcpy(shared_secret, shared_point.x, 32);关键区别在于:X25519 的 “clamping” 操作(设置特定 bit)使得实现天然抵抗某些侧信道攻击;SM2 的实现需要开发者自己保证标量乘法的常量时间性。详细对比见 SM2 vs ECDSA/X25519。
密钥调度:SM3 替代 SHA-256
从共享密钥到实际的加密密钥,中间经过 TLS 1.3 的密钥调度(Key Schedule)。这个过程在国密 TLS 中,把所有 SHA-256 调用替换为 SM3。第四节会详细展开。
握手消息加密:SM4-GCM 替代 AES-128-GCM
从 ServerHello 之后的消息开始,所有内容都用协商好的对称密钥加密。标准 TLS 1.3 用 AES-128-GCM,国密 TLS 用 SM4-GCM。两者都是 128-bit 密钥、128-bit tag、96-bit nonce(IV),所以加密后的报文长度完全一样——只是密文的实际内容不同。
SM4-GCM 和 AES-128-GCM 的对称加密机制相同(CTR 模式 + GHASH),只是底层分组密码不同。详细对比见 SM4 vs AES。
CertificateVerify:SM2 签名 vs ECDSA
服务端在 CertificateVerify 中对握手上下文的哈希做签名。签名的输入构造方式一样(64 字节空格 + context string + 0x00 + transcript hash),但签名算法不同:
// 标准 TLS 1.3
// 1. 计算 transcript hash(SHA-256)
uint8_t hash[32];
sha256(handshake_messages, msg_len, hash);
// 2. 构造签名输入
// 0x20 * 64 || "TLS 1.3, server CertificateVerify" || 0x00 || hash
uint8_t sign_input[130];
memset(sign_input, 0x20, 64);
memcpy(sign_input + 64, context_string, 33);
sign_input[97] = 0x00;
memcpy(sign_input + 98, hash, 32);
// 3. ECDSA-P256-SHA256 签名
uint8_t sig[72]; // DER 编码,长度可变
ecdsa_sign(server_privkey, sign_input, 130, sig, &sig_len);
// 国密 TLS
// 1. 计算 transcript hash(SM3)
uint8_t hash[32];
sm3(handshake_messages, msg_len, hash);
// 2. 构造签名输入(完全相同的格式)
uint8_t sign_input[130];
memset(sign_input, 0x20, 64);
memcpy(sign_input + 64, context_string, 33);
sign_input[97] = 0x00;
memcpy(sign_input + 98, hash, 32);
// 3. SM2 签名(注意:SM2 签名有 Z 值预处理)
uint8_t sig[72]; // DER 编码,长度可变
// SM2 签名的特殊之处:在签名前需要加入 Z 值
// Z = SM3(ENTL || ID || a || b || xG || yG || xA || yA)
sm2_sign(server_privkey, sign_input, 130, sig, &sig_len);SM2 签名和 ECDSA 的一个关键区别是 Z 值预处理:SM2 在签名前会把签名者的 ID 和曲线参数一起哈希,形成一个 Z 值,追加到待签名数据前面。这在 RFC 8998 中是隐含在 SM2 签名算法内部的。
(关于 SM2 签名中 Z 值的详细计算过程,参见 SM2 vs ECDSA 一文。)
三、双证书体系
这是国密 TLS 与标准 TLS 1.3 最大的结构性差异。
标准 TLS 1.3:一张证书
在标准 TLS 1.3 中,服务端在 Certificate 消息里发送一条证书链。叶子证书里包含服务端的公钥(比如 P-256 公钥),这个公钥既用于密钥交换(如果是 static ECDH),也用于身份验证(CertificateVerify 里的签名)。
实际上在 TLS 1.3 中,由于密钥交换是临时的(ephemeral),证书里的公钥只用于签名验证。
国密 TLS:签名证书 + 加密证书
国密体系要求服务端持有两张证书:
| 证书类型 | 用途 | 公钥类型 |
|---|---|---|
| 签名证书 | CertificateVerify 中的签名验证 | SM2 签名公钥 |
| 加密证书 | 密钥托管 / 密钥恢复 | SM2 加密公钥 |
为什么需要两张?这和中国的密码管理政策有关:
- 签名密钥由终端自己生成,私钥不出设备——保证不可否认性
- 加密密钥由 CA 或 KMC(密钥管理中心)生成,私钥可以被第三方托管——保证在必要时(比如司法取证、密钥丢失)可以恢复加密数据
这个设计和标准 TLS 的”一把钥匙开一把锁”理念截然不同。在标准体系中,密钥永远只有端点自己持有。
Certificate 消息怎么携带两张证书
在标准 TLS 1.3 的 Certificate
消息结构中,certificate_list
本身就是一个数组,可以携带多张证书。国密 TLS 的做法是:
// Certificate 消息结构(简化)
struct {
opaque certificate_request_context<0..255>;
CertificateEntry certificate_list<0..2^24-1>;
} Certificate;
// 国密 TLS 的 certificate_list 内容:
// [0] 签名证书(叶子)
// [1] 签名证书的 CA 证书(中间 CA)
// [2] 加密证书(叶子) ← 额外的!
// [3] 加密证书的 CA 证书(可能和 [1] 相同)服务端把签名证书链和加密证书链都放进去。客户端通过证书的密钥用途(Key
Usage)扩展来区分:签名证书的 Key Usage 标记为
digitalSignature,加密证书的 Key Usage 标记为
keyEncipherment 或
dataEncipherment。
CA 体系现状
国密 TLS 需要国密根 CA 签发的证书。目前主要的国密 CA 包括:
- CFCA(中金金融认证中心)
- BJCA(北京数字认证股份有限公司)
- GDCA(数安时代)
- SHECA(上海市数字证书认证中心)
这些 CA 签发的国密证书通常只被国密浏览器(360 安全浏览器、奇安信可信浏览器、密信浏览器等)信任,Chrome/Firefox/Safari 不认。这也是目前国密 TLS 推广的主要障碍之一。
四、密钥调度详解
密钥调度是 TLS 1.3 握手中最精妙的部分。国密 TLS 对此的修改看似简单(“把 SHA-256 换成 SM3”),但值得仔细对比。
标准 TLS 1.3 的密钥调度
在不到 500 行 C 实现 TLS 1.3 握手中我们详细推导过,这里简化成伪代码:
// ========= 标准 TLS 1.3 密钥调度 =========
// 哈希函数: SHA-256 (输出 32 字节)
// HKDF-Extract(salt, IKM) → PRK (32 字节)
// HKDF-Expand-Label(PRK, label, context, length) → OKM
// --- Early Secret ---
// PSK 模式下使用 PSK;非 PSK 模式使用 32 字节的 0
uint8_t early_secret[32];
hkdf_extract_sha256(
NULL, 0, // salt: 空
zeros_32, 32, // IKM: 32 字节 0(无 PSK)
early_secret // output: 32 字节
);
// --- Handshake Secret ---
// 用 ECDHE 共享密钥推导
uint8_t derived_secret[32];
derive_secret_sha256(early_secret, "derived", empty_hash, derived_secret);
uint8_t handshake_secret[32];
hkdf_extract_sha256(
derived_secret, 32, // salt
shared_secret, 32, // IKM: ECDHE 共享密钥
handshake_secret // output: 32 字节
);
// --- 握手流量密钥 ---
uint8_t client_hs_traffic_secret[32];
derive_secret_sha256(
handshake_secret,
"c hs traffic",
transcript_hash_ch_sh, // ClientHello...ServerHello 的哈希
client_hs_traffic_secret
);
// 最终的对称密钥和 IV
uint8_t client_hs_key[16]; // AES-128 密钥: 16 字节
uint8_t client_hs_iv[12]; // GCM nonce: 12 字节
hkdf_expand_label_sha256(client_hs_traffic_secret, "key", "", 16, client_hs_key);
hkdf_expand_label_sha256(client_hs_traffic_secret, "iv", "", 12, client_hs_iv);国密 TLS 的密钥调度
// ========= 国密 TLS 密钥调度 =========
// 哈希函数: SM3 (输出 32 字节) ← 唯一的区别(理论上)
// HKDF-Extract(salt, IKM) → PRK (32 字节)
// HKDF-Expand-Label(PRK, label, context, length) → OKM
// --- Early Secret ---
uint8_t early_secret[32];
hkdf_extract_sm3(
NULL, 0, // salt: 空
zeros_32, 32, // IKM: 32 字节 0(无 PSK)
early_secret // output: 32 字节
);
// --- Handshake Secret ---
uint8_t derived_secret[32];
derive_secret_sm3(early_secret, "derived", empty_hash, derived_secret);
uint8_t handshake_secret[32];
hkdf_extract_sm3(
derived_secret, 32, // salt
shared_secret, 32, // IKM: SM2 ECDHE 共享密钥
handshake_secret // output: 32 字节
);
// --- 握手流量密钥 ---
uint8_t client_hs_traffic_secret[32];
derive_secret_sm3(
handshake_secret,
"c hs traffic", // label 不变!
transcript_hash_ch_sh, // SM3 哈希的结果
client_hs_traffic_secret
);
// 最终的对称密钥和 IV
uint8_t client_hs_key[16]; // SM4 密钥: 16 字节(和 AES-128 一样)
uint8_t client_hs_iv[12]; // GCM nonce: 12 字节(和 AES-128-GCM 一样)
hkdf_expand_label_sm3(client_hs_traffic_secret, "key", "", 16, client_hs_key);
hkdf_expand_label_sm3(client_hs_traffic_secret, "iv", "", 12, client_hs_iv);对比总结
| 步骤 | 标准 TLS 1.3 | 国密 TLS | 输出长度 |
|---|---|---|---|
| HKDF-Extract | HMAC-SHA256 | HMAC-SM3 | 32 字节(相同) |
| HKDF-Expand-Label | SHA-256 | SM3 | 取决于 length 参数 |
| transcript hash | SHA-256 | SM3 | 32 字节(相同) |
| 对称密钥 | AES-128 key = 16 字节 | SM4 key = 16 字节 | 16 字节(相同) |
| IV / nonce | 12 字节 | 12 字节 | 12 字节(相同) |
| traffic secret | 32 字节 | 32 字节 | 32 字节(相同) |
所有长度都碰巧一致,因为 SM3 和 SHA-256 的输出长度相同(256 bit),SM4 和 AES-128 的密钥长度相同(128 bit)。这是一个幸运的巧合,让密钥调度的”结构”完全不变,只有”内容”不同。
但同样的输入(比如同样的 ECDHE 共享密钥),经过 SHA-256 和 SM3 推导出的密钥是完全不同的值。你不能混用——客户端用 SM3 推导、服务端用 SHA-256 推导,握手会立即失败。
密钥调度详解:SM3-HKDF 字节级拆解
上面的伪代码展示了调用顺序,但 HKDF 内部到底做了什么?这里用字节级伪代码拆解 SM3 版本的 HKDF,让你能逐字节比对实现是否正确。
HKDF-Extract:从原始密钥材料提取固定长度 PRK
HKDF-Extract(salt, IKM) → PRK
= HMAC-SM3(key=salt, message=IKM)
HMAC-SM3(key, message) 的展开:
1. 如果 key 长度 > 64 字节:key = SM3(key) // 压缩到 32 字节
2. 如果 key 长度 < 64 字节:右填 0x00 到 64 字节 → K
3. ipad = K ⊕ (0x36 重复 64 次) // 64 字节
4. opad = K ⊕ (0x5C 重复 64 次) // 64 字节
5. inner_hash = SM3(ipad || message) // 32 字节
6. PRK = SM3(opad || inner_hash) // 32 字节
示例——Early Secret(无 PSK 模式):
salt = "" (空) → HMAC 规范:salt 为空时使用 HashLen 个 0x00
= 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 (32 字节)
IKM = 00 00 ... 00 (32 字节全零)
PRK = HMAC-SM3(salt, IKM) = early_secret
Derive-Secret:从 PRK 派生特定用途的密钥
Derive-Secret(Secret, Label, Messages) → derived
= HKDF-Expand-Label(Secret, Label, SM3(Messages), 32)
HKDF-Expand-Label(PRK, Label, Context, Length) → OKM
构造 HkdfLabel 结构体:
struct HkdfLabel {
uint16 length = Length; // 2 字节, 大端序
opaque label<7..255> = "tls13 " || Label; // 1 字节长度前缀 + 内容
opaque context<0..255> = Context; // 1 字节长度前缀 + 内容
};
info = HkdfLabel 的字节序列化
= HKDF-Expand(PRK, info, Length)
HKDF-Expand(PRK, info, L) → OKM
N = ceil(L / 32) // SM3 输出 32 字节
T(0) = ""
T(1) = HMAC-SM3(PRK, T(0) || info || 0x01) // 32 字节
T(2) = HMAC-SM3(PRK, T(1) || info || 0x02) // 32 字节
...
OKM = (T(1) || T(2) || ...)[0..L-1] // 截取前 L 字节
对于 TLS 1.3 密钥调度,L ≤ 32,所以 N=1,只需一次 HMAC-SM3
完整密钥推导链:从 ECDHE 共享密钥到流量密钥
输入:
shared_secret = SM2 ECDHE 共享密钥 (32 字节, 对方临时公钥的 x 坐标)
CH...SH_hash = SM3(ClientHello || ServerHello) (32 字节)
CH...SF_hash = SM3(ClientHello || ... || ServerFinished) (32 字节)
推导过程:
① early_secret = HKDF-Extract(salt=0×32, IKM=0×32)
② derived_1 = Derive-Secret(early_secret, "derived", "")
= HKDF-Expand-Label(early_secret, "derived", SM3(""), 32)
③ handshake_secret = HKDF-Extract(salt=derived_1, IKM=shared_secret)
④ client_handshake_traffic_secret
= Derive-Secret(handshake_secret, "c hs traffic", CH...SH_hash)
⑤ server_handshake_traffic_secret
= Derive-Secret(handshake_secret, "s hs traffic", CH...SH_hash)
⑥ 握手加密密钥:
client_hs_key (16B) = HKDF-Expand-Label(④, "key", "", 16) // SM4 密钥
client_hs_iv (12B) = HKDF-Expand-Label(④, "iv", "", 12) // GCM nonce
server_hs_key (16B) = HKDF-Expand-Label(⑤, "key", "", 16)
server_hs_iv (12B) = HKDF-Expand-Label(⑤, "iv", "", 12)
⑦ derived_2 = Derive-Secret(handshake_secret, "derived", "")
⑧ master_secret = HKDF-Extract(salt=derived_2, IKM=0×32)
⑨ client_application_traffic_secret
= Derive-Secret(master_secret, "c ap traffic", CH...SF_hash)
⑩ server_application_traffic_secret
= Derive-Secret(master_secret, "s ap traffic", CH...SF_hash)
⑪ 应用数据加密密钥:
client_app_key (16B) = HKDF-Expand-Label(⑨, "key", "", 16)
client_app_iv (12B) = HKDF-Expand-Label(⑨, "iv", "", 12)
server_app_key (16B) = HKDF-Expand-Label(⑩, "key", "", 16)
server_app_iv (12B) = HKDF-Expand-Label(⑩, "iv", "", 12)
整个链路中,每一步的 HMAC-SM3 调用都可以用上面展开的字节级公式验证。调试 TLS 握手时,把每一步的输入和输出用十六进制打出来,逐步比对——大部分”密钥推导不一致”的 bug 都是 label 拼写错误、字节序搞反、或者 transcript hash 的范围不对。
五、Wireshark 抓包分析
搭建测试环境
要抓到真实的国密 TLS 握手包,我们需要支持国密的 TLS 实现。目前主流的开源方案有两个:
# 方案一:Tongsuo(铜锁,原 BabaSSL)
# 蚂蚁集团主导的 OpenSSL 分支,国密支持最完整
git clone https://github.com/Tongsuo-Project/Tongsuo.git
cd Tongsuo
./config enable-ntls
make -j$(nproc)
# 启动国密 TLS 服务端
./apps/openssl s_server \
-accept 4433 \
-sign_cert sm2_sign.crt -sign_key sm2_sign.key \
-enc_cert sm2_enc.crt -enc_key sm2_enc.key \
-enable_ntls
# 方案二:GmSSL
# 北京大学主导的国密算法库
git clone https://github.com/guanzhi/GmSSL.git
cd GmSSL
mkdir build && cd build
cmake ..
make -j$(nproc)
# 启动国密 TLS 服务端
./bin/gmssl tls13_server \
-port 4433 \
-sig_cert sm2_sign.crt -sig_key sm2_sign.key \
-enc_cert sm2_enc.crt -enc_key sm2_enc.key抓包方法和 SSLKEYLOGFILE
抓包命令本身很简单:
# 用 tcpdump 抓包
sudo tcpdump -i lo -w gm-tls-handshake.pcap port 4433
# 或者用 tshark
sudo tshark -i lo -w gm-tls-handshake.pcap -f "port 4433"关键是要导出密钥日志文件(SSLKEYLOGFILE),否则 Wireshark 无法解密握手后的加密消息:
# Tongsuo 支持 SSLKEYLOGFILE 环境变量
export SSLKEYLOGFILE=$PWD/keylog.txt
# 发起客户端连接
./apps/openssl s_client \
-connect 127.0.0.1:4433 \
-enable_ntls \
-ciphersuites TLS_SM4_GCM_SM3在 Wireshark
中配置解密:Edit → Preferences → Protocols → TLS → (Pre)-Master-Secret log filename,填入
keylog.txt 的路径。
注意:截至目前,官方 Wireshark 对 TLS_SM4_GCM_SM3 的解密支持有限。你可能需要使用 Tongsuo 项目维护的 Wireshark 补丁版本。
逐字节对比 ClientHello
用 tshark 解析 ClientHello 的关键字段:
# 标准 TLS 1.3 ClientHello(OpenSSL 3.x)
# Record Layer:
# Content Type: Handshake (22)
# Version: TLS 1.0 (0x0301) ← legacy
# Handshake Protocol: Client Hello
# Version: TLS 1.2 (0x0303) ← legacy
# Cipher Suites (2):
# TLS_AES_128_GCM_SHA256 (0x1301)
# Extension: supported_versions
# TLS 1.3 (0x0304)
# Extension: supported_groups
# x25519 (0x001d)
# Extension: key_share
# Group: x25519, Key Exchange Length: 32
# 国密 TLS ClientHello(Tongsuo)
# Record Layer:
# Content Type: Handshake (22)
# Version: TLS 1.0 (0x0301) ← 一样
# Handshake Protocol: Client Hello
# Version: TLS 1.2 (0x0303) ← 一样
# Cipher Suites (2):
# TLS_SM4_GCM_SM3 (0x00c6) ← 不同
# Extension: supported_versions
# TLS 1.3 (0x0304) ← 一样
# Extension: supported_groups
# curveSM2 (0x0029) ← 不同
# Extension: key_share
# Group: curveSM2, Key Exchange Length: 65 ← 不同二进制层面的关键差异:
// cipher_suites 字段
// 标准: 13 01 (TLS_AES_128_GCM_SHA256)
// 国密: 00 C6 (TLS_SM4_GCM_SM3)
// ^^ ^^ 两个字节都不同
// supported_groups 扩展
// 标准: 00 1D (x25519 = 29)
// 国密: 00 29 (curveSM2 = 41)
// ^^ 第二字节不同
// key_share 扩展中的公钥
// 标准: 00 1D 00 20 (x25519, 32 bytes)
// [32 字节 u 坐标]
// 国密: 00 29 00 41 (curveSM2, 65 bytes)
// 04 [32 字节 x] [32 字节 y]
// ^^ 未压缩点前缀ServerHello 和 EncryptedExtensions
ServerHello 的差异模式和 ClientHello 类似(cipher_suite + key_share 的 group/length)。
EncryptedExtensions 从外部看只是一段密文——标准 TLS 1.3 用 AES-128-GCM 加密,国密 TLS 用 SM4-GCM 加密。即使明文内容完全相同,密文也完全不同。只有配置了 SSLKEYLOGFILE,Wireshark 才能解密显示明文。
六、兼容模式和迁移策略
同时支持国密和国际算法
实际部署中,服务器通常需要同时支持标准 TLS 和国密 TLS。主流做法有两种:
方案一:单端口、多 cipher suite
# Tongsuo 配置:同时启用国密和国际算法
./apps/openssl s_server \
-accept 443 \
-cert rsa.crt -key rsa.key \
-sign_cert sm2_sign.crt -sign_key sm2_sign.key \
-enc_cert sm2_enc.crt -enc_key sm2_enc.key \
-enable_ntls \
-ciphersuites "TLS_SM4_GCM_SM3:TLS_AES_128_GCM_SHA256"
# 客户端在 ClientHello 中同时携带两种 cipher suite
# 服务端优先选择国密,不支持则降级为国际算法方案二:Nginx + Tongsuo 双证书配置
# nginx.conf 关键配置
server {
listen 443 ssl;
server_name example.com;
# 国际算法证书
ssl_certificate /etc/nginx/certs/rsa.crt;
ssl_certificate_key /etc/nginx/certs/rsa.key;
# 国密签名证书
ssl_sign_certificate /etc/nginx/certs/sm2_sign.crt;
ssl_sign_certificate_key /etc/nginx/certs/sm2_sign.key;
# 国密加密证书
ssl_enc_certificate /etc/nginx/certs/sm2_enc.crt;
ssl_enc_certificate_key /etc/nginx/certs/sm2_enc.key;
# 同时启用国密和国际算法
enable_ntls on;
ssl_ciphers "ECC-SM2-SM4-GCM-SM3:ECDHE-SM2-SM4-GCM-SM3:ECDHE+AES128:RSA+AES128";
}双证书链部署实战
上面的配置是简化版。实际生产环境的双证书部署远比”填几个文件路径”复杂——你需要处理证书链顺序、signature_algorithms 扩展协商、以及服务端如何根据客户端能力选证书。下面是一份经过实战验证的完整 Nginx 配置:
# === Nginx + Tongsuo 双证书完整配置 ===
# 前提:Nginx 编译时链接了 Tongsuo(而非原版 OpenSSL)
# 编译命令参考:
# ./configure --with-openssl=/path/to/Tongsuo \
# --with-openssl-opt="enable-ntls enable-sm2 enable-sm3 enable-sm4"
server {
listen 443 ssl;
server_name gm.example.com;
# ---- 国际算法证书(RSA 或 ECDSA)----
# 证书链顺序:叶子证书 → 中间 CA(不含根 CA)
ssl_certificate /etc/nginx/certs/ecdsa_chain.pem;
ssl_certificate_key /etc/nginx/certs/ecdsa.key;
# ---- 国密 SM2 签名证书 ----
# 用于 CertificateVerify 中的身份认证
# 证书链顺序:SM2 签名叶子证书 → SM2 中间 CA
ssl_sign_certificate /etc/nginx/certs/sm2_sign_chain.pem;
ssl_sign_certificate_key /etc/nginx/certs/sm2_sign.key;
# ---- 国密 SM2 加密证书 ----
# 用于密钥托管/恢复场景,由 CA/KMC 生成
# 证书链顺序:SM2 加密叶子证书 → SM2 中间 CA
ssl_enc_certificate /etc/nginx/certs/sm2_enc_chain.pem;
ssl_enc_certificate_key /etc/nginx/certs/sm2_enc.key;
# ---- 协议和算法配置 ----
ssl_protocols TLSv1.2 TLSv1.3;
enable_ntls on;
# 密码套件优先级:国密优先,国际回退
# 注意:TLS 1.3 套件用 ssl_ciphers 配置(Tongsuo 扩展)
ssl_ciphers "ECC-SM2-SM4-GCM-SM3:ECDHE-SM2-SM4-CBC-SM3:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256";
# signature_algorithms 扩展处理
# 当客户端 ClientHello 携带 sm2sig_sm3 (0x0708) 时,
# Tongsuo 自动选择 SM2 签名证书做 CertificateVerify
# 当客户端只携带 ecdsa_secp256r1_sha256 (0x0403) 时,
# 自动降级选择 ECDSA 证书
# 此行为由 Tongsuo 内部的 ssl_check_sigalg_cert_match() 自动处理
# ---- 证书链构造规则 ----
# sm2_sign_chain.pem 的文件内容应为:
# -----BEGIN CERTIFICATE-----
# (SM2 签名叶子证书,KeyUsage: digitalSignature)
# -----END CERTIFICATE-----
# -----BEGIN CERTIFICATE-----
# (SM2 签名中间 CA 证书)
# -----END CERTIFICATE-----
#
# sm2_enc_chain.pem 的文件内容应为:
# -----BEGIN CERTIFICATE-----
# (SM2 加密叶子证书,KeyUsage: keyEncipherment)
# -----END CERTIFICATE-----
# -----BEGIN CERTIFICATE-----
# (SM2 加密中间 CA,可能与签名 CA 相同)
# -----END CERTIFICATE-----
# ---- 其他推荐配置 ----
ssl_prefer_server_ciphers on;
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
location / {
proxy_pass http://backend;
}
}
证书选择逻辑:Tongsuo 的握手处理流程大致如下——
- 解析 ClientHello 中的
signature_algorithms扩展 - 如果包含
sm2sig_sm3(0x0708),且cipher_suites包含TLS_SM4_GCM_SM3:- 选择国密路径 → ServerHello 返回 SM4-GCM-SM3
- Certificate 消息中放入 SM2 签名证书链 + SM2 加密证书链
- CertificateVerify 使用 SM2 签名密钥签名
- 否则,降级到国际路径 → ServerHello 返回
AES-128-GCM-SHA256
- Certificate 消息中放入 ECDSA/RSA 证书链
- CertificateVerify 使用对应密钥签名
这个选择过程对客户端完全透明——标准 TLS 1.3 的扩展协商机制天然支持。
浏览器支持现状
| 浏览器 | 国密 TLS 支持 | 备注 |
|---|---|---|
| 360 安全浏览器 | ✅ | 最早支持国密的浏览器之一 |
| 奇安信可信浏览器 | ✅ | 专注政企市场 |
| 密信浏览器(MeSince) | ✅ | 内置国密根 CA |
| 红莲花浏览器 | ✅ | 基于 Chromium + Tongsuo |
| Chrome / Edge | ❌ | 不支持,无计划支持 |
| Firefox | ❌ | 不支持 |
| Safari | ❌ | 不支持 |
国际主流浏览器不支持国密 TLS 是一个现实问题。这意味着面向公众的网站必须做”双算法”部署——国密浏览器走国密通道,其他浏览器走国际通道。
降级策略
当客户端不支持国密算法时,标准的 TLS 1.3 协商机制自然处理了降级:
- 客户端在 ClientHello 的
cipher_suites中只列出了TLS_AES_128_GCM_SHA256 - 服务端发现没有
TLS_SM4_GCM_SM3,于是选择TLS_AES_128_GCM_SHA256 - 同理,
supported_groups和signature_algorithms也按标准 TLS 1.3 选择 - 整个握手退化为标准 TLS 1.3,对客户端完全透明
这个降级过程不需要任何特殊处理——TLS 1.3 的扩展协商机制已经内置了这种灵活性。
中间设备适配
WAF、CDN、负载均衡器等中间设备对国密 TLS 的支持参差不齐:
- 阿里云 CDN:通过 Tongsuo 支持国密 TLS
- 腾讯云 CLB:部分区域支持国密 SSL
- 华为云 ELB:支持国密双证书配置
- 传统 WAF 设备:大部分不识别
TLS_SM4_GCM_SM3,可能误判为异常流量
如果中间设备不支持国密 TLS,常见的做法是在边缘节点做”国密卸载”——边缘节点与客户端之间走国密 TLS,边缘节点与源站之间走标准 TLS:
这种架构的前提是你信任 CDN/WAF 提供商,因为中间节点能看到明文。
总结
回到开头的问题:把 TLS 1.3 的 X25519+AES-128-GCM+SHA-256 全换成 SM2+SM4-GCM+SM3,要改多少?
结构层面没变——TLS 1.3 的消息类型、发送顺序、状态机一模一样。
字节层面改了不少——cipher suite ID、named group ID、签名算法 ID、公钥长度、密钥交换格式都不同。
证书层面改动最大——从单证书变成了双证书,这是标准 TLS 1.3 中完全没有的概念。
生态层面是最大的挑战——浏览器支持有限、CA 体系封闭、中间设备适配复杂。
如果你正在做国密 TLS 的适配工作,建议从 Tongsuo(铜锁)开始——它是目前国密 TLS 1.3 支持最完善的开源实现,API 和 OpenSSL 兼容,迁移成本最低。
参考资料: - RFC 8998: ShangMi (SM) Cipher Suites for TLS 1.3 - RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 - GM/T 0024-2014: SSL VPN 技术规范 - Tongsuo(铜锁)项目 - GmSSL 项目 - 不到 500 行 C 实现 TLS 1.3 握手