tsp-ltv 0.4.0

Shared timestamping (RFC 3161) and long-term validation (OCSP, CRL, trust stores) infrastructure for AdES signatures
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
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
//! Concurrent OCSP + CRL revocation checking orchestrator.
//!
//! This module provides a high-level API for checking a certificate's
//! revocation status using both OCSP and CRL concurrently. Results from
//! both sources are merged using the priority rules from [`resolve_priority`]:
//! `REVOKED > VALID > INVALID > UNKNOWN`.
//!
//! # Fail-closed policy
//!
//! When [`RevocationConfig::require_revocation_check`] is `true` (the default),
//! a merged result that is not a definitive `Valid` or `Revoked` is a hard
//! failure: an `Unknown` outcome is upgraded to `Invalid` so that an
//! unreachable, blocked, or forged revocation source can never be mistaken for
//! "fine to proceed". Set `require_revocation_check = false`
//! ([`RevocationConfig::disabled`]) for best-effort/offline validation where
//! `Unknown` is acceptable.
//!
//! # Configuration
//!
//! [`RevocationConfig`] controls timeouts, OCSP preference, nonce usage,
//! and whether revocation checking is mandatory.
//!
//! # Usage
//!
//! ```rust,no_run
//! use tsp_ltv::ltv::revocation::{RevocationConfig, check_certificate_revocation};
//! use tsp_ltv::ltv::{OcspClient, CrlClient};
//! # async fn example() -> Result<(), tsp_ltv::error::LtvError> {
//! let config = RevocationConfig::default();
//! let ocsp = OcspClient::new()?;
//! let crl = CrlClient::new()?;
//! // let status = check_certificate_revocation(&cert, &issuer, &config, &crl, &ocsp, None).await;
//! # Ok(())
//! # }
//! ```

use std::time::Duration;

use chrono::{DateTime, Utc};
use x509_cert::Certificate;

use crate::error::LtvError;
use crate::ltv::crl::{self, CrlClient};
use crate::ltv::ocsp::{self, OcspClient};
use crate::ltv::status::{resolve_priority, ValidationStatus};

// ── Configuration ─────────────────────────────────────────────────

/// Configuration for revocation checking behavior.
///
/// Defaults match the Java stack's `BasicCertificateValidityChecker`:
/// - OCSP preferred over CRL
/// - Both checked concurrently
/// - 3 second OCSP timeout, 7 second CRL timeout
/// - Nonces enabled for replay protection
/// - Revocation checking required (Unknown → error)
///
/// # Construction
///
/// Build from [`RevocationConfig::default`] (or
/// [`disabled`](RevocationConfig::disabled) / [`strict`](RevocationConfig::strict))
/// and override individual fields with struct-update syntax or the builder
/// methods, e.g.
///
/// ```rust
/// # use tsp_ltv::ltv::revocation::RevocationConfig;
/// let config = RevocationConfig {
///     require_revocation_check: false,
///     ..RevocationConfig::default()
/// };
/// ```
///
/// Using `..Default::default()` (rather than an exhaustive struct literal) keeps
/// your code compiling when new fields are added in a later version.
#[derive(Debug, Clone)]
pub struct RevocationConfig {
    /// Prefer OCSP over CRL when both are available.
    ///
    /// When true, OCSP is given precedence in the result. Both are still
    /// checked concurrently regardless of this setting. Default: `true`.
    pub prefer_ocsp: bool,

    /// Whether a definitive revocation result is required.
    ///
    /// When true (the default), [`check_certificate_revocation`] enforces a
    /// fail-closed policy: any non-definitive merged result (`Unknown`, or an
    /// `Invalid` from a malformed/forged source) blocks, and `Unknown` is
    /// upgraded to `Invalid` so callers cannot treat it as acceptable. When
    /// false, `Unknown` is returned unchanged for best-effort/offline use.
    /// Default: `true`.
    pub require_revocation_check: bool,

    /// OCSP request timeout. Default: 3 seconds.
    pub ocsp_timeout: Duration,

    /// CRL fetch timeout. Default: 7 seconds.
    pub crl_timeout: Duration,

    /// Whether to include a nonce in OCSP requests. Default: `true`.
    pub use_ocsp_nonce: bool,

    /// Maximum recursion depth for OCSP responder revocation checking.
    ///
    /// When an OCSP responder certificate itself needs revocation checking,
    /// this limits how deep we go. Default: 1.
    pub max_ocsp_recursion: usize,

    /// Overall per-certificate timeout for both OCSP + CRL combined.
    ///
    /// If both checks together take longer than this, the remaining check
    /// is abandoned and whatever results are available are used. Default: 10 seconds.
    pub per_cert_timeout: Duration,

