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}