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
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
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
//! 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, TrustAnchor};
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");

/// 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.3 (cache the parsed
///   `CertificateList` in `new` / `with_delta`).
/// - [`RevocationChecker::check_revocation_against_anchor`] is overridden.
///   For the certificate issued directly by a trust anchor, the CRL is verified
///   using the anchor's subject DN and SPKI in place of the missing issuer
///   `Certificate`.  The `cRLSign` `KeyUsage` check is omitted for trust anchors
///   (anchors are trusted by construction; they carry no `KeyUsage` to inspect).
///   If the CRL's issuer name does not match the anchor, the method returns
///   [`Error::CrlIssuerMismatch`] rather than `Ok(())`.
///
/// [`check_revocation`]: crate::RevocationChecker::check_revocation
/// [`RevocationChecker::check_revocation_against_anchor`]: crate::RevocationChecker::check_revocation_against_anchor
#[derive(Clone, Debug)]
pub struct CrlChecker<V> {
    /// Pre-parsed base CRL. Decoded once at construction; reused on every
    /// [`RevocationChecker::check_revocation`] call.
    crl: CertificateList,
    /// Optional pre-parsed delta CRL. When present, its entries are merged
    /// with the base CRL in `check_revocation` (RFC 5280 §5.2.4).
    delta_crl: Option<CertificateList>,
    now_unix: u64,
    verifier: V,
}

impl<V: SignatureVerifier> CrlChecker<V> {
    /// Create a new `CrlChecker`.
    ///
    /// - `crl_der`  — DER-encoded `CertificateList` (any `AsRef<[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
    ///
    /// The DER is parsed once at construction time and the parsed
    /// [`CertificateList`] is reused on every check, eliminating per-check
    /// re-parse work.
    ///
    /// # Errors
    ///
    /// Returns [`Error::CrlParseError`] if `crl_der` cannot be DER-decoded.
    ///
    /// # Security
    ///
    /// **Do not pass a delta CRL** (a `CertificateList` containing a
    /// `deltaCRLIndicator` extension) as the sole `crl_der` argument. Delta CRLs
    /// contain only the changes since the last base CRL; using one alone silently
    /// under-covers revocations. Pass it via [`CrlChecker::with_delta`] together
    /// with the matching base CRL to get correct coverage.
    pub fn new(crl_der: impl AsRef<[u8]>, now_unix: u64, verifier: V) -> crate::Result<Self> {
        let crl = CertificateList::from_der(crl_der.as_ref())
            .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;
        Ok(Self {
            crl,
            delta_crl: 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.
    ///
    /// # Errors
    ///
    /// Returns `Err(Error::CrlParseError)` if either the base or delta CRL DER
    /// cannot be decoded.
    ///
    /// 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 AsRef<[u8]>,
        delta_der: impl AsRef<[u8]>,
        now_unix: u64,
        verifier: V,
    ) -> crate::Result<Self> {
        // Parse both to validate structure and extract CRL numbers.
        let base_crl = CertificateList::from_der(base_der.as_ref())
            .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;
        let delta_crl = CertificateList::from_der(delta_der.as_ref())
            .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;

        // 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 deltaCRLIndicator extension (marks it as a delta CRL).
        // Check presence by OID first to distinguish "absent" from "present but malformed":
        //   - Extension absent           → not a delta CRL → DeltaCrlBaseMismatch
        //   - Extension present, value malformed → CrlParseError (structural error)
        if !has_delta_crl_indicator(&delta_crl) {
            // No deltaCRLIndicator OID → this is not a delta CRL.
            return Err(Error::DeltaCrlBaseMismatch);
        }
        // has_delta_crl_indicator confirmed OID presence above; None is unreachable.
        let delta_base_num = base_crl_number(&delta_crl)
            .ok_or(Error::DeltaCrlBaseMismatch)? // can only happen if code invariant broken
            .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;

        // 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 the base CRL has a CRL number, the delta's BaseCRLNumber must be
        // ≤ it (we have a base that is at least as current as what the delta expects).
        // A malformed or overflowing base CRLNumber is treated as CrlParseError
        // rather than silently skipping the freshness check.
        if let Some(base_num_result) = crl_number(&base_crl) {
            let base_num = base_num_result.map_err(|e| Error::CrlParseError(crate::DerError(e)))?;
            if delta_base_num > base_num {
                return Err(Error::CrlNumberMismatch);
            }
        }

        Ok(Self {
            crl: base_crl,
            delta_crl: Some(delta_crl),
            now_unix,
            verifier,
        })
    }
}

impl<V: SignatureVerifier> RevocationChecker for CrlChecker<V> {
    fn check_revocation(&self, cert: &Certificate, issuer: &Certificate) -> crate::Result<()> {
        // (1) Reuse the pre-parsed base CRL (parsed once at construction).
        let crl = &self.crl;

        // (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.
        //     A malformed KeyUsage extension returns CrlParseError (structural defect),
        //     not CrlSignMissing (bit absent).
        check_crl_sign(issuer)?;

        // (3b) Check CRL validity window before verifying the signature.
        //     Rejecting stale CRLs early avoids a potentially expensive signature
        //     verification on a CRL we would discard anyway.
        //     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);
        }
        let next_update = crl
            .tbs_cert_list
            .next_update
            .as_ref()
            .ok_or(Error::CrlExpired)?;
        if self.now_unix > next_update.to_unix_duration().as_secs() {
            return Err(Error::CrlExpired);
        }

        // (4) Verify the CRL signature against the issuer's SPKI.
        let tbs_bytes = crl
            .tbs_cert_list
            .to_der()
            .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;
        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(),
            )
            // Verifier returns an opaque error; no additional context available.
            .map_err(|_| Error::CrlSignatureInvalid)?;

        // (5) RFC 5280 §5.2.5: if the CRL has an IssuingDistributionPoint extension
        //     (critical), check scope constraints against the certificate.
        // Scope mismatches surface as Error::OutOfScope so callers can distinguish
        // "verified not-revoked" (Ok(())) from "no determination made"
        // (Err(OutOfScope(...))). Hard-fail revocation policies should treat
        // OutOfScope as a failure.
        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 {
                return Err(Error::OutOfScope(
                    crate::OutOfScopeReason::CrlOnlyAttributeCerts,
                ));
            }
            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 {
                return Err(Error::OutOfScope(crate::OutOfScopeReason::CrlOnlyUserCerts));
            }
            // onlyContainsCACerts: CRL only covers CA certs.
            if idp.only_contains_ca_certs && !cert_is_ca {
                return Err(Error::OutOfScope(crate::OutOfScopeReason::CrlOnlyCaCerts));
            }
        }

