《Java 网络编程基础》10.3 会话复用、ALPN 与证书校验

完整握手之后,linkd 还要决定复用哪条会话、用 h2 还是 http/1.1、以及证书到底属于谁。本节讲 TLS 1.3 session ticket 的复用口径、ALPN 协商与主机名校验,给出 TlsSessionPolicy,并用 PKIXParameters 校验证书链。

本节目标:让第二次 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发