Rust in Everything
基于 rustls 0.23.46

状态机

主线位置:握手主线的骨架。客户端发出 ClientHello 之后,每收到一条握手消息,process_new_packets 就把它交给「当前状态」;当前状态检查消息类型、做完自己的事,把自己消费掉,返回下一个状态。本页讲这个机制本身:trait 长什么样、谁来推、带着什么上下文、消息不对时发什么告警。每个状态具体做什么,见客户端 TLS 1.3 和服务端 TLS 1.3。

stateDiagram-v2
  [*] --> ExpectServerHelloOrHelloRetryRequest: ClientConnection new
  ExpectServerHelloOrHelloRetryRequest --> ExpectEncryptedExtensions: ServerHello
  ExpectServerHelloOrHelloRetryRequest --> ExpectServerHello: HelloRetryRequest
  ExpectServerHello --> ExpectEncryptedExtensions: ServerHello
  ExpectEncryptedExtensions --> ExpectCertificateOrCertReq: 完整握手
  ExpectEncryptedExtensions --> ExpectFinished: 会话恢复
  ExpectCertificateOrCertReq --> ExpectCertificateVerify: Certificate
  ExpectCertificateVerify --> ExpectFinished: CertificateVerify
  ExpectFinished --> ExpectTraffic: Finished
  ExpectTraffic --> ExpectTraffic: 票据 数据 KeyUpdate

上图是客户端 TLS 1.3 实际停留过的状态(下文实测打印)。涉及的代码:State<Data> 和 Context 在 common_state.rs,驱动它们的 ConnectionCore 在 conn.rs,检查消息类型的宏在 70 行的 check.rs。

实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46(ring 和 aws_lc_rs 两个 feature 都开,每个配置都用 builder_with_provider 显式指定 provider),release 构建,所有握手在内存里完成,不开套接字。

一个 trait

pub(crate) trait State<Data>: Send + Sync {
    fn handle<'m>(
        self: Box<Self>,
        cx: &mut Context<'_, Data>,
        message: Message<'m>,
    ) -> Result<Box<dyn State<Data> + 'm>, Error>
    where
        Self: 'm;

    fn export_keying_material(
        &self,
        _output: &mut [u8],
        _label: &[u8],
        _context: Option<&[u8]>,
    ) -> Result<(), Error> {
        Err(Error::HandshakeNotComplete)
    }

    fn extract_secrets(&self) -> Result<PartiallyExtractedSecrets, Error> {
        Err(Error::HandshakeNotComplete)
    }

    fn send_key_update_request(&mut self, _common: &mut CommonState) -> Result<(), Error> {
        Err(Error::HandshakeNotComplete)
    }

    fn handle_decrypt_error(&self) {}

    fn into_external_state(self: Box<Self>) -> Result<Box<dyn KernelState + 'static>, Error> {
        Err(Error::HandshakeNotComplete)
    }

    fn into_owned(self: Box<Self>) -> Box<dyn State<Data> + 'static>;
}

要点有三个。

  • handle 拿走 self: Box<Self>。调用之后旧状态就没了,它的字段要么被搬进下一个状态,要么随它一起释放。一个状态不可能被处理两次,也不可能在返回新状态后还被别人拿着。
  • 只有 handle 和 into_owned 没有默认实现。导出密钥材料、取出流量密钥(kTLS 用)、主动发 KeyUpdate,默认都返回 HandshakeNotComplete;只有握手完成后的 ExpectTraffic 覆盖它们。连接对象的 export_keying_material 在握手中途调用时得到这个错误,不是因为哪里查了一个布尔标志,而是因为当前状态的类型没有这个能力。
  • 'm 和 into_owned。消息 Message<'m> 可能借用着输入缓冲里的字节,下一个状态如果直接借用消息里的数据(比如证书链),它的生命周期就被限制在 'm 里。驱动方拿到返回值后立刻调 into_owned(),把借用的部分拷成自己的,得到一个 'static 的状态再存回去。大多数状态的 into_owned 就是 self;客户端的 ExpectCertificateVerify<'a> 带生命周期参数,它的 into_owned 才真的调 server_cert.into_owned()。

