pkix-revocation 0.2.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
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! Offline CRL-based revocation checker.
//!
//! Enabled by the `crl` feature.

use crate::{Error, RevocationChecker};
use der::{Decode as _, Encode as _};
use pkix_path::{names_match, SignatureVerifier};
use spki::der::referenced::OwnedToRef as _;
use x509_cert::{
    crl::{CertificateList, RevokedCert},
    ext::pkix::crl::CrlReason,
    Certificate,
};

// OID 2.5.29.21 — id-ce-CRLReasons (RFC 5280 §5.3.1)
const OID_CRL_REASONS: der::asn1::ObjectIdentifier =
    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.21");

/// OID for `CRLNumber` extension (RFC 5280 §5.2.3) — id-ce-cRLNumber: 2.5.29.20
const OID_CRL_NUMBER: der::asn1::ObjectIdentifier =
    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.20");

/// OID for deltaCRLIndicator extension (RFC 5280 §5.2.4) — id-ce-deltaCRLIndicator: 2.5.29.27
/// This extension is CRITICAL; its presence marks a delta CRL.
const OID_DELTA_CRL_INDICATOR: der::asn1::ObjectIdentifier =
    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.27");

/// OID for issuingDistributionPoint extension (RFC 5280 §5.2.5) — 2.5.29.28
/// Note: x509-cert 0.2.5 has a wrong `AssociatedOid` for `IssuingDistributionPoint`
/// (it uses `SubjectInfoAccess` OID instead). Always look up this extension by
/// raw OID rather than using AssociatedOid-based helpers.
const OID_ISSUING_DISTRIBUTION_POINT: der::asn1::ObjectIdentifier =
    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.28");

/// OID for `KeyUsage` extension (RFC 5280 §4.2.1.3) — id-ce-keyUsage: 2.5.29.15
/// Used to check the `cRLSign` bit on the CRL issuer.
const OID_KEY_USAGE_CRL: der::asn1::ObjectIdentifier =
    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.15");

/// OID for `BasicConstraints` extension (RFC 5280 §4.2.1.9) — id-ce-basicConstraints: 2.5.29.19
const OID_BASIC_CONSTRAINTS: der::asn1::ObjectIdentifier =
    der::asn1::ObjectIdentifier::new_unwrap("2.5.29.19");

/// Offline CRL-based revocation checker.
///
/// Parses a DER-encoded [`CertificateList`][x509_cert::crl::CertificateList],
/// verifies its signature against the issuer's SPKI, checks the
/// `thisUpdate`/`nextUpdate` validity window, and reports whether the
/// certificate's serial number appears in the revoked list.
///
/// To also apply a delta CRL (RFC 5280 §5.2.4), use [`CrlChecker::with_delta`].
///
/// # Feature
///
/// Only available when the `crl` feature is enabled.
///
/// # Return value semantics
///
/// [`RevocationChecker::check_revocation`] returns `Ok(())` in two distinct cases:
///
/// 1. **Not revoked**: the CRL covers this certificate type and the serial number
///    was not found in the revoked list.
/// 2. **Not covered**: the CRL's `IssuingDistributionPoint` scope flags
///    (`onlyContainsUserCerts`, `onlyContainsCACerts`, `onlyContainsAttributeCerts`)
///    indicate the CRL does not apply to this certificate type.
///
/// These two outcomes are indistinguishable from the caller's perspective.
/// Callers enforcing a **hard-fail** revocation policy must separately verify
/// that at least one CRL or OCSP response actually covers the certificate
/// in question; receiving `Ok(())` alone is not sufficient.
///
/// # Limitations (v0.1)
///
 /// - The CRL must be signed directly by the certificate issuer
///   (indirect CRLs are not supported; deferred to v0.2).
/// - CRL Distribution Point name matching (CDP vs IDP name) is not implemented.
///   The checker does enforce `onlyContainsUserCerts`, `onlyContainsCACerts`, and
///   `onlyContainsAttributeCerts` scope flags; full CDP/IDP name matching is v0.2.
/// - Both the base CRL and the delta CRL (if present) are re-parsed from DER on
///   every [`check_revocation`] call. For long chains validated against the same
///   CRL pair, this is O(N) redundant parsing. Tracked for v0.2 (cache the parsed
///   `CertificateList` in `new` / `with_delta`).
/// - [`RevocationChecker::check_revocation_against_anchor`] is not overridden.
///   The certificate immediately issued by the trust anchor is not
///   revocation-checked by this type; revocation against the anchor is the
///   responsibility of the path validator (a v0.1 limitation).
///
/// [`check_revocation`]: crate::RevocationChecker::check_revocation
/// [`RevocationChecker::check_revocation_against_anchor`]: crate::RevocationChecker::check_revocation_against_anchor
#[derive(Clone, Debug)]
pub struct CrlChecker<V> {
    crl_der: Vec<u8>,
    /// Optional delta CRL DER. When present, its entries are merged with the
    /// base CRL in `check_revocation` (RFC 5280 §5.2.4).
    delta_crl_der: Option<Vec<u8>>,
    now_unix: u64,
    verifier: V,
}