        // (6) §5.2.4 delta CRL merge: if a delta CRL is present, verify and collect
        //     its revoked entries.  verify_delta_crl_and_collect handles sig, expiry,
        //     and the primary issuer-name check.  The extra checks below are
        //     defense-in-depth: they guard against any future code path that bypasses
        //     the with_delta() constructor and against subtle cross-name mismatches.
        let delta_entries: Vec<RevokedCert> = if let Some(delta_crl) = &self.delta_crl {
            // Reuse the pre-parsed delta CRL (parsed once at construction).

            // Extra check: delta CRL issuer must also match the base CRL issuer
            // (construction-time invariant, re-checked here for defense-in-depth).
            if !names_match(&delta_crl.tbs_cert_list.issuer, &crl.tbs_cert_list.issuer) {
                return Err(Error::CrlIssuerMismatch);
            }
            // Also verify against cert's issuer (transitively guaranteed above, explicit for clarity).
            if !names_match(
                &delta_crl.tbs_cert_list.issuer,
                &cert.tbs_certificate.issuer,
            ) {
                return Err(Error::CrlIssuerMismatch);
            }

            // cRLSign was already verified above for the base CRL issuer.
            // The delta CRL uses the same `issuer` (confirmed by the name-match
            // checks above), so the cRLSign bit check is not repeated here.
            // verify_delta_crl_and_collect will re-verify the issuer-name match
            // against `expected_issuer_name` as its first step.
            verify_delta_crl_and_collect(
                delta_crl,
                &self.verifier,
                issuer
                    .tbs_certificate
                    .subject_public_key_info
                    .owned_to_ref(),
                &issuer.tbs_certificate.subject,
                self.now_unix,
            )?
        } 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_revocation_status(cert_serial, &delta_entries, crl)
    }

    /// Check revocation for `cert` issued directly by a trust anchor.
    ///
    /// Uses the anchor's `subject` and `subject_public_key_info` in place of
    /// an issuer `Certificate` to verify the CRL.  The `cRLSign` `KeyUsage` bit
    /// check is omitted because trust anchors do not carry a `Certificate` with
    /// extensions to inspect.
    ///
    /// # Limitations (v0.1)
    ///
    /// CRL discovery via the `cRLDistributionPoints` extension is not
    /// implemented.  The CRL DER must be supplied at construction time.
    /// If the CRL's issuer name does not match the anchor's subject, this
    /// method returns [`Error::CrlIssuerMismatch`] rather than `Ok(())`,
    /// ensuring a mismatched CRL is surfaced rather than silently skipped.
    fn check_revocation_against_anchor(
        &self,
        cert: &Certificate,
        anchor: &TrustAnchor,
    ) -> crate::Result<()> {
        // (1) Reuse the pre-parsed base CRL (parsed once at construction).
        let crl = &self.crl;

        // (2) The CRL issuer must match the anchor's subject DN, and the
        // certificate being checked must also be issued by that anchor.
        // Without the second check a caller can supply an anchor for CA-A and
        // a cert issued by CA-B and get Ok(()) (cert not found in CA-A's CRL)
        // when it should get CrlIssuerMismatch.
        if !names_match(&crl.tbs_cert_list.issuer, &anchor.subject) {
            return Err(Error::CrlIssuerMismatch);
        }
        if !names_match(&cert.tbs_certificate.issuer, &anchor.subject) {
            return Err(Error::CrlIssuerMismatch);
        }

        // (3) Check CRL validity window before verifying the signature.
        //     Same rationale as check_revocation: reject stale CRLs early.
        let this_update = crl.tbs_cert_list.this_update.to_unix_duration().as_secs();
        if self.now_unix < this_update {
            return Err(Error::CrlExpired);
        }
        let next_update = crl
            .tbs_cert_list
            .next_update
            .as_ref()
            .ok_or(Error::CrlExpired)?;
        if self.now_unix > next_update.to_unix_duration().as_secs() {
            return Err(Error::CrlExpired);
        }

        // (4) Verify the CRL signature against the anchor's SPKI.
        //     cRLSign KeyUsage check is skipped: trust anchors have no KeyUsage
        //     extension accessible to us (they are trusted by construction).
        let tbs_bytes = crl
            .tbs_cert_list
            .to_der()
            .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;
        self.verifier
            .verify_signature(
                crl.signature_algorithm.owned_to_ref(),
                anchor.subject_public_key_info.owned_to_ref(),
                &tbs_bytes,
                crl.signature.raw_bytes(),
            )
            // Verifier returns an opaque error; no additional context available.
            .map_err(|_| Error::CrlSignatureInvalid)?;

        // (5) IssuingDistributionPoint scope check (same as check_revocation).
        // Scope mismatches surface as Error::OutOfScope; see check_revocation
        // for rationale.
        if let Some(idp) = parse_issuing_dp(crl)? {
            if idp.only_contains_attribute_certs {
                return Err(Error::OutOfScope(
                    crate::OutOfScopeReason::CrlOnlyAttributeCerts,
                ));
            }
            let cert_is_ca = cert_is_ca_cert(cert)?;
            if idp.only_contains_user_certs && cert_is_ca {
                return Err(Error::OutOfScope(crate::OutOfScopeReason::CrlOnlyUserCerts));
            }
            if idp.only_contains_ca_certs && !cert_is_ca {
                return Err(Error::OutOfScope(crate::OutOfScopeReason::CrlOnlyCaCerts));
            }
        }

        // (6) Delta CRL merge — if a delta CRL is present, verify issuer consistency
        //     and merge it.  Uses the anchor SPKI for the delta signature check.
        let delta_entries: Vec<RevokedCert> = if let Some(delta_crl) = &self.delta_crl {
            // Reuse the pre-parsed delta CRL (parsed once at construction).

            // Cross-check: delta CRL issuer must match base CRL issuer and anchor subject.
            // Mirrors the three-way check performed in check_revocation for the cert-issuer path.
            if !names_match(&delta_crl.tbs_cert_list.issuer, &crl.tbs_cert_list.issuer) {
                return Err(Error::CrlIssuerMismatch);
            }
            if !names_match(&delta_crl.tbs_cert_list.issuer, &anchor.subject) {
                return Err(Error::CrlIssuerMismatch);
            }
            verify_delta_crl_and_collect(
                delta_crl,
                &self.verifier,
                anchor.subject_public_key_info.owned_to_ref(),
                &anchor.subject,
                self.now_unix,
            )?
        } else {
            Vec::new()
        };

        // (7) Search for the certificate's serial (delta entries take precedence).
        let cert_serial = &cert.tbs_certificate.serial_number;
        check_revocation_status(cert_serial, &delta_entries, crl)
    }
}

