Rust in Everything
基于 rustls 0.23.46

会话恢复与 0-RTT

主线位置:握手主线的最后一步和下一次握手的第一步。服务端验过客户端的 Finished 后发两张 NewSessionTicket(一次握手页里最后那条 179 字节的记录);客户端把它们存进会话存储,下次连同一个服务器时取一张放进 ClientHello 的 pre_shared_key 扩展,服务端认出它就跳过证书和签名,还可以在第一批就收应用数据。

sequenceDiagram
  participant C as 客户端
  participant S as 服务端
  participant DB as 会话缓存
  Note over C,S: 第一次 完整握手之后
  S->>DB: put 随机 id 和会话值
  S->>C: NewSessionTicket x2
  Note over C,S: 第二次 恢复
  C->>S: ClientHello 带 PSK 和绑定值
  C->>S: 早期数据 可选
  S->>DB: take 随机 id
  S->>S: 核对绑定值
  S->>C: ServerHello EE Finished
  C->>S: EndOfEarlyData Finished
  S->>C: NewSessionTicket x2

涉及的代码:服务端发票据和恢复在 server/tls13.rs,两侧的内存缓存在 server/handy.rs(369 行)和 client/handy.rs(527 行),会话值的编码在 msgs/persist.rs(568 行),票据加密的密钥轮换在 ticketer.rs(365 行)。PSK 和早期密钥在密钥日程里怎么推导,见密钥日程。

实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46,ring provider,release 构建,内存里握手。程序在 exp-rustls/hsb-lab/src/bin/ 下(resume.rs、early.rs、age.rs)。

服务端怎么发票据

ExpectFinished::emit_ticket 每张票做一次:

        let secure_random = config.provider.secure_random;
        let nonce = rand::random_vec(secure_random, 32)?;
        let age_add = rand::random_u32(secure_random)?;

        let now = config.current_time()?;

        let plain =
            get_server_session_value(suite, resumption, cx, &nonce, now, age_add).get_encoding();

        let stateless = config.ticketer.enabled();
        let (ticket, lifetime) = if stateless {
            let Some(ticket) = config.ticketer.encrypt(&plain) else {
                return Ok(());
            };
            (ticket, config.ticketer.lifetime())
        } else {
            let id = rand::random_vec(secure_random, 32)?;
            let stored = config
                .session_storage
                .put(id.clone(), plain);
            if !stored {
                trace!("resumption not available; not issuing ticket");
                return Ok(());
            }
            let stateful_lifetime = 24 * 60 * 60; // this is a bit of a punt
            (id, stateful_lifetime)
        };
  • 会话值是 ServerSessionValue:SNI、版本、套件、PSK、客户端证书链、ALPN、应用自己塞的数据(set_resumption_data)、创建时间、age_add。TLS 1.3 下字段名叫 master_secret,存的却是用这张票的 nonce 从恢复主密钥推出来的 PSK(KeyScheduleResumption::derive_ticket_psk),每张票一个。
  • 有状态(默认):票据本身只是 32 字节随机数,会话值存在服务端的 session_storage。
  • 无状态:ticketer.enabled() 为真时,整个会话值加密后交给客户端,服务端什么都不存。

with_cert_resolver 给出的默认值是有状态:

            session_storage: handy::ServerSessionMemoryCache::new(256),
            // ...
            ticketer: Arc::new(handy::NeverProducesTickets {}),
            // ...
            max_early_data_size: 0,
            send_half_rtt_data: false,
            send_tls13_tickets: 2,

实验程序打印的默认值,以及两种模式下一次完整握手的最后一批:

server defaults: send_tls13_tickets 2, ticketer enabled false, max_early_data_size 0
stateful full: kind Full, 4 flights, 1162 bytes
  flight 4 S->C 184 bytes: ApplicationData 179
stateless full: kind Full, 4 flights, 1348 bytes
  flight 4 S->C 370 bytes: ApplicationData 365

两张票在同一条记录里。有状态时每张 NewSessionTicket 81 字节(一次握手页解密核实过),无状态时每张大 93 字节:会话值的明文,加上 ring 的 AeadTicketer 加的 16 字节密钥名、12 字节随机 nonce、16 字节 ChaCha20-Poly1305 标签。

📝NOTE
冷知识:每次握手发几张票,从 1 到 4 再到 2

