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 x509_cert::Certificate;
43
44/// An unordered collection of certificates used as input to path building.
45///
46/// Certificates are stored by DER bytes and decoded on demand. Add all
47/// candidate intermediate certificates here; the path builder will select
48/// and order the subset that forms a valid path to a trust anchor.
49///
50/// Note: `Hash` is not derived because `x509_cert::Certificate` does not
51/// currently implement `Hash` (upstream limitation); `CertPool` cannot be
52/// used as a hash-map key until that changes.
53///
54/// Note: `PartialEq`/`Eq` are not derived. `CertPool` is documented as an
55/// unordered bag, so a derived implementation (which compares the internal
56/// `Vec` in insertion order) would be semantically wrong.
57#[derive(Clone, Debug, Default)]
58pub struct CertPool {
59    certs: Vec<Certificate>,
60}
61
62impl CertPool {
63    /// Create an empty pool.
64    #[must_use]
65    pub const fn new() -> Self {
66        Self { certs: Vec::new() }
67    }
68
69    /// Add a certificate to the pool.
70    pub fn add(&mut self, cert: Certificate) {
71        self.certs.push(cert);
72    }
73
74    /// Return the number of certificates in the pool.
75    #[must_use]
76    pub fn len(&self) -> usize {
77        self.certs.len()
78    }
79
80    /// Return `true` if the pool contains no certificates.
81    #[must_use]
82    pub fn is_empty(&self) -> bool {
83        self.certs.is_empty()
84    }
85
86    /// Iterate over the certificates in the pool.
87    ///
88    /// Equivalent to `(&pool).into_iter()`.
89    pub fn iter(&self) -> core::slice::Iter<'_, x509_cert::Certificate> {
90        self.certs.iter()
91    }
92
93    /// Return the pool contents as a slice.
94    pub(crate) fn as_slice(&self) -> &[Certificate] {
95        &self.certs
96    }
97}
98
99impl FromIterator<Certificate> for CertPool {
100    fn from_iter<I: IntoIterator<Item = Certificate>>(iter: I) -> Self {
101        Self {
102            certs: iter.into_iter().collect(),
103        }
104    }
105}
106
107impl Extend<Certificate> for CertPool {
108    fn extend<I: IntoIterator<Item = Certificate>>(&mut self, iter: I) {
109        self.certs.extend(iter);
110    }
111}
112
113impl<'a> IntoIterator for &'a CertPool {
114    type Item = &'a x509_cert::Certificate;
115    type IntoIter = core::slice::Iter<'a, x509_cert::Certificate>;
116
117    fn into_iter(self) -> Self::IntoIter {
118        self.certs.iter()
119    }
120}
121
122/// Errors returned by path building.
123#[derive(Clone, Debug, PartialEq, Eq, Hash)]
124#[non_exhaustive]
125pub enum Error {
126    /// No valid path from the target certificate to any trust anchor was found.
127    NoPathFound,
128    /// A topologically valid path exists but requires more intermediates than
129    /// the configured maximum (see [`PathBuilderConfig::max_depth`], default
130    /// [`DEFAULT_MAX_DEPTH`]).
131    DepthExceeded,
132    /// The internal DFS node-visit budget was exhausted in a single round.
133    ///
134    /// This guards against adversarial certificate pools that would otherwise
135    /// cause exponential search time. Each iterative-deepening round and the
136    /// depth probe start with a fresh budget of `DFS_BUDGET` node visits.
137    BudgetExceeded,
138    /// A candidate intermediate's `BasicConstraints` extension was present but
139    /// could not be DER-decoded.
140    ///
141    /// Returning this rather than silently rejecting the candidate avoids the
142    /// situation where a malformed-but-topologically-correct intermediate
143    /// causes a misleading [`Error::NoPathFound`].
144    MalformedIntermediate,
145}
146
147impl core::fmt::Display for Error {
148    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
149        match self {
150            Self::NoPathFound => f.write_str("no certification path found to a trust anchor"),
151            Self::DepthExceeded => f.write_str(
152                "configured maximum intermediate chain depth exceeded; the chain may require a deeper path than this builder is configured to attempt",
153            ),
154            Self::BudgetExceeded => f.write_str(
155                "DFS node-visit budget exceeded; pool may be adversarially large",
156            ),
157            Self::MalformedIntermediate => f.write_str(
158                "a candidate intermediate's BasicConstraints extension is present but cannot be decoded",
159            ),
160        }
161    }
162}
163
164#[cfg(feature = "std")]
165impl std::error::Error for Error {}
166
167/// Result alias for this crate.
168pub type Result<T> = core::result::Result<T, Error>;
169
170/// Returns `Ok(true)` if `cert` has `BasicConstraints` with `cA = TRUE`,
171/// `Ok(false)` if the extension is absent or has `cA = FALSE`, and
172/// [`Error::MalformedIntermediate`] if the extension is present but
173/// cannot be DER-decoded.
174///
175/// Propagating decode failure (rather than silently rejecting the cert
176/// as not-a-CA) avoids the situation where a topologically-valid path
177/// through a malformed-BC intermediate produces a misleading
178/// [`Error::NoPathFound`].
179///
180/// Thin wrapper over [`pkix_path::cert_is_ca`] that maps the opaque
181/// [`pkix_path::DerError`] to this crate's [`Error::MalformedIntermediate`].
182fn cert_is_ca(cert: &Certificate) -> Result<bool> {
183    pkix_path::cert_is_ca(cert).map_err(|_| Error::MalformedIntermediate)
184}
185
186/// Inner DFS step.
187///
188/// `path` is the current (partial) chain, leaf-first. On success it contains
189/// the complete chain from the original target to an anchor-issued cert.
190///
191/// Returns:
192/// - `Ok(true)`  — complete path found; `path` holds the result.
193/// - `Ok(false)` — no path at this depth; `path` is restored to its entry state.
194/// - `Err(Error::BudgetExceeded)` — node-visit budget exhausted in this round.
195/// - `Err(Error::MalformedIntermediate)` — a candidate intermediate's
196///   `BasicConstraints` is present but undecodable.
197///
198/// `budget` is decremented on every call (one visit = one DFS node). When it
199/// reaches zero the function returns [`Error::BudgetExceeded`] without further
200/// exploration.
201///
202/// The invariant `path` is never empty is established by [`build_path`] (which
203/// pushes the target before calling `dfs`) and maintained by the push/pop
204/// discipline below.
205fn dfs(
206    path: &mut Vec<Certificate>,
207    pool: &[Certificate],
208    anchors: &[pkix_path::TrustAnchor],
209    depth_remaining: usize,
210    budget: &mut usize,
211) -> Result<bool> {
212    // Count this node visit against the budget.
213    if *budget == 0 {
214        return Err(Error::BudgetExceeded);
215    }
216    *budget -= 1;
217
218    // Extract the issuer DN by cloning so the immutable borrow on `path` is
219    // released before the mutable push/pop below.
220    let Some(c) = path.last() else {
221        unreachable!("dfs called with empty path — invariant violated");
222    };
223    let current_issuer = c.tbs_certificate.issuer.clone();
224
225    // Base case: does any trust anchor directly issue `current`?
226    for anchor in anchors {
227        if pkix_path::names_match(&anchor.subject, &current_issuer) {
228            return Ok(true);
229        }
230    }
231
232    if depth_remaining == 0 {
233        return Ok(false);
234    }
235
236    // Recursive step: find pool certs that could issue `current`.
237    for candidate in pool {
238        // Candidate subject must match current issuer.
239        if !pkix_path::names_match(&candidate.tbs_certificate.subject, &current_issuer) {
240            continue;
241        }
242
243        // Candidate must be a CA (BasicConstraints cA=TRUE). A malformed BC
244        // is propagated rather than silently rejected — see `cert_is_ca`.
245        if !cert_is_ca(candidate)? {
246            continue;
247        }
248
249        // Cycle guard: skip if candidate's SPKI is already in the path.
250        //
251        // We compare SubjectPublicKeyInfo by value (not subject DNs) because:
252        // - Multiple certificates may share a subject DN (key rollover, bridge CA).
253        //   DN-based cycle detection would incorrectly prune valid paths in those
254        //   topologies.
255        // - SPKI uniquely identifies the signing key: two certs with the same DN
256        //   but different keys have different SPKIs and are distinct nodes in the
257        //   path graph.
258        //
259        // We do NOT use `==` / `PartialEq` on `SubjectPublicKeyInfoOwned` because
260        // `AlgorithmIdentifier::PartialEq` is a full field comparison that includes
261        // the optional `parameters` field. For RSA, one cert may encode
262        // `AlgorithmIdentifier { oid: rsaEncryption, params: NULL }` while another
263        // encodes `AlgorithmIdentifier { oid: rsaEncryption, params: absent }`.
264        // Both represent the same public key but compare as unequal under `PartialEq`,
265        // which would allow the cycle guard to miss a loop between such encoding variants.
266        // Instead we compare only the algorithm OID and the raw key bit-string, which
267        // is the same approach used by `pkix_path::spki_key_matches`.
268        let candidate_spki = &candidate.tbs_certificate.subject_public_key_info;
269        let already_in_path = path.iter().any(|in_path| {
270            let s = &in_path.tbs_certificate.subject_public_key_info;
271            s.algorithm.oid == candidate_spki.algorithm.oid
272                && s.subject_public_key == candidate_spki.subject_public_key
273        });
274        if already_in_path {
275            continue;
276        }
277
278        // Push a clone of the candidate, recurse, pop on backtrack.
279        // Single clone per push (no separate subject clone needed, since
280        // current_issuer was extracted once at the top of this frame).
281        path.push(candidate.clone());
282        if dfs(path, pool, anchors, depth_remaining - 1, budget)? {
283            return Ok(true);
284        }
285        path.pop();
286    }
287
288    Ok(false)
289}
290
291/// Default DFS node-visit budget for a single iterative-deepening round.
292///
293/// Sufficient for legitimate chains (real-world PKI hierarchies have at most
294/// a handful of intermediates and small pools); prevents exponential blow-up
295/// against adversarially constructed pools of O(N) CA certificates with
296/// identical subject/issuer names.
297pub const DEFAULT_DFS_BUDGET: usize = 10_000;
298
299/// Default maximum number of intermediate certificates considered.
300pub const DEFAULT_MAX_DEPTH: usize = 10;
301
302/// Tunable parameters for path building.
303///
304/// Use [`PathBuilderConfig::default`] (or [`PathBuilderConfig::new`]) for the
305/// production defaults. Embedded callers, callers with restricted compute,
306/// and callers handling adversarial pools can tighten these values.
307///
308/// # Stability
309///
310/// Constructed via [`PathBuilderConfig::new`] / `Default`; the struct is
311/// `#[non_exhaustive]` so additional knobs can be added without breaking
312/// existing callers.
313#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
314#[non_exhaustive]
315pub struct PathBuilderConfig {
316    /// Maximum number of intermediates to explore. The depth probe runs at
317    /// `max_depth + 1` to distinguish "no path exists" from "path exists
318    /// but too deep". Default: [`DEFAULT_MAX_DEPTH`].
319    pub max_depth: usize,
320    /// Per-round node-visit budget. Default: [`DEFAULT_DFS_BUDGET`].
321    pub dfs_budget: usize,
322}
323
324impl PathBuilderConfig {
325    /// Construct a config with all knobs set to their default values.
326    #[must_use]
327    pub const fn new() -> Self {
328        Self {
329            max_depth: DEFAULT_MAX_DEPTH,
330            dfs_budget: DEFAULT_DFS_BUDGET,
331        }
332    }
333}
334
335impl Default for PathBuilderConfig {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341/// Build a certification path from `target` through certificates in `pool`
342/// to one of the provided trust anchors.
343///
344/// Returns the ordered chain `[target, intermediate..., anchor-issued]` ready
345/// for [`pkix_path::validate_path`]. Signatures are **not** verified here;
346/// that is the responsibility of the caller via [`pkix_path::validate_path`].
347///
348/// # Algorithm
349///
350/// Iterative-deepening DFS: tries maximum intermediate depths 1 through 10.
351/// Returns the shortest valid topology first. Cycles are detected and pruned
352/// by comparing each candidate's `SubjectPublicKeyInfo` algorithm OID and raw
353/// public-key bits against certificates already in the path. Algorithm
354/// parameters are deliberately excluded from the comparison to tolerate the
355/// RFC 8017 ambiguity between absent and explicit-NULL `parameters` in
356/// rsaEncryption SPKIs (see the inline comment at the cycle-detection site
357/// for rationale).
358///
359/// Each round and the depth probe get a fresh budget of `DFS_BUDGET` node
360/// visits.  Resetting per-round prevents earlier rounds (which re-traverse all
361/// nodes from rounds 1..k-1) from consuming budget that round k needs.  The
362/// depth probe at `MAX_DEPTH + 1` also starts with a fresh budget.
363///
364/// If any round exhausts its budget before finding a path, [`Error::BudgetExceeded`]
365/// is returned. This bounds worst-case complexity against adversarial inputs.
366///
367/// # Errors
368///
369/// - [`Error::NoPathFound`] — no topologically valid path through `pool` leads
370///   to any of the given trust anchors.
371/// - [`Error::DepthExceeded`] — a path exists topologically but requires more
372///   than 10 intermediate certificates; increase the depth limit or provide a
373///   shorter chain.
374/// - [`Error::BudgetExceeded`] — the DFS node-visit budget was exhausted in
375///   some round; the pool may be adversarially large or structured to produce
376///   exponential search.
377///
378/// # Limitations
379///
380/// Cycle detection is based on `SubjectPublicKeyInfo` algorithm OID + raw
381/// public-key bits (parameters intentionally excluded — see Algorithm above)
382/// rather than subject DN. Two certificates with the same subject DN but
383/// different public keys (e.g., during a key rollover or in a bridge CA
384/// topology) are treated as distinct nodes and will not incorrectly prune
385/// valid paths.
386///
387/// **Anchor matching is by DN only.** When a candidate's issuer DN matches
388/// any anchor in `anchors`, path building terminates immediately with that
389/// chain — the anchor's `SubjectPublicKeyInfo` is **not** verified against
390/// what the chain expects. In a key-rollover scenario where two anchors
391/// share a subject DN but hold different keys, this builder may return a
392/// chain whose top cert was actually signed by a different anchor than the
393/// one it is paired with for downstream validation. The downstream caller
394/// ([`pkix_path::validate_path`]) iterates all DN-matching anchors and
395/// returns success if any of them verify the signature, so correctness is
396/// preserved end-to-end. The caller-visible effect is a less informative
397/// error in genuinely unverifiable cases (`SignatureInvalid` from
398/// `validate_path` rather than `NoPathFound` here).
399///
400/// # Security
401///
402/// Pool contents should be from a trusted source. `DFS_BUDGET` enforces a hard
403/// cap on search work per round to prevent denial-of-service via oversized or
404/// crafted pools.
405pub fn build_path(
406    target: &Certificate,
407    pool: &CertPool,
408    anchors: &[pkix_path::TrustAnchor],
409) -> Result<Vec<Certificate>> {
410    build_path_with_config(target, pool, anchors, &PathBuilderConfig::new())
411}
412
413/// Build a certification path with caller-provided budget and depth tunables.
414///
415/// Behaves identically to [`build_path`] but uses the limits in `config`
416/// instead of the workspace defaults. See [`PathBuilderConfig`] for the
417/// individual knobs.
418///
419/// # Errors
420///
421/// Same as [`build_path`].
422pub fn build_path_with_config(
423    target: &Certificate,
424    pool: &CertPool,
425    anchors: &[pkix_path::TrustAnchor],
426    config: &PathBuilderConfig,
427) -> Result<Vec<Certificate>> {
428    let pool_slice = pool.as_slice();
429
430    // Track whether any round was terminated by the budget (not by exhausting
431    // all candidates). If every round hits the budget limit, the pool is
432    // adversarially large and we return BudgetExceeded; otherwise NoPathFound.
433    let mut any_round_budget_exceeded = false;
434
435    for max_depth in 1..=config.max_depth {
436        // Reset budget at the start of each round so that earlier rounds
437        // (which re-traverse the same shallower nodes) do not exhaust the
438        // budget before deeper rounds get a chance to run.
439        let mut budget = config.dfs_budget;
440        let mut path = alloc::vec![target.clone()];
441        match dfs(&mut path, pool_slice, anchors, max_depth, &mut budget) {
442            Ok(true) => return Ok(path),
443            Ok(false) => {}
444            Err(Error::BudgetExceeded) => {
445                // Budget exhausted at this depth does NOT mean there is no
446                // valid path at greater depth. Continue to the next round with
447                // a fresh budget rather than surfacing BudgetExceeded immediately.
448                any_round_budget_exceeded = true;
449            }
450            Err(e) => return Err(e),
451        }
452    }
453
454    if any_round_budget_exceeded {
455        return Err(Error::BudgetExceeded);
456    }
457
458    // No round hit the budget, but no path found within max_depth.
459    // Check if a path exists at max_depth+1 to distinguish "no path exists
460    // at all" from "path exists but too deep". The probe uses its own fresh
461    // budget so it is not affected by prior rounds.
462    //
463    // Note: if the probe itself returns BudgetExceeded (pool is adversarially
464    // large at max_depth+1), the `?` propagates it to the caller. This is a
465    // second, independent path to BudgetExceeded that does not use the
466    // any_round_budget_exceeded flag — both paths produce the same observable
467    // result (Err(BudgetExceeded)), but via different code paths.
468    let mut probe_budget = config.dfs_budget;
469    let mut probe = alloc::vec![target.clone()];
470    if dfs(
471        &mut probe,
472        pool_slice,
473        anchors,
474        config.max_depth + 1,
475        &mut probe_budget,
476    )? {
477        return Err(Error::DepthExceeded);
478    }
479
480    Err(Error::NoPathFound)
481}