From 8808655ffc0dce8abb615fe8a1c5d6ef6da19fe1 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Sat, 28 Mar 2026 03:41:09 +0000 Subject: [PATCH] feat(tls): implement OCSP stapling / --cert-status support Implement OCSP certificate status verification (OCSP stapling) across all layers of the project: - Add `verify_status` field to `TlsConfig` (default: false) - Add `OcspCheckingVerifier` wrapper that requires non-empty OCSP stapled responses from TLS servers during handshake - Add `ssl_verify_status()` method to `Easy` handle - Wire `--cert-status` CLI flag (previously a no-op) to enable OCSP verification, failing with exit code 91 if the server doesn't provide a stapled OCSP response - Add `SslInvalidCertStatus` error variant mapping to `CURLE_SSL_INVALIDCERTSTATUS` (91) - Add `CURLOPT_SSL_VERIFYSTATUS` (232) to the FFI layer - Report "OCSP-stapling" in `--version` Features output Closes #123 Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/liburlx-ffi/include/urlx.h | 1 + crates/liburlx-ffi/src/lib.rs | 8 ++ crates/liburlx/src/easy.rs | 10 ++ crates/liburlx/src/error.rs | 5 + crates/liburlx/src/tls.rs | 205 ++++++++++++++++++++++++++++-- crates/urlx-cli/src/args.rs | 7 +- crates/urlx-cli/src/transfer.rs | 2 + 7 files changed, 222 insertions(+), 16 deletions(-) diff --git a/crates/liburlx-ffi/include/urlx.h b/crates/liburlx-ffi/include/urlx.h index 6133eba..66b4806 100644 --- a/crates/liburlx-ffi/include/urlx.h +++ b/crates/liburlx-ffi/include/urlx.h @@ -319,6 +319,7 @@ typedef enum CURLoption { CURLOPT_STREAM_WEIGHT = 239, CURLOPT_TCP_FASTOPEN = 244, CURLOPT_SOCKS5_AUTH = 267, + CURLOPT_SSL_VERIFYSTATUS = 232, CURLOPT_HTTP09_ALLOWED = 285, CURLOPT_POSTFIELDSIZE_LARGE = 30120, CURLOPT_INFILESIZE_LARGE = 30115, diff --git a/crates/liburlx-ffi/src/lib.rs b/crates/liburlx-ffi/src/lib.rs index 164105b..f3a903f 100644 --- a/crates/liburlx-ffi/src/lib.rs +++ b/crates/liburlx-ffi/src/lib.rs @@ -226,6 +226,7 @@ pub enum CURLoption { CURLOPT_STREAM_WEIGHT = 239, CURLOPT_TCP_FASTOPEN = 244, CURLOPT_SOCKS5_AUTH = 267, + CURLOPT_SSL_VERIFYSTATUS = 232, CURLOPT_HTTP09_ALLOWED = 285, // Off_t options (CURLOPTTYPE_OFF_T = 30000) @@ -2035,6 +2036,12 @@ pub unsafe extern "C" fn curl_easy_setopt( CURLcode::CURLE_OK } + // CURLOPT_SSL_VERIFYSTATUS = 232 + 232 => { + h.easy.ssl_verify_status(value as c_long != 0); + CURLcode::CURLE_OK + } + // CURLOPT_PROXY_SSL_VERIFYPEER = 248 248 => { h.easy.proxy_ssl_verify_peer(value as c_long != 0); @@ -4893,6 +4900,7 @@ fn error_to_curlcode(err: &liburlx::Error) -> CURLcode { match err { liburlx::Error::UrlParse(_) => CURLcode::CURLE_URL_MALFORMAT, liburlx::Error::Connect(_) => CURLcode::CURLE_COULDNT_CONNECT, + liburlx::Error::SslInvalidCertStatus(_) => CURLcode::CURLE_SSL_INVALIDCERTSTATUS, liburlx::Error::Tls(_) => CURLcode::CURLE_SSL_CONNECT_ERROR, liburlx::Error::Http(msg) => { if msg.contains("unsupported scheme") { diff --git a/crates/liburlx/src/easy.rs b/crates/liburlx/src/easy.rs index 3324850..48b0133 100644 --- a/crates/liburlx/src/easy.rs +++ b/crates/liburlx/src/easy.rs @@ -1743,6 +1743,16 @@ impl Easy { self.tls_config.session_cache = enable; } + /// Enable or disable OCSP certificate status verification. + /// + /// When enabled, the server must provide a valid OCSP stapled response + /// during the TLS handshake. If no stapled response is provided, the + /// transfer fails with [`crate::error::Error::SslInvalidCertStatus`]. + /// Equivalent to curl's `--cert-status` flag or `CURLOPT_SSL_VERIFYSTATUS`. + pub const fn ssl_verify_status(&mut self, enable: bool) { + self.tls_config.verify_status = enable; + } + /// Set the TLS-SRP username. Equivalent to curl's `--tlsuser`. pub fn ssl_srp_user(&mut self, user: &str) { self.tls_config.srp_user = Some(user.to_string()); diff --git a/crates/liburlx/src/error.rs b/crates/liburlx/src/error.rs index 57967a2..11f53e5 100644 --- a/crates/liburlx/src/error.rs +++ b/crates/liburlx/src/error.rs @@ -131,6 +131,11 @@ pub enum Error { #[error("RTSP Session mismatch: {0}")] RtspSessionError(String), + /// The server did not provide a valid OCSP certificate status response. + /// Maps to `CURLE_SSL_INVALIDCERTSTATUS` (91) at the FFI boundary. + #[error("SSL certificate status verification FAILED: {0}")] + SslInvalidCertStatus(String), + /// An SMB protocol error occurred. #[error("SMB error: {0}")] Smb(String), diff --git a/crates/liburlx/src/tls.rs b/crates/liburlx/src/tls.rs index bf764ee..2dca38f 100644 --- a/crates/liburlx/src/tls.rs +++ b/crates/liburlx/src/tls.rs @@ -19,6 +19,7 @@ pub enum TlsVersion { /// Controls certificate verification, custom CA bundles, client certificates, /// and TLS version negotiation. #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct TlsConfig { /// Whether to verify the server's TLS certificate (default: true). /// @@ -108,6 +109,14 @@ pub struct TlsConfig { /// TLS-SRP password for password-based TLS authentication (RFC 5054). pub srp_password: Option, + + /// Whether to request and verify the server's OCSP certificate status (default: false). + /// + /// When `true`, the server must provide a valid OCSP stapled response + /// during the TLS handshake. If no stapled response is provided, the + /// connection fails with [`crate::error::Error::SslInvalidCertStatus`]. + /// Equivalent to curl's `--cert-status` flag or `CURLOPT_SSL_VERIFYSTATUS`. + pub verify_status: bool, } impl Default for TlsConfig { @@ -129,6 +138,7 @@ impl Default for TlsConfig { crl_file: None, srp_user: None, srp_password: None, + verify_status: false, } } } @@ -158,6 +168,8 @@ mod rustls_impl { config: Arc, /// SHA-256 pin of the server's public key (base64-encoded). pinned_public_key: Option, + /// Whether to require OCSP stapled responses from the server. + verify_status: bool, } impl TlsConnector { @@ -187,12 +199,16 @@ mod rustls_impl { Self::build(tls_config, false) } + /// Marker prefix used in OCSP verification errors for detection in `connect`. + const OCSP_ERROR_PREFIX: &str = "OCSP_CERT_STATUS:"; + /// Internal builder shared by `new()` and `new_no_alpn()`. + #[allow(clippy::too_many_lines)] // verify_status branches add unavoidable depth fn build(tls_config: &TlsConfig, use_http_alpn: bool) -> Result { let versions = Self::protocol_versions(tls_config); let config = if !tls_config.verify_peer { - // Insecure mode: accept any certificate + // Insecure mode: accept any certificate (no OCSP check) let mut config = Self::config_builder(&versions) .dangerous() .with_custom_certificate_verifier(Arc::new(NoVerifier)) @@ -218,6 +234,8 @@ mod rustls_impl { Error::Tls(format!("CRL verifier build failed: {e}").into()) })?; + let verifier = Self::maybe_wrap_ocsp(verifier, tls_config.verify_status); + let builder = Self::config_builder(&versions) .dangerous() .with_custom_certificate_verifier(verifier); @@ -227,6 +245,26 @@ mod rustls_impl { Self::configure_alpn(&mut config); } config + } else if tls_config.verify_status { + // Need explicit verifier for OCSP status checking + let verifier = + rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store)) + .build() + .map_err(|e| { + Error::Tls(format!("verifier build failed: {e}").into()) + })?; + let verifier: Arc = + Arc::new(OcspCheckingVerifier { inner: verifier }); + + let builder = Self::config_builder(&versions) + .dangerous() + .with_custom_certificate_verifier(verifier); + + let mut config = Self::with_client_auth(builder, tls_config)?; + if use_http_alpn { + Self::configure_alpn(&mut config); + } + config } else { let builder = Self::config_builder(&versions).with_root_certificates(root_store); @@ -241,25 +279,69 @@ mod rustls_impl { // Custom CA bundle from in-memory blob let root_store = load_ca_certs_from_blob(ca_blob)?; - let builder = Self::config_builder(&versions).with_root_certificates(root_store); + if tls_config.verify_status { + let verifier = + rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store)) + .build() + .map_err(|e| { + Error::Tls(format!("verifier build failed: {e}").into()) + })?; + let verifier: Arc = + Arc::new(OcspCheckingVerifier { inner: verifier }); - let mut config = Self::with_client_auth(builder, tls_config)?; - if use_http_alpn { - Self::configure_alpn(&mut config); + let builder = Self::config_builder(&versions) + .dangerous() + .with_custom_certificate_verifier(verifier); + + let mut config = Self::with_client_auth(builder, tls_config)?; + if use_http_alpn { + Self::configure_alpn(&mut config); + } + config + } else { + let builder = + Self::config_builder(&versions).with_root_certificates(root_store); + + let mut config = Self::with_client_auth(builder, tls_config)?; + if use_http_alpn { + Self::configure_alpn(&mut config); + } + config } - config } else { // Default: system root certificates let root_store: rustls::RootCertStore = webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); - let builder = Self::config_builder(&versions).with_root_certificates(root_store); + if tls_config.verify_status { + let verifier = + rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store)) + .build() + .map_err(|e| { + Error::Tls(format!("verifier build failed: {e}").into()) + })?; + let verifier: Arc = + Arc::new(OcspCheckingVerifier { inner: verifier }); - let mut config = Self::with_client_auth(builder, tls_config)?; - if use_http_alpn { - Self::configure_alpn(&mut config); + let builder = Self::config_builder(&versions) + .dangerous() + .with_custom_certificate_verifier(verifier); + + let mut config = Self::with_client_auth(builder, tls_config)?; + if use_http_alpn { + Self::configure_alpn(&mut config); + } + config + } else { + let builder = + Self::config_builder(&versions).with_root_certificates(root_store); + + let mut config = Self::with_client_auth(builder, tls_config)?; + if use_http_alpn { + Self::configure_alpn(&mut config); + } + config } - config }; // Extract the base64 hash from the "sha256//..." format @@ -268,7 +350,23 @@ mod rustls_impl { .as_ref() .and_then(|pin| pin.strip_prefix("sha256//").map(ToString::to_string)); - Ok(Self { config: Arc::new(config), pinned_public_key }) + Ok(Self { + config: Arc::new(config), + pinned_public_key, + verify_status: tls_config.verify_status, + }) + } + + /// Optionally wrap a verifier with OCSP status checking. + fn maybe_wrap_ocsp( + verifier: Arc, + verify_status: bool, + ) -> Arc { + if verify_status { + Arc::new(OcspCheckingVerifier { inner: verifier }) + } else { + verifier + } } /// Determine the allowed TLS protocol versions based on config. @@ -351,7 +449,7 @@ mod rustls_impl { let tls_stream = connector .connect(server_name, stream) .await - .map_err(|e| Error::Tls(Box::new(e)))?; + .map_err(|e| Self::map_tls_error(e, self.verify_status))?; // Verify certificate pinning if configured if let Some(ref expected_hash) = self.pinned_public_key { @@ -392,7 +490,7 @@ mod rustls_impl { let tls_stream = connector .connect(server_name, stream) .await - .map_err(|e| Error::Tls(Box::new(e)))?; + .map_err(|e| Self::map_tls_error(e, self.verify_status))?; let alpn = tls_stream .get_ref() @@ -441,6 +539,85 @@ mod rustls_impl { Ok(()) } + + /// Map a TLS I/O error to the appropriate liburlx error type. + /// + /// When `verify_status` is true, detects OCSP-related failures from the + /// custom verifier and returns [`Error::SslInvalidCertStatus`] instead + /// of the generic [`Error::Tls`]. + fn map_tls_error(e: std::io::Error, verify_status: bool) -> Error { + if verify_status { + let msg = e.to_string(); + if msg.contains(Self::OCSP_ERROR_PREFIX) { + return Error::SslInvalidCertStatus( + "SSL certificate status verification FAILED".to_string(), + ); + } + } + Error::Tls(Box::new(e)) + } + } + + /// A certificate verifier wrapper that requires OCSP stapled responses. + /// + /// Delegates normal certificate verification to the inner verifier, + /// then checks that the server provided a non-empty OCSP response. + /// Used when `--cert-status` / `CURLOPT_SSL_VERIFYSTATUS` is enabled. + #[derive(Debug)] + struct OcspCheckingVerifier { + inner: Arc, + } + + impl rustls::client::danger::ServerCertVerifier for OcspCheckingVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + // Perform normal certificate verification first + let _ = self.inner.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + )?; + + // Then require a non-empty OCSP stapled response + if ocsp_response.is_empty() { + return Err(rustls::Error::General(format!( + "{} no certificate status response from server", + TlsConnector::OCSP_ERROR_PREFIX, + ))); + } + + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } } /// A certificate verifier that accepts any server certificate. diff --git a/crates/urlx-cli/src/args.rs b/crates/urlx-cli/src/args.rs index a072eca..9dd33b1 100644 --- a/crates/urlx-cli/src/args.rs +++ b/crates/urlx-cli/src/args.rs @@ -246,6 +246,7 @@ pub fn print_version() { features.push("libz"); } features.push("NTLM"); + features.push("OCSP-stapling"); features.push("PSL"); features.push("ssl-sessions"); if cfg!(feature = "rustls") || cfg!(feature = "tls-srp") { @@ -417,7 +418,7 @@ pub fn print_usage() { eprintln!(" --no-sessionid Disable TLS session ID reuse (no-op)"); eprintln!(" --no-alpn Disable ALPN negotiation (no-op)"); eprintln!(" --no-npn Disable NPN negotiation (no-op)"); - eprintln!(" --cert-status Request OCSP stapling (no-op)"); + eprintln!(" --cert-status Request OCSP stapling"); eprintln!(" --false-start Enable TLS false start (no-op)"); eprintln!(" --disable-eprt Disable EPRT for FTP (no-op)"); eprintln!(" --disable-epsv Disable EPSV for FTP (no-op)"); @@ -1179,6 +1180,9 @@ fn parse_args_options_with_depth(args: &[String], config_depth: u32) -> Result { + opts.easy.ssl_verify_status(true); + } "--cacert" => { i += 1; let val = require_arg(args, i, "--cacert")?; @@ -2244,7 +2248,6 @@ fn parse_args_options_with_depth(args: &[String], config_depth: u32) -> Result u8 { _ => 7, // CURLE_COULDNT_CONNECT } } + liburlx::Error::SslInvalidCertStatus(_) => 91, // CURLE_SSL_INVALIDCERTSTATUS liburlx::Error::Tls(e) => { let msg = e.to_string(); if msg.contains("certificate") || msg.contains("verify") { @@ -2562,6 +2563,7 @@ pub fn curl_error_message(err: &liburlx::Error) -> String { liburlx::Error::SshRangeError(msg) => msg.clone(), liburlx::Error::SshQuoteErrorWithData { message, .. } => message.clone(), liburlx::Error::Connect(e) => format!("Failed to connect: {e}"), + liburlx::Error::SslInvalidCertStatus(msg) => msg.clone(), liburlx::Error::Tls(e) => e.to_string(), liburlx::Error::FileError(msg) => msg.clone(), liburlx::Error::PartialBody { message, .. } => message.clone(),