服务器证书
主线位置:校验主线的入口和出口。客户端在
ExpectCertificateVerify里调配置的ServerCertVerifier:verify_server_cert进 rustls-webpki 做路径构建(中段在路径构建),回来比对名字;接着verify_tls13_signature验 CertificateVerify 的签名。两个结果都通过,握手才往ExpectFinished走。
sequenceDiagram participant st as ExpectCertificateVerify participant wv as WebPkiServerVerifier participant wp as rustls-webpki st->>wv: verify_server_cert wv->>wp: EndEntityCert try_from wv->>wp: verify_for_usage wp-->>wv: VerifiedPath 或 Error wv->>wp: verify_is_valid_for_subject_name wv-->>st: ServerCertVerified st->>wv: verify_tls13_signature wv->>wp: verify_signature wv-->>st: HandshakeSignatureValid
代码在 rustls 的 rustls/src/verify.rs(383 行,两个 trait 和标记类型)和 rustls/src/webpki/(1863 行),名字匹配在 rustls-webpki 0.103.16 的 src/subject_name/,ServerName 在 rustls-pki-types 1.15.1。
实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46(ring provider,只开 TLS 1.3),rustls-webpki 0.103.16,rcgen 0.14.10 现造证书(ECDSA P-256),握手全在内存里,release 构建。实验程序在 verify-lab/src/bin/errors.rs,输出原样存在 verify-lab/out-errors.txt。
ServerCertVerifier:校验器只回答四个问题
ServerCertVerifier 有四个必须实现的方法:
| 方法 | 谁调、什么时候 | 返回 |
|---|---|---|
verify_server_cert | 客户端 ExpectCertificateVerify,拿到 CertificateVerify 之后 | ServerCertVerified |
verify_tls12_signature | TLS 1.2 的 ServerKeyExchange | HandshakeSignatureValid |
verify_tls13_signature | 紧接在 verify_server_cert 之后 | HandshakeSignatureValid |
supported_verify_schemes | 生成 ClientHello 时 | 写进 signature_algorithms 扩展 |
另有两个带默认值的:requires_raw_public_keys(RFC 7250 裸公钥,默认 false)和 root_hint_subjects(默认 None,给了就作为 certificate_authorities 扩展放进 ClientHello;WebPkiServerVerifier 没有覆盖它,所以默认配置不发这个扩展)。
verify_server_cert 拿到的是原始 DER,trait 的文档特意说明「none of the certificates have been parsed yet」:解析也是校验器的事。intermediates 是服务器 Certificate 消息里除第一张以外的全部证书,按服务器发来的顺序,可能为空。now 由客户端配置的 TimeProvider 给出(current_time),默认是 UnixTime::now();嵌入式环境没有时钟时,可以用 ClientConfig::builder_with_details 换一个 TimeProvider,本页的时间实验就是这样把「现在」钉死的。
调用点在客户端,见客户端 TLS 1.3:先 verify_server_cert,再 verify_tls13_signature,任何一个返回 Err 都经 send_cert_verify_error_alert 发出致命告警。两个 Ok 里装的不是 (),而是两个零大小的类型:
// Marker types. These are used to bind the fact some verification
// (certificate chain or handshake signature) has taken place into
// protocol states. We use this to have the compiler check that there
// are no 'goto fail'-style elisions of important checks before we
// reach the traffic stage.
//
// These types are public, but cannot be directly constructed. This
// means their origins can be precisely determined by looking
// for their `assertion` constructors.
ServerCertVerified 和 HandshakeSignatureValid 被搬进 ExpectFinished,最后变成 ExpectTraffic 的 _cert_verified、_sig_verified 字段(再加 FinishedMessageVerified)。状态机里任何一条通向 ExpectTraffic 的路径都得拿出这两个值,漏掉一次校验就编译不过。状态机里直接造它们的只有会话恢复(TLS 1.3 和 TLS 1.2 各一处)。TLS 1.3 那处在 ExpectEncryptedExtensions 处理恢复成功的分支时,注释写着「We don't reverify the certificate chain here」,然后调 assertion(),理由是恢复延续上一次会话的安全判断,见会话恢复与 0-RTT。
注释说这两个类型「cannot be directly constructed」,但 assertion() 是 pub 的,任何实现 trait 的人都能调用。这道检查防的是 rustls 自己的状态机漏掉一步,挡不住一个什么都不查就返回 assertion() 的自定义校验器,那种校验器的后果见客户端证书与自定义校验。
()提交 cde13c45(2017-07-23,「Use 'marker types' to express verifications as types」,首次进入 rustls 0.10.0)之前,ServerCertVerifier::verify_server_cert 和 verify_signed_struct 返回 Result<(), TLSError>,状态机只能靠「没有报错」推断校验做过了。这次提交在 verify.rs 加了上面那段注释和四个类型(当时就是 pub fn assertion()),把签名改成返回 ServerCertVerified / HandshakeSignatureValid,并在 client/hs.rs 里把它们一路传进状态。今天 manual/implvulns.rs 用 Apple Secure Transport 的 goto fail(CVE-2014-1266)解释这个设计:一行 goto fail; 被重复了一次,「the remaining part of the function (including the actual signature validation) was unconditionally skipped」;rustls 则让「到达 traffic 状态」本身需要这些值,「it has zero runtime cost」。
WebPkiServerVerifier::verify_server_cert
默认校验器 WebPkiServerVerifier 有六个字段:根证书库、CRL 列表、三个吊销策略、签名算法表。verify_server_cert 本身很短:
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, Error> {
let cert = ParsedCertificate::try_from(end_entity)?;
let crl_refs = self.crls.iter().collect::<Vec<_>>();
let revocation = if self.crls.is_empty() {
None
} else {
// ...
};
// Note: we use the crate-internal `_impl` fn here in order to provide revocation
// checking information, if applicable.
verify_server_cert_signed_by_trust_anchor_impl(
&cert,
&self.roots,
intermediates,
revocation,
now,
self.supported.all,
)?;
if !ocsp_response.is_empty() {
trace!("Unvalidated OCSP response: {:?}", ocsp_response.to_vec());
}
verify_server_name(&cert, server_name)?;
Ok(ServerCertVerified::assertion())
}
四步:
ParsedCertificate::try_from把叶子交给webpki::EndEntityCert::try_from解析。DER 格式错误映射成BadEncoding。- 有 CRL 时组装
RevocationOptions,没有就是None。CRL 怎么查见客户端证书与自定义校验。 verify_server_cert_signed_by_trust_anchor_impl调EndEntityCert::verify_for_usage,用途是KeyUsage::server_auth(),最后一个参数verify_path传None。路径构建的全部细节在路径构建。注意传进去的是self.supported.all:给证书签名用的算法列表,和握手签名用的映射表是两回事,见本页最后一节。verify_server_name比对名字。名字放在路径之后,所以一张既找不到签发者、名字也不对的证书,报的是UnknownIssuer。
服务器装订(stapled)的 OCSP 响应只在 trace! 日志里打印一下,标着「Unvalidated」:0.23.46 的默认校验器不验 OCSP。吊销只能靠调用方自己准备的 CRL。
这个结构有两个构造入口,行为不一样:
| 入口 | 吊销 | 根证书库为空时 |
|---|---|---|
ClientConfig 的 with_root_certificates | 走 new_without_revocation,不查 | 照样建好,握手时报 UnknownIssuer |
WebPkiServerVerifier::builder_with_provider → build,再 with_webpki_verifier | 可以 with_crls | build() 返回 NoRootAnchors |
实测(第一行来自 errors.rs 的握手,第二行来自 dangerous.rs 直接调 build()):
empty root store: client error: invalid peer certificate: UnknownIssuer
build(): Err(NoRootAnchors) / no root trust anchors were provided
不带 provider 的 WebPkiServerVerifier::builder(roots) 用进程级默认 provider 的算法表,两个 provider feature 都开时会 panic,原因见 CryptoProvider。
错误和告警
rustls-webpki 有自己的 webpki::Error,rustls 用 pki_error 翻译成 CertificateError。常见的几种有专门的变体(UnknownIssuer、NotValidForNameContext、ExpiredContext、NotValidYetContext、Revoked、InvalidPurposeContext、BadSignature…),其余全部落进最后一个分支:
_ => CertificateError::Other(OtherError(
#[cfg(feature = "std")]
Arc::new(error),
))
.into(),
所以 CaUsedAsEndEntity、PathLenConstraintViolated、NameConstraintViolation、几个预算超限、0.103.16 新加的 CertDistrusted 在 rustls 里都长成 Other(OtherError(...)),要匹配具体原因得 downcast 里面的 webpki::Error。客户端出错后发什么告警由 From<CertificateError> for AlertDescription 决定。下表每一行都是一次真实握手,「客户端报错」是 Display 原文,「服务端看到」是服务端随后 process_new_packets 的报错:
| 情形 | 客户端报错 | 服务端看到 |
|---|---|---|
| 只发叶子,漏了中间证书 | invalid peer certificate: UnknownIssuer | received fatal alert: UnknownCA |
证书只有 DNS 名 localhost,用 127.0.0.1 连 | invalid peer certificate: certificate not valid for name "127.0.0.1"; certificate is only valid for DnsName("localhost") | received fatal alert: BadCertificate |
| 叶子有效期 2020-01-01 到 2021-01-01,客户端时钟 2026-10-30 | invalid peer certificate: certificate expired: verification time 1793318400 (UNIX), but certificate is not valid after 1609459200 (183859200 seconds ago) | received fatal alert: CertificateExpired |
| 客户端时钟为 0 | invalid peer certificate: certificate not valid yet: verification time 0 (UNIX), but certificate is not valid before 1577836800 (1577836800 seconds in future) | received fatal alert: CertificateExpired |
叶子带 CA:TRUE | invalid peer certificate: Other(OtherError(CaUsedAsEndEntity)) | received fatal alert: CertificateUnknown |
| 叶子的 EKU 只有 clientAuth | invalid peer certificate: certificate does not allow extended key usage for server authentication, allows client authentication | received fatal alert: UnsupportedCertificate |
| 7 层中间证书 | invalid peer certificate: Other(OtherError(MaximumPathDepthExceeded)) | received fatal alert: CertificateUnknown |
时钟为 0 得到的是「not valid yet」,不是「expired」:实际签发的证书 notBefore 都晚于 1970 年。「not valid yet」和「expired」在线上是同一个告警 certificate_expired,代码注释引了 RFC:「A certificate has expired or is not currently valid」。CaUsedAsEndEntity 不只出现在「把自签名 CA 直接当服务器证书」的场景(rustls 解决什么问题里的实验),这里的叶子由中间 CA 正常签发,只因为自己带了 CA:TRUE 就被拒。
Display 的长文案来自 impl fmt::Display for CertificateError。名字不匹配时列出证书里所有名字的 NotValidForNameContext 是 0.23.24 才加的(3e19b006「Pass-through detailed certificate name errors」,同一版本的 ded5d4ff 加了上面那种可读的写法),之前只有一个不带数据的 NotValidForName,这个变体现在仍然保留。
RootCertStore 存的是信任锚
RootCertStore 只有一个公开字段 roots: Vec<TrustAnchor<'static>>。TrustAnchor 是 pki-types 的类型,三个字段:
pub struct TrustAnchor<'a> {
/// Value of the `subject` field of the trust anchor
pub subject: Der<'a>,
/// Value of the `subjectPublicKeyInfo` field of the trust anchor
pub subject_public_key_info: Der<'a>,
/// Value of DER-encoded `NameConstraints`, containing name constraints to the trust anchor, if any
pub name_constraints: Option<Der<'a>>,
}
add 和 add_parsable_certificates 收的是证书 DER,内部调 rustls-webpki 的 anchor_from_trusted_cert 只抽出这三样,其余(有效期、签名、基本约束、扩展密钥用途)全部丢掉。函数文档说得很直白:「No additional checks on the content of the certificate, including whether it is self-signed, or has a basic constraints extension indicating the cA boolean is true, will be performed」。add 解析失败就返回错误;add_parsable_certificates 面向系统证书库那种大集合,解析不了的跳过,返回 (成功数, 失败数)。
路径构建时,锚只用来做两件事:用 subject 匹配证书的 issuer,用 SPKI 验签名。所以锚不必是自签名的根,把中间证书放进 RootCertStore 也能用(实测「client trusts intermediate only: Ok」)。
银行不保存你开过的每一张支票,只在印鉴卡上留户名(subject)和预留印鉴(subject_public_key_info),有的卡上还注明「限某地区使用」(name_constraints)。来了一张支票,柜员按出票人户名找卡(issuer 对 subject),再拿卡上的印鉴比对支票上的章(验签名)。印鉴卡本身就是信任的起点,没人再去问「这张卡是谁签发的」。
比方到这里为止:银行会注销印鉴卡,TrustAnchor 上却没有任何有效期或吊销信息,一个锚只要还在 RootCertStore 里就一直有效;要撤掉它只能换掉库本身,比如升级 webpki-roots。
webpki-roots 就是一张编译进二进制的锚表。它的 TLS_SERVER_ROOTS 是 &[TrustAnchor<'static>],由 Mozilla 通过 CCADB 发布的根证书报表生成。本卷实验解析到 webpki-roots 1.0.11,用 verify-lab/src/bin/roots.rs 数了一下:
anchors: 118
subject bytes 11064, spki bytes 42566, name_constraints on 1 anchors (9 bytes)
total anchor bytes: 53639
RootCertStore { roots: "(118 roots)" }
subjects(): 118 DNs, 11317 bytes
full certificates in comments: 118, 127103 DER bytes
118 个锚的三个字段合计 53639 字节,同样 118 张完整证书(源文件注释里的 PEM)是 127103 字节,锚表只有四成多。唯一带名字约束的锚是「TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1」,约束是 \xa0\x070\x05\x82\x03.tr,即只允许 .tr 下的 DNS 名。用 openssl x509 -text(Homebrew 的 OpenSSL 4.1.0)看这张证书本身,没有 Name Constraints 扩展:这条约束是信任库附加的,这正是 TrustAnchor 需要一个独立的 name_constraints 字段的原因。Debug 只打印个数,subjects() 返回的 DER 名字(各自再包一层 SEQUENCE)在客户端认证时会被服务器发出去,见客户端证书与自定义校验。
锚的数量不太影响速度:同一条「根 → 中间 → 叶子」链,verify_server_cert 在只有 1 个锚和 webpki-roots 加 1 个锚(119 个)时分别是 90.8 µs 和 88.3 µs(各 200 次取最小值,uptime 负载 7 到 8)。路径构建对锚是线性扫描,但每个锚只比一次 subject 字节串,签名只在名字对上之后才验。
名字匹配
verify_is_valid_for_subject_name 按 ServerName 的种类分两路:
match server_name {
ServerName::DnsName(dns_name) => verify_dns_names(dns_name, &self.inner),
// IP addresses are not compared against the subject field;
// only against Subject Alternative Names.
ServerName::IpAddress(ip_address) => verify_ip_address_names(ip_address, &self.inner),
_ => Err(Error::UnsupportedNameType),
}
两路都只看证书的 subjectAltName(SAN)扩展:DNS 名只和 SAN 里的 dNSName 比,IP 只和 iPAddress 比(verify_ip_address_names 逐字节比较 4 或 16 字节的网络序地址)。DNS 名和 IP 互不相通:证书里写了 DNS 名 localhost,用 127.0.0.1 连就失败,反过来也一样。匹配不上时 rustls-webpki 把证书里所有 SAN 列进错误,所以报错能直接告诉你证书「is only valid for」哪些名字。
ServerName 从哪来,决定走哪一路。TryFrom<&str> for ServerName 先按 DNS 名解析,失败了再按 IP 地址解析:
"localhost" -> Ok(DnsName("localhost"))
"127.0.0.1" -> Ok(IpAddress(V4(Ipv4Addr([127, 0, 0, 1]))))
"::1" -> Ok(IpAddress(V6(Ipv6Addr([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]))))
"[::1]" -> Err(InvalidDnsNameError)
"example.com." -> Ok(DnsName("example.com."))
"*.example.com" -> Err(InvalidDnsNameError)
"01.2.3.4" -> Err(InvalidDnsNameError)
"1.2.3.4.example" -> Ok(DnsName("1.2.3.4.example"))
"a_b.example" -> Ok(DnsName("a_b.example"))
"exa mple.com" -> Err(InvalidDnsNameError)
"" -> Err(InvalidDnsNameError)
127.0.0.1 不是合法 DNS 名(最后一个标签全是数字),所以落到 IP。带方括号的 IPv6(URL 里的写法)两种都解析不了,要先去掉括号。DNS 名允许下划线,*.example.com 不能当要连接的名字。名字是 IP 时客户端不发 SNI:emit_client_hello_for_retry 只在 ServerName::DnsName 时填 server_name 扩展,DNS 名末尾的点也会在发送前去掉。
DNS 名的比较在 presented_id_matches_reference_id,通配符的合法性在 is_valid_dns_id。规则是 NSS 那一套,比 RFC 6125 严:
- 通配符只能是最左边整个标签恰好为
*,f*.example.com、d.*.b.a都被当成畸形名字跳过; - 通配符后面至少要有两个标签,所以
*.com不合法; *只匹配一个标签,不跨点;- 大小写不敏感,连接名可以带末尾的点。
每条都在握手里跑过(证书里的 SAN 对连接名):
SAN ["*.example.com"] vs "a.example.com": Ok
SAN ["*.example.com"] vs "A.EXAMPLE.COM": Ok
SAN ["*.example.com"] vs "a.b.example.com": client error: invalid peer certificate: certificate not valid for name "a.b.example.com"; certificate is only valid for DnsName("*.example.com")
SAN ["*.example.com"] vs "example.com": client error: invalid peer certificate: certificate not valid for name "example.com"; certificate is only valid for DnsName("*.example.com")
SAN ["*.com"] vs "example.com": client error: invalid peer certificate: certificate not valid for name "example.com"; certificate is only valid for DnsName("*.com")
SAN ["example.com"] vs "example.com.": Ok
SAN ["a.example.com", "b.example.com", "c.example.com"] vs "d.example.com": client error: invalid peer certificate: certificate not valid for name "d.example.com"; certificate is only valid for DnsName("a.example.com"), DnsName("b.example.com") or DnsName("c.example.com")
*.com 那一行说明畸形的 SAN 不报错,只是不参与匹配(verify_dns_names 把 MalformedDnsIdentifier 当成「这一项不匹配」)。
问: 证书的 Subject 里明明写着 CN=localhost,为什么连 localhost 还是不匹配?
答: rustls-webpki 不支持用 CN 当名字(CN-ID),只认 SAN。NameIterator 的注释写着「Since we don't support CN-IDs, the certificate will be rejected either way」。用一张 CN=localhost、没有 SAN 的证书实测,报错是 certificate not valid for name "localhost"; certificate is not valid for any names (according to its subjectAltName extension)。内部 CA 签的老证书如果只有 CN,需要带上 SAN 重签。
CertificateVerify 签名和算法表
verify_server_cert 通过之后,客户端紧接着调 verify_tls13_signature。签名覆盖的消息怎么拼(64 个空格、上下文字符串、转录哈希)在客户端 TLS 1.3里;这里只看校验器这一侧,verify_tls13_signature:
pub fn verify_tls13_signature(
msg: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
supported_schemes: &WebPkiSupportedAlgorithms,
) -> Result<HandshakeSignatureValid, Error> {
if !dss.scheme.supported_in_tls13() {
return Err(PeerMisbehaved::SignedHandshakeWithUnadvertisedSigScheme.into());
}
let alg = supported_schemes.convert_scheme(dss.scheme)?[0];
let cert = webpki::EndEntityCert::try_from(cert).map_err(pki_error)?;
cert.verify_signature(alg, msg, dss.signature())
.map_err(pki_error)
.map(|_| HandshakeSignatureValid::assertion())
}
三道关:方案本身能不能用于 TLS 1.3(SHA-1、RSA-PKCS1 都不行);方案在不在我们的映射表里(不在就是 SignedHandshakeWithUnadvertisedSigScheme,因为 ClientHello 只宣告了表里的方案);用叶子证书的公钥验签名。叶子在这里又被解析了一次,verify_server_cert 里那份 ParsedCertificate 没有传过来。签名不对时报 invalid peer certificate: BadSignature,告警是 decrypt_error。这一步不消耗路径构建的预算,预算只管证书链上的签名。
算法表是 WebPkiSupportedAlgorithms,两个字段用途不同:
| 字段 | 用在哪 | 顺序 |
|---|---|---|
all | 验证书链上的签名(传给 verify_for_usage) | 无所谓 |
mapping | 握手签名:TLS 方案 → 一组 rustls-webpki 算法;supported_schemes() 按它的顺序生成 signature_algorithms | 有意义,第一个最优先 |
一个方案对应多个算法,是因为 TLS 1.2 的 ecdsa_secp256r1_sha256 不绑定曲线,P-384 的密钥也可能用它;所以 verify_tls12_signature 逐个尝试,TLS 1.3 的方案绑定了曲线,只取第一个。ring provider 的表(SUPPORTED_SIG_ALGS)宣告 9 个方案:
ring supported_verify_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]
RSA 的算法名都带 2048_8192:模长不在这个范围的 RSA 密钥直接不支持。aws-lc-rs 的表多 ECDSA_NISTP521_SHA512 和 ML-DSA 三个级别,ClientHello 因此多 8 字节,见套件与密钥交换。
动脑筋答案
根证书过期不影响,中间证书过期就失败:
root expired in 2001, intermediate and leaf valid: Ok
(server then sees: received fatal alert: CertificateExpired)
intermediate expired in 2001: client error: invalid peer certificate: certificate expired: verification time 1793295249 (UNIX), but certificate is not valid after 978307200 (814988049 seconds ago)
- 第一行:根证书进了
RootCertStore就只剩subject、SPKI 和名字约束,notAfter根本没存下来,路径构建无从检查。它对中间证书的签名照样能验。 - 第二行:中间证书是服务器发来的普通证书,路径构建对链上每一张(叶子和中间证书)都查有效期,
978307200就是 2001-01-01。(server then sees ...)一行是服务端收到的告警,打印在客户端那行之前。
这也是浏览器和操作系统需要定期更新根证书库的原因:过期的根要靠信任库自己删掉,校验器不会替你判断。
小结
ServerCertVerifier四个方法:链和名字、TLS 1.2 签名、TLS 1.3 签名、支持的签名方案;返回的ServerCertVerified/HandshakeSignatureValid是零大小的标记类型,ExpectTraffic必须持有它们。WebPkiServerVerifier::verify_server_cert的顺序:解析叶子 → rustls-webpki 路径构建(可带 CRL)→ 名字;装订的 OCSP 只打日志,不验。with_root_certificates不查吊销、空库也能建;WebPkiServerVerifier::builder能配 CRL,空库返回NoRootAnchors。- 许多 webpki 错误在 rustls 里是
Other(OtherError(..));告警按类别映射,「过期」和「尚未生效」同为certificate_expired。 RootCertStore只存信任锚(subject、SPKI、名字约束),不存有效期;webpki-roots 1.0.11 有 118 个锚,共 53639 字节。- 名字只看 SAN,不看 CN;DNS 与 IP 分开比;
ServerName::try_from先试 DNS 再试 IP,IP 不发 SNI;通配符只能是最左整标签、后面至少两个标签、只匹配一层。 - CertificateVerify 用映射表里该方案的第一个算法验,签名不对报
BadSignature。