    /// Signature-algorithm policy applied to OCSP responses and CRLs.
    ///
    /// Defaults to strict — an OCSP response or CRL signed with MD5/SHA-1/SHA-224
    /// fails validation (→ `Invalid`). Set via
    /// [`RevocationConfig::allow_legacy_signatures`] to accept such signatures
    /// when validating historical revocation material.
    pub signature_policy: crate::crypto::verify::SignaturePolicy,

    /// Freshness policy applied to OCSP responses (RFC 6960 §4.2.2.1).
    ///
    /// Bounds the `thisUpdate`/`nextUpdate` window a response may be relied upon
    /// for, relative to the validation time. A stale response fails validation
    /// (→ `Invalid`), so a legitimate "good" response cannot be replayed after
    /// the certificate is later revoked.
    pub ocsp_freshness: crate::ltv::ocsp::OcspFreshness,

    /// Freshness policy applied to CRLs (RFC 5280 §6.3.3).
    ///
    /// Bounds the `thisUpdate`/`nextUpdate` window a CRL may be relied upon for,
    /// relative to the validation time. A stale CRL (validation instant past
    /// `nextUpdate`, or — lacking `nextUpdate` — older than the configured
    /// maximum age) fails validation (→ `Invalid`), so a superseded CRL cannot be
    /// replayed to hide a serial that a fresher CRL revokes. Default:
    /// [`CrlFreshness::default`](crate::ltv::crl::CrlFreshness::default).
    pub crl_freshness: crate::ltv::crl::CrlFreshness,
}

impl Default for RevocationConfig {
    fn default() -> Self {
        Self {
            prefer_ocsp: true,
            require_revocation_check: true,
            ocsp_timeout: Duration::from_secs(3),
            crl_timeout: Duration::from_secs(7),
            use_ocsp_nonce: true,
            max_ocsp_recursion: 1,
            per_cert_timeout: Duration::from_secs(10),
            signature_policy: crate::crypto::verify::SignaturePolicy::default(),
            ocsp_freshness: crate::ltv::ocsp::OcspFreshness::default(),
            crl_freshness: crate::ltv::crl::CrlFreshness::default(),
        }
    }
}

impl RevocationConfig {
    /// Create a config that disables revocation checking.
    ///
    /// Useful for offline validation where OCSP/CRL endpoints are
    /// unreachable.
    pub fn disabled() -> Self {
        Self {
            require_revocation_check: false,
            ..Default::default()
        }
    }

    /// Create a strict config with shorter timeouts.
    pub fn strict() -> Self {
        Self {
            require_revocation_check: true,
            ocsp_timeout: Duration::from_secs(2),
            crl_timeout: Duration::from_secs(5),
            per_cert_timeout: Duration::from_secs(8),
            ..Default::default()
        }
    }

    /// Accept OCSP responses and CRLs signed with weak/legacy digests
    /// (MD5/SHA-1/SHA-224).
    ///
    /// Off by default. Enable only to validate historical revocation material
    /// whose risk you have accepted; never for fresh trust decisions.
    pub fn allow_legacy_signatures(mut self) -> Self {
        self.signature_policy = crate::crypto::verify::SignaturePolicy::allow_legacy();
        self
    }

    /// Set the OCSP response freshness policy (see
    /// [`OcspFreshness`](crate::ltv::ocsp::OcspFreshness)).
    pub fn with_ocsp_freshness(mut self, freshness: crate::ltv::ocsp::OcspFreshness) -> Self {
        self.ocsp_freshness = freshness;
        self
    }

    /// Set the CRL freshness policy (see
    /// [`CrlFreshness`](crate::ltv::crl::CrlFreshness)).
    pub fn with_crl_freshness(mut self, freshness: crate::ltv::crl::CrlFreshness) -> Self {
        self.crl_freshness = freshness;
        self
    }
}

// ── Async orchestrator ────────────────────────────────────────────

