pkix-revocation 0.3.0

Certificate revocation checking (CRL, OCSP) for pkix-path
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
#![cfg_attr(not(feature = "std"), no_std)]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![forbid(unsafe_code)]
#![warn(missing_docs, rust_2018_idioms)]

//! Certificate revocation checking for `pkix-path` and `pkix-chain`.
//!
//! Provides the [`RevocationChecker`] trait and implementations:
//!
//! | Type | Feature | Description |
//! |---|---|---|
//! | [`NoRevocation`] | (always) | Zero-cost; always reports not-revoked |
//! | `CrlChecker` | `crl` | Offline CRL validation (you supply DER bytes) |
//! | `OcspChecker` | `ocsp` | Offline OCSP response validation |
//!
//! # `no_std` note
//!
//! The core trait and `NoRevocation` are `no_std`. Feature-gated checkers
//! that perform network I/O are `std`-only and gated behind separate features.
//!
//! # Security: anchor-issued certificate revocation
//!
//! [`RevocationChecker::check_revocation_against_anchor`] has a default
//! implementation that returns `Ok(())` (i.e., skips the check). Implementors
//! that require **full-chain** revocation coverage — including the certificate
//! issued directly by a trust anchor — **MUST** override this method. Failing
//! to override it will silently leave the anchor-issued certificate unchecked
//! with no compile error or runtime warning. See that method's documentation
//! for details.

use pkix_path::TrustAnchor;
use x509_cert::{ext::pkix::crl::CrlReason, serial_number::SerialNumber, Certificate};

/// Opaque wrapper around an underlying ASN.1 / DER error.
///
/// Carries a [`Display`] message identical to the wrapped `der::Error` so
/// diagnostic output is preserved, but does not expose the underlying type
/// in the public API. This insulates callers from semver-breaking changes
/// in the `der` crate's error variants.
///
/// [`Display`]: core::fmt::Display
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DerError(der::Error);

impl core::fmt::Display for DerError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        core::fmt::Display::fmt(&self.0, f)
    }
}

#[cfg(feature = "std")]
impl std::error::Error for DerError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.0)
    }
}

/// Reason a revocation check produced no determination.
///
/// Carried by [`Error::OutOfScope`] to identify which scope-mismatch case the
/// checker hit. Distinct from `Crl*Error` (parse / signature / validity
/// failures): an `OutOfScope` outcome is structurally well-formed but the
/// revocation source's stated scope excludes the certificate being checked.
///
/// Hard-fail callers should treat any `OutOfScope` as a failure (no
/// revocation determination was made). Soft-fail callers can match on the
/// reason and decide which scopes to tolerate (for example, treating
/// `CrlOnlyAttributeCerts` as "expected and tolerable" while still hard-failing
/// on `CrlOnlyCaCerts` when checking a CA certificate).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OutOfScopeReason {
    /// The CRL's `IssuingDistributionPoint` extension has
    /// `onlyContainsAttributeCerts = true`. Attribute-certificate revocation
    /// is out of scope for `pkix-revocation` (RFC 5755 attribute certificates
    /// are handled by `pkix-ac`); the certificate being checked is a public-key
    /// certificate, so the CRL cannot apply.
    CrlOnlyAttributeCerts,
    /// The CRL's `IssuingDistributionPoint` extension has
    /// `onlyContainsUserCerts = true` but the certificate being checked is a
    /// CA certificate (`BasicConstraints` `cA = TRUE`).
    CrlOnlyUserCerts,
    /// The CRL's `IssuingDistributionPoint` extension has
    /// `onlyContainsCACerts = true` but the certificate being checked is not a
    /// CA certificate.
    CrlOnlyCaCerts,
}

impl core::fmt::Display for OutOfScopeReason {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::CrlOnlyAttributeCerts => f.write_str(
                "CRL onlyContainsAttributeCerts=TRUE; subject is a public-key certificate",
            ),
            Self::CrlOnlyUserCerts => {
                f.write_str("CRL onlyContainsUserCerts=TRUE; subject is a CA certificate")
            }
            Self::CrlOnlyCaCerts => {
                f.write_str("CRL onlyContainsCACerts=TRUE; subject is an end-entity certificate")
            }
        }
    }
}