// ---------------------------------------------------------------------------
// Revocation status helper
// ---------------------------------------------------------------------------

/// Search for `cert_serial` in `delta_entries` (higher priority) and then in
/// the base `crl`, and return the appropriate revocation result.
///
/// RFC 5280 §5.2.4: delta CRL entries take precedence over base entries.
/// - If found in `delta_entries` with reason `RemoveFromCRL`, the
///   certificateHold was lifted and the certificate is not revoked → `Ok(())`.
/// - If found in `delta_entries` for any other reason → `Err(Revoked)`.
/// - If found in the base CRL → `Err(Revoked)`.
/// - If not found in either → `Ok(())` (not revoked).
fn check_revocation_status(
    cert_serial: &x509_cert::serial_number::SerialNumber,
    delta_entries: &[RevokedCert],
    crl: &CertificateList,
) -> crate::Result<()> {
    // Check delta CRL entries first (they take precedence over base entries).
    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(())
}

// ---------------------------------------------------------------------------
// Delta-CRL helper
// ---------------------------------------------------------------------------

/// Verify a delta CRL and return its revoked-certificate entries.
///
/// Performs (in order):
/// 1. Check that the delta CRL issuer matches `expected_issuer_name`.
/// 2. Check the delta validity window against `now_unix`.
/// 3. Verify the delta signature using `issuer_spki`.
/// 4. Return the delta's revoked-certificates list (empty if absent).
///
/// The caller is responsible for parsing the `CertificateList` and for any
/// additional issuer-name cross-checks needed by the calling context (e.g.,
/// checking the delta issuer against the base CRL issuer or the subject
/// certificate's issuer).
fn verify_delta_crl_and_collect<V: SignatureVerifier>(
    delta_crl: &CertificateList,
    verifier: &V,
    issuer_spki: spki::SubjectPublicKeyInfoRef<'_>,
    expected_issuer_name: &x509_cert::name::Name,
    now_unix: u64,
) -> crate::Result<Vec<RevokedCert>> {
    if !names_match(&delta_crl.tbs_cert_list.issuer, expected_issuer_name) {
        return Err(Error::CrlIssuerMismatch);
    }

    // Check validity window before signature verification (same rationale as
    // the base CRL paths: reject stale deltas early without paying sig-verify cost).
    let delta_this_update = delta_crl
        .tbs_cert_list
        .this_update
        .to_unix_duration()
        .as_secs();
    if now_unix < delta_this_update {
        return Err(Error::CrlExpired);
    }
    let delta_next_update = delta_crl
        .tbs_cert_list
        .next_update
        .as_ref()
        .ok_or(Error::CrlExpired)?;
    if now_unix > delta_next_update.to_unix_duration().as_secs() {
        return Err(Error::CrlExpired);
    }

    let delta_tbs_bytes = delta_crl
        .tbs_cert_list
        .to_der()
        .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;
    verifier
        .verify_signature(
            delta_crl.signature_algorithm.owned_to_ref(),
            issuer_spki,
            &delta_tbs_bytes,
            delta_crl.signature.raw_bytes(),
        )
        // Verifier returns an opaque error; no additional context available.
        .map_err(|_| Error::CrlSignatureInvalid)?;

    // NOTE: The clone produces an owned `Vec<RevokedCert>` to keep the function
    // signature simple; both call sites currently consume the result by-value.
    // After the parse-once cache landed (delta_crl is now borrowed from
    // `self.delta_crl`), a borrow-based design returning `Option<&[RevokedCert]>`
    // is feasible — it would require updating both call sites and `check_revocation`
    // / `check_revocation_against_anchor` to consume the slice in-scope. Deferred
    // as a low-priority perf cleanup; the clone is bounded by the size of the
    // revoked list and occurs at most once per call.
    Ok(delta_crl
        .tbs_cert_list
        .revoked_certificates
        .clone()
        .unwrap_or_default())
}

