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`] perform a single-pass
22//! depth-first search up to [`PathBuilderConfig::max_depth`] (default
23//! [`DEFAULT_MAX_DEPTH`] = 10). At each step, candidates are ranked by
24//! AKI/SKI match tier (RFC 4158 §3.2) so the most-likely issuer is tried
25//! first. Memory is bounded to O(depth) stack frames.
26//!
27//! Shortest-first is **not** guaranteed: for typical pools the first chain
28//! found is the shortest, but adversarial pools may yield a deeper chain
29//! first. See [`PathCandidates`] for the full enumeration contract.
30//!
31//! # Spec references
32//!
33//! - RFC 4158 — Internet X.509 PKI: Certification Path Building
34//! - RFC 5280 §6.1 — the validation algorithm this crate feeds into
35//!
36//! # `no_std`
37//!
38//! This crate is `no_std` but requires the `alloc` crate. The `extern crate alloc`
39//! declaration is provided automatically; you do not need to add it yourself, but
40//! your target must supply a global allocator (e.g., `#[global_allocator]`).
41//!
42//! # Limitations
43//!
44//! - **Caller supplies the candidate set.** [`CertPool`] takes a pool of
45//! already-loaded certificates. This crate does not fetch missing
46//! intermediates from `AuthorityInfoAccess` URIs; the optional
47//! `pkix-aia` / `pkix-aia-http` cascade handles that (tracked under
48//! `PKIX-zkjb`).
49//! - **Output feeds `pkix-path`.** The validation algorithm (RFC 5280 §6.1
50//! signature chain walk, name constraints, policy machinery, revocation)
51//! lives in `pkix-path` and `pkix-revocation`. This crate's job ends
52//! when it returns an ordered candidate chain.
53//! - **Known residual divergence.** A single bettertls path-building
54//! corner case (`pathbuilding::tc60`) is documented as a known
55//! divergence; closing it is a 1.0 release blocker tracked under
56//! `PKIX-lwr9.4`. See `pkix-difftest/baseline-limbo-analysis.md`.
57
58extern crate alloc;
59
60use alloc::vec::Vec;
61use der::Decode as _;
62use x509_cert::Certificate;
63
64/// An unordered collection of certificates used as input to path building.
65///
66/// Certificates are stored by DER bytes and decoded on demand. Add all
67/// candidate intermediate certificates here; the path builder will select
68/// and order the subset that forms a valid path to a trust anchor.
69///
70/// Note: `Hash` is not derived because `x509_cert::Certificate` does not
71/// currently implement `Hash` (upstream limitation); `CertPool` cannot be
72/// used as a hash-map key until that changes.
73///
74/// Note: `PartialEq`/`Eq` are not derived. `CertPool` is documented as an
75/// unordered bag, so a derived implementation (which compares the internal
76/// `Vec` in insertion order) would be semantically wrong.
77#[derive(Clone, Debug, Default)]
78pub struct CertPool {
79 certs: Vec<Certificate>,
80}
81
82impl CertPool {
83 /// Create an empty pool.
84 #[must_use]
85 pub const fn new() -> Self {
86 Self { certs: Vec::new() }
87 }
88
89 /// Add a certificate to the pool.
90 pub fn add(&mut self, cert: Certificate) {
91 self.certs.push(cert);
92 }
93
94 /// Return the number of certificates in the pool.
95 #[must_use]
96 pub fn len(&self) -> usize {
97 self.certs.len()
98 }
99
100 /// Return `true` if the pool contains no certificates.
101 #[must_use]
102 pub fn is_empty(&self) -> bool {
103 self.certs.is_empty()
104 }
105
106 /// Iterate over the certificates in the pool.
107 ///
108 /// Equivalent to `(&pool).into_iter()`.
109 pub fn iter(&self) -> core::slice::Iter<'_, x509_cert::Certificate> {
110 self.certs.iter()
111 }
112
113 /// Return the pool contents as a slice.
114 pub(crate) fn as_slice(&self) -> &[Certificate] {
115 &self.certs
116 }
117}
118
119impl FromIterator<Certificate> for CertPool {
120 fn from_iter<I: IntoIterator<Item = Certificate>>(iter: I) -> Self {
121 Self {
122 certs: iter.into_iter().collect(),
123 }
124 }
125}
126
127impl Extend<Certificate> for CertPool {
128 fn extend<I: IntoIterator<Item = Certificate>>(&mut self, iter: I) {
129 self.certs.extend(iter);
130 }
131}
132
133impl<'a> IntoIterator for &'a CertPool {
134 type Item = &'a x509_cert::Certificate;
135 type IntoIter = core::slice::Iter<'a, x509_cert::Certificate>;
136
137 fn into_iter(self) -> Self::IntoIter {
138 self.certs.iter()
139 }
140}
141
142impl IntoIterator for CertPool {
143 type Item = x509_cert::Certificate;
144 type IntoIter = alloc::vec::IntoIter<x509_cert::Certificate>;
145
146 fn into_iter(self) -> Self::IntoIter {
147 self.certs.into_iter()
148 }
149}
150
151/// Errors returned by path building.
152#[derive(Clone, Debug, PartialEq, Eq, Hash)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154#[non_exhaustive]
155pub enum Error {
156 /// No valid path from the target certificate to any trust anchor was found.
157 NoPathFound,
158 /// A topologically valid path exists but requires more intermediates than
159 /// the configured maximum (see [`PathBuilderConfig::max_depth`], default
160 /// [`DEFAULT_MAX_DEPTH`]).
161 DepthExceeded,
162 /// The internal DFS node-visit budget was exhausted.
163 ///
164 /// This guards against adversarial certificate pools that would otherwise
165 /// cause exponential search time. The DFS and the depth probe each start
166 /// with a fresh budget of [`PathBuilderConfig::dfs_budget`] node visits.
167 BudgetExceeded,
168 /// [`build_first_valid_path`] exhausted [`build_path_candidates`] without
169 /// finding a candidate that [`pkix_path::validate_path`] accepted.
170 ///
171 /// At least one topologically valid chain was found by the path builder,
172 /// but every chain was rejected by the verifier or the validation policy
173 /// (e.g., mixed-signature-algorithm cross-signed pools where the DFS
174 /// candidate order picks an algorithm the [`SignatureVerifier`] does not
175 /// dispatch; cross-cert chains where one issuer is expired at the
176 /// validation time; etc.).
177 ///
178 /// `tried` is the count of candidate chains rejected; it is always
179 /// `>= 1` for this variant (zero-yield exhaustion is reported as
180 /// [`Error::NoPathFound`] instead, matching [`build_path`]'s contract).
181 ///
182 /// `last_error` is the [`pkix_path::Error::Display`] rendering of the
183 /// last candidate's validation failure. It is carried as a `String`
184 /// rather than a `pkix_path::Error` so [`Error`] retains its `Hash`
185 /// derive (the upstream error enum does not implement `Hash`). Callers
186 /// that need to programmatically match on the inner error should iterate
187 /// [`build_path_candidates`] directly and call [`pkix_path::validate_path`]
188 /// per candidate themselves.
189 ///
190 /// [`SignatureVerifier`]: pkix_path::SignatureVerifier
191 /// [`pkix_path::Error::Display`]: pkix_path::Error
192 #[non_exhaustive]
193 NoValidPath {
194 /// Number of candidate chains that were tried and rejected.
195 tried: usize,
196 /// `Display` rendering of the last [`pkix_path::Error`] observed.
197 last_error: alloc::string::String,
198 },
199}
200
201impl core::fmt::Display for Error {
202 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
203 match self {
204 Self::NoPathFound => f.write_str("no certification path found to a trust anchor"),
205 Self::DepthExceeded => f.write_str(
206 "configured maximum intermediate chain depth exceeded; the chain may require a deeper path than this builder is configured to attempt",
207 ),
208 Self::BudgetExceeded => f.write_str(
209 "DFS node-visit budget exceeded; pool may be adversarially large",
210 ),
211 Self::NoValidPath { tried, last_error } => write!(
212 f,
213 "tried {tried} candidate path(s); none validated. Last validation error: {last_error}"
214 ),
215 }
216 }
217}
218
219#[cfg(feature = "std")]
220impl std::error::Error for Error {}
221
222/// Result alias for this crate.
223pub type Result<T> = core::result::Result<T, Error>;
224
225/// Returns `true` if `cert` has `BasicConstraints` with `cA = TRUE`,
226/// `false` if the extension is absent, has `cA = FALSE`, or cannot be
227/// DER-decoded.
228///
229/// Malformed `BasicConstraints` is treated as `false` (skip-not-fail):
230/// a single malformed certificate in a CMS `SignedData.certificates` bag
231/// must not poison verification of an otherwise-valid chain.
232fn cert_is_ca(cert: &Certificate) -> bool {
233 pkix_path::cert_is_ca(cert).unwrap_or(false)
234}
235
236/// OID `id-ce-authorityKeyIdentifier` (RFC 5280 §4.2.1.1).
237const OID_AUTHORITY_KEY_IDENTIFIER: der::asn1::ObjectIdentifier =
238 der::asn1::ObjectIdentifier::new_unwrap("2.5.29.35");
239
240/// OID `id-ce-subjectKeyIdentifier` (RFC 5280 §4.2.1.2).
241const OID_SUBJECT_KEY_IDENTIFIER: der::asn1::ObjectIdentifier =
242 der::asn1::ObjectIdentifier::new_unwrap("2.5.29.14");
243
244/// Return the bytes of `cert`'s `AuthorityKeyIdentifier::keyIdentifier`
245/// extension, or `None` if the extension is absent, the `keyIdentifier`
246/// field is absent, or the extension cannot be DER-decoded.
247///
248/// **Fail-soft semantics**: a malformed AKI is treated as if absent rather
249/// than propagated as an error. The AKI keyIdentifier is used purely as
250/// an *ordering heuristic* for candidate selection; it is not a security
251/// gate (the actual signature check happens downstream in
252/// [`pkix_path::validate_path`]). A malformed AKI on the target should
253/// degrade builder selection to DN-only ranking, not abort path building.
254///
255/// RFC 5280 §4.2.1.1: AKI's `keyIdentifier` is normally the SHA-1 hash of
256/// the issuer's `subjectPublicKey` BIT STRING (method 1). This is compared
257/// byte-for-byte against candidate certs' `SubjectKeyIdentifier`; we do
258/// not recompute hashes here — only opaque-byte equality matters.
259///
260/// **Note:** Returns `Vec<u8>` (owned) rather than `&[u8]` because
261/// `AuthorityKeyIdentifier::from_der` produces an owned intermediate
262/// whose lifetime cannot be tied to the input `cert` reference; the
263/// inner `OctetString` bytes do not borrow from the cert's DER.
264fn cert_aki_key_id(cert: &Certificate) -> Option<Vec<u8>> {
265 use x509_cert::ext::pkix::AuthorityKeyIdentifier;
266
267 let extns = cert.tbs_certificate.extensions.as_deref()?;
268 let extn = extns
269 .iter()
270 .find(|e| e.extn_id == OID_AUTHORITY_KEY_IDENTIFIER)?;
271 let aki = AuthorityKeyIdentifier::from_der(extn.extn_value.as_bytes()).ok()?;
272 aki.key_identifier.map(|oct| oct.as_bytes().to_vec())
273}
274
275/// Return the bytes of `cert`'s `SubjectKeyIdentifier` extension, or
276/// `None` if the extension is absent or cannot be DER-decoded.
277///
278/// **Fail-soft semantics**: see [`cert_aki_key_id`] for rationale. A cert
279/// without a parseable SKI ranks below SKI-bearing candidates in the
280/// AKI-matching tier but is still considered for the DN-only fallback
281/// tier.
282///
283/// RFC 5280 §4.2.1.2: SKI is conventionally the SHA-1 hash of the cert's
284/// own `subjectPublicKey` BIT STRING; we do not recompute, we only return
285/// the bytes the cert claims.
286///
287/// **Note:** Returns `Vec<u8>` (owned) for the same reason as
288/// [`cert_aki_key_id`]: `SubjectKeyIdentifier::from_der` produces an
289/// owned `OctetString` that does not borrow from the cert's DER.
290fn cert_ski_key_id(cert: &Certificate) -> Option<Vec<u8>> {
291 use x509_cert::ext::pkix::SubjectKeyIdentifier;
292
293 let extns = cert.tbs_certificate.extensions.as_deref()?;
294 let extn = extns
295 .iter()
296 .find(|e| e.extn_id == OID_SUBJECT_KEY_IDENTIFIER)?;
297 let ski = SubjectKeyIdentifier::from_der(extn.extn_value.as_bytes()).ok()?;
298 Some(ski.0.as_bytes().to_vec())
299}
300
301/// Compute the DN-matching candidates of `cur` from `pool`, ordered by
302/// AKI/SKI matching tier (RFC 5280 §4.2.1.1, RFC 4158 §3.2).
303///
304/// Returns a vector of `(tier, pool_index)` pairs:
305///
306/// - **Tier 0**: candidate's `SubjectKeyIdentifier` matches `cur`'s
307/// `AuthorityKeyIdentifier.keyIdentifier`. This is the §4.2.1.1 method-1
308/// disambiguator: in bridge-CA and key-rollover topologies, multiple CA
309/// certs share an issuer DN; AKI/SKI is the only deterministic way to
310/// pick the cert that actually signed `cur`.
311/// - **Tier 1**: any DN-matching candidate. Used when `cur` has no AKI,
312/// no candidate SKI matches, or AKI/SKI parsing failed (fail-soft — see
313/// [`cert_aki_key_id`]/[`cert_ski_key_id`]).
314///
315/// The result is sorted **stably** by tier so candidates within the same
316/// tier preserve pool insertion order. This is the documented contract for
317/// the no-AKI-signal case.
318///
319/// **Not currently used:** the AKI `authorityCertIssuer` /
320/// `authorityCertSerialNumber` fields. They are rare in practice and
321/// parsing `GeneralNames` for that signal is more work than the marginal
322/// disambiguation benefit justifies. Documented as a deferred enhancement.
323fn rank_candidates(cur: &Certificate, pool: &[Certificate]) -> Vec<(u8, usize)> {
324 let cur_issuer = &cur.tbs_certificate.issuer;
325 let target_aki_kid = cert_aki_key_id(cur);
326 let mut ranked: Vec<(u8, usize)> = Vec::with_capacity(pool.len());
327 for (idx, candidate) in pool.iter().enumerate() {
328 if !pkix_path::names_match(&candidate.tbs_certificate.subject, cur_issuer) {
329 continue;
330 }
331 let tier: u8 = match (
332 target_aki_kid.as_deref(),
333 cert_ski_key_id(candidate).as_deref(),
334 ) {
335 (Some(aki), Some(ski)) if aki == ski => 0,
336 _ => 1,
337 };
338 ranked.push((tier, idx));
339 }
340 ranked.sort_by_key(|&(tier, _)| tier);
341 ranked
342}
343
344/// SPKI-based cycle detection: does `path` already contain a cert with the
345/// same `SubjectPublicKeyInfo` algorithm OID and raw public-key bits as
346/// `candidate`?
347///
348/// Algorithm parameters are deliberately excluded from the comparison to
349/// tolerate the RFC 8017 ambiguity between absent and explicit-NULL
350/// `parameters` in rsaEncryption SPKIs (one cert may encode
351/// `AlgorithmIdentifier { oid: rsaEncryption, params: NULL }` while another
352/// encodes the same key with `params: absent`; both represent the same
353/// public key). DN-based cycle detection is intentionally NOT used: in
354/// key-rollover or bridge-CA topologies multiple certs may share a subject
355/// DN with different keys, and treating them as the same node would
356/// incorrectly prune valid paths.
357fn spki_already_in_path(candidate: &Certificate, path: &[Certificate]) -> bool {
358 let candidate_spki = &candidate.tbs_certificate.subject_public_key_info;
359 path.iter().any(|in_path| {
360 let s = &in_path.tbs_certificate.subject_public_key_info;
361 s.algorithm.oid == candidate_spki.algorithm.oid
362 && s.subject_public_key == candidate_spki.subject_public_key
363 })
364}
365
366/// Default DFS node-visit budget per search pass.
367///
368/// Sufficient for legitimate chains (real-world PKI hierarchies have at most
369/// a handful of intermediates and small pools); prevents exponential blow-up
370/// against adversarially constructed pools of O(N) CA certificates with
371/// identical subject/issuer names.
372pub const DEFAULT_DFS_BUDGET: usize = 10_000;
373
374/// Default maximum number of intermediate certificates considered.
375pub const DEFAULT_MAX_DEPTH: usize = 10;
376
377/// Tunable parameters for path building.
378///
379/// Use [`PathBuilderConfig::default`] (or [`PathBuilderConfig::new`]) for the
380/// production defaults. Embedded callers, callers with restricted compute,
381/// and callers handling adversarial pools can tighten these values.
382///
383/// # Stability
384///
385/// Constructed via [`PathBuilderConfig::new`] / `Default`; the struct is
386/// `#[non_exhaustive]` so additional knobs can be added without breaking
387/// existing callers.
388#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
389#[non_exhaustive]
390pub struct PathBuilderConfig {
391 /// Maximum number of intermediates to explore. The depth probe runs at
392 /// `max_depth + 1` to distinguish "no path exists" from "path exists
393 /// but too deep". Default: [`DEFAULT_MAX_DEPTH`].
394 pub max_depth: usize,
395 /// Per-round node-visit budget. Default: [`DEFAULT_DFS_BUDGET`].
396 pub dfs_budget: usize,
397}
398
399impl PathBuilderConfig {
400 /// Construct a config with all knobs set to their default values.
401 #[must_use]
402 pub const fn new() -> Self {
403 Self {
404 max_depth: DEFAULT_MAX_DEPTH,
405 dfs_budget: DEFAULT_DFS_BUDGET,
406 }
407 }
408}
409
410impl Default for PathBuilderConfig {
411 fn default() -> Self {
412 Self::new()
413 }
414}
415
416// =========================================================================
417// PathCandidates iterator (PKIX-mszo)
418// =========================================================================
419
420/// Per-frame DFS state held by [`PathCandidates`].
421///
422/// Each [`Frame`] mirrors one stack frame of the recursive DFS: it holds
423/// the AKI-ranked candidate list for the cert at this depth and a cursor
424/// into that list, plus state-machine flags so a paused-and-resumed DFS
425/// can pick up where it left off without re-running the anchor check or
426/// re-yielding the same chain twice.
427struct Frame {
428 /// Pre-ranked candidate indices (tier, pool index). Stable-sorted
429 /// by tier, lower-tier first. Computed lazily on first use so that
430 /// frames that yield via anchor match never pay the ranking cost.
431 ranked: Option<Vec<(u8, usize)>>,
432 /// Position in `ranked` to try next.
433 cursor: usize,
434 /// True after the anchor-match check has run for this frame.
435 anchor_checked: bool,
436 /// True if this frame yielded a chain via anchor match. On the next
437 /// `next()` call, that frame is immediately backtracked rather than
438 /// trying its candidates (the recursive DFS short-circuits on anchor
439 /// match; the iterator preserves that semantic).
440 anchor_yielded: bool,
441}
442
443impl Frame {
444 const fn new() -> Self {
445 Self {
446 ranked: None,
447 cursor: 0,
448 anchor_checked: false,
449 anchor_yielded: false,
450 }
451 }
452}
453
454/// Iterator over topologically-valid certification paths from a target
455/// cert through a candidate pool to one of a set of trust anchors.
456///
457/// Each [`Iterator::next`] call returns either:
458/// - `Some(Ok(chain))` — the next leaf-first chain `[target, ...,
459/// anchor-issued]` that is topologically valid (DN chain links,
460/// `BasicConstraints cA=TRUE` on every intermediate, no SPKI cycles).
461/// Signatures are NOT verified; downstream callers must run the
462/// returned chain through [`pkix_path::validate_path`].
463/// - `Some(Err(e))` — a fatal error (see [`Error`]). The iterator is
464/// exhausted; subsequent calls return `None`.
465/// - `None` — DFS has been exhausted; no more chains exist within the
466/// configured `max_depth`.
467///
468/// **Resumable DFS**: candidates are explored in AKI-ranked
469/// order (`rank_candidates`); when a chain is yielded, the next call
470/// resumes from the same DFS state and explores alternate paths. This
471/// is the contract S/MIME callers depend on for build-then-validate
472/// retry loops in adversarial pools (CMS bags, federal-bridge cross-cert
473/// topologies, etc.) where the topologically-first chain may not be the
474/// cryptographically-verifying one.
475///
476/// **Bounded enumeration**: a single shared budget (initial value
477/// [`PathBuilderConfig::dfs_budget`]) is decremented once per DFS frame
478/// entry across all `next()` calls. When the budget is exhausted, the
479/// next call returns `Some(Err(`[`Error::BudgetExceeded`]`))` and the
480/// iterator becomes exhausted. This bounds worst-case work to
481/// `O(dfs_budget)` total across the entire iterator's lifetime,
482/// preventing an adversarial pool from causing unbounded enumeration.
483///
484/// **No iterative deepening**: unlike legacy `build_path`, this iterator
485/// performs a single DFS at `max_depth`. Paths are yielded in DFS order
486/// (depth-first, AKI-tier-ordered, then pool insertion order within a
487/// tier). Shortest-first is no longer guaranteed; for typical pools the
488/// first yielded chain is still the shortest, but adversarial pools can
489/// produce a deeper chain first if its branch is explored before a
490/// shallower alternative.
491///
492/// # Examples
493///
494/// Build-then-validate retry loop:
495///
496/// ```ignore
497/// let mut candidates = pkix_path_builder::build_path_candidates(
498/// &target, &pool, &anchors,
499/// );
500/// loop {
501/// match candidates.next() {
502/// None => break Err(NoVerifiableChain),
503/// Some(Err(e)) => break Err(e.into()),
504/// Some(Ok(chain)) => match pkix_path::validate_path(
505/// &chain, &anchors, &policy, &verifier,
506/// ) {
507/// Ok(vp) => break Ok(vp),
508/// Err(_) => continue, // try next candidate
509/// },
510/// }
511/// }
512/// ```
513///
514/// The pattern above is exactly what [`build_first_valid_path`] wraps:
515/// callers that only need "find any chain that validates" should prefer
516/// the helper. Drop down to this iterator when you need per-candidate
517/// diagnostics, want to limit the number of candidates tried, or are
518/// composing the retry loop with additional per-candidate policy
519/// (e.g., per-candidate revocation checks).
520pub struct PathCandidates<'a> {
521 pool: &'a [Certificate],
522 anchors: &'a [pkix_path::TrustAnchor],
523 max_depth: usize,
524 /// Current chain, leaf-first. `path[0]` is the target.
525 path: Vec<Certificate>,
526 /// Frame stack; `frames.len() == path.len()` while the iterator is
527 /// active. When both are empty after `started` was true, the
528 /// iterator is exhausted.
529 frames: Vec<Frame>,
530 /// Shared DFS-frame-entry budget. Decremented once per anchor check
531 /// (one charge per frame entered). Bounded by the configured budget
532 /// across all `next()` calls.
533 budget: usize,
534 /// True after the first `next()` call. Used to lazily push the
535 /// initial frame.
536 started: bool,
537 /// True once the iterator has yielded a fatal error or exhausted
538 /// the search space; subsequent calls return `None`.
539 done: bool,
540}
541
542impl<'a> PathCandidates<'a> {
543 /// Construct a new path-candidate iterator.
544 ///
545 /// The `target`, `pool`, and `anchors` references are borrowed for
546 /// the lifetime of the iterator; `config` is read once at construction
547 /// (its `max_depth` and `dfs_budget` values are copied in).
548 fn new(
549 target: &Certificate,
550 pool: &'a [Certificate],
551 anchors: &'a [pkix_path::TrustAnchor],
552 config: &PathBuilderConfig,
553 ) -> Self {
554 // Pre-seed `path` with the target and `frames` with the initial
555 // frame. This avoids an extra branch in `next()` for the
556 // first-call case and keeps the invariant `path.len() ==
557 // frames.len()` true at all times when the iterator is active.
558 let path = alloc::vec![target.clone()];
559 let frames = alloc::vec![Frame::new()];
560 Self {
561 pool,
562 anchors,
563 max_depth: config.max_depth,
564 path,
565 frames,
566 budget: config.dfs_budget,
567 started: false,
568 done: false,
569 }
570 }
571}
572
573impl<'a> Iterator for PathCandidates<'a> {
574 type Item = Result<Vec<Certificate>>;
575
576 fn next(&mut self) -> Option<Self::Item> {
577 if self.done {
578 return None;
579 }
580
581 // The first call to `next()` finds the first chain (or exhausts
582 // the search). `started` lets us distinguish "iterator just
583 // constructed" (path/frames pre-seeded with the initial target
584 // frame) from "iterator was previously called and yielded a
585 // chain" (resume from the yielded frame).
586 //
587 // When resuming after a yield, the top frame's `anchor_yielded`
588 // flag is true; the loop below will see this and immediately
589 // backtrack from that frame, advancing the parent's candidate
590 // cursor on the next iteration.
591 self.started = true;
592
593 loop {
594 // Empty stack: search space exhausted.
595 if self.frames.is_empty() {
596 self.done = true;
597 return None;
598 }
599
600 // If the top frame already yielded an anchor match on a
601 // prior call, backtrack from it now (the recursive DFS
602 // short-circuits on anchor match without exploring
603 // candidates; the iterator preserves that semantic).
604 if self.frames.last().expect("non-empty").anchor_yielded {
605 self.frames.pop();
606 self.path.pop();
607 continue;
608 }
609
610 // Anchor check: each frame charges 1 unit of budget at this
611 // point (mirrors the recursive DFS's per-call decrement).
612 // Budget exhaustion makes the iterator terminal.
613 if !self.frames.last().expect("non-empty").anchor_checked {
614 if self.budget == 0 {
615 self.done = true;
616 return Some(Err(Error::BudgetExceeded));
617 }
618 self.budget -= 1;
619 self.frames.last_mut().expect("non-empty").anchor_checked = true;
620
621 // Read the issuer DN of the cert at the top of the path
622 // — that is the cert this frame is seeking an issuer
623 // for. The path mirrors frames; path.last() corresponds
624 // to frames.last() at all times.
625 let cur_issuer = &self
626 .path
627 .last()
628 .expect("path mirrors frames; non-empty")
629 .tbs_certificate
630 .issuer;
631 let matched = self
632 .anchors
633 .iter()
634 .any(|a| pkix_path::names_match(&a.subject, cur_issuer));
635 if matched {
636 self.frames.last_mut().expect("non-empty").anchor_yielded = true;
637 return Some(Ok(self.path.clone()));
638 }
639 }
640
641 // Past the anchor check. If this frame is at or beyond the
642 // depth limit, it cannot host any further intermediate
643 // candidates — backtrack. The recursive DFS's
644 // `if depth_remaining == 0 { return Ok(false); }` clause
645 // corresponds to this gate: a frame exists at every depth
646 // up to and including `max_depth + 1` (the deepest frame
647 // performs anchor check then returns immediately).
648 if self.frames.len() > self.max_depth {
649 self.frames.pop();
650 self.path.pop();
651 continue;
652 }
653
654 // Compute candidate ranking lazily — only frames that
655 // survive the anchor check pay the cost.
656 if self.frames.last().expect("non-empty").ranked.is_none() {
657 let cur = self.path.last().expect("non-empty");
658 let ranked = rank_candidates(cur, self.pool);
659 self.frames.last_mut().expect("non-empty").ranked = Some(ranked);
660 }
661
662 // Pull the next candidate index, or backtrack if exhausted.
663 let frame = self.frames.last_mut().expect("non-empty");
664 let ranked = frame.ranked.as_ref().expect("set above");
665 if frame.cursor >= ranked.len() {
666 // No more candidates: backtrack.
667 self.frames.pop();
668 self.path.pop();
669 continue;
670 }
671 let (_tier, idx) = ranked[frame.cursor];
672 frame.cursor += 1;
673
674 let candidate = &self.pool[idx];
675
676 // CA check (BasicConstraints cA=TRUE). Skip-not-fail: a
677 // candidate whose `BasicConstraints` is absent, has
678 // `cA = FALSE`, or fails to DER-decode is treated as
679 // "not a CA" and silently skipped. This keeps DFS alive
680 // when the certificate pool carries unsolicited or corrupt
681 // certs — e.g. CMS `SignedData.certificates` bags routinely
682 // include certs the verifier did not solicit (other
683 // recipients in a multi-recipient message, intermediates
684 // from unrelated CAs that rode along, expired or corrupt
685 // certs from someone's pipeline). One bad cert in the bag
686 // must not poison verification of an otherwise-valid chain.
687 //
688 // The error itself is not lost: when no path can be built
689 // and skipping malformed candidates is what prevented one,
690 // `build_path` returns `Error::NoPathFound`, indistinguishable
691 // from any other no-path case. Callers that want diagnostic
692 // detail ("why didn't this path build?") need a future
693 // diagnostic mode; that is out of scope for skip-not-fail.
694 if !cert_is_ca(candidate) {
695 continue;
696 }
697
698 // SPKI cycle guard.
699 if spki_already_in_path(candidate, &self.path) {
700 continue;
701 }
702
703 // Eligible: push the candidate onto path/frames and let the
704 // outer loop iterate, processing the new top frame.
705 self.path.push(candidate.clone());
706 self.frames.push(Frame::new());
707 }
708 }
709}
710
711/// Construct a [`PathCandidates`] iterator using the workspace defaults
712/// ([`DEFAULT_MAX_DEPTH`], [`DEFAULT_DFS_BUDGET`]).
713///
714/// See [`PathCandidates`] for usage and semantics.
715#[must_use]
716pub fn build_path_candidates<'a>(
717 target: &Certificate,
718 pool: &'a CertPool,
719 anchors: &'a [pkix_path::TrustAnchor],
720) -> PathCandidates<'a> {
721 PathCandidates::new(target, pool.as_slice(), anchors, &PathBuilderConfig::new())
722}
723
724/// Construct a [`PathCandidates`] iterator with caller-provided budget
725/// and depth tunables.
726///
727/// See [`PathCandidates`] for usage and semantics, and
728/// [`PathBuilderConfig`] for the individual knobs.
729#[must_use]
730pub fn build_path_candidates_with_config<'a>(
731 target: &Certificate,
732 pool: &'a CertPool,
733 anchors: &'a [pkix_path::TrustAnchor],
734 config: &PathBuilderConfig,
735) -> PathCandidates<'a> {
736 PathCandidates::new(target, pool.as_slice(), anchors, config)
737}
738
739// =========================================================================
740// build_path / build_path_with_config — single-shot wrappers
741// =========================================================================
742
743/// Build a certification path from `target` through certificates in `pool`
744/// to one of the provided trust anchors.
745///
746/// Returns the ordered chain `[target, intermediate..., anchor-issued]` ready
747/// for [`pkix_path::validate_path`]. Signatures are **not** verified here;
748/// that is the responsibility of the caller via [`pkix_path::validate_path`].
749///
750/// # Algorithm
751///
752/// Single-pass depth-first search at the configured `max_depth`. Candidates
753/// at each frame are ordered by AKI/SKI tier (RFC 5280 §4.2.1.1) so
754/// disambiguating bridge-CA / cross-cert topologies succeeds on the first
755/// candidate when AKI/SKI bindings are well-formed. Cycles are detected by
756/// `SubjectPublicKeyInfo` algorithm OID + raw public-key bits; algorithm
757/// parameters are excluded so RFC 8017 absent-vs-NULL ambiguity in
758/// rsaEncryption SPKIs does not break detection.
759///
760/// This is a thin wrapper over the [`PathCandidates`] iterator: it returns
761/// the iterator's first yield, or invokes a depth+1 probe (with fresh
762/// budget) on `None` to distinguish [`Error::NoPathFound`] from
763/// [`Error::DepthExceeded`].
764///
765/// # Errors
766///
767/// - [`Error::NoPathFound`] — no topologically valid path through `pool` leads
768/// to any of the given trust anchors.
769/// - [`Error::DepthExceeded`] — a path exists topologically but requires more
770/// than [`PathBuilderConfig::max_depth`] intermediate certificates.
771/// - [`Error::BudgetExceeded`] — the DFS frame-entry budget was exhausted
772/// before a path was found; the pool may be adversarially large or
773/// structured to produce exponential search.
774///
775/// # Choosing between `build_path`, the iterator, and `build_first_valid_path`
776///
777/// Use this single-shot API when:
778/// - the pool is from a trusted source (in-house cert store, configured
779/// intermediate bundle), and
780/// - finding any topologically valid chain is sufficient (the caller does
781/// not need to retry with alternate chains if signature verification
782/// fails downstream).
783///
784/// Use [`build_path_candidates`] (or its `_with_config` sibling) when you
785/// want full control over candidate iteration — for adversarial pools (CMS
786/// `SignedData.certificates` bags, federal-bridge cross-cert topologies,
787/// anywhere the wire-order of certs is not under your control) so failed
788/// signature verification can be retried against the next candidate path.
789/// See [`PathCandidates`] for the build-then-validate retry-loop pattern.
790///
791/// Use [`build_first_valid_path`] (or its `_with_config` sibling) for the
792/// common case of "iterate candidates until one validates": it wraps the
793/// iterator + [`pkix_path::validate_path`] retry loop and returns the
794/// first chain that survives both topological build and signature
795/// verification. Prefer this over `build_path` when the pool contains
796/// alternatives whose signatures may be rejected by the verifier (e.g.,
797/// cross-signed intermediates using algorithms outside the verifier's
798/// dispatch table).
799///
800/// # Limitations
801///
802/// **Candidate selection uses AKI/SKI as an ordering heuristic, not a
803/// security gate.** When the cert seeking an issuer carries an
804/// `AuthorityKeyIdentifier` extension with a `keyIdentifier` field
805/// (RFC 5280 §4.2.1.1), pool candidates whose `SubjectKeyIdentifier`
806/// (§4.2.1.2) matches are tried before DN-only matches. This is
807/// best-effort disambiguation for bridge-CA and key-rollover topologies
808/// where multiple CA certs share an issuer DN. The signature itself is
809/// **not** verified by this crate — that happens downstream in
810/// [`pkix_path::validate_path`]. Consequences:
811///
812/// - When the AKI heuristic picks the wrong candidate (e.g., AKI is
813/// absent or malformed, multiple candidates share the same SKI, or
814/// the AKI/SKI binding is wrong), the returned chain may fail
815/// `validate_path` with `SignatureInvalid` rather than
816/// [`Error::NoPathFound`] here. Callers handling adversarial pools
817/// should use [`build_path_candidates`] to retry alternate chains.
818/// - Malformed AKI or SKI extensions are treated as if absent (fail-soft).
819/// They do not cause path building to abort; they simply degrade
820/// selection to DN-only ranking for that cert.
821/// - The AKI `authorityCertIssuer` + `authorityCertSerialNumber` fields
822/// (the rare alternative to `keyIdentifier`) are not currently used for
823/// ranking. Only the `keyIdentifier` field participates.
824///
825/// **Anchor matching is by DN only.** When a candidate's issuer DN matches
826/// any anchor in `anchors`, path building terminates immediately with that
827/// chain — the anchor's `SubjectPublicKeyInfo` is **not** verified against
828/// what the chain expects.
829///
830/// **Shortest-first is no longer guaranteed.** Earlier versions of this
831/// crate used iterative-deepening DFS to return the shortest topology
832/// first. The single-pass DFS used now (which shares state with the
833/// `PathCandidates` iterator) yields paths in depth-first order. For
834/// typical pools the first yielded chain is still the shortest; for
835/// adversarial pools, a deeper chain may be returned first if its branch
836/// is explored before a shallower alternative. If shortest-first matters,
837/// inspect the returned chain length and (rarely) re-run with a tightened
838/// `max_depth`.
839///
840/// # Security
841///
842/// Pool contents should be from a trusted source. The DFS frame-entry
843/// budget enforces a hard cap on search work to prevent denial-of-service
844/// via oversized or crafted pools.
845pub fn build_path(
846 target: &Certificate,
847 pool: &CertPool,
848 anchors: &[pkix_path::TrustAnchor],
849) -> Result<Vec<Certificate>> {
850 build_path_with_config(target, pool, anchors, &PathBuilderConfig::new())
851}
852
853/// Build a certification path with caller-provided budget and depth tunables.
854///
855/// Behaves identically to [`build_path`] but uses the limits in `config`
856/// instead of the workspace defaults. See [`PathBuilderConfig`] for the
857/// individual knobs and [`build_path`] for full semantics.
858///
859/// # Errors
860///
861/// Same as [`build_path`].
862pub fn build_path_with_config(
863 target: &Certificate,
864 pool: &CertPool,
865 anchors: &[pkix_path::TrustAnchor],
866 config: &PathBuilderConfig,
867) -> Result<Vec<Certificate>> {
868 let pool_slice = pool.as_slice();
869
870 // First pass at the configured max_depth.
871 let mut iter = PathCandidates::new(target, pool_slice, anchors, config);
872 match iter.next() {
873 Some(Ok(chain)) => return Ok(chain),
874 Some(Err(e)) => return Err(e),
875 None => {}
876 }
877
878 // Iterator exhausted at max_depth. Probe at max_depth+1 to distinguish
879 // NoPathFound from DepthExceeded. The probe gets a fresh budget (it is
880 // a brand-new PathCandidates), matching the legacy iterative-deepening
881 // probe's behaviour.
882 let probe_config = PathBuilderConfig {
883 max_depth: config.max_depth.saturating_add(1),
884 dfs_budget: config.dfs_budget,
885 };
886 let mut probe = PathCandidates::new(target, pool_slice, anchors, &probe_config);
887 match probe.next() {
888 Some(Ok(_)) => Err(Error::DepthExceeded),
889 Some(Err(e)) => Err(e),
890 None => Err(Error::NoPathFound),
891 }
892}
893
894// =========================================================================
895// build_first_valid_path — candidate iteration + signature verification
896// =========================================================================
897
898/// Build a certification path that both **(a)** is topologically valid through
899/// `pool` to one of `anchors` and **(b)** passes
900/// [`pkix_path::validate_path`] under `policy` and `verifier`.
901///
902/// Iterates [`build_path_candidates`] until the first candidate chain
903/// validates. Returns the validating chain. If every candidate is rejected
904/// by `validate_path`, returns [`Error::NoValidPath`] carrying the count of
905/// candidates tried and the `Display` rendering of the last
906/// [`pkix_path::Error`].
907///
908/// # When to use this over [`build_path`]
909///
910/// [`build_path`] is single-shot: it returns the first DFS candidate without
911/// any knowledge of which signature algorithms `verifier` actually dispatches
912/// or which intermediates are within their validity window at `policy`'s
913/// validation time. In adversarial pools — for example, cross-signed graphs
914/// that include an alternative intermediate signed under
915/// `ecdsa-with-SHA1` (RFC 5758 §3.2 legacy OID, not dispatched by
916/// [`pkix_path::DefaultVerifier`]) — the first DFS yield can be rejected by
917/// `validate_path` even though a SHA-256-only path exists in the same pool.
918///
919/// `build_first_valid_path` closes this gap: it iterates
920/// [`build_path_candidates`] and tries `validate_path` per yielded chain,
921/// returning the first chain that survives both passes.
922///
923/// # Errors
924///
925/// - [`Error::NoPathFound`] — the underlying iterator yielded no candidates
926/// at all (no topologically valid chain through `pool` to any anchor).
927/// Matches [`build_path`]'s behaviour for that case.
928/// - [`Error::DepthExceeded`] / [`Error::BudgetExceeded`] — propagated from
929/// [`build_path_candidates`] when the iterator surfaces them.
930/// - [`Error::NoValidPath`] — at least one candidate was yielded but none
931/// passed `validate_path`. Carries `tried` (>= 1) and the last
932/// `validate_path` error rendering.
933///
934/// # Out of scope
935///
936/// - Async / parallel candidate evaluation. Candidates are tried sequentially.
937/// - Caching of `validate_path` failures across candidates. Each yielded
938/// chain is freshly validated.
939/// - Promoting [`build_path`] itself to iterate. The single-shot helper is
940/// retained verbatim for backward compatibility; callers opt in to the
941/// iterating semantics by using this function.
942///
943/// # Relationship to other path-builder entry points
944///
945/// | Entry point | Verifier? | Returns |
946/// |------------------------------|-----------|----------------------------------------------|
947/// | [`build_path`] | No | First DFS topological candidate (one-shot) |
948/// | [`build_path_candidates`] | No | Iterator of topological candidates |
949/// | [`build_first_valid_path`] | Yes | First candidate that passes `validate_path` |
950pub fn build_first_valid_path<V>(
951 target: &Certificate,
952 pool: &CertPool,
953 anchors: &[pkix_path::TrustAnchor],
954 policy: &pkix_path::ValidationPolicy,
955 verifier: &V,
956) -> Result<Vec<Certificate>>
957where
958 V: pkix_path::SignatureVerifier,
959{
960 build_first_valid_path_with_config(
961 target,
962 pool,
963 anchors,
964 policy,
965 verifier,
966 &PathBuilderConfig::new(),
967 )
968}
969
970/// Build a verifier-validated certification path with caller-provided
971/// budget and depth tunables.
972///
973/// Behaves identically to [`build_first_valid_path`] but uses the limits
974/// in `config` instead of the workspace defaults. See
975/// [`PathBuilderConfig`] for the individual knobs and
976/// [`build_first_valid_path`] for full semantics.
977///
978/// # Errors
979///
980/// Same as [`build_first_valid_path`].
981pub fn build_first_valid_path_with_config<V>(
982 target: &Certificate,
983 pool: &CertPool,
984 anchors: &[pkix_path::TrustAnchor],
985 policy: &pkix_path::ValidationPolicy,
986 verifier: &V,
987 config: &PathBuilderConfig,
988) -> Result<Vec<Certificate>>
989where
990 V: pkix_path::SignatureVerifier,
991{
992 let mut iter = PathCandidates::new(target, pool.as_slice(), anchors, config);
993 let mut tried: usize = 0;
994 let mut last_error: Option<alloc::string::String> = None;
995
996 loop {
997 match iter.next() {
998 Some(Ok(chain)) => {
999 match pkix_path::validate_path(&chain, anchors, policy, verifier) {
1000 Ok(_) => return Ok(chain),
1001 Err(e) => {
1002 tried += 1;
1003 last_error = Some(alloc::format!("{e}"));
1004 // Continue to the next candidate.
1005 }
1006 }
1007 }
1008 // The iterator surfaces its own errors (BudgetExceeded; in
1009 // principle future variants). Propagate verbatim. We do NOT
1010 // wrap these in NoValidPath because they describe failures
1011 // of the topological search, not of signature verification.
1012 Some(Err(e)) => return Err(e),
1013 // Iterator exhausted.
1014 None => {
1015 return match last_error {
1016 // At least one candidate was yielded; every one was
1017 // rejected by validate_path.
1018 Some(last) => Err(Error::NoValidPath {
1019 tried,
1020 last_error: last,
1021 }),
1022 // Zero candidates ever yielded. Distinguish
1023 // NoPathFound from DepthExceeded the same way
1024 // build_path does: probe at max_depth + 1.
1025 None => {
1026 let probe_config = PathBuilderConfig {
1027 max_depth: config.max_depth.saturating_add(1),
1028 dfs_budget: config.dfs_budget,
1029 };
1030 let mut probe =
1031 PathCandidates::new(target, pool.as_slice(), anchors, &probe_config);
1032 match probe.next() {
1033 Some(Ok(_)) => Err(Error::DepthExceeded),
1034 Some(Err(e)) => Err(e),
1035 None => Err(Error::NoPathFound),
1036 }
1037 }
1038 };
1039 }
1040 }
1041 }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 //! Unit tests for the private AKI/SKI extraction helpers.
1047 //!
1048 //! Independent oracle: byte values were derived by running
1049 //! `openssl x509 -text` on the PKITS DER fixtures and pasting the
1050 //! displayed `Authority Key Identifier` / `Subject Key Identifier`
1051 //! hex bytes into the test expectations. The helpers are *not* used
1052 //! to compute the expected values — they are checked against the
1053 //! external openssl-derived ground truth.
1054 #[allow(unused_extern_crates)] // load-bearing under #![no_std] default features
1055 extern crate std;
1056
1057 use super::{cert_aki_key_id, cert_ski_key_id};
1058 use der::Decode as _;
1059 use std::path::PathBuf;
1060 use x509_cert::Certificate;
1061
1062 fn pkits_cert(name: &str) -> Certificate {
1063 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1064 .join("../pkix-path/tests/pkits/certs")
1065 .join(std::format!("{name}.crt"));
1066 let bytes = std::fs::read(&path)
1067 .unwrap_or_else(|e| std::panic!("fixture not found at {}: {}", path.display(), e));
1068 Certificate::from_der(&bytes).unwrap_or_else(|e| std::panic!("failed to parse {name}: {e}"))
1069 }
1070
1071 #[test]
1072 fn cert_aki_key_id_test4ee_matches_oldkey_ski() {
1073 // Test4EE.AKI.keyIdentifier (per `openssl x509 -text` on the fixture):
1074 // DD:0D:75:8D:53:68:12:C4:CB:15:40:C0:14:86:14:16:30:A1:BE:AF
1075 const EXPECTED: [u8; 20] = [
1076 0xdd, 0x0d, 0x75, 0x8d, 0x53, 0x68, 0x12, 0xc4, 0xcb, 0x15, 0x40, 0xc0, 0x14, 0x86,
1077 0x14, 0x16, 0x30, 0xa1, 0xbe, 0xaf,
1078 ];
1079 let ee = pkits_cert("ValidBasicSelfIssuedNewWithOldTest4EE");
1080 let aki = cert_aki_key_id(&ee).expect("Test4EE has an AKI extension");
1081 assert_eq!(aki.as_slice(), &EXPECTED);
1082 }
1083
1084 #[test]
1085 fn cert_ski_key_id_oldkey_matches_test4ee_aki() {
1086 // BasicSelfIssuedOldKeyCACert.SKI must equal Test4EE.AKI.keyIdentifier.
1087 // Same hex bytes as the AKI test above; parsed independently from a
1088 // different DER file via a different code path.
1089 const EXPECTED: [u8; 20] = [
1090 0xdd, 0x0d, 0x75, 0x8d, 0x53, 0x68, 0x12, 0xc4, 0xcb, 0x15, 0x40, 0xc0, 0x14, 0x86,
1091 0x14, 0x16, 0x30, 0xa1, 0xbe, 0xaf,
1092 ];
1093 let oldkey = pkits_cert("BasicSelfIssuedOldKeyCACert");
1094 let ski = cert_ski_key_id(&oldkey).expect("OldKeyCACert has an SKI extension");
1095 assert_eq!(ski.as_slice(), &EXPECTED);
1096 }
1097
1098 #[test]
1099 fn cert_ski_key_id_bridge_ca_differs_from_oldkey() {
1100 // BasicSelfIssuedOldKeyNewWithOldCACert shares a subject DN with
1101 // OldKeyCACert but has a distinct SPKI and SKI:
1102 // 88:5F:BE:3F:35:39:66:9A:EB:4D:C2:26:1B:26:B1:2A:27:B5:08:2A
1103 // This is the disambiguation signal AKI ranking exploits.
1104 const EXPECTED: [u8; 20] = [
1105 0x88, 0x5f, 0xbe, 0x3f, 0x35, 0x39, 0x66, 0x9a, 0xeb, 0x4d, 0xc2, 0x26, 0x1b, 0x26,
1106 0xb1, 0x2a, 0x27, 0xb5, 0x08, 0x2a,
1107 ];
1108 let bridge = pkits_cert("BasicSelfIssuedOldKeyNewWithOldCACert");
1109 let ski = cert_ski_key_id(&bridge).expect("bridge cert has an SKI extension");
1110 assert_eq!(ski.as_slice(), &EXPECTED);
1111 }
1112
1113 #[test]
1114 fn cert_aki_key_id_returns_none_when_aki_absent() {
1115 // The PKITS trust anchor cert is self-signed and (per its DER) has
1116 // NO AuthorityKeyIdentifier extension — only a SubjectKeyIdentifier.
1117 // The helper must return None, exercising the early-return branch
1118 // in cert_aki_key_id.
1119 let anchor = pkits_cert("TrustAnchorRootCertificate");
1120 assert!(cert_aki_key_id(&anchor).is_none());
1121 }
1122
1123 #[test]
1124 fn cert_ski_key_id_present_on_trust_anchor() {
1125 // Trust anchor's SKI per `openssl x509 -text`:
1126 // E4:7D:5F:D1:5C:95:86:08:2C:05:AE:BE:75:B6:65:A7:D9:5D:A8:66
1127 // Round-trips the same bytes that downstream certs reference via
1128 // their AKI.keyIdentifier (AKI/SKI binding cross-check).
1129 const EXPECTED: [u8; 20] = [
1130 0xe4, 0x7d, 0x5f, 0xd1, 0x5c, 0x95, 0x86, 0x08, 0x2c, 0x05, 0xae, 0xbe, 0x75, 0xb6,
1131 0x65, 0xa7, 0xd9, 0x5d, 0xa8, 0x66,
1132 ];
1133 let anchor = pkits_cert("TrustAnchorRootCertificate");
1134 let ski = cert_ski_key_id(&anchor).expect("trust anchor has an SKI");
1135 assert_eq!(ski.as_slice(), &EXPECTED);
1136 }
1137}