Rust in Everything
基于 rustls 0.23.46

一次握手

主线位置:握手主线和记录主线各完整走一遍。客户端、服务端在同一个进程里,用两块内存当网络;握手两轮,再发一条 ping、回一条 pong。每条记录都被截下来,事后用 KeyLogFile 写出的密钥逐条解密,看清它的外层类型、长度、由哪个流量密钥保护、序列号是几、里面装的是什么。

sequenceDiagram
  participant C as 客户端
  participant S as 服务端
  C->>S: ClientHello 236 字节 明文
  S->>C: ServerHello 与 CCS 明文
  S->>C: EE Cert CV Finished 526 加密
  C->>S: CCS 与 Finished 80 字节
  S->>C: 两张票据 184 字节
  C->>S: ping 26 字节
  S->>C: pong 26 字节

程序

实验和 sans-IO 里的驱动循环一样:ring provider,with_safe_default_protocol_versions(),rcgen 生成的 localhost 自签名证书(ECDSA P-256),客户端只信任这一张证书。多做了三件事:

  • 客户端配置设 key_log = Arc::new(KeyLogFile::new()),运行时 SSLKEYLOGFILE 指向一个文件。只给客户端设,免得每个秘密写两遍。
  • transfer 把 write_tls 写出的字节按 5 字节记录头切开,每条记录存下来。每次 write_tls 写出的都是整数条记录。
  • 握手和 ping / pong 都结束后,读回密钥文件,对每条外层类型为 23 的记录按 RFC 8446 第 7 节算出 key 和 IV 去解密。

KeyLogFile 实现 KeyLog trait,按 NSS 的格式每行写「标签、client random、秘密」。这次写了五行,标签依次是 CLIENT_HANDSHAKE_TRAFFIC_SECRET、SERVER_HANDSHAKE_TRAFFIC_SECRET、CLIENT_TRAFFIC_SECRET_0、SERVER_TRAFFIC_SECRET_0、EXPORTER_SECRET;第二列都一样,正好等于 ClientHello 里的 32 字节随机数。解密只用到前四个:

/// RFC 8446 7.1 HKDF-Expand-Label with an empty context.
fn expand_label(alg: hkdf::Algorithm, secret: &[u8], label: &str, len: usize) -> Vec<u8> {
  let full = format!("tls13 {label}");
  let len16 = (len as u16).to_be_bytes();
  let label_len = [full.len() as u8];
  let info: [&[u8]; 4] = [&len16, &label_len, full.as_bytes(), &[0u8]];
  let prk = hkdf::Prk::new_less_safe(alg, secret);
  let mut out = vec![0u8; len];
  prk.expand(&info, Len(len)).unwrap().fill(&mut out).unwrap();
  out
}

key 是 expand_label(秘密, "key", 32),IV 是 expand_label(秘密, "iv", 12),nonce 是 IV 和 64 位序列号异或,附加数据是 5 字节记录头,AES-256-GCM 解开后去掉末尾的 0,最后一个字节是内层内容类型。程序不假设哪条记录用哪个密钥:每个方向按「握手密钥 → 应用密钥」的顺序试,当前密钥解不开就换下一个、序列号从 0 重新数。所以下表的「protected by」一列是解密成功得出的,不是推断的。完整程序、密钥文件、每条记录的十六进制都在实验目录 exp-rustls/mainline/ 里,其他页面引用同一份数据。

九条记录

原样输出(release):

round 1: client->server 236 bytes, server->client 664 bytes
round 2: client->server 80 bytes, server->client 184 bytes
after handshake: client wants_write=false, server wants_write=false
app: client->server 26 bytes ("ping"), server->client 26 bytes ("pong")
version Some(TLSv1_3), suite Some(TLS13_AES_256_GCM_SHA384), kx Some(X25519)

