Rust in Everything
基于 rustls 0.23.46

客户端证书与自定义校验

主线位置:校验主线的两个分支。一个是反方向:服务器要求客户端出示证书时,服务端在 ExpectCertificate 里调 ClientCertVerifier,用的是和服务器证书同一套 rustls-webpki 路径构建;另一个是主线的可选一步,CRL 吊销检查。最后讲 dangerous():把校验主线整个换成自己的代码时,到底关掉了什么。

sequenceDiagram
  participant cl as 客户端
  participant sv as 服务端状态
  participant vf as ClientCertVerifier
  sv->>vf: offer_client_auth
  sv->>cl: CertificateRequest
  Note over cl: 按提示名单选证书
  cl->>sv: Certificate
  sv->>vf: verify_client_cert
  vf-->>sv: 路径与 CRL 结果
  cl->>sv: CertificateVerify
  sv->>vf: verify_tls13_signature
  cl->>sv: Finished

代码在 rustls/src/verify.rs(两个 trait)、rustls/src/webpki/client_verifier.rs(665 行,后 235 行是测试)、rustls/src/server/tls13.rs(服务端的 ExpectCertificate、ExpectCertificateVerify)、rustls/src/client/builder.rs(dangerous()),CRL 在 rustls-webpki 0.103.16 的 src/crl/(1661 行)。

实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46(ring provider,只开 TLS 1.3),rcgen 0.14.10 造证书和 CRL(ECDSA P-256),握手全在内存里,release 构建。程序在 verify-lab/src/bin/clientauth.rs 和 dangerous.rs,输出原样存在 verify-lab/out-clientauth.txt、out-dangerous.txt。

ClientCertVerifier

服务端的 trait ClientCertVerifier 比服务器那一侧多三个决定「要不要、怎么要」的方法:

方法默认值作用
offer_client_authtrue是否发 CertificateRequest
client_auth_mandatory等于 offer_client_auth()客户端不给证书时是否失败
root_hint_subjects必须实现CertificateRequest 里的 certificate_authorities
verify_client_cert必须实现验客户端证书链,没有 server_name 参数

另外三个(两个版本的签名校验、supported_verify_schemes)和服务器证书里的同名方法一样。ServerConfig 的 with_no_client_auth() 用的是 NoClientAuth:offer_client_auth 返回 false,其余方法全是 unimplemented!(),因为不发 CertificateRequest 就永远不会被调到。

服务端在发第一组消息时调 emit_certificate_req_tls13:

        if !config.verifier.offer_client_auth() {
            return Ok(false);
        }

        let cr = CertificateRequestPayloadTls13 {
            context: PayloadU8::empty(),
            extensions: CertificateRequestExtensions {
                signature_algorithms: Some(
                    config
                        .verifier
                        .supported_verify_schemes(),
                ),
                // ...
                authority_names: match config.verifier.root_hint_subjects() {
                    &[] => None,
                    authorities => Some(authorities.to_vec()),
                },
            },
        };

客户端收到后,把 authority_names 和签名方案交给 ResolvesClientCert::resolve,由它挑一张证书;with_no_client_auth() 的客户端挑不出来,就回一个空的 Certificate。服务端的 ExpectCertificate 处理两种情况:

        let Some((end_entity, intermediates)) = client_cert.split_first() else {
            if !mandatory {
                debug!("client auth requested but no certificate supplied");
                self.transcript.abandon_client_auth();
                return Ok(Box::new(ExpectFinished {
                    // ...
                }));
            }

            return Err(cx.common.send_fatal_alert(
                AlertDescription::CertificateRequired,
                Error::NoCertificatesPresented,
            ));
        };

        let now = self.config.current_time()?;

        self.config
            .verifier
            .verify_client_cert(end_entity, intermediates, now)
            .map_err(|err| {
                cx.common
                    .send_cert_verify_error_alert(err)
            })?;

空证书且可选:跳过 CertificateVerify,直接等 Finished。空证书且必选:发 certificate_required 告警。有证书:先验链,再进 ExpectCertificateVerify 验签名。和客户端那一侧不同,服务端的 TLS 1.3 状态里没有保存 ClientCertVerified / HandshakeSignatureValid 标记:verify_client_cert 的结果被 ? 用完就丢,ExpectCertificateVerify 的字段里只有证书链本身。

