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`] uses iterative-deepening DFS (RFC 4158 §2.5): it tries
22//! increasing maximum path depths from 1 to 10, performing a full DFS at
23//! each depth. This guarantees that the shortest valid path is returned while
24//! bounding memory to O(depth) stack frames per attempt.
25//!
26//! # Spec references
27//!
28//! - RFC 4158 — Internet X.509 PKI: Certification Path Building
29//! - RFC 5280 §6.1 — the validation algorithm this crate feeds into
30//!
31//! # `no_std`
32//!
33//! This crate is `no_std` but requires the `alloc` crate. The `extern crate alloc`
34//! declaration is provided automatically; you do not need to add it yourself, but
35//! your target must supply a global allocator (e.g., `#[global_allocator]`).
36
37extern crate alloc;
38
39use alloc::vec::Vec;
40use x509_cert::Certificate;
41
42/// OID for BasicConstraints (2.5.29.19).
43const OID_BASIC_CONSTRAINTS: der::asn1::ObjectIdentifier =
44    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.19");
45
46/// An unordered collection of certificates used as input to path building.
47///
48/// Certificates are stored by DER bytes and decoded on demand. Add all
49/// candidate intermediate certificates here; the path builder will select
50/// and order the subset that forms a valid path to a trust anchor.
51///
52/// Note: `Hash` is not derived because `x509_cert::Certificate` does not
53/// currently implement `Hash` (upstream limitation); `CertPool` cannot be
54/// used as a hash-map key until that changes.
55#[derive(Debug, Default, PartialEq, Eq)]
56pub struct CertPool {
57    certs: Vec<Certificate>,
58}
59
60impl CertPool {
61    /// Create an empty pool.
62    #[must_use]
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Add a certificate to the pool.
68    pub fn add(&mut self, cert: Certificate) {
69        self.certs.push(cert);
70    }
71
72    /// Return the number of certificates in the pool.
73    #[must_use]
74    pub fn len(&self) -> usize {
75        self.certs.len()
76    }
77
78    /// Return `true` if the pool contains no certificates.
79    #[must_use]
80    pub fn is_empty(&self) -> bool {
81        self.certs.is_empty()
82    }
83
84    /// Iterate over the certificates in the pool.
85    ///
86    /// Equivalent to `(&pool).into_iter()`.
87    pub fn iter(&self) -> core::slice::Iter<'_, x509_cert::Certificate> {
88        self.certs.iter()
89    }
90}
91
92impl<'a> IntoIterator for &'a CertPool {
93    type Item = &'a x509_cert::Certificate;
94    type IntoIter = core::slice::Iter<'a, x509_cert::Certificate>;
95
96    fn into_iter(self) -> Self::IntoIter {
97        self.certs.iter()
98    }
99}
100
101/// Errors returned by path building.
102#[derive(Clone, Debug, PartialEq, Eq, Hash)]
103#[non_exhaustive]
104pub enum Error {
105    /// No valid path from the target certificate to any trust anchor was found.
106    NoPathFound,
107    /// A topologically valid path exists but requires more than the configured
108    /// maximum number of intermediates. Try increasing `max_depth` in the call
109    /// to [`build_path`].
110    DepthExceeded,
111}
112
113impl core::fmt::Display for Error {
114    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
115        match self {
116            Self::NoPathFound => f.write_str("no certification path found to a trust anchor"),
117            Self::DepthExceeded => f.write_str(
118                "no certification path found within depth limit (try increasing max_depth)",
119            ),
120        }
121    }
122}
123
124#[cfg(feature = "std")]
125impl std::error::Error for Error {}
126
127/// Result alias for this crate.
128pub type Result<T> = core::result::Result<T, Error>;
129
130/// Returns `true` if `cert` has `BasicConstraints` with `cA = TRUE`.
131///
132/// A missing extension or `cA = FALSE` both return `false`.
133/// A present extension whose DER cannot be decoded returns `false` (fail-open
134/// for the builder: a cert that cannot be decoded as a CA will simply not be
135/// selected as an intermediate, and validate_path will catch any structural
136/// problem on final verification).
137fn cert_is_ca(cert: &Certificate) -> bool {
138    use der::Decode as _;
139    use x509_cert::ext::pkix::BasicConstraints;
140
141    cert.tbs_certificate
142        .extensions
143        .as_deref()
144        .unwrap_or(&[])
145        .iter()
146        .find(|ext| ext.extn_id == OID_BASIC_CONSTRAINTS)
147        .and_then(|ext| BasicConstraints::from_der(ext.extn_value.as_bytes()).ok())
148        .is_some_and(|bc| bc.ca)
149}
150
151/// Inner DFS step.
152///
153/// `path` is the current (partial) chain, leaf-first. On success it contains
154/// the complete chain from the original target to an anchor-issued cert.
155/// Returns `true` if a complete path was found; `false` otherwise.
156///
157/// The invariant `path` is never empty is established by `build_path` (which
158/// pushes the target before calling `dfs`) and maintained by the push/pop
159/// discipline below. `debug_assert` catches violations in test builds without
160/// panicking in release builds.
161fn dfs(
162    path: &mut Vec<Certificate>,
163    pool: &[Certificate],
164    anchors: &[pkix_path::TrustAnchor],
165    depth_remaining: usize,
166) -> bool {
167    // Extract the issuer DN by cloning so the immutable borrow on `path` is
168    // released before the mutable push/pop below.
169    let current_issuer = match path.last() {
170        Some(c) => c.tbs_certificate.issuer.clone(),
171        None => {
172            // Invariant violated: path must never be empty when dfs is called.
173            debug_assert!(false, "dfs called with empty path — invariant violated");
174            return false;
175        }
176    };
177
178    // Base case: does any trust anchor directly issue `current`?
179    for anchor in anchors {
180        if pkix_path::names_match(&anchor.subject, &current_issuer) {
181            return true;
182        }
183    }
184
185    if depth_remaining == 0 {
186        return false;
187    }
188
189    // Recursive step: find pool certs that could issue `current`.
190    for candidate in pool {
191        // Candidate subject must match current issuer.
192        if !pkix_path::names_match(&candidate.tbs_certificate.subject, &current_issuer) {
193            continue;
194        }
195
196        // Candidate must be a CA (BasicConstraints cA=TRUE).
197        if !cert_is_ca(candidate) {
198            continue;
199        }
200
201        // Cycle guard: skip if candidate's subject is already in the path.
202        // The issuer DN is already cloned above, so we do not need an extra
203        // clone here — compare candidate's subject against all path entries.
204        let already_in_path = path.iter().any(|in_path| {
205            pkix_path::names_match(
206                &in_path.tbs_certificate.subject,
207                &candidate.tbs_certificate.subject,
208            )
209        });
210        if already_in_path {
211            continue;
212        }
213
214        // Push a clone of the candidate, recurse, pop on backtrack.
215        // Single clone per push (no separate subject clone needed, since
216        // current_issuer was extracted once at the top of this frame).
217        path.push(candidate.clone());
218        if dfs(path, pool, anchors, depth_remaining - 1) {
219            return true;
220        }
221        path.pop();
222    }
223
224    false
225}
226
227/// Build a certification path from `target` through certificates in `pool`
228/// to one of the provided trust anchors.
229///
230/// Returns the ordered chain `[target, intermediate..., anchor-issued]` ready
231/// for [`pkix_path::validate_path`]. Signatures are **not** verified here;
232/// that is the responsibility of the caller via `validate_path`.
233///
234/// # Algorithm
235///
236/// Iterative-deepening DFS: tries maximum intermediate depths 1 through 10.
237/// Returns the shortest valid topology first. Cycles are detected and pruned
238/// by comparing subject DNs already present in the path.
239///
240/// # Errors
241///
242/// - [`Error::NoPathFound`] — no topologically valid path through `pool` leads
243///   to any of the given trust anchors.
244/// - [`Error::DepthExceeded`] — a path exists topologically but requires more
245///   than 10 intermediate certificates; increase the depth limit or provide a
246///   shorter chain.
247///
248/// # Limitations
249///
250/// Cycle detection is based on subject DN comparison. In key-rollover or
251/// bridge-CA topologies where multiple certificates share a subject DN, the
252/// path builder may fail to find valid paths. This is a v0.1 limitation;
253/// v0.2 will use certificate identity (SPKI fingerprint) for cycle detection.
254///
255/// # Security
256///
257/// The DFS path builder has worst-case exponential complexity in the pool size.
258/// Do not populate the certificate pool from untrusted input without prior size
259/// limits. A future version will add a configurable budget cap.
260#[must_use = "path building result must be checked"]
261pub fn build_path(
262    target: &Certificate,
263    pool: &CertPool,
264    anchors: &[pkix_path::TrustAnchor],
265) -> Result<Vec<Certificate>> {
266    const MAX_DEPTH: usize = 10;
267
268    let pool_slice: &[Certificate] = &pool.certs;
269
270    for max_depth in 1..=MAX_DEPTH {
271        let mut path = alloc::vec![target.clone()];
272        if dfs(&mut path, pool_slice, anchors, max_depth) {
273            return Ok(path);
274        }
275    }
276
277    // No path found within MAX_DEPTH. Check if a path exists at MAX_DEPTH+1
278    // to distinguish "no path exists at all" from "path exists but too deep".
279    let mut probe = alloc::vec![target.clone()];
280    if dfs(&mut probe, pool_slice, anchors, MAX_DEPTH + 1) {
281        return Err(Error::DepthExceeded);
282    }
283
284    Err(Error::NoPathFound)
285}