本节目标:让第二次 TLS 连接少花一个往返,协商出
h2或http/1.1,并把主机名校验和证书链校验放进同一条策略。
适用版本:JDK 25(Temurin 25.0.5+8);只使用javax.net.ssl与java.security.cert。
承接的问题
10.2 的 NonBlockingTlsHandshake 每次连接都从头握手:客户端发 ClientHello,服务端回证书和 Finished,客户端验链、验签、发 Finished。一次完整握手至少一个往返,还要做一次证书链验证和一次私钥签名。linkd 面对短连接或频繁重连时,这些成本会直接摊到首字节时间上。
TLS 1.3 用 session ticket 做复用:服务端在握手后发一张票据,客户端缓存它;下一次连接在 ClientHello 里带上 pre_shared_key,服务端验证票据后直接派生密钥,省掉证书和签名。复用的连接仍然要握手,但消息少、计算少。
复用之外还有两个问题:这条连接到底跑 h2 还是 http/1.1,由 ALPN 决定;证书链验证通过只说明「某个 CA 签过这张证书」,不说明「这张证书是发给当前主机名的」,主机名校验必须单独打开。
linkd 的 TLS 入口在 9443,ALPN 只提供 h2 和 http/1.1;h2 接到第 9 章的帧处理,http/1.1 接到第 8 章的解析器。本节把复用、协议选择、主机名和链校验收进 TlsSessionPolicy。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:session ticket 与会话复用
TLS 1.3 的复用不是「记住 session id」,而是「记住票据」。完整握手结束后,服务端发 NewSessionTicket,票据里含 PSK(pre-shared key)和票据生命周期;客户端把它存进 SSLSessionContext。下一次连接,客户端在 ClientHello 里带 pre_shared_key 扩展和票据,服务端用票据密钥解密、校验后派生 PSK。
缓存由 SSLSessionContext 管,客户端和服务端各有一份:
setSessionCacheSize(int size):缓存多少条会话;0 表示禁用。setSessionTimeout(int seconds):会话存活多少秒;0 表示不过期,通常不要这么设。- 服务端还有票据密钥轮换;票据是单次的,用一次就作废,客户端要保存多张。
linkd 在启动时给同一个 SSLContext 配缓存,客户端和服务端都配:
SSLSessionContext clientSessions = context.getClientSessionContext();
clientSessions.setSessionCacheSize(256);
clientSessions.setSessionTimeout(3600);
SSLSessionContext serverSessions = context.getServerSessionContext();
serverSessions.setSessionCacheSize(256);
serverSessions.setSessionTimeout(3600);
两个上下文不能混:getClientSessionContext() 存的是「我作为客户端连别人」的会话,getServerSessionContext() 存的是「别人连我」的会话。只配一边,另一边仍然用默认值。
本机用两个 SSLSocket 连续连同一个服务端,第二次连接的输出:
connection 1 protocol=TLSv1.3 alpn=h2 sessionIdLength=32 hostnameMatch=true resumed=n/a
pkix=PKIXCertPathValidatorResult
connection 2 protocol=TLSv1.3 alpn=h2 sessionIdLength=32 hostnameMatch=true resumed=true
resumed=true 不是靠 session id 相等判断的:本机两次的 session id 不同,JDK 25 的 TLS 1.3 恢复会换一个新的 legacy session id。本机用 session.getCreationTime() 是否沿用首次握手时间来判断,并配合 -Djavax.net.debug=ssl:handshake 交叉验证。日志里能看到:
PreSharedKeyExtension.java:703|Found resumable session. Preparing PSK message.
PreSharedKeyExtension.java:406|Resuming session:
ServerHello.java:1250|Using PSK to derive early secret
本机输出(-Djavax.net.debug=ssl:handshake 节选,行号随 JDK 构建变化)。这三行说明客户端确实带了 PSK,服务端也确实用它派生了 early secret,不是一次新的完整握手。
一个容易漏掉的细节:客户端必须把握手后的记录读干净,票据才会被处理。如果 startHandshake() 一返回就关 socket,NewSessionTicket 可能还在内核缓冲区里,第二次连接自然复用不了。本机实验在关闭前用 setSoTimeout(300) 读到超时,就是为了把票据收完。
核心机制二:ALPN 协商 h2 与 http/1.1
ALPN(Application-Layer Protocol Negotiation)在 TLS 握手里夹带一个协议列表,客户端按自己的偏好排序,服务端从自己支持的列表里选交集。协商结果通过 SSLEngine.getApplicationProtocol() 读取;没有交集时握手直接失败。
设置方式是在 beginHandshake() 之前把 SSLParameters 装到引擎上:
SSLParameters parameters = new SSLParameters();
parameters.setApplicationProtocols(new String[]{"h2", "http/1.1"});
engine.setSSLParameters(parameters);
服务端也用同一个方法设置自己支持的列表。客户端和服务端的列表不必完全相同,但必须有交集。本机两边都配置 h2、http/1.1,结果是:
connection 1 protocol=TLSv1.3 alpn=h2 sessionIdLength=32 hostnameMatch=true resumed=n/a
如果客户端只给 http/1.1、服务端只给 h2,本机在客户端拿到:
javax.net.ssl.SSLHandshakeException: (no_application_protocol) Received fatal alert: no_application_protocol
这不是证书问题,也不是套件问题,而是应用层协议没有交集。排查时先看两边的 setApplicationProtocols,不要先换证书。
linkd 在握手完成后按 getApplicationProtocol() 分流:h2 进入第 9 章的 preface 和帧解码,http/1.1 进入第 8 章的请求行解析。分流必须发生在读取第一段应用数据之前,因为 HTTP/2 的 24 字节 preface 和 HTTP/1.1 的请求行无法靠猜区分。
ALPN 的选择顺序不要在应用层假设。JSSE 的服务端选择逻辑属于实现细节;客户端把偏好的协议排在前面,服务端配置自己愿意接受的集合,最终以 getApplicationProtocol() 的返回值为准。
核心机制三:主机名校验与 PKIX 路径校验
SSLEngine 默认只做证书链校验,不做主机名校验。也就是说,一张由受信任 CA 签发给 example.com 的证书,用来冒充 libraryd,SSLEngine 也会握手成功。必须显式打开:
SSLParameters parameters = new SSLParameters();
parameters.setEndpointIdentificationAlgorithm("HTTPS");
engine.setSSLParameters(parameters);
HttpsURLConnection 和 java.net.http.HttpClient 默认会做这一步,裸 SSLEngine 不会。这是从阻塞客户端迁移到非阻塞引擎时最容易丢的安全检查。
主机名校验的规则是:如果证书有 SAN,只匹配 SAN 里的 dNSName 和 iPAddress;没有 SAN 时才回退 CN。DNS 通配符只允许最左一个标签,*.example.com 匹配 a.example.com,不匹配 example.com,也不匹配 a.b.example.com。IP 地址要按字节比较,不能按字符串比较。
链校验用 PKIXParameters 和 CertPathValidator:
Set<TrustAnchor> anchors = new HashSet<>();
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (trustStore.getCertificate(alias) instanceof X509Certificate cert) {
anchors.add(new TrustAnchor(cert, null));
}
}
PKIXParameters parameters = new PKIXParameters(anchors);
parameters.setRevocationEnabled(false);
CertPath path = CertificateFactory.getInstance("X.509")
.generateCertPath(List.of(chain));
CertPathValidator.getInstance("PKIX").validate(path, parameters);
本机对自签链跑这段代码,返回 PKIXCertPathValidatorResult,同时 hostnameMatch=true。setRevocationEnabled(false) 只是为了本地实验不依赖 CRL/OCSP;生产要打开吊销检查,并给 PKIXParameters 配 PKIXRevocationChecker,否则被撤销的证书仍会通过。
在 linkd 里落地:TlsSessionPolicy
TlsSessionPolicy 把缓存、ALPN、主机名和 PKIX 校验收在一个类里。linkd 每个 TLS 监听器创建一个实例,所有连接共享同一个 SSLContext 和同一份会话缓存。
package com.plume.linkd.tls;
import javax.net.ssl.*;
import java.net.*;
import java.security.*;
import java.security.cert.*;
import java.util.*;
public final class TlsSessionPolicy {
private final List<String> applicationProtocols;
private final boolean verifyHostname;
private final int cacheSize;
private final int timeoutSeconds;
public TlsSessionPolicy(List<String> applicationProtocols, boolean verifyHostname,
int cacheSize, int timeoutSeconds) {
if (applicationProtocols.isEmpty()) {
throw new IllegalArgumentException("at least one ALPN protocol is required");
}
this.applicationProtocols = List.copyOf(applicationProtocols);
this.verifyHostname = verifyHostname;
this.cacheSize = cacheSize;
this.timeoutSeconds = timeoutSeconds;
}
public void configure(SSLContext context) {
configure(context.getClientSessionContext());
configure(context.getServerSessionContext());
}
private void configure(SSLSessionContext sessions) {
sessions.setSessionCacheSize(cacheSize);
sessions.setSessionTimeout(timeoutSeconds);
}
public SSLParameters clientParameters() {
SSLParameters parameters = new SSLParameters();
parameters.setApplicationProtocols(applicationProtocols.toArray(String[]::new));
if (verifyHostname) {
parameters.setEndpointIdentificationAlgorithm("HTTPS");
}
return parameters;
}
public SSLParameters serverParameters() {
SSLParameters parameters = new SSLParameters();
parameters.setApplicationProtocols(applicationProtocols.toArray(String[]::new));
return parameters;
}
public String selectedProtocol(SSLEngine engine) {
String protocol = engine.getApplicationProtocol();
if (protocol == null || protocol.isEmpty()) {
throw new IllegalStateException("ALPN was not negotiated");
}
return protocol;
}
public boolean hostnameMatches(String host, SSLSession session) {
try {
X509Certificate cert = (X509Certificate) session.getPeerCertificates()[0];
return hostnameMatches(host, cert);
} catch (SSLPeerUnverifiedException e) {
return false;
}
}
static boolean hostnameMatches(String host, X509Certificate cert) {
boolean ip = isIpAddress(host);
Collection<List<?>> names;
try {
names = cert.getSubjectAlternativeNames();
} catch (java.security.cert.CertificateParsingException e) {
return false;
}
if (names == null) {
return false;
}
for (List<?> entry : names) {
int type = (Integer) entry.get(0);
String value = (String) entry.get(1);
if (ip && type == 7 && ipMatches(host, value)) {
return true;
}
if (!ip && type == 2 && dnsMatches(host, value)) {
return true;
}
}
return false;
}
private static boolean isIpAddress(String host) {
return host.indexOf(':') >= 0 || host.matches("\\d{1,3}(\\.\\d{1,3}){3}");
}
private static boolean ipMatches(String host, String san) {
try {
return Arrays.equals(InetAddress.getByName(host).getAddress(),
InetAddress.getByName(san).getAddress());
} catch (UnknownHostException e) {
return false;
}
}
private static boolean dnsMatches(String host, String pattern) {
String name = IDN.toASCII(host).toLowerCase(Locale.ROOT);
String rule = IDN.toASCII(pattern).toLowerCase(Locale.ROOT);
if (!rule.startsWith("*.")) {
return name.equals(rule);
}
if (rule.indexOf('*', 1) >= 0) {
return false;
}
String suffix = rule.substring(1);
if (!name.endsWith(suffix)) {
return false;
}
String label = name.substring(0, name.length() - suffix.length());
return !label.isEmpty() && label.indexOf('.') < 0;
}
public boolean resumed(SSLSession previous, SSLSession current) {
return previous != null
&& current != null
&& previous.getCreationTime() == current.getCreationTime()
&& previous.getProtocol().equals(current.getProtocol());
}
public static PKIXParameters trustParameters(KeyStore trustStore)
throws GeneralSecurityException {
Set<TrustAnchor> anchors = new HashSet<>();
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (trustStore.getCertificate(alias) instanceof X509Certificate cert) {
anchors.add(new TrustAnchor(cert, null));
}
}
PKIXParameters parameters = new PKIXParameters(anchors);
parameters.setRevocationEnabled(false);
return parameters;
}
public static CertPathValidatorResult validate(X509Certificate[] chain, KeyStore trustStore)
throws GeneralSecurityException {
CertPath path = CertificateFactory.getInstance("X.509")
.generateCertPath(List.of(chain));
return CertPathValidator.getInstance("PKIX").validate(path, trustParameters(trustStore));
}
}
linkd 的用法只有三行:
TlsSessionPolicy policy = new TlsSessionPolicy(List.of("h2", "http/1.1"), true, 256, 3600);
policy.configure(serverContext);
serverEngine.setSSLParameters(policy.serverParameters());
客户端引擎装 policy.clientParameters(),服务端引擎装 policy.serverParameters();握手完成后用 policy.selectedProtocol(engine) 分流。resumed 只用于观测,不能拿它做安全判断:真正的身份判断是证书链加主机名。
hostnameMatches 是 RFC 6125 的教学子集:支持精确 DNS、单层通配符和 IP SAN,不支持 IDN 通配符、多级通配符和 CNAME 回溯。生产优先用 endpointIdentificationAlgorithm("HTTPS") 让 JSSE 在握手阶段完成校验,手工方法只用于握手后复核或特殊客户端。
常见错误与边界
错误一:用 session id 判断复用。 TLS 1.3 恢复会换 session id,本机两次连接的 id 不同,但 -Djavax.net.debug 明确显示 Using PSK。比较 id 会得出「没复用」的错误结论;SSLSession 也没有公开的 isResumed()。
错误二:忘记打开主机名校验。 裸 SSLEngine 只验链,不验主机名。正确做法是在 beginHandshake() 之前设置 setEndpointIdentificationAlgorithm("HTTPS"),不要等握手完成再补。
错误三:拿 HttpsURLConnection.getDefaultHostnameVerifier() 做手工校验。 JDK 25 里这个默认实现就是 return false 的占位类,直接用会把所有主机名判成不匹配。要么用 endpointIdentificationAlgorithm,要么自己按 SAN 规则实现。
错误四:握手后立刻关闭连接。 NewSessionTicket 是握手后的记录,客户端不读就不会缓存。短连接压测里看到「复用率一直是 0」,先检查关闭时机,再检查缓存配置。
错误五:只配客户端或只配服务端缓存。 客户端缓存决定「我带不带票据」,服务端缓存决定「我认不认票据」。只配一边,复用率会停在一半。
错误六:生产环境关闭吊销检查。 示例里的 setRevocationEnabled(false) 是为了本地不依赖网络;生产要打开,并配置 CRL 或 OCSP,否则被撤销的证书仍然能通过链校验。
边界:0-RTT 不在本书范围。 TLS 1.3 的 0-RTT 可以在第一个飞行里带应用数据,但有重放风险,linkd 不启用。客户端证书(mTLS)也没有展开:它需要 setNeedClientAuth(true)、双向 truststore 和独立的授权策略,和「服务端证明自己」是两套问题。
小结
- TLS 1.3 用
NewSessionTicket做复用,票据是单次的,客户端要读干净握手后的记录才会缓存。 - 客户端和服务端的
SSLSessionContext分开配置;缓存大小和超时按连接量设置。 - TLS 1.3 恢复会换 session id,不能用 id 相等判断复用;
SSLSession没有公开的isResumed()。 - ALPN 在
SSLParameters里设置,握手后用getApplicationProtocol()读取;没有交集会报no_application_protocol。 SSLEngine默认不校验主机名,必须设置endpointIdentificationAlgorithm("HTTPS")。PKIXParameters从 truststore 建信任锚,CertPathValidator做路径校验;生产要打开吊销检查。
TLS 这一章到这里收口:10.1 确认证书和握手参数,10.2 把握手拆成非阻塞状态机,10.3 决定复用、协议和身份。下一节进入 WebSocket,先看 Upgrade 握手怎么在 HTTP/1.1 的连接上升级出一个双向帧通道。
阅读导航:上一节:10.2 SSLEngine 非阻塞握手 · 下一节:11.1 WebSocket 握手与 Upgrade 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。