Rust in Everything
基于 rustls 0.23.46

去帧与缓冲

主线位置:记录主线的接收侧前半段。对端 write_tls 交出的字节经 read_tls 进入 rustls,process_new_packets 把它们切成记录、交给记录层解密、把握手消息拼完整,最后交给状态机或者放进明文缓冲等 reader() 来读。解密本身见加密与序列号,解析见编解码。

flowchart TB
  rt["read_tls"] --> vb["DeframerVecBuffer"]
  vb --> pnp["process_new_packets"]
  pnp --> it["DeframerIter"]
  it --> pl{"允许明文"}
  pl -->|否| dec["decrypt_incoming 原地"]
  pl -->|是| plain["InboundPlainMessage"]
  dec --> plain
  plain --> typ{"握手消息"}
  typ -->|否| st["状态机"]
  typ -->|是| hd["HandshakeDeframer"]
  hd --> co["coalesce"]
  co --> st
  st --> rp["received_plaintext"]

文件:msgs/deframer/mod.rs(切记录)、buffers.rs(缓冲和区间换算)、handshake.rs(握手消息拼接),驱动它们的循环在 conn.rs 的 ConnectionCore 上。实验程序是 exp-rustls/rec-lab 的 deframe、allocs、outbound。

输入缓冲:DeframerVecBuffer

ConnectionCommon 自己持有一个 DeframerVecBuffer:一个 Vec<u8> 加已用长度 used。read_tls 调它的 read,先 prepare_read 准备空间,再对调用方的 io::Read 调一次 read,读进 buf[used..]:

        let allow_max = match is_joining_hs {
            true => MAX_HANDSHAKE_SIZE as usize,
            false => MAX_WIRE_SIZE,
        };

        if self.used >= allow_max {
            return Err("message buffer full");
        }

        let need_capacity = Ord::min(allow_max, self.used + READ_SIZE);
        if need_capacity > self.buf.len() {
            self.buf.resize(need_capacity, 0);
        } else if self.used == 0 || self.buf.len() > allow_max {
            self.buf.resize(need_capacity, 0);
            self.buf.shrink_to(need_capacity);
        }
  • 每次只多给 READ_SIZE = 4096 字节的空位,所以一次 read_tls 最多读 4 KiB 左右。
  • 上限平时是 MAX_WIRE_SIZE = 18437:最大载荷 16384 + 2048 加 5 字节头,也就是一条最大记录。只有当 HandshakeDeframer 手里有半条握手消息时,上限放宽到 0xffff。
  • 缓冲清空(used == 0)后下一次 read_tls 会把它缩回 4096。

实测(outbound 的最后一段):服务端一次写 100000 字节,客户端循环 read_tls + process_new_packets、不读明文。前 4 次各读 4096 字节,第 5 次读 2053 字节,合计正好 18437,第一条 16406 字节的记录才完整;之后缓冲已经够大,第 6 次一口气读了 16406:

== receive side: plaintext not read
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  2053 -> plaintext_bytes_to_read 16384
  read_tls 16406 -> plaintext_bytes_to_read 32768
...

处理完的字节由 discard 从前面去掉:还没处理的尾巴(半条记录)用 copy_within 挪到开头。

切记录:DeframerIter

DeframerIter 拿着缓冲的 &mut [u8],每次 next 读一个头、把头和正文从切片前面 split_at_mut 下来:

        let end = HEADER_SIZE + len as usize;

        self.buf.get(HEADER_SIZE..end)?;

        // we now have a TLS header and body on the front of `self.buf`.  remove
        // it from the front.
        let (consumed, remainder) = mem::take(&mut self.buf).split_at_mut(end);
        self.buf = remainder;
        self.consumed += end;

        Some(Ok(InboundOpaqueMessage::new(
            typ,
            version,
            &mut consumed[HEADER_SIZE..],
        )))

