客户端 TLS 1.3
主线位置:握手主线的客户端一侧,从
ClientConnection::new排好 ClientHello,到收下服务器的 Finished、发出自己的 Finished、进入ExpectTraffic收会话票据。每一步推进的密钥在密钥日程,状态机本身怎么运转在状态机,服务器那一侧在服务端 TLS 1.3。
sequenceDiagram participant cl as 客户端状态 participant sv as 服务端 Note over cl: new 时已排好 ClientHello cl->>sv: ClientHello Note over cl: ExpectServerHelloOrHelloRetryRequest sv->>cl: ServerHello Note over cl: 算握手密钥 换解密器 sv->>cl: EncryptedExtensions Note over cl: ExpectEncryptedExtensions sv->>cl: Certificate Note over cl: ExpectCertificateOrCertReq sv->>cl: CertificateVerify Note over cl: ExpectCertificateVerify 验链和签名 sv->>cl: Finished Note over cl: ExpectFinished cl->>sv: CCS 和 Finished Note over cl: ExpectTraffic sv->>cl: NewSessionTicket x2
代码在 client/hs.rs(1205 行,ClientHello、ServerHello 和 HelloRetryRequest,TLS 1.2 和 1.3 共用)和 client/tls13.rs(1732 行,ServerHello 之后的 TLS 1.3 状态)。
实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46,ring 和 aws_lc_rs 两个 feature 都开,rustls 的默认 feature(含 prefer-post-quantum、tls12)保持打开;每个配置用 builder_with_provider 显式指定 provider;release 构建,握手全在内存里。服务端证书由 rcgen 现生成(ECDSA P-256,自签),所以服务端第一轮的字节数在不同运行之间差一两个字节(663 或 664)。
ClientHello 在 new 里就排好了
ClientConnection::new 走到 ConnectionCore::for_client:
let input = ClientHelloInput::new(name, &extra_exts, &mut cx, config)?;
let state = input.start_handshake(extra_exts, &mut cx)?;
Ok(Self::new(state, data, common_state))
ClientHelloInput::new 查会话缓存(有没有可恢复的票据)、生成 32 字节随机数和 legacy_session_id,start_handshake 生成密钥共享的私钥,再调 emit_client_hello_for_retry 编码 ClientHello、send_msg 放进待发送队列,返回第一个状态。所以构造完连接对象,wants_write() 已经是 true,第一次 write_tls 就能拿到 ClientHello;密钥生成的开销也算在 new 里。这个函数名里的 for_retry 指它同时负责 HelloRetryRequest 之后的第二个 ClientHello,参数 retryreq 第一次是 None。
逐个扩展
实验程序 hsa-lab/src/bin/clienthello.rs 构造客户端(SNI localhost、空根证书库、不加 ALPN),把第一次 write_tls 写出的字节按 RFC 8446 的格式逐字段拆开。ring provider:
== ring (alpn=false): 236 bytes on the wire
record: type 22 version 0301 len 231
handshake: type 1 len 227
legacy_session_id: 32 bytes
cipher_suites: 10 entries [TLS13_AES_256_GCM_SHA384, TLS13_AES_128_GCM_SHA256, TLS13_CHACHA20_POLY1305_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256]
extensions: 134 bytes
35 session_ticket 0B
45 psk_key_exchange_modes 2B
0 server_name 14B
11 ec_point_formats 2B
23 extended_master_secret 0B
43 supported_versions 5B ["0304", "0303"]
10 supported_groups 8B ["X25519", "secp256r1", "secp384r1"]
51 key_share 38B X25519:32B
13 signature_algorithms 20B 9 schemes ["ECDSA_NISTP384_SHA384", "ECDSA_NISTP256_SHA256", "ED25519", "RSA_PSS_SHA512", "RSA_PSS_SHA384", "RSA_PSS_SHA256", "RSA_PKCS1_SHA512", "RSA_PKCS1_SHA384", "RSA_PKCS1_SHA256"]
5 status_request 5B
先看扩展之外的字段:
- 记录版本
0301,握手里的legacy_version是0303。emit_client_hello_for_retry末尾特意为第一个 ClientHello 选TLSv1_0,注释引了 RFC 8446 §5.1「MAY also be 0x0301 for compatibility purposes」;HelloRetryRequest 之后的第二个 ClientHello 用0303。 legacy_session_id是 32 个随机字节。TLS 1.3 不用它,但 RFC 8446 附录 D.4 的「中间设备兼容模式」要求非空;ClientHelloInput::new只在 QUIC 或不支持 TLS 1.3 时把它置空。- 10 个套件:provider 的 9 个,加上末尾的
TLS_EMPTY_RENEGOTIATION_INFO_SCSV(0x00ff),只在提议 TLS 1.2 时加。rustls 用它代替renegotiation_info扩展,表示「我支持安全重协商」,实际上什么重协商都不做。
扩展逐个对应到 emit_client_hello_for_retry 里的代码:
| 扩展 | 内容 | 来源 |
|---|---|---|
server_name | localhost | config.enable_sni 且 ServerName 是 DNS 名;用 IP 连时不发 |
supported_versions | 1.3、1.2 | with_safe_default_protocol_versions;tls12 feature 关掉或配置只要 1.3 时只有 0304 |
supported_groups | provider 的 kx_groups,按顺序 | 过滤掉不适用于所提议版本的组 |
key_share | 一个或两个,见下节 | initial_key_share |
signature_algorithms | 校验器的 supported_verify_schemes() | 来自 WebPkiSupportedAlgorithms,见服务器证书 |
psk_key_exchange_modes | 只有 psk_dhe_ke | 注释:纯 PSK 模式「don't have forward secrecy」,所以不提议 |
status_request | OCSP | 每次都发,CertificateStatusRequest::build_ocsp() |
extended_master_secret | 空 | 每次都发,只对 TLS 1.2 有意义 |
ec_point_formats | 只有 uncompressed | provider 里有 ECDHE 组时发,也是 TLS 1.2 的遗留 |
session_ticket | 空(请求票据) | TLS 1.2 的票据,见 prepare_resumption:支持 TLS 1.2 且 tls12_resumption 是默认的 SessionIdOrTickets 时发 |
alpn、pre_shared_key、early_data、compress_certificate、certificate_authorities、cookie、ECH 都是按条件出现的:配置了 alpn_protocols 才有 ALPN(加 h2、http/1.1 两个协议后 ClientHello 是 254 字节,多 18 字节),有可恢复的 TLS 1.3 票据才有 PSK(见会话恢复与 0-RTT),开了 brotli / zlib feature 才提议证书压缩,HelloRetryRequest 带了 cookie 才回 cookie。
同一个程序第二次构造客户端,扩展的顺序变了,内容和总长度不变:
== ring (alpn=false): 236 bytes on the wire
...
10 supported_groups 8B ["X25519", "secp256r1", "secp384r1"]
11 ec_point_formats 2B
5 status_request 5B
51 key_share 38B X25519:32B
35 session_ticket 0B
45 psk_key_exchange_modes 2B
23 extended_master_secret 0B
13 signature_algorithms 20B 9 schemes [...]
43 supported_versions 5B ["0304", "0303"]
0 server_name 14B
order_insensitive_extensions_in_random_order 用每个连接一个的 16 位随机种子(ClientHelloDetails 里的 extension_order_seed)给扩展排序,只有 ECH 和 pre_shared_key 固定排在最后(RFC 要求 PSK 必须是最后一个扩展)。种子存在连接里,HelloRetryRequest 之后的第二个 ClientHello 用同一个顺序。
0.22 及以前,ClientHello 的扩展按代码里 push 的先后排列,每个 rustls 客户端发出的顺序都一样,成了识别「这是 rustls」的指纹之一。提交 c296594d(「Randomize ClientHello extensions」,首次发布在 0.23.0)引用了 Chrome 随机化 ClientHello 扩展的提案,提交说明写的是:「This commit sorts all the extensions that have been sent in the same order as before by using a seed that is saved at the start of the connection. And keeps the PSK extension in the end.」也就是上面的 order_seed:每个连接随机一次,同一个连接内保持不变。
发几个密钥共享
initial_key_share 只为一个组生成私钥:会话缓存里记着这个服务器上次用的组(kx_hint)就用它,否则取 provider 的 kx_groups 的第一个。ring 的默认组是 X25519、P-256、P-384,所以只发一个 32 字节的 X25519 公钥。
aws-lc-rs 的情况不同。rustls 的默认 feature 里有 prefer-post-quantum,它让 aws-lc-rs 的 DEFAULT_KX_GROUPS 把混合组 X25519MLKEM768 排到第一个。同一个程序换成 aws-lc-rs:
== aws-lc-rs (alpn=false): 1466 bytes on the wire
record: type 22 version 0301 len 1461
handshake: type 1 len 1457
legacy_session_id: 32 bytes
cipher_suites: 10 entries [...]
extensions: 1364 bytes
43 supported_versions 5B ["0304", "0303"]
35 session_ticket 0B
10 supported_groups 10B ["X25519MLKEM768", "X25519", "secp256r1", "secp384r1"]
51 key_share 1258B X25519MLKEM768:1216B X25519:32B
5 status_request 5B
0 server_name 14B
13 signature_algorithms 28B 13 schemes ["ECDSA_NISTP384_SHA384", "ECDSA_NISTP256_SHA256", "ECDSA_NISTP521_SHA512", "ED25519", "RSA_PSS_SHA512", "RSA_PSS_SHA384", "RSA_PSS_SHA256", "RSA_PKCS1_SHA512", "RSA_PKCS1_SHA384", "RSA_PKCS1_SHA256", "ML_DSA_44", "ML_DSA_65", "ML_DSA_87"]
23 extended_master_secret 0B
11 ec_point_formats 2B
45 psk_key_exchange_modes 2B
key_share 里有两项:1216 字节的 X25519MLKEM768(ML-KEM-768 的 1184 字节封装公钥加 X25519 的 32 字节)和一个 32 字节的 X25519。第二项是「顺带」的,代码在 emit_client_hello_for_retry 里:
if let Some((component_group, component_share)) =
key_share
.hybrid_component()
.filter(|(group, _)| {
config
.find_kx_group(*group, ProtocolVersion::TLSv1_3)
.is_some()
})
{
shares.push(KeyShareEntry::new(component_group, component_share));
}
混合组的私钥里本来就有一个 X25519 私钥,hybrid_component() 把它的公钥单独拿出来,只要 provider 也单独支持 X25519,就再放一项。服务器如果不支持后量子组、选了 X25519,客户端用同一个私钥完成(KeyExchangeChoice::Component),不必多一轮 HelloRetryRequest。两项加起来,ClientHello 从 236 字节涨到 1466 字节,多出来的 1230 字节里 1184 字节是 ML-KEM 公钥,剩下 46 字节是多一项密钥共享的 32 字节公钥和 4 字节项头、多出的四个签名算法(8 字节)和一个组名(2 字节)。签名算法多了 ECDSA_NISTP521_SHA512 和三个 ML-DSA,是 aws-lc-rs 的 WebPkiSupportedAlgorithms 比 ring 多支持的。两个 provider 的组和套件对比、prefer-post-quantum 关掉后的情况,见套件与密钥交换。
1466 字节比以太网上常见的 TCP 最大段长 1460 字节还多 6 字节,这样的 ClientHello 在 TCP 上通常要分两个段发。
ServerHello:选版本、套件,换上握手密钥
客户端的第一个状态是 ExpectServerHelloOrHelloRetryRequest,它只是分派器:ServerHello 交给内部的 ExpectServerHello,HelloRetryRequest 交给 handle_hello_retry_request。
ExpectServerHello::handle 做版本和套件的协商检查,每一项不过都有对应的告警:
- 版本:
legacy_version是 1.2 时看supported_versions扩展,得到 1.3 还是 1.2;配置不支持就发protocol_version。 - 压缩方法必须是 null,服务器回了没请求过的扩展发
unsupported_extension。 - 套件必须在自己提议的列表里(
offered_cipher_suites),且和版本匹配;HelloRetryRequest 之后还必须和 HelloRetryRequest 里的套件相同。 - 套件定下来,哈希算法也就定了:
transcript_buffer.start_hash(suite.hash_provider())把之前缓存的 ClientHello 字节灌进哈希,转为增量哈希,再加入 ServerHello。
然后转到 tls13::handle_server_hello:
validate_server_hello检查 ServerHello 只带key_share、pre_shared_key、supported_versions三种明文扩展,其他扩展(如 ALPN)在 TLS 1.3 里必须放进加密的 EncryptedExtensions。- 服务器的密钥共享组必须是我们发过的那个(或混合组的分量),否则
illegal_parameter。 - 没有选 PSK 时,丢掉早期数据的密钥日程,
KeySchedulePreHandshake::new(suite);用 ECDHE 共享秘密into_handshake,再用Hash(ClientHello..ServerHello)派生出两个握手流量密钥,装上解密器(服务器的握手密钥);没有早期数据时也装上加密器。 check_aligned_handshake():换密钥时,不允许缓冲区里还有半条握手消息,否则这条消息的前后两半会落在不同的保护下。emit_fake_ccs:发出那条兼容中间设备的 CCS,只在这个连接上发一次。实测它和客户端 Finished 一起出现在第二轮的 80 字节里(6 + 74)。
返回 ExpectEncryptedExtensions,它手里的密钥日程是 KeyScheduleHandshake。
EncryptedExtensions:分岔点
ExpectEncryptedExtensions::handle 处理第一条加密消息:
validate_encrypted_extensions:不能出现没请求过的扩展,也不能出现只该在 ServerHello 里的扩展或 TLS 1.3 禁用的扩展(ec_point_formats、session_ticket、renegotiation_info、extended_master_secret),违反就unsupported_extension。- ALPN 在这里定:
process_alpn_protocol,check_selected_alpn打开(默认)时服务器选了没提议的协议就illegal_parameter。 - QUIC 连接必须有传输参数扩展。
- ECH:服务器拒绝时把它给的
retry_configs记下来,到握手末尾才报错(见 TLS 1.2 与其他)。
然后按「是不是会话恢复」分岔。恢复时服务器不发证书,状态直接跳到 ExpectFinished,对端证书用缓存里的那条链,两个校验凭据用 assertion() 直接造出来,代码注释写明:「We don't reverify the certificate chain here: resumption is a continuation of the previous session in terms of security policy.」完整握手则进入 ExpectCertificateOrCertReq(或提议了证书压缩时的 ExpectCertificateOrCompressedCertificateOrCertReq),等 Certificate,或者先来一个要求客户端证书的 CertificateRequest。
同一程序连续做两次握手、共用一个 ClientConfig,第二次就是恢复,客户端只经过三个状态:
== second handshake, same configs (resumption)
[hsa] Client: rustls::client::hs::ExpectServerHelloOrHelloRetryRequest + ServerHello -> rustls::client::tls13::ExpectEncryptedExtensions
[hsa] Client: rustls::client::tls13::ExpectEncryptedExtensions + EncryptedExtensions -> rustls::client::tls13::ExpectFinished
[hsa] Client: rustls::client::tls13::ExpectFinished + Finished -> rustls::client::tls13::ExpectTraffic
-- round 1: c->s 327 bytes, s->c 219 bytes
Certificate 和 CertificateVerify
服务器的证书链和对握手的签名是两条消息,客户端也就是两个状态,但真正的校验全在第二个里。
ExpectCertificate::handle 只做三件事:把消息加入转录哈希;检查 certificate_request_context 为空(只有客户端证书才用这个字段,非空就是 decode_error);把证书链和叶子证书的 OCSP 响应 into_owned() 收起来,交给 ExpectCertificateVerify。
ExpectCertificateVerify::handle 按顺序:
// 1. Verify the certificate chain.
let (end_entity, intermediates) = self
.server_cert
.cert_chain
.split_first()
.ok_or(Error::NoCertificatesPresented)?;
let now = self.config.current_time()?;
let cert_verified = self
.config
.verifier
.verify_server_cert(
end_entity,
intermediates,
&self.server_name,
&self.server_cert.ocsp_response,
now,
)
.map_err(|err| {
cx.common
.send_cert_verify_error_alert(err)
})?;
// 2. Verify their signature on the handshake.
let handshake_hash = self.transcript.current_hash();
let sig_verified = self
.config
.verifier
.verify_tls13_signature(
construct_server_verify_message(&handshake_hash).as_ref(),
end_entity,
cert_verify,
)
.map_err(|err| {
cx.common
.send_cert_verify_error_alert(err)
})?;
签名的输入是「64 个空格 + TLS 1.3, server CertificateVerify + 0 + 到 Certificate 为止的转录哈希」(construct_server_verify_message),所以算哈希必须在把 CertificateVerify 自己加进转录之前,代码里 add_message 确实在最后。链的校验推迟到这里,和签名校验连着做:只有链通过了,才用叶子证书的公钥去验签名,两个结果 ServerCertVerified、HandshakeSignatureValid 一起搬进 ExpectFinished。如果中间夹着 CertificateRequest,它在 Certificate 之前到达,由 ExpectCertificateRequest 处理后再进 ExpectCertificate。verify_server_cert 里面做了什么,是校验主线的内容,见服务器证书。
Finished:先验对方,再发自己的
ExpectFinished::handle 是客户端最长的一步:
- 用
key_schedule.sign_server_finish(&handshake_hash)算出期望的verify_data,和服务器发来的做常量时间比较(subtle::ConstantTimeEq),不等就decrypt_error。 - 把服务器 Finished 加入转录,得到
hash_after_handshake。有早期数据时这里补发 EndOfEarlyData,并把加密器从早期数据密钥换成握手密钥。 - 服务器要了客户端证书的话,发 Certificate(和 CertificateVerify),仍用握手密钥加密。
into_pre_finished_client_traffic:用hash_after_handshake派生两个应用流量密钥和导出密钥,同时用客户端握手密钥算出自己的 Finished;发出 Finished。set_kx_hint:把这次服务器选的组记进会话缓存;remove_tls12_session:确认对方支持 1.3 之后,删掉这个服务器的 TLS 1.2 会话,避免以后降级恢复。into_traffic:现在才把应用流量密钥装进记录层,同时派生恢复主密钥(KeyScheduleResumption)。- ECH 被拒绝时在这里报错,然后
start_traffic把握手期间缓冲的明文发出去,返回ExpectTraffic。
第 4 步和第 6 步分开,是因为客户端的 Finished 必须用握手密钥加密,而应用密钥的推导又只依赖到服务器 Finished 为止的转录,所以中间有一个「算好了但还没装上」的 KeyScheduleClientBeforeFinished,见密钥日程。
ExpectTraffic 接受三种消息:应用数据、NewSessionTicket、KeyUpdate。实测服务器在第二轮发了两张票(184 字节),客户端每收一张就用 KeyScheduleResumption::derive_ticket_psk 算出 PSK,存进会话缓存,状态不变。默认发两张票是服务端的配置(send_tls13_tickets),见会话恢复与 0-RTT。
HelloRetryRequest
服务器不支持客户端猜的组时,回一个 HelloRetryRequest 指定组。实验让服务端 provider 只保留 secp256r1,客户端用默认的 ring provider(先猜 X25519):
== HelloRetryRequest (server only secp256r1), ring
[hsa] Client: rustls::client::hs::ExpectServerHelloOrHelloRetryRequest + HelloRetryRequest -> rustls::client::hs::ExpectServerHello
-- round 1: c->s 236 bytes, s->c 99 bytes
[hsa] Client: rustls::client::hs::ExpectServerHello + ServerHello -> rustls::client::tls13::ExpectEncryptedExtensions
...
-- round 2: c->s 275 bytes, s->c 689 bytes
-- round 3: c->s 74 bytes, s->c 184 bytes
-- done: Some(TLSv1_3) Some(TLS13_AES_256_GCM_SHA384) kx Some(secp256r1) kind Some(FullWithHelloRetryRequest)
多了一整轮。第二轮客户端的 275 字节是 6 字节的 CCS(HelloRetryRequest 之后先发假 CCS)加 269 字节的新 ClientHello:密钥共享从 32 字节的 X25519 换成 65 字节的未压缩 P-256 点,正好多 33 字节。第三轮只剩 74 字节的 Finished,CCS 已经发过。
handle_hello_retry_request 拒绝几类不合理的重试,全部是 illegal_parameter:没有 cookie 却要求一个已经发过的组、cookie 为空、什么都不要求改、legacy_session_id 没有原样回显、版本不是 1.3、套件没提议过、要求的组 provider 不支持。通过后,HelloRetryRequest 选定的套件就是最终套件(ExpectServerHello 会检查 ServerHello 和它一致),转录哈希变成「ClientHello1 的哈希 + HelloRetryRequest」(into_hrr_buffer,见密钥日程),早期数据作废,再调一次 emit_client_hello_for_retry,这次 retryreq 是 Some,返回的状态是只认 ServerHello 的 ExpectServerHello。
ClientConfig 再连一次这个只要 secp256r1 的服务器,还会收到 HelloRetryRequest 吗?换一个新的会话缓存呢?提示:ExpectFinished 第 5 步。答案在文末。
动脑筋答案
同一个配置再连不会;换新的会话缓存又会。实验程序接着上面的握手跑了两次:
== again, same configs after HRR
[hsa] Client: rustls::client::hs::ExpectServerHelloOrHelloRetryRequest + ServerHello -> rustls::client::tls13::ExpectEncryptedExtensions
[hsa] Client: rustls::client::tls13::ExpectEncryptedExtensions + EncryptedExtensions -> rustls::client::tls13::ExpectFinished
[hsa] Client: rustls::client::tls13::ExpectFinished + Finished -> rustls::client::tls13::ExpectTraffic
-- round 1: c->s 360 bytes, s->c 252 bytes
-- round 2: c->s 80 bytes, s->c 184 bytes
-- done: Some(TLSv1_3) Some(TLS13_AES_256_GCM_SHA384) kx Some(secp256r1) kind Some(Resumed)
== fresh session store, same server
[hsa] Client: rustls::client::hs::ExpectServerHelloOrHelloRetryRequest + HelloRetryRequest -> rustls::client::hs::ExpectServerHello
-- round 1: c->s 236 bytes, s->c 99 bytes
...
-- done: Some(TLSv1_3) Some(TLS13_AES_256_GCM_SHA384) kx Some(secp256r1) kind Some(FullWithHelloRetryRequest)
- 第一次握手结束时,
ExpectFinished调了set_kx_hint(server_name, secp256r1)。第二次initial_key_share先查kx_hint,直接生成 P-256 的密钥共享,服务器接受,ServerHello 一次到位。这次同时也是会话恢复(缓存里有上次的票),所以 ClientHello 是 360 字节,比 HelloRetryRequest 之后的 269 字节多出了 PSK 扩展。 - 提示存在
ClientSessionStore里(set_kx_hint是这个 trait 的方法),和票据放在一起。新配置换成Resumption::in_memory_sessions(256),提示没了,又从 provider 的第一个组 X25519 猜起,HelloRetryRequest 重现。进程重启后内存缓存清空,效果相同。
小结
- ClientHello 在
ClientConnection::new里就生成并排队;第一个 ClientHello 的记录版本是0301,legacy_session_id是 32 个随机字节,套件列表末尾有TLS_EMPTY_RENEGOTIATION_INFO_SCSV。 - 默认配置下 ring 的 ClientHello 236 字节、一个 X25519 共享;aws-lc-rs(默认开
prefer-post-quantum)1466 字节,X25519MLKEM7681216 字节加一个顺带的 X25519 32 字节。 - 扩展顺序每个连接随机一次(0.23.0 起),只有 ECH 和 PSK 固定在最后。
- 完整握手客户端停留六个状态:
ExpectServerHelloOrHelloRetryRequest→ExpectEncryptedExtensions→ExpectCertificateOrCertReq→ExpectCertificateVerify→ExpectFinished→ExpectTraffic;会话恢复从 EncryptedExtensions 直接跳到 Finished。 - 证书链和签名都在
ExpectCertificateVerify里校验,签名覆盖的转录到 Certificate 为止;服务器 Finished 用常量时间比较。 - HelloRetryRequest 多一整轮;
ExpectFinished把服务器选的组记进会话缓存,同一缓存下次直接猜中。