ServerConfig 最早只发 1 张票。d7807903「Allow configuring how many TLS1.3 tickets servers send」(首次发布在 0.21.0)加了 send_tls13_tickets,说明写着「This changes the default from 1 to 4 tickets.」字段文档给的理由今天还在:TLS 1.3 的票据是一次性的,多发几张,客户端就能连着恢复几次。

5199db3e「server: default send_tls13_tickets 4 -> 2」(首次发布在 0.23.17)又降回 2,说明是:「aligns the Rustls default ... to match BoringSSL/OpenSSL's default of 2」,「Previously we used 4 but this manifests as a performance gap in server-side resumption cost when comparing default configurations between TLS libraries due to the high cost of creating new tickets.」发票据的成本在服务端:每张都要随机数、一次密钥推导、一次缓存写入或一次 AEAD 加密。客户端这边 ClientSessionMemoryCache 每台服务器最多存 8 张,每次恢复用掉 1 张、拿到 2 张,库存会一直涨到 8。

有状态缓存

ServerSessionMemoryCache 是一把 Mutex 套一个 LimitedCache(HashMap 加一个记插入顺序的 VecDeque,满了先丢最早插入的)。恢复时用的是 take,取出即删除:

        fn take(&self, key: &[u8]) -> Option<Vec<u8>> {
            self.cache.lock().unwrap().remove(key)
        }

StoresServerSessions 的文档特别强调这一点:实现可以随便丢数据,但「take must reliably delete a returned value. If it does not, there may be security consequences.」下面的重放和 0-RTT 都靠它。

0.23.46 还修了一个容量问题(aecf92c8「Retain up to the requested LimitedCache capacity.」):之前淘汰发生在插入之后、而且按 VecDeque 实际分配的大小判断,「a cache of size 1 immediately dropped its only entry and a cache of size N stored N-1 items」。

有状态模式下服务端不检查会话有多老。告诉客户端的寿命是 24 小时(代码注释自己说「a bit of a punt」),但恢复时没有任何地方拿创建时间和当前时间比,只要还在缓存里就能用。实验把服务端的 time_provider 往后拨,客户端的时钟不动:

server clock +0 days: Some(Resumed)
server clock +2 days: Some(Resumed)
server clock +30 days: Some(Resumed)

寿命由客户端执行(下一节),服务端靠容量 256 把旧会话挤出去。创建时间只在判断 0-RTT 时用到。

无状态票据

无状态要自己设 ticketer,例如 ring 的 Ticketer::new:

        Ok(Arc::new(crate::ticketer::TicketRotator::new(
            6 * 60 * 60,
            make_ticket_generator,
        )?))

TicketRotator 每 6 小时换一把新钥,旧钥降为 previous 只用来解密,再过 6 小时被丢掉,所以告诉客户端的寿命是 12 小时(实验打印 stateless ticketer lifetime 43200 s)。ProducesTickets 的文档要求寿命「must be implemented by key rolling and erasure, not by storing a lifetime in the ticket」:目的是限制票据密钥泄露对前向安全的损害,到期的票据之所以失效,是因为能解开它的钥已经不存在了。

ring 的 AeadTicketer::decrypt 还会先拒掉比它产生过的最长密文还长的输入(8c46c080「ticketer: reject ciphertexts longer than we produced」,0.23.11):票据完全由对端控制,长度检查在解密之前做。

客户端怎么存票据

客户端收到 NewSessionTicket,在 ExpectTraffic::handle_new_ticket_impl 里用同一个 nonce 推出同一个 PSK,连同票据、服务器证书链、当前时间、寿命、age_add、票据里的 max_early_data_size,做成一个 Tls13ClientSessionValue,交给 config.resumption.store.insert_tls13_ticket。寿命在构造时被截到 MAX_TICKET_LIFETIME,7 天。

