状态:v1(2026-09-28)已实现并验证(§6);评审意见按条修订。
- 把
com.netonstream:openssl(openssl-kotlin,OpenSSL 4.0.2 的无 I/O 安全封装TlsContext/TlsEngine)接到com.netonstream:io的IoStream上:TlsStream(inner: IoStream, engine)本身也是IoStream,http(https)、websocket(wss) 直接使用;QUIC 不走这里(QUIC 用 openssl-kotlin 的 QUIC TLS 接口,另见 quic SPEC §4)。 - 不实现任何密码学或 TLS 协议逻辑:握手、记录层、证书与主机名验证、ALPN 全部由
TlsEngine完成;本库只负责在协程里 搬运密文、调度读写、把状态映射到IoStream契约(neton-io SPEC §28.6)。 - 配置(信任根、证书、版本、套件、ALPN、客户端认证)就是
TlsContext的构造参数,本库不重复包装;只提供连接级的便捷入口tlsConnect(stream, context, peer)/tlsAccept(stream, context)。 - 不依赖、也不修改 neton-io 的内部实现,只用其公开 API。
- 入站:
inner.read读密文到本连接的密文输入缓冲 →engine.feedCiphertext(引擎内部 BIO 有界,可能只收下一部分,余下留在输入缓冲) →engine.read直接解密进调用方dst的底层数组(reserve/commitWrite,不经中间缓冲)。 - 出站:
engine.write直接从调用方src的底层数组取明文(每次至多 16 KiB,一条记录)→engine.drainCiphertext取出密文 →inner.write。src只在该段明文对应的密文全部交给inner之后才前移。 - 按 OpenSSL 的重试契约调度(SSL_get_error(3),4.0.2 原文:"If you get SSL_ERROR_WANT_WRITE from SSL_write() ... you should not do any
other operation that could trigger IO other than to repeat the previous SSL_write() call";WANT_READ 时则可以在两次重试之间读取):
- WANT_WRITE(读、写、握手、close_notify 都可能):立即、不挂起地把引擎输出取入暂存缓冲,然后重复同一调用;两者之间不做任何其他引擎操作, 也不跨越网络等待。因此不存在"写重试悬而未决、同时阻塞在网络写上"的状态,对端不读时也不会挡住本端的读。
- 写返回 WANT_READ:写者自己取得更多输入(与读者用输入锁和"已喂入代次"协调,同一时刻只有一方读
inner,别人刚喂过就不再读),然后重复 同一写入;期间读者等待(openssl-kotlin 当前在任何重试期间都拒绝读取;按 OpenSSL 契约 WANT_READ 时本可读取,已请其按原因区分)。 - 密文经暂存缓冲写出:持有写锁者把暂存缓冲写给
inner;该次写入进行中新取出的密文放入溢出缓冲(正在发送的缓冲不被触碰,io_uring 时由内核 持有),写完后接到暂存缓冲之后,顺序不变。暂存缓冲始终是同一数组(交替两个数组会让反应器的固定缓存失效、每次写一次分配,已实测)。 - 读者产生的密文(告警等):写锁空闲时读者自己写出;否则由持锁者在当前写完后一并写出。预算按全部待发密文计(暂存 + 溢出):超过
pendingLimit(默认 64 KiB)时读者等到全部写出再继续读取。一次引擎读取最多追加一个引擎缓冲(每次都取空),所以高水位 ≤pendingLimit+ 引擎缓冲 + 一条记录;实测高水位作为pendingHighWater可观察。(修订:初版只检查了正在发送的暂存缓冲,增长中的溢出缓冲 不计入,审查指出;小消息写入阻塞时溢出可无界增长,实测 200 KB 而上限应为 22 KB。) 注:OpenSSL 4.0.2 对 KeyUpdate 请求不在读取时应答(只置标志,下一次写时合并发送一次,见 statem_lib.ctls_process_key_update、 rec_layer_s3.cssl3_write_bytes);TLS 1.2 拒绝重协商的告警每轮只有一次。即 OpenSSL 本身目前不会让读者输出无界增长,这个预算是防御性的, 以脚本引擎测试。
- 缓冲:每连接一个密文输入缓冲、一个暂存缓冲(各为引擎缓冲容量 + 一条记录的余量)与一个很少使用的溢出缓冲;连接存续期间复用。引擎缓冲容量 不再有下限要求(1 KiB 已测)。
read:追加到dst,返回 > 0;对端发送 close_notify 后返回 -1。没有 close_notify 的 EOF 是截断,抛TlsTruncatedException(IoException),不能当作正常结束——这是 TLS 防截断攻击的要求。write:写完全部明文才返回。shutdownOutput(声明HalfClose):发送 close_notify 并写出;之后仍可读。不关闭底层 TCP 的写方向(部分实现收到 TCP FIN 会中止)。close:立即、幂等;不发送 close_notify(close不能挂起)。需要优雅结束时先shutdownOutput(),或用 neton-io 的closeGracefully。关闭时挂起的读写得到ClosedException。- 并发:同时至多一个读、一个写,第二个并发调用抛
IllegalStateException。只在创建它的线程上使用(不声明AnyThread)。 - 超时:
ReadTimeout/WriteTimeout/IdleTimeout随inner声明,透传给inner。 - 取消:不声明
ResumableAfterCancel。读写被取消(或超时抛出)后流关闭:取消可能发生在一条记录只写出一半时,TLS 无法 继续在同一连接上正确收发。被取消的写:src只计入整条记录已交给inner的明文,对端收到的明文不会多于src的前移量 (对端可能因半条记录而报错,而不是正常 EOF)。 - 握手:首次读或写时自动进行;也可显式
handshake()。tlsConnect/tlsAccept的握手无论以何种方式失败(TLS 错误、对端关闭、inner报告已关闭、取消),都关闭已取得的引擎与inner(调用方拿不到TlsStream,只能由工厂释放);inner抛出ClosedException时流也随之 关闭(幂等)。TLS 失败抛TlsFailureException(IoException,带TlsFailureKind、验证码、告警码,duringHandshake区分握手阶段),失败前尽力把引擎生成的告警写给对端,然后关闭。 - 连接信息:握手完成后可取
alpn、protocolVersion、cipherSuite、serverName(服务端)、peerCertificates()(DER,叶子在前)、exportKeyingMaterial。
io-testkit一致性套件:TlsStream(TCP 之上、内存流之上)全部通过;取消相关检查按 §3 "不声明 ResumableAfterCancel" 的规则。- 专项:TLS 1.2 / 1.3 握手与收发;分片(
inner每次只给 1 字节);大块(1 MiB)双向同时收发;ALPN 协商;主机名错误 / 未知 CA / 过期证书 →TlsFailureException分类正确;mTLS;close_notify → -1;无 close_notify 的 EOF →TlsTruncatedException;shutdownOutput后仍可读;关闭时挂起的读写得到ClosedException;取消读 / 写后流关闭。 - 测试证书:测试代码内用 openssl-kotlin 的原始绑定生成自签 CA 与叶子证书(仅测试,不进入任何可用配置)。
- 脚本引擎(内部接口
EngineOps的测试实现,"密文"即明文、输入输出队列有界):覆盖真实 OpenSSL 无法按需产生的情形——读者持续产生输出而 发送被阻塞(检查实际高水位与输出顺序)、写入返回 WANT_READ(有无读者两种情况,并断言等待期间没有读取引擎)。 - 真实 OpenSSL 的原始对端(测试内以原始绑定驱动的 TLS 1.3 服务端):发送被阻塞时对端发起 5 000 次 KeyUpdate,全部处理、数据完整、内存有界。
- 验收沿用 neton-io SPEC §28.4 的规程与指标:同机 TLS 回显(本库)对照 Rust 的 tokio-rustls / tokio-openssl 回显,同等配置(TLS 1.3、 AES-128-GCM、同样的证书);每请求指令数与分配数用 callgrind 实测。
- 已知待测项:openssl-kotlin 的数组接口每次调用固定(pin)一次数组(
usePinned),每请求至少四次;若实测分配 / 指令显著,向 openssl-kotlin 提出显式不安全的指针接口(其契约已预留)。
-
依赖:
com.netonstream:openssl:4.0.2(本机 mavenLocal,openssl-kotlin 提交 aec636c)、com.netonstream:io0.2.0-SNAPSHOT(兄弟目录复合构建)。 -
测试(初版)11/11:macOS arm64;colima Linux arm64 io_uring(multishot)/ io_uring 单次 RECV / epoll 各 2 次(这也是 openssl-kotlin 的 TLS 路径首次在 Linux 上实际运行)。 含
io-testkit一致性套件(TCP 之上、内存流之上)全部通过。 -
初版的全双工死锁与修正:初版在 WANT_WRITE 后先把密文写到网络(可能阻塞)再重试,重试期间引擎拒绝读取;双方的写都阻塞在 TCP 上时双方都 读不了——死锁(引擎缓冲 16 KiB、双向同时 1 MiB 时复现)。初版以"引擎缓冲至少一条记录(17 KiB)"规避,并错误地请 openssl-kotlin 放开重试期间的 读取;审查指出 OpenSSL 契约对 WANT_WRITE 恰恰禁止这样做。现行版本按上面 §2 的规则调度:WANT_WRITE 就地取出并立即重试,重试从不跨越网络等待。 测试:引擎缓冲 1 KiB / 4 KiB 下双向同时 1 MiB、慢速链路(每次至多 700 字节、写间停顿)下双向同时 96 KiB,均完成;把初版行为放回去,1 KiB 测试 挂起(强制超时)。现行 12/12:macOS;colima Linux arm64 三种驱动配置各 2–3 次。
-
性能(153,callgrind,TLS 1.3 AES-128-GCM,128 字节回显,12 连接,每请求 =(15 s − 5 s)差值):
版本 每请求指令(epoll / io_uring) 每请求分配 初版(每个辅助函数一个挂起函数) 26.2k / 28.0k 12 全部内联 28.8k / 30.3k 4 → 2(加 intResult)热路径内联、少见路径不内联 23.5k / 25.0k 2 按 OpenSSL 重试契约重写调度 23.4k / 24.9k 2 预算按总量计 + EngineOps接口(现行)23.5k / 25.0k 2 对照:neton-io 原始回显(无 TLS) 2.4k / 3.8k 0 发现:Kotlin/Native 在挂起函数每次进入与恢复时把整个栈帧中的 GC 槽清零;把少见路径也内联使
read的帧涨到 2.7 KB(memset每请求约 5.4k 指令),反而更慢。现行版本read/write帧各约 1.4 KB。剩余 2 次分配是公开read/write各自的续体(一次调用一个)。 现行版本每请求约 24.6k 指令中的主要项:memset3.2k(其中 OpenSSLtls_write_records_default每条记录约 2.4k,本库帧清零约 0.7k)、ERR_clear_error1.1k(封装在每次引擎读写前调用)、OpenSSL 每条记录的malloc/free约 1.3k、数组固定与范围检查(Pinned、checkRange、pendingCiphertext的句柄访问)约 0.9k、真正的加解密(AES-GCM、GHASH)约 2–3k。与 Rust(tokio-rustls / tokio-openssl)的同机对照尚未进行。现行版本,三类开销分开统计(epoll,每请求):Kotlin 堆分配 2 次(66 指令;本库公开
read/write的续体);原生malloc4 次(172 指令, 来自 OpenSSLCRYPTO_zalloc)、free103 次(752 指令,CRYPTO_free);memset13 次共 3.1k 指令,其中 OpenSSLtls_write_records_default每条记录一次约 2.4k,其余为结构体清零与本库 / 反应器的栈帧清零(各数百指令)。原生分配与清零的用途(结构体初始化、记录缓冲、秘密数据清除) 尚未逐项定位,不能为跑分删除秘密数据的清零。 -
未覆盖(当时):TLS 1.3 KeyUpdate(openssl-kotlin 未提供触发接口,无法在测试中产生);写入遇到 WANT_READ 的路径(TLS 1.3 下正常情况不会出现,代码按 契约处理但尚无测试能触发)。两项随后由 2cc2bec 补上:
KeyUpdateTest以原始 OpenSSL 调用构造的测试服务端请求密钥更新,脚本引擎测试覆盖 WANT_READ 写入(见下)。 -
第二轮审查的修正(2026-09-28):(1) 待发密文预算按暂存 + 溢出总量计(见 §2),脚本引擎测试:小消息写入阻塞、对端每 10 字节诱发 100 字节 应答时高水位 10.3 KB(上限 22.5 KB),改回旧检查则达 200 KB、测试失败;恢复读取后对端收到的字节与引擎产出顺序完全一致。(2) 握手失败统一释放 (见 §3):去掉修正时"
inner报告已关闭"一例未释放、测试失败。EngineOps接口与高水位统计的代价:该回显实验中每请求 +116 条指令(约 +0.5%),分配不变;此前"与之前一致"的表述只说明该实验未观察到明显的 指令数回退,不等于没有性能代价。测试 16/16:macOS;colima Linux arm64 三种驱动配置各 3 次。
- 依赖:io 0.1.0 → 0.3.2(io-testkit 同步),openssl 4.0.2;Kotlin 2.4.20。原有 16 个测试不改即通过。
- 系统信任库
systemTrustRootsPem()(rustls-native-certsload_native_certs):SSL_CERT_FILE/SSL_CERT_DIR设置时取代平台存储; 否则 Linux 取发行版证书包(openssl-probe 的清单,都没有时/etc/ssl/certs)、Android 取 Conscrypt APEX 或系统 CA 目录、macOS 取 Security 框架的锚证书、Windows 取系统 ROOT 存储;iOS 没有可用 API,抛UnsupportedOperationException。每张证书以d2i_X509校验, 重复的只留一份,输出一个 PEM 包供TlsContext(trustRootsPem = ...)使用(openssl-kotlin 的上下文只收 PEM,且它的"默认路径"是 OpenSSL 编译时的目录,不是系统信任库)。来源与 quic 的Certificates.system()相同(quic 不依赖 tls,两处各有一份;openssl-kotlin 不由本仓库修改)。d2i_X509的长度参数在 Windows 上是 32 位,用convert()。 peerIdentityOf(host):URL 中的 IP 字面量(含[::1])按证书 IP 校验,其余按 DNS 名(并作为 SNI)。tls-http(新坐标com.netonstream:tls-http,hyper-rustls 的角色):HttpsConnector实现 http 连接池客户端的Connector:https先经HttpConnector建 TCP,再tlsConnect,ALPN 选中h2时报告negotiatedH2,客户端在该连接上用 HTTP/2;http照常 明文(httpsOnly时拒绝)。默认上下文:系统信任库,ALPNh2、http/1.1;传入的上下文归调用方。与 hyper-util 相同,ALPN 只有握手后 才知道,尚无连接时的一批并发请求可能各开一个连接;之后的并发请求共用已协商出 h2 的连接(测试按此断言)。tls-websocket(新坐标com.netonstream:tls-websocket):WssConnector实现 websocket 的TlsConnector(connect("wss://...")用它包 TLS),默认上下文为系统信任库、ALPNhttp/1.1。- 两个模块独立成坐标:http 与 websocket 本身不带 TLS(hyper、tungstenite 亦然),tls 也不依赖它们。
- 测试(macOS):tls 18(新增系统信任库 2 个:本机 157 个根、PEM 读取跳过文本与私钥块);tls-http 6(ALPN h2 时并发请求共用
一个连接、只给 http/1.1 时保持连接复用、IP 地址按 IP SAN 校验、不受信任的服务端被拒绝(
TlsFailureException)、httpsOnly、默认 上下文可建);tls-websocket 3(wss 回显含 70 KB 消息、不受信任被拒绝、默认上下文)。全部目标与发布用的元数据编译通过。 - CI(本仓库此前没有):三个模块的测试在 macOS、Linux(epoll、io_uring)、Windows(IOCP、WSAPoll)上运行,另编译全部目标; 摘要中打印各平台读到的系统根数量。
- 现象:0.2.0 首次在 Windows IOCP 上跑 CI(提交 6b24f09,运行 37925871731),
TlsStreamTest.conformanceOverTcp的一致性检查 "被取消的写入恰好推进已发送的字节"失败:对端收到 196,608 字节,src只记 180,224,多出一条完整记录。其余平台通过。 - 原因:完成式驱动(IOCP,io_uring 同类)上,内核可能在取消送达的同时完成发送:内层流把字节记为已发送,同时以取消结束这次写入。
writeAll只在flushInline()正常返回后才src.consume(len),于是一条已整条发出、对端能解密的记录没有计入src。 - 修正(dc1e86a,即 0.2.0 的发版提交):
flushInline()抛出CancellationException时,若本条记录的密文已全部离开(stage与overflow都空),先src.consume(len)再抛出;只发出一部分的记录永远无法解密,src不动。修正后 CI 五个平台通过(运行 37926783790、 37927504344)。 - 确定性回归测试(2026-10-10,
CancelledWriteTest):真实竞态取决于时序,一致性套件中的检查只能偶然触发;这里用包在内层流外的测试替身CancelAfterSending,在第 N 次写入时先交出给定比例的字节、再抛CancellationException,按需复现两种结局:aRecordSentWholeBeforeTheCancellationCountsAsWritten:第 2 条记录整条发出后取消 →src前进 2 × 16 KiB,与对端收到的字节相等;theFirstRecordSentWholeCountsToo:第 1 条记录整条发出后取消 → 16 KiB;aRecordCutInHalfDoesNotCount:第 2 条记录只发出一半 →src只前进 16 KiB(第 1 条),对端也只能解密 16 KiB。 去掉修正后前两个失败(期望 32,768 实为 16,384;期望 16,384 实为 0),第三个照常通过(修正不影响它),恢复修正后全部通过。 每个测试同时断言对端收到的是负载的前缀。macOS tls 测试 21 个全部通过。
- 缺口:握手没有时限。连接后不发数据(或发得极慢)的对端一直占着流、引擎与原生内存;tokio-rustls 同样没有, 由调用方(如 axum-server、hyper-util)各自加。本库把它作为资源上限(stack 工程标准 §7.2)放在入口。
- 接口:
tlsConnect(..., handshakeTimeoutMillis = DEFAULT_HANDSHAKE_TIMEOUT_MILLIS)、tlsAccept(..., handshakeTimeoutMillis = ...), 默认 10 s,0 不设限。超时:关闭流(与其他握手失败相同,调用方拿不到TlsStream),抛TlsHandshakeTimeoutException(timeoutMillis)(IoException)。新参数在末尾、带默认值,原有调用源码不变;默认值是行为变化(此前无限等待),发版按次版本处理。tls-http的HttpsConnector、tls-websocket的WssConnector走tlsConnect的默认值。 - 测试(
HandshakeTimeoutTest):不发数据的客户端 → 服务端 200 ms 后放弃,对端读到 EOF;只收不回的服务端 → 客户端 200 ms 后放弃, 对端读到 EOF;0 与 5 s 的时限内握手照常完成;默认值为 10 s。去掉时限逻辑后前者挂到外层 10 s 超时、测试失败。macOS:tls 24、tls-http 6、 tls-websocket 3 全部通过。
各层自己的测试多用内存流或测试替身;这一组让 HTTP / WebSocket 经本库 TLS、真实 TCP 与各平台反应器(kqueue、epoll、io_uring、IOCP、 WSAPoll)一起运行,检验层与层之间的传递:大于 TLS 记录、流控窗口与套接字缓冲的数据、背压、取消与关闭。
- HTTPS(
tls-httpCompositionTest,HTTP/2 与 HTTP/1.1 各一份,由 ALPN 选定):8 MiB 下载与上传逐字节校验、全程一个连接;客户端停读 时服务端对无限响应体的取用停在 32 MiB 以下(实测 HTTP/2 约 2.1 MiB、HTTP/1.1 约 0.9 MiB),恢复读取后继续;客户端丢弃响应体后服务端 停止取用——HTTP/2 复位该流、连接继续承载后续请求,HTTP/1.1 关闭该连接;服务端响应体中途失败时客户端得到错误而不是被截断的"正常结束", 之后照常请求。 - wss(
tls-websocketCompositionTest):8 MiB 二进制消息往返逐字节校验;服务端停读时客户端发送被挡在 32 MiB 以下、恢复后 64 MiB 全部到达;服务端以 1001 关闭、客户端收到该码后正常结束;客户端发起关闭后服务端以 close_notify 结束 TLS(原始 TLS 客户端读到 EOF 而非 截断);不带关闭帧直接断开时客户端得到错误而不是正常结束。 - 由此发现并已修复的缺陷:
- http:HTTP/1 客户端丢弃未读完的响应体时,连接既不复用也不关闭,服务端一直卡在写上(http SPEC 同日条目,a3fb7bc)。
- websocket:关闭握手完成后服务端不发 close_notify 直接关闭,客户端又把对端关闭帧之后的 TLS 截断当作 I/O 错误,正常关闭以
WebSocketException.Io结束(websocket SPEC §11.9,65dda80)。
- 运行方式:这两处修复在 http / websocket 0.2.0 之后,组合测试因此对它们的 main 运行:CI 新增
composition作业,五种反应器上签出 netonframework/http 与 websocket 的 main,以--include-build构建,只跑*CompositionTest*;常规test作业仍用已发布版本,以-PskipComposition排除这组测试。本地:./gradlew --include-build ../http --include-build ../websocket :tls-http:macosArm64Test --tests '*CompositionTest*' :tls-websocket:macosArm64Test --tests '*CompositionTest*'(macOS arm64:8 + 5 个通过)。在两个依赖所用的方式之间切换后, 若链接报 "is cached … but its dependency isn't",删除tls-*/build/kotlin-native-ic-cache。tls-http / tls-websocket 依赖含修复的版本后, 去掉开关并入常规作业。 - HTTP/3 → QUIC → UDP → 反应器的组合由 http3 仓库承担(其测试在 neton.quic 上经回环 UDP 运行,含真实 TLS 1.3 变体)。