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}