WebPkiClientVerifier

默认实现 WebPkiClientVerifier 只能通过 builder 构造。ClientCertVerifierBuilder::new 的默认值:

        Self {
            root_hint_subjects: roots.subjects(),
            roots,
            crls: Vec::new(),
            anon_policy: AnonymousClientPolicy::Deny,
            revocation_check_depth: RevocationCheckDepth::Chain,
            unknown_revocation_policy: UnknownStatusPolicy::Deny,
            revocation_expiration_policy: ExpirationPolicy::Ignore,
            supported_algs,
        }

默认必须出示证书;allow_unauthenticated 把 anon_policy 改成 Allow,client_auth_mandatory() 随之返回 false。build 在根证书库为空时返回 NoRootAnchors,CRL 解析失败时返回 InvalidCrl。verify_client_cert 和服务器那一侧几乎逐行相同,区别只有用途换成 KeyUsage::client_auth(),并且没有名字匹配:服务端不知道「应该是谁」,谁持有受信任 CA 签的证书都能通过,身份要由应用层从 peer_certificates() 里读。

实测,客户端证书链是「根 → 中间 CA → alice」,alice 的 EKU 是 clientAuth。方括号里是服务端第一组消息的字节数和握手后 peer_certificates() 里的证书张数:

== no client auth (baseline)
with_no_client_auth [server first flight 634 B, server sees client certs: None]: Ok

== mandatory client auth
  (client then sees: received fatal alert: CertificateRequired)
client sends no cert [server first flight 696 B, server sees client certs: None]: server error: peer sent no certificates
...
client sends alice [server first flight 695 B, server sees client certs: Some(2)]: Ok
  (client then sees: received fatal alert: UnknownCA)
client sends alice without intermediate [server first flight 696 B, server sees client certs: None]: server error: invalid peer certificate: UnknownIssuer
...
  (client then sees: received fatal alert: UnsupportedCertificate)
client cert with serverAuth EKU only [server first flight 695 B, server sees client certs: None]: server error: invalid peer certificate: certificate does not allow extended key usage for client authentication, allows server authentication
...

== optional client auth (allow_unauthenticated)
client sends no cert [server first flight 696 B, server sees client certs: None]: Ok
client sends alice [server first flight 696 B, server sees client certs: Some(2)]: Ok
  (client then sees: received fatal alert: UnknownCA)
client sends a cert from an unknown CA [server first flight 695 B, server sees client certs: None]: server error: invalid peer certificate: UnknownIssuer

几点:

  • 「可选」只是允许不出示。出示了一张验不过的证书,照样失败,不会降级成匿名。
  • 客户端漏发中间证书和服务器漏发一样,报 UnknownIssuer。
  • 必选模式下客户端不给证书,服务端的错误是 peer sent no certificates,客户端看到的是 certificate_required 告警。
  • 加上 CertificateRequest,服务端第一组消息从 634 字节涨到 695 字节左右(ECDSA 签名长度会差一两个字节)。

提示名单的代价

root_hint_subjects 默认等于根证书库的 subjects(),每个锚的 DER 名字原样放进 CertificateRequest。根证书库小的时候无所谓;如果服务端图省事,直接用 webpki-roots 当客户端证书的信任库:

root_hint_subjects: 119 names, 11341 bytes
webpki-roots as client roots [server first flight 12250 B, server sees client certs: Some(2)]: Ok
same, clear_root_hint_subjects [server first flight 664 B, server sees client certs: Some(2)]: Ok

119 个名字(webpki-roots 的 118 个加实验的客户端根)共 11341 字节,服务端第一组消息从约 700 字节涨到 12250 字节,每次握手都发。clear_root_hint_subjects() 之后回到 664 字节,客户端那边的 resolve 收到空名单,自己决定发哪张。反过来,add_root_hint_subjects 可以在名单里加上不在根证书库里的名字。用公共 CA 的根来认证客户端本身就不常见,这里的数字主要说明名单是按锚的个数线性增长的。

CRL 吊销检查

