Skip to main content

pkix_path_builder/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![forbid(unsafe_code)]
4#![warn(missing_docs, rust_2018_idioms)]
5
6//! RFC 4158 certification path building for [`pkix_path`].
7//!
8//! Accepts an unordered collection of certificates ([`CertPool`]) and
9//! constructs a valid ordered chain suitable for [`pkix_path::validate_path`].
10//!
11//! # Relationship to `pkix-path`
12//!
13//! `pkix-path` validates a caller-ordered `&[Certificate]`. This crate
14//! handles the prior step: discovering and ordering that chain from a bag
15//! of certificates when the caller does not know the chain order in advance.
16//! Cross-certificates and bridge CA topologies are handled here, not in
17//! `pkix-path`.
18//!
19//! # Algorithm
20//!
21//! [`build_path`] and [`build_path_with_config`] perform a single-pass
22//! depth-first search up to [`PathBuilderConfig::max_depth`] (default
23//! [`DEFAULT_MAX_DEPTH`] = 10). At each step, candidates are ranked by
24//! AKI/SKI match tier (RFC 4158 §3.2) so the most-likely issuer is tried
25//! first. Memory is bounded to O(depth) stack frames.
26//!
27//! Shortest-first is **not** guaranteed: for typical pools the first chain
28//! found is the shortest, but adversarial pools may yield a deeper chain
29//! first. See [`PathCandidates`] for the full enumeration contract.
30//!
31//! # Spec references
32//!
33//! - RFC 4158 — Internet X.509 PKI: Certification Path Building
34//! - RFC 5280 §6.1 — the validation algorithm this crate feeds into
35//!
36//! # `no_std`
37//!
38//! This crate is `no_std` but requires the `alloc` crate. The `extern crate alloc`
39//! declaration is provided automatically; you do not need to add it yourself, but
40//! your target must supply a global allocator (e.g., `#[global_allocator]`).
41//!
42//! # Limitations
43//!
44//! - **Caller supplies the candidate set.** [`CertPool`] takes a pool of
45//!   already-loaded certificates. This crate does not fetch missing
46//!   intermediates from `AuthorityInfoAccess` URIs; the optional
47//!   `pkix-aia` / `pkix-aia-http` cascade handles that (tracked under
48//!   `PKIX-zkjb`).
49//! - **Output feeds `pkix-path`.** The validation algorithm (RFC 5280 §6.1
50//!   signature chain walk, name constraints, policy machinery, revocation)
51//!   lives in `pkix-path` and `pkix-revocation`. This crate's job ends
52//!   when it returns an ordered candidate chain.
53//! - **Known residual divergence.** A single bettertls path-building
54//!   corner case (`pathbuilding::tc60`) is documented as a known
55//!   divergence; closing it is a 1.0 release blocker tracked under
56//!   `PKIX-lwr9.4`. See `pkix-difftest/baseline-limbo-analysis.md`.
57
58extern crate alloc;
59
60use alloc::vec::Vec;
61use der::Decode as _;
62use x509_cert::Certificate;
63
64/// An unordered collection of certificates used as input to path building.
65///
66/// Certificates are stored by DER bytes and decoded on demand. Add all
67/// candidate intermediate certificates here; the path builder will select
68/// and order the subset that forms a valid path to a trust anchor.
69///
70/// Note: `Hash` is not derived because `x509_cert::Certificate` does not
71/// currently implement `Hash` (upstream limitation); `CertPool` cannot be
72/// used as a hash-map key until that changes.
73///
74/// Note: `PartialEq`/`Eq` are not derived. `CertPool` is documented as an
75/// unordered bag, so a derived implementation (which compares the internal
76/// `Vec` in insertion order) would be semantically wrong.
77#[derive(Clone, Debug, Default)]
78pub struct CertPool {
79    certs: Vec<Certificate>,
80}
81
82impl CertPool {
83    /// Create an empty pool.
84    #[must_use]
85    pub const fn new() -> Self {
86        Self { certs: Vec::new() }
87    }
88
89    /// Add a certificate to the pool.
90    pub fn add(&mut self, cert: Certificate) {
91        self.certs.push(cert);
92    }
93
94    /// Return the number of certificates in the pool.
95    #[must_use]
96    pub fn len(&self) -> usize {
97        self.certs.len()
98    }
99
100    /// Return `true` if the pool contains no certificates.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.certs.is_empty()
104    }
105
106    /// Iterate over the certificates in the pool.
107    ///
108    /// Equivalent to `(&pool).into_iter()`.
109    pub fn iter(&self) -> core::slice::Iter<'_, x509_cert::Certificate> {
110        self.certs.iter()
111    }
112
113    /// Return the pool contents as a slice.
114    pub(crate) fn as_slice(&self) -> &[Certificate] {
115        &self.certs
116    }
117}
118
119impl FromIterator<Certificate> for CertPool {
120    fn from_iter<I: IntoIterator<Item = Certificate>>(iter: I) -> Self {
121        Self {
122            certs: iter.into_iter().collect(),
123        }
124    }
125}
126
127impl Extend<Certificate> for CertPool {
128    fn extend<I: IntoIterator<Item = Certificate>>(&mut self, iter: I) {
129        self.certs.extend(iter);
130    }
131}
132
133impl<'a> IntoIterator for &'a CertPool {
134    type Item = &'a x509_cert::Certificate;
135    type IntoIter = core::slice::Iter<'a, x509_cert::Certificate>;
136
137    fn into_iter(self) -> Self::IntoIter {
138        self.certs.iter()
139    }
140}
141
142impl IntoIterator for CertPool {
143    type Item = x509_cert::Certificate;
144    type IntoIter = alloc::vec::IntoIter<x509_cert::Certificate>;
145
146    fn into_iter(self) -> Self::IntoIter {
147        self.certs.into_iter()
148    }
149}
150
151/// Errors returned by path building.
152#[derive(Clone, Debug, PartialEq, Eq, Hash)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154#[non_exhaustive]
155pub enum Error {
156    /// No valid path from the target certificate to any trust anchor was found.
157    NoPathFound,
158    /// A topologically valid path exists but requires more intermediates than
159    /// the configured maximum (see [`PathBuilderConfig::max_depth`], default
160    /// [`DEFAULT_MAX_DEPTH`]).
161    DepthExceeded,
162    /// The internal DFS node-visit budget was exhausted.
163    ///
164    /// This guards against adversarial certificate pools that would otherwise
165    /// cause exponential search time. The DFS and the depth probe each start
166    /// with a fresh budget of [`PathBuilderConfig::dfs_budget`] node visits.
167    BudgetExceeded,
168    /// [`build_first_valid_path`] exhausted [`build_path_candidates`] without
169    /// finding a candidate that [`pkix_path::validate_path`] accepted.
170    ///
171    /// At least one topologically valid chain was found by the path builder,
172    /// but every chain was rejected by the verifier or the validation policy
173    /// (e.g., mixed-signature-algorithm cross-signed pools where the DFS
174    /// candidate order picks an algorithm the [`SignatureVerifier`] does not
175    /// dispatch; cross-cert chains where one issuer is expired at the
176    /// validation time; etc.).
177    ///
178    /// `tried` is the count of candidate chains rejected; it is always
179    /// `>= 1` for this variant (zero-yield exhaustion is reported as
180    /// [`Error::NoPathFound`] instead, matching [`build_path`]'s contract).
181    ///
182    /// `last_error` is the [`pkix_path::Error::Display`] rendering of the
183    /// last candidate's validation failure. It is carried as a `String`
184    /// rather than a `pkix_path::Error` so [`Error`] retains its `Hash`
185    /// derive (the upstream error enum does not implement `Hash`). Callers
186    /// that need to programmatically match on the inner error should iterate
187    /// [`build_path_candidates`] directly and call [`pkix_path::validate_path`]
188    /// per candidate themselves.
189    ///
190    /// [`SignatureVerifier`]: pkix_path::SignatureVerifier
191    /// [`pkix_path::Error::Display`]: pkix_path::Error
192    #[non_exhaustive]
193    NoValidPath {
194        /// Number of candidate chains that were tried and rejected.
195        tried: usize,
196        /// `Display` rendering of the last [`pkix_path::Error`] observed.
197        last_error: alloc::string::String,
198    },
199}
200
201impl core::fmt::Display for Error {
202    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
203        match self {
204            Self::NoPathFound => f.write_str("no certification path found to a trust anchor"),
205            Self::DepthExceeded => f.write_str(
206                "configured maximum intermediate chain depth exceeded; the chain may require a deeper path than this builder is configured to attempt",
207            ),
208            Self::BudgetExceeded => f.write_str(
209                "DFS node-visit budget exceeded; pool may be adversarially large",
210            ),
211            Self::NoValidPath { tried, last_error } => write!(
212                f,
213                "tried {tried} candidate path(s); none validated. Last validation error: {last_error}"
214            ),
215        }
216    }
217}
218
219#[cfg(feature = "std")]
220impl std::error::Error for Error {}
221
222/// Result alias for this crate.
223pub type Result<T> = core::result::Result<T, Error>;
224
225/// Returns `true` if `cert` has `BasicConstraints` with `cA = TRUE`,
226/// `false` if the extension is absent, has `cA = FALSE`, or cannot be
227/// DER-decoded.
228///
229/// Malformed `BasicConstraints` is treated as `false` (skip-not-fail):
230/// a single malformed certificate in a CMS `SignedData.certificates` bag
231/// must not poison verification of an otherwise-valid chain.
232fn cert_is_ca(cert: &Certificate) -> bool {
233    pkix_path::cert_is_ca(cert).unwrap_or(false)
234}
235
236/// OID `id-ce-authorityKeyIdentifier` (RFC 5280 §4.2.1.1).
237const OID_AUTHORITY_KEY_IDENTIFIER: der::asn1::ObjectIdentifier =
238    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.35");
239
240/// OID `id-ce-subjectKeyIdentifier` (RFC 5280 §4.2.1.2).
241const OID_SUBJECT_KEY_IDENTIFIER: der::asn1::ObjectIdentifier =
242    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.14");
243
244/// Return the bytes of `cert`'s `AuthorityKeyIdentifier::keyIdentifier`
245/// extension, or `None` if the extension is absent, the `keyIdentifier`
246/// field is absent, or the extension cannot be DER-decoded.
247///
248/// **Fail-soft semantics**: a malformed AKI is treated as if absent rather
249/// than propagated as an error. The AKI keyIdentifier is used purely as
250/// an *ordering heuristic* for candidate selection; it is not a security
251/// gate (the actual signature check happens downstream in
252/// [`pkix_path::validate_path`]). A malformed AKI on the target should
253/// degrade builder selection to DN-only ranking, not abort path building.
254///
255/// RFC 5280 §4.2.1.1: AKI's `keyIdentifier` is normally the SHA-1 hash of
256/// the issuer's `subjectPublicKey` BIT STRING (method 1). This is compared
257/// byte-for-byte against candidate certs' `SubjectKeyIdentifier`; we do
258/// not recompute hashes here — only opaque-byte equality matters.
259///
260/// **Note:** Returns `Vec<u8>` (owned) rather than `&[u8]` because
261/// `AuthorityKeyIdentifier::from_der` produces an owned intermediate
262/// whose lifetime cannot be tied to the input `cert` reference; the
263/// inner `OctetString` bytes do not borrow from the cert's DER.
264fn cert_aki_key_id(cert: &Certificate) -> Option<Vec<u8>> {
265    use x509_cert::ext::pkix::AuthorityKeyIdentifier;
266
267    let extns = cert.tbs_certificate.extensions.as_deref()?;
268    let extn = extns
269        .iter()
270        .find(|e| e.extn_id == OID_AUTHORITY_KEY_IDENTIFIER)?;
271    let aki = AuthorityKeyIdentifier::from_der(extn.extn_value.as_bytes()).ok()?;
272    aki.key_identifier.map(|oct| oct.as_bytes().to_vec())
273}
274
275/// Return the bytes of `cert`'s `SubjectKeyIdentifier` extension, or
276/// `None` if the extension is absent or cannot be DER-decoded.
277///
278/// **Fail-soft semantics**: see [`cert_aki_key_id`] for rationale. A cert
279/// without a parseable SKI ranks below SKI-bearing candidates in the
280/// AKI-matching tier but is still considered for the DN-only fallback
281/// tier.
282///
283/// RFC 5280 §4.2.1.2: SKI is conventionally the SHA-1 hash of the cert's
284/// own `subjectPublicKey` BIT STRING; we do not recompute, we only return
285/// the bytes the cert claims.
286///
287/// **Note:** Returns `Vec<u8>` (owned) for the same reason as
288/// [`cert_aki_key_id`]: `SubjectKeyIdentifier::from_der` produces an
289/// owned `OctetString` that does not borrow from the cert's DER.
290fn cert_ski_key_id(cert: &Certificate) -> Option<Vec<u8>> {
291    use x509_cert::ext::pkix::SubjectKeyIdentifier;
292
293    let extns = cert.tbs_certificate.extensions.as_deref()?;
294    let extn = extns
295        .iter()
296        .find(|e| e.extn_id == OID_SUBJECT_KEY_IDENTIFIER)?;
297    let ski = SubjectKeyIdentifier::from_der(extn.extn_value.as_bytes()).ok()?;
298    Some(ski.0.as_bytes().to_vec())
299}
300
301/// Compute the DN-matching candidates of `cur` from `pool`, ordered by
302/// AKI/SKI matching tier (RFC 5280 §4.2.1.1, RFC 4158 §3.2).
303///
304/// Returns a vector of `(tier, pool_index)` pairs:
305///
306/// - **Tier 0**: candidate's `SubjectKeyIdentifier` matches `cur`'s
307///   `AuthorityKeyIdentifier.keyIdentifier`. This is the §4.2.1.1 method-1
308///   disambiguator: in bridge-CA and key-rollover topologies, multiple CA
309///   certs share an issuer DN; AKI/SKI is the only deterministic way to
310///   pick the cert that actually signed `cur`.
311/// - **Tier 1**: any DN-matching candidate. Used when `cur` has no AKI,
312///   no candidate SKI matches, or AKI/SKI parsing failed (fail-soft — see
313///   [`cert_aki_key_id`]/[`cert_ski_key_id`]).
314///
315/// The result is sorted **stably** by tier so candidates within the same
316/// tier preserve pool insertion order. This is the documented contract for
317/// the no-AKI-signal case.
318///
319/// **Not currently used:** the AKI `authorityCertIssuer` /
320/// `authorityCertSerialNumber` fields. They are rare in practice and
321/// parsing `GeneralNames` for that signal is more work than the marginal
322/// disambiguation benefit justifies. Documented as a deferred enhancement.
323fn rank_candidates(cur: &Certificate, pool: &[Certificate]) -> Vec<(u8, usize)> {
324    let cur_issuer = &cur.tbs_certificate.issuer;
325    let target_aki_kid = cert_aki_key_id(cur);
326    let mut ranked: Vec<(u8, usize)> = Vec::with_capacity(pool.len());
327    for (idx, candidate) in pool.iter().enumerate() {
328        if !pkix_path::names_match(&candidate.tbs_certificate.subject, cur_issuer) {
329            continue;
330        }
331        let tier: u8 = match (
332            target_aki_kid.as_deref(),
333            cert_ski_key_id(candidate).as_deref(),
334        ) {
335            (Some(aki), Some(ski)) if aki == ski => 0,
336            _ => 1,
337        };
338        ranked.push((tier, idx));
339    }
340    ranked.sort_by_key(|&(tier, _)| tier);
341    ranked
342}
343
344/// SPKI-based cycle detection: does `path` already contain a cert with the
345/// same `SubjectPublicKeyInfo` algorithm OID and raw public-key bits as
346/// `candidate`?
347///
348/// Algorithm parameters are deliberately excluded from the comparison to
349/// tolerate the RFC 8017 ambiguity between absent and explicit-NULL
350/// `parameters` in rsaEncryption SPKIs (one cert may encode
351/// `AlgorithmIdentifier { oid: rsaEncryption, params: NULL }` while another
352/// encodes the same key with `params: absent`; both represent the same
353/// public key). DN-based cycle detection is intentionally NOT used: in
354/// key-rollover or bridge-CA topologies multiple certs may share a subject
355/// DN with different keys, and treating them as the same node would
356/// incorrectly prune valid paths.
357fn spki_already_in_path(candidate: &Certificate, path: &[Certificate]) -> bool {
358    let candidate_spki = &candidate.tbs_certificate.subject_public_key_info;
359    path.iter().any(|in_path| {
360        let s = &in_path.tbs_certificate.subject_public_key_info;
361        s.algorithm.oid == candidate_spki.algorithm.oid
362            && s.subject_public_key == candidate_spki.subject_public_key
363    })
364}
365
366/// Default DFS node-visit budget per search pass.
367///
368/// Sufficient for legitimate chains (real-world PKI hierarchies have at most
369/// a handful of intermediates and small pools); prevents exponential blow-up
370/// against adversarially constructed pools of O(N) CA certificates with
371/// identical subject/issuer names.
372pub const DEFAULT_DFS_BUDGET: usize = 10_000;
373
374/// Default maximum number of intermediate certificates considered.
375pub const DEFAULT_MAX_DEPTH: usize = 10;
376
377/// Tunable parameters for path building.
378///
379/// Use [`PathBuilderConfig::default`] (or [`PathBuilderConfig::new`]) for the
380/// production defaults. Embedded callers, callers with restricted compute,
381/// and callers handling adversarial pools can tighten these values.
382///
383/// # Stability
384///
385/// Constructed via [`PathBuilderConfig::new`] / `Default`; the struct is
386/// `#[non_exhaustive]` so additional knobs can be added without breaking
387/// existing callers.
388#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
389#[non_exhaustive]
390pub struct PathBuilderConfig {
391    /// Maximum number of intermediates to explore. The depth probe runs at
392    /// `max_depth + 1` to distinguish "no path exists" from "path exists
393    /// but too deep". Default: [`DEFAULT_MAX_DEPTH`].
394    pub max_depth: usize,
395    /// Per-round node-visit budget. Default: [`DEFAULT_DFS_BUDGET`].
396    pub dfs_budget: usize,
397}
398
399impl PathBuilderConfig {
400    /// Construct a config with all knobs set to their default values.
401    #[must_use]
402    pub const fn new() -> Self {
403        Self {
404            max_depth: DEFAULT_MAX_DEPTH,
405            dfs_budget: DEFAULT_DFS_BUDGET,
406        }
407    }
408}
409
410impl Default for PathBuilderConfig {
411    fn default() -> Self {
412        Self::new()
413    }
414}
415
416// =========================================================================
417// PathCandidates iterator (PKIX-mszo)
418// =========================================================================
419
420/// Per-frame DFS state held by [`PathCandidates`].
421///
422/// Each [`Frame`] mirrors one stack frame of the recursive DFS: it holds
423/// the AKI-ranked candidate list for the cert at this depth and a cursor
424/// into that list, plus state-machine flags so a paused-and-resumed DFS
425/// can pick up where it left off without re-running the anchor check or
426/// re-yielding the same chain twice.
427struct Frame {
428    /// Pre-ranked candidate indices (tier, pool index). Stable-sorted
429    /// by tier, lower-tier first. Computed lazily on first use so that
430    /// frames that yield via anchor match never pay the ranking cost.
431    ranked: Option<Vec<(u8, usize)>>,
432    /// Position in `ranked` to try next.
433    cursor: usize,
434    /// True after the anchor-match check has run for this frame.
435    anchor_checked: bool,
436    /// True if this frame yielded a chain via anchor match. On the next
437    /// `next()` call, that frame is immediately backtracked rather than
438    /// trying its candidates (the recursive DFS short-circuits on anchor
439    /// match; the iterator preserves that semantic).
440    anchor_yielded: bool,
441}
442
443impl Frame {
444    const fn new() -> Self {
445        Self {
446            ranked: None,
447            cursor: 0,
448            anchor_checked: false,
449            anchor_yielded: false,
450        }
451    }
452}
453
454/// Iterator over topologically-valid certification paths from a target
455/// cert through a candidate pool to one of a set of trust anchors.
456///
457/// Each [`Iterator::next`] call returns either:
458/// - `Some(Ok(chain))` — the next leaf-first chain `[target, ...,
459///   anchor-issued]` that is topologically valid (DN chain links,
460///   `BasicConstraints cA=TRUE` on every intermediate, no SPKI cycles).
461///   Signatures are NOT verified; downstream callers must run the
462///   returned chain through [`pkix_path::validate_path`].
463/// - `Some(Err(e))` — a fatal error (see [`Error`]). The iterator is
464///   exhausted; subsequent calls return `None`.
465/// - `None` — DFS has been exhausted; no more chains exist within the
466///   configured `max_depth`.
467///
468/// **Resumable DFS**: candidates are explored in AKI-ranked
469/// order (`rank_candidates`); when a chain is yielded, the next call
470/// resumes from the same DFS state and explores alternate paths. This
471/// is the contract S/MIME callers depend on for build-then-validate
472/// retry loops in adversarial pools (CMS bags, federal-bridge cross-cert
473/// topologies, etc.) where the topologically-first chain may not be the
474/// cryptographically-verifying one.
475///
476/// **Bounded enumeration**: a single shared budget (initial value
477/// [`PathBuilderConfig::dfs_budget`]) is decremented once per DFS frame
478/// entry across all `next()` calls. When the budget is exhausted, the
479/// next call returns `Some(Err(`[`Error::BudgetExceeded`]`))` and the
480/// iterator becomes exhausted. This bounds worst-case work to
481/// `O(dfs_budget)` total across the entire iterator's lifetime,
482/// preventing an adversarial pool from causing unbounded enumeration.
483///
484/// **No iterative deepening**: unlike legacy `build_path`, this iterator
485/// performs a single DFS at `max_depth`. Paths are yielded in DFS order
486/// (depth-first, AKI-tier-ordered, then pool insertion order within a
487/// tier). Shortest-first is no longer guaranteed; for typical pools the
488/// first yielded chain is still the shortest, but adversarial pools can
489/// produce a deeper chain first if its branch is explored before a
490/// shallower alternative.
491///
492/// # Examples
493///
494/// Build-then-validate retry loop:
495///
496/// ```ignore
497/// let mut candidates = pkix_path_builder::build_path_candidates(
498///     &target, &pool, &anchors,
499/// );
500/// loop {
501///     match candidates.next() {
502///         None => break Err(NoVerifiableChain),
503///         Some(Err(e)) => break Err(e.into()),
504///         Some(Ok(chain)) => match pkix_path::validate_path(
505///             &chain, &anchors, &policy, &verifier,
506///         ) {
507///             Ok(vp) => break Ok(vp),
508///             Err(_) => continue,  // try next candidate
509///         },
510///     }
511/// }
512/// ```
513///
514/// The pattern above is exactly what [`build_first_valid_path`] wraps:
515/// callers that only need "find any chain that validates" should prefer
516/// the helper. Drop down to this iterator when you need per-candidate
517/// diagnostics, want to limit the number of candidates tried, or are
518/// composing the retry loop with additional per-candidate policy
519/// (e.g., per-candidate revocation checks).
520pub struct PathCandidates<'a> {
521    pool: &'a [Certificate],
522    anchors: &'a [pkix_path::TrustAnchor],
523    max_depth: usize,
524    /// Current chain, leaf-first. `path[0]` is the target.
525    path: Vec<Certificate>,
526    /// Frame stack; `frames.len() == path.len()` while the iterator is
527    /// active. When both are empty after `started` was true, the
528    /// iterator is exhausted.
529    frames: Vec<Frame>,
530    /// Shared DFS-frame-entry budget. Decremented once per anchor check
531    /// (one charge per frame entered). Bounded by the configured budget
532    /// across all `next()` calls.
533    budget: usize,
534    /// True after the first `next()` call. Used to lazily push the
535    /// initial frame.
536    started: bool,
537    /// True once the iterator has yielded a fatal error or exhausted
538    /// the search space; subsequent calls return `None`.
539    done: bool,
540}
541
542impl<'a> PathCandidates<'a> {
543    /// Construct a new path-candidate iterator.
544    ///
545    /// The `target`, `pool`, and `anchors` references are borrowed for
546    /// the lifetime of the iterator; `config` is read once at construction
547    /// (its `max_depth` and `dfs_budget` values are copied in).
548    fn new(
549        target: &Certificate,
550        pool: &'a [Certificate],
551        anchors: &'a [pkix_path::TrustAnchor],
552        config: &PathBuilderConfig,
553    ) -> Self {
554        // Pre-seed `path` with the target and `frames` with the initial
555        // frame. This avoids an extra branch in `next()` for the
556        // first-call case and keeps the invariant `path.len() ==
557        // frames.len()` true at all times when the iterator is active.
558        let path = alloc::vec![target.clone()];
559        let frames = alloc::vec![Frame::new()];
560        Self {
561            pool,
562            anchors,
563            max_depth: config.max_depth,
564            path,
565            frames,
566            budget: config.dfs_budget,
567            started: false,
568            done: false,
569        }
570    }
571}
572
573impl<'a> Iterator for PathCandidates<'a> {
574    type Item = Result<Vec<Certificate>>;
575
576    fn next(&mut self) -> Option<Self::Item> {
577        if self.done {
578            return None;
579        }
580
581        // The first call to `next()` finds the first chain (or exhausts
582        // the search). `started` lets us distinguish "iterator just
583        // constructed" (path/frames pre-seeded with the initial target
584        // frame) from "iterator was previously called and yielded a
585        // chain" (resume from the yielded frame).
586        //
587        // When resuming after a yield, the top frame's `anchor_yielded`
588        // flag is true; the loop below will see this and immediately
589        // backtrack from that frame, advancing the parent's candidate
590        // cursor on the next iteration.
591        self.started = true;
592
593        loop {
594            // Empty stack: search space exhausted.
595            if self.frames.is_empty() {
596                self.done = true;
597                return None;
598            }
599
600            // If the top frame already yielded an anchor match on a
601            // prior call, backtrack from it now (the recursive DFS
602            // short-circuits on anchor match without exploring
603            // candidates; the iterator preserves that semantic).
604            if self.frames.last().expect("non-empty").anchor_yielded {
605                self.frames.pop();
606                self.path.pop();
607                continue;
608            }
609
610            // Anchor check: each frame charges 1 unit of budget at this
611            // point (mirrors the recursive DFS's per-call decrement).
612            // Budget exhaustion makes the iterator terminal.
613            if !self.frames.last().expect("non-empty").anchor_checked {
614                if self.budget == 0 {
615                    self.done = true;
616                    return Some(Err(Error::BudgetExceeded));
617                }
618                self.budget -= 1;
619                self.frames.last_mut().expect("non-empty").anchor_checked = true;
620
621                // Read the issuer DN of the cert at the top of the path
622                // — that is the cert this frame is seeking an issuer
623                // for. The path mirrors frames; path.last() corresponds
624                // to frames.last() at all times.
625                let cur_issuer = &self
626                    .path
627                    .last()
628                    .expect("path mirrors frames; non-empty")
629                    .tbs_certificate
630                    .issuer;
631                let matched = self
632                    .anchors
633                    .iter()
634                    .any(|a| pkix_path::names_match(&a.subject, cur_issuer));
635                if matched {
636                    self.frames.last_mut().expect("non-empty").anchor_yielded = true;
637                    return Some(Ok(self.path.clone()));
638                }
639            }
640
641            // Past the anchor check. If this frame is at or beyond the
642            // depth limit, it cannot host any further intermediate
643            // candidates — backtrack. The recursive DFS's
644            // `if depth_remaining == 0 { return Ok(false); }` clause
645            // corresponds to this gate: a frame exists at every depth
646            // up to and including `max_depth + 1` (the deepest frame
647            // performs anchor check then returns immediately).
648            if self.frames.len() > self.max_depth {
649                self.frames.pop();
650                self.path.pop();
651                continue;
652            }
653
654            // Compute candidate ranking lazily — only frames that
655            // survive the anchor check pay the cost.
656            if self.frames.last().expect("non-empty").ranked.is_none() {
657                let cur = self.path.last().expect("non-empty");
658                let ranked = rank_candidates(cur, self.pool);
659                self.frames.last_mut().expect("non-empty").ranked = Some(ranked);
660            }
661
662            // Pull the next candidate index, or backtrack if exhausted.
663            let frame = self.frames.last_mut().expect("non-empty");
664            let ranked = frame.ranked.as_ref().expect("set above");
665            if frame.cursor >= ranked.len() {
666                // No more candidates: backtrack.
667                self.frames.pop();
668                self.path.pop();
669                continue;
670            }
671            let (_tier, idx) = ranked[frame.cursor];
672            frame.cursor += 1;
673
674            let candidate = &self.pool[idx];
675
676            // CA check (BasicConstraints cA=TRUE). Skip-not-fail: a
677            // candidate whose `BasicConstraints` is absent, has
678            // `cA = FALSE`, or fails to DER-decode is treated as
679            // "not a CA" and silently skipped. This keeps DFS alive
680            // when the certificate pool carries unsolicited or corrupt
681            // certs — e.g. CMS `SignedData.certificates` bags routinely
682            // include certs the verifier did not solicit (other
683            // recipients in a multi-recipient message, intermediates
684            // from unrelated CAs that rode along, expired or corrupt
685            // certs from someone's pipeline). One bad cert in the bag
686            // must not poison verification of an otherwise-valid chain.
687            //
688            // The error itself is not lost: when no path can be built
689            // and skipping malformed candidates is what prevented one,
690            // `build_path` returns `Error::NoPathFound`, indistinguishable
691            // from any other no-path case. Callers that want diagnostic
692            // detail ("why didn't this path build?") need a future
693            // diagnostic mode; that is out of scope for skip-not-fail.
694            if !cert_is_ca(candidate) {
695                continue;
696            }
697
698            // SPKI cycle guard.
699            if spki_already_in_path(candidate, &self.path) {
700                continue;
701            }
702
703            // Eligible: push the candidate onto path/frames and let the
704            // outer loop iterate, processing the new top frame.
705            self.path.push(candidate.clone());
706            self.frames.push(Frame::new());
707        }
708    }
709}
710
711/// Construct a [`PathCandidates`] iterator using the workspace defaults
712/// ([`DEFAULT_MAX_DEPTH`], [`DEFAULT_DFS_BUDGET`]).
713///
714/// See [`PathCandidates`] for usage and semantics.
715#[must_use]
716pub fn build_path_candidates<'a>(
717    target: &Certificate,
718    pool: &'a CertPool,
719    anchors: &'a [pkix_path::TrustAnchor],
720) -> PathCandidates<'a> {
721    PathCandidates::new(target, pool.as_slice(), anchors, &PathBuilderConfig::new())
722}
723
724/// Construct a [`PathCandidates`] iterator with caller-provided budget
725/// and depth tunables.
726///
727/// See [`PathCandidates`] for usage and semantics, and
728/// [`PathBuilderConfig`] for the individual knobs.
729#[must_use]
730pub fn build_path_candidates_with_config<'a>(
731    target: &Certificate,
732    pool: &'a CertPool,
733    anchors: &'a [pkix_path::TrustAnchor],
734    config: &PathBuilderConfig,
735) -> PathCandidates<'a> {
736    PathCandidates::new(target, pool.as_slice(), anchors, config)
737}
738
739// =========================================================================
740// build_path / build_path_with_config — single-shot wrappers
741// =========================================================================
742
743/// Build a certification path from `target` through certificates in `pool`
744/// to one of the provided trust anchors.
745///
746/// Returns the ordered chain `[target, intermediate..., anchor-issued]` ready
747/// for [`pkix_path::validate_path`]. Signatures are **not** verified here;
748/// that is the responsibility of the caller via [`pkix_path::validate_path`].
749///
750/// # Algorithm
751///
752/// Single-pass depth-first search at the configured `max_depth`. Candidates
753/// at each frame are ordered by AKI/SKI tier (RFC 5280 §4.2.1.1) so
754/// disambiguating bridge-CA / cross-cert topologies succeeds on the first
755/// candidate when AKI/SKI bindings are well-formed. Cycles are detected by
756/// `SubjectPublicKeyInfo` algorithm OID + raw public-key bits; algorithm
757/// parameters are excluded so RFC 8017 absent-vs-NULL ambiguity in
758/// rsaEncryption SPKIs does not break detection.
759///
760/// This is a thin wrapper over the [`PathCandidates`] iterator: it returns
761/// the iterator's first yield, or invokes a depth+1 probe (with fresh
762/// budget) on `None` to distinguish [`Error::NoPathFound`] from
763/// [`Error::DepthExceeded`].
764///
765/// # Errors
766///
767/// - [`Error::NoPathFound`] — no topologically valid path through `pool` leads
768///   to any of the given trust anchors.
769/// - [`Error::DepthExceeded`] — a path exists topologically but requires more
770///   than [`PathBuilderConfig::max_depth`] intermediate certificates.
771/// - [`Error::BudgetExceeded`] — the DFS frame-entry budget was exhausted
772///   before a path was found; the pool may be adversarially large or
773///   structured to produce exponential search.
774///
775/// # Choosing between `build_path`, the iterator, and `build_first_valid_path`
776///
777/// Use this single-shot API when:
778/// - the pool is from a trusted source (in-house cert store, configured
779///   intermediate bundle), and
780/// - finding any topologically valid chain is sufficient (the caller does
781///   not need to retry with alternate chains if signature verification
782///   fails downstream).
783///
784/// Use [`build_path_candidates`] (or its `_with_config` sibling) when you
785/// want full control over candidate iteration — for adversarial pools (CMS
786/// `SignedData.certificates` bags, federal-bridge cross-cert topologies,
787/// anywhere the wire-order of certs is not under your control) so failed
788/// signature verification can be retried against the next candidate path.
789/// See [`PathCandidates`] for the build-then-validate retry-loop pattern.
790///
791/// Use [`build_first_valid_path`] (or its `_with_config` sibling) for the
792/// common case of "iterate candidates until one validates": it wraps the
793/// iterator + [`pkix_path::validate_path`] retry loop and returns the
794/// first chain that survives both topological build and signature
795/// verification. Prefer this over `build_path` when the pool contains
796/// alternatives whose signatures may be rejected by the verifier (e.g.,
797/// cross-signed intermediates using algorithms outside the verifier's
798/// dispatch table).
799///
800/// # Limitations
801///
802/// **Candidate selection uses AKI/SKI as an ordering heuristic, not a
803/// security gate.** When the cert seeking an issuer carries an
804/// `AuthorityKeyIdentifier` extension with a `keyIdentifier` field
805/// (RFC 5280 §4.2.1.1), pool candidates whose `SubjectKeyIdentifier`
806/// (§4.2.1.2) matches are tried before DN-only matches. This is
807/// best-effort disambiguation for bridge-CA and key-rollover topologies
808/// where multiple CA certs share an issuer DN. The signature itself is
809/// **not** verified by this crate — that happens downstream in
810/// [`pkix_path::validate_path`]. Consequences:
811///
812/// - When the AKI heuristic picks the wrong candidate (e.g., AKI is
813///   absent or malformed, multiple candidates share the same SKI, or
814///   the AKI/SKI binding is wrong), the returned chain may fail
815///   `validate_path` with `SignatureInvalid` rather than
816///   [`Error::NoPathFound`] here. Callers handling adversarial pools
817///   should use [`build_path_candidates`] to retry alternate chains.
818/// - Malformed AKI or SKI extensions are treated as if absent (fail-soft).
819///   They do not cause path building to abort; they simply degrade
820///   selection to DN-only ranking for that cert.
821/// - The AKI `authorityCertIssuer` + `authorityCertSerialNumber` fields
822///   (the rare alternative to `keyIdentifier`) are not currently used for
823///   ranking. Only the `keyIdentifier` field participates.
824///
825/// **Anchor matching is by DN only.** When a candidate's issuer DN matches
826/// any anchor in `anchors`, path building terminates immediately with that
827/// chain — the anchor's `SubjectPublicKeyInfo` is **not** verified against
828/// what the chain expects.
829///
830/// **Shortest-first is no longer guaranteed.** Earlier versions of this
831/// crate used iterative-deepening DFS to return the shortest topology
832/// first. The single-pass DFS used now (which shares state with the
833/// `PathCandidates` iterator) yields paths in depth-first order. For
834/// typical pools the first yielded chain is still the shortest; for
835/// adversarial pools, a deeper chain may be returned first if its branch
836/// is explored before a shallower alternative. If shortest-first matters,
837/// inspect the returned chain length and (rarely) re-run with a tightened
838/// `max_depth`.
839///
840/// # Security
841///
842/// Pool contents should be from a trusted source. The DFS frame-entry
843/// budget enforces a hard cap on search work to prevent denial-of-service
844/// via oversized or crafted pools.
845pub fn build_path(
846    target: &Certificate,
847    pool: &CertPool,
848    anchors: &[pkix_path::TrustAnchor],
849) -> Result<Vec<Certificate>> {
850    build_path_with_config(target, pool, anchors, &PathBuilderConfig::new())
851}
852
853/// Build a certification path with caller-provided budget and depth tunables.
854///
855/// Behaves identically to [`build_path`] but uses the limits in `config`
856/// instead of the workspace defaults. See [`PathBuilderConfig`] for the
857/// individual knobs and [`build_path`] for full semantics.
858///
859/// # Errors
860///
861/// Same as [`build_path`].
862pub fn build_path_with_config(
863    target: &Certificate,
864    pool: &CertPool,
865    anchors: &[pkix_path::TrustAnchor],
866    config: &PathBuilderConfig,
867) -> Result<Vec<Certificate>> {
868    let pool_slice = pool.as_slice();
869
870    // First pass at the configured max_depth.
871    let mut iter = PathCandidates::new(target, pool_slice, anchors, config);
872    match iter.next() {
873        Some(Ok(chain)) => return Ok(chain),
874        Some(Err(e)) => return Err(e),
875        None => {}
876    }
877
878    // Iterator exhausted at max_depth. Probe at max_depth+1 to distinguish
879    // NoPathFound from DepthExceeded. The probe gets a fresh budget (it is
880    // a brand-new PathCandidates), matching the legacy iterative-deepening
881    // probe's behaviour.
882    let probe_config = PathBuilderConfig {
883        max_depth: config.max_depth.saturating_add(1),
884        dfs_budget: config.dfs_budget,
885    };
886    let mut probe = PathCandidates::new(target, pool_slice, anchors, &probe_config);
887    match probe.next() {
888        Some(Ok(_)) => Err(Error::DepthExceeded),
889        Some(Err(e)) => Err(e),
890        None => Err(Error::NoPathFound),
891    }
892}
893
894// =========================================================================
895// build_first_valid_path — candidate iteration + signature verification
896// =========================================================================
897
898/// Build a certification path that both **(a)** is topologically valid through
899/// `pool` to one of `anchors` and **(b)** passes
900/// [`pkix_path::validate_path`] under `policy` and `verifier`.
901///
902/// Iterates [`build_path_candidates`] until the first candidate chain
903/// validates. Returns the validating chain. If every candidate is rejected
904/// by `validate_path`, returns [`Error::NoValidPath`] carrying the count of
905/// candidates tried and the `Display` rendering of the last
906/// [`pkix_path::Error`].
907///
908/// # When to use this over [`build_path`]
909///
910/// [`build_path`] is single-shot: it returns the first DFS candidate without
911/// any knowledge of which signature algorithms `verifier` actually dispatches
912/// or which intermediates are within their validity window at `policy`'s
913/// validation time. In adversarial pools — for example, cross-signed graphs
914/// that include an alternative intermediate signed under
915/// `ecdsa-with-SHA1` (RFC 5758 §3.2 legacy OID, not dispatched by
916/// [`pkix_path::DefaultVerifier`]) — the first DFS yield can be rejected by
917/// `validate_path` even though a SHA-256-only path exists in the same pool.
918///
919/// `build_first_valid_path` closes this gap: it iterates
920/// [`build_path_candidates`] and tries `validate_path` per yielded chain,
921/// returning the first chain that survives both passes.
922///
923/// # Errors
924///
925/// - [`Error::NoPathFound`] — the underlying iterator yielded no candidates
926///   at all (no topologically valid chain through `pool` to any anchor).
927///   Matches [`build_path`]'s behaviour for that case.
928/// - [`Error::DepthExceeded`] / [`Error::BudgetExceeded`] — propagated from
929///   [`build_path_candidates`] when the iterator surfaces them.
930/// - [`Error::NoValidPath`] — at least one candidate was yielded but none
931///   passed `validate_path`. Carries `tried` (>= 1) and the last
932///   `validate_path` error rendering.
933///
934/// # Out of scope
935///
936/// - Async / parallel candidate evaluation. Candidates are tried sequentially.
937/// - Caching of `validate_path` failures across candidates. Each yielded
938///   chain is freshly validated.
939/// - Promoting [`build_path`] itself to iterate. The single-shot helper is
940///   retained verbatim for backward compatibility; callers opt in to the
941///   iterating semantics by using this function.
942///
943/// # Relationship to other path-builder entry points
944///
945/// | Entry point                  | Verifier? | Returns                                      |
946/// |------------------------------|-----------|----------------------------------------------|
947/// | [`build_path`]               | No        | First DFS topological candidate (one-shot)   |
948/// | [`build_path_candidates`]    | No        | Iterator of topological candidates           |
949/// | [`build_first_valid_path`]   | Yes       | First candidate that passes `validate_path`  |
950pub fn build_first_valid_path<V>(
951    target: &Certificate,
952    pool: &CertPool,
953    anchors: &[pkix_path::TrustAnchor],
954    policy: &pkix_path::ValidationPolicy,
955    verifier: &V,
956) -> Result<Vec<Certificate>>
957where
958    V: pkix_path::SignatureVerifier,
959{
960    build_first_valid_path_with_config(
961        target,
962        pool,
963        anchors,
964        policy,
965        verifier,
966        &PathBuilderConfig::new(),
967    )
968}
969
970/// Build a verifier-validated certification path with caller-provided
971/// budget and depth tunables.
972///
973/// Behaves identically to [`build_first_valid_path`] but uses the limits
974/// in `config` instead of the workspace defaults. See
975/// [`PathBuilderConfig`] for the individual knobs and
976/// [`build_first_valid_path`] for full semantics.
977///
978/// # Errors
979///
980/// Same as [`build_first_valid_path`].
981pub fn build_first_valid_path_with_config<V>(
982    target: &Certificate,
983    pool: &CertPool,
984    anchors: &[pkix_path::TrustAnchor],
985    policy: &pkix_path::ValidationPolicy,
986    verifier: &V,
987    config: &PathBuilderConfig,
988) -> Result<Vec<Certificate>>
989where
990    V: pkix_path::SignatureVerifier,
991{
992    let mut iter = PathCandidates::new(target, pool.as_slice(), anchors, config);
993    let mut tried: usize = 0;
994    let mut last_error: Option<alloc::string::String> = None;
995
996    loop {
997        match iter.next() {
998            Some(Ok(chain)) => {
999                match pkix_path::validate_path(&chain, anchors, policy, verifier) {
1000                    Ok(_) => return Ok(chain),
1001                    Err(e) => {
1002                        tried += 1;
1003                        last_error = Some(alloc::format!("{e}"));
1004                        // Continue to the next candidate.
1005                    }
1006                }
1007            }
1008            // The iterator surfaces its own errors (BudgetExceeded; in
1009            // principle future variants). Propagate verbatim. We do NOT
1010            // wrap these in NoValidPath because they describe failures
1011            // of the topological search, not of signature verification.
1012            Some(Err(e)) => return Err(e),
1013            // Iterator exhausted.
1014            None => {
1015                return match last_error {
1016                    // At least one candidate was yielded; every one was
1017                    // rejected by validate_path.
1018                    Some(last) => Err(Error::NoValidPath {
1019                        tried,
1020                        last_error: last,
1021                    }),
1022                    // Zero candidates ever yielded. Distinguish
1023                    // NoPathFound from DepthExceeded the same way
1024                    // build_path does: probe at max_depth + 1.
1025                    None => {
1026                        let probe_config = PathBuilderConfig {
1027                            max_depth: config.max_depth.saturating_add(1),
1028                            dfs_budget: config.dfs_budget,
1029                        };
1030                        let mut probe =
1031                            PathCandidates::new(target, pool.as_slice(), anchors, &probe_config);
1032                        match probe.next() {
1033                            Some(Ok(_)) => Err(Error::DepthExceeded),
1034                            Some(Err(e)) => Err(e),
1035                            None => Err(Error::NoPathFound),
1036                        }
1037                    }
1038                };
1039            }
1040        }
1041    }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    //! Unit tests for the private AKI/SKI extraction helpers.
1047    //!
1048    //! Independent oracle: byte values were derived by running
1049    //! `openssl x509 -text` on the PKITS DER fixtures and pasting the
1050    //! displayed `Authority Key Identifier` / `Subject Key Identifier`
1051    //! hex bytes into the test expectations. The helpers are *not* used
1052    //! to compute the expected values — they are checked against the
1053    //! external openssl-derived ground truth.
1054    #[allow(unused_extern_crates)] // load-bearing under #![no_std] default features
1055    extern crate std;
1056
1057    use super::{cert_aki_key_id, cert_ski_key_id};
1058    use der::Decode as _;
1059    use std::path::PathBuf;
1060    use x509_cert::Certificate;
1061
1062    fn pkits_cert(name: &str) -> Certificate {
1063        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1064            .join("../pkix-path/tests/pkits/certs")
1065            .join(std::format!("{name}.crt"));
1066        let bytes = std::fs::read(&path)
1067            .unwrap_or_else(|e| std::panic!("fixture not found at {}: {}", path.display(), e));
1068        Certificate::from_der(&bytes).unwrap_or_else(|e| std::panic!("failed to parse {name}: {e}"))
1069    }
1070
1071    #[test]
1072    fn cert_aki_key_id_test4ee_matches_oldkey_ski() {
1073        // Test4EE.AKI.keyIdentifier (per `openssl x509 -text` on the fixture):
1074        //   DD:0D:75:8D:53:68:12:C4:CB:15:40:C0:14:86:14:16:30:A1:BE:AF
1075        const EXPECTED: [u8; 20] = [
1076            0xdd, 0x0d, 0x75, 0x8d, 0x53, 0x68, 0x12, 0xc4, 0xcb, 0x15, 0x40, 0xc0, 0x14, 0x86,
1077            0x14, 0x16, 0x30, 0xa1, 0xbe, 0xaf,
1078        ];
1079        let ee = pkits_cert("ValidBasicSelfIssuedNewWithOldTest4EE");
1080        let aki = cert_aki_key_id(&ee).expect("Test4EE has an AKI extension");
1081        assert_eq!(aki.as_slice(), &EXPECTED);
1082    }
1083
1084    #[test]
1085    fn cert_ski_key_id_oldkey_matches_test4ee_aki() {
1086        // BasicSelfIssuedOldKeyCACert.SKI must equal Test4EE.AKI.keyIdentifier.
1087        // Same hex bytes as the AKI test above; parsed independently from a
1088        // different DER file via a different code path.
1089        const EXPECTED: [u8; 20] = [
1090            0xdd, 0x0d, 0x75, 0x8d, 0x53, 0x68, 0x12, 0xc4, 0xcb, 0x15, 0x40, 0xc0, 0x14, 0x86,
1091            0x14, 0x16, 0x30, 0xa1, 0xbe, 0xaf,
1092        ];
1093        let oldkey = pkits_cert("BasicSelfIssuedOldKeyCACert");
1094        let ski = cert_ski_key_id(&oldkey).expect("OldKeyCACert has an SKI extension");
1095        assert_eq!(ski.as_slice(), &EXPECTED);
1096    }
1097
1098    #[test]
1099    fn cert_ski_key_id_bridge_ca_differs_from_oldkey() {
1100        // BasicSelfIssuedOldKeyNewWithOldCACert shares a subject DN with
1101        // OldKeyCACert but has a distinct SPKI and SKI:
1102        //   88:5F:BE:3F:35:39:66:9A:EB:4D:C2:26:1B:26:B1:2A:27:B5:08:2A
1103        // This is the disambiguation signal AKI ranking exploits.
1104        const EXPECTED: [u8; 20] = [
1105            0x88, 0x5f, 0xbe, 0x3f, 0x35, 0x39, 0x66, 0x9a, 0xeb, 0x4d, 0xc2, 0x26, 0x1b, 0x26,
1106            0xb1, 0x2a, 0x27, 0xb5, 0x08, 0x2a,
1107        ];
1108        let bridge = pkits_cert("BasicSelfIssuedOldKeyNewWithOldCACert");
1109        let ski = cert_ski_key_id(&bridge).expect("bridge cert has an SKI extension");
1110        assert_eq!(ski.as_slice(), &EXPECTED);
1111    }
1112
1113    #[test]
1114    fn cert_aki_key_id_returns_none_when_aki_absent() {
1115        // The PKITS trust anchor cert is self-signed and (per its DER) has
1116        // NO AuthorityKeyIdentifier extension — only a SubjectKeyIdentifier.
1117        // The helper must return None, exercising the early-return branch
1118        // in cert_aki_key_id.
1119        let anchor = pkits_cert("TrustAnchorRootCertificate");
1120        assert!(cert_aki_key_id(&anchor).is_none());
1121    }
1122
1123    #[test]
1124    fn cert_ski_key_id_present_on_trust_anchor() {
1125        // Trust anchor's SKI per `openssl x509 -text`:
1126        //   E4:7D:5F:D1:5C:95:86:08:2C:05:AE:BE:75:B6:65:A7:D9:5D:A8:66
1127        // Round-trips the same bytes that downstream certs reference via
1128        // their AKI.keyIdentifier (AKI/SKI binding cross-check).
1129        const EXPECTED: [u8; 20] = [
1130            0xe4, 0x7d, 0x5f, 0xd1, 0x5c, 0x95, 0x86, 0x08, 0x2c, 0x05, 0xae, 0xbe, 0x75, 0xb6,
1131            0x65, 0xa7, 0xd9, 0x5d, 0xa8, 0x66,
1132        ];
1133        let anchor = pkits_cert("TrustAnchorRootCertificate");
1134        let ski = cert_ski_key_id(&anchor).expect("trust anchor has an SKI");
1135        assert_eq!(ski.as_slice(), &EXPECTED);
1136    }
1137}