产出的 InboundOpaqueMessage 的载荷就是缓冲里那一段的可变借用。数据不够一条记录时返回 None(等下一次 read_tls),头不合法时返回 Some(Err),连接就此作废。头的检查在 read_opaque_message_header:内容类型必须是已知的四种之一;版本必须是 0x03XX;除应用数据外长度不能为 0;长度必须小于 MAX_PAYLOAD = 18432。实测,对一个刚建好的服务端喂各种头:

== record header checks (fed to a fresh server)
  type 0x63 ('c')              -> Err(InvalidMessage(InvalidContentType)); alert 15030300020232
  version 0x0400               -> Err(InvalidMessage(UnknownProtocolVersion)); alert 15030300020232
  handshake, length 0          -> Err(InvalidMessage(InvalidEmptyPayload)); alert 15030300020232
  length 18432                 -> Err(InvalidMessage(MessageTooLarge)); alert 15030300020232
  length 18431 (header only)   -> Ok("Ok (waiting)"); alert 
  GET / HTTP/1.1               -> Err(InvalidMessage(InvalidContentType)); alert 15030300020232

错误都回一个致命的 decode_error(02 32,50)告警。对一个 TLS 端口说 HTTP,第一个字节 G(0x47)就不是合法的内容类型,rustls 读完 5 个字节就判定。长度检查用的是 >=:RFC 5246 写的是密文长度「不得超过 2^14 + 2048」,恰好等于 18432 的记录在 RFC 里合法,rustls 拒绝。正常的实现不会发这么长的记录(rustls 自己发的最长是 16406 字节),这一字节的差别没有实际影响。

一轮 process_more_input

process_new_packets 循环调 deframe,每次拿出一条消息交给 process_msg,直到拿不出为止。deframe 先看 HandshakeDeframer 里有没有攒好的完整握手消息,有就直接返回,没有才调 process_more_input 从缓冲里切新记录。后者对每条记录:

  1. 要不要解密。 ChangeCipherSpec 永远是明文。告警只在三个条件同时成立时当作明文:协商的是 TLS 1.3、还没有成功解密过任何记录、载荷不超过 2 字节。TLS 1.3 没有 ChangeCipherSpec 标记换密钥的时刻,对端在启用握手密钥之前发出的告警只能靠这种启发式认出来。其他记录一律交给 record_layer.decrypt_incoming;密钥没就位时它原样放行,所以握手开头的明文 ClientHello / ServerHello 也走这条路。
  2. 解密失败但允许失败。 服务端拒绝了 0-RTT 时,客户端已经发出的早期数据用的是服务端没有的密钥,试解密失败的记录直接跳过(Ok(None) 后 continue);但如果这时手里有半条握手消息,就报 RejectedEarlyDataInterleavedWithHandshakeMessage。
  3. 不得穿插。 半条握手消息还没拼完时来了别的类型,报 MessageInterleavedWithHandshakeMessage(RFC 8446 §5.1)。
  4. 空记录。 连续 32 条空记录报 TooManyEmptyFragments,上限 ALLOWED_CONSECUTIVE_EMPTY_FRAGMENTS_MAX 照搬 BoringSSL。
  5. 分流。 不是握手消息的立刻返回;握手消息交给 HandshakeDeframer,拼出一条完整消息才返回,否则继续切下一条记录。

借用与拷贝

从 read_tls 到 reader().read,数据被拷贝了几次?逐步看:

步骤借用还是拷贝源码
read_tls拷贝:调用方的 io::Read 写进 DeframerVecBufferDeframerVecBuffer::read
切记录借用:InboundOpaqueMessage 的载荷是缓冲的 &mut [u8]DeframerIter::next
解密原地:AEAD open_in_place,明文是同一段的前缀,去掉标签和填充靠截短切片BorrowedPayload
握手消息跨记录缓冲内移动:copy_within 把后一段挪到前一段末尾HandshakeDeframer::coalesce
解析握手消息借用:Message::try_from 得到 Payload::Borrowed,状态机需要时再 into_ownedMessagePayload::new
应用数据进缓冲拷贝:Payload::into_vec 新分配一个 Vectake_received_plaintext
reader().read拷贝到调用方的切片;fill_buf / into_first_chunk 不拷Reader::read、fill_buf
处理完半条记录的尾巴挪到缓冲开头DeframerVecBuffer::discard