// ---------------------------------------------------------------------------
// 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` — `CRLNumber` extension absent (field is optional per RFC 5280 §5.2.3)
/// - `Some(Ok(n))` — extension present and successfully decoded
/// - `Some(Err(e))` — extension present but the INTEGER value is malformed or
///   too large to fit in a `u64` (a `CRLNumber` > 2^64 is implausible in any
///   deployed PKI but must not silently disable the staleness check)
fn crl_number(crl: &CertificateList) -> Option<Result<u64, der::Error>> {
    let ext = crl
        .tbs_cert_list
        .crl_extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .find(|e| e.extn_id == OID_CRL_NUMBER)?;
    let result = der::asn1::Uint::from_der(ext.extn_value.as_bytes())
        .and_then(|n| uint_to_u64(&n).ok_or_else(|| der::Error::from(der::ErrorKind::Overflow)));
    Some(result)
}

/// 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` — extension absent (CRL is not a delta CRL)
/// - `Some(Ok(n))` — extension present and successfully decoded
/// - `Some(Err(e))` — extension present but the INTEGER value is malformed
fn base_crl_number(crl: &CertificateList) -> Option<Result<u64, der::Error>> {
    let ext = crl
        .tbs_cert_list
        .crl_extensions
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .find(|e| e.extn_id == OID_DELTA_CRL_INDICATOR)?;
    let result = der::asn1::Uint::from_der(ext.extn_value.as_bytes())
        .and_then(|n| uint_to_u64(&n).ok_or_else(|| der::Error::from(der::ErrorKind::Overflow)));
    Some(result)
}