/// Check a single certificate's revocation status using OCSP and CRL concurrently.
///
/// This is the primary entry point for revocation checking. It:
///
/// 1. Launches OCSP and CRL checks concurrently
/// 2. Wraps both in a per-certificate timeout
/// 3. Merges results using `resolve_priority` (REVOKED > VALID > UNKNOWN)
///
/// # Arguments
///
/// - `cert` — the certificate to check
/// - `issuer` — the issuer's certificate (needed for signature verification)
/// - `config` — timeout/behavior configuration
/// - `crl_client` — CRL fetching client
/// - `ocsp_client` — OCSP querying client
/// - `validation_time` — if `None`, uses the current time
///
/// # Returns
///
/// A [`ValidationStatus`] reflecting the merged result of both checks, after
/// applying the `require_revocation_check` policy.
///
/// - With `require_revocation_check == true` (default), only a verified `Valid`
///   or a `Revoked` passes; any `Unknown` (both sources unreachable/blocked/
///   absent) or `Invalid` (a source served malformed/forged data) is a hard
///   failure, with `Unknown` upgraded to `Invalid`. This prevents an attacker
///   from getting a revoked certificate accepted by blocking or forging the
///   revocation sources.
/// - With `require_revocation_check == false`, the merged status is returned
///   unchanged, so `Unknown` is acceptable (best-effort/offline mode).
pub async fn check_certificate_revocation(
    cert: &Certificate,
    issuer: &Certificate,
    config: &RevocationConfig,
    crl_client: &CrlClient,
    ocsp_client: &OcspClient,
    validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
    // Run OCSP and CRL checks concurrently with a per-cert timeout
    let ocsp_fut = run_ocsp_check(
        cert,
        issuer,
        config,
        crl_client,
        ocsp_client,
        validation_time,
    );
    let crl_fut = run_crl_check(cert, issuer, config, crl_client, validation_time);

    // Use tokio::join! for concurrent execution, wrapped in a timeout
    let result = tokio::time::timeout(config.per_cert_timeout, async {
        tokio::join!(ocsp_fut, crl_fut)
    })
    .await;

    let (ocsp_status, crl_status) = match result {
        Ok((ocsp, crl)) => (ocsp, crl),
        Err(_elapsed) => {
            log::warn!(
                "per-certificate revocation check timed out after {:?}",
                config.per_cert_timeout
            );
            (
                ValidationStatus::Unknown {
                    reason: "OCSP check timed out".into(),
                },
                ValidationStatus::Unknown {
                    reason: "CRL check timed out".into(),
                },
            )
        }
    };

    log::debug!("OCSP result: {ocsp_status}, CRL result: {crl_status}");

    // Merge results using priority, then apply the fail-closed policy.
    let merged = resolve_priority(ocsp_status, crl_status);
    enforce_revocation_policy(merged, config.require_revocation_check)
}

/// Apply the `require_revocation_check` policy to a merged revocation result.
///
/// Under a strict policy a result that is not a definitive `Valid` or `Revoked`
/// must block. An `Unknown` outcome — no usable revocation information, because
/// every source was absent, unreachable, blocked, or timed out — is upgraded to
/// `Invalid` so the returned status is unambiguously non-acceptable; a caller
/// can no longer mistake a blocked or absent revocation source for "fine to
/// proceed". An already-`Invalid` result (a source served malformed/forged
/// data) is left as-is — it already blocks. When the policy is relaxed
/// (`require_revocation_check == false`), the merged status is returned
/// unchanged so `Unknown` remains acceptable for offline/best-effort use.
fn enforce_revocation_policy(
    status: ValidationStatus,
    require_revocation_check: bool,
) -> ValidationStatus {
    if !require_revocation_check {
        return status;
    }
    match status {
        ValidationStatus::Unknown { reason } => ValidationStatus::Invalid {
            reason: format!(
                "revocation check required but status could not be established: {reason}"
            ),
        },
        other => other,
    }
}

/// Classify an error from [`ocsp::check_revocation`] into a [`ValidationStatus`].
///
/// Only **definitive integrity failures** — bad signature, malformed/expired
/// response, nonce mismatch, untrusted responder — become `Invalid` (a hard,
/// dominating, attack-indicating result). A **responder-side / transient
/// status** (`tryLater`, `internalError`, `unauthorized`, ...) is reported by
/// the responder, not proof of a forged or malformed response, so it stays
/// `Unknown` (non-determinative). This keeps a temporary OCSP outage as
/// best-effort `Unknown` rather than escalating it to a hard failure that would
/// dominate a CRL `Unknown` even under a relaxed policy.
fn ocsp_check_error_to_status(e: LtvError) -> ValidationStatus {
    match e {
        LtvError::OcspResponderStatus(_) => {
            log::warn!("OCSP responder returned a non-successful status: {e}");
            ValidationStatus::Unknown {
                reason: format!("OCSP responder unavailable: {e}"),
            }
        }
        other => {
            log::warn!("OCSP response failed validation: {other}");
            ValidationStatus::Invalid {
                reason: format!("OCSP response failed validation: {other}"),
            }
        }
    }
}

