TLS握手与证书验证:失败在哪一段,信任从哪来
先读 HTTP/TLS 连接池与重试边界——那页是 deadline、重试与池容量的唯一权威;本页回答两个后续问题:一次 TLS 握手分哪几段、三类证书失败为什么不可互换,以及连接复用与两级超时的实测边界。协议依据 RFC 8446 TLS 1.3、RFC 6125 主机名验证(查询 2026-10-25);实现固定 OpenJDK jdk-17.0.12+7,本机实测 macOS 26.7.1 x86_64 + Oracle JDK 17.0.12+8-LTS-286。
握手分段与可观测点
TLS 1.3 的握手(RFC 8446 §4)四段:ClientHello(带 SNI 目标名、ALPN 协议清单、密钥共享)→ ServerHello(选定协议版本与 cipher suite)→ EncryptedExtensions + Certificate + CertificateVerify + Finished(证书与证明在加密层内)→ 应用数据。与 1.2 相比,1.3 的证书传输已进入加密段,抓包只能看到明文的两个 Hello——这意味着"抓包看不到证书内容"在 1.3 是常态,诊断要靠运行时证据而不是报文。
JDK 17 提供两级运行时证据:javax.net.debug=ssl:handshake 打印每条握手消息的产生/消费行。本票实验在单 JVM 内同时跑服务端与客户端,实测捕获(Oracle JDK 17.0.12+8,2026-10-25):
...|HttpClient-1-Worker-0|ClientHello.java:641|Produced ClientHello handshake message
...|pool-1-thread-1|ClientHello.java:827|Negotiated protocol version: TLSv1.3 (服务端视角)
...|HttpClient-1-Worker-0|ServerHello.java:897|Consuming ServerHello handshake message
...|HttpClient-1-Worker-0|ServerHello.java:993|Negotiated protocol version: TLSv1.3 (客户端视角)"Produced/Consuming" 成对出现且各标线程,能直接看出协商结果停在谁手里;Negotiated protocol version 是版本证据,比"配了 TLSv1.3"可靠。注意 debug 输出走向:JDK 17 把它写到 stderr(实验 runner 需合并两个流),且握手成功与否都会打印——它是观察通道,不是断言。
代理会改变对端。HttpClient 默认用系统 ProxySelector:对主机名请求先向代理发 CONNECT,由代理去连真实目标——本票实验初版漏设 .proxy(ProxySelector.of(null)),本机系统代理(127.0.0.1:7897)截获了请求,客户端报的正是 Remote host terminated the handshake。也就是说:这条消息的第一嫌疑人是对端断连,第二嫌疑人就是代理层;对 IP 直连与显式禁代理后复测是排障第一步。
三道验证边界不可互换
客户端在握手里依次验证三件独立的事,失败消息互不相同(本票全部实测):
| 边界 | 验证什么 | 实测失败消息(Oracle JDK 17.0.12+8) | 修复方向 |
|---|---|---|---|
| 信任锚 | 证书链能否到达本地信任的 CA | PKIX path building failed: … unable to find valid certification path | 导入正确的 CA,而不是关验证 |
| 有效期 | 证书在当前时间是否有效 | PKIX path validation failed: … validity check failed | 换新证书 |
| 主机名 | 证书 SAN 覆盖正在连接的名字(RFC 6125) | No subject alternative DNS name matching bad.test found. | 连对域名或补 SAN |
三道按序执行:链都建不起来时,过期与主机名都轮不到报。所以"过期证书上线"的报错常常不是过期——先看链。JDK 内部把异常映射为 fatal alert 下发:
原码连续摘录:SSLSocketImpl.java L1684–L1712,仅统一公共缩进。
// need to perform error shutdown
boolean isSSLException = (cause instanceof SSLException);
Alert alert;
if (isSSLException) {
if (cause instanceof SSLHandshakeException) {
alert = Alert.HANDSHAKE_FAILURE;
} else {
alert = Alert.UNEXPECTED_MESSAGE;
}
} else {
if (cause instanceof IOException) {
alert = Alert.UNEXPECTED_MESSAGE;
} else {
// RuntimeException
alert = Alert.INTERNAL_ERROR;
}
}
if (cause instanceof SocketException) {
try {
throw conContext.fatal(alert, cause);
} catch (Exception e) {
// Just delivering the fatal alert, re-throw the socket exception instead.
}
throw (SocketException)cause;
}
throw conContext.fatal(alert, cause);分支把握手类异常归为 HANDSHAKE_FAILURE alert,其余 IOException 归 UNEXPECTED_MESSAGE;SocketException 先尽力下发 alert 再重抛原异常——对端于是能区分"协议层失败"与"连接层故障",但alert 类型不携带根因,客户端消息仍以本表三行为准。对端视角的 EOF 有专门分支:
原码连续摘录:同文件 L1715–L1728。
private Plaintext handleEOF(EOFException eofe) throws IOException {
if (requireCloseNotify || conContext.handshakeContext != null) {
SSLException ssle;
if (conContext.handshakeContext != null) {
ssle = new SSLHandshakeException(
"Remote host terminated the handshake");
} else {
ssle = new SSLProtocolException(
"Remote host terminated the connection");
}握手未完成时收到 EOF 一律转成 Remote host terminated the handshake——这是一句不指认根因的兜底话:对端直接断、代理截断 CONNECT 隧道、协议版本不匹配都能走到这里。正文与排障手册都应把它当"起点"而不是"结论"。
三道边界之外还有两道常见误置的边界:SNI 只是告诉服务器要哪个名字,不参与客户端验证;关闭任一道验证(自定义 TrustManager 全放行、关主机名校验)都不能作为连通性修复——那是在删掉边界而不是找到问题。
复用与超时的覆盖范围(实测)
HTTP/1.1 keep-alive(RFC 9112 §9.3)由两端共同维持:本票实验里服务端循环读多个请求时,3 个请求共用 1 次 accept;首版服务端每连接只服务一个请求就关闭,同样代码变成 3 请求 3 accept——"客户端没关连接"不等于"连接被复用"。连接的控制权实测边界:
- 客户端手动
Connection: close被拒:IllegalArgumentException: restricted header name: "Connection"(JDK HttpClient 的受限头规则;jdk.httpclient.allowRestrictedHeaders可以放开,属实现细节不是正道)。 - 服务端响应
Connection: close生效:断连后下一请求新建连接(实测 accept 计数 1→2)。 connectTimeout只覆盖建连阶段;实测对"已 accept 但不响应"的服务器,请求在.timeout(700ms)附近抛HttpTimeoutException: request timed out(实测 706–723ms),证明两级超时互不替代。- 无监听端口的拒绝是另一个量级:实测 ~3ms 返回
ConnectException: Connection refused(RST)。真正的连接超时(无响应路径)本机无法构造——对 RFC 5737 文档地址 192.0.2.1 的实测连接竟然成功(本机网络不把文档地址当黑洞),该路径按 TCP 页的NioSocketImpl分支与 JDK 手册推导,标注未实测。
TLS 会话恢复与 TCP 连接复用也是两件事:前者省的是握手(1.3 用 PSK),后者省的是整个连接建立;HTTP/2 多路复用与队列头阻塞留在 HTTP 页,本页不重复。
实验:临时 CA 与四类负例
公开 runner 的 tls 模式在 TemporaryDirectory 里用 keytool 生成全部材料(不触碰系统钥匙串/cacerts):文档域名 good.test/bad.test 通过私有 hosts 文件映射到 127.0.0.1(JVM 内解析,不发真实 DNS),lab CA 自签 2 天,服务端证书 SAN 含 dns:good.test,ip:127.0.0.1,另一份由同一 CA 签发但 -startdate -400d 制造已过期证书。五个子案例与实测结论:
| 子案例 | 输入 | 实测结果 |
|---|---|---|
| tls-pos | 自有 truststore + good.test | 200 OK,TLSv1.3,TLS_AES_256_GCM_SHA384 |
| tls-trust | 默认信任库 + 同一服务 | PKIX path building failed(lab CA 不在 cacerts) |
| tls-expire | 过期证书 + lab CA 信任 | PKIX path validation failed: validity check failed |
| tls-san | good.test 证书 + 连 bad.test | No subject alternative DNS name matching bad.test found. |
| tls-debug | 正例 + javax.net.debug=ssl:handshake | 200 + 实测握手分段行(见上文) |
与旧页 TLS 负例的未运行协议对照:那页列出的"临时 CA/truststore 与正确测试域名对照不可信、过期、SAN 不符"三项由本实验落实为实跑,撤销检查(OCSP/CRL)、mTLS 客户端证书、0-RTT、HTTP/2 与连接池压测仍未运行,不冒充已验证。
架构师推演:证书轮换与信任库预算(算例,非实测)
设服务证书有效期 90 天、自动化轮换成功率 99%:每年约 4 次轮换、期望 1 次人工介入,监控起点应设在剩余 30 天而不是过期当天(剩余 1 天时轮换失败已无回滚窗口)。信任库变更(加中间 CA、换根)的影响面比单证书大:本票实验证明"链建不起来"先于一切失败——只更新叶子证书而漏发中间 CA 的部署,客户端会以 PKIX path building failed 报错(该组合为推导,未构造中间 CA 实验)。两个方案的取舍:有效期短+全自动(过期窗口小,自动化是新故障域)vs 有效期长+双人复核(窗口大,人工是故障源);恢复责任上,证书过期告警、轮换执行、撤销应急三者分属不同角色,验收必须演练"轮换失败 48 小时内回退到旧证书"而不是只演练成功路径。撤销检查(OCSP/CRL)本票未配置未实跑,上线软失败/硬失败策略前必须先固定验证手段(openssl s_client 与 javax.net.debug 各自能回答什么)。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
- RFC 8446 §4、RFC 6125、RFC 9112 §9.3(查询 2026-10-25):握手分段、主机名验证、keep-alive 语义。
- JDK 17 HttpClient(connectTimeout/proxy/受限头)、javax.net.ssl 与
javax.net.debug手册、jdk.httpclient.allowRestrictedHeaders(查询 2026-10-25)。 - OpenJDK
jdk-17.0.12+7SSLSocketImpl.javaL1684–L1712 与 L1715–L1728(本页两处摘录):alert 分类与 EOF 兜底消息;本机运行为 Oracle JDK 17.0.12+8,构建号不同如实分记。 - 本机实验:Oracle JDK 17.0.12+8-LTS-286、macOS 26.7.1 x86_64,见实验 README 与证据记录;撤销检查、mTLS、0-RTT、HTTP/2 未实跑;连接超时路径按源码推导(TEST-NET 实测反例已登记)。