#   step     dir  outer type             len  protected by                     seq  inner
1   round 1  C->S Handshake(22)          231  (plaintext)                        -  Handshake: ClientHello(231)
2   round 1  S->C Handshake(22)          122  (plaintext)                        -  Handshake: ServerHello(122)
3   round 1  S->C ChangeCipherSpec(20)     1  (plaintext)                        -  ChangeCipherSpec [01]
4   round 1  S->C ApplicationData(23)    526  SERVER_HANDSHAKE_TRAFFIC_SECRET    0  Handshake: EncryptedExtensions(10) + Certificate(367) + CertificateVerify(80) + Finished(52)
5   round 2  C->S ChangeCipherSpec(20)     1  (plaintext)                        -  ChangeCipherSpec [01]
6   round 2  C->S ApplicationData(23)     69  CLIENT_HANDSHAKE_TRAFFIC_SECRET    0  Handshake: Finished(52)
7   round 2  S->C ApplicationData(23)    179  SERVER_TRAFFIC_SECRET_0            0  Handshake: NewSessionTicket(81) + NewSessionTicket(81)
8   app      C->S ApplicationData(23)     21  CLIENT_TRAFFIC_SECRET_0            0  ApplicationData "ping"
9   app      S->C ApplicationData(23)     21  SERVER_TRAFFIC_SECRET_0            1  ApplicationData "pong"

len 是记录体的长度,不含 5 字节头;括号里是握手消息的长度,含 4 字节消息头。几个数字能对上账:

  • 第 4 条:10 + 367 + 80 + 52 = 509 字节明文,加 1 字节内层类型、16 字节 GCM 标签,正好 526。服务端第一轮 664 = (5 + 122) + (5 + 1) + (5 + 526)。
  • 第 8、9 条:4 字节明文 + 1 + 16 = 21。一条 ping 在线上是 26 字节,多出的 22 字节就是记录头、内层类型和标签。rustls 不加填充:解开后末尾没有多余的 0。
  • Finished 是 52 字节:4 字节头加 48 字节的 verify_data,48 是 SHA-384 的输出长度,因为套件是 TLS13_AES_256_GCM_SHA384。
  • 服务端第一轮在 662 到 664 字节之间浮动(六次运行:662 四次,663、664 各一次):证书里和 CertificateVerify 里的 ECDSA 签名是 DER 编码,长度会差一两个字节;其他记录每次都一样长。

外层:TLS 1.3 的加密记录外层一律写成 ApplicationData(23)、版本 0x0303,真正的类型藏在密文里。只有第一条 ClientHello 的记录头版本是 0x0301(16 03 01 00 e7),其余都是 0x0303。

第一轮:ClientHello

ClientConnection::new 返回时 ClientHello 已经拼好放进发送缓冲:emit_client_hello_for_retry 在构造函数里就运行了。把第 1 条记录按 RFC 8446 的结构拆开:

字段内容
legacy_session_id32 字节随机数(TLS 1.3 的「中间盒兼容模式」,见下文 CCS)
套件10 个:1302、1301、1303(TLS 1.3 的三个),六个 TLS 1.2 的 ECDHE 套件,最后是 00ff(TLS_EMPTY_RENEGOTIATION_INFO_SCSV)
key_share只有一个:X25519,32 字节
supported_groupsX25519、secp256r1、secp384r1
supported_versionsTLS 1.3、TLS 1.2
signature_algorithms9 个签名方案
其他server_name(localhost)、status_request、extended_master_secret、session_ticket、psk_key_exchange_modes、ec_point_formats

密钥共享只猜一个组:initial_key_share 先看会话缓存里有没有这个服务器上次用的组(kx_hint),没有就取 provider 的 kx_groups 第一个。ring 的默认表第一个是 X25519,服务端也支持,所以不用多一轮。扩展逐个讲、aws-lc-rs 下发几个密钥共享,见 客户端 TLS 1.3。客户端的下一个状态是 ExpectServerHelloOrHelloRetryRequest,它根据来的是 ServerHello 还是 HelloRetryRequest 把消息转给 ExpectServerHello 或进入重试。

第一轮:服务端一次回四样东西

服务端的 ExpectClientHello 选出 TLS 1.3、TLS13_AES_256_GCM_SHA384、X25519 和证书,交给 CompleteClientHelloHandling::handle_client_hello。它在同一次调用里依次排进发送缓冲:

  1. emit_server_hello:明文 ServerHello,只有 key_share(X25519,32 字节)和 supported_versions(0304)两个扩展,legacy_session_id_echo 原样回显客户端那 32 字节。发完它,服务端就有了握手密钥。
  2. emit_fake_ccs:一个 1 字节的 ChangeCipherSpec。
  3. emit_encrypted_extensions、证书、emit_certificate_verify_tls13、emit_finished_tls13:四条握手消息,用 SERVER_HANDSHAKE_TRAFFIC_SECRET 加密。

