3、SSL/TLS握手过程:握手阶段详解、加密套件协商、证书验证流程
说实话,搞了这么多年HTTPS排障,我最怕的就是有人说“握手慢”但不知道具体慢在哪一步。你想想看,一个完整的TLS握手少则几百毫秒,多则几秒,每个环节都可能出幺蛾子。今天我把自己踩过的坑和经验摊开来讲,咱们把握手全流程捋一遍。
核心定位:TLS握手是HTTPS安全通信的“敲门砖”。它完成三件事——身份认证、密钥协商、参数确认。任何一步失败,后面的加密传输都无从谈起。
3.1 握手阶段详解
标准握手(非PSK/0-RTT)大致分四轮交互。我用一个实际排查案例切入:有次客户反馈某APK连后台频繁超时,抓包一看——客户端发完ClientHello后服务器等了快2秒才回复ServerHello。原因?服务器DNS解析上游CDN节点花了太久。嗯,这里要提醒:Wireshark看握手时延,重点盯每个包之前的Delta时间。
下面是握手的核心流程(简化版,忽略密钥更新与会话恢复):
- ClientHello — 客户端发密码套件列表、TLS版本、随机数、Session ID、扩展(SNI、ALPN等)。
- ServerHello — 服务端选定套件、版本、随机数;同时发证书(如果要求双向认证还会发CertificateRequest)。
- Certificate — 服务端证书链,从叶子证书到根(不含根,除非特意发)。
- ServerKeyExchange — 仅DHE/ECDHE套件存在,携带公钥参数。我见过不少人忽略这条,导致密钥交换失败。
- ServerHelloDone — 服务端说“我发完了”。
- ClientKeyExchange — 客户端发premaster secret(RSA)或公钥(DHE),之后双方用随机数+premaster算出master secret。
- ChangeCipherSpec + Finished — 客户端告知后续用加密发Finished验证;服务端同样回复CCS+Finished。
别小看Finished这一步。我曾经遇到一个老版本OpenSSL的bug,服务端发完CCS后Finished直接飘丢了,客户端等超时重传,硬生生多耗了1个RTT。
| 阶段 | 方向 | 典型延迟来源 | 我的经验 |
|---|---|---|---|
| ClientHello → ServerHello | C→S | DNS、7层负载均衡选套件 | 曾见CN域名解析失败导致握手阻塞 |
| Certificate | S→C | 证书链过大、OCSP Stapling未开启 | 一个包含6级中间证的链延迟增加120ms |
| ClientKeyExchange → CCS | C→S | 非对称计算(RSA解密比ECDHE慢) | RSA 4096位在低端IoT设备上耗时近300ms |
| Finished验证 | 双向 | MAC校验、握手日志刷盘 | 老版本Nginx线程模型导致Finished延迟 |
避坑指南:我曾经在客户环境抓到过客户端发完CCS后直接发了Application Data(未等服务端Finished),服务端直接告警。这不是标准行为,很多安全检测工具会报警。务必确保两边都走完Finished再传业务数据。
3.2 加密套件协商
套件协商说白了就是“双方选一个都会用的加密配方”。客户端在ClientHello里列出一堆,服务端在自己的支持列表里挑一个最安全的。我经常跟团队说,别把客户端列表写得太多——优先把现代套件放前面,否则服务端可能选到老旧的。
举个例子,某个兼容性方案中客户端把TLS_RSA_WITH_3DES_EDE_CBC_SHA放在第1位,服务端一看自己能支持,就直接选了3DES……结果CBC模式还容易被时序攻击。正确的做法是:ECDHE+AEAD套件放前面,比如TLS_AES_128_GCM_SHA256 (TLS 1.3) 或TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (TLS 1.2)。
协商过程还受TLS版本影响。TLS 1.3的握手交互次数减到1-RTT(甚至0-RTT),但它用SignatureScheme代替了以前的CipherSuite分组确认。我亲测过,同样网络条件下TLS 1.3比1.2快约30%。
| 类型 | 典型套件 | 安全性评级 | 我个人习惯 |
|---|---|---|---|
| 现代AEAD | TLS_AES_128_GCM_SHA256 | 高 | 优先使用,支持PFS |
| 传统CBC | TLS_RSA_WITH_AES_128_CBC_SHA | 中低 | 除非兼容遗留系统,否则禁用 |
| 废弃 | TLS_RSA_WITH_3DES_EDE_CBC_SHA | 极低 | 一定要从列表移除 |
| PFS关键 | 所有以ECDHE开头的套件 | 高 | 检查是EHE还是RSA: ECDHE才提供前向安全 |
注意:很多旧版Java(JDK 6/7)默认禁用了GCM套件,导致协商降级到CBC。如果你用Java做服务端,务必检查jdk.tls.disabledAlgorithms配置。
3.3 证书验证流程
证书验证是很多工程师的盲区。我遇到过一个生产事故:客户端突然报证书错误,但浏览器访问正常。排查后发现是客户端没有携带中间证书,而服务端下发的链又缺少一级,导致客户端本地找不到信任锚定。验证流程说白了就是三关:合法性、有效性、域名匹配。
- 第一步:构建证书链 — 客户端把服务端发来的证书链(叶子+中间)和本地受信任根证书列表比对。如果找不到匹配根,报错。这条我最常踩的坑是“证书链不完整”——很多运营商CDN会截断中间证书,而客户端不信任根。
- 第二步:验证签名 — 从叶子到根逐级用上一级证书的公钥验签。实际中RSA签名验证比ECDSA慢不少。我推荐优先使用ECDSA证书+ECDHE密钥交换,验证速度快,还能减小证书体积。
- 第三步:检查有效性 — 当前时间在证书有效期、是否被撤销(CRL/OCSP)、扩展密钥用途(Key Usage、Extended Key Usage)。这里有个坑:时间不对时握手直接失败。我见过客户机器NTP同步配置错误,时间偏差超过24小时,导致所有HTTPS请求报错。
- 第四步:域名匹配 — 检查服务器主机名是否匹配证书的subjectAltName(SAN)。通配符仅生效于一个层级,比如“*.example.com”不能匹配“sub.sub.example.com”。
为什么浏览器访问正常但客户端调用失败? 因为浏览器有内置根证书库和中间证书缓存(如Firefox自有NSS库),而很多系统库(Java cacerts、Android根信任池)可能缺少某些中间根。我建议服务端始终开启OCSP Stapling,且下发完整的证书链(叶子证书+所有中间证书,不含根)。
我个人习惯用openssl s_client -connect host:443 -showcerts来快速检查服务端发来的链是否完整。如果看到verify error:num=20:unable to get local issuer certificate,那肯定漏了中间证书。
# 快速验证命令
openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null | grep -E "^Certificate chain|depth|verify"
# 输出示例
Certificate chain
0 s:CN=example.com
i:C=US, O=Amazon, CN=Amazon RSA 2048 M02
1 s:C=US, O=Amazon, CN=Amazon RSA 2048 M02
i:C=US, O=Starfield Technologies, CN=Starfield Services Root
2 s:C=US, O=Starfield Technologies, CN=Starfield Services Root
i:C=US, O=Starfield Technologies, CN=Starfield Root Certificate Authority - G2
--- 这里如果没有第3级说明链不完整
警惕:部分CDN或WAF在透传证书时可能会替换自己的证书。这在某些内网架构中很常见。如果你的业务需要验证端到端完整证书链,记得在白名单里排除中间代理的证书。
图上省略了ServerKeyExchange和CertificateRequest,实际中很常见。如果你碰到支持ECDHE的服务,记得检查ServerKeyExchange里携带的椭圆曲线参数(支持哪几条曲线)。我曾经遇到过服务端只允许secp256r1,而客户端列表只有X25519,导致协商失败。
检查证书链小技巧:用 openssl x509 -text -noout -in cert.pem | grep -A1 "Subject Key Identifier" 看SKI,再用 openssl x509 -text -noout -in ca.pem | grep "Authority Key Identifier" 确认上一级证书的AKI是否匹配。我在排障时经常这么干。
好了,到这里握手里面的三个核心环节——阶段、套件、证书验证——就掰扯清楚了。每一个环节的延迟和错误都可能让HTTPS变成“HTTDNS”。下回遇到握手慢,别先怀疑网络,先按这个流程定位一下到底是哪个阶段在拖后腿。