/// Sync wrapper for [`check_certificate_revocation`].
///
/// Available when the `blocking` feature is enabled. Uses `tokio::runtime::Runtime::block_on()`
/// to execute the async function synchronously.
#[cfg(feature = "blocking")]
pub fn check_certificate_revocation_blocking(
    cert: &Certificate,
    issuer: &Certificate,
    config: &RevocationConfig,
    crl_client: &CrlClient,
    ocsp_client: &OcspClient,
    validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("failed to create tokio runtime");
    rt.block_on(check_certificate_revocation(
        cert,
        issuer,
        config,
        crl_client,
        ocsp_client,
        validation_time,
    ))
}

// ── Internal: OCSP check ──────────────────────────────────────────

/// Run the OCSP check for a single certificate.
///
/// Returns `Unknown` if no OCSP URLs are available, the fetch fails, the request
/// times out, or the responder returns a non-successful status (tryLater,
/// internalError, unauthorized, ...) — all non-determinative. Returns `Invalid`
/// only for a definitive integrity failure on a received response (bad
/// signature, malformed, nonce mismatch, untrusted responder).
async fn run_ocsp_check(
    cert: &Certificate,
    issuer: &Certificate,
    config: &RevocationConfig,
    crl_client: &CrlClient,
    ocsp_client: &OcspClient,
    validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
    // Check if cert has OCSP URLs
    let urls = OcspClient::extract_ocsp_urls(cert);
    if urls.is_empty() {
        log::debug!("no OCSP responder URL in certificate AIA extension");
        return ValidationStatus::Unknown {
            reason: "no OCSP responder URL available".into(),
        };
    }

    // Apply OCSP-specific timeout
    let result = tokio::time::timeout(config.ocsp_timeout, async {
        // Fetch the OCSP response. A fetch failure (network, no endpoint, HTTP
        // error) is non-determinative → Unknown.
        let fetched = if config.use_ocsp_nonce {
            ocsp_client
                .fetch_ocsp_response_with_nonce(cert, issuer)
                .await
                .map(|(der, nonce)| (der, Some(nonce)))
        } else {
            ocsp_client
                .fetch_ocsp_response(cert, issuer)
                .await
                .map(|der| (der, None))
        };
        let (response_der, nonce) = match fetched {
            Ok(v) => v,
            Err(e) => {
                log::warn!("OCSP fetch failed: {e}");
                return ValidationStatus::Unknown {
                    reason: format!("OCSP fetch failed: {e}"),
                };
            }
        };

        // We received a response. Classify the outcome: a definitive integrity
        // failure (bad signature, malformed/expired response, nonce mismatch,
        // untrusted responder) is Invalid, but a responder-side / transient
        // status (tryLater, internalError, unauthorized, ...) is non-
        // determinative and stays Unknown — so a temporary responder outage
        // does not become a hard failure under best-effort/offline policy.
        let outcome = match ocsp::check_revocation_detailed(
            &response_der,
            cert,
            issuer,
            nonce.as_deref(),
            validation_time,
            &config.signature_policy,
            &config.ocsp_freshness,
        ) {
            Ok(outcome) => outcome,
            Err(e) => return ocsp_check_error_to_status(e),
        };

        // RFC 6960 §4.2.2.2.1: a delegated responder lacking
        // id-pkix-ocsp-nocheck must itself be revocation-checked. If its own
        // status is Revoked — or definitively Invalid (e.g. an exhausted
        // recursion budget on a nested delegation, or an integrity failure on
        // the responder's own revocation data) — the OCSP response cannot be
        // trusted, so we fail closed (Invalid). The sub-check inherits the
        // parent's `require_revocation_check` policy, so under the strict
        // default an Unknown/unreachable responder-revocation result also
        // blocks (upgraded to Invalid inside the sub-check); only under a
        // relaxed policy is it tolerated. Recursion is bounded by
        // `max_ocsp_recursion` so a responder that itself uses a delegated
        // responder cannot loop unboundedly.
        if let Some(responder) = &outcome.delegated_responder {
            if config.max_ocsp_recursion == 0 {
                // No budget to check the responder's own status. Fail closed:
                // an unchecked delegated responder is not trustworthy.
                return ValidationStatus::Invalid {
                    reason: "delegated OCSP responder lacks id-pkix-ocsp-nocheck and \
                             responder-revocation checking is disabled (max_ocsp_recursion = 0)"
                        .into(),
                };
            }
            // Box the recursive call to break the async-recursion cycle
            // (run_ocsp_check → here → run_ocsp_check), bounded by
            // `max_ocsp_recursion`.
            let responder_status: ValidationStatus =
                Box::pin(check_delegated_responder_revocation(
                    responder,
                    issuer,
                    config,
                    crl_client,
                    ocsp_client,
                    validation_time,
                ))
                .await;
            if responder_status.is_revoked() || responder_status.is_invalid() {
                return ValidationStatus::Invalid {
                    reason: format!(
                        "delegated OCSP responder certificate could not be confirmed \
                         unrevoked: {responder_status}"
                    ),
                };
            }
        }

        outcome.status
    })
    .await;

    match result {
        Ok(status) => status,
        Err(_elapsed) => {
            log::warn!("OCSP check timed out after {:?}", config.ocsp_timeout);
            ValidationStatus::Unknown {
                reason: format!("OCSP check timed out after {:?}", config.ocsp_timeout),
            }
        }
    }
}

