Rust in Everything
基于 rustls 0.23.46

编解码

主线位置:记录主线和握手主线共用的最底层。记录解密之后,载荷还只是一串字节;这一页讲 rustls 怎么把它解析成 HandshakeMessagePayload、ClientExtensions 这些类型,又怎么把它们编码回字节。去帧和解密在去帧与缓冲和加密与序列号,解析出来的消息交给状态机,见状态机。

flowchart TB
  bytes["明文字节"] --> mp["MessagePayload new"]
  mp --> hmp["read_version"]
  hmp --> typ{"握手类型"}
  typ --> ch["ClientHelloPayload"]
  typ --> sh["ServerHello 或 HRR"]
  typ --> unk["Unknown"]
  ch --> ext["ClientExtensions"]
  ext --> one["read_one"]
  one --> known["已知 read_once"]
  one --> other["未知 交给闭包"]

代码在 msgs/ 下:codec.rs 是读写接口和长度前缀,base.rs 是几种不透明载荷,macros.rs 是两个生成代码的宏,handshake.rs 用它们定义全部握手消息。实验程序是 exp-rustls/rec-lab 的 codec:从真实的 ClientHello 和 ServerHello 出发,改几个字节再交给 rustls 解析。

Reader 与 Codec

Reader 就是一个切片加一个游标。take(n) 返回 &'a [u8],不拷贝;sub(n) 切出长度为 n 的子 Reader,长度前缀的结构都用它,读子结构时越不过自己的边界。所有线上结构都实现 Codec:

pub trait Codec<'a>: Debug + Sized {
    fn encode(&self, bytes: &mut Vec<u8>);

    fn read(_: &mut Reader<'a>) -> Result<Self, InvalidMessage>;

    // ...
    fn read_bytes(bytes: &'a [u8]) -> Result<Self, InvalidMessage> {
        let mut reader = Reader::init(bytes);
        Self::read(&mut reader).and_then(|r| {
            reader.expect_empty("read_bytes")?;
            Ok(r)
        })
    }
}

trait 上的生命周期 'a 是输入缓冲的生命周期,读出来的值可以借用它:Payload 有 Borrowed(&'a [u8]) 和 Owned(Vec<u8>) 两种,Payload::read 直接返回 Borrowed(r.rest());证书 CertificateDer<'a> 也是借来的。需要活得比缓冲久的消息再调 into_owned(),比如 ClientHelloPayload 的扩展读完就 into_owned() 装进 Box。read_bytes 要求整段读完,剩下字节就是 TrailingData;各个结构自己的 read 结尾也基本都有这一步,多一个字节都不放过。

整数一律大端。TLS 有 3 字节长度,Rust 没有 u24,于是有了一个新类型 u24(pub u32),只用在握手消息头和证书长度上。

长度前缀

TLS 的「向量」是长度前缀加元素,前缀是 1、2 还是 3 字节由元素类型决定。rustls 让元素类型实现 TlsListElement,给出一个 ListLength,然后 Vec<T> 的 Codec 统一处理。ListLength 只有四种:NonZeroU8、U16、NonZeroU16、U24 { max },前缀为 0 的非空列表和超过 max 的 3 字节列表在读长度时就报错。比如 CipherSuite 是 NonZeroU16(RFC 要求至少一个套件),证书条目是 U24,上限 CERTIFICATE_MAX_SIZE_LIMIT 即 64 KiB。不知道内容的字节串用 PayloadU8 / PayloadU16 / PayloadU24,类型参数 NonEmpty / MaybeEmpty 决定空串合不合法。

编码时长度要等内容写完才知道。LengthPrefixedBuffer 先写占位、记下位置,内容追加到它借来的 buf 里,Drop 时回填真实长度:

    pub(crate) fn new(size_len: ListLength, buf: &'a mut Vec<u8>) -> Self {
        let len_offset = buf.len();
        buf.extend(match size_len {
            ListLength::NonZeroU8 { .. } => &[0xff][..],
            ListLength::U16 | ListLength::NonZeroU16 { .. } => &[0xff, 0xff],
            ListLength::U24 { .. } => &[0xff, 0xff, 0xff],
        });
        // ...
    }

