Rust in Everything
基于 rustls 0.23.46

0-RTT 没生效,早期数据不见了

主线位置:握手主线的恢复分支。第二次连接时客户端用票据里的 PSK 推出早期密钥,在 ClientHello 后面紧跟着发应用数据。服务端要满足哪些条件才接受、拒绝时怎么丢掉这些记录,见会话恢复与 0-RTT。本页讲两种「什么报错也没有」的情况:一种是根本没发,一种是发了、被丢了。

sequenceDiagram
  participant C as 客户端
  participant A as 服务端A
  participant B as 服务端B
  C->>A: 完整握手
  A->>C: 票据 带额度
  C->>B: ClientHello 带 PSK
  C->>B: 早期数据 23 字节
  B->>C: ServerHello 不用 PSK
  Note over B: 丢掉早期数据
  C->>B: Finished
  Note over C: 写入返回过 Ok(23)

实验 crate:exp-rustls/pitb-lab,src/bin/p13_early.rs,握手辅助函数复制自 hsb-lab。客户端 enable_early_data = true,先和一个服务端完整握手拿票据,再新建连接,用 early_data() 写一行 GET /early HTTP/1.1\r\n\r\n(23 字节)。ring provider,TLS 1.3。M2 Max、macOS 27、rustc 1.99.0,release。

现象

== never offered
server default (max_early_data_size 0): early_data() = None; handshake Ok, kind Resumed; is_early_data_accepted false; server early "", server reader ""
[2026-10-29T18:05:23Z WARN  rustls::server::tls13] early_data with stateless resumption is not allowed
...
stateless tickets + 16384: early_data() = None; handshake Ok, kind Resumed; is_early_data_accepted false; server early "", server reader ""
== offered, written, dropped
stateful + 16384, same server: early_data() wrote Ok(23); handshake Ok, kind Resumed; is_early_data_accepted true; server early "GET /early HTTP/1.1\r\n\r\n", server reader ""
ticket from server A, reconnect to B: early_data() wrote Ok(23); handshake Ok, kind Full; is_early_data_accepted false; server early "", server reader ""

(WARN 一行原样打了四次,... 略去三次。)

  • 没发。 服务端用默认配置,或者开了额度但用的是无状态票据,客户端拿到的票据里没有早期数据额度,early_data() 返回 None。如果代码写成 if let Some(mut w) = conn.early_data() { ... },这条分支就从来没执行过,0-RTT 从未生效,也没有任何报错。无状态那种在服务端日志里有一行 WARN,前提是开了日志。
  • 发了,被丢了。 票据来自服务端 A,第二次连接落到了服务端 B:同样的证书、同样的 max_early_data_size,但 B 有自己的会话缓存。write 返回 Ok(23),握手成功(kind Full,退回了完整握手),服务端什么也没读到,客户端也没收到错误。只有 is_early_data_accepted() 是 false。

第二种在部署时最常见:负载均衡后面有多个进程,或者服务端重启过(内存里的会话缓存没了)。

原因

客户端只看票据。 ClientConnection::early_data 只在早期数据状态是「可用」时返回 Some,而这要求取到的票据带着大于 0 的 max_early_data_size。服务端只有在 ServerConfig::max_early_data_size 大于 0 并且是有状态模式时,才在票据里写这个额度(emit_ticket 里那句 WARN 就在无状态分支)。两条代码都在会话恢复与 0-RTT里逐行讲过。

写入只是排队。 WriteEarlyData::write 在额度内把数据加密、排进发送队列,返回写了多少,它不知道、也不可能知道服务端会不会收。服务端的决定要等 ServerHello 和 EncryptedExtensions 回来。

被拒绝时,客户端只是换个状态。 B 不认识这张票,不接受 PSK,ServerHello 里没有 pre_shared_key。客户端在 client/tls13.rs 走「不恢复」分支:

        _ => {
            debug!("Not resuming");
            // Discard the early data key schedule.
            cx.data.early_data.rejected();
            cx.common.early_traffic = false;
            resuming_session.take();
            KeySchedulePreHandshake::new(suite)
        }

恢复成功但 EncryptedExtensions 里没有 early_data 确认(服务端拒绝了早期数据但接受了 PSK)走的是另一处,结果一样:状态变成 Rejected,不返回错误。已经写进去的那 23 字节不会进普通的发送缓冲。early_data() 的文档写得明白:「The server can choose not to accept any sent early data -- in this case the data is lost but the connection continues. You can tell this happened using is_early_data_accepted.」服务端那边按会话恢复与 0-RTT讲的「试解密」把这些记录丢掉,应用一个字节也看不到。

多台服务端之间为什么不行。 有状态的票据只是会话缓存里的一个键,B 的缓存里没有它。用无状态票据(共享 Ticketer 密钥)倒是能让 B 恢复会话,但 decide_if_early_data_allowed 里的条件是 config.max_early_data_size > 0 && !config.ticketer.enabled(),无状态时永远不接受早期数据:服务端得记住每张票据用过没有,才挡得住重放。想在多台机器上用 0-RTT,只能给 ServerConfig::session_storage 换一个多台机器共享的实现(rustls 的 StoresServerSessions trait),让所有进程看到同一份「票据已用」的记录。

修法

握手完成后查一次,没被接受就用普通写入重发。 同一个场景,加上这几行:

// after the handshake has completed
if !conn.is_early_data_accepted() {
  conn.writer().write_all(REQ)?;
}
== fix: check is_early_data_accepted and resend
ticket from server A, reconnect to B, resend: early_data() wrote Ok(23); handshake Ok, kind Full; is_early_data_accepted false; server early "", server reader "GET /early HTTP/1.1\r\n\r\n"

数据这次从普通的应用数据通道到了服务端(server reader),多花一个往返,但没丢。server early "" 说明被拒绝的那一份没有交给应用,重发不会让服务端看到两份。

用 tokio-rustls 的客户端不用自己写:0.26.6 的 client.rs 在握手结束时有一段注释为「write early data (fallback)」的代码,!stream.session.is_early_data_accepted() 时把早期数据原样再写一遍。直接用 rustls 的 ClientConnection、或者自己写 sans-IO 驱动循环的,要自己补上。

想让 0-RTT 真的生效: 服务端 max_early_data_size 设成大于 0;保持有状态票据(默认的 ServerSessionMemoryCache);多个进程时提供共享的会话存储;客户端打开 enable_early_data。早期数据会被重放,只能放幂等的请求,这一点见会话恢复与 0-RTT。

怎么检查

  • 客户端: 打点记录 early_data() 是不是 Some,以及握手后 is_early_data_accepted()。前者总是 None,看服务端配置;后者总是 false,看是不是落到了没有这张票的服务端(handshake_kind() 是 Full 就是没恢复上)。
  • 服务端: 开 log 输出,搜 early_data with stateless resumption is not allowed;ServerConnection::early_data() 返回 Some 才说明真的收到了早期数据。

小结

  • 服务端默认 max_early_data_size 为 0,无状态票据下设了也无效,客户端的 early_data() 是 None,没有报错。
  • 早期数据被拒绝时,write 早已返回 Ok,握手照常成功,数据被丢弃;唯一的信号是 is_early_data_accepted() 为 false。
  • 负载均衡、重启都会让票据失效,连接退回完整握手,早期数据随之丢失;0-RTT 要有状态、且多进程共享的会话存储。
  • 握手后检查 is_early_data_accepted(),为假就用 writer() 重发;tokio-rustls 0.26.6 已经替你做了。

💬 关联讨论

发起讨论
加载中...