/// Check the revocation status of a *delegated* OCSP responder certificate
/// (RFC 6960 §4.2.2.2.1) against its issuing CA.
///
/// Runs the same OCSP+CRL machinery as a normal certificate, but with
/// `max_ocsp_recursion` decremented so a responder that is itself served by a
/// delegated responder cannot recurse without bound. The parent's
/// `require_revocation_check` policy is **inherited**: under the strict default
/// an unreachable/absent responder-revocation source upgrades to `Invalid`
/// (RFC 6960 §4.2.2.2.1 — a delegated responder without `id-pkix-ocsp-nocheck`
/// whose own status cannot be established is not trustworthy, and an attacker
/// who can block the responder's revocation sources must not be able to keep a
/// revoked responder's responses acceptable). Under a relaxed policy
/// (`require_revocation_check == false`) an `Unknown` responder status is
/// tolerated and only a definitively **Revoked**/**Invalid** responder blocks
/// (handled by the caller).
async fn check_delegated_responder_revocation(
    responder: &Certificate,
    ca_issuer: &Certificate,
    config: &RevocationConfig,
    crl_client: &CrlClient,
    ocsp_client: &OcspClient,
    validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
    let sub_config = RevocationConfig {
        // Decrement the recursion budget; bottom out at 0 (the OCSP path treats
        // a delegated responder with no remaining budget as fail-closed).
        max_ocsp_recursion: config.max_ocsp_recursion.saturating_sub(1),
        // Inherit the parent's strictness (see the doc comment above).
        require_revocation_check: config.require_revocation_check,
        ..config.clone()
    };

    // Run OCSP and CRL concurrently for the responder, bounded by the same
    // per-cert timeout. This intentionally does NOT recurse through
    // `check_certificate_revocation` (which would re-apply the strict policy);
    // it calls the inner checks directly with the relaxed sub-config.
    let ocsp_fut = run_ocsp_check(
        responder,
        ca_issuer,
        &sub_config,
        crl_client,
        ocsp_client,
        validation_time,
    );
    let crl_fut = run_crl_check(
        responder,
        ca_issuer,
        &sub_config,
        crl_client,
        validation_time,
    );

    let (ocsp_status, crl_status) = match tokio::time::timeout(config.per_cert_timeout, async {
        tokio::join!(ocsp_fut, crl_fut)
    })
    .await
    {
        Ok(pair) => pair,
        Err(_) => (
            ValidationStatus::Unknown {
                reason: "responder OCSP check timed out".into(),
            },
            ValidationStatus::Unknown {
                reason: "responder CRL check timed out".into(),
            },
        ),
    };

    // Apply the inherited policy to the merged responder status: under strict,
    // an Unknown (unreachable/absent responder-revocation source) upgrades to
    // Invalid, which the caller treats as blocking.
    let merged = resolve_priority(ocsp_status, crl_status);
    enforce_revocation_policy(merged, sub_config.require_revocation_check)
}

// ── Internal: CRL check ───────────────────────────────────────────

