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 thiserror::Error;
36use tracing::{debug, warn};
37
38/// OCSP-specific errors.
39#[derive(Debug, Error)]
40pub enum OcspError {
41    /// Failed to build the OCSP request.
42    #[error("OCSP request build error: {0}")]
43    RequestBuild(String),
44
45    /// HTTP transport error when contacting the responder.
46    #[error("OCSP transport error: {0}")]
47    Transport(String),
48
49    /// The responder returned a non-successful OCSP response status
50    /// (RFC 6960 §4.2.1: malformedRequest, internalError, tryLater, etc.).
51    #[error("OCSP response status: {0}")]
52    ResponseStatus(String),
53
54    /// Signature verification of the OCSP response failed.
55    #[error("OCSP response signature verification failed: {0}")]
56    SignatureVerification(String),
57
58    /// The response did not contain a status for the queried certificate.
59    #[error("OCSP response missing status for queried certificate")]
60    MissingCertStatus,
61
62    /// Nonce mismatch between request and response.
63    #[error("OCSP nonce mismatch: replay attack possible")]
64    NonceMismatch,
65
66    /// Response parsing error.
67    #[error("OCSP response parse error: {0}")]
68    Parse(String),
69
70    /// Timeout contacting the OCSP responder.
71    #[error("OCSP responder timeout after {0}s")]
72    Timeout(u64),
73}
74
75/// Result type for OCSP operations.
76pub type OcspResult<T> = Result<T, OcspError>;
77
78/// Certificate revocation status per RFC 6960 §4.2.1.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum OcspStatus {
81    /// The certificate is not revoked (RFC 6960 §4.2.1, CertStatus `good`).
82    Good,
83
84    /// The certificate has been revoked (RFC 6960 §4.2.1, CertStatus `revoked`).
85    ///
86    /// Includes the CRL reason code (RFC 5280 §5.3.1) and revocation time
87    /// as an ISO 8601 string.
88    Revoked {
89        /// CRL reason code per RFC 5280 §5.3.1 (e.g., "keyCompromise").
90        reason: String,
91        /// Revocation time as ISO 8601 string.
92        revocation_time: String,
93    },
94
95    /// The responder does not know the certificate (RFC 6960 §4.2.1,
96    /// CertStatus `unknown`).
97    Unknown,
98}
99
100/// Identifier for a certificate in an OCSP request.
101///
102/// Per RFC 6960 §4.1.1:
103/// ```text
104/// CertID ::= SEQUENCE {
105///     hashAlgorithm       AlgorithmIdentifier,
106///     issuerNameHash      OCTET STRING,
107///     issuerKeyHash       OCTET STRING,
108///     serialNumber        CertificateSerialNumber
109/// }
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq, Hash)]
112pub struct CertId {
113    /// Hash algorithm OID (e.g., SHA-256 = "2.16.840.1.101.3.4.2.1").
114    pub hash_algorithm: String,
115    /// SHA-256 hash of the issuer's distinguished name DER encoding.
116    pub issuer_name_hash: Vec<u8>,
117    /// SHA-256 hash of the issuer's public key BIT STRING value.
118    pub issuer_key_hash: Vec<u8>,
119    /// Certificate serial number.
120    pub serial_number: Vec<u8>,
121}
122
123/// Cached OCSP response with expiry.
124#[derive(Debug, Clone)]
125struct CachedOcspResponse {
126    /// The OCSP status from the response.
127    status: OcspStatus,
128    /// When this cache entry was stored.
129    cached_at: Instant,
130    /// TTL for this entry.
131    ttl: Duration,
132}
133
134impl CachedOcspResponse {
135    fn is_expired(&self) -> bool {
136        self.cached_at.elapsed() > self.ttl
137    }
138}
139
140/// OCSP configuration.
141///
142/// Loaded from the `[ocsp]` section of the Kipuka configuration file.
143#[derive(Debug, Clone, Deserialize)]
144#[serde(default)]
145pub struct OcspConfig {
146    /// Whether OCSP checking is enabled. Default: `false`.
147    pub enabled: bool,
148
149    /// Override OCSP responder URL. When `None`, the AIA extension
150    /// from the certificate is used (RFC 5280 §4.2.2.1).
151    pub responder_url: Option<String>,
152
153    /// Cache TTL in seconds. Default: 300 (5 minutes).
154    pub cache_ttl_secs: u64,
155
156    /// HTTP timeout in seconds for OCSP requests. Default: 10.
157    pub timeout_secs: u64,
158
159    /// Whether to require a nonce in responses (RFC 6960 §4.4.1).
160    /// Default: `true`.
161    pub require_nonce: bool,
162
163    /// Soft-fail mode: if `true`, accept the certificate when the OCSP
164    /// responder is unreachable. If `false`, reject. Default: `false`.
165    pub soft_fail: bool,
166}
167
168impl Default for OcspConfig {
169    fn default() -> Self {
170        Self {
171            enabled: false,
172            responder_url: None,
173            cache_ttl_secs: 300,
174            timeout_secs: 10,
175            require_nonce: true,
176            soft_fail: false,
177        }
178    }
179}
180
181/// OCSP client for checking certificate revocation status.
182///
183/// Thread-safe: all methods take `&self` and the cache uses `DashMap`
184/// for lock-free concurrent access.
185pub struct OcspClient {
186    config: OcspConfig,
187    /// Response cache keyed by CertId.
188    cache: Arc<DashMap<CertId, CachedOcspResponse>>,
189}
190
191impl OcspClient {
192    /// Creates a new OCSP client with the given configuration.
193    pub fn new(config: OcspConfig) -> Self {
194        Self {
195            config,
196            cache: Arc::new(DashMap::new()),
197        }
198    }
199
200    /// Check the revocation status of a certificate.
201    ///
202    /// Per RFC 6960 §4.1, builds an OCSPRequest with a CertID computed
203    /// from the certificate and its issuer, sends it to the responder
204    /// via HTTP POST with Content-Type `application/ocsp-request`, and
205    /// parses the response.
206    ///
207    /// # Arguments
208    ///
209    /// * `cert_der` - DER-encoded certificate to check
210    /// * `issuer_der` - DER-encoded issuer certificate (needed for CertID)
211    ///
212    /// # Errors
213    ///
214    /// Returns `OcspError` if the request fails, the response is invalid,
215    /// or the nonce does not match (when required).
216    pub async fn check_certificate_status(
217        &self,
218        cert_der: &[u8],
219        issuer_der: &[u8],
220    ) -> OcspResult<OcspStatus> {
221        if !self.config.enabled {
222            return Ok(OcspStatus::Good);
223        }
224
225        // Build CertID from certificate and issuer.
226        let cert_id = self.build_cert_id(cert_der, issuer_der)?;
227
228        // Check cache first.
229        if let Some(cached) = self.cache.get(&cert_id)
230            && !cached.is_expired()
231        {
232            debug!("OCSP cache hit for certificate");
233            return Ok(cached.status.clone());
234        }
235
236        // Determine responder URL: config override or AIA extension.
237        let responder_url = self.resolve_responder_url(cert_der)?;
238
239        // Build OCSP request DER.
240        let request_der = self.build_ocsp_request(&cert_id)?;
241
242        // Send request via HTTP POST.
243        let response_der = self.send_ocsp_request(&responder_url, &request_der).await?;
244
245        // Parse response and extract status.
246        let status = self.parse_ocsp_response(&response_der, &cert_id)?;
247
248        // Cache the result.
249        self.cache.insert(
250            cert_id,
251            CachedOcspResponse {
252                status: status.clone(),
253                cached_at: Instant::now(),
254                ttl: Duration::from_secs(self.config.cache_ttl_secs),
255            },
256        );
257
258        Ok(status)
259    }
260
261    /// Returns a stapled OCSP response for the EST server's own certificate.
262    ///
263    /// OCSP stapling (RFC 6066 §8) allows the server to include a pre-fetched
264    /// OCSP response in the TLS handshake, avoiding the client needing to
265    /// contact the OCSP responder separately.
266    ///
267    /// # Arguments
268    ///
269    /// * `server_cert_der` - DER-encoded server certificate
270    /// * `issuer_der` - DER-encoded issuer certificate
271    ///
272    /// # Returns
273    ///
274    /// The DER-encoded OCSP response suitable for TLS stapling, or an error
275    /// if the response cannot be obtained.
276    pub async fn get_stapled_response(
277        &self,
278        server_cert_der: &[u8],
279        issuer_der: &[u8],
280    ) -> OcspResult<Vec<u8>> {
281        if !self.config.enabled {
282            return Err(OcspError::Transport("OCSP not enabled".to_string()));
283        }
284
285        let cert_id = self.build_cert_id(server_cert_der, issuer_der)?;
286        let responder_url = self.resolve_responder_url(server_cert_der)?;
287        let request_der = self.build_ocsp_request(&cert_id)?;
288
289        self.send_ocsp_request(&responder_url, &request_der).await
290    }
291
292    /// Evict expired entries from the response cache.
293    pub fn evict_expired(&self) {
294        self.cache.retain(|_, v| !v.is_expired());
295    }
296
297    /// Returns the number of cached responses.
298    pub fn cache_size(&self) -> usize {
299        self.cache.len()
300    }
301
302    // ── Internal helpers ────────────────────────────────────────────────
303
304    /// Build a CertID from a certificate and its issuer.
305    ///
306    /// Per RFC 6960 §4.1.1, the CertID uses SHA-256 hashes of the issuer
307    /// name and key, plus the certificate serial number.
308    fn build_cert_id(&self, cert_der: &[u8], issuer_der: &[u8]) -> OcspResult<CertId> {
309        use sha2::{Digest, Sha256};
310
311        if cert_der.is_empty() {
312            return Err(OcspError::RequestBuild("empty certificate DER".to_string()));
313        }
314        if issuer_der.is_empty() {
315            return Err(OcspError::RequestBuild(
316                "empty issuer certificate DER".to_string(),
317            ));
318        }
319
320        // Placeholder hashes — real implementation extracts the issuer Name
321        // and public key BIT STRING from the DER-encoded certificates using
322        // the synta_certificate parser, then hashes those specific fields.
323        let issuer_name_hash = Sha256::digest(issuer_der).to_vec();
324        let issuer_key_hash = Sha256::digest(issuer_der).to_vec();
325
326        // Placeholder serial — real implementation extracts from TBSCertificate.
327        let serial_number = cert_der.get(..8).unwrap_or(cert_der).to_vec();
328
329        Ok(CertId {
330            hash_algorithm: "2.16.840.1.101.3.4.2.1".to_string(), // SHA-256
331            issuer_name_hash,
332            issuer_key_hash,
333            serial_number,
334        })
335    }
336
337    /// Resolve the OCSP responder URL from config or the certificate AIA extension.
338    fn resolve_responder_url(&self, _cert_der: &[u8]) -> OcspResult<String> {
339        if let Some(ref url) = self.config.responder_url {
340            return Ok(url.clone());
341        }
342        // TODO: Extract from Authority Information Access (AIA) extension
343        // (OID 1.3.6.1.5.5.7.1.1) in the certificate. The id-ad-ocsp
344        // access method (OID 1.3.6.1.5.5.7.48.1) provides the URL.
345        Err(OcspError::RequestBuild(
346            "no OCSP responder URL configured and AIA extraction not yet implemented".to_string(),
347        ))
348    }
349
350    /// Build an OCSPRequest DER from a CertID.
351    ///
352    /// Per RFC 6960 §4.1:
353    /// ```text
354    /// OCSPRequest ::= SEQUENCE {
355    ///     tbsRequest      TBSRequest,
356    ///     optionalSignature [0] EXPLICIT Signature OPTIONAL
357    /// }
358    /// TBSRequest ::= SEQUENCE {
359    ///     version           [0] EXPLICIT Version DEFAULT v1,
360    ///     requestorName     [1] EXPLICIT GeneralName OPTIONAL,
361    ///     requestList           SEQUENCE OF Request,
362    ///     requestExtensions [2] EXPLICIT Extensions OPTIONAL
363    /// }
364    /// ```
365    fn build_ocsp_request(&self, _cert_id: &CertId) -> OcspResult<Vec<u8>> {
366        // Placeholder — real implementation constructs the ASN.1 DER
367        // using the synta crate. The request includes:
368        // 1. A single Request with the CertID
369        // 2. An optional nonce extension (OID 1.3.6.1.5.5.7.48.1.2)
370        //    when require_nonce is true
371        Ok(vec![0x30, 0x00]) // minimal SEQUENCE placeholder
372    }
373
374    /// Send an OCSP request via HTTP POST.
375    ///
376    /// Per RFC 6960 §A.1, the request is sent as:
377    /// - Method: POST
378    /// - Content-Type: application/ocsp-request
379    /// - Accept: application/ocsp-response
380    async fn send_ocsp_request(
381        &self,
382        _responder_url: &str,
383        _request_der: &[u8],
384    ) -> OcspResult<Vec<u8>> {
385        // Placeholder — real implementation uses reqwest or hyper to POST
386        // the DER-encoded OCSP request to the responder URL.
387        //
388        // The timeout is set from self.config.timeout_secs.
389        warn!("OCSP HTTP transport not yet implemented");
390        Err(OcspError::Transport(
391            "OCSP HTTP transport not yet implemented".to_string(),
392        ))
393    }
394
395    /// Parse an OCSP response DER and extract the certificate status.
396    ///
397    /// Per RFC 6960 §4.2:
398    /// ```text
399    /// OCSPResponse ::= SEQUENCE {
400    ///     responseStatus    OCSPResponseStatus,
401    ///     responseBytes [0] EXPLICIT ResponseBytes OPTIONAL
402    /// }
403    /// ```
404    fn parse_ocsp_response(
405        &self,
406        _response_der: &[u8],
407        _cert_id: &CertId,
408    ) -> OcspResult<OcspStatus> {
409        // Placeholder — real implementation:
410        // 1. Parse OCSPResponseStatus (successful=0, malformedRequest=1, etc.)
411        // 2. Parse BasicOCSPResponse from responseBytes
412        // 3. Verify responder signature
413        // 4. Check nonce if require_nonce is true
414        // 5. Find the SingleResponse matching our CertID
415        // 6. Extract certStatus (good/revoked/unknown)
416        Err(OcspError::Parse(
417            "OCSP response parsing not yet implemented".to_string(),
418        ))
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_ocsp_config_defaults() {
428        let config = OcspConfig::default();
429        assert!(!config.enabled);
430        assert!(config.responder_url.is_none());
431        assert_eq!(config.cache_ttl_secs, 300);
432        assert_eq!(config.timeout_secs, 10);
433        assert!(config.require_nonce);
434        assert!(!config.soft_fail);
435    }
436
437    #[test]
438    fn test_ocsp_status_variants() {
439        let good = OcspStatus::Good;
440        assert_eq!(good, OcspStatus::Good);
441
442        let revoked = OcspStatus::Revoked {
443            reason: "keyCompromise".to_string(),
444            revocation_time: "2026-01-15T10:00:00Z".to_string(),
445        };
446        assert!(matches!(revoked, OcspStatus::Revoked { .. }));
447
448        let unknown = OcspStatus::Unknown;
449        assert_eq!(unknown, OcspStatus::Unknown);
450    }
451
452    #[test]
453    fn test_cert_id_equality() {
454        let id1 = CertId {
455            hash_algorithm: "2.16.840.1.101.3.4.2.1".to_string(),
456            issuer_name_hash: vec![0x01, 0x02],
457            issuer_key_hash: vec![0x03, 0x04],
458            serial_number: vec![0x05],
459        };
460        let id2 = id1.clone();
461        assert_eq!(id1, id2);
462    }
463
464    #[tokio::test]
465    async fn test_ocsp_disabled_returns_good() {
466        let config = OcspConfig::default(); // enabled = false
467        let client = OcspClient::new(config);
468        let status = client
469            .check_certificate_status(&[0x30, 0x00], &[0x30, 0x00])
470            .await
471            .unwrap();
472        assert_eq!(status, OcspStatus::Good);
473    }
474
475    #[test]
476    fn test_build_cert_id_empty_cert() {
477        let config = OcspConfig::default();
478        let client = OcspClient::new(config);
479        let result = client.build_cert_id(&[], &[0x30, 0x00]);
480        assert!(matches!(result, Err(OcspError::RequestBuild(_))));
481    }
482
483    #[test]
484    fn test_build_cert_id_empty_issuer() {
485        let config = OcspConfig::default();
486        let client = OcspClient::new(config);
487        let result = client.build_cert_id(&[0x30, 0x00], &[]);
488        assert!(matches!(result, Err(OcspError::RequestBuild(_))));
489    }
490
491    #[test]
492    fn test_resolve_responder_url_from_config() {
493        let config = OcspConfig {
494            responder_url: Some("http://ocsp.example.com".to_string()),
495            ..Default::default()
496        };
497        let client = OcspClient::new(config);
498        let url = client.resolve_responder_url(&[0x30, 0x00]).unwrap();
499        assert_eq!(url, "http://ocsp.example.com");
500    }
501
502    #[test]
503    fn test_resolve_responder_url_no_config() {
504        let config = OcspConfig::default();
505        let client = OcspClient::new(config);
506        let result = client.resolve_responder_url(&[0x30, 0x00]);
507        assert!(matches!(result, Err(OcspError::RequestBuild(_))));
508    }
509
510    #[test]
511    fn test_cache_operations() {
512        let config = OcspConfig::default();
513        let client = OcspClient::new(config);
514        assert_eq!(client.cache_size(), 0);
515
516        // Manually insert a cache entry
517        let cert_id = CertId {
518            hash_algorithm: "2.16.840.1.101.3.4.2.1".to_string(),
519            issuer_name_hash: vec![0x01],
520            issuer_key_hash: vec![0x02],
521            serial_number: vec![0x03],
522        };
523        client.cache.insert(
524            cert_id,
525            CachedOcspResponse {
526                status: OcspStatus::Good,
527                cached_at: Instant::now(),
528                ttl: Duration::from_secs(300),
529            },
530        );
531        assert_eq!(client.cache_size(), 1);
532
533        client.evict_expired();
534        assert_eq!(client.cache_size(), 1); // not expired yet
535    }
536}