rustls 不下载 CRL,也不验 OCSP。调用方自己拿到 CRL 的 DER,交给 builder 的 with_crls,客户端证书和服务器证书两边的 builder 都有同样四个开关(服务器那一侧的 ServerCertVerifierBuilder::with_crls 文档写的是「presented client certificates」,是从客户端这一侧照搬过来的,行为一样):

开关默认改成
with_crls不查给了 CRL 才查
only_check_end_entity_revocation查整条链(RevocationCheckDepth::Chain,不含锚)只查叶子
allow_unknown_revocation_status找不到负责的 CRL 就失败(UnknownStatusPolicy::Deny)当作未吊销
enforce_revocation_expiration不看 CRL 的 nextUpdate(ExpirationPolicy::Ignore)过期的 CRL 报错

rustls-webpki 那边的 RevocationOptions::check 对路径上的每张证书调一次:

        // If the policy only specifies checking EndEntity revocation state and we're looking at an
        // issuer certificate, return early without considering the certificate's revocation state.
        if let (RevocationCheckDepth::EndEntity, Role::Issuer) = (self.depth, path.role()) {
            return Ok(None);
        }

        let crl = self
            .crls
            .iter()
            .find(|candidate_crl| candidate_crl.authoritative(path));

        use UnknownStatusPolicy::*;
        let crl = match (crl, self.status_policy) {
            (Some(crl), _) => crl,
            // If the policy allows unknown, return Ok(None) to indicate that the certificate
            // was not confirmed as CertNotRevoked, but that this isn't an error condition.
            (None, Allow) => return Ok(None),
            // Otherwise, this is an error condition based on the provided policy.
            (None, _) => return Err(Error::UnknownRevocationStatus),
        };

        // Verify the CRL signature with the issuer SPKI.
        // ...
        crl.verify_signature(supported_sig_algs, issuer_spki, budget)
            .map_err(crl_signature_err)?;

        if self.expiration_policy == ExpirationPolicy::Enforce {
            crl.check_expiration(time)?;
        }

        // Verify that if the issuer has a KeyUsage bitstring it asserts cRLSign.
        KeyUsageMode::CrlSign.check(issuer_ku)?;

        // Try to find the cert serial in the verified CRL contents.
        let cert_serial = path.cert.serial.as_slice_less_safe();
        match crl.find_serial(cert_serial)? {
            None => Ok(Some(CertNotRevoked::assertion())),
            Some(_) => Err(Error::CertRevoked),
        }

「负责」的 CRL(authoritative)首先要求 CRL 的签发者名字等于证书的签发者名字;不支持间接 CRL。CRL 的签名在每次查找时现验,消耗路径构建的签名预算(见路径构建)。签发者有 KeyUsage 扩展时必须带 cRLSign,否则是 IssuerNotCrlSigner。

实验用 rcgen 造了三份 CRL:中间 CA 签的、列着 alice 序列号 1001 的;中间 CA 签的空 CRL;根签的空 CRL。nextUpdate 都是 2026-02-01,比实验时间早,所以它们其实都过期了,默认不看过期,只有最后一行打开了检查:

root's CRL + intermediate's CRL listing alice [server first flight 695 B, server sees client certs: None]: server error: invalid peer certificate: Revoked
intermediate's CRL lists alice, only_check_end_entity_revocation [server first flight 696 B, server sees client certs: None]: server error: invalid peer certificate: Revoked
only intermediate's CRL (empty), default depth [server first flight 695 B, server sees client certs: None]: server error: invalid peer certificate: UnknownRevocationStatus
only intermediate's CRL (empty), only_check_end_entity_revocation [server first flight 695 B, server sees client certs: Some(2)]: Ok
root's CRL + intermediate's CRL (both empty) [server first flight 697 B, server sees client certs: Some(2)]: Ok
only intermediate's CRL, allow_unknown_revocation_status [server first flight 696 B, server sees client certs: Some(2)]: Ok
both CRLs, nextUpdate 2026-02-01, enforce_revocation_expiration [server first flight 696 B, server sees client certs: None]: server error: invalid peer certificate: certificate revocation list expired: verification time 1793295478 (UNIX), but CRL is not valid after 1769904000 (23391478 seconds ago)