trait 的类型参数 Data 区分两侧:客户端是 ClientConnectionData(只有 early_data 和 ech_status 两个字段),服务端是 ServerConnectionData。client/hs.rs 给常用的组合起了别名:

pub(super) type NextState<'a> = Box<dyn State<ClientConnectionData> + 'a>;
pub(super) type NextStateOrError<'a> = Result<NextState<'a>, Error>;
pub(super) type ClientContext<'a> = crate::common_state::Context<'a, ClientConnectionData>;
📝NOTE
冷知识:'m 是 0.23 才加的

0.22 及以前,handle 的签名是 fn handle(self: Box<Self>, cx: &mut Context<'_, Data>, message: Message) -> Result<Box<dyn State<Data>>, Error>:Message 不带生命周期,消息体一律是拥有的 Vec<u8>,状态也一律是 'static。提交 65370b8d(「prepare State trait to handle borrowed types」,首次发布在 0.23.0)给 handle 加上 'm、给 trait 加上 into_owned,并把 process_main_protocol 里的 state = next; 改成 state = next.into_owned();。这是 0.23 为「消息直接借用输入缓冲、不先拷一份」铺路的一步;同一版本还加了不经过内部缓冲的 UnbufferedConnectionCommon,它和缓冲版共用同一套状态。

谁来推

ConnectionCore 把当前状态存在一个 Result 里:

pub(crate) struct ConnectionCore<Data> {
    pub(crate) state: Result<Box<dyn State<Data>>, Error>,
    pub(crate) data: Data,
    pub(crate) common_state: CommonState,
    pub(crate) hs_deframer: HandshakeDeframer,
    // ...
}

process_new_packets 先用 mem::replace 把状态取出来(槽里临时放一个 Err(HandshakeNotComplete)),然后循环:去帧拿到一条消息,交给 process_msg,拿回新状态,再取下一条。任何一步出错,就把错误克隆一份存进槽里再返回:

            match self.process_msg(msg, state, Some(sendable_plaintext)) {
                Ok(new) => state = new,
                Err(e) => {
                    self.state = Err(e.clone());
                    deframer_buffer.discard(buffer_progress.take_discard());
                    return Err(e);
                }
            }

所以 rustls 的连接出错一次就永久失效:之后每次 process_new_packets 都在开头看到 Err(e),原样返回同一个错误。下面的实验能看到这一点。

process_msg 在交给状态之前先处理三类不归状态管的东西:

  1. TLS 1.3 握手期间的 ChangeCipherSpec。为了兼容中间设备,TLS 1.3 双方会发一条假的 CCS(内容是一个字节 01)。握手阶段收到合法的 CCS 直接丢掉,不进状态机;内容不是 01 的,以 unexpected_message 告警中止。
  2. 解析失败。Message::try_from 失败时,按错误类型换成告警(多数是 decode_error)。
  3. 告警消息。交给 CommonState::process_alert:收到 close_notify 记一笔;TLS 1.3 里收到除 user_canceled 以外的 warning 级告警,回一个 decode_error 并中止;fatal 告警直接变成 Error::AlertReceived。

剩下的才进 process_main_protocol:

        let mut cx = Context {
            common: self,
            data,
            sendable_plaintext,
        };
        match state.handle(&mut cx, msg) {
            Ok(next) => {
                state = next.into_owned();
                Ok(state)
            }
            Err(e @ Error::InappropriateMessage { .. })
            | Err(e @ Error::InappropriateHandshakeMessage { .. }) => {
                Err(self.send_fatal_alert(AlertDescription::UnexpectedMessage, e))
            }
            Err(e) => Err(e),
        }

它前面还有一段只对 TLS 1.2 生效的逻辑:握手完成后收到重协商请求(客户端收到 HelloRequest、服务端收到 ClientHello),回一个 warning 级的 no_renegotiation,不交给状态。rustls 不支持重协商。

Context:状态能碰到的东西

pub(crate) struct Context<'a, Data> {
    pub(crate) common: &'a mut CommonState,
    pub(crate) data: &'a mut Data,
    /// Buffered plaintext. This is `Some` if any plaintext was written during handshake and `None`
    /// otherwise.
    pub(crate) sendable_plaintext: Option<&'a mut ChunkVecBuffer>,
}
  • common 是两侧共用的 CommonState:记录层(装哪把加密、解密密钥)、待发送的 TLS 字节、协商结果(版本、套件、ALPN、对端证书)、告警。状态通过它发消息(send_msg)、换密钥、发告警。
  • data 是这一侧特有的少量数据。客户端的早期数据状态和 ECH 状态放在这里,而不是放在某个状态里,因为它们要跨越多个状态,连接对象的公开方法(如 is_early_data_accepted)也要读。
  • sendable_plaintext 是握手完成前应用层已经写进来的明文。握手的最后一步调 common.start_traffic(&mut cx.sendable_plaintext),把它们一次加密发出去,见发送路径。