Resumption 的默认值是 in_memory_sessions(256)。ClientSessionMemoryCache 按服务器名分组:

    const MAX_TLS13_TICKETS_PER_SERVER: usize = 8;

    struct ServerData {
        kx_hint: Option<NamedGroup>,

        // Zero or one TLS1.2 sessions.
        #[cfg(feature = "tls12")]
        tls12: Option<persist::Tls12ClientSessionValue>,

        // Up to MAX_TLS13_TICKETS_PER_SERVER TLS1.3 tickets, oldest first.
        tls13: VecDeque<persist::Tls13ClientSessionValue>,
    }
  • new(256) 记住的是 256 / 8 = 32 台服务器,不是 256 个会话。0.23.46 之前的文档把这个参数写成「maximum number of stored sessions」,1fd63e8b 改正了文档。
  • 每台服务器最多 8 张 TLS 1.3 票据,满了丢最旧的;取的时候 pop_back,先用最新的,每张只用一次。
  • 同一个结构里还放着 TLS 1.2 的会话和 kx_hint(上次服务端选的组,见客户端 TLS 1.3)。

下次连接时 ClientSessionValue::retrieve 先取 TLS 1.3 票据,没有再找 TLS 1.2 会话,然后过两道筛:

  • compatible_config:会话值里存着当时的 ServerCertVerifier 和客户端证书解析器的 Weak 指针,和当前配置的 Arc 比较指针。换了校验器,旧会话就不用:恢复握手不再校验证书,这道筛保证「当初用来校验的就是现在这个」。
  • has_expired:按客户端自己的时钟和票据寿命判断。

PSK 绑定值

ClientHello 的 pre_shared_key 扩展里,每个票据身份配一个绑定值(binder):用 PSK 推出的 binder 密钥,对「截止到绑定值之前的 ClientHello」做 HMAC。它证明发 ClientHello 的人持有 PSK,也把 PSK 和这条 ClientHello 绑在一起。麻烦在于绑定值本身在 ClientHello 里面。客户端的 fill_in_psk_binder 注释写得很直白:

/// This implements the horrifying TLS1.3 hack where PSK binders have a
/// data dependency on the message they are contained within.
pub(super) fn fill_in_psk_binder(
    resuming: &persist::Tls13ClientSessionValue,
    transcript: &HandshakeHashBuffer,
    hmp: &mut HandshakeMessagePayload<'_>,
) -> KeyScheduleEarly {
    // ...
    let binder_plaintext = hmp.encoding_for_binder_signing();
    let handshake_hash = transcript.hash_given(suite_hash, &binder_plaintext);

    // Run a fake key_schedule to simulate what the server will do if it chooses
    // to resume.
    let key_schedule = KeyScheduleEarly::new(suite, resuming.secret());
    let real_binder = key_schedule.resumption_psk_binder_key_and_sign_verify_data(&handshake_hash);
    // ...

做法是两遍:prepare_resumption 先放一个全零、长度正确的占位绑定值,编码后去掉绑定值部分算哈希,再把真值填回去。rustls 客户端每次只提供一个票据身份。

服务端的 check_binder 反过来算:从收到的 ClientHello 编码末尾去掉 total_binder_length() 字节,同样做 KeyScheduleEarly::new(suite, psk) 再算一遍,用 ConstantTimeEq 比较。服务端处理 PSK 的顺序是:逐个身份 attempt_tls13_ticket_decryption(有状态就 take,无状态就解密)→ set_freshness → can_resume(套件和 SNI 必须和当初一致)→ 核对绑定值,不对就发 decrypt_error。

把一个正要恢复的 ClientHello 的 client random 翻转一位再交给服务端:

tampered resuming ClientHello: server error PeerMisbehaved(IncorrectBinder) / "peer misbehaved: IncorrectBinder"

random 在绑定值覆盖的范围里,哈希变了,绑定值就对不上。注意有状态模式下 take 发生在核对绑定值之前,这条被篡改的 ClientHello 已经把对应的会话从缓存里删掉了。

客户端还必须提供 psk_dhe_ke 模式,否则服务端放弃恢复,也不发票据(send_tickets = 0):rustls 服务端只做「PSK 加 ECDHE」,恢复握手照样有一次密钥交换,保留前向安全。

恢复握手少了什么

同一对配置,先完整握手一次,再恢复一次:

stateful full: kind Full, 4 flights, 1162 bytes
  flight 1 C->S 236 bytes: Handshake 231 [ClientHello]
  flight 2 S->C 662 bytes: Handshake 122 [ServerHello/HRR] | ChangeCipherSpec 1 | ApplicationData 524
  flight 3 C->S 80 bytes: ChangeCipherSpec 1 | ApplicationData 69
  flight 4 S->C 184 bytes: ApplicationData 179
stateful resumed: kind Resumed, 4 flights, 810 bytes
  flight 1 C->S 327 bytes: Handshake 322 [ClientHello]
  flight 2 S->C 219 bytes: Handshake 128 [ServerHello/HRR] | ChangeCipherSpec 1 | ApplicationData 75
  flight 3 C->S 80 bytes: ChangeCipherSpec 1 | ApplicationData 69
  flight 4 S->C 184 bytes: ApplicationData 179
  • 往返次数不变,还是一个 RTT 拿到服务端的 Finished。少的是服务端第一批里的 Certificate 和 CertificateVerify:加密记录从 524 字节降到 75 字节。
  • 75 字节记录的明文是 75 − 16(标签)− 1(内层类型)= 58 字节 = EncryptedExtensions 6 字节 + Finished 52 字节。完整握手里 EncryptedExtensions 是 10 字节,多出的 4 字节是 SNI 确认:process_common 在恢复时不回 SNI 确认。
  • ClientHello 大了 91 字节:多了一个 95 字节的 pre_shared_key 扩展(32 字节的票据身份、4 字节混淆后的票据年龄、48 字节的绑定值,加上各层长度前缀和扩展头),少了一个 4 字节的空 session_ticket 扩展(那是给 TLS 1.2 票据用的)。ServerHello 大了 6 字节(选中的身份序号)。
  • 客户端不再校验证书链,服务端不再签名。

无状态票据下 ClientHello 更大,因为票据就是加密的会话值:

stateless resumed: kind Resumed, 4 flights, 1089 bytes
  flight 1 C->S 420 bytes: Handshake 415 [ClientHello]

耗时(内存里,每种 300 次,取最小值和中位数;完整握手的客户端关掉了会话存储,保证每次都是完整握手;负载 5 左右):

time n=300: full min 224 us median 234 us; stateful resumed min 125 us median 132 us
time n=300: full min 224 us median 234 us; stateless resumed min 127 us median 132 us

连跑三次,完整握手最小值 223–224 µs,恢复 124–127 µs,省下约 100 µs 的 CPU,有状态和无状态差不多。网络上的往返次数没变,恢复的收益在 CPU 和字节数上;要省往返,得靠 0-RTT。

重放:同一条 ClientHello 发两次

ClientHello 是明文,任何人都能抓下来再发一遍。把一条正要恢复的 ClientHello 先后交给两个新建的服务端连接;再把上面那次恢复握手里已经用过的 ClientHello 重发一次:

replay of used ClientHello, stateful: server kind Some(Full)
replay of used ClientHello, stateless: server kind Some(Resumed)
fresh ClientHello #1, stateful: server kind Some(Resumed)
fresh ClientHello #2, stateful: server kind Some(Full)
fresh ClientHello #1, stateless: server kind Some(Resumed)
fresh ClientHello #2, stateless: server kind Some(Resumed)

有状态时第二次 take 拿不到会话,服务端退回完整握手;无状态时服务端不记得发生过什么,每次都接受。不带早期数据时这无关紧要:重放的人拿不到 PSK,算不出客户端的 Finished,握手完成不了,也读不懂服务端发的东西。问题出在 0-RTT:早期数据在服务端验 Finished 之前就交给了应用。

0-RTT 早期数据

客户端用票据的 PSK 推出 client_early_traffic_secret,在 ClientHello 后面紧跟着发加密的应用数据,不等服务端回话。rustls 默认两边都不开。

客户端要满足(prepare_resumption):ClientConfig::enable_early_data 为真;取到的票据里 max_early_data_size 大于 0;不是 HelloRetryRequest 之后的第二个 ClientHello。满足时 ClientConnection::early_data 返回一个 WriteEarlyData,最多写 bytes_left() 字节。

服务端要满足两层。第一层在发票据时:ServerConfig::max_early_data_size 大于 0,而且是有状态模式,票据里才带 max_early_data_size 扩展:

        if config.max_early_data_size > 0 {
            if !stateless {
                payload.extensions.max_early_data_size = Some(config.max_early_data_size);
            } else {
                // We implement RFC8446 section 8.1: by enforcing that 0-RTT is
                // only possible if using stateful resumption
                warn!("early_data with stateless resumption is not allowed");
            }
        }

第二层在恢复时,decide_if_early_data_allowed:

        let early_data_configured = config.max_early_data_size > 0 && !config.ticketer.enabled();
        // ...
        let early_data_possible = early_data_requested
            && psk_index == 0
            && resume.is_fresh()
            && Some(resume.version) == cx.common.negotiated_version
            && resume.cipher_suite == suite.common.suite
            && resume.alpn.as_ref().map(|p| &p.0[..]) == cx.common.alpn_protocol.as_deref();

        if early_data_configured && early_data_possible && !cx.data.early_data.was_rejected() {
            EarlyDataDecision::Accepted
  • psk_index == 0:只接受客户端第一个票据身份上的早期数据(RFC 8446 §4.2.10)。这一条是 0.23.46 才加的(5ed42b32「server: only allow early data for first PSK」);rustls 客户端只发一个身份,不受影响。
  • is_fresh():set_freshness 用 age_add 还原客户端报的票据年龄,和服务端按创建时间算出的年龄比,差不超过 MAX_FRESHNESS_SKEW_MS(60 秒)才算新鲜。
  • 版本、套件、ALPN 必须和签发票据时一样。
  • was_rejected():应用可以在握手中调 ServerConnection::reject_early_data 主动拒绝。

接受了,服务端进 ExpectEarlyData 状态收数据,直到 EndOfEarlyData;应用通过 ServerConnection::early_data() 单独读这部分,和握手后的普通数据分开。拒绝了,服务端装上握手密钥并打开「试解密」:解不开的记录按 max_early_data_size 的额度丢掉。

客户端开 enable_early_data,服务端用不同配置,各先完整握手一次,再在第二次连接里写一行 GET /early HTTP/1.1\r\n\r\n(23 字节):

server defaults: early_data() = None; kind Resumed; client is_early_data_accepted false; server read ""
stateful, max_early_data_size 16384: early_data() = Some, bytes_left 16384, wrote 23; kind Resumed; client is_early_data_accepted true; server read "GET /early HTTP/1.1\r\n\r\n"
  flight 1 C->S 382 bytes: Handshake 326 [ClientHello] | ChangeCipherSpec 1 | ApplicationData 40
  flight 2 S->C 223 bytes: Handshake 128 [ServerHello/HRR] | ChangeCipherSpec 1 | ApplicationData 79
  flight 3 C->S 100 bytes: ApplicationData 21 | ApplicationData 69
  flight 4 S->C 200 bytes: ApplicationData 195
[... WARN  rustls::server::tls13] early_data with stateless resumption is not allowed
stateless, max_early_data_size 16384: early_data() = None; kind Resumed; client is_early_data_accepted false; server read ""
client enable_early_data false: early_data() = None; kind Resumed; client is_early_data_accepted false; server read ""

(时间戳一列用 ... 略去;同一行 WARN 打了四次,每次发票据一次。)

  • 服务端默认 max_early_data_size 为 0,票据里没有额度,客户端的 early_data() 直接是 None。
  • 有状态加额度:第一批多了一条 40 字节的加密记录(23 字节数据 + 1 字节内层类型 + 16 字节标签),服务端在 Finished 之前就读到了它。第三批的 21 字节记录是 EndOfEarlyData(4 字节),用早期密钥加密。EncryptedExtensions 多了 early_data 确认,所以 75 变 79。票据每张多 8 字节的 max_early_data_size 扩展,184 变 200。
  • 无状态加额度:服务端打警告,票据里不带额度,客户端根本不尝试。
  • 客户端没开:照常恢复,不发早期数据。

max_early_data_size 的单位有个说不清的地方,字段文档写着「The units for this are both plaintext bytes, and ciphertext bytes」,并建议留些余量。来历见下面的冷知识。

❗IMPORTANT
动脑筋:服务端的时钟比签发票据时快了 120 秒(客户端时钟不变),客户端照样写早期数据。这次连接能恢复吗?早期数据会被接受吗?

实验里把第二次连接的服务端 ServerConfig::time_provider 换成「当前时间 + 120 秒」,会话缓存仍是同一个。答案在文末。

再看重放。抓下一个带早期数据的第一批(ClientHello、假 CCS、一条 POST /transfer),先后交给两个新建的服务端连接:

captured flight 1: Handshake 326 [ClientHello] | ChangeCipherSpec 1 | ApplicationData 31
fresh flight #1: Ok(()), kind Some(Resumed), early data accepted true "POST /transfer"
fresh flight #2: Ok(()), kind Some(Full), early data accepted false ""
replay of accepted flight 1: Ok(()), kind Some(Full), early data accepted false ""

第一次,服务端在没有见到任何客户端 Finished 的情况下就把 POST /transfer 交给了应用;第二次会话已被 take 删掉,退回完整握手,早期数据被丢弃。这就是 rustls 限制重放的全部手段:

  1. 只在有状态模式下接受,靠 take 让每张票只能接受一次早期数据。无状态模式做不到这一点,所以干脆不允许。
  2. 票据年龄和服务端算的差 60 秒以上不接受,限制了抓包之后能拖多久。
  3. 早期数据通过单独的 early_data() 交给应用,应用知道这部分可能被重放,自己决定哪些请求可以在这里处理。

单进程的 ServerSessionMemoryCache 只防得住同一个进程。多台服务器共享票据时,共享的存储必须保证 take 是原子的,否则同一条第一批发给两台机器,两台都会接受。这一层 rustls 管不到。

📝NOTE
冷知识:服务端 0-RTT 最早只给 QUIC 用

725870a4「Enable server support for 0-RTT data」(首次发布在 0.20.3)说明的第一句是「This previously existed, but only for QUIC.」它给 TLS over TCP 加上了服务端早期数据,并写下了实现中最别扭的部分:客户端不管服务端接不接受都会先发 0-RTT 数据,于是服务端需要几种互不相干的办法跳过它,一种是记录层的试解密,另一种是在两个 ClientHello 之间跳过加密消息(今天的 ExpectAndSkipRejectedEarlyData)。能跳过的量有上限,但上限 max_early_data_size「is expressed ... in units of plaintext bytes, but skipping data requires it in units of padded, tagged ciphertext bytes. The server cannot compute one from the other, so we're interpretting max_early_data_size as both at the same time.」这正是字段文档里「both plaintext bytes, and ciphertext bytes」那句话的由来。「只允许有状态恢复」那条警告也是同一个提交写下的。

动脑筋答案

能恢复,早期数据不被接受:

server clock +120 s at resumption: early_data() = Some, bytes_left 16384, wrote 23; kind Resumed; client is_early_data_accepted false; server read ""
  flight 1 C->S 382 bytes: Handshake 326 [ClientHello] | ChangeCipherSpec 1 | ApplicationData 40
  flight 2 S->C 219 bytes: Handshake 128 [ServerHello/HRR] | ChangeCipherSpec 1 | ApplicationData 75
  flight 3 C->S 74 bytes: ApplicationData 69
  flight 4 S->C 200 bytes: ApplicationData 195
  • 恢复本身不看时间:有状态模式下服务端不检查会话年龄,can_resume 只比套件和 SNI。
  • 服务端算出的票据年龄约 120 秒,客户端报的年龄接近 0,差超过 60 秒,is_fresh() 为假,decide_if_early_data_allowed 返回 RequestedButRejected。
  • 客户端在第一批里已经发出了 40 字节的早期数据;服务端用握手密钥试解密失败,按额度丢掉。EncryptedExtensions 里没有 early_data 确认(75 字节,不是 79),客户端不发 EndOfEarlyData,第三批只有 Finished。
  • 数据丢了,连接照常建立。is_early_data_accepted() 为假时要不要重发,由应用决定,ClientConnection::is_early_data_accepted 的文档也是这么写的。

小结

  • 服务端默认有状态:票据是 32 字节随机 id,会话值在 256 项的 ServerSessionMemoryCache 里,恢复时 take 掉;设 ticketer 才是无状态,会话值加密后交给客户端,靠每 6 小时轮换密钥让旧票失效。
  • 每次握手默认发 2 张票;客户端每台服务器最多存 8 张,new(256) 是 32 台服务器;会话只在同一个校验器下恢复。
  • PSK 绑定值是对「去掉绑定值的 ClientHello」做的 HMAC,客户端先填占位再回填;服务端不对就是 IncorrectBinder。
  • 恢复握手往返次数不变,省掉证书和签名:实测 810 对 1162 字节,124 对 223 µs。
  • 0-RTT 需要客户端 enable_early_data、服务端 max_early_data_size > 0 且有状态;还要第一个身份、年龄差不超过 60 秒、版本套件 ALPN 一致。同一条早期数据第二次到达时会话已被删除,被拒绝。

💬 关联讨论

发起讨论
加载中...