一次握手
主线位置:握手主线和记录主线各完整走一遍。客户端、服务端在同一个进程里,用两块内存当网络;握手两轮,再发一条
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_id | 32 字节随机数(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_groups | X25519、secp256r1、secp384r1 |
supported_versions | TLS 1.3、TLS 1.2 |
signature_algorithms | 9 个签名方案 |
| 其他 | 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。它在同一次调用里依次排进发送缓冲:
emit_server_hello:明文 ServerHello,只有key_share(X25519,32 字节)和supported_versions(0304)两个扩展,legacy_session_id_echo原样回显客户端那 32 字节。发完它,服务端就有了握手密钥。emit_fake_ccs:一个 1 字节的 ChangeCipherSpec。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 条记录,状态依次是:
ExpectServerHello→handle_server_hello:算出共享秘密和握手流量密钥,把自己的假 CCS(emit_fake_ccs,sent_tls13_fake_ccs保证只发一次)排进发送缓冲。ExpectEncryptedExtensions:没有恢复会话,客户端也没提供证书压缩,下一个是ExpectCertificateOrCertReq(卷目录写的ExpectCertificate之前还有这一个分派状态)。ExpectCertificate:只解析、存下证书链。ExpectCertificateVerify:这时才调校验器验证书链,再验 CertificateVerify 的签名(校验主线,见 04 证书校验)。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),应用数据从零开始。
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 字节。
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/里。