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