去帧与缓冲
主线位置:记录主线的接收侧前半段。对端
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 从缓冲里切新记录。后者对每条记录:
- 要不要解密。
ChangeCipherSpec永远是明文。告警只在三个条件同时成立时当作明文:协商的是 TLS 1.3、还没有成功解密过任何记录、载荷不超过 2 字节。TLS 1.3 没有 ChangeCipherSpec 标记换密钥的时刻,对端在启用握手密钥之前发出的告警只能靠这种启发式认出来。其他记录一律交给record_layer.decrypt_incoming;密钥没就位时它原样放行,所以握手开头的明文 ClientHello / ServerHello 也走这条路。 - 解密失败但允许失败。 服务端拒绝了 0-RTT 时,客户端已经发出的早期数据用的是服务端没有的密钥,试解密失败的记录直接跳过(
Ok(None)后continue);但如果这时手里有半条握手消息,就报RejectedEarlyDataInterleavedWithHandshakeMessage。 - 不得穿插。 半条握手消息还没拼完时来了别的类型,报
MessageInterleavedWithHandshakeMessage(RFC 8446 §5.1)。 - 空记录。 连续 32 条空记录报
TooManyEmptyFragments,上限ALLOWED_CONSECUTIVE_EMPTY_FRAGMENTS_MAX照搬 BoringSSL。 - 分流。 不是握手消息的立刻返回;握手消息交给
HandshakeDeframer,拼出一条完整消息才返回,否则继续切下一条记录。
借用与拷贝
从 read_tls 到 reader().read,数据被拷贝了几次?逐步看:
| 步骤 | 借用还是拷贝 | 源码 |
|---|---|---|
read_tls | 拷贝:调用方的 io::Read 写进 DeframerVecBuffer | DeframerVecBuffer::read |
| 切记录 | 借用:InboundOpaqueMessage 的载荷是缓冲的 &mut [u8] | DeframerIter::next |
| 解密 | 原地:AEAD open_in_place,明文是同一段的前缀,去掉标签和填充靠截短切片 | BorrowedPayload |
| 握手消息跨记录 | 缓冲内移动:copy_within 把后一段挪到前一段末尾 | HandshakeDeframer::coalesce |
| 解析握手消息 | 借用:Message::try_from 得到 Payload::Borrowed,状态机需要时再 into_owned | MessagePayload::new |
| 应用数据进缓冲 | 拷贝:Payload::into_vec 新分配一个 Vec | take_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。
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() 的程序会。
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)。