impl<V: SignatureVerifier> CrlChecker<V> {
    /// Create a new `CrlChecker`.
    ///
    /// - `crl_der`  — DER-encoded `CertificateList` (any `Into<Vec<u8>>`, e.g. `Vec<u8>` or `&[u8]`)
    /// - `now_unix` — current time as seconds since the Unix epoch
    /// - `verifier` — signature verifier used to authenticate the CRL
    #[must_use]
    pub fn new(crl_der: impl Into<Vec<u8>>, now_unix: u64, verifier: V) -> Self {
        Self {
            crl_der: crl_der.into(),
            delta_crl_der: None,
            now_unix,
            verifier,
        }
    }

    /// Create a `CrlChecker` with a base CRL and a delta CRL.
    ///
    /// The delta CRL is merged into the base CRL per RFC 5280 §5.2.4:
    /// - Entries in the delta that are not in the base are added.
    /// - Entries in the delta with reason `removeFromCRL` are removed from the
    ///   base.
    /// - The merged result is used for all subsequent `check_revocation` calls.
    ///
    /// Returns `Err(Error::DeltaCrlBaseMismatch)` if:
    /// - The delta CRL's `BaseCRLNumber` is absent (not a delta CRL), or
    /// - The delta's `BaseCRLNumber` is greater than the base CRL's `CRLNumber`
    ///   (the delta was produced against a newer base than the one supplied).
    pub fn with_delta(
        base_der: impl Into<Vec<u8>>,
        delta_der: impl Into<Vec<u8>>,
        now_unix: u64,
        verifier: V,
    ) -> crate::Result<Self> {
        let base_der = base_der.into();
        let delta_der_bytes = delta_der.into();

        // Parse both to validate structure and extract CRL numbers.
        let base_crl = CertificateList::from_der(&base_der).map_err(Error::CrlParseError)?;
        let delta_crl =
            CertificateList::from_der(&delta_der_bytes).map_err(Error::CrlParseError)?;

        // The base CRL MUST NOT itself be a delta CRL (RFC 5280 §5.2.4: only a
        // full CRL may serve as the base).  Detect by OID presence alone — do not
        // rely on successful decode, since a malformed deltaCRLIndicator value
        // would cause base_crl_number() to return None and silently pass as a base.
        if has_delta_crl_indicator(&base_crl) {
            return Err(Error::DeltaCrlBaseMismatch);
        }

        // The delta MUST have a BaseCRLNumber extension (marks it as a delta CRL).
        let delta_base_num = base_crl_number(&delta_crl);
        if delta_base_num.is_none() {
            // No deltaCRLIndicator → this is not a delta CRL.
            return Err(Error::DeltaCrlBaseMismatch);
        }

        // The base CRL and delta CRL MUST have the same issuer.
        if !names_match(
            &base_crl.tbs_cert_list.issuer,
            &delta_crl.tbs_cert_list.issuer,
        ) {
            return Err(Error::DeltaCrlBaseMismatch);
        }

        // If both CRL numbers are present, the delta's BaseCRLNumber must be
        // ≤ the base's CRLNumber (we have a base that is at least as current as
        // what the delta expects).
        if let (Some(base_num), Some(db_num)) = (crl_number(&base_crl), delta_base_num) {
            if db_num > base_num {
                return Err(Error::CrlNumberMismatch);
            }
        }

        Ok(Self {
            crl_der: base_der,
            delta_crl_der: Some(delta_der_bytes),
            now_unix,
            verifier,
        })
    }
}

