3. SSL/TLS 握手协议详解

说实话,SSL/TLS 握手这个流程我前前后后抓包看过不下几百次。早年第一次调试国密 SSL 库的时候,就因为 Certificate 消息里带的证书是 SM2 的、但签名算法协商成了 RSA,导致握手失败——这种不匹配在互操作性测试中特别坑。今天咱们就把握手协议里最核心的几个步骤拆开,从 ClientHello 到 Finished,一条一条捋清楚。

3.1 ClientHello——客户端先开口

握手开始,客户端先发一个 ClientHello。这里面带的东西可不少:

  • 版本号:比如 TLS 1.2 或 TLS 1.3。国密场景下很多设备还卡在 TLS 1.2。
  • 随机数:32 字节,其中 4 字节是时间戳,剩下 28 字节随机。我见过有些嵌入式实现为了省事,随机数生成太弱,导致预主密钥可预测——这种坑最好用硬件真随机数发生器。
  • 密码套件列表:客户端支持的加密算法组合。比如 TLS_SM4_GCM_SM3 或 TLS_ECDHE_SM2_WITH_SM4_GCM_SM3。注意,列表里套件的顺序很重要,服务端一般选第一个它能支持的。
  • 扩展字段:SNI、supported_groups、signature_algorithms 等。SNI 在现代 Web 上几乎必带,不然多域名服务器不知道你访问哪个站点。

我曾经遇到过一个案例:客户端 ClientHello 里写了 30 个套件,但服务端只支持 1 个,而且是排在倒数第三。结果协商出来的套件跟预期完全不一样。所以国密终端里我建议只放你想让服务端用的那几个套件,别啥都往上堆。

3.2 ServerHello——服务端的回应

服务端收到 ClientHello 后,选出一组参数,回复 ServerHello。内容跟 ClientHello 很对称:

  • 协议版本(通常是客户端支持的最高版本)
  • 服务端随机数(同样 32 字节,结构同客户端)
  • 选定的密码套件
  • 压缩方法(现在基本都是 null,我还没见过谁还开着压缩)

你想想看,如果服务端选了客户端没支持的套件,或者版本比客户端低太多,那基本上就会触发 Alert 握手失败。我在调式一个 SM2 网关时,服务端就固执地回了一个 NULL-SHA 套件——结果不用说,客户端直接 break 了。

3.3 证书交换——验证身份的核心

ServerHello 之后,服务端会发 Certificate 消息。里面是一个 X.509 证书链。国密场景下,证书是 SM2 椭圆曲线公钥证书,签名算法用 SM2withSM3。这里有几个要点:

  • 服务端必须把完整的证书链发下来(根证书可以省略,但中间证书一定不能少)。我曾经遇到一个设备只发了叶子证书,客户端本地又没有签发该证书的 CA,那验证直接挂掉。
  • 如果服务端要求双向认证,还会发 CertificateRequest 消息,客户端在后面也会发送自己的证书。
  • 证书的有效期、CRL/OCSP 状态也是握手过程中的隐性检查点——这部分很多实现都不严格,但国密合规性要求必须验。

注意:国密 SSL 中,Certificate 消息里携带的 SM2 证书必须满足《GM/T 0015-2012》格式。我在项目中曾遇到过证书的签名值编码用 DER 还是 BER 不一致的问题,导致验签通不过——这个坑排查了整整两天。

3.4 密钥协商——真正的“秘密”交换

证书交换后,开始生成会话密钥。具体流程依赖选定的密码套件。主流方式有两种:

方式 说明 国密对应
RSA 密钥传输 客户端生成预主密钥,用服务端公钥加密发送 一般不推荐,无前向安全性
ECDHE 密钥协商 双方各自生成临时私钥,交换公钥,计算共享预主密钥 国密使用 SM2 密钥交换协议(参照 GM/T 0009-2012)

ECDHE 是现在的主流。服务器发送 ServerKeyExchange 消息,携带椭圆曲线参数和临时公钥,以及用证书私钥做的签名。客户端收到后验证签名,再发 ClientKeyExchange(如果是 RSA 传密钥的话)或 ECDHE 的临时公钥。然后两端分别计算出预主密钥,再用 PRF 衍生出主密钥和会话密钥。

核心推导:主密钥 = PRF(预主密钥 + 客户端随机数 + 服务端随机数)。所以随机数的质量直接影响会话的唯一性——我建议项目里随机数生成一定要从内核熵池或硬件 TRNG 取,不要用 rand()。

3.5 Finished 消息——握手成功的“封印”

密钥都算完后,双方分别发送 ChangeCipherSpec(不算握手消息,是单独记录层协议)和 Finished。Finished 里面是一个基于之前所有握手消息的 HMAC 值,计算方式:

verify_data = PRF(master_secret, "finished", hash(handshake_messages)) [0..11]

如果双方 Finished 都能验证通过,说明握手过程中没人篡改消息,密钥达成一致。否则连接直接断开。我在调试时经常会打印这个 verify_data 跟抓包里的比对,有一次发现服务端算出来的值跟客户端不一样,最后定位是握手消息的拼接顺序——服务端多发送了一条 HelloRequest 消息,导致 hash 不一致。

国密 SSL/TLS 握手流程(ECDHE+SM2) 客户端 服务端 ClientHello (版本、随机数、套件列表) ServerHello (选定套件、随机数、扩展) Certificate (SM2 证书链) ServerKeyExchange (SM2 临时公钥+签名) ClientKeyExchange (客户端 SM2 临时公钥) ChangeCipherSpec + Finished (双向) 关键说明 1. 证书验证后才会信任公钥 2. SM2 密钥交换计算预主密钥 3. Finished 验证完整性

3.6 各消息字段快速查表

消息 方向 核心字段
ClientHello C→S 版本、随机数、套件列表、扩展(SNI, supported_groups 等)
ServerHello S→C 版本、随机数、选定套件、压缩方法
Certificate S→C SM2 证书链(叶子证书 + 中间 CA)
ServerKeyExchange S→C 曲线参数、临时公钥、SM2withSM3 签名
ClientKeyExchange C→S 客户端临时公钥(ECDHE)或加密的预主密钥(RSA)
Finished 双向 verify_data = PRF(master_secret, “finished”, hash)

我的调试习惯:抓包时先过滤 tls.handshake,然后逐条对比 ClientHello 里的套件列表和服务端返回的套件。如果协商结果不是你期望的国密套件,多半是客户端把非国密套件放在了前面。记得调整顺序。