默认的「整条链 + 未知即失败」意味着链上每一级 CA 都要有一份 CRL:只给中间 CA 的 CRL 不够,中间 CA 本身也要被根的 CRL 覆盖。告警方面,Revoked 发 certificate_revoked;UnknownRevocationStatus 和过期的 CRL 发 unknown_ca,error.rs 的注释说吊销状态无法确定时 OpenSSL、BoringSSL 和 AWS-LC 都发这个告警,rustls 照做。

❗IMPORTANT
动脑筋:只给服务端一份 CRL,就是中间 CA 签的、列着 alice 序列号的那份,其余用默认值。alice 来连,报错是 Revoked 吗?平台同上。答案在文末。

dangerous():换掉整个校验器

客户端有两个入口能放进任意 ServerCertVerifier,都要经过名字带 danger 的路径:

trait 本身和两个标记类型在 rustls::client::danger 模块里,要写自定义校验器,代码里必然出现 danger。服务端没有对应的关卡:with_client_cert_verifier 直接收 Arc<dyn ClientCertVerifier>,只有 trait 放在 rustls::server::danger 里。

📝NOTE
冷知识:danger 模块取代了一个 Cargo feature

0.22.0 之前,自定义校验器要先打开 dangerous_configuration feature。5fd434f7(「proj: remove dangerous_configuration feature」,首次进入 0.22.0)把它删了,换成现在的 danger 模块和 dangerous() 方法。提交说明给的理由是:「Cargo features are additive, which means transitive dependencies could enable them for you without explicit opt-in. Using obviously named modules will maintain the property that it's easy to grep for imports, but avoids feature flag bloat and the additive downsides」。也就是说,旧的 feature 开关并不能保证「我的程序没有关掉校验」:依赖树里任何一个 crate 打开它,你的 crate 也就能用了;而现在每个用到的地方都要写出 danger,搜一下就能找到。同一个提交还说明,webpki 校验器和它的辅助函数「not include[d] ... as part of the dangerous API surface」,所以今天的 WebPkiServerVerifier 在普通模块里。

no-op 校验器跳过了什么

最常见的「跳过证书校验」写法,是一个所有方法都直接返回 assertion() 的 ServerCertVerifier。实验写了两个版本,对照默认校验器:

  • NoVerifier:四个方法全部 Ok(...assertion()),supported_verify_schemes 返回 ring 的方案表。
  • ChainOnlyNoop:verify_server_cert 直接通过,两个签名方法调 rustls::crypto::verify_tls13_signature / verify_tls12_signature 真的验。rustls 自带示例 tlsclient-mio 的 --insecure 用的 NoCertificateVerification 就是这种写法。

服务器有两种冒充方式:拿一张自己现造的自签名证书(名字都不对);或者拿真实网站的证书(证书是公开的),但手里没有对应私钥,用别的私钥签 CertificateVerify。后一种用 with_single_cert 配不出来,它会检查公私钥匹配:keys may not be consistent: KeyMismatch;实验改用 CertifiedKey::new 加 with_cert_resolver,这条路径不做检查。结果:

冒充方式NoVerifierChainOnlyNoop默认校验器
自造的自签名证书OkOkUnknownIssuer
真证书,私钥不对OkBadSignatureBadSignature

原文:

== server presents an unrelated self-made certificate
NoVerifier: Ok
ChainOnlyNoop: Ok
  (server then sees: received fatal alert: UnknownCA)
default verifier: client error: invalid peer certificate: UnknownIssuer
...
== server presents the real certificate but signs with a different key
with_single_cert(real cert, other key): keys may not be consistent: KeyMismatch / InconsistentKeys(KeyMismatch)
NoVerifier: Ok
  (server then sees: received fatal alert: DecryptError)
ChainOnlyNoop: client error: invalid peer certificate: BadSignature
...

NoVerifier 那一格说明:CertificateVerify 的签名完全由校验器负责,rustls 的状态机自己不再验一遍。连签名一起返回 assertion(),握手就不再证明「对面持有这张证书的私钥」,对面可以出示任何人的证书。这仍然是一次加密的握手,密钥交换照常,只是不知道在和谁交换。

📝NOTE
没有蠢问题

