Rust in Everything
基于 rustls 0.23.46

服务器证书

主线位置:校验主线的入口和出口。客户端在 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_signatureTLS 1.2 的 ServerKeyExchangeHandshakeSignatureValid
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() 的自定义校验器,那种校验器的后果见客户端证书与自定义校验。

📝NOTE
冷知识:标记类型是 2017 年加的,之前校验函数返回 ()

提交 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())
    }

四步:

  1. ParsedCertificate::try_from 把叶子交给 webpki::EndEntityCert::try_from 解析。DER 格式错误映射成 BadEncoding。
  2. 有 CRL 时组装 RevocationOptions,没有就是 None。CRL 怎么查见客户端证书与自定义校验。
  3. verify_server_cert_signed_by_trust_anchor_impl 调 EndEntityCert::verify_for_usage,用途是 KeyUsage::server_auth(),最后一个参数 verify_path 传 None。路径构建的全部细节在路径构建。注意传进去的是 self.supported.all:给证书签名用的算法列表,和握手签名用的映射表是两回事,见本页最后一节。
  4. 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_crlsbuild() 返回 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: UnknownIssuerreceived 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-30invalid peer certificate: certificate expired: verification time 1793318400 (UNIX), but certificate is not valid after 1609459200 (183859200 seconds ago)received fatal alert: CertificateExpired
客户端时钟为 0invalid 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:TRUEinvalid peer certificate: Other(OtherError(CaUsedAsEndEntity))received fatal alert: CertificateUnknown
叶子的 EKU 只有 clientAuthinvalid peer certificate: certificate does not allow extended key usage for server authentication, allows client authenticationreceived 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」)。

💡TIP
打个比方:银行柜台的印鉴卡

银行不保存你开过的每一张支票,只在印鉴卡上留户名(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 字节串,签名只在名字对上之后才验。

❗IMPORTANT
动脑筋:根证书 2000-01-01 生效、2001-01-01 到期,它签发的中间证书和叶子都在有效期内。客户端信任这个根,握手能成功吗?如果过期的是中间证书呢?平台:同上,ring provider,客户端用系统时钟。答案在文末。

名字匹配

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 当成「这一项不匹配」)。

📝NOTE
没有蠢问题

问: 证书的 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。

💬 关联讨论

发起讨论
加载中...