Rust in Everything
基于 rustls-webpki 0.103.16

路径构建

主线位置:校验主线的中段。服务器证书里的 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
        })

按顺序:

  1. 头的 SPKI 在不信任表里,直接失败(见本页最后一节)。
  2. check_issuer_independent_properties:不需要知道签发者就能查的东西,有效期、基本约束、EKU,下一节细讲。
  3. 先试所有信任锚:锚的 subject 和头的 issuer 字节相等才算候选。对上了,check_signed_chain 从锚开始往下把整条路径的签名验一遍(有 CRL 时顺便查吊销),再 check_signed_chain_name_constraints 查名字约束。
  4. 锚都不行,再试服务器发来的每一张中间证书: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,
        }
    }
}
预算上限在哪里消耗超限的错误
签名100verify_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。

📝NOTE
冷知识:签名上限是 2023 年 8 月同一天进三条版本线的

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结果签名递归耗时
1Ok31130.2 µs
2Ok84347.7 µs
3Ok24151.047 ms
4Ok89644.012 ms
5MaximumSignatureChecksExceeded101944.504 ms
6MaximumSignatureChecksExceeded101994.513 ms

n = 1 就已经是 3 次签名而不是 2 次:叶子的签发者名字和锚一样,搜索先拿锚的公钥验叶子,失败,浪费一次;然后才走到中间证书。每多一张同名证书,「名字对上、签名不对」的组合就多出一层。n = 5 时第 101 次验签被拒,耗时封顶在 4.5 ms 左右,约等于 100 次 P-256 验签。

❗IMPORTANT
动脑筋:情形一的 n = 5 失败了。把同样 5 张中间证书倒过来,按「叶 → 根」的顺序发,结果会变吗?要验几次签名?平台同上。答案在文末。

情形二:锚的名字(Bogus Trust Anchor)和链上任何签发者都对不上。 没有任何路径能通到锚,所以一次签名都不验,消耗的全是递归预算:

n结果签名递归耗时
5UnknownIssuer0325317.9 µs
6UnknownIssuer019562.302 ms
7MaximumPathDepthExceeded01369912.06 ms
8MaximumPathDepthExceeded06928045.88 ms
9MaximumPathBuildCallsExceeded0200001107.4 ms
10MaximumPathBuildCallsExceeded020000192.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:

KM叶子大小比较次数结果耗时
1001002408 B10100Ok583 µs
99925023185 B250000Ok14.32 ms
100025023209 B250001MaximumNameConstraintComparisonsExceeded14.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 上。

📝NOTE
没有蠢问题

问: 情形二的 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。

💬 关联讨论

发起讨论
加载中...