路径构建
主线位置:校验主线的中段。服务器证书里的
verify_server_cert调EndEntityCert::verify_for_usage,从这里开始到返回VerifiedPath或错误为止,全在 rustls-webpki 的verify_cert.rs里:从叶子出发,一层层找能签发它的证书,直到某个信任锚。返回后 rustls 再去比对名字、验 CertificateVerify 签名。
flowchart TB
start["build_chain_inner"] --> dis{"SPKI 在不信任表"}
dis -->|是| err["CertDistrusted"]
dis -->|否| props["有效期 基本约束 EKU"]
props --> anchors["逐个信任锚"]
anchors -->|subject 对上| sig["从锚往下验签名"]
sig --> nc["名字约束"]
nc -->|通过| done["返回锚"]
anchors -->|都不行| inter["逐个中间证书"]
inter -->|subject 对上| push["预算减一 入栈"]
push --> start
代码在 rustls-webpki 0.103.16(tag v/0.103.16):src/verify_cert.rs 1523 行(路径构建、预算、EKU,约 600 行是测试),src/end_entity.rs 241 行,src/cert.rs 749 行(证书解析),src/signed_data.rs 269 行,名字约束在 src/subject_name/mod.rs,吊销在 src/crl/。verify_cert.rs 的文件头保留着 2015 年的版权行,注释里多处对照 Firefox 的 mozilla::pkix。
实验平台:M2 Max、macOS 27、rustc 1.99.0,rustls 0.23.46(ring provider),rcgen 0.14.10 现造 ECDSA P-256 证书,release 构建。计时直接调 WebPkiServerVerifier::verify_server_cert,每种情形跑 10 次取最小值,uptime 负载 7.5 到 8(同时有其他实验在跑)。签名次数和递归次数来自另一份构建:把 rustls-webpki 0.103.16 复制一份,只在 Budget 的三个 consume_* 方法里各加一个原子计数器,经 [patch.crates-io] 换进去(verify-count),计的是尝试次数,超限那一次也算。程序和输出在 verify-lab/src/bin/budget.rs、verify-lab/out-budget.txt、verify-count/out-count.txt。
入口:verify_for_usage
EndEntityCert::verify_for_usage 把参数装进 ChainOptions:
verify_cert::ChainOptions {
eku: usage,
supported_sig_algs,
trust_anchors,
intermediate_certs,
revocation,
distrusted_spkis: verify_cert::DISTRUSTED_SPKIS,
}
.build_chain(self, time, verify_path)
build_chain 建一个只含叶子的 PartialPath 和一个新的 Budget::default(),调递归函数 build_chain_inner。预算是每次 verify_for_usage 一份,不跨证书、不跨连接共享。成功时返回 VerifiedPath:叶子、用到的中间证书、锚。rustls 只看成功与否,把它丢掉了。
usage 是 impl ExtendedKeyUsageValidator,rustls 传 KeyUsage::server_auth() 或 KeyUsage::client_auth()。最后一个参数 verify_path 是一个可选的回调,只在一条路径已经通到锚之后调用,可以否决它、让搜索继续找别的路径;rustls 传 None。
PartialPath 用定长数组 [Option<Cert<'a>>; MAX_SUB_CA_COUNT] 存中间证书,MAX_SUB_CA_COUNT 是 6,整个搜索不在堆上分配路径。
一层递归做什么
build_chain_inner 处理当前路径的头(最后加进来的那张证书):
if self
.distrusted_spkis
.contains(&path.head().spki.as_slice_less_safe())
{
return Err(Error::CertDistrusted.into());
}
let role = path.node().role();
check_issuer_independent_properties(path.head(), time, role, sub_ca_count, &self.eku)?;
// ...
let result =
loop_while_non_fatal_error(Error::UnknownIssuer, self.trust_anchors, |trust_anchor| {
let trust_anchor_subject = untrusted::Input::from(trust_anchor.subject.as_ref());
if !public_values_eq(path.head().issuer, trust_anchor_subject) {
return Err(Error::UnknownIssuer.into());
}
// ...
let node = path.node();
self.check_signed_chain(&node, time, trust_anchor, budget)?;
check_signed_chain_name_constraints(&node, trust_anchor, budget)?;
// ...
});
// ...
loop_while_non_fatal_error(err, self.intermediate_certs, |cert_der| {
let potential_issuer = Cert::from_der(untrusted::Input::from(cert_der))?;
if !public_values_eq(potential_issuer.subject, path.head().issuer) {
return Err(Error::UnknownIssuer.into());
}
// Prevent loops; see RFC 4158 section 5.2.
if path.node().iter().any(|prev| {
public_values_eq(potential_issuer.spki, prev.cert.spki)
&& public_values_eq(potential_issuer.subject, prev.cert.subject)
}) {
return Err(Error::UnknownIssuer.into());
}
// ...
budget.consume_build_chain_call()?;
path.push(potential_issuer)?;
let result = self.build_chain_inner(path, time, verify_path, next_sub_ca_count, budget);
if result.is_err() {
path.pop();
}
result
})
按顺序:
- 头的 SPKI 在不信任表里,直接失败(见本页最后一节)。
check_issuer_independent_properties:不需要知道签发者就能查的东西,有效期、基本约束、EKU,下一节细讲。- 先试所有信任锚:锚的
subject和头的issuer字节相等才算候选。对上了,check_signed_chain从锚开始往下把整条路径的签名验一遍(有 CRL 时顺便查吊销),再check_signed_chain_name_constraints查名字约束。 - 锚都不行,再试服务器发来的每一张中间证书:
subject对上头的issuer;同一个「公钥 + subject」不能在路径上出现两次(RFC 4158 的防环);消耗一次递归预算,入栈,递归;递归失败就出栈,换下一张。
签名只在路径通到锚之后才验,而且每通到一次锚就从头验整条路径。往上找签发者靠的只是 DER 名字的字节比较,不看 Authority Key Identifier:两张证书 subject 相同、公钥不同,在这一步无法区分,只能各走一遍。这一点决定了后面病态链的形状。
常规链的开销很小:「根 → 一张中间证书 → 叶子」验了 2 次签名、递归 1 次,verify_server_cert 总共约 90 µs,主要花在两次 ECDSA P-256 验签上。
失败的处理在 loop_while_non_fatal_error:错误被 From<Error> for ControlFlow 分成两类。三个预算超限是 Break,立即终止整个搜索;其他错误是 Continue,记下来接着试下一个候选。所有候选都失败后报哪个错误,由 Error::rank 决定:过期 290、名字 280、吊销 270、签名错 260……MaximumPathDepthExceeded 61,UnknownIssuer 是 0,排在最后。也就是说,只要有任何一条候选路径给出了更具体的理由,就不会报 UnknownIssuer。
与签发者无关的检查
cert.validity
.read_all(Error::BadDer, |value| check_validity(value, time))?;
untrusted::read_all_optional(cert.basic_constraints, Error::BadDer, |value| {
check_basic_constraints(value, role, sub_ca_count)
})?;
untrusted::read_all_optional(cert.eku, Error::BadDer, |input| check_eku(input, eku))?;
这三项对叶子和每一张进入路径的中间证书都做,锚不做(锚只剩 subject 和公钥,见服务器证书)。
有效期,check_validity:notBefore > notAfter 是 InvalidCertValidity,time < notBefore 是 CertNotValidYet,time > notAfter 是 CertExpired,后两个带着时间值。等号两端都算有效。rcgen 的默认有效期是 1975-01-01 到 4096-01-01,本卷其他实验的证书因此永远不会过期。
基本约束,check_basic_constraints:
match (role, is_ca, path_len_constraint) {
(Role::EndEntity, true, _) => Err(Error::CaUsedAsEndEntity),
(Role::Issuer, false, _) => Err(Error::EndEntityUsedAsCa),
(Role::Issuer, true, Some(len)) if sub_ca_count > len => {
Err(Error::PathLenConstraintViolated)
}
_ => Ok(()),
}
没有基本约束扩展的证书按「不是 CA」处理。三种错误都在握手里复现过(rustls 的报错原文):
leaf has CA:TRUE: client error: invalid peer certificate: Other(OtherError(CaUsedAsEndEntity))
issuer is an end-entity cert: client error: invalid peer certificate: Other(OtherError(EndEntityUsedAsCa))
pathLen=0 intermediate with a sub-CA below: client error: invalid peer certificate: Other(OtherError(PathLenConstraintViolated))
注释说明了为什么不查 KeyUsage:「For CA certificates, BasicConstraints.cA makes KeyUsage redundant. Firefox and other common browsers do not check KeyUsage for end-entities」。唯一查 KeyUsage 的地方是 CRL 的签发者必须有 cRLSign。
扩展密钥用途(EKU),check_eku 交给 KeyUsage 的 validate。KeyUsage::server_auth() 是 required_if_present:证书没有 EKU 扩展就放行,有就必须包含 serverAuth;扩展存在但为空是 EmptyEkuExtension。因为这一步也作用在中间证书上,一张 EKU 只有 clientAuth 的中间 CA 签出来的服务器证书,即使叶子自己写了 serverAuth,也会失败:
intermediate EKU = clientAuth, leaf EKU = serverAuth: client error: invalid peer certificate: certificate does not allow extended key usage for server authentication, allows client authentication
报错说「allows client authentication」,指的是那张中间证书,报错里没有说是哪一张。
名字约束
CA 证书(或信任锚的 name_constraints 字段)可以规定它下面的证书只能用哪些名字。check_signed_chain_name_constraints 在签名验完之后,从锚往下走:每一级(锚或中间 CA)的约束作用于它下面的每一张证书,包括每张证书的所有 SAN 和 subject。具体比较在 check_presented_id_conforms_to_constraints,有几条保守的规则:
- 目录名(directoryName)约束一律不实现,按 RFC 5280 允许的方式拒绝:permitted 永不匹配、excluded 永远匹配;
- URI 约束同样「fail closed」;
- 带通配符的 SAN 遇到 permitted 约束时按字面比较,不展开:
*.example.com可能代表evil.example.com,不能算落在good.example.com之内;遇到 excluded 约束时则展开,只要可能落进去就拒绝。
实测,锚都是 rcgen 造的带约束的根:
root permits .example.com, leaf www.example.com: Ok
root permits .example.com, leaf evil.org: client error: invalid peer certificate: Other(OtherError(NameConstraintViolation))
root permits good.example.com, leaf *.example.com, connect good.example.com: client error: invalid peer certificate: Other(OtherError(NameConstraintViolation))
名字约束只在路径已经通过签名检查之后才查,所以不在最终路径上的证书不会消耗比较预算。webpki 自己的测试 name_constraint_budget 专门验证了这一点。
三个预算和一个深度上限
Budget 有三个计数器,每个 consume_* 用 checked_sub(1),减到 0 之后再要就报错:
impl Default for Budget {
fn default() -> Self {
Self {
// This limit is taken from the remediation for golang CVE-2018-16875. However,
// note that golang subsequently implemented AKID matching due to this limit
// being hit in real applications (see <https://github.com/spiffe/spire/issues/1004>).
// So this may actually be too aggressive.
signatures: 100,
// This limit is taken from mozilla::pkix, see:
// <https://github.com/nss-dev/nss/blob/bb4a1d38dd9e92923525ac6b5ed0288479f3f3fc/lib/mozpkix/lib/pkixbuild.cpp#L381-L393>
build_chain_calls: 200_000,
// This limit is taken from golang crypto/x509's default, see:
// <https://github.com/golang/go/blob/ac17bb6f13979f2ab9fcd45f0758b43ed72d0973/src/crypto/x509/verify.go#L588-L592>
name_constraint_comparisons: 250_000,
}
}
}
| 预算 | 上限 | 在哪里消耗 | 超限的错误 |
|---|---|---|---|
| 签名 | 100 | verify_signed_data 每验一个签名(证书或 CRL) | MaximumSignatureChecksExceeded |
| 递归 | 200000 | 每把一张中间证书入栈 | MaximumPathBuildCallsExceeded |
| 名字约束比较 | 250000 | 每个名字和每条约束比一次 | MaximumNameConstraintComparisonsExceeded |
三个都是致命错误,搜索立即结束。另外还有一个深度上限:PartialPath::push 在已有 MAX_SUB_CA_COUNT(6)张中间证书时报 MaximumPathDepthExceeded。它不是致命错误,只是这条路走不通,搜索换下一个候选。webpki 的测试 longest_allowed_path / path_too_long 和本卷的握手实测一致:6 层中间证书能过,7 层失败。
rustls 不认识这四个错误,它们都以 Other(OtherError(...)) 出现,告警是 certificate_unknown。
e473ee1(2023-08-21,「verify_cert: enforce maximum number of signatures.」)之前,路径构建没有任何次数限制。提交说明写道:「Pathbuilding complexity can be quadratic, particularly when the set of intermediates all have subjects matching a trust anchor. In these cases we need to bound the number of expensive signature validation operations that are performed to avoid a DoS on CPU usage」,并说上限 100「inspired by the approach taken in the Golang x509 package」。它首次进入主线的 0.102.0-alpha.2;同一天的 dcad240 和 4ea0523 把同样的改动放进了 0.101.4 和 0.100.2。最初的实现只是一路传下去的 &mut usize,*signatures > 100 就报错。两周后 3a11a9e(「Apply budget to number of calls to build_chain_inner」)把它和新的递归计数(200000)合进 Budget 结构,同一天的 2774cdc 再加上名字约束比较(250000);递归预算随后也回移到了 0.101.5 和 0.100.3。三个数字从那以后没有变过。
实验:用病态链触发预算
照 webpki 自己的测试 build_and_verify_degenerate_chain 的形状,用 rcgen 造 n 张中间证书,subject 全是 Bogus Subject,第一张由同名的根签发,之后每张签下一张,最后一张签发叶子。因为签发者只按名字匹配,每张中间证书在名字上都能当任何一张证书的签发者,搜索树按排列数增长。
情形一:锚也叫 Bogus Subject,中间证书按「根 → 叶」的顺序发。 链是合法的,存在一条正确路径。
| n | 结果 | 签名 | 递归 | 耗时 |
|---|---|---|---|---|
| 1 | Ok | 3 | 1 | 130.2 µs |
| 2 | Ok | 8 | 4 | 347.7 µs |
| 3 | Ok | 24 | 15 | 1.047 ms |
| 4 | Ok | 89 | 64 | 4.012 ms |
| 5 | MaximumSignatureChecksExceeded | 101 | 94 | 4.504 ms |
| 6 | MaximumSignatureChecksExceeded | 101 | 99 | 4.513 ms |
n = 1 就已经是 3 次签名而不是 2 次:叶子的签发者名字和锚一样,搜索先拿锚的公钥验叶子,失败,浪费一次;然后才走到中间证书。每多一张同名证书,「名字对上、签名不对」的组合就多出一层。n = 5 时第 101 次验签被拒,耗时封顶在 4.5 ms 左右,约等于 100 次 P-256 验签。
情形二:锚的名字(Bogus Trust Anchor)和链上任何签发者都对不上。 没有任何路径能通到锚,所以一次签名都不验,消耗的全是递归预算:
| n | 结果 | 签名 | 递归 | 耗时 |
|---|---|---|---|---|
| 5 | UnknownIssuer | 0 | 325 | 317.9 µs |
| 6 | UnknownIssuer | 0 | 1956 | 2.302 ms |
| 7 | MaximumPathDepthExceeded | 0 | 13699 | 12.06 ms |
| 8 | MaximumPathDepthExceeded | 0 | 69280 | 45.88 ms |
| 9 | MaximumPathBuildCallsExceeded | 0 | 200001 | 107.4 ms |
| 10 | MaximumPathBuildCallsExceeded | 0 | 200001 | 92.5 ms |
n = 5 的 325 次正好是 5 张证书的所有排列前缀数(5 + 20 + 60 + 120 + 120)。n 到 9 就撞上 200000 次,单次校验约 0.1 秒(n = 10 反而略快一些,两者都停在第 200001 次尝试)。每层递归都要把所有候选重新 Cert::from_der 解析一遍,再比名字。
情形三:名字约束。 根带 M 条 permitted 的 DNS 约束(p0.example.com 到 p{M-1}.example.com),叶子有 K 个 SAN,全在最后一条约束之下,每个名字都要和 M 条约束全部比一遍。连接名用叶子的第一个 SAN:
| K | M | 叶子大小 | 比较次数 | 结果 | 耗时 |
|---|---|---|---|---|---|
| 100 | 100 | 2408 B | 10100 | Ok | 583 µs |
| 999 | 250 | 23185 B | 250000 | Ok | 14.32 ms |
| 1000 | 250 | 23209 B | 250001 | MaximumNameConstraintComparisonsExceeded | 14.37 ms |
10100 = (100 + 1) × 100:除了 SAN,证书的 subject 也作为一个 directoryName 参与比较,每条约束都要先计数再判断类型。999 × 250 恰好用完预算还能通过,再多一个名字就超。
最后把情形二 n = 10 的链真的放进握手(服务器发 11 张证书,约 4.1 KB):
(server then sees: received fatal alert: CertificateUnknown)
certificate chain 4110 bytes, handshake took 95.031916ms
pathological chain: client error: invalid peer certificate: Other(OtherError(MaximumPathBuildCallsExceeded))
debug: InvalidCertificate(Other(OtherError(MaximumPathBuildCallsExceeded)))
一次几 KB 的 Certificate 消息让客户端花了约 95 ms,然后失败。这就是三个预算给出的上界:签名约 4.5 ms(P-256)、递归约 0.1 s、名字约束约 15 ms,三者叠加也在同一量级,而不是指数增长。服务器要求客户端证书时,同一套预算也作用在服务端的 verify_client_cert 上。
问: 情形二的 n = 7、8 报的是 MaximumPathDepthExceeded,可真正的原因是根本找不到锚。这个报错不是在误导人吗?
答: 是错误排序的副作用。深度超限不是致命错误,只是让当前这条路失败;所有候选都失败后,most_specific 按 rank 挑一个最「具体」的报:MaximumPathDepthExceeded 是 61,UnknownIssuer 是 0,于是前者胜出。只要某条候选路径深到 7 层,最终报错就会是它。看到这个错误,先检查锚的名字和链上的签发者名字对不对得上,再考虑链是不是真的太长。
吊销检查在路径里的位置
有 CRL 时,check_signed_chain 每验完一张证书的签名,就调 RevocationOptions::check 查这一张,顺序和签名一样是从锚往下:先查最靠近锚的中间证书,最后查叶子。CRL 自己的签名也要验,同样消耗签名预算。四个开关的含义、默认值和一个由这个顺序导致的、容易误读的报错,见客户端证书与自定义校验。
不信任的公钥表
0.103.16 是在 bb7dcc8(「reject cert paths containing distrusted SPKIs」)之后发布的,这个版本新加了 DISTRUSTED_SPKIS:8 个编译进去的 SPKI(src/data/distrusted/*.spki.der,每个 118 字节),提交说明是「Match SPKI bytes throughout the path, including leaves and trust anchors against a static list of distrusted keys」。build_chain_inner 在处理每张头证书之前、以及每个名字对上的锚,都先比一次这张表,命中就是 CertDistrusted,它的 rank 是 270,和吊销同级。
这张表不经过 ChainOptions 的公开参数,调用方改不了,只能升级 crate。按 SPKI 而不是按证书比对,意味着同一把密钥的交叉签名证书也一并失效(webpki 的测试覆盖了这种情况)。rustls 0.23.46 的 pki_error 没有为它加专门的变体,它和预算错误一样以 Other(OtherError(CertDistrusted)) 出现。依赖 rustls 0.23.46 的项目只要锁文件里的 rustls-webpki 是 0.103.16,就已经带上了这张表;本站的锁文件就是 0.103.16。
动脑筋答案
会变,倒过来发就成功了,只验 11 次签名:
-- same, intermediates sent in reverse (leaf-to-root) order
[signatures 3, build_chain_calls 1, name_constraint_comparisons 0]
n=1: Ok(()) in 132.25µs
...
[signatures 11, build_chain_calls 5, name_constraint_comparisons 0]
n=5: Ok(()) in 536.417µs
[signatures 13, build_chain_calls 6, name_constraint_comparisons 0]
n=6: Ok(()) in 577.416µs
(这几行来自计数版本 verify-count,单次运行;10 次取最小值的计时是 n = 5 486.2 µs、n = 6 577.4 µs。)
- 搜索按
intermediate_certs的顺序试候选,是深度优先。倒过来发时,叶子的真正签发者排在第一个,每一层第一个候选就是对的,n 张中间证书只递归 n 次。 - 每层仍然多花一次签名:头证书的签发者名字和锚一样,
build_chain_inner总是先拿锚试一次,失败后才往下找中间证书。n = 5 是 5 次失败加最后通到锚时整条路径的 6 次,共 11 次。 - 正向发时,每层第一个候选都是错的(离锚最近的那张),错的分支要一直试到深处才失败。同一组证书本该验证通过,只因为顺序不同就撞上了上限。rustls 并不要求服务器按顺序发,trait 文档只说
intermediates「is in the same order that the server sent them」。Budget的注释也承认 100 次「may actually be too aggressive」。
小结
verify_for_usage每次调用新建一份Budget,build_chain_inner深度优先:查头证书 → 试所有锚(名字对上才验整条路径的签名、再查名字约束)→ 试所有中间证书(名字对上、防环、入栈、递归)。- 签发者只按 DER 名字匹配,不看 AKID;签名只在通到锚时从锚往下验。
- 有效期、基本约束、EKU 对叶子和每张中间证书都查,锚不查;EKU 是「有则必须包含」,中间证书的 EKU 也会让服务器证书失败。
- 名字约束在签名通过后查,目录名和 URI 约束一律拒绝,通配符 SAN 在 permitted 约束下按字面比较。
- 三个致命预算:签名 100、递归 200000、名字约束比较 250000;深度上限 6 层中间证书,超出不致命;最终报错按
rank挑最具体的。 - 实测上界:签名预算约 4.5 ms,递归预算约 0.1 s,名字约束约 15 ms;同样的证书换个顺序,可能从失败变成 11 次签名就成功。
- 0.103.16 新加 8 个不信任的 SPKI,在路径上任何位置命中都报
CertDistrusted。