问: 那像官方示例那样,只跳过证书链、保留签名校验,是不是就安全一点?

答: 对中间人没有区别。签名校验只证明「对面持有它自己发来的那张证书的私钥」,而中间人完全可以现造一对证书和私钥,表的第一行 ChainOnlyNoop: Ok 就是这种情况。保留签名校验真正挡住的只是「出示别人的证书、却没有私钥」这一种,而这种攻击者本来就有更省事的选择。能证明对面是谁的是证书链和名字匹配,也就是被跳过的那部分。示例把它放在 --insecure 开关后面,是给调试用的。

自定义校验器有正当用途:固定(pin)某个公钥、只信任某一张自签名证书、在 WebPkiServerVerifier 之外再加一道检查。最稳妥的写法是把 WebPkiServerVerifier 包一层,先调它,再做额外检查,签名方法原样转发;要用操作系统的信任库和吊销机制,有单独的 rustls-platform-verifier crate。写校验器时还有两个和安全无关、但会让握手直接失败的细节:

supported_verify_schemes() = []: server error: received corrupt message of type NoSignatureSchemes
supported_verify_schemes() = [ED25519] only, server key is P-256: server error: peer is incompatible: NoSignatureSchemesInCommon

supported_verify_schemes 返回空表,ClientHello 里的 signature_algorithms 就是空的,服务端按畸形消息处理(客户端看到 decode_error 告警);只返回服务器密钥用不了的方案,服务端选不出签名方案,客户端看到 handshake_failure。照抄 provider 的 signature_verification_algorithms.supported_schemes() 最省事。

最后,会话恢复不会再调校验器(服务器证书里提到的 assertion() 直接构造),而客户端只在校验器是同一个 Arc 时才复用缓存的会话(compatible_config 用 Weak::ptr_eq 比较),见会话恢复与 0-RTT。所以 set_certificate_verifier 换了校验器之后,旧会话不会绕过新校验器被恢复。

动脑筋答案

不是,报的是 UnknownRevocationStatus:

  (client then sees: received fatal alert: UnknownCA)
intermediate's CRL lists alice [server first flight 695 B, server sees client certs: None]: server error: invalid peer certificate: UnknownRevocationStatus
    debug: InvalidCertificate(UnknownRevocationStatus)
  • 吊销检查跟着签名检查走,顺序是从锚往下(路径构建里的 check_signed_chain)。第一张被查的是中间 CA 本身,它的签发者是根,而服务端没有根签的 CRL。
  • 默认策略是 Deny,这一步就返回 UnknownRevocationStatus,整个检查结束,根本没走到 alice。alice 确实被吊销了,但报错没有说出来。
  • 加上根的 CRL,或者打开 only_check_end_entity_revocation,报错就变成 Revoked(上面 CRL 一节的前两行)。看到 UnknownRevocationStatus,先确认链上每一级签发者都有对应的 CRL。

小结

  • ClientCertVerifier 用 offer_client_auth / client_auth_mandatory 决定发不发 CertificateRequest、能不能不给证书;NoClientAuth 不发请求。
  • WebPkiClientVerifier 默认必须出示证书,allow_unauthenticated 允许匿名但不放过坏证书;它只验链和 clientAuth 用途,不做名字匹配,身份由应用从 peer_certificates() 读。
  • CertificateRequest 默认带上根证书库所有锚的名字,用 webpki-roots 时第一组消息涨到 12 KB,clear_root_hint_subjects 可去掉。
  • CRL 由调用方提供;默认查整条链、找不到负责的 CRL 就失败、不看过期;只给中间 CA 的 CRL 会报 UnknownRevocationStatus 而不是 Revoked。OCSP 不验。
  • 自定义校验器要经过 dangerous() 和 danger 模块(0.22.0 起取代 dangerous_configuration feature);服务端的 with_client_cert_verifier 没有这道关卡。
  • CertificateVerify 签名完全由校验器负责:no-op 校验器连私钥持有都不再证明;只保留签名校验也挡不住中间人。
  • 自定义校验器的 supported_verify_schemes 不能为空,也要覆盖服务器密钥的方案,否则握手在服务端就失败。

💬 关联讨论

发起讨论
加载中...