/// Errors returned by revocation checking.
///
/// # Variant naming convention
///
/// Most variants carry a `Crl*` or `Ocsp*` prefix indicating which revocation
/// source produced the failure. Four variants intentionally do not:
///
/// - [`Error::Revoked`] applies to both CRL and OCSP outcomes; no prefix is
///   correct. This is what [`RevocationChecker::check_revocation`] returns
///   generically when a serial is found in either kind of response.
/// - [`Error::MalformedCertificate`] fires on the *subject* certificate being
///   checked (e.g., a missing serial number), not on the CRL or OCSP response.
/// - [`Error::DeltaCrlBaseMismatch`] uses `DeltaCrl*` rather than `CrlDelta*`
///   because the failure is scoped to the delta-CRL workflow — the prefix
///   reads as the noun phrase "delta CRL" rather than as a sub-namespace of
///   `Crl*`.
/// - [`Error::OutOfScope`] applies whenever a revocation source's stated
///   scope excludes the certificate being checked. Today only CRL `IDP`
///   scope mismatches produce this; the variant is named generically so that
///   future OCSP / SCT / OCSP-stapling scope-mismatch cases can reuse it
///   without an additional rename.
///
/// Renames are a semver break; do not "normalize" these without coordinating
/// a major version.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
    /// The certificate has been revoked.
    Revoked {
        /// Serial number of the revoked certificate (for logging/diagnostics).
        serial: SerialNumber,
        /// RFC 5280 §5.3.1 reason code from the CRL/OCSP entry, if present.
        /// `None` means no reason code was provided.
        reason_code: Option<CrlReason>,
    },

    /// The CRL validity window check failed.
    ///
    /// This covers two cases:
    /// - `now < thisUpdate`: the CRL is not yet valid (clock skew or future-dated CRL)
    /// - `now > nextUpdate`: the CRL has expired
    /// - `nextUpdate` absent: treated as expired (no expiry information means stale)
    CrlExpired,

    /// The CRL issuer name does not match the certificate's issuer.
    ///
    /// The CRL's `issuer` field must match the certificate's `issuer` field for the
    /// CRL to apply to that certificate. A mismatch indicates the wrong CRL was provided.
    CrlIssuerMismatch,

    /// The CRL signature did not verify against the issuer's SPKI.
    CrlSignatureInvalid,

    /// DER decoding of a CRL failed.
    CrlParseError(DerError),

    /// An OCSP response signature did not verify against the responder's key.
    OcspSignatureInvalid,

    /// The OCSP `ResponderId` does not match the expected issuer identity.
    ///
    /// Returned when the `byName` DN or `byKey` SHA-1 hash in the OCSP response
    /// does not match the issuer (or trust anchor) used for this check.
    ///
    /// - `byName`: the name in the `ResponderId` does not match the issuer's
    ///   subject DN (RFC 4518 comparison).
    /// - `byKey`: the hash in the `ResponderId` does not match SHA-1 of the
    ///   issuer's `subjectPublicKey` bit string (raw bytes, with tag, length,
    ///   and unused-bits prefix stripped — not SHA-1 of the full SPKI DER).
    ///
    /// This is a distinct failure from [`Error::OcspSignatureInvalid`]: the
    /// response may be cryptographically valid, but it was produced by a
    /// different responder than expected.
    OcspResponderIdMismatch,

    /// The OCSP response's `CertID` issuer hashes do not match the expected issuer.
    ///
    /// The `issuerNameHash` or `issuerKeyHash` field in a `SingleResponse`
    /// identifies which issuer the status assertion covers. A mismatch means
    /// the response was produced for a certificate from a *different* CA
    /// (or was tampered with) — it is not a responder-reported "unknown"
    /// status. Callers MUST NOT treat this error as "try another responder".
    OcspCertIdMismatch,

    /// The `issuer` argument passed to [`RevocationChecker::check_revocation`] is
    /// not the issuer of `cert`.
    ///
    /// This is a caller-contract violation: the subject DN of `issuer` does not
    /// match the issuer DN of `cert`. The OCSP response was not consulted.
    OcspIssuerCertMismatch,

    /// The OCSP responder returned an `unknown` status (hard-fail mode).
    OcspStatusUnknown,

    /// The OCSP response's validity window is in the past (stale) or absent.
    ///
    /// Returned in two cases:
    /// - `now > nextUpdate`: the `SingleResponse` has expired
    /// - `nextUpdate` absent: no freshness guarantee is available; treated as stale
    OcspExpired,

    /// DER decoding of an OCSP response failed.
    OcspParseError(DerError),

    /// The OCSP response is structurally invalid per RFC 6960 but DER-decodable.
    ///
    /// Currently returned in two cases:
    /// - `responseBytes` is absent in a `Successful` response (RFC 6960 §4.2.1)
    /// - `responseType` is not `id-pkix-ocsp-basic` (unrecognized response format)
    OcspMalformed,

    /// The CRL issuer certificate does not have the `cRLSign` bit set in `KeyUsage`
    /// (RFC 5280 §6.3.3(f)).
    CrlSignMissing,

    /// The base/delta CRL pair cannot be used together.
    ///
    /// Returned in any of these cases:
    /// - The supplied "base" CRL is itself a delta CRL (has a `deltaCRLIndicator`
    ///   extension) — RFC 5280 §5.2.4 requires a full CRL as the base.
    /// - The supplied "delta" CRL has no `deltaCRLIndicator` extension and is
    ///   therefore not a delta CRL at all.
    /// - The base and delta CRL have different issuers.
    ///
    /// Note: when the delta's `BaseCRLNumber` exceeds the base CRL's `CRLNumber`
    /// (a staleness mismatch), [`Error::CrlNumberMismatch`] is returned instead.
    DeltaCrlBaseMismatch,

    /// The CRL's CRL number is lower than expected (base CRL must have a number
    /// ≥ the delta's `BaseCRLNumber`).
    CrlNumberMismatch,

    /// A subject certificate's `BasicConstraints` extension is present but
    /// could not be DER-decoded.
    ///
    /// Returned when the IDP scope check (`onlyContainsCACerts` /
    /// `onlyContainsUserCerts`) cannot determine whether a CRL applies to
    /// `cert` because `cert`'s own `BasicConstraints` is malformed.
    /// This is a fail-closed alternative to silently treating the cert as
    /// not-a-CA (which would let CA-scoped CRLs be skipped for an actual CA).
    MalformedCertificate,

    /// The revocation source's stated scope excludes the certificate being
    /// checked, so the checker made **no determination** about its revocation
    /// status.
    ///
    /// This is distinct from "verified not-revoked" (the historic ambiguous
    /// `Ok(())` return that this variant replaces). Hard-fail callers should
    /// treat any `OutOfScope` as a failure; soft-fail callers can match on
    /// the [`OutOfScopeReason`] and decide which scopes to tolerate.
    ///
    /// Currently produced by [`CrlChecker`] for the three
    /// `IssuingDistributionPoint` scope-flag mismatches in RFC 5280 §5.2.5
    /// (`onlyContainsAttributeCerts`, `onlyContainsUserCerts`, and
    /// `onlyContainsCACerts`). [`OcspChecker`] does **not** produce this
    /// variant: it returns [`Error::OcspStatusUnknown`] when no matching
    /// `SingleResponse` is found, which is its analogue of "not covered" and
    /// already fail-closed.
    ///
    /// [`CrlChecker`]: crate::CrlChecker
    /// [`OcspChecker`]: crate::OcspChecker
    OutOfScope(OutOfScopeReason),
}