密文只在 read_tls 拷贝一次,之后的切分、解密、拼接都在这块缓冲里。应用数据还要再拷两次:进 received_plaintext 一次,读出来一次(用 BufRead 可以省掉后一次)。用一个计数的全局分配器(allocs)在握手后测一次往返,接收侧每一步的分配:

payload 16384 bytes:
  client writer().write                        allocs    1  bytes    16406  [alloc 16406]
  client write_tls (into a pre-sized Vec)      allocs    0  bytes        0  []
  server read_tls + process_new_packets + read allocs    4  bytes    73728  [realloc 8192, realloc 16384, realloc 32768, alloc 16384]
    (5 read_tls calls, 16384 bytes read)
payload 65536 bytes:
  client writer().write                        allocs    4  bytes    65624  [alloc 16406, alloc 16406, alloc 16406, alloc 16406]
  client write_tls (into a pre-sized Vec)      allocs    0  bytes        0  []
  server read_tls + process_new_packets + read allocs    8  bytes   126976  [realloc 4096, realloc 8192, realloc 16384, realloc 32768, alloc 16384, alloc 16384, alloc 16384, alloc 16384]
    (8 read_tls calls, 65536 bytes read)
  server reader().fill_buf()                   allocs    0  bytes        0  []
    fill_buf returned 5 bytes

接收一条 16 KiB 记录:三次 realloc 是输入缓冲从 4096 长到能放下一条完整记录(Vec::resize 按容量翻倍,所以是 8192、16384、32768),一次 16384 字节的 alloc 是明文进 received_plaintext。四条记录时第一个 realloc 4096 是上一轮清空后的收缩,之后缓冲不再收缩,每条记录只剩一次明文分配。解密本身没有分配。发送侧每条记录一次分配,见发送路径。

InboundUnborrowedMessage 是这套借用方案里的一个补丁:process_more_input 在循环里先借用缓冲得到明文消息,判断类型后要么返回它,要么交给 HandshakeDeframer 再继续用缓冲。按 Rust 现在的借用检查,「条件返回一个借用、否则继续可变借用」通不过,于是先把消息换成一个 Range<usize>,在各个分支里再借回来。源码注释指向 NLL RFC 的「problem case #3」,说 -Zpolonius 能修好它。

握手消息跨记录:HandshakeDeframer

一条握手消息可以跨多条记录(证书链常常超过 16 KiB),一条记录也可以装多条握手消息(rustls 自己就把 EncryptedExtensions 到 Finished 四条消息放进一条记录,见一次握手)。HandshakeDeframer 不保存字节,只保存一列 FragmentSpan:

struct FragmentSpan {
    /// version taken from containing message.
    version: ProtocolVersion,

    /// size of the handshake message body (excluding header)
    ///
    /// `None` means the size is unknown, because `bounds` is not
    /// large enough to encompass a whole header.
    size: Option<usize>,

    /// bounds of the handshake message, including header
    bounds: Range<usize>,
}

bounds 是在输入缓冲里的区间,size 来自握手消息头里的 3 字节长度。区间恰好覆盖「4 字节头 + size」时这个片段完整。

  • input_message 收下一条解密后的握手记录。如果最后一个片段不完整,新记录整段当作它的延续;否则用 DissectHandshakeIter 按消息头把它切成一个或多个片段。
  • coalesce 找到第一个「不完整且后面还有片段」的位置,coalesce_one 用 copy_within 把后一个片段挪到紧接前一个片段之后,覆盖掉中间的记录头、AEAD 标签和内层类型字节,然后把合并后的区间重新切一遍。处理完后检查每个片段的 size 不超过 MAX_HANDSHAKE_SIZE = 0xffff,否则 HandshakePayloadTooLarge。
  • HandshakeIter 按顺序交出完整的消息,交出最后一条时把整段输入标记为可丢弃。

