2. TLS握手协议详解:客户端Hello、服务器Hello、证书交换、密钥协商
好,咱们正式进入握手协议的世界。这部分是整个TLS的心脏,也是最容易踩坑的地方。
握手协议说白了就干三件事:确认身份、协商密码、生成会话密钥。整个流程大概需要两次往返(TLS 1.2),也就几毫秒的事。但就是这几毫秒,我见过无数生产环境出问题,都是握手阶段挂了。
2.1 第一回合:客户端Hello
握手的第一步,客户端发一个 ClientHello 消息给服务端。
这个包长得挺规矩的,包含以下核心字段:
- TLS版本:客户端支持的最高版本,比如 TLS 1.2
- 随机数:32字节随机种子,后面用来算密钥
- 密码套件列表:客户端支持的所有CipherSuite,按优先级排序
- 扩展字段:SNI、ALPN、支持的签名算法等
我个人的经验:我第一次抓包分析TLS时,就栽在密码套件列表上。客户端列了20多个套件,服务端一个都不支持,直接握手失败。
来看一个典型的ClientHello抓包,我用Wireshark截的:
TLSv1.2 Record Layer: Handshake Protocol: Client Hello
Handshake Type: Client Hello (1)
Version: TLS 1.0 (0x0301)
Random: 1c 9b a2 3f 8d 4e ...
Cipher Suites Length: 58
Cipher Suites (29 suites)
Cipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)
Cipher Suite: TLS_CHACHA20_POLY1305_SHA256 (0x1303)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
...
Extensions Length: 218
Extension: server_name (len=23)
Extension: supported_groups (len=12)
Extension: signature_algorithms (len=30)
注意一下这里:很多同学以为ClientHello里填的是TLS 1.2,其实版本字段写的是0x0301(TLS 1.0)。这是历史遗留问题。服务端收到后会根据实际能力再决定用哪个版本。
2.2 第二回合:服务器Hello
服务端收到ClientHello后,也不是全盘接收。它会从客户端提供的列表中挑一个自己能支持的组合,然后回复 ServerHello。
ServerHello的字段和ClientHello基本对称:
- 选择的TLS版本:我选TLS 1.2
- 服务器随机数:也是32字节,用于密钥计算
- 选择的密码套件:比如
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - Session ID:如果要做会话复用,这里填一个ID
一个坑:有一次我在项目中遇到客户端和服务端版本协商失败,查了半天发现服务端配置的TLS版本列表里没写TLS 1.2,只写了1.0和1.1。但其实客户端只带了1.2,两边直接不匹配,握手失败。所以,服务器支持的版本别设得太死。
ServerHello发完,服务端紧接着会发 Certificate、ServerKeyExchange(如果套件需要)、ServerHelloDone。
2.3 证书交换:你真的是你吗?
这一步的核心是身份验证。服务端把自己的证书链发给客户端。
证书链的结构是这样的:
- 服务器证书(叶子证书)
- 中间CA证书(可能多个)
- 根CA证书(一般不发送)
客户端收到后会做一系列校验:
- 证书是否过期
- 证书域名是否匹配
- 签名链是否可信
- 证书撤销状态(可选)
我曾经遇过一个非常隐蔽的问题:某个服务端发了完整的证书链,但中间证书过期了。客户端在校验时发现签名链断了,直接拒绝连接。排查了大半天,才注意到那个不起眼的中间证书有效期。所以,证书链里的每一张证书都要在有效期内。
有些场景还会做客户端证书验证(双向认证)。这时服务端会在CertificateRequest消息里要求客户端也发证书。不过大多数Web场景只做单向认证。
2.4 密钥协商:真正的加密从这里开始
证书交换完成后,就到了最关键的密钥协商。当前主流方案是 ECDHE(临时椭圆曲线Diffie-Hellman)。
ECDHE交换过程大致如下:
| 步骤 | 消息方向 | 内容 |
|---|---|---|
| 1. 服务端发送 | Server → Client | 椭圆曲线参数 + 服务端公钥(签名) |
| 2. 客户端发送 | Client → Server | 客户端公钥 |
| 3. 双方计算 | - | 使用ECDH算法计算出预主密钥(Pre-Master Secret) |
| 4. 派生 | - | 通过PRF将预主密钥 + 客户端随机数 + 服务器随机数 → 主密钥 |
| 5. 生成工作密钥 | - | 从主密钥派生出加密密钥、MAC密钥、IV等 |
这里有个关键点:ECDHE的私钥是临时生成的,用完就丢。所以即使未来有人拿到了服务器的长期私钥,也无法解密以前抓到的会话数据。这就是前向安全性(Perfect Forward Secrecy)。
如果是RSA密钥交换(旧方案),客户端直接生成预主密钥,用服务端公钥加密后发给服务端。但这种方式不具备前向安全性,我建议所有新系统都不要用。
来看伪代码展示一下密钥计算:
// 服务端
serverPrivateKey, serverPublicKey = generateECDHKeyPair()
// 将serverPublicKey签名后发给客户端
// 客户端
clientPrivateKey, clientPublicKey = generateECDHKeyPair()
// clientPublicKey发给服务端
// 两边各自计算
preMasterSecret = ECDH_agree(clientPrivateKey, serverPublicKey)
// 或者 ECDH_agree(serverPrivateKey, clientPublicKey)
// 结果相同
// 派生主密钥
masterSecret = PRF(preMasterSecret + clientRandom + serverRandom, "master secret")
// 派生会话密钥
keyBlock = PRF(masterSecret, "key expansion", serverRandom + clientRandom)
clientWriteKey = keyBlock[0:16]
serverWriteKey = keyBlock[16:32]
// 还有IV、MAC密钥等
说白了就是:握手阶段产生两个随机数(客户端和服务器各一个),再通过ECDHE交换得到预主密钥。这三个种子一混合,最后通过PRF函数不断拉伸、混合、再拉伸,得到最终的工作密钥。
2.5 握手完成与Change Cipher Spec
密钥协商完成后,双方会互相发一个 Change Cipher Spec 消息,告诉对方:后面我要开始加密通信了。
紧接着发一个 Finished 消息,里面是所有握手消息的摘要(用刚才协商好的密钥加密)。
如果双方都能正确解密并验证Finished消息,握手正式完成。从此以后的数据就全加密了。
我的一个调试技巧:如果TLS连接建立了但业务数据传不过去,我通常会先看看握手阶段是否完整。在Wireshark里看,是否有Finished消息,以及加密后的数据是否正常。很多时候问题出在握手阶段没走完,但客户端以为已经连接成功了。
整个TLS 1.2握手流程,我把它们画成了一张流程图,方便你对照理解:
你把这个流程记在心里,后面讲配置和排错时,你会发现大部分问题都能对号入座。握手协议的核心其实就是:用非对称加密交换对称密钥。
具体来说,握手协议解决了三个问题:
- 你是谁:通过证书验明正身
- 我们用什么加密:通过密码套件协商确定算法
- 密钥怎么来:通过ECDHE或RSA等手段,双方安全地算出同一个密钥
这三件事搞定了,后面的加密通信就水到渠成了。嗯,接下来我们可以深入看看每种密钥交换算法的细节,以及如何在实际项目中选择最合适的配置。