impl core::fmt::Display for Error {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Revoked {
                serial,
                reason_code,
            } => match reason_code {
                Some(code) => write!(
                    f,
                    "certificate {serial} is revoked (reason {})",
                    crl_reason_name(*code)
                ),
                None => write!(f, "certificate {serial} is revoked"),
            },
            Self::CrlExpired => f.write_str("CRL validity window check failed"),
            Self::CrlIssuerMismatch => f.write_str("CRL issuer does not match certificate issuer"),
            Self::CrlSignatureInvalid => f.write_str("CRL signature is invalid"),
            Self::CrlParseError(e) => write!(f, "CRL parse error: {e}"),
            Self::OcspSignatureInvalid => f.write_str("OCSP response signature is invalid"),
            Self::OcspResponderIdMismatch => {
                f.write_str("OCSP ResponderId does not match the expected issuer identity")
            }
            Self::OcspCertIdMismatch => {
                f.write_str("OCSP CertID issuer hashes do not match the expected issuer")
            }
            Self::OcspIssuerCertMismatch => f.write_str(
                "issuer certificate subject DN does not match the certificate's issuer DN",
            ),
            Self::OcspStatusUnknown => f.write_str("OCSP responder returned unknown status"),
            Self::OcspExpired => f.write_str("OCSP response is stale or has no nextUpdate"),
            Self::OcspParseError(e) => write!(f, "OCSP response parse error: {e}"),
            Self::OcspMalformed => {
                f.write_str("OCSP response is structurally invalid (malformed per RFC 6960)")
            }
            Self::CrlSignMissing => {
                f.write_str("CRL issuer KeyUsage does not include cRLSign (RFC 5280 §6.3.3(f))")
            }
            Self::DeltaCrlBaseMismatch => {
                f.write_str("delta CRL BaseCRLNumber does not match the base CRL's CRLNumber")
            }
            Self::CrlNumberMismatch => f.write_str("CRL number is lower than expected"),
            Self::MalformedCertificate => f.write_str(
                "certificate BasicConstraints extension is present but cannot be decoded",
            ),
            Self::OutOfScope(reason) => {
                write!(f, "revocation source out of scope: {reason}")
            }
        }
    }
}

