本节目标:看懂 TLS 1.3 一次 1-RTT 握手交换了哪些消息,并能用 keytool 生成一张 SAN 正确、可被 Java 校验的本地证书。
适用版本:JDK 25(Temurin 25.0.5+8);只使用javax.net.ssl与 JDK 自带keytool。
承接的问题
9.3 的 HTTP/2 多路复用已经能在一条连接上并发处理多个流,但那条连接是明文的。linkd 一旦离开 loopback,明文就意味着路径上的任何一跳都能读到 /books/{id} 的请求头、响应体和 Cookie。TLS 要解决的不是「把字节变乱」,而是三件事:对端身份可信、传输内容不可读、内容未被篡改。
TLS 1.3 把完整握手压缩到 1-RTT:客户端在第一个 ClientHello 里就带上密钥份额,服务端回一个 ServerHello 和加密的证书飞行,客户端验证完就能发 Finished 和业务数据。TLS 1.2 的完整握手通常要 2-RTT,差别不只是快一点,而是首字节时间少一个往返。
linkd 的明文入口仍是 9090;TLS 实验入口单独用 9443,避免和现有实验脚本抢端口。本节先用阻塞 SSLSocket 把证书和握手参数验证清楚,10.2 再换成 SSLEngine 接进 6.2 的 EventLoop。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:1-RTT 里交换了什么
TLS 1.3 的完整握手可以按「明文两帧、加密一大段」来记:
| 顺序 | 消息 | 是否加密 | 关键内容 |
|---|---|---|---|
| 1 | ClientHello | 明文 | 支持的版本、SNI、ALPN、key_share、signature_algorithms |
| 2 | ServerHello | 明文 | 选定的版本、套件、服务端 key_share |
| 3 | EncryptedExtensions | 加密 | ALPN 选择、其他扩展 |
| 4 | Certificate | 加密 | 叶子证书与中间证书链 |
| 5 | CertificateVerify | 加密 | 用证书私钥签握手 transcript |
| 6 | Finished | 加密 | 对 transcript 的 HMAC |
| 7 | Finished(客户端) | 加密 | 客户端确认,之后可发应用数据 |
ClientHello 和 ServerHello 里的 key_share 让双方各自算出共享秘密,再用 HKDF 派生出握手密钥。从 ServerHello 之后,后面的握手消息全部加密,所以抓包只能看到前两个明文消息;证书在 TLS 1.3 里是加密的,这一点和 TLS 1.2 不同。
三条结论直接影响排查:
- 抓包看不到证书内容,只能看到 SNI 和套件。想确认证书,用
keytool -list -v或握手后的SSLSession。 CertificateVerify用的是证书私钥,不是密钥交换私钥;证书和密钥交换是两套东西。Finished校验的是整段握手 transcript,任何一条消息被改动都会在这里失败。
TLS 1.3 的套件只保留 AEAD,JDK 25 默认提供 TLS_AES_128_GCM_SHA256、TLS_AES_256_GCM_SHA384、TLS_CHACHA20_POLY1305_SHA256。套件名里不再包含密钥交换算法和签名算法,这两者分别由 key_share 和 signature_algorithms 协商。
核心机制二:用 keytool 生成带 SAN 的证书
本地实验不需要 CA。用 keytool 生成一张自签的 EC 证书,把 localhost 和 127.0.0.1 都写进 SAN:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
keytool -genkeypair -alias linkd-local \
-keyalg EC -groupname secp256r1 -sigalg SHA256withECDSA \
-dname "CN=localhost, OU=linkd, O=Plume, L=Hangzhou, ST=Zhejiang, C=CN" \
-ext "SAN=dns:localhost,ip:127.0.0.1" \
-validity 365 -storetype PKCS12 \
-keystore linkd.p12 -storepass changeit -keypass changeit
本机输出:
正在为以下对象生成 256 位 EC (secp256r1) 密钥对和自签名证书 (SHA256withECDSA)(有效期为 365 天):
CN=localhost, OU=linkd, O=Plume, L=Hangzhou, ST=Zhejiang, C=CN
几个参数的选择理由:
-keyalg EC -groupname secp256r1:P-256 密钥短、握手快,JDK 25 默认支持;换成 RSA 也能跑,但证书和签名会大很多。-ext SAN=...:现代客户端按 SAN 匹配主机名。只有证书完全没有 SAN 时,JDK 才回退到 CN;一旦带了 SAN,CN 不再参与匹配。-storetype PKCS12:JDK 9 之后的默认格式,单个文件同时装私钥和证书链。-validity 365:本地证书不要设太长;过期后握手报certificate_has_expired,症状和主机名错误很像。
用 keytool -list -v 确认证书链和 SAN:
keytool -list -v -alias linkd-local -keystore linkd.p12 -storepass changeit
本机输出(节选):
别名: linkd-local
条目类型: PrivateKeyEntry
证书链长度: 1
证书[1]:
所有者: CN=localhost, OU=linkd, O=Plume, L=Hangzhou, ST=Zhejiang, C=CN
发布者: CN=localhost, OU=linkd, O=Plume, L=Hangzhou, ST=Zhejiang, C=CN
签名算法名称: SHA256withECDSA
主体公共密钥算法: 256 位 EC (secp256r1) 密钥
扩展:
#1: ObjectId: 2.5.29.17 Criticality=false
SubjectAlternativeName [
DNSName: localhost
IPAddress: 127.0.0.1
]
证书链长度: 1 说明这是自签证书:它既是叶子证书,也是信任锚。生产环境服务端至少要发「叶子 + 中间 CA」两张,根证书不发送,由客户端在 truststore 里找。链的顺序必须是叶子在前、中间在后;顺序反了,客户端会报 unable to find valid certification path。
服务端只发叶子、漏掉中间证书,是线上最常见的 TLS 故障之一。浏览器有时能靠缓存补链,Java 客户端通常直接失败,所以用 keytool -list -v 看 证书链长度 是最快的确认方式。
客户端要信任这张自签证书,需要把公钥证书导入 truststore:
keytool -exportcert -alias linkd-local -keystore linkd.p12 \
-storepass changeit -file linkd.crt
keytool -importcert -alias linkd-local -file linkd.crt \
-keystore trust.p12 -storetype PKCS12 -storepass changeit -noprompt
本机输出:
存储在文件 <linkd.crt> 中的证书
证书已添加到密钥库中
linkd.p12 里有私钥,不能提交到仓库,也不能放进容器镜像的公开层;trust.p12 只含公钥证书,可以随测试环境分发。两者的密码在实验里都用 changeit,生产必须换成密钥管理服务注入的值。
核心机制三:用 TlsHandshakeInspector 读握手结果
握完手之后,SSLSession 里有协议版本、套件、对端证书链。把这些打印出来,比猜「到底连没连上」有用得多:
package com.plume.linkd.tls;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
public final class TlsHandshakeInspector {
private TlsHandshakeInspector() {
}
public static String describe(SSLSession session) {
StringBuilder out = new StringBuilder();
out.append("protocol=").append(session.getProtocol()).append('\n');
out.append("cipher=").append(session.getCipherSuite()).append('\n');
out.append("sessionIdLength=").append(session.getId().length).append('\n');
out.append("peerHost=").append(session.getPeerHost()).append('\n');
try {
Certificate[] chain = session.getPeerCertificates();
out.append("chainLength=").append(chain.length).append('\n');
for (int i = 0; i < chain.length; i++) {
X509Certificate cert = (X509Certificate) chain[i];
out.append("cert[").append(i).append("].subject=")
.append(cert.getSubjectX500Principal().getName()).append('\n');
out.append("cert[").append(i).append("].issuer=")
.append(cert.getIssuerX500Principal().getName()).append('\n');
out.append("cert[").append(i).append("].san=")
.append(formatSans(cert)).append('\n');
}
} catch (SSLPeerUnverifiedException e) {
out.append("peer=unverified\n");
}
return out.toString();
}
private static String formatSans(X509Certificate cert) {
try {
Collection<List<?>> names = cert.getSubjectAlternativeNames();
if (names == null || names.isEmpty()) {
return "[]";
}
StringBuilder out = new StringBuilder("[");
for (List<?> entry : names) {
if (out.length() > 1) {
out.append(", ");
}
int type = ((Integer) entry.get(0)).intValue();
out.append(type == 2 ? "dns:" : type == 7 ? "ip:" : "type" + type + ":");
out.append(entry.get(1));
}
return out.append(']').toString();
} catch (Exception e) {
return "[unreadable:" + e.getMessage() + "]";
}
}
}
服务端和客户端各自用自己的 SSLContext 握手,然后在两边调用 describe。本机输出:
client view:
protocol=TLSv1.3
cipher=TLS_AES_256_GCM_SHA384
sessionIdLength=32
peerHost=localhost
chainLength=1
cert[0].subject=CN=localhost,OU=linkd,O=Plume,L=Hangzhou,ST=Zhejiang,C=CN
cert[0].issuer=CN=localhost,OU=linkd,O=Plume,L=Hangzhou,ST=Zhejiang,C=CN
cert[0].san=[dns:localhost, ip:127.0.0.1]
server view:
protocol=TLSv1.3
cipher=TLS_AES_256_GCM_SHA384
sessionIdLength=32
peerHost=127.0.0.1
peer=unverified
逐行读:
protocol=TLSv1.3说明双方都禁用了旧版本;如果这里是TLSv1.2,先查setEnabledProtocols和 JDK 安全属性,不要先怀疑证书。cipher=TLS_AES_256_GCM_SHA384是 TLS 1.3 的 AEAD 套件,没有密钥交换和签名信息。sessionIdLength=32是 TLS 1.3 回显的 legacy session id,不是会话票据;10.3 讲复用时会看到它并不等于「恢复成功」。peer=unverified出现在服务端,因为本实验没有要求客户端证书;服务端看不到对端身份是正常的,不代表握手失败。chainLength=1与keytool -list -v对得上;生产环境这里应该是 2 或更多。
构造这两个上下文的代码不长,关键是服务端用 KeyManagerFactory 装私钥,客户端用 TrustManagerFactory 装信任锚:
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (InputStream in = Files.newInputStream(Path.of("linkd.p12"))) {
keyStore.load(in, "changeit".toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(
KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, "changeit".toCharArray());
SSLContext serverContext = SSLContext.getInstance("TLSv1.3");
serverContext.init(kmf.getKeyManagers(), null, null);
客户端把 trust.p12 装进 TrustManagerFactory,再 init(null, tmf.getTrustManagers(), null)。两边都不需要第三方库;SSLContext 默认就会做证书链校验,但主机名校验要额外打开,10.3 会讲。
在 linkd 里落地
linkd 在启动时做一次 TLS 自检:用配置里的 PKCS12 建服务端上下文,在 9443 起一个临时 SSLServerSocket,再用装了同一张证书的客户端上下文连一次,打印 TlsHandshakeInspector.describe。证书过期、SAN 写错、链缺中间证书,都会在接收真实流量之前暴露。
SSLServerSocket server = (SSLServerSocket) serverContext.getServerSocketFactory()
.createServerSocket(9443, 128, InetAddress.getLoopbackAddress());
server.setEnabledProtocols(new String[]{"TLSv1.3"});
try (SSLSocket socket = (SSLSocket) server.accept()) {
socket.startHandshake();
System.out.print(TlsHandshakeInspector.describe(socket.getSession()));
}
自检开关放在 LinkdMain 的 --tls-selftest 分支,默认不阻塞启动。自检客户端必须使用 truststore,不能图省事关掉校验;关掉校验的自检只能证明「端口通了」,证明不了证书可用。
第二个落地点是代理上游。linkd 把 /books/{id} 转发到 libraryd 时,如果上游是 TLS,linkd 就是客户端,必须在自己的 truststore 里信任上游证书,并在握手后记录对端证书的 subject 和 SAN。上游换证书、证书链变短、SAN 从 libraryd 改成 libraryd.internal,都会在这里留下记录。
这一步先把证书和阻塞握手验证清楚。10.2 会把 SSLSocket 换成 SSLEngine,让握手不再占住一条线程。
常见错误与边界
错误一:只写 CN,不写 SAN。 用 -dname "CN=localhost" 而不加 -ext SAN=...,再让客户端用 localhost 连接,JDK 仍可能回退到 CN 而成功;但证书一旦带了 SAN,CN 就被忽略。把 SAN 写成 dns:example.com、用 localhost 连接,本机会得到:
javax.net.ssl.SSLHandshakeException: (certificate_unknown) No subject alternative DNS name matching localhost found.
排查顺序是先用 keytool -list -v 看 SAN,再看客户端连接的主机名,不要先改代码。
错误二:服务端只发叶子证书。 客户端 truststore 里有根 CA,但服务端没发中间 CA,路径建不起来,典型报错是 PKIX path building failed: unable to find valid certification path to requested target。keytool -list -v 看服务端的 证书链长度,再用 openssl s_client -showcerts 或 Java 的 describe 看实际发了几张。
错误三:以为抓包能看到证书。 TLS 1.3 的 Certificate 在 ServerHello 之后加密,tcpdump 只能看到 ClientHello 里的 SNI、版本和套件。想在本地看握手细节,用 -Djavax.net.debug=ssl:handshake,不要在生产上长期打开。
错误四:忽略有效期和时钟。 证书 notBefore 在未来、notAfter 已过,握手都会失败。容器里时钟漂移、镜像里证书没更新,是同一类问题;先 keytool -list -v 看生效时间,再查系统时间。
边界:自签证书只用于本地。 它没有第三方背书,不能证明「这个服务就是 linkd」。生产必须用 CA 签发的证书,并且客户端要打开主机名校验。本节也没有覆盖客户端证书(mTLS)和 TLS 1.3 的 0-RTT,前者要配 setNeedClientAuth(true) 和双向 truststore,后者有重放风险,linkd 不使用。
小结
- TLS 1.3 完整握手是 1-RTT:
ClientHello带key_share,ServerHello之后的消息全部加密。 keytool -genkeypair生成 PKCS12 私钥和自签证书,-ext SAN=必须同时写 DNS 和 IP。- 服务端发送「叶子 + 中间」证书链,根证书留在客户端 truststore;链顺序不能反。
SSLSession能读出协议、套件、证书链和 SAN,peer=unverified在没有客户端证书时是正常的。- 证书问题先看
keytool -list -v的 SAN、链长度和有效期,再查客户端主机名与时钟。 - 本节用阻塞
SSLSocket验证配置;事件循环里的非阻塞握手要换成SSLEngine。
证书和握手参数确认之后,下一个问题是握手本身不能阻塞事件循环。下一节把 SSLEngine 的 wrap/unwrap 状态机封装起来,处理 BUFFER_OVERFLOW、BUFFER_UNDERFLOW 和 NEED_TASK,再接到 6.2 的 EventLoop。
阅读导航:上一节:9.3 多路复用、流量控制与优先级 · 下一节:10.2 SSLEngine 非阻塞握手 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。