copy_within 只在消息真的跨记录时发生,挪的是后一个片段的正文,不会整条消息重新拷贝。源码注释解释了为什么一定要挪:后面解析握手消息的地方(core、webpki、CryptoProvider 接口)都要求连续的切片,支持分散的输入要改的地方太多。

HandshakeDeframer 是否「对齐」(没有半条消息)还决定能不能换密钥:状态机在每个换密钥的地方(收到 ServerHello、Finished,处理 KeyUpdate 等十几处)先调 check_aligned_handshake,半条消息还没拼完就换密钥会报 KeyEpochWithPendingFragment:拼出来的消息两半会受不同密钥保护,RFC 8446 §5.1 写明握手消息不得跨越密钥变更。

实测,让服务端用 max_fragment_size = Some(100)(每条记录 95 字节明文),再把它的第一组飞行(flight)逐字节喂给客户端:

== server flight with max_fragment_size Some(100): handshake messages split across records
  server flight: 762 bytes, 9 records, lengths [95, 27, 1, 112, 112, 112, 112, 112, 34]
  fed byte by byte: 762 process_new_packets calls; client first wants_write after byte Some(132) of 762 (ServerHello complete)
  handshaking: client false, server false

122 字节的 ServerHello 拆成了 95 和 27 两条明文记录,客户端在第 132 个字节(两条记录的头加正文:5 + 95 + 5 + 27)到达时拼出了它,随即排队一条兼容用的 ChangeCipherSpec;后面 6 条加密记录里是被切开的 EncryptedExtensions、Certificate、CertificateVerify、Finished,每条 112 字节 = 95 字节明文 + 1 字节内层类型 + 16 字节标签。一次喂一个字节、调 762 次 process_new_packets,握手照样完成。

再让服务端带一条越来越长的证书链(同一张证书重复 N 次),看 64 KiB 的上限:

== Certificate message size vs the 64 KiB cap
  140 certs: Certificate message 50268 bytes, flight 50614 bytes: all consumed, handshaking false; client then sends (type, len) [(20, 1), (23, 53)]
  150 certs: Certificate message 53858 bytes, flight 54204 bytes: all consumed, handshaking false; client then sends (type, len) [(20, 1), (23, 53)]
  190 certs: Certificate message 68218 bytes, flight 68586 bytes: process_new_packets Err(InvalidMessage(HandshakePayloadTooLarge)) with 50016 bytes left; client then sends (type, len) [(20, 1)]
  200 certs: Certificate message 71808 bytes, flight 72177 bytes: process_new_packets Err(InvalidMessage(HandshakePayloadTooLarge)) with 53607 bytes left; client then sends (type, len) [(20, 1)]

(证书由 rcgen 每次重新生成,长度在几个字节内浮动。)53858 字节的 Certificate 消息能过,68218 字节的不能。拒绝发生得很早:68586 字节的飞行只读了 18570 字节,第一条带着 Certificate 消息头的记录一解密,coalesce 就从头里的长度看出它超限,不用等剩下的 50 KB。两点值得记下:限制是 rustls 自己定的(TLS 允许 16 MiB 的握手消息),注释写的理由是防拒绝服务;这个错误从 coalesce 经 ? 直接返回,没有经过 handle_deframe_error,所以客户端没有发 decode_error 告警,之后只发出了握手开头就排好的那条 ChangeCipherSpec。

📝NOTE
冷知识:64 KiB 的检查在重构里丢过一次