/// Map a `CrlReason` variant to its RFC 5280 §5.3.1 camelCase name.
const fn crl_reason_name(r: CrlReason) -> &'static str {
    match r {
        CrlReason::Unspecified => "unspecified",
        CrlReason::KeyCompromise => "keyCompromise",
        CrlReason::CaCompromise => "cACompromise",
        CrlReason::AffiliationChanged => "affiliationChanged",
        CrlReason::Superseded => "superseded",
        CrlReason::CessationOfOperation => "cessationOfOperation",
        CrlReason::CertificateHold => "certificateHold",
        CrlReason::RemoveFromCRL => "removeFromCRL",
        CrlReason::PrivilegeWithdrawn => "privilegeWithdrawn",
        CrlReason::AaCompromise => "aACompromise",
    }
}

#[cfg(feature = "std")]
impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::CrlParseError(e) | Self::OcspParseError(e) => Some(e),
            _ => None,
        }
    }
}

/// Result alias for this crate.
pub type Result<T> = core::result::Result<T, Error>;

/// Pluggable revocation checking.
///
/// Called once per certificate in the chain, in leaf-to-issuer order,
/// after path signature validation has succeeded.
///
/// Implement this trait to plug CRL, OCSP, or a custom revocation mechanism
/// into `pkix_chain::verify_chain`. Use [`NoRevocation`] for offline or
/// embedded environments.
/// # Implementing this trait
///
/// Implementors MUST provide [`RevocationChecker::check_revocation`].
///
/// Implementors that want **full-chain** revocation coverage — i.e., revocation
/// checking for every certificate including the one issued directly by a trust
/// anchor — MUST also override
/// [`RevocationChecker::check_revocation_against_anchor`]. The default
/// implementation skips the check silently; forgetting to override it will
/// leave the anchor-issued certificate unchecked with no compile error or
/// runtime warning.
pub trait RevocationChecker {
    /// Check whether `cert` has been revoked.
    ///
    /// - `cert`   — the certificate being checked
    /// - `issuer` — the certificate that issued `cert` (signature-validated)
    ///
    /// # Return value
    ///
    /// `Ok(())` means **verified not-revoked**: the revocation source covers
    /// this certificate and the serial number was not found in the revoked
    /// list. This is an unambiguous "not revoked" determination.
    ///
    /// "Not covered" — i.e., the revocation source's scope excludes the
    /// certificate so no determination was made — surfaces as
    /// <code>Err([Error::OutOfScope]([OutOfScopeReason]))</code> for CRL
    /// scope-flag mismatches and as
    /// <code>Err([Error::OcspStatusUnknown])</code> for OCSP responses with no
    /// matching `SingleResponse`. Hard-fail callers should treat both as
    /// failures; soft-fail callers can match on the specific variant /
    /// reason and decide which non-determinations to tolerate.
    ///
    /// # Errors
    ///
    /// - [`Error::Revoked`] — the certificate's serial number appears in the
    ///   CRL's or OCSP response's revoked list.
    /// - [`Error::CrlExpired`] — the CRL has passed its `nextUpdate` timestamp.
    /// - [`Error::OcspMalformed`] — the OCSP response is structurally invalid or
    ///   its validity window check failed.
    /// - [`Error::OcspStatusUnknown`] — no matching `SingleResponse` covered
    ///   the certificate (OCSP-side "not covered").
    /// - [`Error::OutOfScope`] — a CRL `IssuingDistributionPoint` scope flag
    ///   excludes the certificate being checked (CRL-side "not covered").
    /// - Other [`Error`] variants for parse failures, signature verification
    ///   failures, or structural constraint violations.
    fn check_revocation(&self, cert: &Certificate, issuer: &Certificate) -> crate::Result<()>;