状态自己持有的是只有握手某一段才需要的东西:配置、转录哈希、密钥日程、对端证书、随机数。状态之间靠搬运字段交接,例如 ExpectCertificateVerify 处理完,把 transcript、key_schedule 等原样搬进新建的 ExpectFinished。

实测:真实的状态名

状态类型是 crate 私有的,从外面看不到当前停在哪个状态。实验从 cargo 缓存里把 rustls-0.23.46 拷一份到实验目录(hsa-patched/rustls,src/common_state.rs 与 tag 上的文件逐字节相同),只加两处:给 trait 加一个默认方法,用 type_name 取实现者的类型名;在 process_main_protocol 里调 handle 前后各取一次打印出来。

  // hsa experiment: name the concrete state type
  fn hsa_state_name(&self) -> &'static str {
    core::any::type_name::<Self>()
  }

默认方法在每个实现里单态化,经 dyn State 调用时拿到的是具体类型的名字。实验程序 hsa-states 用 [dependencies] rustls = { path = "../hsa-patched/rustls" } 跑一次 ring provider 的完整握手,客户端一侧的输出:

[hsa] Client: rustls::client::hs::ExpectServerHelloOrHelloRetryRequest + ServerHello -> rustls::client::tls13::ExpectEncryptedExtensions
[hsa] Client: rustls::client::tls13::ExpectEncryptedExtensions + EncryptedExtensions -> rustls::client::tls13::ExpectCertificateOrCertReq
[hsa] Client: rustls::client::tls13::ExpectCertificateOrCertReq + Certificate -> rustls::client::tls13::ExpectCertificateVerify<'_>
[hsa] Client: rustls::client::tls13::ExpectCertificateVerify<'_> + CertificateVerify -> rustls::client::tls13::ExpectFinished
[hsa] Client: rustls::client::tls13::ExpectFinished + Finished -> rustls::client::tls13::ExpectTraffic
-- round 1: c->s 236 bytes, s->c 663 bytes
...
[hsa] Client: rustls::client::tls13::ExpectTraffic + NewSessionTicket -> rustls::client::tls13::ExpectTraffic
[hsa] Client: rustls::client::tls13::ExpectTraffic + NewSessionTicket -> rustls::client::tls13::ExpectTraffic
-- round 2: c->s 80 bytes, s->c 184 bytes

几点和直觉不同的地方:

  • 初始状态不是 ExpectServerHello,而是 ExpectServerHelloOrHelloRetryRequest。只有收到 HelloRetryRequest、发出第二个 ClientHello 之后,才换成只认 ServerHello 的 ExpectServerHello:第二次再来 HelloRetryRequest 就是 unexpected_message。
  • ExpectCertificate 从没被存下来。ExpectCertificateOrCertReq 收到 Certificate 时,现场 Box::new(ExpectCertificate { .. }).handle(cx, m),把消息转交给它,返回的是 ExpectCertificateVerify。名字里带 Or 的状态都是这样的分派器,自己不处理消息。
  • 服务器没有开证书压缩,客户端也没提议:ExpectEncryptedExtensions 根据自己是否在 ClientHello 里提议了证书压缩,在 ExpectCertificateOrCompressedCertificateOrCertReq 和 ExpectCertificateOrCertReq 之间选一个。模板没开 brotli / zlib feature,所以是后者。
  • ChangeCipherSpec 不出现:服务端在 ServerHello 后发的假 CCS 在 process_msg 里就被丢掉了。

