设计取舍
主线位置:三条主线的交叉处。握手主线上的状态链和密钥日程、记录主线两端的
read_tls/write_tls、校验主线交出去的 rustls-webpki,前面各页讲的是它们「怎么做」。本页把这些选择放在一起问「换来了什么、放弃了什么」,机制只给一句话和链接,篇幅留给代价和数字。
flowchart TB goal["默认就安全"] --> sans["sans-IO"] goal --> typ["类型驱动"] goal --> old["不做旧协议"] goal --> prov["CryptoProvider"] goal --> wp["rustls-webpki"] goal --> nounsafe["forbid unsafe"] sans --> c1["循环归调用方"] typ --> c2["每步一次 Box"] old --> c3["连不上旧服务器"] prov --> c4["两个都开没默认"] prov --> aws["默认 aws-lc-rs"] aws --> c5["大 1.4 MB 慢 10 秒"] wp --> c6["feature 两头转发"] nounsafe --> c7["unsafe 在后端"]
README 和 lib.rs 开头那句定位是本页的总纲:「aims to provide a good level of cryptographic security, requires no configuration to achieve that security, and provides no unsafe features or obsolete cryptography by default」。它在代码里落成什么,见 rustls 解决什么问题;下面七节逐个看它的代价。每节分「做法」「换来的」「放弃的」三段。
实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46,release 构建,uptime 负载 4 到 7(同机有别的写手在跑)。本页自己的实验在 exp-rustls/design-size:一个和 rustls 解决什么问题 里同样的最小程序(内存里握手一次,证书用 include_bytes! 嵌入),用 crate feature 切换 provider 和 tls12,另有统计分配次数的 allocs 和连旧服务器的 oldpeer 两个程序。其他数字来自本卷各页,出处随文给出。
sans-IO:字节由调用方搬
做法。 ClientConnection / ServerConnection 不碰套接字,调用方用 read_tls 喂进密文、process_new_packets 推动状态、write_tls 取走密文。驱动循环怎么写见 sans-IO。
换来的。
- 一个核心,多种外壳。 阻塞 IO 的
Stream整个文件只有 306 行;不缓冲、可在no_std下用的ConnectionState接口 624 行;QUIC(quic.rs,1056 行)复用同一套握手状态机和密钥日程,只是不走 TLS 记录层;kTLS(conn/kernel.rs,268 行)在握手后把密钥交给内核(见 TLS 1.2 与其他)。tokio-rustls 在 crate 外面,也只是把同一组调用接到AsyncRead/AsyncWrite上。 - 握手可以放在内存里做。 本卷几乎所有实验都是两个连接对象在一个进程里互相
read_tls/write_tls:一次完整握手 ring 约 220 µs,而同样的握手走 127.0.0.1 上的 TCP 要 1 毫秒左右(rustls 解决什么问题)。逐条记录的抓取(一次握手)、2^24 条记录的序列号上限实验(加密与序列号)都靠这一点,不需要网络、不需要抓包工具。
放弃的。
- 循环写错不报错,只是停住。 漏掉
write_tls,两边都在等对方,没有超时也没有错误;漏掉process_new_packets,握手卡住,握手后缓冲涨到 18437 字节才报message buffer full(sans-IO)。阻塞套接字库里由库负责的事,在这里变成调用方的责任。 - 缓冲在 rustls 里。 密文由
read_tls拷进 rustls 自己的缓冲,解密在原地做,明文再拷进received_plaintext、读出时再拷一次(去帧与缓冲)。rustls 不知道调用方的缓冲长什么样,只能先把字节收进自己手里;不想要内部缓冲的调用方得换用上面那个 624 行的无缓冲接口,自己管理缓冲区。
hyper 也把 IO 抽成自己的 rt::Read / rt::Write,但仍然自己发起读写(hyper 卷的 IO trait);rustls 更彻底,连「什么时候读」都交给调用方。
类型驱动的状态机与密钥日程
做法。 握手是一串 Box<dyn State<Data>>,handle 按值拿走旧状态、返回新状态(状态机);TLS 1.3 的密钥日程是一串 KeySchedule* 类型,每一步消耗上一步(密钥日程)。
换来的。 顺序错误变成编译错误。密钥日程在 crate 内部故意写了三处错:跳过 ECDHE、同一个握手秘密派生两次、在应用阶段算服务器 Finished,分别得到 E0599、E0382、E0599。状态里那些从不读的字段(ExpectTraffic 的 _cert_verified、_sig_verified、_fin_verified)只能由校验器构造,能走到这一步本身就证明校验做过(状态机)。
放弃的。 三样东西,都能量化。
每次转移一次堆分配。 用一个计数的全局分配器(allocs)数一次完整握手(ring,关掉会话恢复,配置提前建好,不计在内)里两侧各分配多少次,跑三遍:
run 0: client 81 allocs 19203 bytes; server 101 allocs 17233 bytes
run 1: client 81 allocs 19203 bytes; server 100 allocs 17169 bytes
run 2: client 81 allocs 19205 bytes; server 100 allocs 17175 bytes
Box::new([u8; 256]) + drop: 13.8 ns (min of 5 x 1000000)
按状态机实测的状态序列,客户端装箱 7 次(6 个状态,加上分派时临时出现的 ExpectCertificate),服务端 3 次,共 10 次,占一次握手约 180 次分配的 5% 左右。一次 256 字节的 Box 分配加释放约 14 ns,10 次约 0.14 µs,而整个内存握手约 220 µs。
代码不会因为类型多而变少。 用链接器的 map 文件(-Wl,-map)按符号归属统计 ring + tls12 的最小程序(工具 design-size/tools/attr.py,Rust 符号用 llvm-cxxfilt 还原后按路径归到 crate 和模块,C 符号按目标文件归属),rustls 自己约占 617 KB,其中两侧状态机(client::tls13、client::tls12、client::hs、server::tls13、server::tls12、server::hs)约 154 KB,tls13::key_schedule 约 26 KB,最大的单个模块是消息编解码 msgs::handshake,约 102 KB。没有一个「用枚举写」的版本可比,所以这里只能说类型链在二进制里是多大,不能说它比别的写法大多少。
类型管不到值。 传进 derive_client_handshake_secrets 的 hs_hash 只是一段字节,传成别的时刻的转录哈希编译器不会发现,只会在对端验 Finished 时失败(密钥日程)。这一类错误仍要靠测试向量;迷你密钥日程的实验里,把 derived 的上下文写错,类型照样对,四组向量里两组挂掉。
不实现旧协议
做法。 只有 TLS 1.2 和 1.3 两个版本,九个套件全是 ECDHE 加 AEAD,重协商请求只回一个警告(逐条核实见 rustls 解决什么问题)。ALL_VERSIONS 里根本没有更老的版本可选。
换来的。 没有的代码不用审计,也没有更老的版本可以被协商到。连 TLS 1.2 本身都是一个可以关掉的 feature:同一个最小程序,只去掉 tls12,剥离后的大小(字节):
| provider | 开 tls12 | 不开 | 差 |
|---|---|---|---|
| ring | 1643168 | 1556272 | 86896 |
| aws-lc-rs | 3070176 | 2982912 | 87264 |
两个 provider 下都是约 87 KB。拿两个 ring 程序的 map 相减(未剥离,共差 99 KB):client::tls12 和 server::tls12 两个模块合计约 51 KB,rustls 目标文件里的常量和字面量约 23 KB,消息编解码约 9 KB,TLS 1.2 的 ConnectionSecrets(PRF 在这里用)约 5 KB,ring provider 里 TLS 1.2 的 AEAD 胶水约 6 KB;也有几个模块反而变小(tls13::key_schedule 少 8 KB),是内联决定跟着变了。只有 TLS 1.3 时服务端完整握手只有三个状态(服务端 TLS 1.3),TLS 1.2 的状态链见 TLS 1.2 与其他。
放弃的:连不上老服务器。 oldpeer 用默认版本(1.3 和 1.2)的 rustls 客户端,去连本机 Homebrew OpenSSL 4.1.0 的 s_server,四种配置:
== s_server -tls1_2
ok: Some(TLSv1_2) Some(TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384), read 64 bytes
== s_server -tls1_2 -cipher ECDHE-ECDSA-AES128-SHA256
error: received fatal alert: HandshakeFailure
error:0A0000C1:SSL routines:tls_post_process_client_hello:no shared cipher:ssl/statem/statem_srvr.c:2726:
== s_server -tls1_1 -cipher ALL:@SECLEVEL=0
error: received fatal alert: ProtocolVersion
error:0A000102:SSL routines:tls_early_post_process_client_hello:unsupported protocol:ssl/statem/statem_srvr.c:2121:
== s_server -tls1 -cipher ALL:@SECLEVEL=0
error: received fatal alert: ProtocolVersion
error:0A000102:SSL routines:tls_early_post_process_client_hello:unsupported protocol:ssl/statem/statem_srvr.c:2121:
每组第二行是 rustls 客户端打印的错误,第三行是 s_server 日志里的原因。只开 CBC 套件的 TLS 1.2 服务器、只开 TLS 1.1 或 1.0 的服务器,都在服务端处理 ClientHello 时失败。rustls 这一侧只看到对方发来的告警(HandshakeFailure、ProtocolVersion),看不到「没有共同的套件」「版本不支持」这样的原因:判断是服务端做的,客户端只收到一个告警码。
可替换的密码学后端
做法。 握手和记录层不直接调用任何密码学库,只读 Arc<CryptoProvider> 的五个字段;内置 ring 和 aws-lc-rs 两个实现,也可以自己拼(CryptoProvider)。
换来的。 同一份协议代码可以换后端:本卷在 ring 和 aws-lc-rs 上跑同一个握手,ring 约 222 µs,aws-lc-rs 只用 X25519 时约 211 µs(rustls 解决什么问题),16 KiB 记录的 AES-128-GCM 发送 5.5 GB/s 对 6.7 GB/s(发送路径)。要 FIPS 就换成 fips 构建,要硬件密钥就只换 key_provider 一个字段。
放弃的:「两个都开」时没有默认值。 不传 provider 的入口(ClientConfig::builder() 等)要靠 crate feature 推断一个,from_crate_features 只在恰好开了一个内置 provider 时给出答案。Cargo feature 在整个依赖图里取并集,「两个都开」往往是两个依赖各开一个凑出来的;默认 feature 里的 prefer-post-quantum 还会顺带打开 aws-lc-rs。结果是运行时 panic,而不是编译错误,七种 feature 组合里四种 panic(实测表和 panic 原文见 CryptoProvider)。rustls 的选择是:宁可在构建配置时 panic,也不替应用猜一个后端。修法是在 main 开头 install_default,或者处处用 builder_with_provider。本站的依赖组合下为什么没有 panic,见在本站中。
custom-provider 这个 feature 一行代码都没有它是 522baf09(「Crypto: add feature to disable installation of built-in provider」,首次发布在 0.23.13)加的。之前,用自定义 provider 的应用只要依赖图里恰好开了一个内置 provider,又忘了(或晚了)install_default,第一个 builder() 就会悄悄装上那个内置的,和应用以为在用的不是同一个。这个提交在 Cargo.toml 里加了空的 custom-provider = [],在 from_crate_features 的两个 cfg 里各加一个 not(feature = "custom-provider"),文档写的是「This will disable any implicit use of a built-in provider」。也就是说,它不提供任何功能,只是把「推断成功」变回「推断失败」:漏掉的 builder() 从静默用错,变成上面那个 panic。
证书校验放在独立的 crate
做法。 rustls 只定义 ServerCertVerifier 这样的 trait;默认实现 WebPkiServerVerifier 把路径构建、名字匹配交给另一个仓库的 rustls-webpki(服务器证书、路径构建)。
换来的。
- 各自发版。 rustls-webpki 0.103 线从
v/0.103.0(2025-02-24)到本卷锁定的v/0.103.16一共 17 个 tag。rustls 0.23.46 的工作区Cargo.toml写的是version = "0.103.14",即「不低于 0.103.14」:webpki 发一个修复版,应用cargo update就拿到,不用等 rustls 发版。 - 边界清楚。 webpki 有 10244 行,rustls 里接它的
webpki/目录 1863 行(含测试)。校验器是 trait 对象,换成平台校验器或自定义校验器时,rustls 的握手代码不用动(客户端证书与自定义校验)。 - webpki 的源码里没有一个
unsafe(tag 上全仓库搜不到这个词),和 rustls 一样。
放弃的:feature 要两头转发。 webpki 自己也要验签名,需要知道用哪个密码学后端,所以 rustls 的 feature 一个个往下传。rustls/Cargo.toml 里:
aws_lc_rs = ["dep:aws-lc-rs", "webpki/aws-lc-rs", "aws-lc-rs/aws-lc-sys", "aws-lc-rs/prebuilt-nasm"]
fips = ["aws_lc_rs", "aws-lc-rs?/fips", "webpki/aws-lc-rs-fips"]
ring = ["dep:ring", "webpki/ring"]
std = ["webpki/std", "pki-types/std", "once_cell/std"]
后端因此被选了两次:一次给 rustls 的 provider,一次给 webpki。而 webpki 实际用来验签名的算法表,又是 rustls 从 provider 的 signature_verification_algorithms 传进去的(CryptoProvider)。新加一个后端或一种模式(比如 fips),两个仓库都要改。
0.20 及以前,rustls 依赖的是上游的 webpki = { version = "0.22.0", features = ["alloc", "std"] }。提交 d3123912(「Move to rustls-webpki fork」,2023-01-13,经分支 feat-ip-address 合入,首次发布在 0.21.0-alpha.1,正式版 0.21.0)把这一行换成 webpki = { package = "rustls-webpki", version = "0.100.0-alpha.2", ... }。同一个提交在 README 的下一版说明里写了原因:「Support for connecting to peers named with IP addresses. This means rustls now depends on a fork of webpki - rustls-webpki - with a suitably extended API.」也就是说,要支持用 IP 地址连服务器,需要校验库提供新的 API;从那以后校验库由 rustls 组织自己维护,package = "rustls-webpki" 的重命名一直留在今天的工作区 Cargo.toml 里,代码里仍然写 webpki::。
默认 aws-lc-rs
做法。 0.23.46 的默认 feature 是 ["aws_lc_rs", "logging", "prefer-post-quantum", "std", "tls12"]。默认 provider 从 ring 换成 aws-lc-rs 的来历见 rustls 解决什么问题。
换来的。
- 后量子密钥交换。 默认第一位是
X25519MLKEM768,ring 没有它(套件与密钥交换)。代价已经量过:ClientHello 从 236 字节涨到 1466 字节,一次内存握手比 ring 多约 25 µs(rustls 解决什么问题)。 - FIPS 路径。
fipsfeature 只能经 aws-lc-rs 走(套件与密钥交换)。 - AES-GCM 更快。 16 KiB 记录上发送快约 21%(发送路径)。
放弃的:体积和构建。
体积:剥离后的最小程序 ring 1.64 MB,aws-lc-rs 3.07 MB(上一节的表)。map 文件的归属给出差在哪:
== out/ring-tls12.map
all symbols 1505355
rustls 616980 41.0%
ring (C/asm) 300335 20.0%
core 107174 7.1%
std 94809 6.3%
ring 81148 5.4%
...
== out/aws-tls12.map
all symbols 2914828
aws-lc (C) 1733111 59.5%
rustls 638306 21.9%
...
aws_lc_rs 34780 1.2%
...
-- aws-lc by object
bcm.o 884340
obj.o 73920
edwards25519_scalarmulbase.o 60744
curve25519_x25519base_byte.o 60444
...
rustls 自己在两个程序里差不多大(617 KB 对 638 KB,多出来的是 crypto::aws_lc_rs)。差别几乎全在 C 代码:ring 的 C 和汇编约 0.30 MB,aws-lc 约 1.73 MB,其中一半是 bcm.o(AWS-LC 把核心密码学编成一个大目标文件)。bcm.c 本身只是 118 行 #include,把 fipsmodule/ 下的源文件拼成一个编译单元。rustls 用不到的算法也没有全被丢掉:DSA、RIPEMD-160、MD4、brainpool 曲线的目标文件各留下 2 到 7 KB 存活的符号(DES 的两个目标文件被整个剥掉了)。
构建:分别在空的目标目录里做一次 release 构建(cargo build --timings),最慢的几个单元:
clean-ring
rustls 3.22s
ring build-script (run) 2.91s
ring 0.90s
...
clean-aws
aws-lc-sys build-script (run) 12.78s
rustls 3.41s
aws-lc-rs 0.82s
...
整个构建 ring 8.4 s,aws-lc-rs 19.6 s(12 核,各一次)。差的 10 秒几乎全是 aws-lc-sys 的构建脚本在编 C。
构建依赖:本机只要 C 编译器,不要 cmake。 lib.rs 的 feature 说明写着「Note that aws-lc-rs has additional build-time dependencies like cmake」(2023 年 aaf21d1c 引入 aws-lc-rs 时写的)。读 aws-lc-sys 0.47.0 的 builder/main.rs:get_builder 先找系统里装好的 AWS-LC,没有就在 CcBuilder(只用 cc crate 调 C 编译器)和 CmakeBuilder 之间选;只有 FIPS 构建、AWS_LC_SYS_NO_ASM、开了 sanitizer、显式要求 cmake、或者需要现场跑 bindgen 时才用 cmake。默认 feature 下的非 FIPS 构建总有预生成的绑定可用(没有目标专用的就用 universal),不需要 bindgen。本机的构建输出是 Building with: CC。把 cmake 从 PATH 里拿掉(PATH=~/.cargo/bin:/usr/bin:/bin:...)再从头构建一次(-vv),照样成功、照样握手(前两行来自构建,最后一行是随后运行程序的输出):
cmake: not on PATH
warning: aws-lc-sys@0.47.0: Building with: CC
...
TLS13_AES_256_GCM_SHA384 X25519
所以「需要 cmake」对 aws-lc-sys 0.47.0 的默认路径已经不成立,对 fips 仍然成立(is_fips_build() 直接返回 CmakeBuilder)。Windows x86_64 上还要汇编器 NASM,rustls 的 aws_lc_rs feature 为此打开了 aws-lc-rs/prebuilt-nasm。
C 编译器则两边都要:ring 也要编自己的 C 和汇编。用 CC=/nonexistent/cc 各构建一次,两个都在构建脚本里失败,最后一行一样:
error: failed to run custom build command for `ring v0.17.14`
...
error occurred in cc-rs: failed to find tool "/nonexistent/cc": No such file or directory (os error 2)
warning: aws-lc-sys@0.47.0: Building with: CC
...
error: failed to run custom build command for `aws-lc-sys v0.47.0`
...
error occurred in cc-rs: failed to find tool "/nonexistent/cc": No such file or directory (os error 2)
找不到编译器时 aws-lc-sys 不会退回 cmake:CcBuilder::check_dependencies 只检查动态库、OpenHarmony 和 ssl feature,不检查编译器是否存在。
ring、std、tls12),把 tls12 去掉,剥离后的二进制会小多少字节?把 provider 换成 aws-lc-rs 再去掉 tls12 呢,差值会变大还是变小?提示:TLS 1.2 的代码在 rustls 里,不在后端里。答案在文末。forbid(unsafe_code)
做法。 lib.rs 第 332 行 #![forbid(unsafe_code, unused_must_use)],上一行的注释「Require docs for public APIs, deny unsafe code, etc.」从 2017 年的 72390eac(首次发布在 0.9.0)起就在。forbid 比 deny 更强:crate 里任何地方都不能再用 #[allow(unsafe_code)] 放开。
换来的。 rustls 自己的 46310 行里没有 unsafe 块;在 tag 上搜 unsafe 这个词,rustls/src 只有五处,全在注释和文档里(「Container for unsafe APIs」之类)。rustls-webpki 一处都没有;rustls-pki-types 有一处,server_name.rs 里把 [u16; 8] 转成 [u8; 16] 的 transmute。
放弃的:unsafe 没有消失,只是移到了后端。 同样的搜索(连注释一起算),ring 0.17.14 的 src/ 有 230 行含 unsafe 这个词,aws-lc-rs 1.19.0 有 381 行;不受 Rust 检查的源码量差得更远,aws-lc-sys 包里的 AWS-LC 源码(.c、.h、.S、.pl)约 145 万行,ring 包里约 19 万行。rustls 能 forbid,前提就是把常数时间运算、汇编加速这些必须碰内存的事交给 provider。所以 provider 的选择不只关乎速度和体积,也决定了进程里那部分不受 Rust 检查的代码有多大。
动脑筋答案
ring 小 86896 字节;aws-lc-rs 小 87264 字节,差值几乎一样(多 368 字节)。四个变体的剥离后大小:
ring-tls12 2029776 1643168 TLS13_AES_256_GCM_SHA384 X25519
ring 1914240 1556272 TLS13_AES_256_GCM_SHA384 X25519
aws-tls12 3541744 3070176 TLS13_AES_256_GCM_SHA384 X25519
aws 3427552 2982912 TLS13_AES_256_GCM_SHA384 X25519
每行依次是变体、未剥离大小、剥离后大小、协商结果(四个都是 TLS 1.3,tls12 只是编进来,没有用上)。tls12 这个 feature 只控制 rustls 自己的代码:client/tls12.rs、server/tls12.rs、tls12/mod.rs(TLS 1.2 的连接秘密和套件定义)、crypto/tls12.rs(PRF),以及两个 provider 目录下各自的 tls12.rs(把后端的 AEAD 接成 TLS 1.2 的记录加解密)。TLS 1.2 用的 AES-GCM、ECDHE、签名算法和 TLS 1.3 是同一批,后端里没有可以因此去掉的东西,所以换了 provider 差值也不变。
小结
- sans-IO:一个核心接出阻塞、异步、无缓冲、QUIC、kTLS 五种用法,握手能在内存里做(约 220 µs);代价是循环写错只会停住、不会报错,以及密文先拷进 rustls 自己的缓冲。
- 类型驱动:顺序错误是编译错误;一次握手约 10 次状态装箱,占约 180 次分配的 5%、约 0.14 µs;类型管不到传入的哈希值。
- 不做旧协议:连 TLS 1.2 都能关,省约 87 KB;代价是连不上只有 CBC 或只有 TLS 1.0 / 1.1 的服务器,客户端只看到
HandshakeFailure/ProtocolVersion告警。 - 可替换后端:同一份协议代码跑 ring 和 aws-lc-rs;代价是两个都开时没有默认值,
builder()运行时 panic,custom-provider(0.23.13)专门把推断变成失败。 - 独立的 rustls-webpki:0.103 线 17 个版本可以单独升级;代价是后端 feature 要转发给 webpki、后端被选两次。分支的起因是支持 IP 地址(0.21.0)。
- 默认 aws-lc-rs:后量子、FIPS、更快的 AES-GCM;代价是剥离后多 1.43 MB(其中
bcm.o0.88 MB)、构建多约 10 秒。aws-lc-sys 0.47.0 默认路径只要 C 编译器,lib.rs说的 cmake 只在 FIPS 等路径上需要;ring 同样要 C 编译器。 forbid(unsafe_code)从 0.9.0 起就在;unsafe和大量 C 代码在 provider 里,选 provider 也是在选信任边界。