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, ¤t_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, ¤t_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}