同一程序里会话恢复、HelloRetryRequest 和 aws-lc-rs 的输出放在客户端 TLS 1.3。

不该来的消息

每个状态的 handle 第一件事是确认消息类型。check.rs 提供两个宏:

macro_rules! require_handshake_msg(
  ( $m:expr, $handshake_type:path, $payload_type:path ) => (
    match &$m.payload {
        MessagePayload::Handshake { parsed: $crate::msgs::handshake::HandshakeMessagePayload(
            $payload_type(hm),
        ), .. } => Ok(hm),
        payload => Err($crate::check::inappropriate_handshake_message(
            payload,
            &[$crate::ContentType::Handshake],
            &[$handshake_type]))
    }
  )
);

require_handshake_msg! 借用消息体,require_handshake_msg_move! 把它搬出来(ExpectCertificate 用后者拿走证书链,省一次拷贝)。类型不对时调 inappropriate_handshake_message:收到的是握手消息,就构造 Error::InappropriateHandshakeMessage { expect_types, got_type };不是握手消息(比如握手中途来了应用数据),就构造 Error::InappropriateMessage { expect_types, got_type },两个分支都先打一行 warn! 日志。接受多种消息的状态(Or 分派器、ExpectTraffic)自己写 match,在兜底分支调同一个函数。

这两个函数只构造错误,不发告警。告警在上面的 process_main_protocol 里统一补上:这两种错误一律换成 unexpected_message。其他错误由状态自己在出错处调 send_fatal_alert 发,它的返回值就是错误本身,所以写法总是 return Err(cx.common.send_fatal_alert(描述, 错误))。客户端 TLS 1.3 里常见的几种:

情况在哪告警
消息类型不对process_main_protocolunexpected_message
ServerHello 里有不允许的明文扩展validate_server_hellounsupported_extension
服务器选了没提议的套件ExpectServerHello::handlehandshake_failure
服务器的密钥共享组不是我们发过的handle_server_helloillegal_parameter
证书校验失败send_cert_verify_error_alert按 CertificateError 映射,如 UnknownIssuer → unknown_ca,过期 → certificate_expired
服务器 Finished 对不上ExpectFinished::handledecrypt_error
记录解不开handle_deframe_errorbad_record_mac

告警只发一次:send_fatal_alert 开头有 debug_assert!(!self.sent_fatal_alert),发完置位。

❗IMPORTANT
动脑筋:客户端刚发出 ClientHello,收到的却是一条明文的 Finished(16 03 03 00 24 | 14 00 00 20 | 32 个 0)。process_new_packets 返回错误之后,write_tls 会写出多少字节?再调一次 process_new_packets 呢?提示:告警还没有加密密钥可用。答案在文末。

为什么是 Box<dyn State>

也可以写成一个大 enum,每个变体一个状态。rustls 选 trait 对象,代码里能看到的好处是:

  • 每个状态只带自己需要的字段,且不用 Option。ExpectFinished 有 cert_verified: ServerCertVerified 和 sig_verified: HandshakeSignatureValid 两个字段,这两个类型只能由校验器返回(或在会话恢复时用 assertion() 显式声明),构造 ExpectFinished 就得先拿到它们。ExpectTraffic 里也留着 _cert_verified、_sig_verified、_fin_verified 三个从不读的字段,它们存在的意义就是「能走到这里,说明三项校验都做过」。用 enum 时这些字段要么在每个变体里重复,要么放进一个公共结构变成 Option,每次用都要 unwrap。
  • 消费式转移。self: Box<Self> 让「旧状态用完即毁」由所有权保证;enum 的写法通常是 match mem::replace(&mut self.state, ..),同样能做到,但每个分支都要自己记得放回去。
  • 两侧和两个协议版本共用驱动。ConnectionCore 不知道有哪些状态,TLS 1.2 的 client/tls12.rs、QUIC 的 ExpectQuicTraffic、kTLS 用的 into_external_state 都只是 trait 的又一个实现。

