状态机
主线位置:握手主线的骨架。客户端发出 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>;
'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 在交给状态之前先处理三类不归状态管的东西:
- TLS 1.3 握手期间的 ChangeCipherSpec。为了兼容中间设备,TLS 1.3 双方会发一条假的 CCS(内容是一个字节
01)。握手阶段收到合法的 CCS 直接丢掉,不进状态机;内容不是01的,以unexpected_message告警中止。 - 解析失败。
Message::try_from失败时,按错误类型换成告警(多数是decode_error)。 - 告警消息。交给
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/zlibfeature,所以是后者。 - 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_protocol | unexpected_message |
| ServerHello 里有不允许的明文扩展 | validate_server_hello | unsupported_extension |
| 服务器选了没提议的套件 | ExpectServerHello::handle | handshake_failure |
| 服务器的密钥共享组不是我们发过的 | handle_server_hello | illegal_parameter |
| 证书校验失败 | send_cert_verify_error_alert | 按 CertificateError 映射,如 UnknownIssuer → unknown_ca,过期 → certificate_expired |
| 服务器 Finished 对不上 | ExpectFinished::handle | decrypt_error |
| 记录解不开 | handle_deframe_error | bad_record_mac |
告警只发一次:send_fatal_alert 开头有 debug_assert!(!self.sent_fatal_alert),发完置位。
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 参数的版本。
上面说握手期间的 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 加的。