Skip to main content

kipuka/ocsp/
mod.rs

1//! OCSP client for certificate revocation checking per RFC 6960.
2//!
3//! Provides an [`OcspClient`] that sends OCSP requests to a configured
4//! responder URL, caches responses, and integrates with the mTLS
5//! authentication layer (RHELBU-3536 R21).
6//!
7//! # Protocol overview (RFC 6960)
8//!
9//! An OCSP request identifies the certificate to check via a `CertID`
10//! structure (§4.1.1) containing:
11//! - Hash algorithm
12//! - Hash of issuer's distinguished name
13//! - Hash of issuer's public key
14//! - Certificate serial number
15//!
16//! The responder returns a signed `BasicOCSPResponse` (§4.2.1) with a
17//! status of `good`, `revoked`, or `unknown` for each queried certificate.
18//!
19//! # Nonce support
20//!
21//! Per RFC 6960 §4.4.1, the client MAY include a nonce extension in the
22//! request to prevent replay attacks. When [`OcspConfig::require_nonce`]
23//! is `true`, the client rejects responses that do not echo the nonce.
24//!
25//! # Caching
26//!
27//! Responses are cached in a concurrent `DashMap` keyed by `CertId`.
28//! The cache TTL is configurable via [`OcspConfig::cache_ttl_secs`].
29
30use std::sync::Arc;
31use std::time::{Duration, Instant};
32
33use dashmap::DashMap;
34use serde::Deserialize;
35use sha2::{Digest, Sha256};
36use synta::{Decoder, Encoding, ToDer};
37use synta_certificate::SignatureVerifier;
38use thiserror::Error;
39use tracing::{debug, info, warn};
40
41/// OCSP-specific errors.
42#[derive(Debug, Error)]
43pub enum OcspError {
44    /// Failed to build the OCSP request.
45    #[error("OCSP request build error: {0}")]
46    RequestBuild(String),
47
48    /// HTTP transport error when contacting the responder.
49    #[error("OCSP transport error: {0}")]
50    Transport(String),
51
52    /// The responder returned a non-successful OCSP response status
53    /// (RFC 6960 §4.2.1: malformedRequest, internalError, tryLater, etc.).
54    #[error("OCSP response status: {0}")]
55    ResponseStatus(String),
56
57    /// Signature verification of the OCSP response failed.
58    #[error("OCSP response signature verification failed: {0}")]
59    SignatureVerification(String),
60
61    /// The response did not contain a status for the queried certificate.
62    #[error("OCSP response missing status for queried certificate")]
63    MissingCertStatus,
64
65    /// Nonce mismatch between request and response.
66    #[error("OCSP nonce mismatch: replay attack possible")]
67    NonceMismatch,
68
69    /// Response parsing error.
70    #[error("OCSP response parse error: {0}")]
71    Parse(String),
72
73    /// Timeout contacting the OCSP responder.
74    #[error("OCSP responder timeout after {0}s")]
75    Timeout(u64),
76}
77
78/// Result type for OCSP operations.
79pub type OcspResult<T> = Result<T, OcspError>;
80
81/// Certificate revocation status per RFC 6960 §4.2.1.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum OcspStatus {
84    /// The certificate is not revoked (RFC 6960 §4.2.1, CertStatus `good`).
85    Good,
86
87    /// The certificate has been revoked (RFC 6960 §4.2.1, CertStatus `revoked`).
88    ///
89    /// Includes the CRL reason code (RFC 5280 §5.3.1) and revocation time
90    /// as an ISO 8601 string.
91    Revoked {
92        /// CRL reason code per RFC 5280 §5.3.1 (e.g., "keyCompromise").
93        reason: String,
94        /// Revocation time as ISO 8601 string.
95        revocation_time: String,
96    },
97
98    /// The responder does not know the certificate (RFC 6960 §4.2.1,
99    /// CertStatus `unknown`).
100    Unknown,
101}
102
103/// Identifier for a certificate in an OCSP request.
104///
105/// Per RFC 6960 §4.1.1:
106/// ```text
107/// CertID ::= SEQUENCE {
108///     hashAlgorithm       AlgorithmIdentifier,
109///     issuerNameHash      OCTET STRING,
110///     issuerKeyHash       OCTET STRING,
111///     serialNumber        CertificateSerialNumber
112/// }
113/// ```
114#[derive(Debug, Clone, PartialEq, Eq, Hash)]
115pub struct CertId {
116    /// Hash algorithm OID (e.g., SHA-256 = "2.16.840.1.101.3.4.2.1").
117    pub hash_algorithm: String,
118    /// SHA-256 hash of the issuer's distinguished name DER encoding.
119    pub issuer_name_hash: Vec<u8>,
120    /// SHA-256 hash of the issuer's public key BIT STRING value.
121    pub issuer_key_hash: Vec<u8>,
122    /// Certificate serial number.
123    pub serial_number: Vec<u8>,
124}
125
126/// Cached OCSP response with expiry.
127#[derive(Debug, Clone)]
128struct CachedOcspResponse {
129    /// The OCSP status from the response.
130    status: OcspStatus,
131    /// When this cache entry was stored.
132    cached_at: Instant,
133    /// TTL for this entry.
134    ttl: Duration,
135}
136
137impl CachedOcspResponse {
138    fn is_expired(&self) -> bool {
139        self.cached_at.elapsed() > self.ttl
140    }
141}
142
143/// OCSP configuration.
144///
145/// Loaded from the `[ocsp]` section of the Kipuka configuration file.
146#[derive(Debug, Clone, Deserialize)]
147#[serde(default)]
148pub struct OcspConfig {
149    /// Whether OCSP checking is enabled. Default: `false`.
150    pub enabled: bool,
151
152    /// Override OCSP responder URL. When `None`, the AIA extension
153    /// from the certificate is used (RFC 5280 §4.2.2.1).
154    pub responder_url: Option<String>,
155
156    /// Cache TTL in seconds. Default: 300 (5 minutes).
157    pub cache_ttl_secs: u64,
158
159    /// HTTP timeout in seconds for OCSP requests. Default: 10.
160    pub timeout_secs: u64,
161
162    /// Whether to require a nonce in responses (RFC 6960 §4.4.1).
163    /// Default: `true`.
164    pub require_nonce: bool,
165
166    /// Soft-fail mode: if `true`, accept the certificate when the OCSP
167    /// responder is unreachable. If `false`, reject. Default: `false`.
168    pub soft_fail: bool,
169}
170
171impl Default for OcspConfig {
172    fn default() -> Self {
173        Self {
174            enabled: false,
175            responder_url: None,
176            cache_ttl_secs: 300,
177            timeout_secs: 10,
178            require_nonce: true,
179            soft_fail: false,
180        }
181    }
182}
183
184/// OCSP client for checking certificate revocation status.
185///
186/// Thread-safe: all methods take `&self` and the cache uses `DashMap`
187/// for lock-free concurrent access.
188pub struct OcspClient {
189    config: OcspConfig,
190    /// Response cache keyed by CertId.
191    cache: Arc<DashMap<CertId, CachedOcspResponse>>,
192}
193
194impl OcspClient {
195    /// Creates a new OCSP client with the given configuration.
196    pub fn new(config: OcspConfig) -> Self {
197        Self {
198            config,
199            cache: Arc::new(DashMap::new()),
200        }
201    }
202
203    /// Check the revocation status of a certificate.
204    ///
205    /// Per RFC 6960 §4.1, builds an OCSPRequest with a CertID computed
206    /// from the certificate and its issuer, sends it to the responder
207    /// via HTTP POST with Content-Type `application/ocsp-request`, and
208    /// parses the response.
209    ///
210    /// # Arguments
211    ///
212    /// * `cert_der` - DER-encoded certificate to check
213    /// * `issuer_der` - DER-encoded issuer certificate (needed for CertID)
214    ///
215    /// # Errors
216    ///
217    /// Returns `OcspError` if the request fails, the response is invalid,
218    /// or the nonce does not match (when required).
219    pub async fn check_certificate_status(
220        &self,
221        cert_der: &[u8],
222        issuer_der: &[u8],
223    ) -> OcspResult<OcspStatus> {
224        if !self.config.enabled {
225            return Ok(OcspStatus::Good);
226        }
227
228        // Build CertID from certificate and issuer.
229        let cert_id = self.build_cert_id(cert_der, issuer_der)?;
230
231        // Check cache first.
232        if let Some(cached) = self.cache.get(&cert_id)
233            && !cached.is_expired()
234        {
235            debug!("OCSP cache hit for certificate");
236            return Ok(cached.status.clone());
237        }
238
239        // Determine responder URL: config override or AIA extension.
240        let responder_url = self.resolve_responder_url(cert_der)?;
241
242        // Build OCSP request DER.
243        let nonce = if self.config.require_nonce {
244            use rand::Rng;
245            let mut n = vec![0; 32];
246            rand::rng().fill_bytes(&mut n);
247            Some(n)
248        } else {
249            None
250        };
251        let request_der = self.build_ocsp_request_with_nonce(&cert_id, nonce.as_deref())?;
252
253        // Send request via HTTP POST.
254        let response_der = self.send_ocsp_request(&responder_url, &request_der).await?;
255
256        // Parse response and extract status.
257        let mut valid_for = Duration::ZERO;
258        let status = self.parse_ocsp_response(
259            &response_der,
260            &cert_id,
261            issuer_der,
262            nonce.as_deref(),
263            &mut valid_for,
264        )?;
265
266        // Cache the result.
267        self.cache.insert(
268            cert_id,
269            CachedOcspResponse {
270                status: status.clone(),
271                cached_at: Instant::now(),
272                ttl: valid_for.min(Duration::from_secs(self.config.cache_ttl_secs)),
273            },
274        );
275
276        Ok(status)
277    }
278
279    /// Returns a stapled OCSP response for the EST server's own certificate.
280    ///
281    /// OCSP stapling (RFC 6066 §8) allows the server to include a pre-fetched
282    /// OCSP response in the TLS handshake, avoiding the client needing to
283    /// contact the OCSP responder separately.
284    ///
285    /// # Arguments
286    ///
287    /// * `server_cert_der` - DER-encoded server certificate
288    /// * `issuer_der` - DER-encoded issuer certificate
289    ///
290    /// # Returns
291    ///
292    /// The DER-encoded OCSP response suitable for TLS stapling, or an error
293    /// if the response cannot be obtained.
294    pub async fn get_stapled_response(
295        &self,
296        server_cert_der: &[u8],
297        issuer_der: &[u8],
298    ) -> OcspResult<Vec<u8>> {
299        if !self.config.enabled {
300            return Err(OcspError::Transport("OCSP not enabled".to_string()));
301        }
302
303        let cert_id = self.build_cert_id(server_cert_der, issuer_der)?;
304        let responder_url = self.resolve_responder_url(server_cert_der)?;
305        let request_der = self.build_ocsp_request(&cert_id)?;
306
307        let response = self.send_ocsp_request(&responder_url, &request_der).await?;
308        let mut valid_for = Duration::ZERO;
309        self.parse_ocsp_response(&response, &cert_id, issuer_der, None, &mut valid_for)?;
310        Ok(response)
311    }
312
313    /// Evict expired entries from the response cache.
314    pub fn evict_expired(&self) {
315        self.cache.retain(|_, v| !v.is_expired());
316    }
317
318    /// Returns the number of cached responses.
319    pub fn cache_size(&self) -> usize {
320        self.cache.len()
321    }
322
323    // ── Internal helpers ────────────────────────────────────────────────
324
325    /// Build a CertID from a certificate and its issuer.
326    ///
327    /// Per RFC 6960 §4.1.1, the CertID uses SHA-256 hashes of the issuer
328    /// name and key, plus the certificate serial number.
329    fn build_cert_id(&self, cert_der: &[u8], issuer_der: &[u8]) -> OcspResult<CertId> {
330        if cert_der.is_empty() {
331            return Err(OcspError::RequestBuild("empty certificate DER".to_string()));
332        }
333        if issuer_der.is_empty() {
334            return Err(OcspError::RequestBuild(
335                "empty issuer certificate DER".to_string(),
336            ));
337        }
338
339        // Parse the certificate to extract the serial number.
340        let cert = synta_certificate::Certificate::from_der(cert_der)
341            .map_err(|e| OcspError::RequestBuild(format!("certificate parse failed: {e}")))?;
342
343        // Parse the issuer certificate to extract the subject Name and
344        // SubjectPublicKeyInfo for hashing per RFC 6960 §4.1.1.
345        let issuer_cert = synta_certificate::Certificate::from_der(issuer_der).map_err(|e| {
346            OcspError::RequestBuild(format!("issuer certificate parse failed: {e}"))
347        })?;
348
349        // Hash the issuer's distinguished name DER encoding (subject field
350        // of the issuer certificate, which equals the issuer field of the
351        // target certificate).
352        let issuer_name_der = issuer_cert.tbs_certificate.subject.0;
353        let issuer_name_hash = Sha256::digest(issuer_name_der).to_vec();
354
355        // Hash the issuer's public key BIT STRING value (the raw key bytes
356        // without the BIT STRING tag/length/unused-bits prefix).
357        let issuer_spki = &issuer_cert.tbs_certificate.subject_public_key_info;
358        let issuer_key_bytes = issuer_spki.subject_public_key.as_bytes();
359        let issuer_key_hash = Sha256::digest(issuer_key_bytes).to_vec();
360
361        // Extract the certificate serial number as big-endian bytes.
362        let serial_number = cert
363            .tbs_certificate
364            .serial_number
365            .to_der()
366            .map_err(|e| OcspError::RequestBuild(format!("serial encode failed: {e}")))?;
367        // `to_der()` returns the full INTEGER TLV; extract just the value
368        // bytes (skip tag + length).
369        let serial_value = extract_integer_value(&serial_number)
370            .ok_or_else(|| OcspError::RequestBuild("malformed serial INTEGER".into()))?;
371
372        Ok(CertId {
373            hash_algorithm: "2.16.840.1.101.3.4.2.1".to_string(), // SHA-256
374            issuer_name_hash,
375            issuer_key_hash,
376            serial_number: serial_value.to_vec(),
377        })
378    }
379
380    /// Resolve the OCSP responder URL from config or the certificate AIA extension.
381    fn resolve_responder_url(&self, cert_der: &[u8]) -> OcspResult<String> {
382        if let Some(ref url) = self.config.responder_url {
383            return Ok(url.clone());
384        }
385
386        // Extract from Authority Information Access (AIA) extension
387        // (OID 1.3.6.1.5.5.7.1.1) in the certificate.
388        let cert = synta_certificate::Certificate::from_der(cert_der)
389            .map_err(|e| OcspError::RequestBuild(format!("certificate parse for AIA: {e}")))?;
390
391        // Use synta-certificate's find_extension_value to locate the AIA
392        // extension value efficiently (single-pass scan, stops at first match).
393        if let Some(ref exts_raw) = cert.tbs_certificate.extensions
394            && let Some(aia_value) = synta_certificate::find_extension_value(
395                exts_raw.as_bytes(),
396                synta_certificate::oids::AUTHORITY_INFO_ACCESS,
397            )
398            && let Some(url) = extract_ocsp_url_from_aia(aia_value)
399        {
400            debug!(url = %url, "resolved OCSP responder URL from AIA extension");
401            return Ok(url);
402        }
403
404        Err(OcspError::RequestBuild(
405            "no OCSP responder URL configured and no AIA OCSP URI found in certificate".into(),
406        ))
407    }
408
409    /// Build an OCSPRequest DER from a CertID.
410    ///
411    /// Per RFC 6960 §4.1:
412    /// ```text
413    /// OCSPRequest ::= SEQUENCE {
414    ///     tbsRequest      TBSRequest,
415    ///     optionalSignature [0] EXPLICIT Signature OPTIONAL
416    /// }
417    /// TBSRequest ::= SEQUENCE {
418    ///     version           [0] EXPLICIT Version DEFAULT v1,
419    ///     requestorName     [1] EXPLICIT GeneralName OPTIONAL,
420    ///     requestList           SEQUENCE OF Request,
421    ///     requestExtensions [2] EXPLICIT Extensions OPTIONAL
422    /// }
423    /// ```
424    fn build_ocsp_request(&self, cert_id: &CertId) -> OcspResult<Vec<u8>> {
425        // Build the SHA-256 AlgorithmIdentifier DER for the CertID.
426        // AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters NULL }
427        let sha256_alg_der = sha256_algorithm_identifier_der();
428
429        let request_der = synta_certificate::OCSPRequestBuilder::new()
430            .add_request(synta_certificate::CertIDSpec {
431                hash_algorithm_der: &sha256_alg_der,
432                issuer_name_hash: &cert_id.issuer_name_hash,
433                issuer_key_hash: &cert_id.issuer_key_hash,
434                serial: &cert_id.serial_number,
435            })
436            .build_tbs()
437            .map_err(|e| OcspError::RequestBuild(format!("OCSPRequest build: {e}")))?;
438
439        debug!(request_len = request_der.len(), "built OCSP request DER");
440
441        Ok(request_der)
442    }
443
444    fn build_ocsp_request_with_nonce(
445        &self,
446        cert_id: &CertId,
447        nonce: Option<&[u8]>,
448    ) -> OcspResult<Vec<u8>> {
449        let raw = self.build_ocsp_request(cert_id)?;
450        let Some(nonce) = nonce else {
451            return Ok(raw);
452        };
453        let mut request = synta_certificate::ocsp_2024_88_types::OCSPRequest::from_der(&raw)
454            .map_err(|e| OcspError::RequestBuild(e.to_string()))?;
455        let encoded = synta::OctetStringRef::new(nonce)
456            .to_der()
457            .map_err(|e| OcspError::RequestBuild(e.to_string()))?;
458        request.tbs_request.request_extensions = Some(vec![synta_certificate::Extension {
459            extn_id: synta::ObjectIdentifier::new(&[1, 3, 6, 1, 5, 5, 7, 48, 1, 2])
460                .map_err(|e| OcspError::RequestBuild(e.to_string()))?,
461            critical: None,
462            extn_value: synta::OctetStringRef::new(&encoded),
463        }]);
464        request
465            .to_der()
466            .map_err(|e| OcspError::RequestBuild(e.to_string()))
467    }
468
469    /// Send an OCSP request via HTTP POST.
470    ///
471    /// Per RFC 6960 §A.1, the request is sent as:
472    /// - Method: POST
473    /// - Content-Type: application/ocsp-request
474    /// - Accept: application/ocsp-response
475    async fn send_ocsp_request(
476        &self,
477        responder_url: &str,
478        request_der: &[u8],
479    ) -> OcspResult<Vec<u8>> {
480        let timeout = Duration::from_secs(self.config.timeout_secs);
481
482        let client = reqwest::Client::builder()
483            .timeout(timeout)
484            .build()
485            .map_err(|e| OcspError::Transport(format!("HTTP client build: {e}")))?;
486
487        info!(
488            url = %responder_url,
489            request_len = request_der.len(),
490            timeout_secs = self.config.timeout_secs,
491            "sending OCSP request"
492        );
493
494        let mut response = client
495            .post(responder_url)
496            .header("Content-Type", "application/ocsp-request")
497            .header("Accept", "application/ocsp-response")
498            .body(request_der.to_vec())
499            .send()
500            .await
501            .map_err(|e| {
502                if e.is_timeout() {
503                    OcspError::Timeout(self.config.timeout_secs)
504                } else {
505                    OcspError::Transport(format!("HTTP POST failed: {e}"))
506                }
507            })?;
508
509        let status = response.status();
510        if !status.is_success() {
511            return Err(OcspError::Transport(format!(
512                "OCSP responder returned HTTP {status}"
513            )));
514        }
515
516        let mut response_bytes = Vec::new();
517        while let Some(chunk) = response
518            .chunk()
519            .await
520            .map_err(|e| OcspError::Transport(e.to_string()))?
521        {
522            if chunk.len() > 1_048_576usize.saturating_sub(response_bytes.len()) {
523                return Err(OcspError::Transport("OCSP body exceeds 1 MiB".into()));
524            }
525            response_bytes.extend_from_slice(&chunk);
526        }
527
528        debug!(
529            response_len = response_bytes.len(),
530            "received OCSP response"
531        );
532
533        Ok(response_bytes.to_vec())
534    }
535
536    /// Parse an OCSP response DER and extract the certificate status.
537    ///
538    /// Per RFC 6960 §4.2:
539    /// ```text
540    /// OCSPResponse ::= SEQUENCE {
541    ///     responseStatus    OCSPResponseStatus,
542    ///     responseBytes [0] EXPLICIT ResponseBytes OPTIONAL
543    /// }
544    /// ```
545    fn parse_ocsp_response(
546        &self,
547        response_der: &[u8],
548        cert_id: &CertId,
549        issuer_der: &[u8],
550        nonce: Option<&[u8]>,
551        valid_for: &mut Duration,
552    ) -> OcspResult<OcspStatus> {
553        // Parse the outer OCSPResponse envelope.
554        let ocsp_response: synta_certificate::ocsp::OCSPResponse<'_> =
555            Decoder::new(response_der, Encoding::Der)
556                .decode()
557                .map_err(|e| OcspError::Parse(format!("OCSPResponse decode: {e}")))?;
558
559        // Check the response status (successful = 0).
560        match &ocsp_response.response_status {
561            synta_certificate::ocsp::OCSPResponseStatus::Successful => {}
562            status => {
563                return Err(OcspError::ResponseStatus(format!("{status:?}")));
564            }
565        }
566
567        // Extract responseBytes — must be present for a successful response.
568        let response_bytes = ocsp_response
569            .response_bytes
570            .as_ref()
571            .ok_or(OcspError::Parse(
572                "successful OCSPResponse has no responseBytes".into(),
573            ))?;
574
575        // Verify the responseType is id-pkix-ocsp-basic (1.3.6.1.5.5.7.48.1.1).
576        if response_bytes.response_type.components() != synta_certificate::ocsp::ID_PKIX_OCSP_BASIC
577        {
578            return Err(OcspError::Parse(format!(
579                "unexpected responseType: {:?}",
580                response_bytes.response_type.components()
581            )));
582        }
583
584        // Parse the BasicOCSPResponse from the response OCTET STRING.
585        let basic_response: synta_certificate::ocsp::BasicOCSPResponse<'_> =
586            Decoder::new(response_bytes.response.as_bytes(), Encoding::Der)
587                .decode()
588                .map_err(|e| OcspError::Parse(format!("BasicOCSPResponse decode: {e}")))?;
589
590        // Trust is anchored in the actual issuer, never in an arbitrary embedded cert.
591        let invalid = |e: String| OcspError::SignatureVerification(e);
592        let mut candidates = vec![issuer_der];
593        if let Some(certs) = &basic_response.certs {
594            candidates.extend(certs.iter().map(|c| c.as_bytes()));
595        }
596        let tbs = extract_basic_ocsp_tbs_bytes(response_bytes.response.as_bytes())
597            .ok_or_else(|| invalid("invalid response TBS".into()))?;
598        let alg = basic_response
599            .signature_algorithm
600            .to_der()
601            .map_err(|e| invalid(e.to_string()))?;
602        let verifier = synta_certificate::default_signature_verifier();
603        let trusted = candidates.into_iter().any(|der| {
604            let check = || -> Result<(), String> {
605                crate::auth::certificate::valid_now(der)?;
606                let cert =
607                    synta_certificate::Certificate::from_der(der).map_err(|e| e.to_string())?;
608                if der != issuer_der {
609                    if !crate::auth::certificate::issued_by(der, issuer_der) {
610                        return Err("untrusted OCSP signer".into());
611                    }
612                    let extensions = cert
613                        .tbs_certificate
614                        .extensions
615                        .as_ref()
616                        .ok_or("missing OCSP signing EKU")?;
617                    let eku = synta_certificate::find_extension_value(
618                        extensions.as_bytes(),
619                        &[2, 5, 29, 37],
620                    )
621                    .ok_or("missing OCSP signing EKU")?;
622                    let oids: Vec<synta::ObjectIdentifier> = Decoder::new(eku, Encoding::Der)
623                        .decode()
624                        .map_err(|e| e.to_string())?;
625                    if !oids
626                        .iter()
627                        .any(|o| o.components() == [1, 3, 6, 1, 5, 5, 7, 3, 9])
628                    {
629                        return Err("OCSP signing EKU required".into());
630                    }
631                }
632                let matches = match &basic_response.tbs_response_data.responder_id {
633                    synta_certificate::ocsp::ResponderID::ByName(name) => {
634                        name.to_der().map_err(|e| e.to_string())?
635                            == cert.tbs_certificate.subject.as_bytes()
636                    }
637                    synta_certificate::ocsp::ResponderID::ByKey(hash) => {
638                        use synta_certificate::DataHasher;
639                        let hash_bytes = synta_certificate::default_data_hasher()
640                            .hash_data(
641                                "sha1",
642                                cert.tbs_certificate
643                                    .subject_public_key_info
644                                    .subject_public_key
645                                    .as_bytes(),
646                            )
647                            .map_err(|e| e.to_string())?;
648                        hash.as_bytes() == hash_bytes
649                    }
650                };
651                if !matches {
652                    return Err("responder ID mismatch".into());
653                }
654                let ranges = synta_certificate::cert_byte_ranges(der)
655                    .ok_or("invalid responder certificate")?;
656                verifier
657                    .verify_certificate_signature(
658                        tbs,
659                        &alg,
660                        basic_response.signature.as_bytes(),
661                        &der[ranges.subject_public_key_info],
662                    )
663                    .map_err(|e| e.to_string())
664            };
665            check().is_ok()
666        });
667        if !trusted {
668            return Err(invalid("no authorized OCSP signature".into()));
669        }
670        let now = chrono::Utc::now();
671        let convert = |t: &synta::GeneralizedTime| {
672            crate::auth::certificate::time(&synta_certificate::Time::GeneralTime(t.clone()))
673                .map_err(OcspError::Parse)
674        };
675        let produced = convert(&basic_response.tbs_response_data.produced_at)?;
676        if produced > now + chrono::Duration::seconds(300) {
677            return Err(OcspError::Parse("future OCSP producedAt".into()));
678        }
679        if let Some(expected) = nonce {
680            let extensions = basic_response
681                .tbs_response_data
682                .response_extensions
683                .as_ref()
684                .ok_or(OcspError::NonceMismatch)?;
685            let ext = extensions
686                .iter()
687                .find(|e| e.extn_id.components() == [1, 3, 6, 1, 5, 5, 7, 48, 1, 2])
688                .ok_or(OcspError::NonceMismatch)?;
689            let got: synta::OctetStringRef<'_> =
690                Decoder::new(ext.extn_value.as_bytes(), Encoding::Der)
691                    .decode()
692                    .map_err(|_| OcspError::NonceMismatch)?;
693            if got.as_bytes() != expected {
694                return Err(OcspError::NonceMismatch);
695            }
696        }
697
698        // Find the SingleResponse matching our CertID by comparing the
699        // issuer name hash and serial number.
700        for single in &basic_response.tbs_response_data.responses {
701            let resp_name_hash = single.cert_id.issuer_name_hash.as_bytes();
702            let resp_key_hash = single.cert_id.issuer_key_hash.as_bytes();
703
704            if resp_name_hash == cert_id.issuer_name_hash.as_slice()
705                && resp_key_hash == cert_id.issuer_key_hash.as_slice()
706                && single.cert_id.serial_number
707                    == synta::Integer::from_bytes(&cert_id.serial_number)
708                && single.cert_id.hash_algorithm.algorithm.to_string() == cert_id.hash_algorithm
709            {
710                let updated = convert(&single.this_update)?;
711                let expires = if let Some(next) = &single.next_update {
712                    convert(next)?
713                } else {
714                    updated + chrono::Duration::seconds(3600)
715                };
716                if updated > now + chrono::Duration::seconds(300)
717                    || expires <= now
718                    || expires <= updated
719                    || produced < updated - chrono::Duration::seconds(300)
720                {
721                    return Err(OcspError::Parse(
722                        "stale or invalid OCSP update interval".into(),
723                    ));
724                }
725                *valid_for = (expires - now)
726                    .to_std()
727                    .map_err(|e| OcspError::Parse(e.to_string()))?;
728                // Match found — extract the cert status.
729                return match &single.cert_status {
730                    synta_certificate::ocsp::CertStatus::Good(_) => {
731                        debug!("OCSP status: good");
732                        Ok(OcspStatus::Good)
733                    }
734                    synta_certificate::ocsp::CertStatus::Revoked(info) => {
735                        let reason = info
736                            .revocation_reason
737                            .as_ref()
738                            .map(|r| format!("{r:?}"))
739                            .unwrap_or_else(|| "unspecified".into());
740                        let revocation_time = format!(
741                            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
742                            info.revocation_time.year,
743                            info.revocation_time.month,
744                            info.revocation_time.day,
745                            info.revocation_time.hour,
746                            info.revocation_time.minute,
747                            info.revocation_time.second,
748                        );
749                        warn!(
750                            reason = %reason,
751                            revocation_time = %revocation_time,
752                            "OCSP status: revoked"
753                        );
754                        Ok(OcspStatus::Revoked {
755                            reason,
756                            revocation_time,
757                        })
758                    }
759                    synta_certificate::ocsp::CertStatus::Unknown(_) => {
760                        debug!("OCSP status: unknown");
761                        Ok(OcspStatus::Unknown)
762                    }
763                };
764            }
765        }
766
767        Err(OcspError::MissingCertStatus)
768    }
769}
770
771// ── Module-level helpers ───────────────────────────────────────────────────────
772
773/// Extract the TBS ResponseData bytes from a BasicOCSPResponse DER.
774///
775/// The BasicOCSPResponse is (RFC 6960 §4.2.1):
776/// ```text
777/// BasicOCSPResponse ::= SEQUENCE {
778///     tbsResponseData      ResponseData,       -- first field
779///     signatureAlgorithm   AlgorithmIdentifier,
780///     signature            BIT STRING,
781///     certs            [0] EXPLICIT SEQUENCE OF Certificate OPTIONAL
782/// }
783/// ```
784///
785/// This function navigates the outer SEQUENCE tag+length to locate the
786/// first field (tbsResponseData) and returns its complete TLV bytes from
787/// the original DER.  This avoids re-encoding, which can produce different
788/// bytes when the original used BER conventions (non-minimal lengths,
789/// indefinite length encoding, etc.).
790fn extract_basic_ocsp_tbs_bytes(basic_response_der: &[u8]) -> Option<&[u8]> {
791    // The outer structure is a SEQUENCE.
792    if basic_response_der.is_empty() || basic_response_der[0] != 0x30 {
793        return None;
794    }
795
796    // Read the outer SEQUENCE length to find where content starts.
797    let (outer_len, outer_len_bytes) = read_der_length_ocsp(basic_response_der, 1)?;
798    let content_start = 1 + outer_len_bytes;
799    let content_end = content_start + outer_len;
800    if content_end > basic_response_der.len() {
801        return None;
802    }
803
804    // The first field at `content_start` is the tbsResponseData (a SEQUENCE).
805    // Read its tag and length to determine the full TLV extent.
806    let tbs_start = content_start;
807    if tbs_start >= basic_response_der.len() {
808        return None;
809    }
810    let tbs_tag = basic_response_der[tbs_start];
811    // tbsResponseData is a SEQUENCE (tag 0x30).
812    if tbs_tag != 0x30 {
813        return None;
814    }
815
816    let (tbs_value_len, tbs_len_bytes) = read_der_length_ocsp(basic_response_der, tbs_start + 1)?;
817    let tbs_end = tbs_start + 1 + tbs_len_bytes + tbs_value_len;
818    if tbs_end > basic_response_der.len() {
819        return None;
820    }
821
822    Some(&basic_response_der[tbs_start..tbs_end])
823}
824
825/// Read a DER/BER definite-length starting at `bytes[offset]`.
826/// Returns `(value_length, bytes_consumed_for_length)` or `None`.
827fn read_der_length_ocsp(bytes: &[u8], offset: usize) -> Option<(usize, usize)> {
828    let first = *bytes.get(offset)?;
829    if first < 0x80 {
830        Some((first as usize, 1))
831    } else {
832        let n = (first & 0x7f) as usize;
833        if n == 0 || n > 4 || offset + 1 + n > bytes.len() {
834            return None;
835        }
836        let mut val = 0usize;
837        for i in 0..n {
838            val = (val << 8) | bytes[offset + 1 + i] as usize;
839        }
840        Some((val, 1 + n))
841    }
842}
843
844/// SHA-256 AlgorithmIdentifier DER: SEQUENCE { OID 2.16.840.1.101.3.4.2.1, NULL }
845///
846/// Pre-encoded constant avoids runtime construction. The encoding is:
847///   30 0d                 -- SEQUENCE, length 13
848///     06 09               -- OID, length 9
849///       60 86 48 01 65 03 04 02 01  -- 2.16.840.1.101.3.4.2.1
850///     05 00               -- NULL
851fn sha256_algorithm_identifier_der() -> Vec<u8> {
852    vec![
853        0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00,
854    ]
855}
856
857/// Extract the INTEGER value bytes from a DER-encoded INTEGER TLV.
858///
859/// Skips the 0x02 tag and the length octets, returning only the value.
860fn extract_integer_value(der: &[u8]) -> Option<&[u8]> {
861    if der.len() < 2 || der[0] != 0x02 {
862        return None;
863    }
864    let len_byte = der[1];
865    if len_byte < 0x80 {
866        // Short form length.
867        let value_start = 2;
868        let value_len = len_byte as usize;
869        der.get(value_start..value_start + value_len)
870    } else {
871        // Long form length.
872        let num_len_bytes = (len_byte & 0x7f) as usize;
873        if der.len() < 2 + num_len_bytes {
874            return None;
875        }
876        let mut value_len: usize = 0;
877        for &b in &der[2..2 + num_len_bytes] {
878            value_len = (value_len << 8) | b as usize;
879        }
880        let value_start = 2 + num_len_bytes;
881        der.get(value_start..value_start + value_len)
882    }
883}
884
885/// Extract an OCSP responder URL from an AIA extension value.
886///
887/// Parses the AuthorityInfoAccessSyntax SEQUENCE OF AccessDescription,
888/// looking for the id-ad-ocsp (1.3.6.1.5.5.7.48.1) access method.
889fn extract_ocsp_url_from_aia(aia_value: &[u8]) -> Option<String> {
890    // AuthorityInfoAccessSyntax ::= SEQUENCE SIZE (1..MAX) OF AccessDescription
891    // AccessDescription ::= SEQUENCE {
892    //     accessMethod    OBJECT IDENTIFIER,
893    //     accessLocation  GeneralName
894    // }
895    // We look for accessMethod = id-ad-ocsp (1.3.6.1.5.5.7.48.1)
896    // and accessLocation = uniformResourceIdentifier [6] IA5String.
897    let mut decoder = Decoder::new(aia_value, Encoding::Der);
898    let seq_tag = synta::Tag::universal_constructed(synta::tag::TAG_SEQUENCE);
899
900    let mut outer = decoder.enter_constructed(seq_tag).ok()?;
901    while !outer.is_empty() {
902        let mut access_desc = outer.enter_constructed(seq_tag).ok()?;
903
904        let method: synta::ObjectIdentifier = access_desc.decode().ok()?;
905        if method.components() == synta_certificate::oids::AD_OCSP {
906            // accessLocation is a GeneralName CHOICE; uniformResourceIdentifier
907            // is [6] IMPLICIT IA5String.
908            let tag = access_desc.peek_tag().ok()?;
909            if tag.number() == 6 {
910                let raw: synta::RawDer<'_> = access_desc.decode().ok()?;
911                // The RawDer includes the [6] tag and length; extract the IA5String value.
912                let raw_bytes = raw.as_bytes();
913                let url_bytes = extract_context_tagged_value(raw_bytes)?;
914                return String::from_utf8(url_bytes.to_vec()).ok();
915            }
916        }
917        // Skip remaining fields if we didn't match.
918    }
919    None
920}
921
922/// Extract the value bytes from a context-specific tagged TLV.
923fn extract_context_tagged_value(tlv: &[u8]) -> Option<&[u8]> {
924    if tlv.len() < 2 {
925        return None;
926    }
927    let len_byte = tlv[1];
928    if len_byte < 0x80 {
929        Some(&tlv[2..2 + len_byte as usize])
930    } else {
931        let num = (len_byte & 0x7f) as usize;
932        if tlv.len() < 2 + num {
933            return None;
934        }
935        let mut vlen: usize = 0;
936        for &b in &tlv[2..2 + num] {
937            vlen = (vlen << 8) | b as usize;
938        }
939        let start = 2 + num;
940        tlv.get(start..start + vlen)
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    #[test]
949    fn test_ocsp_config_defaults() {
950        let config = OcspConfig::default();
951        assert!(!config.enabled);
952        assert!(config.responder_url.is_none());
953        assert_eq!(config.cache_ttl_secs, 300);
954        assert_eq!(config.timeout_secs, 10);
955        assert!(config.require_nonce);
956        assert!(!config.soft_fail);
957    }
958
959    #[test]
960    fn test_ocsp_status_variants() {
961        let good = OcspStatus::Good;
962        assert_eq!(good, OcspStatus::Good);
963
964        let revoked = OcspStatus::Revoked {
965            reason: "keyCompromise".to_string(),
966            revocation_time: "2026-01-15T10:00:00Z".to_string(),
967        };
968        assert!(matches!(revoked, OcspStatus::Revoked { .. }));
969
970        let unknown = OcspStatus::Unknown;
971        assert_eq!(unknown, OcspStatus::Unknown);
972    }
973
974    #[test]
975    fn test_cert_id_equality() {
976        let id1 = CertId {
977            hash_algorithm: "2.16.840.1.101.3.4.2.1".to_string(),
978            issuer_name_hash: vec![0x01, 0x02],
979            issuer_key_hash: vec![0x03, 0x04],
980            serial_number: vec![0x05],
981        };
982        let id2 = id1.clone();
983        assert_eq!(id1, id2);
984    }
985
986    #[tokio::test]
987    async fn test_ocsp_disabled_returns_good() {
988        let config = OcspConfig::default(); // enabled = false
989        let client = OcspClient::new(config);
990        let status = client
991            .check_certificate_status(&[0x30, 0x00], &[0x30, 0x00])
992            .await
993            .unwrap();
994        assert_eq!(status, OcspStatus::Good);
995    }
996
997    #[test]
998    fn test_build_cert_id_empty_cert() {
999        let config = OcspConfig::default();
1000        let client = OcspClient::new(config);
1001        let result = client.build_cert_id(&[], &[0x30, 0x00]);
1002        assert!(matches!(result, Err(OcspError::RequestBuild(_))));
1003    }
1004
1005    #[test]
1006    fn test_build_cert_id_empty_issuer() {
1007        let config = OcspConfig::default();
1008        let client = OcspClient::new(config);
1009        let result = client.build_cert_id(&[0x30, 0x00], &[]);
1010        assert!(matches!(result, Err(OcspError::RequestBuild(_))));
1011    }
1012
1013    #[test]
1014    fn test_resolve_responder_url_from_config() {
1015        let config = OcspConfig {
1016            responder_url: Some("http://ocsp.example.com".to_string()),
1017            ..Default::default()
1018        };
1019        let client = OcspClient::new(config);
1020        let url = client.resolve_responder_url(&[0x30, 0x00]).unwrap();
1021        assert_eq!(url, "http://ocsp.example.com");
1022    }
1023
1024    #[test]
1025    fn test_resolve_responder_url_no_config() {
1026        let config = OcspConfig::default();
1027        let client = OcspClient::new(config);
1028        let result = client.resolve_responder_url(&[0x30, 0x00]);
1029        assert!(matches!(result, Err(OcspError::RequestBuild(_))));
1030    }
1031
1032    #[test]
1033    fn test_cache_operations() {
1034        let config = OcspConfig::default();
1035        let client = OcspClient::new(config);
1036        assert_eq!(client.cache_size(), 0);
1037
1038        // Manually insert a cache entry
1039        let cert_id = CertId {
1040            hash_algorithm: "2.16.840.1.101.3.4.2.1".to_string(),
1041            issuer_name_hash: vec![0x01],
1042            issuer_key_hash: vec![0x02],
1043            serial_number: vec![0x03],
1044        };
1045        client.cache.insert(
1046            cert_id,
1047            CachedOcspResponse {
1048                status: OcspStatus::Good,
1049                cached_at: Instant::now(),
1050                ttl: Duration::from_secs(300),
1051            },
1052        );
1053        assert_eq!(client.cache_size(), 1);
1054
1055        client.evict_expired();
1056        assert_eq!(client.cache_size(), 1); // not expired yet
1057    }
1058}
1059
1060#[cfg(test)]
1061#[path = "regression_tests.rs"]
1062mod regression_tests;