/// Checks that the CRL issuer certificate has `cRLSign` set in its `KeyUsage`
/// extension, or that `KeyUsage` is absent entirely (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.
///
/// # Errors
///
/// - `Err(CrlParseError)` — the `KeyUsage` extension value is structurally malformed.
/// - `Err(CrlSignMissing)` — the extension is present but `cRLSign` bit is not set.
fn check_crl_sign(cert: &Certificate) -> crate::Result<()> {
    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 Ok(()); // KeyUsage absent (or no extensions) → no constraint
    };
    let ku = KeyUsage::from_der(ku_ext.extn_value.as_bytes())
        .map_err(|e| Error::CrlParseError(crate::DerError(e)))?;
    if ku.crl_sign() {
        Ok(())
    } else {
        Err(Error::CrlSignMissing)
    }
}

/// 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,
) -> crate::Result<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)
        .map(|e| {
            IssuingDistributionPoint::from_der(e.extn_value.as_bytes())
                .map_err(|err| Error::CrlParseError(crate::DerError(err)))
        })
        .transpose()
}

/// Returns `Ok(true)` if `cert` is a CA certificate (`BasicConstraints`
/// `cA = TRUE`), `Ok(false)` if the extension is absent or `cA = FALSE`,
/// and [`Error::MalformedCertificate`] if the extension is present but
/// undecodable.
///
/// Fail-closed: a malformed `BasicConstraints` is propagated so the IDP
/// scope check cannot silently skip a CRL that should cover the certificate.
///
/// Thin wrapper over [`pkix_path::cert_is_ca`] that maps the opaque
/// [`pkix_path::DerError`] to this crate's [`Error::MalformedCertificate`].
fn cert_is_ca_cert(cert: &Certificate) -> crate::Result<bool> {
    pkix_path::cert_is_ca(cert).map_err(|_| Error::MalformedCertificate)
}