四条加密的握手消息被装进同一条记录(第 4 条):rustls 把一轮里连续的握手消息合并后再分片,509 字节远小于 16384 的分片上限。怎么合并、怎么切,见 发送路径。

CCS 在 TLS 1.3 里没有任何密码学作用。RFC 8446 附录 D.4 的「中间盒兼容模式」要求客户端带一个非空的 legacy_session_id、双方各发一个假的 CCS,让握手在只认识 TLS 1.2 的中间设备看来像一次会话恢复。rustls 客户端在 ClientHelloInput::new 里只要会说 TLS 1.3(且不是 QUIC)、又没有可恢复的 TLS 1.2 会话 id,就生成随机的 32 字节 session id;收到 CCS 的一方直接丢弃它(conn.rs 里的 trace!("Dropping CCS"))。

第一轮:客户端逐个状态校验

客户端一次 process_new_packets 处理完第 2 到 4 条记录,状态依次是:

  1. ExpectServerHello → handle_server_hello:算出共享秘密和握手流量密钥,把自己的假 CCS(emit_fake_ccs,sent_tls13_fake_ccs 保证只发一次)排进发送缓冲。
  2. ExpectEncryptedExtensions:没有恢复会话,客户端也没提供证书压缩,下一个是 ExpectCertificateOrCertReq(卷目录写的 ExpectCertificate 之前还有这一个分派状态)。
  3. ExpectCertificate:只解析、存下证书链。
  4. ExpectCertificateVerify:这时才调校验器验证书链,再验 CertificateVerify 的签名(校验主线,见 04 证书校验)。
  5. ExpectFinished:核对服务端的 Finished,派生应用流量密钥,排进自己的 Finished,进入 ExpectTraffic。

这五步背后的密钥日程每一步怎么推进,见 密钥日程;状态之间为什么用 Box<dyn State> 串起来,见 状态机。

第二轮:Finished 和票据

客户端第二轮发 80 字节:CCS 记录 6 字节,加 Finished 记录 74 字节(第 6 条,52 + 1 + 16 = 69 字节体),用 CLIENT_HANDSHAKE_TRAFFIC_SECRET 加密,序列号 0:每换一个密钥,序列号从 0 重新数。

服务端在 ExpectFinished 里核对完客户端的 Finished,用 emit_ticket 连发两张 NewSessionTicket(ServerConfig::send_tls13_tickets 默认 2),同样合在一条记录里(第 7 条)。这时服务端已经换到了应用流量密钥 SERVER_TRAFFIC_SECRET_0,票据用它的序列号 0。票据怎么生成、下一次连接怎么用,见 会话恢复与 0-RTT。

所以「握手两轮」的第二轮里,服务端那 184 字节其实已经是应用阶段的数据。握手结束后两边都没有待发字节(wants_write=false),应用数据从零开始。

❗IMPORTANT
动脑筋:第 8 条 ping 的序列号是 0,第 9 条 pong 也是这个方向上第一条应用数据,为什么它的序列号是 1?

HelloRetryRequest:猜错组的代价

客户端只为 X25519 发了密钥共享。如果服务端不支持 X25519,就要回一个 HelloRetryRequest(形式上是一个随机数为固定值的 ServerHello),告诉客户端换哪个组再来一次,由 emit_hello_retry_request 发出。把服务端 provider 的 kx_groups 改成只有 secp384r1,其他不变,同一个程序:

round 1: client->server 236 bytes, server->client 99 bytes
round 2: client->server 307 bytes, server->client 723 bytes
round 3: client->server 74 bytes, server->client 184 bytes
after handshake: client wants_write=false, server wants_write=false
app: client->server 26 bytes ("ping"), server->client 26 bytes ("pong")
version Some(TLSv1_3), suite Some(TLS13_AES_256_GCM_SHA384), kx Some(secp384r1)

同一次运行记录表的前七条:

1   round 1  C->S Handshake(22)          231  (plaintext)                        -  Handshake: ClientHello(231)
2   round 1  S->C Handshake(22)           88  (plaintext)                        -  Handshake: ServerHello(88)
3   round 1  S->C ChangeCipherSpec(20)     1  (plaintext)                        -  ChangeCipherSpec [01]
4   round 2  C->S ChangeCipherSpec(20)     1  (plaintext)                        -  ChangeCipherSpec [01]
5   round 2  C->S Handshake(22)          296  (plaintext)                        -  Handshake: ClientHello(296)
6   round 2  S->C Handshake(22)          187  (plaintext)                        -  Handshake: ServerHello(187)
7   round 2  S->C ApplicationData(23)    526  SERVER_HANDSHAKE_TRAFFIC_SECRET    0  Handshake: EncryptedExtensions(10) + Certificate(367) + CertificateVerify(80) + Finished(52)

