Skip to content

Latest commit

 

History

History
190 lines (167 loc) · 21.9 KB

File metadata and controls

190 lines (167 loc) · 21.9 KB

tls — TLS 作为 IoStream(com.netonstream:tls)

状态:v1(2026-09-28)已实现并验证(§6);评审意见按条修订。

1. 定位与边界

  • 把 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。

2. 数据路径

  • 入站: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.c tls_process_key_update、 rec_layer_s3.c ssl3_write_bytes);TLS 1.2 拒绝重协商的告警每轮只有一次。即 OpenSSL 本身目前不会让读者输出无界增长,这个预算是防御性的, 以脚本引擎测试。
  • 缓冲:每连接一个密文输入缓冲、一个暂存缓冲(各为引擎缓冲容量 + 一条记录的余量)与一个很少使用的溢出缓冲;连接存续期间复用。引擎缓冲容量 不再有下限要求(1 KiB 已测)。

3. IoStream 契约(neton-io SPEC §28.6)

  • 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。

4. 测试

  • 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,全部处理、数据完整、内存有界。

5. 性能

  • 验收沿用 neton-io SPEC §28.4 的规程与指标:同机 TLS 回显(本库)对照 Rust 的 tokio-rustls / tokio-openssl 回显,同等配置(TLS 1.3、 AES-128-GCM、同样的证书);每请求指令数与分配数用 callgrind 实测。
  • 已知待测项:openssl-kotlin 的数组接口每次调用固定(pin)一次数组(usePinned),每请求至少四次;若实测分配 / 指令显著,向 openssl-kotlin 提出显式不安全的指针接口(其契约已预留)。

6. 实现与验证记录(2026-09-28)

  • 依赖:com.netonstream:openssl:4.0.2(本机 mavenLocal,openssl-kotlin 提交 aec636c)、com.netonstream:io 0.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 指令中的主要项:memset 3.2k(其中 OpenSSL tls_write_records_default 每条记录约 2.4k,本库帧清零约 0.7k)、 ERR_clear_error 1.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 的续体);原生 malloc 4 次(172 指令, 来自 OpenSSL CRYPTO_zalloc)、free 103 次(752 指令,CRYPTO_free);memset 13 次共 3.1k 指令,其中 OpenSSL tls_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 次。

7. 0.2.0:系统信任库、HTTPS 与 wss 连接器、CI(2026-10-09)

  • 依赖:io 0.1.0 → 0.3.2(io-testkit 同步),openssl 4.0.2;Kotlin 2.4.20。原有 16 个测试不改即通过。
  • 系统信任库 systemTrustRootsPem()(rustls-native-certs load_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 时拒绝)。默认上下文:系统信任库,ALPN h2、http/1.1;传入的上下文归调用方。与 hyper-util 相同,ALPN 只有握手后 才知道,尚无连接时的一批并发请求可能各开一个连接;之后的并发请求共用已协商出 h2 的连接(测试按此断言)。
  • tls-websocket(新坐标 com.netonstream:tls-websocket):WssConnector 实现 websocket 的 TlsConnector(connect("wss://...") 用它包 TLS),默认上下文为系统信任库、ALPN http/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)上运行,另编译全部目标; 摘要中打印各平台读到的系统根数量。

8. 取消写入的计数:IOCP 上发现的缺陷(2026-10-09 / 10)

  • 现象: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 个全部通过。

9. 握手时限(2026-10-11)

  • 缺口:握手没有时限。连接后不发数据(或发得极慢)的对端一直占着流、引擎与原生内存;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 全部通过。

10. 跨层组合测试(2026-10-11)

各层自己的测试多用内存流或测试替身;这一组让 HTTP / WebSocket 经本库 TLS、真实 TCP 与各平台反应器(kqueue、epoll、io_uring、IOCP、 WSAPoll)一起运行,检验层与层之间的传递:大于 TLS 记录、流控窗口与套接字缓冲的数据、背压、取消与关闭。

  • HTTPS(tls-http CompositionTest,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-websocket CompositionTest):8 MiB 二进制消息往返逐字节校验;服务端停读时客户端发送被挡在 32 MiB 以下、恢复后 64 MiB 全部到达;服务端以 1001 关闭、客户端收到该码后正常结束;客户端发起关闭后服务端以 close_notify 结束 TLS(原始 TLS 客户端读到 EOF 而非 截断);不带关闭帧直接断开时客户端得到错误而不是正常结束。
  • 由此发现并已修复的缺陷:
    1. http:HTTP/1 客户端丢弃未读完的响应体时,连接既不复用也不关闭,服务端一直卡在写上(http SPEC 同日条目,a3fb7bc)。
    2. 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 变体)。