Skip to main content

pkix_revocation/
crl.rs

1//! Offline CRL-based revocation checker.
2//!
3//! Enabled by the `crl` feature.
4
5use crate::{Error, RevocationChecker};
6use der::{Decode as _, Encode as _};
7use pkix_path::{names_match, SignatureVerifier};
8use spki::der::referenced::OwnedToRef as _;
9use x509_cert::{
10    crl::{CertificateList, RevokedCert},
11    ext::pkix::crl::CrlReason,
12    Certificate,
13};
14
15// OID 2.5.29.21 — id-ce-CRLReasons (RFC 5280 §5.3.1)
16const OID_CRL_REASONS: der::asn1::ObjectIdentifier =
17    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.21");
18
19/// OID for `CRLNumber` extension (RFC 5280 §5.2.3) — id-ce-cRLNumber: 2.5.29.20
20const OID_CRL_NUMBER: der::asn1::ObjectIdentifier =
21    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.20");
22
23/// OID for deltaCRLIndicator extension (RFC 5280 §5.2.4) — id-ce-deltaCRLIndicator: 2.5.29.27
24/// This extension is CRITICAL; its presence marks a delta CRL.
25const OID_DELTA_CRL_INDICATOR: der::asn1::ObjectIdentifier =
26    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.27");
27
28/// OID for issuingDistributionPoint extension (RFC 5280 §5.2.5) — 2.5.29.28
29/// Note: x509-cert 0.2.5 has a wrong `AssociatedOid` for `IssuingDistributionPoint`
30/// (it uses `SubjectInfoAccess` OID instead). Always look up this extension by
31/// raw OID rather than using AssociatedOid-based helpers.
32const OID_ISSUING_DISTRIBUTION_POINT: der::asn1::ObjectIdentifier =
33    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.28");
34
35/// OID for `KeyUsage` extension (RFC 5280 §4.2.1.3) — id-ce-keyUsage: 2.5.29.15
36/// Used to check the `cRLSign` bit on the CRL issuer.
37const OID_KEY_USAGE_CRL: der::asn1::ObjectIdentifier =
38    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.15");
39
40/// OID for `BasicConstraints` extension (RFC 5280 §4.2.1.9) — id-ce-basicConstraints: 2.5.29.19
41const OID_BASIC_CONSTRAINTS: der::asn1::ObjectIdentifier =
42    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.19");
43
44/// Offline CRL-based revocation checker.
45///
46/// Parses a DER-encoded [`CertificateList`][x509_cert::crl::CertificateList],
47/// verifies its signature against the issuer's SPKI, checks the
48/// `thisUpdate`/`nextUpdate` validity window, and reports whether the
49/// certificate's serial number appears in the revoked list.
50///
51/// To also apply a delta CRL (RFC 5280 §5.2.4), use [`CrlChecker::with_delta`].
52///
53/// # Feature
54///
55/// Only available when the `crl` feature is enabled.
56///
57/// # Return value semantics
58///
59/// [`RevocationChecker::check_revocation`] returns `Ok(())` in two distinct cases:
60///
61/// 1. **Not revoked**: the CRL covers this certificate type and the serial number
62///    was not found in the revoked list.
63/// 2. **Not covered**: the CRL's `IssuingDistributionPoint` scope flags
64///    (`onlyContainsUserCerts`, `onlyContainsCACerts`, `onlyContainsAttributeCerts`)
65///    indicate the CRL does not apply to this certificate type.
66///
67/// These two outcomes are indistinguishable from the caller's perspective.
68/// Callers enforcing a **hard-fail** revocation policy must separately verify
69/// that at least one CRL or OCSP response actually covers the certificate
70/// in question; receiving `Ok(())` alone is not sufficient.
71///
72/// # Limitations (v0.1)
73///
74 /// - The CRL must be signed directly by the certificate issuer
75///   (indirect CRLs are not supported; deferred to v0.2).
76/// - CRL Distribution Point name matching (CDP vs IDP name) is not implemented.
77///   The checker does enforce `onlyContainsUserCerts`, `onlyContainsCACerts`, and
78///   `onlyContainsAttributeCerts` scope flags; full CDP/IDP name matching is v0.2.
79/// - Both the base CRL and the delta CRL (if present) are re-parsed from DER on
80///   every [`check_revocation`] call. For long chains validated against the same
81///   CRL pair, this is O(N) redundant parsing. Tracked for v0.2 (cache the parsed
82///   `CertificateList` in `new` / `with_delta`).
83/// - [`RevocationChecker::check_revocation_against_anchor`] is not overridden.
84///   The certificate immediately issued by the trust anchor is not
85///   revocation-checked by this type; revocation against the anchor is the
86///   responsibility of the path validator (a v0.1 limitation).
87///
88/// [`check_revocation`]: crate::RevocationChecker::check_revocation
89/// [`RevocationChecker::check_revocation_against_anchor`]: crate::RevocationChecker::check_revocation_against_anchor
90#[derive(Clone, Debug)]
91pub struct CrlChecker<V> {
92    crl_der: Vec<u8>,
93    /// Optional delta CRL DER. When present, its entries are merged with the
94    /// base CRL in `check_revocation` (RFC 5280 §5.2.4).
95    delta_crl_der: Option<Vec<u8>>,
96    now_unix: u64,
97    verifier: V,
98}
99
100impl<V: SignatureVerifier> CrlChecker<V> {
101    /// Create a new `CrlChecker`.
102    ///
103    /// - `crl_der`  — DER-encoded `CertificateList` (any `Into<Vec<u8>>`, e.g. `Vec<u8>` or `&[u8]`)
104    /// - `now_unix` — current time as seconds since the Unix epoch
105    /// - `verifier` — signature verifier used to authenticate the CRL
106    #[must_use]
107    pub fn new(crl_der: impl Into<Vec<u8>>, now_unix: u64, verifier: V) -> Self {
108        Self {
109            crl_der: crl_der.into(),
110            delta_crl_der: None,
111            now_unix,
112            verifier,
113        }
114    }
115
116    /// Create a `CrlChecker` with a base CRL and a delta CRL.
117    ///
118    /// The delta CRL is merged into the base CRL per RFC 5280 §5.2.4:
119    /// - Entries in the delta that are not in the base are added.
120    /// - Entries in the delta with reason `removeFromCRL` are removed from the
121    ///   base.
122    /// - The merged result is used for all subsequent `check_revocation` calls.
123    ///
124    /// Returns `Err(Error::DeltaCrlBaseMismatch)` if:
125    /// - The delta CRL's `BaseCRLNumber` is absent (not a delta CRL), or
126    /// - The delta's `BaseCRLNumber` is greater than the base CRL's `CRLNumber`
127    ///   (the delta was produced against a newer base than the one supplied).
128    pub fn with_delta(
129        base_der: impl Into<Vec<u8>>,
130        delta_der: impl Into<Vec<u8>>,
131        now_unix: u64,
132        verifier: V,
133    ) -> crate::Result<Self> {
134        let base_der = base_der.into();
135        let delta_der_bytes = delta_der.into();
136
137        // Parse both to validate structure and extract CRL numbers.
138        let base_crl = CertificateList::from_der(&base_der).map_err(Error::CrlParseError)?;
139        let delta_crl =
140            CertificateList::from_der(&delta_der_bytes).map_err(Error::CrlParseError)?;
141
142        // The base CRL MUST NOT itself be a delta CRL (RFC 5280 §5.2.4: only a
143        // full CRL may serve as the base).  Detect by OID presence alone — do not
144        // rely on successful decode, since a malformed deltaCRLIndicator value
145        // would cause base_crl_number() to return None and silently pass as a base.
146        if has_delta_crl_indicator(&base_crl) {
147            return Err(Error::DeltaCrlBaseMismatch);
148        }
149
150        // The delta MUST have a BaseCRLNumber extension (marks it as a delta CRL).
151        let delta_base_num = base_crl_number(&delta_crl);
152        if delta_base_num.is_none() {
153            // No deltaCRLIndicator → this is not a delta CRL.
154            return Err(Error::DeltaCrlBaseMismatch);
155        }
156
157        // The base CRL and delta CRL MUST have the same issuer.
158        if !names_match(
159            &base_crl.tbs_cert_list.issuer,
160            &delta_crl.tbs_cert_list.issuer,
161        ) {
162            return Err(Error::DeltaCrlBaseMismatch);
163        }
164
165        // If both CRL numbers are present, the delta's BaseCRLNumber must be
166        // ≤ the base's CRLNumber (we have a base that is at least as current as
167        // what the delta expects).
168        if let (Some(base_num), Some(db_num)) = (crl_number(&base_crl), delta_base_num) {
169            if db_num > base_num {
170                return Err(Error::CrlNumberMismatch);
171            }
172        }
173
174        Ok(Self {
175            crl_der: base_der,
176            delta_crl_der: Some(delta_der_bytes),
177            now_unix,
178            verifier,
179        })
180    }
181}
182
183impl<V: SignatureVerifier> RevocationChecker for CrlChecker<V> {
184    fn check_revocation(&self, cert: &Certificate, issuer: &Certificate) -> crate::Result<()> {
185        // (1) Parse the base CRL.
186        let crl = CertificateList::from_der(&self.crl_der).map_err(Error::CrlParseError)?;
187
188        // (2) Verify the CRL issuer name matches the certificate's issuer.
189        //     A CRL signed by a different CA does not convey revocation status for
190        //     certificates issued by this CA.
191        if !names_match(&crl.tbs_cert_list.issuer, &cert.tbs_certificate.issuer) {
192            return Err(Error::CrlIssuerMismatch);
193        }
194        // (2b) Verify the `issuer` Certificate's subject DN matches the CRL issuer.
195        //      This guards against a caller passing a mismatched issuer certificate
196        //      (e.g., a cert from a different CA whose name happens to appear in a
197        //      CRL distribution point). Without this check, the cRLSign and SPKI
198        //      checks below would operate on the wrong certificate.
199        if !names_match(&issuer.tbs_certificate.subject, &crl.tbs_cert_list.issuer) {
200            return Err(Error::CrlIssuerMismatch);
201        }
202
203        // (3) RFC 5280 §6.3.3(f): the CRL issuer must have cRLSign in KeyUsage when present.
204        //     Check this before verifying the signature so we reject on the correct error
205        //     (CrlSignMissing rather than CrlSignatureInvalid) when the key lacks cRLSign.
206        if !issuer_has_crl_sign(issuer) {
207            return Err(Error::CrlSignMissing);
208        }
209
210        // (3b) Verify the CRL signature against the issuer's SPKI.
211        let tbs_bytes = crl.tbs_cert_list.to_der().map_err(Error::CrlParseError)?;
212        self.verifier
213            .verify_signature(
214                crl.signature_algorithm.owned_to_ref(),
215                issuer
216                    .tbs_certificate
217                    .subject_public_key_info
218                    .owned_to_ref(),
219                &tbs_bytes,
220                crl.signature.raw_bytes(),
221            )
222            .map_err(|_| Error::CrlSignatureInvalid)?;
223
224        // (4) Check CRL validity window: thisUpdate ≤ now ≤ nextUpdate.
225        //     Absent nextUpdate is treated as expired: an indefinitely valid CRL would
226        //     allow a stale revocation list to suppress detection of revoked certificates.
227        let this_update = crl.tbs_cert_list.this_update.to_unix_duration().as_secs();
228        if self.now_unix < this_update {
229            return Err(Error::CrlExpired);
230        }
231        match &crl.tbs_cert_list.next_update {
232            Some(next_update) => {
233                if self.now_unix > next_update.to_unix_duration().as_secs() {
234                    return Err(Error::CrlExpired);
235                }
236            }
237            None => return Err(Error::CrlExpired),
238        }
239
240        // (5) RFC 5280 §5.2.5: if the CRL has an IssuingDistributionPoint extension
241        //     (critical), check scope constraints against the certificate.
242        if let Some(idp) = parse_issuing_dp(&crl) {
243            // onlyContainsAttributeCerts: attribute cert validation is out of scope
244            // for pkix-revocation (RFC 5755 is handled by pkix-ac, tracked for v0.2).
245            if idp.only_contains_attribute_certs {
246                // CRL does not cover this cert type — returning Ok(()) (not-covered, not not-revoked).
247                // Callers with hard-fail revocation requirements must verify CRL coverage separately.
248                return Ok(());
249            }
250            let cert_is_ca = cert_is_ca_cert(cert);
251            // onlyContainsUserCerts: CRL only covers end-entity (non-CA) certs.
252            if idp.only_contains_user_certs && cert_is_ca {
253                // CRL does not cover this cert type — returning Ok(()) (not-covered, not not-revoked).
254                // Callers with hard-fail revocation requirements must verify CRL coverage separately.
255                return Ok(());
256            }
257            // onlyContainsCACerts: CRL only covers CA certs.
258            if idp.only_contains_ca_certs && !cert_is_ca {
259                // CRL does not cover this cert type — returning Ok(()) (not-covered, not not-revoked).
260                // Callers with hard-fail revocation requirements must verify CRL coverage separately.
261                return Ok(());
262            }
263        }
264
265        // (6) §5.2.4 delta CRL merge: if a delta CRL is present, collect its revoked
266        //     entries and merge with the base CRL's revoked list.
267        let delta_entries: Vec<RevokedCert> = if let Some(ref delta_der) = self.delta_crl_der {
268            let delta_crl = CertificateList::from_der(delta_der).map_err(Error::CrlParseError)?;
269
270            // Defense-in-depth: verify delta CRL issuer matches the base CRL issuer.
271            // The with_delta() constructor already enforces this at construction time,
272            // but re-checking here guards against any future path that bypasses the
273            // constructor (RFC 5280 §5.2.4: base and delta must come from the same CA).
274            if !names_match(&delta_crl.tbs_cert_list.issuer, &crl.tbs_cert_list.issuer) {
275                return Err(Error::CrlIssuerMismatch);
276            }
277
278            // Verify delta CRL issuer also matches the certificate's issuer
279            // (transitively guaranteed by the two checks above, but explicit for clarity).
280            if !names_match(
281                &delta_crl.tbs_cert_list.issuer,
282                &cert.tbs_certificate.issuer,
283            ) {
284                return Err(Error::CrlIssuerMismatch);
285            }
286
287            // Verify the `issuer` Certificate's subject DN matches the delta CRL issuer.
288            // Mirrors step (2b) for the base CRL: without this check, the delta sig and
289            // cRLSign checks below operate on an unverified `issuer` cert identity.
290            if !names_match(
291                &issuer.tbs_certificate.subject,
292                &delta_crl.tbs_cert_list.issuer,
293            ) {
294                return Err(Error::CrlIssuerMismatch);
295            }
296
297            // Verify delta CRL signature.
298            let delta_tbs_bytes = delta_crl
299                .tbs_cert_list
300                .to_der()
301                .map_err(Error::CrlParseError)?;
302            self.verifier
303                .verify_signature(
304                    delta_crl.signature_algorithm.owned_to_ref(),
305                    issuer
306                        .tbs_certificate
307                        .subject_public_key_info
308                        .owned_to_ref(),
309                    &delta_tbs_bytes,
310                    delta_crl.signature.raw_bytes(),
311                )
312                .map_err(|_| Error::CrlSignatureInvalid)?;
313
314            // Verify delta CRL validity window.
315            let delta_this_update = delta_crl
316                .tbs_cert_list
317                .this_update
318                .to_unix_duration()
319                .as_secs();
320            if self.now_unix < delta_this_update {
321                return Err(Error::CrlExpired);
322            }
323            match &delta_crl.tbs_cert_list.next_update {
324                Some(nu) => {
325                    if self.now_unix > nu.to_unix_duration().as_secs() {
326                        return Err(Error::CrlExpired);
327                    }
328                }
329                None => return Err(Error::CrlExpired),
330            }
331
332            delta_crl
333                .tbs_cert_list
334                .revoked_certificates
335                .unwrap_or_default()
336        } else {
337            Vec::new()
338        };
339
340        // (7) Search for the certificate's serial number, delta entries first.
341        //     RFC 5280 §5.2.4: delta CRL entries take precedence over base entries.
342        //     A removeFromCRL reason in the delta means the cert was un-held.
343        let cert_serial = &cert.tbs_certificate.serial_number;
344
345        // Check delta CRL entries (they take precedence).
346        if let Some(delta_entry) = delta_entries
347            .iter()
348            .find(|e| &e.serial_number == cert_serial)
349        {
350            let reason = extract_reason_code(delta_entry);
351            if reason == Some(CrlReason::RemoveFromCRL) {
352                // certificateHold was lifted; cert is not revoked.
353                return Ok(());
354            }
355            return Err(Error::Revoked {
356                serial: cert_serial.clone(),
357                reason_code: reason,
358            });
359        }
360
361        // Check base CRL entries.
362        if let Some(revoked) = &crl.tbs_cert_list.revoked_certificates {
363            if let Some(entry) = revoked.iter().find(|e| &e.serial_number == cert_serial) {
364                return Err(Error::Revoked {
365                    serial: cert_serial.clone(),
366                    reason_code: extract_reason_code(entry),
367                });
368            }
369        }
370
371        Ok(())
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Extension helpers
377// ---------------------------------------------------------------------------
378
379/// Convert a DER [`Uint`][der::asn1::Uint] to a `u64`, padding from the left.
380///
381/// Returns `None` if the integer is larger than 8 bytes (would overflow `u64`).
382/// CRL numbers in PKITS are small (1–5), so this is not a practical limit.
383fn uint_to_u64(n: &der::asn1::Uint) -> Option<u64> {
384    let b = n.as_bytes();
385    if b.len() > 8 {
386        return None; // too large for u64
387    }
388    let mut arr = [0u8; 8];
389    arr[8 - b.len()..].copy_from_slice(b);
390    Some(u64::from_be_bytes(arr))
391}
392
393/// Extract the CRL number from a `CertificateList`'s extensions.
394///
395/// Returns `None` if the `CRLNumber` extension is absent or cannot be decoded.
396/// `CRLNumber` is a non-negative INTEGER (RFC 5280 §5.2.3).
397fn crl_number(crl: &CertificateList) -> Option<u64> {
398    crl.tbs_cert_list
399        .crl_extensions
400        .as_deref()
401        .unwrap_or(&[])
402        .iter()
403        .find(|e| e.extn_id == OID_CRL_NUMBER)
404        .and_then(|e| {
405            der::asn1::Uint::from_der(e.extn_value.as_bytes())
406                .ok()
407                .and_then(|n| uint_to_u64(&n))
408        })
409}
410
411/// Returns `true` if `crl` contains a `deltaCRLIndicator` extension (OID 2.5.29.27),
412/// regardless of whether the extension value can be decoded.
413///
414/// Presence of this OID (which MUST be critical) is the canonical marker that a
415/// CRL is a delta CRL per RFC 5280 §5.2.4.  Checking presence — not decode success —
416/// is important: a malformed value still makes the CRL a delta CRL and must prevent
417/// it from being used as a base.
418fn has_delta_crl_indicator(crl: &CertificateList) -> bool {
419    crl.tbs_cert_list
420        .crl_extensions
421        .as_deref()
422        .unwrap_or(&[])
423        .iter()
424        .any(|e| e.extn_id == OID_DELTA_CRL_INDICATOR)
425}
426
427/// Extract the `BaseCRLNumber` from a delta CRL's extensions.
428///
429/// The `deltaCRLIndicator` extension value IS the `BaseCRLNumber` — it is an
430/// INTEGER encoding the CRL number of the base CRL this delta updates.
431/// This extension MUST be critical (RFC 5280 §5.2.4).
432///
433/// Returns `None` if the extension is absent (CRL is not a delta CRL),
434/// or the `u64` value if it is present.
435fn base_crl_number(crl: &CertificateList) -> Option<u64> {
436    crl.tbs_cert_list
437        .crl_extensions
438        .as_deref()
439        .unwrap_or(&[])
440        .iter()
441        .find(|e| e.extn_id == OID_DELTA_CRL_INDICATOR)
442        .and_then(|e| {
443            der::asn1::Uint::from_der(e.extn_value.as_bytes())
444                .ok()
445                .and_then(|n| uint_to_u64(&n))
446        })
447}
448
449/// Returns `true` if the certificate has `cRLSign` set in its `KeyUsage` extension,
450/// OR if the `KeyUsage` extension is absent (no constraint).
451///
452/// RFC 5280 §6.3.3(f): a CRL issuer that has a `KeyUsage` extension MUST assert
453/// the `cRLSign` bit. If `KeyUsage` is absent, there is no constraint.
454fn issuer_has_crl_sign(cert: &Certificate) -> bool {
455    use x509_cert::ext::pkix::KeyUsage;
456
457    let Some(ku_ext) = cert
458        .tbs_certificate
459        .extensions
460        .as_deref()
461        .unwrap_or(&[])
462        .iter()
463        .find(|e| e.extn_id == OID_KEY_USAGE_CRL)
464    else {
465        return true; // KeyUsage absent (or no extensions) → no constraint
466    };
467    KeyUsage::from_der(ku_ext.extn_value.as_bytes())
468        .map(|ku| ku.crl_sign())
469        .unwrap_or(false) // malformed KeyUsage → treat as missing the bit
470}
471
472/// Extract the `CRLReason` code from a revoked cert entry's extensions, if present.
473///
474/// Returns the `CrlReason` (RFC 5280 §5.3.1), or `None` if the extension is absent.
475fn extract_reason_code(entry: &RevokedCert) -> Option<CrlReason> {
476    let exts = entry.crl_entry_extensions.as_ref()?;
477    exts.iter()
478        .find(|ext| ext.extn_id == OID_CRL_REASONS)
479        .and_then(|ext| CrlReason::from_der(ext.extn_value.as_bytes()).ok())
480}
481
482/// Extract the `IssuingDistributionPoint` from a CRL, if present.
483///
484/// Uses raw OID lookup because x509-cert 0.2.5 has a wrong `AssociatedOid` for
485/// this type (it maps to `SubjectInfoAccess` instead of 2.5.29.28).
486fn parse_issuing_dp(
487    crl: &CertificateList,
488) -> Option<x509_cert::ext::pkix::crl::IssuingDistributionPoint> {
489    use x509_cert::ext::pkix::crl::IssuingDistributionPoint;
490
491    crl.tbs_cert_list
492        .crl_extensions
493        .as_deref()
494        .unwrap_or(&[])
495        .iter()
496        .find(|e| e.extn_id == OID_ISSUING_DISTRIBUTION_POINT)
497        .and_then(|e| IssuingDistributionPoint::from_der(e.extn_value.as_bytes()).ok())
498}
499
500/// Returns `true` if `cert` is a CA certificate (`BasicConstraints` `cA = TRUE`).
501fn cert_is_ca_cert(cert: &Certificate) -> bool {
502    use x509_cert::ext::pkix::BasicConstraints;
503
504    cert.tbs_certificate
505        .extensions
506        .as_deref()
507        .unwrap_or(&[])
508        .iter()
509        .find(|e| e.extn_id == OID_BASIC_CONSTRAINTS)
510        .and_then(|e| BasicConstraints::from_der(e.extn_value.as_bytes()).ok())
511        .map(|bc| bc.ca)
512        .unwrap_or(false)
513}