多出一整轮往返:第一轮服务端只回 88 字节的 HRR 和 CCS(99 字节),客户端先补上自己的 CCS,再发第二个 ClientHello(296 字节,secp384r1 的公钥 97 字节比 X25519 的 32 字节长);之后和正常握手一样。第三轮客户端只发 74 字节的 Finished 记录,没有 CCS:它在第二轮已经发过了。在网络上,这一轮就是多一个 RTT。

客户端记住了这次的教训。同一个 ClientConfig 再连一次:

second connection round 1: client->server 392 bytes, server->client 284 bytes
second connection round 2: client->server 80 bytes, server->client 184 bytes
second connection: kx Some(secp384r1), handshake kind Some(Resumed)

第二次只有两轮,直接猜中 secp384r1:initial_key_share 从会话缓存里读到了 kx_hint。这次还用上了第一次拿到的票据,所以是恢复握手(Resumed),服务端第一轮不再发证书,只有 284 字节。

📝NOTE
冷知识:记住「服务端喜欢哪个组」的时机,在 0.23.46 才推迟到握手确认之后

kx_hint 是客户端会话缓存里的一项:每个服务器名记一个组,下次连它就先猜这个组。0.23.45 及之前,handle_server_hello 在刚收到 ServerHello 时就调 set_kx_hint 记下服务端选的组,这时证书和 Finished 都还没校验。f04804fa(2026-10-10,「Delay saving KX hint until handshake confirmation」,就是 tag v/0.23.46 指向的提交,首次发布于 0.23.46)把这几行从 handle_server_hello 删掉,给从 ExpectEncryptedExtensions 到 ExpectFinished 的每个状态都加了一个 kx_group: NamedGroup 字段,把组一路带下去,在客户端的 ExpectFinished 核对完服务端的 Finished、把自己的 Finished 排进发送缓冲之后才 set_kx_hint。提交说明只有标题。上面第二次连接猜中 secp384r1,用的就是在这个新位置写下的值。

动脑筋答案

序列号是按「方向 + 密钥」计的,不是按「应用数据」计的。服务端换到 SERVER_TRAFFIC_SECRET_0 后发的第一条记录是第 7 条,装着两张票据,用掉了序列号 0;pong 是这个密钥下的第二条记录,所以是 1。客户端的 CLIENT_TRAFFIC_SECRET_0 下在 ping 之前没有发过任何记录,ping 是 0。解密程序如果把 pong 当成序列号 0 去算 nonce,GCM 校验会失败;本卷的程序就是靠「换密钥、换序列号」的试探才把这一列打对的。序列号怎么存、nonce 怎么算,见 加密与序列号。

小结

  • ring provider、ECDSA P-256 证书的一次 TLS 1.3 完整握手:两轮,客户端 236 + 80 字节,服务端 662 到 664 + 184 字节;共 7 条记录,再加 ping、pong 各一条。
  • 明文的只有 ClientHello、ServerHello 和两个假 CCS;服务端的 EncryptedExtensions、Certificate、CertificateVerify、Finished 合在一条 526 字节的记录里,用服务端握手密钥加密。
  • 四个流量密钥各保护什么:服务端握手密钥 → 第 4 条;客户端握手密钥 → 客户端 Finished;服务端应用密钥 → 票据和 pong;客户端应用密钥 → ping。每个密钥的序列号从 0 开始,pong 是 1。
  • 客户端只为 provider 的第一个组(或 kx_hint)发密钥共享;服务端不支持时多一轮 HRR,第二个 ClientHello 296 字节;下一次连接按 kx_hint 直接猜中,0.23.46 起这个提示在握手确认后才写入。
  • KeyLogFile 写出的 NSS 格式密钥文件加上 HKDF-Expand-Label,足以在没有 Wireshark 的情况下解开每一条记录;数据在实验目录的 mainline/ 里。

💬 关联讨论

发起讨论
加载中...