0.23.12 之前,去帧是一个 MessageDeframer 结构,切记录、调解密、拼握手消息都在它的 pop 里,拼接状态是一个 joining_hs: Option<HandshakePayloadMeta>,一次只能有一条拼到一半的消息,QUIC 还有一套单独的 push 入口。1442fe16(「Introduce iterator-based TLS message deframer」)把它拆成现在的 DeframerIter、HandshakeDeframer 和 RecordLayer,提交说明写着「QUIC code directly inputs data to HandshakeDeframer. There is no QUIC-specific code now in either deframer」。重构把超大握手消息的检查弄丢了,858b19d3(「Reinstate checking for HandshakePayloadTooLarge」)补回来,说明写的是:「There was no separate test for this, so it was dropped in the recent refactor. However connect-tests too_many_sans does actually exercise this.」两个提交首次进入的都是 0.23.13,所以没有哪个发布版缺这道检查。

明文缓冲的上限

应用数据解密后进 CommonState 的 received_plaintext,一个上限为 DEFAULT_RECEIVED_PLAINTEXT_LIMIT = 16 KiB 的 ChunkVecBuffer。这个上限不阻止 process_new_packets 往里放(一次解出几条记录就放几条),只在 read_tls 的入口检查:

    pub fn read_tls(&mut self, rd: &mut dyn io::Read) -> Result<usize, io::Error> {
        if self.received_plaintext.is_full() {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                "received plaintext buffer full",
            ));
        }
        // ...

is_full 判断的是 len() > limit,严格大于。和它配套的是 wants_read:只要还有没读的明文就返回 false。按 wants_read 驱动 IO 的程序(Stream、tokio-rustls)不会撞上这个错误;自己写循环、只管 read_tls 不管 reader() 的程序会。

❗IMPORTANT
动脑筋:握手完成后服务端一次写入 100000 字节、write_tls 全部交出;客户端循环 read_tls + process_new_packets,一直不读明文。第几次 read_tls 报错?报错时缓冲了多少明文?

ring provider,TLS13_AES_128_GCM_SHA256,默认配置。答案在文末。

动脑筋答案

== receive side: plaintext not read
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  4096 -> plaintext_bytes_to_read 0
  read_tls  2053 -> plaintext_bytes_to_read 16384
  read_tls 16406 -> plaintext_bytes_to_read 32768
  read_tls error: kind Other, "received plaintext buffer full" (wire left 65311)
  reader().read -> 32768
  reader().read again -> Err(WouldBlock)

第 7 次,缓冲了 32768 字节,是 16 KiB 上限的两倍。第 5 次 read_tls 之后第一条记录完整,明文 16384 字节,正好等于上限,is_full 用的是严格大于,所以第 6 次还能读;第 6 次读进第二条整记录,明文变成 32768,第 7 次才被拒绝。错误的 ErrorKind 是 Other,不是 WouldBlock,按「WouldBlock 就等一会儿」写的循环会把它当成致命错误。读空明文之后,reader().read 返回 WouldBlock,表示连接还开着、只是暂时没有数据。

小结

  • read_tls 把密文拷进 DeframerVecBuffer,每次最多多读 4 KiB;缓冲平时最多一条最大记录(18437 字节),拼握手消息时放宽到 64 KiB,清空后缩回 4096。
  • DeframerIter 按 5 字节头切出借用缓冲的记录;内容类型、0x03XX 版本、空记录、长度 >= 18432 都在头里就拒绝,回 decode_error。
  • 解密原地进行,握手消息跨记录时 HandshakeDeframer 只记区间、用 copy_within 拼接,超过 0xffff 报 HandshakePayloadTooLarge(不发告警)。应用数据在进 received_plaintext 和读出时各拷贝一次。
  • 去帧在 0.23.13 由一个大结构拆成迭代器、握手拼接器和记录层三块,丢过一次又补回的 64 KiB 检查在同一个版本里。
  • received_plaintext 上限 16 KiB、按严格大于判断,实测缓冲到 32768 字节时 read_tls 才报 received plaintext buffer full(ErrorKind::Other)。

💬 关联讨论

发起讨论
加载中...