TLS 1.2 与其他
主线位置:握手主线的四条岔路。服务端在
ExpectClientHello里选了 1.2,就走另一串状态(TLS 1.2);客户端在发 ClientHello 之前可以把真正的 ClientHello 加密藏进外层(ECH);QUIC 下握手消息不进记录层,rustls 只交出握手字节和每一级的密钥;握手完成之后,还可以把流量密钥交出去,让内核加解密记录(kTLS)。
flowchart TB
ech["ECH 外层包内层"] --> ch["ClientHello"]
ch --> ver{"服务端选版本"}
ver -->|1.3| main["握手主线"]
ver -->|1.2| t12["tls12 状态链"]
main --> q{"QUIC"}
q -->|是| hs["write_hs 交出密钥"]
q -->|否| rec["记录层加密"]
rec --> k["kTLS 交出流量密钥"]
四节对应的代码:TLS 1.2 在 server/tls12.rs(1004 行)、client/tls12.rs(1389 行)和 tls12/mod.rs(380 行);ECH 在 client/ech.rs(1365 行);QUIC 在 quic.rs(1056 行);kTLS 在 conn/kernel.rs(268 行)。实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46,release 构建,内存里握手;除 ECH 一节用 aws-lc-rs 外都是 ring provider。程序在 exp-rustls/hsb-lab/src/bin/ 下(tls12.rs、ech.rs、quic.rs、secrets.rs)。
TLS 1.2
tls12 feature
rustls/Cargo.toml 的默认 feature:
default = ["aws_lc_rs", "logging", "prefer-post-quantum", "std", "tls12"]
tls12 = [] 不引入任何依赖,它只是 #[cfg(feature = "tls12")] 的开关:lib.rs 里的 mod tls12、两个 provider 的 TLS 1.2 套件、server/tls12.rs 和 client/tls12.rs 两串状态、ServerConfig::require_ems / ClientConfig::require_ems 字段都挂在它下面。关掉它,DEFAULT_VERSIONS 里就只剩 TLS 1.3。
为什么默认开着,源码里没有一句话的理由。能查到的是来历和后果:
f35a22f7「Make TLS 1.2 optional」(首次发布在 0.20.0)把 TLS 1.2 的代码挪到 #[cfg(feature = "tls12")] 后面。在这之前 TLS 1.2 总是编进来的;这个提交在 Cargo.toml 里加 tls12 = [] 的同时,把默认 feature 从 ["logging"] 改成 ["logging", "tls12"],所以升级到 0.20 的用户什么都不用改,行为和以前一样。此后 TLS 1.2 一直留在默认里。
后果是只开 1.3 的客户端连不上只会 1.2 的服务器。用 rustls 自己演示(服务端 with_protocol_versions(&[&TLS12]),客户端只开 TLS13):
TLS 1.3-only client: server Err(PeerIncompatible(Tls12NotOfferedOrEnabled)); sent Alert 2; client Err(AlertReceived(ProtocolVersion))
lib.rs 的 feature 说明还提醒了另一件事:feature 是叠加的,依赖图里任何一个 crate 开了 tls12,你的程序里就有 TLS 1.2。要在自己的连接上禁用 1.2,不能指望关 feature,要在构建配置时只给 TLS13。
状态链和往返
TLS 1.2 握手两侧各是一串独立的状态。服务端从同一个 ExpectClientHello 出发(版本、证书、套件的选择和 1.3 共用,见服务端 TLS 1.3),然后进 tls12::CompleteClientHelloHandling 发 ServerHello、Certificate、ServerKeyExchange、ServerHelloDone,再依次是 ExpectCertificate(要客户端证书时)、ExpectClientKx、ExpectCertificateVerify(同上)、ExpectCcs、ExpectFinished、ExpectTraffic。客户端是 ExpectCertificate、ExpectCertificateStatusOrServerKx、ExpectServerKx、ExpectServerDoneOrCertReq、ExpectServerDone、ExpectNewTicket(服务端承诺发票据时)、ExpectCcs、ExpectFinished、ExpectTraffic。
两个 rustls 之间做一次 1.2 握手,再用同一个 ClientConfig 恢复一次:
TLS 1.2 full: TLSv1_2 TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 kind Full, 4 flights, 955 bytes, ServerHello extensions [0, 65281, 23]
flight 1 C->S 236 bytes: Handshake 231 [ClientHello]
flight 2 S->C 575 bytes: Handshake 570 [ServerHello/HRR, Certificate, ServerKeyExchange, ServerHelloDone]
flight 3 C->S 93 bytes: Handshake 37 [ClientKeyExchange] | ChangeCipherSpec 1 | Handshake 40 [?]
flight 4 S->C 51 bytes: ChangeCipherSpec 1 | Handshake 40 [?]
TLS 1.2 resumed: TLSv1_2 TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 kind Resumed, 3 flights, 428 bytes, ServerHello extensions [65281, 23]
flight 1 C->S 236 bytes: Handshake 231 [ClientHello]
flight 2 S->C 141 bytes: Handshake 85 [ServerHello/HRR] | ChangeCipherSpec 1 | Handshake 40 [?]
flight 3 C->S 51 bytes: ChangeCipherSpec 1 | Handshake 40 [?]
(Handshake 40 [?] 是 ChangeCipherSpec 之后加密的 Finished,记录类型仍是 22,实验程序解不开里面的消息头。)
- 完整握手四批,两个往返:客户端要等第二批(服务端的 Finished)之后才能发应用数据。TLS 1.3 是一个往返。
- 证书、ServerKeyExchange 都是明文,旁观者看得到服务器证书;TLS 1.3 里它们在加密记录里。
- ServerHello 的扩展
0、65281、23分别是 SNI 确认、renegotiation_info(rustls 不支持重协商,但回应安全重协商的信号)和扩展主密钥(extended master secret,下面讲)。 - 恢复走的是会话 ID:服务端默认没有
ticketer,不发 1.2 的票据。恢复是一个往返,服务端先发 Finished。
内存里的耗时,1.2 和 1.3 的完整握手几乎一样(各 300 次,客户端关掉会话存储):
time n=300: TLS 1.2 full min 217 us median 225 us; TLS 1.3 full min 223 us median 230 us
差别在网络上:1.2 多一个往返。
当服务端同时支持 1.3、却和客户端协商成了 1.2,它会把 ServerHello random 的最后 8 字节改成 DOWNGRADE_SENTINEL(DOWNGRD\x01),支持 1.3 的客户端看到它就中止握手:说明有人在中间把 ClientHello 里的 1.3 删掉了(RFC 8446 §4.1.3)。
扩展主密钥
TLS 1.2 原来的主密钥只由密钥交换的结果和双方的 random 算出来,和握手里其他内容无关。三重握手攻击(3SHAKE)利用这一点,让两条不同的连接得到同一个主密钥。RFC 7627 的扩展主密钥把握手转录哈希也算进去。ConnectionSecrets::from_key_exchange:
let (label, seed) = match ems_seed {
Some(seed) => ("extended master secret", Seed::Ems(seed)),
None => (
"master secret",
Seed::Randoms(join_randoms(&ret.randoms.client, &ret.randoms.server)),
),
};
ems_seed 是截至 ClientKeyExchange 的转录哈希。rustls 客户端每个 ClientHello 都带 extended_master_secret 请求(client/hs.rs 里构造扩展时写死 Some(())),服务端看到请求就用、并在 ServerHello 里确认,所以两个 rustls 之间总是用扩展主密钥(上面实验里的扩展 23)。对方不支持时:
require_ems为真,客户端在收到没有确认的 ServerHello 时、服务端在收到没有请求的 ClientHello 时,都发handshake_failure,错误是ExtendedMasterSecretExtensionRequired。- 默认值是
provider.fips()。ring provider 和非 FIPS 的 aws-lc-rs 下为假(实验打印server require_ems false),也就是说对方不支持时仍然接受旧算法;FIPS 140-3 要求用扩展主密钥,所以 FIPS provider 下默认为真,ServerConfig::fips()也要求它为真。
恢复时也要一致:服务端的 can_resume 不允许把用了扩展主密钥的会话恢复成不用的;客户端发现服务端在恢复时改变了 EMS 状态,报 ResumptionOfferedWithVariedEms。manual/tlsvulns.rs 的 3SHAKE 一节写着:rustls 不支持重协商、也没有 RSA 密钥交换,攻击的两个前提都不成立,扩展主密钥是额外的一层。
ECH
加密 ClientHello(Encrypted Client Hello,ECH)要藏的是 SNI:TLS 1.3 把证书加密了,旁观者却还能从明文 ClientHello 里读到要访问的域名。ECH 让客户端发两层 ClientHello:外层是明文,SNI 写一个公共名字(public_name,通常是前端服务器的名字);真正的 ClientHello(内层)用服务器发布的 HPKE 公钥加密,放在外层的 encrypted_client_hello 扩展里。
rustls 0.23.46 只实现客户端(manual/features.rs:「Client-side Encrypted client hello (ECH)」)。服务端收到 ECH 扩展不会解密,只按外层处理。
调用方要给什么
impl ConfigBuilder<ClientConfig, WantsVersions> {
pub fn with_ech(
self,
mode: EchMode,
) -> Result<ConfigBuilder<ClientConfig, WantsVerifier>, Error> {
let mut res = self.with_protocol_versions(&[&TLS13][..])?;
res.state.client_ech_mode = Some(mode);
Ok(res)
}
}
with_ech 顺带把版本锁死在 TLS 1.3:ECH 是 1.3 的扩展,外层要是协商成 1.2,内层就白藏了。EchMode 有两种:
Enable(EchConfig):真的发 ECH。EchConfig::new要两样东西,都由调用方准备:EchConfigListBytes:服务器在 DNS 的 HTTPS 记录里发布的 ECH 配置列表。rustls 不做 DNS 查询,文档建议用 DNS-over-HTTPS 取、base64 解码后传进来。&[&'static dyn Hpke]:可用的 HPKE 实现。new逐个看列表里的配置,跳过不认识的版本(只认 draft-18,0xfe0d)和带未知强制扩展的,选第一个有匹配 HPKE 套件的配置;一个都没有就是NoCompatibleConfig。
Grease(EchGreaseConfig):没有真配置时发一个形状一样、内容是随机数的假扩展,防止中间设备把「带 ECH 扩展」当成异常(GREASE,RFC 8701 的思路)。
HPKE 实现只有 aws-lc-rs provider 自带:rustls::crypto::aws_lc_rs::hpke::ALL_SUPPORTED_SUITES。ring provider 没有 Hpke 的实现,只用 ring 的程序(本站的 sqlx、lettre 就是)要用 ECH 得自己提供一个。
两层 ClientHello
EchState::ech_hello 拿普通流程构造好的外层 ClientHello 当模板:
encode_inner_hello造内层:自己的 random、真正的 SNI;外层里只对 1.2 有用的扩展(扩展主密钥、session_ticket、点格式)不进内层;和外层相同的扩展用ech_outer_extensions引用,不重复编码;最后按配置里的maximum_name_length和 32 字节对齐补零,让内层长度不泄露名字长度。- 外层不许恢复会话:外层里的 PSK 换成随机的 GREASE PSK。
- 先往外层放一个负载全零、长度正确的 ECH 扩展,把这时的外层编码当作 AEAD 的附加数据,用 HPKE 加密内层,再把真负载填回去。和 PSK 绑定值一样,又是「消息的一部分依赖消息本身」。
服务端是否接受,要看 ServerHello 的 random:接受 ECH 的服务端会在 random 的最后 8 字节里放一个由内层转录推出的确认值(confirm_acceptance)。对上了,后面的握手都按内层走;对不上,客户端按外层继续,用 public_name 校验服务器证书,握手结束时报错,不发应用数据。0.23.46 修了这里的两处:ca4a9c21「ech: hold the server to its hello retry request decision」(HelloRetryRequest 已经接受或拒绝 ECH 之后,ServerHello 不能再改主意,RFC 9849 §6.1.4);4de14a50「client: fail on ECH rejection before encrypting application data」。
实验:rustls 服务端,证书同时对 secret.test 和 public.test 有效;客户端连 secret.test,分别不用 ECH、用 GREASE、用一个手工编码的 ECH 配置(X25519 + HKDF-SHA256 + AES-128-GCM,public_name 是 public.test)。量的是还没存过票据时的第一个 ClientHello:
no ECH: ClientHello flight 1468 bytes; ech_status NotOffered; server saw SNI Some("secret.test"); result Ok(4 flights)
GREASE ECH: ClientHello flight 1874 bytes; ech_status Grease; server saw SNI Some("secret.test"); result Ok(4 flights)
real ECH offer: ClientHello flight 1874 bytes; ech_status Rejected; server saw SNI Some("public.test"); result Err(PeerIncompatible(ServerRejectedEncryptedClientHello(None))) "peer is incompatible: ServerRejectedEncryptedClientHello(None)"
- aws-lc-rs 默认带
X25519MLKEM768的密钥共享(1258 字节的key_share扩展),所以基线就有 1468 字节。 - ECH 扩展(类型
0xfe0d)410 字节,GREASE 和真 ECH 一样大:GREASE 的负载长度是照着同一个外层造一个内层量出来的,旁观者从长度上分不出真假。真假两次的总长度都是 1874 字节。和基线相比,除了多出这个扩展,with_ech只开 1.3,supported_versions变短,给 1.2 票据用的session_ticket扩展也没了。 - 真 ECH 那次,服务端只看到外层的
public.test。rustls 服务端不懂 ECH,握手按外层完成,客户端在最后报ServerRejectedEncryptedClientHello(None):None表示服务端没给新的 ECH 配置(retry configs)。
QUIC
QUIC 自己有分包、重传和加密,它要的只是 TLS 1.3 的握手消息和每个阶段的密钥。rustls::quic 提供另一组连接类型:
quic::ServerConnection::new检查配置支持 TLS 1.3、至少有一个能用于 QUIC 的套件(套件上的quic字段),max_early_data_size只能是 0 或0xffff_ffff(RFC 9001 的规定)。传输参数作为额外的扩展发出去。- 没有
read_tls/write_tls。read_hs收的是 QUIC CRYPTO 帧里的握手字节,write_hs交出要发的握手字节,都不带记录头。 write_hs返回KeyChange:Handshake { keys }或OneRtt { keys, next }。keys里每个方向是一把包加密密钥(PacketKey)和一把包头保护密钥(HeaderProtectionKey),next是之后做密钥更新用的Secrets。Initial 级的密钥和 TLS 无关,由 QUIC 用目标连接 ID 和版本自带的盐算出来(Keys::initial)。
write_hs 的规则(Quic::write_hs):把排队的握手消息写进缓冲,遇到下一条要用新密钥发的消息就停下,返回 KeyChange;这次写出的字节属于旧的级别,之后的字节用新密钥。实验让两边在内存里来回调 read_hs / write_hs,每次只送一块,打印每块属于哪一级、里面的握手消息类型和长度:
C->S 205 bytes @Initial: type1(205)
S->C 90 bytes @Initial: type2(90)
S->C KeyChange::Handshake
S->C 534 bytes @Handshake: type8(36) type11(367) type15(79) type20(52)
S->C KeyChange::OneRtt
C->S KeyChange::Handshake
C->S 52 bytes @Handshake: type20(52)
C->S KeyChange::OneRtt
S->C 162 bytes @1-RTT: type4(81) type4(81)
client handshaking false, alpn Some("h3"), server got params Some("client-params"), client got params Some("server-params")
max_early_data_size 16384: Err(General("QUIC sessions must set a max early data of 0 or 2^32-1"))
- 第一个字节就是握手类型(1 = ClientHello),没有 5 字节记录头,没有假的 ChangeCipherSpec(
emit_fake_ccs遇到 QUIC 直接返回)。 - ClientHello 205 字节,比 TLS 下的 231 字节小:QUIC 下
legacy_session_id为空(TLS 下是 32 字节的兼容模式 ID),多出来的是 ALPN 和传输参数扩展。 - 服务端写完 ServerHello 就拿到握手密钥,接着在 Handshake 级写 EncryptedExtensions(8)、Certificate(11)、CertificateVerify(15)、Finished(20),然后拿到 1-RTT 密钥。客户端读到 ServerHello 才有握手密钥,所以它的
KeyChange::Handshake出现在收到第一块之后。 - 票据(类型 4)在 1-RTT 级发出。
- 两边都拿到了对方的传输参数。
握手完成后,服务端进 ExpectQuicTraffic,它拒收一切消息;QUIC 下的 KeyUpdate 也是错误(KeyUpdateReceivedInQuicConnection),密钥更新由 QUIC 自己用 Secrets::next_packet_keys 做。ALPN 在 QUIC 下是强制的:双方配了或提了 ALPN 却选不出来,服务端发 no_application_protocol(ExtensionProcessing::process_common 里的 QUIC 分支)。
kTLS
Linux 内核可以替用户态加解密 TLS 记录(kTLS),配合 sendfile 省掉拷贝。rustls 不碰内核,它只负责把握手完成时的流量密钥交出去,并在之后帮着算更新后的密钥。conn/kernel.rs 的模块文档写得很清楚:
There are only two things that [
KernelConnection] is able to do:That's it. Everything else you will need to implement yourself.
- Compute new traffic secrets when a key update occurs.
- Save received session tickets sent by a server peer.
入口是 UnbufferedClientConnection / UnbufferedServerConnection 的 dangerous_into_kernel_connection,核心在 ConnectionCore::dangerous_into_kernel_connection:
if !self
.common_state
.enable_secret_extraction
{
return Err(Error::General("Secret extraction is disabled".into()));
}
if self.common_state.is_handshaking() {
return Err(Error::HandshakeNotComplete);
}
if !self
.common_state
.sendable_tls
.is_empty()
{
return Err(Error::General(
"cannot convert into an KernelConnection while there are still buffered TLS records to send"
.into()
));
}
let state = self.state?;
let record_layer = &self.common_state.record_layer;
let secrets = state.extract_secrets()?;
let secrets = ExtractedSecrets {
tx: (record_layer.write_seq(), secrets.tx),
rx: (record_layer.read_seq(), secrets.rx),
};
- 三个前提:配置里开了
enable_secret_extraction(默认关),握手完成,没有还没发出去的记录。 - 交出的是
ExtractedSecrets:两个方向各一个序列号加一个ConnectionTrafficSecrets(按套件是Aes128Gcm/Aes256Gcm/Chacha20Poly1305的 key 和 iv)。序列号必须一起交,内核要从这里接着算 nonce。 - 剩下的状态变成
KernelConnection:update_tx_secret/update_rx_secret在 KeyUpdate 时算出下一代密钥,序列号从 0 开始;客户端还有handle_new_session_ticket,因为票据在握手之后才到,这时记录已经由内核解密,调用方要自己切出消息体交给它。
普通的 ClientConnection / ServerConnection 只有 dangerous_extract_secrets,内部就是上面这个函数,丢掉 KernelConnection 只留密钥;Unbuffered* 上的同名方法已经标了 #[deprecated],理由是「does not support session tickets or key updates, use dangerous_into_kernel_connection() instead」。
实验(打印密钥长度,不打印密钥):
default config: Err(General("Secret extraction is disabled"))
before handshake: Err(HandshakeNotComplete)
buffered client: tx seq 0 Aes256Gcm key 32B iv 12B; rx seq 1 Aes256Gcm key 32B iv 12B
buffered server: tx seq 1 Aes256Gcm key 32B iv 12B; rx seq 0 Aes256Gcm key 32B iv 12B
client tx == server rx: true
unbuffered: client ready true, server ready true
kernel client: tx seq 0 Aes256Gcm key 32B iv 12B; rx seq 1 Aes256Gcm key 32B iv 12B
kernel server: tx seq 1 Aes256Gcm key 32B iv 12B; rx seq 0 Aes256Gcm key 32B iv 12B
kernel: version TLSv1_3, suite TLS13_AES_256_GCM_SHA384
after client update_tx_secret: seq 0, Aes256Gcm key 32B iv 12B, matches server update_rx_secret true, differs from old true
- 服务端的发送序列号是 1:两张票据已经用应用流量密钥发了一条记录(序列号 0),内核要从 1 开始。客户端的接收序列号同理。
- 客户端调
update_tx_secret得到的新密钥和服务端update_rx_secret的结果相同,和旧密钥不同;真正的 KeyUpdate 消息要调用方自己发。 - 本机是 macOS,没有 kTLS,实验只验证了 rustls 交出的东西;把它们交给 Linux 的
setsockopt(SOL_TLS, ...)不在 rustls 里。
模块文档还提醒:AES-GCM 这类套件有保密上限(confidentiality limit),平时 rustls 数着记录数、快到上限就主动更新密钥(见加密与序列号),交给内核之后没人数了,要调用方自己盯。
很长一段时间 rustls 只有 dangerous_extract_secrets:交出密钥,连接就结束了。之后对端发来的 KeyUpdate 和 NewSessionTicket,用户态没有任何办法处理。5a121715「Add kernel connection API」(首次发布在 0.23.27)的说明是:「For kTLS we want to be able to interact with rustls in order to refresh traffic keys and save session tickets for future usage. The remaining parts of the TLS protocol are possible to implement externally provided that the user is willing to put in enough effort.」于是有了只提供三种能力的 KernelConnection,并把 Unbuffered* 上的 dangerous_extract_secrets 标为不推荐。0.23.46 的模块文档说前提之一是开 enable_extract_secrets,配置里实际的字段名是 enable_secret_extraction。
小结
tls12是默认 feature,0.20.0 把它做成可选时为了不改变行为放进了默认;要禁用 1.2,在配置里只给TLS13。1.2 完整握手实测两个往返、955 字节,CPU 和 1.3 相当。- rustls 客户端总是请求扩展主密钥,服务端看到就用;
require_ems只在 FIPS provider 下默认为真。 - ECH 只有客户端:调用方给 DNS 里取来的配置列表和 HPKE 实现(只有 aws-lc-rs 自带),
with_ech锁定 TLS 1.3;外层带public_name,内层加密;GREASE 与真 ECH 等长。 - QUIC 下没有记录层:
read_hs/write_hs只搬握手字节,KeyChange把每一级的包密钥和包头保护密钥交给 QUIC。 - kTLS:握手完成、没有待发记录、开了
enable_secret_extraction,才能拿到两个方向的序列号和密钥;KernelConnection之后只会算新密钥、存票据。