/// Run the CRL check for a single certificate.
///
/// Returns `Unknown` if no CRL distribution points are available, no CRL could
/// be fetched, or the fetch times out. Returns `Invalid` if a CRL was fetched
/// but failed validation (bad signature, malformed structure).
async fn run_crl_check(
    cert: &Certificate,
    issuer: &Certificate,
    config: &RevocationConfig,
    crl_client: &CrlClient,
    validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
    // Check if cert has CRL distribution points
    let urls = CrlClient::extract_crl_urls(cert);
    if urls.is_empty() {
        log::debug!("no CRL distribution points in certificate");
        return ValidationStatus::Unknown {
            reason: "no CRL distribution points available".into(),
        };
    }

    // Apply CRL-specific timeout
    let result = tokio::time::timeout(config.crl_timeout, async {
        // Fetch CRL(s) for this certificate. Thread the *configured* CRL
        // freshness into the fetch/cache selection so a single policy governs
        // both which CRL is chosen and the authoritative freshness check below —
        // a caller who widens `crl_freshness` is never blocked by a stricter
        // default at the fetch layer.
        match crl_client
            .fetch_crls_for_cert_with_freshness(cert, &config.crl_freshness)
            .await
        {
            Ok(crls) => {
                if crls.is_empty() {
                    return ValidationStatus::Unknown {
                        reason: "no CRLs could be fetched".into(),
                    };
                }

                // Check the CRL that `fetch_crls_for_cert` selected: the first
                // distribution point that downloaded and was current, or — if
                // none were current — a stale CRL as a fail-closed fallback.
                // A validation failure on a CRL we actually received (bad
                // signature, malformed structure, stale/expired window) is a
                // definitive negative result → Invalid, not Unknown, so a forged
                // or superseded CRL cannot fail open by masquerading as "status
                // undetermined".
                match crl::check_revocation_with_options(
                    &crls[0],
                    cert,
                    issuer,
                    validation_time,
                    &config.signature_policy,
                    &config.crl_freshness,
                ) {
                    Ok(status) => status,
                    Err(e) => {
                        log::warn!("CRL revocation check failed: {e}");
                        ValidationStatus::Invalid {
                            reason: format!("CRL validation failed: {e}"),
                        }
                    }
                }
            }
            Err(e) => {
                log::warn!("CRL fetch failed: {e}");
                ValidationStatus::Unknown {
                    reason: format!("CRL fetch failed: {e}"),
                }
            }
        }
    })
    .await;

    match result {
        Ok(status) => status,
        Err(_elapsed) => {
            log::warn!("CRL check timed out after {:?}", config.crl_timeout);
            ValidationStatus::Unknown {
                reason: format!("CRL check timed out after {:?}", config.crl_timeout),
            }
        }
    }
}

