土法炼钢兴趣小组的算法知识备份

国密 TLS(RFC 8998)vs 标准 TLS 1.3:握手报文逐字节对比

目录

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 和国密 TLS 的握手流程对比:

标准 TLS 1.3 vs 国密 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 加密公钥

为什么需要两张?这和中国的密码管理政策有关:

  1. 签名密钥由终端自己生成,私钥不出设备——保证不可否认性
  2. 加密密钥由 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 标记为 keyEnciphermentdataEncipherment

CA 体系现状

国密 TLS 需要国密根 CA 签发的证书。目前主要的国密 CA 包括:

这些 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 的握手处理流程大致如下——

  1. 解析 ClientHello 中的 signature_algorithms 扩展
  2. 如果包含 sm2sig_sm3 (0x0708),且 cipher_suites 包含 TLS_SM4_GCM_SM3
    • 选择国密路径 → ServerHello 返回 SM4-GCM-SM3
    • Certificate 消息中放入 SM2 签名证书链 + SM2 加密证书链
    • CertificateVerify 使用 SM2 签名密钥签名
  3. 否则,降级到国际路径 → 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 协商机制自然处理了降级:

  1. 客户端在 ClientHello 的 cipher_suites 中只列出了 TLS_AES_128_GCM_SHA256
  2. 服务端发现没有 TLS_SM4_GCM_SM3,于是选择 TLS_AES_128_GCM_SHA256
  3. 同理,supported_groupssignature_algorithms 也按标准 TLS 1.3 选择
  4. 整个握手退化为标准 TLS 1.3,对客户端完全透明

这个降级过程不需要任何特殊处理——TLS 1.3 的扩展协商机制已经内置了这种灵活性。

中间设备适配

WAF、CDN、负载均衡器等中间设备对国密 TLS 的支持参差不齐:

如果中间设备不支持国密 TLS,常见的做法是在边缘节点做”国密卸载”——边缘节点与客户端之间走国密 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 握手


By .