本节目标:把
SSLEngine的wrap/unwrap状态机写成一个可以被事件循环反复调用的类,并正确处理欠载、溢出与委托任务。
适用版本:JDK 25(Temurin 25.0.5+8);只使用javax.net.ssl与java.nio。
承接的问题
10.1 的 SSLSocket 很好用:startHandshake() 一调,线程就停在原地等对端。linkd 的 6.2 事件循环不能这样等——一条连接等一个往返,循环里排队的其他连接就一起等这个往返。TLS 握手必须拆成「读一段密文、推进一点状态、写一段密文」的步骤。
SSLEngine 就是为此设计的。它不持有 socket,也不创建线程;调用方把密文喂给 unwrap,把明文交给 wrap,引擎返回这次操作的状态和下一步该做什么。同一个引擎可以被事件循环反复调用,直到握手完成。
代价是调用方要自己管三个缓冲区、四种 Status、六种 HandshakeStatus,还要处理「目标缓冲区不够」和「记录还没收全」。这一节把这些细节收进 NonBlockingTlsHandshake,10.3 再处理会话复用和 ALPN。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:wrap、unwrap 与四种返回状态
SSLEngine 只有两个数据方向:
wrap(appData, netOut):把应用明文加密成 TLS 记录,写进netOut。unwrap(netIn, appIn):把netIn里的 TLS 记录解密,写进appIn。
每次调用返回一个 SSLEngineResult,其中的 Status 有四种:
| Status | 含义 | 调用方动作 |
|---|---|---|
OK | 有进展 | 继续循环,直到没有更多数据 |
BUFFER_UNDERFLOW | netIn 里的记录不完整 | 保留剩余字节,等下一次读事件 |
BUFFER_OVERFLOW | 目标缓冲区不够 | 扩容目标缓冲区,重试同一操作 |
CLOSED | 收到或发出了 close_notify | 停止读写,关闭连接 |
HandshakeStatus 描述握手下一步:
| HandshakeStatus | 含义 | 调用方动作 |
|---|---|---|
NEED_WRAP | 有握手数据要发 | 用空应用缓冲区调用 wrap |
NEED_UNWRAP | 等对端握手数据 | 从 netIn 解一条记录 |
NEED_UNWRAP_AGAIN | 还有已缓冲的记录要解 | 不读新数据,继续 unwrap |
NEED_TASK | 有委托任务(签名、验签) | 取出任务异步执行,完成后唤醒 |
FINISHED | 握手刚完成 | 记录完成标志 |
NOT_HANDSHAKING | 当前没有握手 | 已完成时可传应用数据 |
缓冲区大小不要写死 16384。本机在 TLS 1.3 下打印:
packet=16709
application=16704
packet 是 JSSE 为单条 TLS 记录预留的缓冲区大小,包含 5 字节记录头、1 字节内容类型、16 字节 AEAD tag 和填充;application 是单次 unwrap 最多能吐出的明文字节。握手完成后 JSSE 可能调整这两个值,所以扩容要按 getSession() 的当前值来,不能只信启动时的一次读取。
核心机制二:NonBlockingTlsHandshake 状态机
这个类只做三件事:把 socket 读到的密文攒进 netIn,按 HandshakeStatus 驱动引擎,把产生的密文和明文分别交出去。
package com.plume.linkd.tls;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLEngineResult;
import javax.net.ssl.SSLException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
public final class NonBlockingTlsHandshake {
public enum Status { NEED_READ, NEED_WRITE, NEED_TASK, DONE }
public record Pump(Status status, List<ByteBuffer> encrypted, List<ByteBuffer> application) { }
private static final ByteBuffer EMPTY = ByteBuffer.allocate(0);
private final SSLEngine engine;
private final Executor tasks;
private final Runnable wakeup;
private ByteBuffer netIn;
private ByteBuffer netOut;
private ByteBuffer appIn;
private boolean finished;
private boolean closed;
public NonBlockingTlsHandshake(SSLEngine engine, Executor tasks, Runnable wakeup)
throws SSLException {
this.engine = engine;
this.tasks = tasks;
this.wakeup = wakeup;
int packet = engine.getSession().getPacketBufferSize();
this.netIn = ByteBuffer.allocate(packet);
this.netIn.flip();
this.netOut = ByteBuffer.allocate(packet);
this.appIn = ByteBuffer.allocate(engine.getSession().getApplicationBufferSize());
engine.beginHandshake();
}
public void feed(ByteBuffer fromSocket) {
netIn.compact();
if (netIn.remaining() < fromSocket.remaining()) {
ByteBuffer bigger = ByteBuffer.allocate(netIn.position() + fromSocket.remaining());
netIn.flip();
bigger.put(netIn);
netIn = bigger;
}
netIn.put(fromSocket);
netIn.flip();
}
public Pump pump() throws SSLException {
List<ByteBuffer> encrypted = new ArrayList<>();
List<ByteBuffer> application = new ArrayList<>();
while (!closed) {
SSLEngineResult.HandshakeStatus hs = engine.getHandshakeStatus();
if (hs == SSLEngineResult.HandshakeStatus.FINISHED) {
finished = true;
continue;
}
switch (hs) {
case NEED_TASK -> {
if (!runTasks()) {
return new Pump(Status.NEED_TASK, encrypted, application);
}
}
case NEED_WRAP -> {
if (wrap(EMPTY, encrypted).getStatus() == SSLEngineResult.Status.CLOSED) {
closed = true;
}
}
case NEED_UNWRAP, NEED_UNWRAP_AGAIN -> {
if (!netIn.hasRemaining()) {
return finish(encrypted, application);
}
if (unwrap(encrypted, application).getStatus()
== SSLEngineResult.Status.BUFFER_UNDERFLOW) {
return finish(encrypted, application);
}
}
case NOT_HANDSHAKING, FINISHED -> {
finished = true;
if (netIn.hasRemaining()) {
if (unwrap(encrypted, application).bytesConsumed() > 0) {
continue;
}
}
return finish(encrypted, application);
}
}
}
return new Pump(Status.DONE, encrypted, application);
}
private Pump finish(List<ByteBuffer> encrypted, List<ByteBuffer> application) {
Status status = !encrypted.isEmpty() ? Status.NEED_WRITE
: (finished || closed) ? Status.DONE : Status.NEED_READ;
return new Pump(status, encrypted, application);
}
private boolean runTasks() {
List<Runnable> delegated = new ArrayList<>();
Runnable task;
while ((task = engine.getDelegatedTask()) != null) {
delegated.add(task);
}
if (delegated.isEmpty()) {
return true;
}
CompletableFuture<?>[] running = delegated.stream()
.map(t -> CompletableFuture.runAsync(t, tasks))
.toArray(CompletableFuture[]::new);
CompletableFuture.allOf(running).thenRun(wakeup);
return false;
}
private SSLEngineResult wrap(ByteBuffer application, List<ByteBuffer> encrypted)
throws SSLException {
while (true) {
netOut.clear();
SSLEngineResult result = engine.wrap(application, netOut);
if (result.getStatus() == SSLEngineResult.Status.BUFFER_OVERFLOW) {
netOut = ByteBuffer.allocate(netOut.capacity() * 2);
continue;
}
if (netOut.position() > 0) {
netOut.flip();
ByteBuffer copy = ByteBuffer.allocate(netOut.remaining());
copy.put(netOut);
copy.flip();
encrypted.add(copy);
}
return result;
}
}
private SSLEngineResult unwrap(List<ByteBuffer> encrypted, List<ByteBuffer> application)
throws SSLException {
while (true) {
appIn.clear();
SSLEngineResult result = engine.unwrap(netIn, appIn);
if (appIn.position() > 0) {
appIn.flip();
ByteBuffer copy = ByteBuffer.allocate(appIn.remaining());
copy.put(appIn);
copy.flip();
application.add(copy);
}
if (result.getStatus() == SSLEngineResult.Status.BUFFER_OVERFLOW) {
appIn = ByteBuffer.allocate(appIn.capacity() * 2);
continue;
}
return result;
}
}
public boolean isHandshakeFinished() {
return finished;
}
}
几个关键点:
- 构造函数里先
flip()。ByteBuffer.allocate()出来是填充模式,position=0、limit=capacity,hasRemaining()为真。不flip就调用unwrap,引擎会把 32KB 零字节当成密文,报Unrecognized SSL message, plaintext connection?。 feed用compact保留半条记录。BUFFER_UNDERFLOW时netIn里剩的是下一条记录的开头,下一次读要和它拼起来。pump是循环,不是一次调用。 一次unwrap可能只解出一条记录,但NEED_UNWRAP_AGAIN要求继续;循环直到NEED_READ、NEED_WRITE或DONE。NOT_HANDSHAKING也算完成。 JSSE 有时从NEED_WRAP直接进入NOT_HANDSHAKING,不经过一次FINISHED;只看FINISHED会漏掉完成标志。- 输出用堆缓冲区拷贝。
netOut和appIn是复用的,直接把它们交给写队列,下一次wrap/unwrap会覆盖。拷贝一次换来所有权清晰。
用两个 SSLEngine 在内存里对灌,本机输出:
round 1 client=NEED_WRITE out=1, server=NEED_READ out=0
round 2 client=NEED_READ out=0, server=NEED_TASK out=0
round 3 client=NEED_READ out=0, server=NEED_WRITE out=3
round 4 client=NEED_TASK out=0, server=NEED_READ out=0
round 5 client=NEED_TASK out=1, server=NEED_READ out=0
round 6 client=NEED_WRITE out=1, server=NEED_READ out=0
round 7 client=DONE out=0, server=NEED_WRITE out=1
client finished=true protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
server finished=true protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
第 1 轮客户端发 ClientHello;第 2 轮服务端解开后出现 NEED_TASK,把签名任务交给委托线程;第 3 轮服务端一次吐出 3 段密文;第 4、5 轮客户端在验证证书链,out=1 说明 Finished 已在任务完成前加密好;第 6 轮客户端发出 Finished;第 7 轮服务端的 out=1 通常是握手后的 NewSessionTicket,10.3 会用到。out 是密文缓冲区个数,不等于握手消息条数。
核心机制三:三种边界状态怎么处理
BUFFER_UNDERFLOW 不是错误。 它只表示 netIn 里的记录还没收全。正确处理是保留 netIn 的剩余字节,返回 NEED_READ,等下一次 onReadable。把 underflow 当异常、或者把剩余字节丢掉,会让下一条记录从中间开始解析,症状是随机握手失败。
BUFFER_OVERFLOW 要扩容后重试同一操作。 wrap 溢出说明 netOut 小于一条记录,unwrap 溢出说明 appIn 小于一条记录的明文。两个方法里都用 while (true) 包住,扩容后 continue,不能直接返回,否则这条记录永远发不出去。
NEED_TASK 不能就地跑。 委托任务包括证书链验证和私钥签名,耗时不可忽略。runTasks 把任务提交给执行器,全部完成后用 wakeup 把事件循环叫回来;pump 先返回 NEED_TASK。在事件循环线程里直接 task.run(),这条连接的握手会拖住所有连接。
在 linkd 里落地:TlsHandshakeHandler
TlsHandshakeHandler 把状态机接到 6.2 的 Handler 上。它只处理握手阶段,握手完成后由 linkd 换成 HTTP 协议处理器。
package com.plume.linkd.tls;
import com.plume.linkd.core.Connection;
import com.plume.linkd.core.EventLoop;
import com.plume.linkd.core.Handler;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLException;
import java.io.IOException;
import java.nio.ByteBuffer;
final class TlsHandshakeHandler implements Handler {
private final NonBlockingTlsHandshake handshake;
private final ByteBuffer inbound = ByteBuffer.allocate(16 * 1024);
private Connection connection;
TlsHandshakeHandler(SSLEngine engine, EventLoop loop) throws SSLException {
handshake = new NonBlockingTlsHandshake(engine, loop::execute,
() -> loop.execute(this::pump));
}
@Override
public void onReadable(Connection c) {
connection = c;
try {
int read = c.channel().read(inbound);
if (read == -1) { c.close(); return; }
if (read == 0) return;
inbound.flip();
handshake.feed(inbound);
inbound.compact();
pump();
} catch (IOException e) { c.close(); }
}
@Override public void onWritable(Connection c) { connection = c; pump(); }
@Override public void onClosed(Connection c) { }
private void pump() {
if (connection == null) return;
try {
NonBlockingTlsHandshake.Pump result = handshake.pump();
for (ByteBuffer out : result.encrypted()) connection.enqueue(out);
for (ByteBuffer app : result.application()) app.position(app.limit());
} catch (SSLException e) { connection.close(); }
}
}
onReadable 做四步:从 Connection.channel() 读到 inbound,flip 后交给 feed,compact 清空已消费部分,调用 pump。onWritable 只调用 pump,因为 6.2 的 Connection.flush() 已经把写队列排空,此时可以继续推进握手。
pump 把 result.encrypted() 逐个 connection.enqueue,由 6.2 的 Connection 打开 OP_WRITE 并负责部分写;result.application() 是握手完成后才出现的明文,这里先消费掉,10.3 之后按 ALPN 交给 HTTP/2 帧解码器或 HTTP/1.1 解析器。
NEED_TASK 的唤醒路径是 loop.execute(this::pump)。任务在业务执行器上跑完后,回到事件循环线程继续 pump;Connection 和 SSLEngine 的可变状态仍然只被一个线程碰。
常见错误与边界
错误一:netIn 分配后忘记 flip。 本机第一次跑就撞上:
javax.net.ssl.SSLException: Unrecognized SSL message, plaintext connection?
allocate 后的缓冲区 limit=capacity,里面全是零字节;unwrap 把零当成 TLS 记录头。构造函数里 netIn.flip() 一行就能避免。
错误二:用 16384 当记录缓冲区。 本机 JSSE 给 TLS 1.3 记录的 packet buffer 是 16709,包含记录头和 AEAD tag,比 16384 大。固定 16384 会在握手证书较大时反复 BUFFER_OVERFLOW,正确做法是 engine.getSession().getPacketBufferSize()。
错误三:只调用一次 unwrap。 一次读事件可能带来多条记录,unwrap 只解一条就返回,剩下的字节留在 netIn 里,握手会停在 NEED_UNWRAP 不动。必须循环到 BUFFER_UNDERFLOW、NEED_TASK 或 CLOSED。
错误四:把 BUFFER_UNDERFLOW 当握手失败。 半条记录是 TCP 流的常态,不是协议错误。只有 SSLException 和 CLOSED 才应该关闭连接。
错误五:在事件循环里执行 NEED_TASK。 证书链验证可能触发磁盘或 OCSP 访问,私钥签名也不便宜;这些任务必须交给业务执行器,完成后 wakeup。
错误六:忘记 close_notify。 直接关 socket 会让对端看到截断的 TLS 流,而不是干净的关闭。优雅关闭要先 engine.closeOutbound(),再 wrap 到 CLOSED,最后关通道;14.3 会把这条路径并进连接排空。
边界:本节只做握手。 不覆盖 0-RTT、客户端证书和握手后的 NewSessionTicket;票据要在 DONE 之后继续 unwrap,10.3 会讲。SSLEngine 默认也不校验主机名,必须显式设置 endpointIdentificationAlgorithm,同样留到 10.3。
小结
SSLEngine不持有 socket,调用方负责wrap/unwrap、缓冲区和线程。Status四种:OK继续,BUFFER_UNDERFLOW等数据,BUFFER_OVERFLOW扩容重试,CLOSED关闭。NEED_TASK的委托任务要交给执行器,完成后用wakeup回到事件循环。- 缓冲区按
getPacketBufferSize()/getApplicationBufferSize()分配,不要写死 16384。 netIn要在构造后flip,BUFFER_UNDERFLOW时要保留剩余字节。TlsHandshakeHandler把密文入Connection写队列,把明文留给后续协议处理器。
握手能非阻塞跑通之后,下一步是少握手:会话复用让第二次连接只花一个往返,ALPN 决定这条连接跑 h2 还是 http/1.1,主机名校验决定「证书到底是不是发给这个域名的」。下一节把这三件事收进 TlsSessionPolicy。
阅读导航:上一节:10.1 TLS 1.3 握手与证书链 · 下一节:10.3 会话复用、ALPN 与证书校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。