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}