一次遍历写完,不需要先算长度、也不需要先写到临时 Vec 再拷。扩展、握手消息头(U24)都是这样写的。

📝NOTE
没有蠢问题

问: 占位为什么写 0xff 而不是 0?反正 Drop 时都会被覆盖。

答: 为了「没被覆盖」的情况。interrupted_length_prefixed_buffer_leaves_maximum_length 这个测试写的就是:如果有人在 Drop 之前读了缓冲(一个 bug),看到的长度是最大值,后面已经写进去的字节都算在这个结构里面,「不可能被当成后面另一个编码来解释(比如注入一个别的扩展)」。占位写 0 的话,结构体内容会被解析成紧跟其后的独立结构。

协议枚举:enum_builder!

TLS 里的数字码(内容类型、握手类型、扩展类型、套件、签名算法……)都由 enum_builder! 生成,tag 上一共 29 处。写法是「变体 => 数值」:

enum_builder! {
    #[repr(u16)]
    pub enum ExtensionType {
        ServerName => 0x0000,
        MaxFragmentLength => 0x0001,
        // ...
    }
}

宏在最后追加一个 Unknown($uint) 变体并加上 #[non_exhaustive],生成 From<u16>(不认识的数落进 Unknown)、From<Self> for u16、Codec 和 Debug。所以「读到一个不认识的扩展号」不是解析错误,而是一个合法的值 ExtensionType::Unknown(0xfe00),要不要拒绝由上层决定。ExtensionType 里列了 MaxFragmentLength(RFC 6066)这样的号码,只是为了命名:rustls 并不协商它。

握手消息:read_version

一条握手消息是 1 字节类型、3 字节长度、正文。MessagePayload::new 按记录的内容类型分派,握手类型交给 HandshakeMessagePayload::read_version,它多带一个参数:已经协商的版本。Certificate、CertificateRequest、NewSessionTicket 三种消息在 TLS 1.2 和 1.3 里格式不同,同一个类型号要按版本选解析器。还有两处特殊:

  • ServerHello 和 HelloRetryRequest 共用类型号 2,靠 random 是不是 RFC 8446 规定的那个魔数区分。
  • MessageHash(254,只在转录哈希里出现)和 HelloRetryRequest(6,RFC 8446 草案时期的类型号,rustls 内部拿它给 HRR 命名)出现在线上就报 UnexpectedMessage;其他不认识的类型解析成 HandshakePayload::Unknown,由状态机拒绝。

MessagePayload::Handshake 同时保存 parsed 和 encoded:后者是收到的原始字节(Payload::Borrowed)。转录哈希(transcript hash)要对收到的字节原样求值,不能拿解析后重新编码的结果代替,两者在扩展顺序之类的地方可能不同。

扩展:extension_struct!

ClientHello 带二十多种可能的扩展,ServerHello、EncryptedExtensions、证书条目、证书请求、票据也都有自己的扩展列表。rustls 用 extension_struct! 把每种列表声明成一个结构,每个字段是一个 Option,前面写它对应的扩展类型。ClientExtensions 的开头:

extension_struct! {
    pub(crate) struct ClientExtensions<'a> {
        /// Requested server name indication (RFC6066)
        ExtensionType::ServerName =>
            pub(crate) server_name: Option<ServerNamePayload<'a>>,

        /// Certificate status is requested (RFC6066)
        ExtensionType::StatusRequest =>
            pub(crate) certificate_status_request: Option<CertificateStatusRequest>,
        // ...
    } + {
        /// Order randomization seed.
        pub(crate) order_seed: u16,

        /// Extensions that must appear contiguously.
        pub(crate) contiguous_extensions: Vec<ExtensionType>,
    }
}

