Skip to main content

ocsp_staple/
lib.rs

1//! Build OCSP requests, parse OCSP responses, and extract the OCSP
2//! responder URL from a certificate's Authority Information Access
3//! (AIA) extension. With the `fetch` feature, also performs an async
4//! HTTP/1.1 POST against the responder via hyper.
5//!
6//! ## Transport policy: HTTP-only
7//!
8//! Production CAs (Let's Encrypt, `DigiCert`, Sectigo, Entrust,
9//! `GlobalSign`) all ship HTTP-only OCSP responders, and OCSP responses
10//! are independently signed (the transport adds nothing the response
11//! signature doesn't already provide). This crate enforces HTTP-only:
12//! HTTPS responder URLs surface as [`OcspError::HttpsNotSupported`]
13//! at extract / fetch time; the caller can deliver such responses
14//! through other channels (e.g. a pre-fetched DER blob on disk).
15//!
16//! ## API shape
17//!
18//! Three layers:
19//!
20//! - Pure functions on cert DER (always compiled): [`extract_ocsp_url`],
21//!   [`build_ocsp_request`], [`parse_ocsp_response`]. No IO; unit-
22//!   testable in isolation.
23//! - One async transport function (`fetch` feature): [`fetch_ocsp`].
24//!   Wraps a hyper HTTP/1.1 conn behind a single timeout.
25//! - Convenience (`fetch` feature): [`fetch_ocsp_for_cert`] runs the
26//!   whole pipeline (extract → build → fetch → parse) given the leaf
27//!   + issuer DER.
28
29#[cfg(feature = "fetch")]
30use std::time::Duration;
31use std::time::SystemTime;
32
33use der::{Decode, Encode};
34use sha1::Sha1;
35use x509_ocsp::builder::OcspRequestBuilder;
36use x509_ocsp::{BasicOcspResponse, OcspResponse, OcspResponseStatus, Request as OcspReq};
37
38/// PKIX `id-ad-ocsp` OID per RFC 5280 §4.2.2.1. The `AccessDescription`
39/// in an AIA extension whose `accessMethod` matches this OID carries
40/// the OCSP responder URL in its `accessLocation` `GeneralName::URI`
41/// field.
42const ID_AD_OCSP: &str = "1.3.6.1.5.5.7.48.1";
43
44/// Error surface for the OCSP pipeline. Categorised so callers can
45/// branch on transport / parse / responder failures without
46/// string-matching.
47#[derive(Debug, thiserror::Error)]
48pub enum OcspError {
49	#[error("certificate has no Authority Information Access extension")]
50	NoAia,
51	#[error("AIA extension has no OCSP responder URL")]
52	NoOcspUrl,
53	#[error(
54		"OCSP responder URL uses HTTPS, which is not supported by this crate \
55		 (deliver pre-fetched OCSP responses through another channel): {0}"
56	)]
57	HttpsNotSupported(String),
58	#[error("invalid OCSP responder URL: {0}")]
59	InvalidUrl(String),
60	#[error("certificate parse failed: {0}")]
61	CertParse(String),
62	#[error("OCSP request build failed: {0}")]
63	RequestBuild(String),
64	#[error("OCSP responder returned HTTP {status}")]
65	HttpStatus { status: u16 },
66	#[error("OCSP responder unreachable: {0}")]
67	Transport(String),
68	#[error("OCSP response parse failed: {0}")]
69	ResponseParse(String),
70	#[error("OCSP responder returned non-successful status: {0}")]
71	ResponderError(String),
72	#[error("OCSP response body exceeds {cap} bytes")]
73	BodyTooLarge { cap: usize },
74}
75
76/// Parsed OCSP response result. `staple` is the full DER `OCSPResponse`
77/// suitable for handing to rustls via `CertifiedKey.ocsp`.
78/// `next_update` is the responder's `nextUpdate` (or `producedAt + 7d`
79/// when omitted — RFC 6960 §4.2.2.1 allows `nextUpdate` to be absent
80/// for "indefinite" responses; we still need a wall-clock deadline so
81/// a renewal scheduler can plan a refresh).
82#[derive(Debug, Clone)]
83pub struct OcspStaple {
84	pub staple: Vec<u8>,
85	pub next_update: SystemTime,
86}
87
88/// `producedAt + 7d` fallback when the responder omits `nextUpdate`.
89/// Picked to match the typical Let's Encrypt / industry validity
90/// window so omitted-`nextUpdate` responders blend with the rest.
91const DEFAULT_NEXT_UPDATE_AHEAD: std::time::Duration = std::time::Duration::from_hours(168);
92
93/// Total budget for a single OCSP fetch (DNS + connect + send + recv).
94/// 10 seconds covers any reasonable CA OCSP responder; if it doesn't
95/// answer in 10 seconds, callers typically ship the cert without a
96/// staple and the scheduler retries on the next tick.
97#[cfg(feature = "fetch")]
98pub const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
99
100/// Extract the OCSP responder URL from a cert's AIA extension.
101///
102/// # Errors
103///
104/// - [`OcspError::NoAia`] when the cert has no AIA extension.
105/// - [`OcspError::NoOcspUrl`] when the AIA extension has no
106///   `id-ad-ocsp` access descriptor (some CAs include only
107///   `caIssuers`).
108/// - [`OcspError::HttpsNotSupported`] when the URL is HTTPS — see
109///   the module-level transport policy paragraph.
110/// - [`OcspError::InvalidUrl`] for any other scheme (`ftp://`, …) or
111///   a URL that doesn't parse.
112/// - [`OcspError::CertParse`] when the cert DER is malformed.
113pub fn extract_ocsp_url(cert_der: &[u8]) -> Result<String, OcspError> {
114	use x509_parser::extensions::{GeneralName, ParsedExtension};
115	use x509_parser::prelude::FromDer;
116
117	let (_, cert) = x509_parser::prelude::X509Certificate::from_der(cert_der)
118		.map_err(|e| OcspError::CertParse(format!("{e}")))?;
119
120	let mut saw_aia = false;
121	for ext in cert.tbs_certificate.extensions() {
122		if let ParsedExtension::AuthorityInfoAccess(aia) = ext.parsed_extension() {
123			saw_aia = true;
124			for desc in &aia.accessdescs {
125				if desc.access_method.to_id_string() == ID_AD_OCSP
126					&& let GeneralName::URI(url) = &desc.access_location
127				{
128					return classify_url(url);
129				}
130			}
131		}
132	}
133	if saw_aia { Err(OcspError::NoOcspUrl) } else { Err(OcspError::NoAia) }
134}
135
136/// Reject HTTPS / non-HTTP URLs at this layer so the rest of the
137/// pipeline can assume the URL is a vanilla `http://` URL.
138fn classify_url(url: &str) -> Result<String, OcspError> {
139	if url.starts_with("https://") {
140		Err(OcspError::HttpsNotSupported(url.to_owned()))
141	} else if url.starts_with("http://") {
142		Ok(url.to_owned())
143	} else {
144		Err(OcspError::InvalidUrl(format!("expected `http://` scheme, got: {url}")))
145	}
146}
147
148/// Build an `OCSPRequest` DER for `cert_der` signed by `issuer_der` per
149/// RFC 6960 §4.1.1. Cert ID hash is SHA-1 — RFC-mandated, not
150/// security-critical (the hash is a routing identifier).
151///
152/// # Errors
153///
154/// [`OcspError::CertParse`] when either DER fails to decode;
155/// [`OcspError::RequestBuild`] when the x509-ocsp builder rejects the
156/// inputs (e.g. issuer cert lacks a usable subject / key).
157pub fn build_ocsp_request(cert_der: &[u8], issuer_der: &[u8]) -> Result<Vec<u8>, OcspError> {
158	use x509_cert::Certificate;
159	let cert = Certificate::from_der(cert_der).map_err(|e| OcspError::CertParse(format!("{e}")))?;
160	let issuer =
161		Certificate::from_der(issuer_der).map_err(|e| OcspError::CertParse(format!("{e}")))?;
162	let req = OcspRequestBuilder::default()
163		.with_request(
164			OcspReq::from_cert::<Sha1>(&issuer, &cert)
165				.map_err(|e| OcspError::RequestBuild(format!("{e}")))?,
166		)
167		.build();
168	req.to_der().map_err(|e| OcspError::RequestBuild(format!("DER encode: {e}")))
169}
170
171/// Parse an `OCSPResponse` DER into a [`OcspStaple`]. The original
172/// bytes are returned verbatim as the `staple` (rustls ships them on
173/// the wire without re-encoding).
174///
175/// # Errors
176///
177/// - [`OcspError::ResponseParse`] for malformed DER, missing
178///   `responseBytes`, or no `SingleResponse` entries.
179/// - [`OcspError::ResponderError`] when `responseStatus` is not
180///   `successful`.
181pub fn parse_ocsp_response(resp_der: &[u8]) -> Result<OcspStaple, OcspError> {
182	let resp = OcspResponse::from_der(resp_der)
183		.map_err(|e| OcspError::ResponseParse(format!("OcspResponse decode: {e}")))?;
184
185	if resp.response_status != OcspResponseStatus::Successful {
186		return Err(OcspError::ResponderError(format!("{:?}", resp.response_status)));
187	}
188
189	let response_bytes = resp
190		.response_bytes
191		.as_ref()
192		.ok_or_else(|| OcspError::ResponseParse("successful response has no responseBytes".into()))?;
193	let basic = BasicOcspResponse::from_der(response_bytes.response.as_bytes())
194		.map_err(|e| OcspError::ResponseParse(format!("BasicOcspResponse decode: {e}")))?;
195
196	let single = basic
197		.tbs_response_data
198		.responses
199		.first()
200		.ok_or_else(|| OcspError::ResponseParse("no SingleResponse entries".into()))?;
201
202	let next_update = match &single.next_update {
203		Some(t) => generalized_time_to_system(t),
204		None => {
205			// RFC 6960 §4.2.2.1 allows `nextUpdate` to be absent ("the
206			// responder always has up-to-date information"). We still
207			// need a wall-clock deadline; fall back to
208			// `producedAt + 7d` to match typical CA validity windows.
209			generalized_time_to_system(&basic.tbs_response_data.produced_at) + DEFAULT_NEXT_UPDATE_AHEAD
210		}
211	};
212
213	Ok(OcspStaple { staple: resp_der.to_vec(), next_update })
214}
215
216fn generalized_time_to_system(t: &x509_ocsp::OcspGeneralizedTime) -> SystemTime {
217	SystemTime::UNIX_EPOCH + t.0.to_unix_duration()
218}
219
220#[cfg(feature = "fetch")]
221mod fetch {
222	use std::time::Duration;
223
224	use bytes::Bytes;
225	use http_body_util::{BodyExt, Full, Limited};
226	use hyper::Request;
227
228	use super::{
229		OcspError, OcspStaple, build_ocsp_request, classify_url, extract_ocsp_url, parse_ocsp_response,
230	};
231
232	/// Hard cap on the OCSP response body. A signed OCSPResponse for a
233	/// single cert is typically a few KiB; 1 MiB is generous and
234	/// rejects pathological / adversarial responders before they pin
235	/// RAM. Matches the cap used by the CRL fetcher so the two trust-
236	/// material channels surface the same magnitude of failure.
237	const MAX_OCSP_BODY_BYTES: usize = 1024 * 1024;
238
239	/// HTTP POST `request_der` to `responder_url` and return the raw
240	/// `OCSPResponse` bytes. Caps the entire fetch at `timeout` (DNS +
241	/// connect + send + recv). Rejects HTTPS URLs with
242	/// [`OcspError::HttpsNotSupported`].
243	///
244	/// # Errors
245	///
246	/// - [`OcspError::HttpsNotSupported`] / [`OcspError::InvalidUrl`] on
247	///   scheme problems.
248	/// - [`OcspError::Transport`] on DNS / connect / hyper failures.
249	/// - [`OcspError::HttpStatus`] on non-200 responses.
250	pub async fn fetch_ocsp(
251		responder_url: &str,
252		request_der: Vec<u8>,
253		timeout: Duration,
254	) -> Result<Vec<u8>, OcspError> {
255		classify_url(responder_url)?;
256		let parsed = url::Url::parse(responder_url)
257			.map_err(|e| OcspError::InvalidUrl(format!("parse {responder_url}: {e}")))?;
258		let host = parsed
259			.host_str()
260			.ok_or_else(|| OcspError::InvalidUrl(format!("no host in {responder_url}")))?
261			.to_owned();
262		let port = parsed.port().unwrap_or(80);
263		let path_and_query = if parsed.path().is_empty() {
264			"/".to_owned()
265		} else {
266			match parsed.query() {
267				Some(q) => format!("{}?{q}", parsed.path()),
268				None => parsed.path().to_owned(),
269			}
270		};
271
272		let fut = perform_fetch(host.clone(), port, path_and_query, request_der);
273		tokio::time::timeout(timeout, fut)
274			.await
275			.map_err(|_| OcspError::Transport(format!("timed out after {timeout:?}")))?
276	}
277
278	async fn perform_fetch(
279		host: String,
280		port: u16,
281		path_and_query: String,
282		body: Vec<u8>,
283	) -> Result<Vec<u8>, OcspError> {
284		use hyper_util::rt::TokioIo;
285
286		let stream = tokio::net::TcpStream::connect((host.as_str(), port))
287			.await
288			.map_err(|e| OcspError::Transport(format!("connect {host}:{port}: {e}")))?;
289		let io = TokioIo::new(stream);
290		let (mut sender, conn) = hyper::client::conn::http1::handshake::<_, Full<Bytes>>(io)
291			.await
292			.map_err(|e| OcspError::Transport(format!("handshake: {e}")))?;
293		let conn_handle = tokio::spawn(async move {
294			// We don't care about the conn's exit status — `Connection: close`
295			// makes hyper return Ok once the server-issued FIN arrives.
296			let _ = conn.await;
297		});
298
299		let body_len = body.len();
300		let req = Request::builder()
301			.method("POST")
302			.uri(path_and_query)
303			.header(hyper::header::HOST, &host)
304			.header(hyper::header::CONTENT_TYPE, "application/ocsp-request")
305			.header(hyper::header::CONTENT_LENGTH, body_len.to_string())
306			.header(hyper::header::CONNECTION, "close")
307			.body(Full::new(Bytes::from(body)))
308			.map_err(|e| OcspError::Transport(format!("build request: {e}")))?;
309
310		let resp =
311			sender.send_request(req).await.map_err(|e| OcspError::Transport(format!("send: {e}")))?;
312		let status = resp.status();
313		if !status.is_success() {
314			conn_handle.abort();
315			return Err(OcspError::HttpStatus { status: status.as_u16() });
316		}
317		let limited = Limited::new(resp.into_body(), MAX_OCSP_BODY_BYTES);
318		let bytes = match limited.collect().await {
319			Ok(collected) => collected.to_bytes(),
320			Err(e) => {
321				conn_handle.abort();
322				if e.downcast_ref::<http_body_util::LengthLimitError>().is_some() {
323					return Err(OcspError::BodyTooLarge { cap: MAX_OCSP_BODY_BYTES });
324				}
325				return Err(OcspError::Transport(format!("read body: {e}")));
326			}
327		};
328		drop(sender);
329		let _ = conn_handle.await;
330		Ok(bytes.to_vec())
331	}
332
333	/// Convenience wrapper: extract AIA URL → build request → fetch →
334	/// parse, all in one call.
335	///
336	/// # Errors
337	///
338	/// Any error from the underlying [`extract_ocsp_url`] /
339	/// [`build_ocsp_request`] / [`fetch_ocsp`] / [`parse_ocsp_response`].
340	pub async fn fetch_ocsp_for_cert(
341		cert_der: &[u8],
342		issuer_der: &[u8],
343		timeout: Duration,
344	) -> Result<OcspStaple, OcspError> {
345		let url = extract_ocsp_url(cert_der)?;
346		let req = build_ocsp_request(cert_der, issuer_der)?;
347		let resp_bytes = fetch_ocsp(&url, req, timeout).await?;
348		parse_ocsp_response(&resp_bytes)
349	}
350}
351
352#[cfg(feature = "fetch")]
353pub use fetch::{fetch_ocsp, fetch_ocsp_for_cert};
354
355#[cfg(test)]
356mod tests {
357	use rcgen::{
358		BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair, KeyUsagePurpose,
359		PKCS_ECDSA_P256_SHA256,
360	};
361	use x509_cert::Certificate;
362
363	use super::*;
364
365	/// Build a self-signed CA + a leaf cert whose AIA extension points
366	/// at `aia_url`. Returns DER blobs for both. End-to-end signing of
367	/// an `OCSPResponse` is exercised by an external mock responder
368	/// (see `ocsp-mock-responder`); this crate's own tests cover only
369	/// the structural primitives that don't need a running responder.
370	fn build_test_ca_and_leaf(aia_url: &str) -> (Vec<u8>, Vec<u8>) {
371		// CA.
372		let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("ca key");
373		let mut ca_params = CertificateParams::new(vec!["Test CA".to_owned()]).expect("ca params");
374		ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
375		ca_params.key_usages.push(KeyUsagePurpose::KeyCertSign);
376		ca_params.key_usages.push(KeyUsagePurpose::CrlSign);
377		let ca_cert = ca_params.clone().self_signed(&ca_key).expect("self_signed");
378		let ca_der = ca_cert.der().to_vec();
379
380		// Leaf with AIA pointing at the test responder URL.
381		let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("leaf key");
382		let mut leaf_params =
383			CertificateParams::new(vec!["leaf.example".to_owned()]).expect("leaf params");
384		leaf_params.use_authority_key_identifier_extension = true;
385		leaf_params.custom_extensions.push(build_aia_custom_extension(aia_url));
386		let issuer = Issuer::from_params(&ca_params, &ca_key);
387		let leaf_cert = leaf_params.signed_by(&leaf_key, &issuer).expect("leaf signed_by");
388		let leaf_der = leaf_cert.der().to_vec();
389		(ca_der, leaf_der)
390	}
391
392	/// rcgen does not natively support AIA, so we hand-craft a DER
393	/// extension. The shape is `AuthorityInfoAccessSyntax ::=
394	/// SEQUENCE OF AccessDescription`, each `AccessDescription` is
395	/// `SEQUENCE { accessMethod OID, accessLocation GeneralName }`.
396	fn build_aia_custom_extension(aia_url: &str) -> rcgen::CustomExtension {
397		// OID 1.3.6.1.5.5.7.1.1 = id-pe-authorityInfoAccess
398		let oid_aia: &[u64] = &[1, 3, 6, 1, 5, 5, 7, 1, 1];
399		// Build:
400		//   SEQUENCE {                       (SEQUENCE OF AccessDescription)
401		//     SEQUENCE {                     (one AccessDescription)
402		//       OID 1.3.6.1.5.5.7.48.1       (id-ad-ocsp)
403		//       [6] IMPLICIT IA5String       (URI form of GeneralName)
404		//     }
405		//   }
406		let ocsp_oid_der: Vec<u8> = vec![0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01];
407		let url_bytes = aia_url.as_bytes();
408		let mut uri_tlv = vec![0x86];
409		uri_tlv.extend_from_slice(&der_length(url_bytes.len()));
410		uri_tlv.extend_from_slice(url_bytes);
411		let mut access_desc_inner = ocsp_oid_der;
412		access_desc_inner.extend_from_slice(&uri_tlv);
413		let mut access_desc_tlv = vec![0x30];
414		access_desc_tlv.extend_from_slice(&der_length(access_desc_inner.len()));
415		access_desc_tlv.extend_from_slice(&access_desc_inner);
416		let mut outer_tlv = vec![0x30];
417		outer_tlv.extend_from_slice(&der_length(access_desc_tlv.len()));
418		outer_tlv.extend_from_slice(&access_desc_tlv);
419		rcgen::CustomExtension::from_oid_content(oid_aia, outer_tlv)
420	}
421
422	fn der_length(n: usize) -> Vec<u8> {
423		// Test-only DER length encoder; inputs come from `aia_url` byte
424		// counts and stay well under `u16::MAX`.
425		if n < 0x80 {
426			vec![u8::try_from(n).unwrap()]
427		} else if n < 0x100 {
428			vec![0x81, u8::try_from(n).unwrap()]
429		} else {
430			vec![0x82, u8::try_from((n >> 8) & 0xff).unwrap(), u8::try_from(n & 0xff).unwrap()]
431		}
432	}
433
434	#[test]
435	fn extract_ocsp_url_returns_url_for_cert_with_aia() {
436		let (_, leaf_der) = build_test_ca_and_leaf("http://ocsp.example.test/");
437		let url = extract_ocsp_url(&leaf_der).expect("extract ok");
438		assert_eq!(url, "http://ocsp.example.test/");
439	}
440
441	#[test]
442	fn extract_ocsp_url_returns_no_aia_for_cert_without_extension() {
443		let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("key");
444		let params = CertificateParams::new(vec!["plain.example".to_owned()]).expect("params");
445		let cert = params.self_signed(&key).expect("self_signed");
446		let err = extract_ocsp_url(cert.der()).expect_err("no AIA → err");
447		assert!(matches!(err, OcspError::NoAia), "got {err:?}");
448	}
449
450	#[test]
451	fn extract_ocsp_url_returns_https_not_supported() {
452		let (_, leaf_der) = build_test_ca_and_leaf("https://ocsp.example.test/");
453		let err = extract_ocsp_url(&leaf_der).expect_err("HTTPS rejected");
454		match err {
455			OcspError::HttpsNotSupported(url) => {
456				assert_eq!(url, "https://ocsp.example.test/");
457			}
458			other => panic!("expected HttpsNotSupported, got {other:?}"),
459		}
460	}
461
462	#[test]
463	fn extract_ocsp_url_returns_invalid_url_for_non_http() {
464		let (_, leaf_der) = build_test_ca_and_leaf("ftp://ocsp.example.test/");
465		let err = extract_ocsp_url(&leaf_der).expect_err("ftp rejected");
466		assert!(matches!(err, OcspError::InvalidUrl(_)), "got {err:?}");
467	}
468
469	#[test]
470	fn build_ocsp_request_round_trips_through_x509_ocsp() {
471		let (issuer_der, leaf_der) = build_test_ca_and_leaf("http://ocsp.example.test/");
472		let bytes = build_ocsp_request(&leaf_der, &issuer_der).expect("build ok");
473		let req = x509_ocsp::OcspRequest::from_der(&bytes).expect("decode");
474		assert!(!req.tbs_request.request_list.is_empty());
475		let leaf = Certificate::from_der(&leaf_der).expect("leaf decode");
476		let want_serial = leaf.tbs_certificate.serial_number.clone();
477		let got_serial = req.tbs_request.request_list[0].req_cert.serial_number.clone();
478		assert_eq!(got_serial.as_bytes(), want_serial.as_bytes());
479	}
480
481	#[test]
482	fn parse_ocsp_response_returns_responder_error_on_try_later() {
483		let bytes = OcspResponse::try_later().to_der().expect("encode");
484		let err = parse_ocsp_response(&bytes).expect_err("try_later → err");
485		assert!(matches!(err, OcspError::ResponderError(_)), "got {err:?}");
486	}
487
488	#[test]
489	fn parse_ocsp_response_rejects_garbage_bytes() {
490		let err = parse_ocsp_response(&[0x30, 0x00]).expect_err("garbage rejected");
491		assert!(matches!(err, OcspError::ResponseParse(_)), "got {err:?}");
492	}
493
494	#[cfg(feature = "fetch")]
495	#[test]
496	fn fetch_ocsp_rejects_https_url_pre_connect() {
497		// No connection is attempted — the url scheme check fires
498		// first. Single-poll task; runs under a fresh runtime.
499		let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
500		let err = rt.block_on(async {
501			fetch_ocsp("https://ocsp.example.test/", vec![1, 2, 3], std::time::Duration::from_secs(1))
502				.await
503				.expect_err("https rejected")
504		});
505		assert!(matches!(err, OcspError::HttpsNotSupported(_)), "got {err:?}");
506	}
507}