impl<V: SignatureVerifier> RevocationChecker for CrlChecker<V> {
    fn check_revocation(&self, cert: &Certificate, issuer: &Certificate) -> crate::Result<()> {
        // (1) Parse the base CRL.
        let crl = CertificateList::from_der(&self.crl_der).map_err(Error::CrlParseError)?;

        // (2) Verify the CRL issuer name matches the certificate's issuer.
        //     A CRL signed by a different CA does not convey revocation status for
        //     certificates issued by this CA.
        if !names_match(&crl.tbs_cert_list.issuer, &cert.tbs_certificate.issuer) {
            return Err(Error::CrlIssuerMismatch);
        }
        // (2b) Verify the `issuer` Certificate's subject DN matches the CRL issuer.
        //      This guards against a caller passing a mismatched issuer certificate
        //      (e.g., a cert from a different CA whose name happens to appear in a
        //      CRL distribution point). Without this check, the cRLSign and SPKI
        //      checks below would operate on the wrong certificate.
        if !names_match(&issuer.tbs_certificate.subject, &crl.tbs_cert_list.issuer) {
            return Err(Error::CrlIssuerMismatch);
        }

        // (3) RFC 5280 §6.3.3(f): the CRL issuer must have cRLSign in KeyUsage when present.
        //     Check this before verifying the signature so we reject on the correct error
        //     (CrlSignMissing rather than CrlSignatureInvalid) when the key lacks cRLSign.
        if !issuer_has_crl_sign(issuer) {
            return Err(Error::CrlSignMissing);
        }

        // (3b) Verify the CRL signature against the issuer's SPKI.
        let tbs_bytes = crl.tbs_cert_list.to_der().map_err(Error::CrlParseError)?;
        self.verifier
            .verify_signature(
                crl.signature_algorithm.owned_to_ref(),
                issuer
                    .tbs_certificate
                    .subject_public_key_info
                    .owned_to_ref(),
                &tbs_bytes,
                crl.signature.raw_bytes(),
            )
            .map_err(|_| Error::CrlSignatureInvalid)?;

        // (4) Check CRL validity window: thisUpdate ≤ now ≤ nextUpdate.
        //     Absent nextUpdate is treated as expired: an indefinitely valid CRL would
        //     allow a stale revocation list to suppress detection of revoked certificates.
        let this_update = crl.tbs_cert_list.this_update.to_unix_duration().as_secs();
        if self.now_unix < this_update {
            return Err(Error::CrlExpired);
        }
        match &crl.tbs_cert_list.next_update {
            Some(next_update) => {
                if self.now_unix > next_update.to_unix_duration().as_secs() {
                    return Err(Error::CrlExpired);
                }
            }
            None => return Err(Error::CrlExpired),
        }

        // (5) RFC 5280 §5.2.5: if the CRL has an IssuingDistributionPoint extension
        //     (critical), check scope constraints against the certificate.
        if let Some(idp) = parse_issuing_dp(&crl) {
            // onlyContainsAttributeCerts: attribute cert validation is out of scope
            // for pkix-revocation (RFC 5755 is handled by pkix-ac, tracked for v0.2).
            if idp.only_contains_attribute_certs {
                // CRL does not cover this cert type — returning Ok(()) (not-covered, not not-revoked).
                // Callers with hard-fail revocation requirements must verify CRL coverage separately.
                return Ok(());
            }
            let cert_is_ca = cert_is_ca_cert(cert);
            // onlyContainsUserCerts: CRL only covers end-entity (non-CA) certs.
            if idp.only_contains_user_certs && cert_is_ca {
                // CRL does not cover this cert type — returning Ok(()) (not-covered, not not-revoked).
                // Callers with hard-fail revocation requirements must verify CRL coverage separately.
                return Ok(());
            }
            // onlyContainsCACerts: CRL only covers CA certs.
            if idp.only_contains_ca_certs && !cert_is_ca {
                // CRL does not cover this cert type — returning Ok(()) (not-covered, not not-revoked).
                // Callers with hard-fail revocation requirements must verify CRL coverage separately.
                return Ok(());
            }
        }

        // (6) §5.2.4 delta CRL merge: if a delta CRL is present, collect its revoked
        //     entries and merge with the base CRL's revoked list.
        let delta_entries: Vec<RevokedCert> = if let Some(ref delta_der) = self.delta_crl_der {
            let delta_crl = CertificateList::from_der(delta_der).map_err(Error::CrlParseError)?;

            // Defense-in-depth: verify delta CRL issuer matches the base CRL issuer.
            // The with_delta() constructor already enforces this at construction time,
            // but re-checking here guards against any future path that bypasses the
            // constructor (RFC 5280 §5.2.4: base and delta must come from the same CA).
            if !names_match(&delta_crl.tbs_cert_list.issuer, &crl.tbs_cert_list.issuer) {
                return Err(Error::CrlIssuerMismatch);
            }

            // Verify delta CRL issuer also matches the certificate's issuer
            // (transitively guaranteed by the two checks above, but explicit for clarity).
            if !names_match(
                &delta_crl.tbs_cert_list.issuer,
                &cert.tbs_certificate.issuer,
            ) {
                return Err(Error::CrlIssuerMismatch);
            }

            // Verify the `issuer` Certificate's subject DN matches the delta CRL issuer.
            // Mirrors step (2b) for the base CRL: without this check, the delta sig and
            // cRLSign checks below operate on an unverified `issuer` cert identity.
            if !names_match(
                &issuer.tbs_certificate.subject,
                &delta_crl.tbs_cert_list.issuer,
            ) {
                return Err(Error::CrlIssuerMismatch);
            }

            // Verify delta CRL signature.
            let delta_tbs_bytes = delta_crl
                .tbs_cert_list
                .to_der()
                .map_err(Error::CrlParseError)?;
            self.verifier
                .verify_signature(
                    delta_crl.signature_algorithm.owned_to_ref(),
                    issuer
                        .tbs_certificate
                        .subject_public_key_info
                        .owned_to_ref(),
                    &delta_tbs_bytes,
                    delta_crl.signature.raw_bytes(),
                )
                .map_err(|_| Error::CrlSignatureInvalid)?;

            // Verify delta CRL validity window.
            let delta_this_update = delta_crl
                .tbs_cert_list
                .this_update
                .to_unix_duration()
                .as_secs();
            if self.now_unix < delta_this_update {
                return Err(Error::CrlExpired);
            }
            match &delta_crl.tbs_cert_list.next_update {
                Some(nu) => {
                    if self.now_unix > nu.to_unix_duration().as_secs() {
                        return Err(Error::CrlExpired);
                    }
                }
                None => return Err(Error::CrlExpired),
            }

            delta_crl
                .tbs_cert_list
                .revoked_certificates
                .unwrap_or_default()
        } else {
            Vec::new()
        };

        // (7) Search for the certificate's serial number, delta entries first.
        //     RFC 5280 §5.2.4: delta CRL entries take precedence over base entries.
        //     A removeFromCRL reason in the delta means the cert was un-held.
        let cert_serial = &cert.tbs_certificate.serial_number;

        // Check delta CRL entries (they take precedence).
        if let Some(delta_entry) = delta_entries
            .iter()
            .find(|e| &e.serial_number == cert_serial)
        {
            let reason = extract_reason_code(delta_entry);
            if reason == Some(CrlReason::RemoveFromCRL) {
                // certificateHold was lifted; cert is not revoked.
                return Ok(());
            }
            return Err(Error::Revoked {
                serial: cert_serial.clone(),
                reason_code: reason,
            });
        }

        // Check base CRL entries.
        if let Some(revoked) = &crl.tbs_cert_list.revoked_certificates {
            if let Some(entry) = revoked.iter().find(|e| &e.serial_number == cert_serial) {
                return Err(Error::Revoked {
                    serial: cert_serial.clone(),
                    reason_code: extract_reason_code(entry),
                });
            }
        }

        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Extension helpers
// ---------------------------------------------------------------------------

/// Convert a DER [`Uint`][der::asn1::Uint] to a `u64`, padding from the left.
///
/// Returns `None` if the integer is larger than 8 bytes (would overflow `u64`).
/// CRL numbers in PKITS are small (1–5), so this is not a practical limit.
fn uint_to_u64(n: &der::asn1::Uint) -> Option<u64> {
    let b = n.as_bytes();
    if b.len() > 8 {
        return None; // too large for u64
    }
    let mut arr = [0u8; 8];
    arr[8 - b.len()..].copy_from_slice(b);
    Some(u64::from_be_bytes(arr))
}

/// Extract the CRL number from a `CertificateList`'s extensions.
///
/// Returns `None` if the `CRLNumber` extension is absent or cannot be decoded.
/// `CRLNumber` is a non-negative INTEGER (RFC 5280 §5.2.3).
fn crl_number(crl: &CertificateList) -> Option<u64> {
    crl.tbs_cert_list
        .crl_extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .find(|e| e.extn_id == OID_CRL_NUMBER)
        .and_then(|e| {
            der::asn1::Uint::from_der(e.extn_value.as_bytes())
                .ok()
                .and_then(|n| uint_to_u64(&n))
        })
}

/// Returns `true` if `crl` contains a `deltaCRLIndicator` extension (OID 2.5.29.27),
/// regardless of whether the extension value can be decoded.
///
/// Presence of this OID (which MUST be critical) is the canonical marker that a
/// CRL is a delta CRL per RFC 5280 §5.2.4.  Checking presence — not decode success —
/// is important: a malformed value still makes the CRL a delta CRL and must prevent
/// it from being used as a base.
fn has_delta_crl_indicator(crl: &CertificateList) -> bool {
    crl.tbs_cert_list
        .crl_extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .any(|e| e.extn_id == OID_DELTA_CRL_INDICATOR)
}

/// Extract the `BaseCRLNumber` from a delta CRL's extensions.
///
/// The `deltaCRLIndicator` extension value IS the `BaseCRLNumber` — it is an
/// INTEGER encoding the CRL number of the base CRL this delta updates.
/// This extension MUST be critical (RFC 5280 §5.2.4).
///
/// Returns `None` if the extension is absent (CRL is not a delta CRL),
/// or the `u64` value if it is present.
fn base_crl_number(crl: &CertificateList) -> Option<u64> {
    crl.tbs_cert_list
        .crl_extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .find(|e| e.extn_id == OID_DELTA_CRL_INDICATOR)
        .and_then(|e| {
            der::asn1::Uint::from_der(e.extn_value.as_bytes())
                .ok()
                .and_then(|n| uint_to_u64(&n))
        })
}

/// Returns `true` if the certificate has `cRLSign` set in its `KeyUsage` extension,
/// OR if the `KeyUsage` extension is absent (no constraint).
///
/// RFC 5280 §6.3.3(f): a CRL issuer that has a `KeyUsage` extension MUST assert
/// the `cRLSign` bit. If `KeyUsage` is absent, there is no constraint.
fn issuer_has_crl_sign(cert: &Certificate) -> bool {
    use x509_cert::ext::pkix::KeyUsage;

    let Some(ku_ext) = cert
        .tbs_certificate
        .extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .find(|e| e.extn_id == OID_KEY_USAGE_CRL)
    else {
        return true; // KeyUsage absent (or no extensions) → no constraint
    };
    KeyUsage::from_der(ku_ext.extn_value.as_bytes())
        .map(|ku| ku.crl_sign())
        .unwrap_or(false) // malformed KeyUsage → treat as missing the bit
}

/// Extract the `CRLReason` code from a revoked cert entry's extensions, if present.
///
/// Returns the `CrlReason` (RFC 5280 §5.3.1), or `None` if the extension is absent.
fn extract_reason_code(entry: &RevokedCert) -> Option<CrlReason> {
    let exts = entry.crl_entry_extensions.as_ref()?;
    exts.iter()
        .find(|ext| ext.extn_id == OID_CRL_REASONS)
        .and_then(|ext| CrlReason::from_der(ext.extn_value.as_bytes()).ok())
}

/// Extract the `IssuingDistributionPoint` from a CRL, if present.
///
/// Uses raw OID lookup because x509-cert 0.2.5 has a wrong `AssociatedOid` for
/// this type (it maps to `SubjectInfoAccess` instead of 2.5.29.28).
fn parse_issuing_dp(
    crl: &CertificateList,
) -> Option<x509_cert::ext::pkix::crl::IssuingDistributionPoint> {
    use x509_cert::ext::pkix::crl::IssuingDistributionPoint;

    crl.tbs_cert_list
        .crl_extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .find(|e| e.extn_id == OID_ISSUING_DISTRIBUTION_POINT)
        .and_then(|e| IssuingDistributionPoint::from_der(e.extn_value.as_bytes()).ok())
}

/// Returns `true` if `cert` is a CA certificate (`BasicConstraints` `cA = TRUE`).
fn cert_is_ca_cert(cert: &Certificate) -> bool {
    use x509_cert::ext::pkix::BasicConstraints;

    cert.tbs_certificate
        .extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .find(|e| e.extn_id == OID_BASIC_CONSTRAINTS)
        .and_then(|e| BasicConstraints::from_der(e.extn_value.as_bytes()).ok())
        .map(|bc| bc.ca)
        .unwrap_or(false)
}