+ { ... } 里的字段不参与编解码,只是附带的数据。宏为结构生成一组方法,全部是对字段逐个展开的 match:

  • read_one 读类型、2 字节长度、切出子 Reader,交给 read_extension_body。已知类型由 read_once 解析进对应字段,字段已经是 Some 就是 DuplicateExtension;解析完子 Reader 必须读空。未知类型跳过正文,交给调用方传入的闭包 unknown。
  • encode_one 编码一个类型的扩展(字段是 None 就什么也不写),collect_used、only_contains、contains_any、clear、clone_one 供握手逻辑检查「对方发了哪些扩展」。

每种列表自己实现 Codec,在这里决定未知扩展怎么办:

结构未知扩展实现
ClientExtensions、CertificateRequestExtensions、NewSessionTicketExtensions丢弃,只检查重复DuplicateExtensionChecker 记下类型号
ServerExtensions(ServerHello、EncryptedExtensions)类型号存进 unknown_extensions客户端用 server_sent_unsolicited_extensions 拒绝没请求过的扩展
HelloRetryRequestExtensions报 UnknownHelloRetryRequestExtensionCodec 里的闭包直接返回错误
CertificateExtensions报 UnknownCertificateExtensionCodec 同上

服务端必须容忍未知扩展,否则客户端每加一个新扩展都会把老服务端打挂;客户端只会收到自己请求过的扩展,多出来的就是对方行为不端。ClientExtensions 的 read 还在这里检查 pre_shared_key 是不是最后一个扩展(RFC 8446 的要求,PSK 绑定值要对它之前的全部内容签名)。

编码顺序也由各结构自己定。ServerExtensions 按 ALL_EXTENSIONS 的固定顺序写;HelloRetryRequestExtensions 解析时记下收到的顺序,重新编码时照原样输出,ECH 确认要对重新编码的 HRR 求哈希。ClientHello 的扩展顺序是随机的:order_insensitive_extensions_in_random_order 用每个连接一个的 order_seed 和扩展号求一个整数哈希排序,used_extensions_in_encoding_order 再把 contiguous_extensions、ECH 和 PSK 按规定的位置放到最后。随机化来自 c296594d(「Randomize ClientHello extensions」,首次进入 0.23.0),提交说明引用的是 Chrome 为了对抗指纹识别提出的做法。

📝NOTE
冷知识:扩展列表从 Vec 变成结构是 0.23.29 的事

在此之前,ClientHelloPayload 存的是 Vec<ClientExtension>,ClientExtension 是一个每种扩展一个变体、外加 Unknown(UnknownExtension) 的枚举;找某个扩展靠 find_extension 线性扫描,重复检查是解析完以后单独调 has_duplicate_extension。4513c69e(「Introduce ClientExtensions type」,首次进入 0.23.29)改成了现在的样子,提交说明写的理由是:之前的提交已经去掉了「无损往返扩展数据」的需要;构造客户端扩展的分配更少;服务端处理 ClientHello 时不再反复遍历向量找扩展;重复检测挪进解码过程,为此新增了 InvalidMessage::DuplicateExtension。说明里还提到宏「把整体的编码和解码外置」,好让 ClientHello 能定制编码顺序(随机化)和「是否允许空输入」(TLS 1.2 的 ClientHello 可以完全没有扩展块)。PSK 必须在最后的检查也是这时挪进解码的,取代了原来的 check_psk_ext_is_last。之后几个提交(467c85c2 等,同在 0.23.29)把 HRR、TLS 1.3 证书条目、证书请求也换成了同一个宏。

实测

同一份配置连续建 8 个连接,ClientHello 都是 207 字节,扩展顺序每次不同;之后在一个 ClientHello 里插入各种扩展再解析,最后给客户端一个多了扩展 0xfe00 的 ServerHello:

ClientHello #0: 207 bytes, extension order [002b, 000a, 0017, 0033, 0005, 0000, 000d, 002d, 000b]
ClientHello #1: 207 bytes, extension order [000a, 0000, 0005, 000b, 000d, 002b, 002d, 0033, 0017]
ClientHello #2: 207 bytes, extension order [000a, 0000, 0033, 002b, 000b, 0005, 000d, 002d, 0017]
...
parse original: Ok(())
parse with unknown 0xfe00: Ok, parsed part mentions it? false
parse with 0xfe00 twice: Err(DuplicateExtension(65024))
parse with 0x0017 added again: Err(DuplicateExtension(23))
parse with encrypt_then_mac(0x0016) + 1 byte: Ok(())
parse with supported_versions twice: Err(DuplicateExtension(43))
parse with pre_shared_key last: Ok(())
parse with pre_shared_key first: Err(PreSharedKeyIsNotFinalExtension)
...
server flight records: [(22, 122), (20, 1), (23, 508)]
ServerHello extensions: [0033, 002b]
client on ServerHello + 0xfe00: PeerMisbehaved(UnsolicitedServerHelloExtension)
  Display: peer misbehaved: UnsolicitedServerHelloExtension
  client queued alert record: 1503030002026e
  • 9 个扩展(这个配置只开 TLS 1.3:SNI、status_request、supported_groups、ec_point_formats、signature_algorithms、extended_master_secret、supported_versions、psk_key_exchange_modes、key_share)每次排列不同,长度不变。
  • 未知的 0xfe00 被接受,解析结果里没有它的痕迹;同一个未知扩展出现两次被 DuplicateExtensionChecker 拦下,已知扩展重复由 read_once 拦下,错误里都是十进制的类型号。0x0016(encrypt_then_mac)rustls 不认识,带一个字节的正文也照样跳过。
  • 解析是 rustls::internal::msgs 里公开的 Message::try_from(PlainMessage),和连接里用的是同一条路径。
  • ServerHello 只有 key_share 和 supported_versions 两个扩展;多一个未请求的扩展,客户端报 UnsolicitedServerHelloExtension,排队的告警记录是 15 0303 0002 02 6e:致命(2)、unsupported_extension(110)。
❗IMPORTANT
动脑筋:把一个正文为空的 pre_shared_key(00 29 00 00)插到 ClientHello 扩展列表的最前面,Message::try_from 报什么错?

上面的「pre_shared_key first」用的是一个格式正确的 PSK 正文,报的是 PreSharedKeyIsNotFinalExtension。换成空正文呢?程序同上,答案在文末。

动脑筋答案

parse with empty pre_shared_key first: Err(MissingData("u16"))

不是 PreSharedKeyIsNotFinalExtension。ClientExtensions::read 的循环先调 read_one,read_one 立刻用 read_once 把正文解析成 PresharedKeyOffer,它的第一个字段是 2 字节长度的身份列表,空正文读不出 u16,错误从这里冒出来;「PSK 是不是最后一个」的判断在 read_one 返回之后才做,根本轮不到。rustls 的解析是逐字段、遇错即停的,先撞上哪条规则取决于检查的先后,不取决于哪条更「根本」。

小结

  • Reader 是切片加游标,Codec<'a> 的读结果可以借用输入;read_bytes 和各结构的 read 都不容忍多余字节。
  • 列表的长度前缀由元素类型的 TlsListElement::SIZE_LEN 决定,只有四种;编码时 LengthPrefixedBuffer 先写 0xff 占位、Drop 时回填,没回填时读到的是最大长度,杜绝把内容当成后续结构。
  • enum_builder! 让每个协议枚举都有 Unknown,不认识的数字码是合法值;read_version 按协商版本选解析器,握手消息同时保留原始字节给转录哈希用。
  • extension_struct! 把扩展列表变成全是 Option 字段的结构(0.23.29 起):重复扩展在解码时报错,未知扩展在 ClientHello 里丢弃、在 ServerHello 里记下并当作未请求的扩展拒绝、在 HRR 和证书条目里直接报错。
  • ClientHello 的扩展顺序按每连接一个的种子随机化(0.23.0 起),PSK 固定在最后。

💬 关联讨论

发起讨论
加载中...