客户端证书与自定义校验
主线位置:校验主线的两个分支。一个是反方向:服务器要求客户端出示证书时,服务端在
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_auth | true | 是否发 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 照做。
Revoked 吗?平台同上。答案在文末。dangerous():换掉整个校验器
客户端有两个入口能放进任意 ServerCertVerifier,都要经过名字带 danger 的路径:
- 构建时:
ClientConfig::builder...走到WantsVerifier这一步,调dangerous()得到DangerousClientConfigBuilder,再with_custom_certificate_verifier。 - 构建后:
ClientConfig::dangerous()返回DangerousClientConfig,set_certificate_verifier替换掉已有的校验器。
trait 本身和两个标记类型在 rustls::client::danger 模块里,要写自定义校验器,代码里必然出现 danger。服务端没有对应的关卡:with_client_cert_verifier 直接收 Arc<dyn ClientCertVerifier>,只有 trait 放在 rustls::server::danger 里。
danger 模块取代了一个 Cargo feature0.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,这条路径不做检查。结果:
| 冒充方式 | NoVerifier | ChainOnlyNoop | 默认校验器 |
|---|---|---|---|
| 自造的自签名证书 | Ok | Ok | UnknownIssuer |
| 真证书,私钥不对 | Ok | BadSignature | BadSignature |
原文:
== 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(),握手就不再证明「对面持有这张证书的私钥」,对面可以出示任何人的证书。这仍然是一次加密的握手,密钥交换照常,只是不知道在和谁交换。
问: 那像官方示例那样,只跳过证书链、保留签名校验,是不是就安全一点?
答: 对中间人没有区别。签名校验只证明「对面持有它自己发来的那张证书的私钥」,而中间人完全可以现造一对证书和私钥,表的第一行 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_configurationfeature);服务端的with_client_cert_verifier没有这道关卡。 - CertificateVerify 签名完全由校验器负责:no-op 校验器连私钥持有都不再证明;只保留签名校验也挡不住中间人。
- 自定义校验器的
supported_verify_schemes不能为空,也要覆盖服务器密钥的方案,否则握手在服务端就失败。