// ── Tests ─────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ltv::status::RevocationSource;

    // ── RevocationConfig tests ────────────────────────────────────

    #[test]
    fn test_default_config() {
        let config = RevocationConfig::default();
        assert!(config.prefer_ocsp);
        assert!(config.require_revocation_check);
        assert!(config.use_ocsp_nonce);
        assert_eq!(config.ocsp_timeout, Duration::from_secs(3));
        assert_eq!(config.crl_timeout, Duration::from_secs(7));
        assert_eq!(config.max_ocsp_recursion, 1);
        assert_eq!(config.per_cert_timeout, Duration::from_secs(10));
    }

    #[test]
    fn test_disabled_config() {
        let config = RevocationConfig::disabled();
        assert!(!config.require_revocation_check);
        assert!(config.prefer_ocsp); // still defaults for others
    }

    #[test]
    fn test_strict_config() {
        let config = RevocationConfig::strict();
        assert!(config.require_revocation_check);
        assert_eq!(config.ocsp_timeout, Duration::from_secs(2));
        assert_eq!(config.crl_timeout, Duration::from_secs(5));
        assert_eq!(config.per_cert_timeout, Duration::from_secs(8));
    }

    // ── Orchestrator unit tests (no network) ──────────────────────
    //
    // These tests verify the orchestrator logic using certificates
    // that have no OCSP/CRL endpoints (our test fixtures), so both
    // checks return Unknown → merged result is Unknown.

    fn load_ca_and_intermediate() -> (Certificate, Certificate) {
        let ca_pem = include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/ca_cert.pem"
        ));
        let intermediate_pem = include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/intermediate_ca_cert.pem"
        ));

        let (_, ca_der) = pem_rfc7468::decode_vec(ca_pem.as_bytes()).unwrap();
        let (_, inter_der) = pem_rfc7468::decode_vec(intermediate_pem.as_bytes()).unwrap();

        let ca = der::Decode::from_der(&ca_der).unwrap();
        let intermediate: Certificate = der::Decode::from_der(&inter_der).unwrap();
        (ca, intermediate)
    }

    #[tokio::test]
    async fn test_check_no_endpoints_strict_hard_fails() {
        // C-2 regression: with the default (strict) policy, a certificate that
        // offers no usable revocation source must NOT fail open as Unknown — it
        // must hard-fail (Invalid) so callers cannot proceed.
        let (ca, intermediate) = load_ca_and_intermediate();

        let config = RevocationConfig::default();
        assert!(config.require_revocation_check);
        let crl_client = CrlClient::new().unwrap();
        let ocsp_client = OcspClient::new().unwrap();

        let status = check_certificate_revocation(
            &intermediate,
            &ca,
            &config,
            &crl_client,
            &ocsp_client,
            None,
        )
        .await;

        assert!(
            status.is_invalid(),
            "strict policy must hard-fail when no revocation source is usable, got: {status}"
        );
        assert!(
            !status.is_unknown(),
            "strict policy must not return Unknown (fail-open), got: {status}"
        );
    }

    #[tokio::test]
    async fn test_check_no_endpoints_disabled_returns_unknown() {
        // With revocation checking disabled, the same certs return Unknown
        // (best-effort/offline mode) rather than hard-failing.
        let (ca, intermediate) = load_ca_and_intermediate();

        let config = RevocationConfig::disabled();
        assert!(!config.require_revocation_check);
        let crl_client = CrlClient::new().unwrap();
        let ocsp_client = OcspClient::new().unwrap();

        let status = check_certificate_revocation(
            &intermediate,
            &ca,
            &config,
            &crl_client,
            &ocsp_client,
            None,
        )
        .await;

        assert!(
            status.is_unknown(),
            "disabled policy should return Unknown for certs without endpoints, got: {status}"
        );
    }

    #[tokio::test]
    async fn test_resolve_ocsp_valid_crl_unknown() {
        // Test the resolve_priority logic directly through the merge
        let ocsp = ValidationStatus::Valid {
            source: RevocationSource::Ocsp,
            checked_at: Utc::now(),
        };
        let crl = ValidationStatus::Unknown {
            reason: "no CRL endpoints".into(),
        };
        let result = resolve_priority(ocsp, crl);
        assert!(result.is_valid());
    }

    #[tokio::test]
    async fn test_resolve_ocsp_unknown_crl_revoked() {
        let ocsp = ValidationStatus::Unknown {
            reason: "timeout".into(),
        };
        let crl = ValidationStatus::Revoked {
            source: RevocationSource::Crl,
            reason: crate::ltv::status::RevocationReason::KeyCompromise,
            revocation_time: Utc::now(),
        };
        let result = resolve_priority(ocsp, crl);
        assert!(result.is_revoked());
    }

    #[tokio::test]
    async fn test_resolve_both_valid_picks_first() {
        let ocsp = ValidationStatus::Valid {
            source: RevocationSource::Ocsp,
            checked_at: Utc::now(),
        };
        let crl = ValidationStatus::Valid {
            source: RevocationSource::Crl,
            checked_at: Utc::now(),
        };
        let result = resolve_priority(ocsp, crl);
        // Both valid, first (OCSP) wins since priorities are equal
        assert!(result.is_valid());
        match result {
            ValidationStatus::Valid { source, .. } => {
                assert_eq!(source, RevocationSource::Ocsp);
            }
            _ => panic!("expected Valid"),
        }
    }

    #[tokio::test]
    async fn test_resolve_both_unknown() {
        let a = ValidationStatus::Unknown {
            reason: "no OCSP".into(),
        };
        let b = ValidationStatus::Unknown {
            reason: "no CRL".into(),
        };
        let result = resolve_priority(a, b);
        assert!(result.is_unknown());
    }

    #[tokio::test]
    async fn test_check_with_signer_cert_strict_hard_fails() {
        // Signer cert also has no OCSP/CRL endpoints in our test fixtures, so
        // the strict default policy hard-fails (Invalid) rather than Unknown.
        let signer_pem = include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/signer_cert.pem"
        ));
        let intermediate_pem = include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/intermediate_ca_cert.pem"
        ));

        let (_, signer_der) = pem_rfc7468::decode_vec(signer_pem.as_bytes()).unwrap();
        let (_, inter_der) = pem_rfc7468::decode_vec(intermediate_pem.as_bytes()).unwrap();

        let signer: Certificate = der::Decode::from_der(&signer_der).unwrap();
        let intermediate: Certificate = der::Decode::from_der(&inter_der).unwrap();

        let config = RevocationConfig {
            per_cert_timeout: Duration::from_secs(2),
            ..Default::default()
        };
        let crl_client = CrlClient::new().unwrap();
        let ocsp_client = OcspClient::new().unwrap();

        let status = check_certificate_revocation(
            &signer,
            &intermediate,
            &config,
            &crl_client,
            &ocsp_client,
            None,
        )
        .await;

        assert!(
            status.is_invalid(),
            "expected hard fail (Invalid) for signer cert without endpoints under strict policy, got: {status}"
        );
    }

    // ── Policy enforcement unit tests ─────────────────────────────

    #[test]
    fn test_enforce_policy_upgrades_unknown_to_invalid_when_required() {
        let unknown = ValidationStatus::Unknown {
            reason: "both sources unreachable".into(),
        };
        let enforced = enforce_revocation_policy(unknown, true);
        assert!(
            enforced.is_invalid(),
            "strict policy must upgrade Unknown to Invalid, got: {enforced}"
        );
    }

    #[test]
    fn test_enforce_policy_keeps_unknown_when_not_required() {
        let unknown = ValidationStatus::Unknown {
            reason: "offline".into(),
        };
        assert!(enforce_revocation_policy(unknown, false).is_unknown());
    }

    #[test]
    fn test_enforce_policy_passes_valid_and_revoked_through() {
        let valid = ValidationStatus::Valid {
            source: RevocationSource::Ocsp,
            checked_at: Utc::now(),
        };
        assert!(enforce_revocation_policy(valid, true).is_valid());

        let revoked = ValidationStatus::Revoked {
            source: RevocationSource::Crl,
            reason: crate::ltv::status::RevocationReason::KeyCompromise,
            revocation_time: Utc::now(),
        };
        assert!(enforce_revocation_policy(revoked, true).is_revoked());
    }

    #[test]
    fn test_enforce_policy_keeps_invalid_blocking() {
        // An Invalid (forged/malformed source) already blocks and is left as-is.
        let invalid = ValidationStatus::Invalid {
            reason: "forged CRL".into(),
        };
        assert!(enforce_revocation_policy(invalid, true).is_invalid());
    }

    #[test]
    fn test_ocsp_responder_status_maps_to_unknown_not_invalid() {
        // Responder-side / transient statuses (tryLater, internalError, ...) are
        // non-determinative and must NOT escalate to Invalid.
        for msg in ["tryLater (3)", "internalError (2)", "unauthorized (6)"] {
            let status = ocsp_check_error_to_status(LtvError::OcspResponderStatus(msg.into()));
            assert!(
                status.is_unknown(),
                "responder status {msg} must map to Unknown, got: {status}"
            );
        }
    }

    #[test]
    fn test_ocsp_integrity_failure_maps_to_invalid() {
        // Definitive integrity failures (bad signature, malformed, nonce
        // mismatch, untrusted responder) must map to Invalid.
        for e in [
            LtvError::Ocsp("OCSP signature verification failed".into()),
            LtvError::Ocsp("nonce mismatch".into()),
            LtvError::Ocsp("responder certificate not trusted".into()),
        ] {
            let status = ocsp_check_error_to_status(e);
            assert!(
                status.is_invalid(),
                "integrity failure must map to Invalid, got: {status}"
            );
        }
    }

    #[tokio::test]
    async fn test_ocsp_outage_does_not_dominate_under_relaxed_policy() {
        // Regression: a transient OCSP responder status (Unknown) must not, when
        // merged with a CRL Unknown, become a dominating Invalid. Under a
        // relaxed policy the merged result stays Unknown (best-effort).
        let ocsp = ocsp_check_error_to_status(LtvError::OcspResponderStatus("tryLater (3)".into()));
        let crl = ValidationStatus::Unknown {
            reason: "no CRL distribution points".into(),
        };
        let merged = resolve_priority(ocsp, crl);
        assert!(
            merged.is_unknown(),
            "transient OCSP outage must not become Invalid"
        );
        assert!(enforce_revocation_policy(merged, false).is_unknown());
    }

    #[test]
    fn test_forged_source_plus_unreachable_hard_fails_under_strict() {
        // Attack scenario: one source is forged (→ Invalid), the other is
        // blocked (→ Unknown). Merge yields Invalid (dominates Unknown), and
        // the strict policy keeps it blocking. The revoked cert is not accepted.
        let forged = ValidationStatus::Invalid {
            reason: "forged CRL signature".into(),
        };
        let blocked = ValidationStatus::Unknown {
            reason: "OCSP egress blocked".into(),
        };
        let merged = resolve_priority(forged, blocked);
        assert!(merged.is_invalid());
        assert!(enforce_revocation_policy(merged, true).is_invalid());
    }

    #[test]
    fn test_config_custom() {
        let config = RevocationConfig {
            prefer_ocsp: false,
            require_revocation_check: false,
            ocsp_timeout: Duration::from_millis(500),
            crl_timeout: Duration::from_millis(1000),
            use_ocsp_nonce: false,
            max_ocsp_recursion: 0,
            per_cert_timeout: Duration::from_secs(2),
            signature_policy: crate::crypto::verify::SignaturePolicy::default(),
            ocsp_freshness: crate::ltv::ocsp::OcspFreshness::default(),
            crl_freshness: crate::ltv::crl::CrlFreshness::default(),
        };
        assert!(!config.prefer_ocsp);
        assert!(!config.require_revocation_check);
        assert!(!config.use_ocsp_nonce);
        assert_eq!(config.max_ocsp_recursion, 0);
    }
}