代价是每次转移一次堆分配和一次虚调用。一次完整握手,客户端一侧是六个状态,和一次 ECDHE、一次证书链校验相比可以忽略。客户端和服务端在 0.20 之前各有一个 trait,提交 a055bd38(「Use a single State trait across client/server」,首次发布在 0.20.0)把它们合成现在这个带 Data 参数的版本。

📝NOTE
冷知识:加密的 ChangeCipherSpec 直到 0.23.46 才被拒绝

上面说握手期间的 CCS 会被丢掉,这个判断在 process_msg 里做,而 process_msg 看到的是解密之后的消息。所以一条被握手密钥加密、内层内容类型写成 change_cipher_spec 的记录,解密后和明文 CCS 长得一样,也被当成兼容性 CCS 丢掉了。RFC 要求这种「受保护的 CCS」必须以 unexpected_message 中止。提交 fce9c85c(「reject protected change_cipher_spec records」,首次发布就是本卷锁定的 0.23.46)把检查放到解开 TLS 1.3 内层明文的地方:into_tls13_unpadded_message 看到内层类型是 CCS 就返回 PeerMisbehaved::IllegalMiddleboxChangeCipherSpec,handle_deframe_error 为它补上 unexpected_message 告警,就是本页 handle_deframe_error 里倒数第二个分支。

动脑筋答案

7 个字节;再调一次得到同一个错误。实验程序 hsa-states 的 unexpected() 的输出:

[hsa] Client: rustls::client::hs::ExpectServerHelloOrHelloRetryRequest + Finished -> Err(InappropriateHandshakeMessage { expect_types: [ServerHello, HelloRetryRequest], got_type: Finished })
error Debug: InappropriateHandshakeMessage { expect_types: [ServerHello, HelloRetryRequest], got_type: Finished }
error Display: received unexpected handshake message: got Finished when expecting ServerHello or HelloRetryRequest
client then writes 7 bytes: [15, 03, 03, 00, 02, 02, 0a]
process_new_packets again: received unexpected handshake message: got Finished when expecting ServerHello or HelloRetryRequest
  • expect_types 是 [ServerHello, HelloRetryRequest]:当前状态是 ExpectServerHelloOrHelloRetryRequest,它的兜底分支列出了两种。
  • 7 个字节是一条明文告警记录:15 是内容类型 Alert(21),03 03 是记录版本,00 02 是长度,02 是级别 fatal,0a 是 unexpected_message(10)。还没收到 ServerHello,记录层没有加密密钥,send_fatal_alert 按 record_layer.is_encrypting() 决定是否加密,这里是否。
  • 第二次调用不再解析任何东西:状态槽里已经是 Err,process_new_packets 开头就把它克隆出来返回。

小结

  • State<Data> 只要求实现 handle 和 into_owned;handle 按值拿走 Box<Self>,返回下一个状态,导出密钥等能力默认返回 HandshakeNotComplete,只有 ExpectTraffic 覆盖。
  • ConnectionCore 把状态存在 Result 里,出错后永久保存错误;process_msg 先处理 TLS 1.3 兼容 CCS、解析错误和告警,剩下的经 process_main_protocol 交给状态。
  • Context 给状态三样东西:CommonState、这一侧的 Data、握手前缓冲的明文。
  • check.rs 只构造 InappropriateMessage / InappropriateHandshakeMessage,process_main_protocol 统一补发 unexpected_message;其他告警由状态在出错处自己发。
  • 实测客户端完整握手停留过的状态:ExpectServerHelloOrHelloRetryRequest → ExpectEncryptedExtensions → ExpectCertificateOrCertReq → ExpectCertificateVerify → ExpectFinished → ExpectTraffic;ExpectCertificate 只在分派时临时出现。
  • 'm 和 into_owned 是 0.23.0 加的;加密 CCS 的拒绝是 0.23.46 加的。

💬 关联讨论

发起讨论
加载中...