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