    /// Check whether `cert` (issued directly by a trust anchor) has been revoked.
    ///
    /// Called by `verify_chain` for the last certificate in the chain — the one
    /// whose issuer is a [`TrustAnchor`] rather than another certificate in the
    /// chain. For example, in the chain `[leaf, intermediate_CA]` this method is
    /// called with `cert = intermediate_CA` and `anchor` set to the matched anchor.
    ///
    /// **Default implementation returns `Ok(())` (skip).** Override this method
    /// to enforce revocation checking for certificates issued directly by a trust
    /// anchor (e.g., fetch and verify the CA's CRL using the anchor's public key).
    ///
    /// `NoRevocation` inherits this default and skips the check, matching its
    /// overall no-op behaviour. `CrlChecker` and `OcspChecker` both override
    /// this method: they verify the pre-loaded CRL or OCSP response against the
    /// anchor's subject DN and SPKI.
    ///
    /// # Security
    ///
    /// **The default implementation silently skips revocation checking for the
    /// anchor-issued certificate.** If your threat model requires revocation
    /// checking for every certificate in the chain — including the one issued
    /// directly by the trust anchor — you MUST override this method. There is
    /// no compile-time or runtime warning when the default is used; the skip
    /// is intentional for environments (e.g., embedded, offline, short-lived
    /// certificates) where anchor-level revocation data is unavailable.
    ///
    /// Failing to override this method in a context that requires full-chain
    /// revocation coverage is a silent security gap.
    ///
    /// # Note: default is a no-op
    ///
    /// The default implementation performs **no revocation check** and always
    /// returns `Ok(())`. Any implementor that does not override this method
    /// silently skips revocation for the certificate directly issued by the
    /// trust anchor. Override this method to enable anchor-level revocation.
    ///
    /// # Errors
    ///
    /// The default implementation always returns `Ok(())`; override this method
    /// to enable error-returning revocation checks.
    fn check_revocation_against_anchor(
        &self,
        _cert: &Certificate,
        _anchor: &TrustAnchor,
    ) -> crate::Result<()> {
        Ok(())
    }
}

/// A no-op revocation checker that always reports certificates as not revoked.
///
/// Use this when:
/// - Running in embedded / offline environments with no revocation infrastructure
/// - Revocation is enforced at a higher layer
/// - In tests and development environments
///
/// # Security note
///
/// `NoRevocation` does **not** consult CRLs or OCSP. A revoked certificate
/// will pass validation. Only use this when your threat model permits
/// unenforced revocation (e.g., closed networks, short-lived certificates,
/// hardware attestation where issuance itself is the control).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct NoRevocation;

impl RevocationChecker for NoRevocation {
    #[inline]
    fn check_revocation(&self, _cert: &Certificate, _issuer: &Certificate) -> crate::Result<()> {
        Ok(())
    }
}

#[cfg(feature = "crl")]
mod crl;
#[cfg(feature = "crl")]
#[cfg_attr(docsrs, doc(cfg(feature = "crl")))]
pub use crl::CrlChecker;

#[cfg(feature = "ocsp")]
mod ocsp;
#[cfg(feature = "ocsp")]
#[cfg_attr(docsrs, doc(cfg(feature = "ocsp")))]
pub use ocsp::OcspChecker;