diff --git a/services/api_gateway/src/auth/mtls/revocation.rs b/services/api_gateway/src/auth/mtls/revocation.rs index c74c22105..58264ecc1 100644 --- a/services/api_gateway/src/auth/mtls/revocation.rs +++ b/services/api_gateway/src/auth/mtls/revocation.rs @@ -104,11 +104,6 @@ impl OcspCache { } /// Store OCSP status in cache with TTL - /// - /// Currently used for OCSP caching infrastructure (line 311). - /// Will be actively used once OCSP protocol implementation is complete - /// (pending ocsp crate library upgrade - see AGENT_S9_OCSP_IMPLEMENTATION_STATUS.md) - #[allow(dead_code)] async fn put(&self, key: String, status: OcspStatus) { let mut cache = self.cache.write().await; let entry = OcspCacheEntry { @@ -357,18 +352,19 @@ impl RevocationChecker { /// Check certificate via OCSP (Online Certificate Status Protocol) - RFC 6960 /// - /// NOTE: OCSP implementation pending library upgrade (see AGENT_S9_OCSP_IMPLEMENTATION_STATUS.md) - /// The `ocsp` crate v0.4.0 has incomplete request encoding and response parsing (both marked as WIP). - /// Infrastructure is 100% complete (caching, metrics, retry logic, health monitoring). - /// - /// TODO: Complete OCSP when library supports it or implement manual ASN.1 encoding/parsing + /// Builds an OCSP request using SHA-1 hashes of the issuer name and public key + /// (per RFC 6960 Section 4.1.1), sends it via HTTP POST to the responder, and + /// parses the DER-encoded response to determine revocation status. async fn check_ocsp_revocation( &self, cert: &X509Certificate<'_>, - _issuer: &X509Certificate<'_>, + issuer: &X509Certificate<'_>, ocsp_url: &str, ) -> Result { - debug!("OCSP check requested for certificate SN {:X} against {}", cert.serial, ocsp_url); + debug!( + "OCSP check requested for certificate SN {:X} against {}", + cert.serial, ocsp_url + ); OCSP_REQUESTS_TOTAL.inc(); let _timer = OCSP_REQUEST_LATENCY.start_timer(); @@ -385,20 +381,296 @@ impl RevocationChecker { } debug!("OCSP cache miss for SN: {}", cache_key); - // Log OCSP attempt (infrastructure operational, pending library upgrade) - warn!( - "OCSP checking infrastructure operational but protocol implementation pending. \ - Certificate SN {:X} - falling back to CRL check. \ - See AGENT_S9_OCSP_IMPLEMENTATION_STATUS.md for details.", - cert.serial - ); + // Build the OCSP request DER bytes + let ocsp_request_der = Self::build_ocsp_request(cert, issuer) + .context("Failed to build OCSP request")?; - // Return error to trigger CRL fallback - Err(anyhow!("OCSP protocol implementation pending library upgrade")) + // Send OCSP request via HTTP POST (RFC 6960 Appendix A) + let response = self + .http_client + .post(ocsp_url) + .header("Content-Type", "application/ocsp-request") + .header("Accept", "application/ocsp-response") + .body(ocsp_request_der) + .send() + .await + .context("Failed to send OCSP request")?; + + if !response.status().is_success() { + return Err(anyhow!( + "OCSP responder returned HTTP status {}", + response.status() + )); + } + + let response_bytes = response + .bytes() + .await + .context("Failed to read OCSP response body")?; + + if response_bytes.is_empty() { + return Err(anyhow!("OCSP responder returned empty response")); + } + + // Parse the OCSP response status and certificate status + let status = Self::parse_ocsp_response(&response_bytes, cert) + .context("Failed to parse OCSP response")?; + + // Cache the result + self.ocsp_cache.put(cache_key.clone(), status).await; + debug!("OCSP result cached for SN {}: {:?}", cache_key, status); + + match status { + OcspStatus::Good => Ok(false), + OcspStatus::Revoked => Ok(true), + OcspStatus::Unknown => Err(anyhow!( + "OCSP responder returned Unknown status for certificate SN {:X}", + cert.serial + )), + } } - // OCSP request/response handling will be implemented here when library supports it - // See AGENT_S9_OCSP_IMPLEMENTATION_STATUS.md for implementation plan + /// Encode ASN.1 DER length bytes. + fn der_encode_length(len: usize) -> Vec { + if len < 128 { + vec![len as u8] + } else { + let be_bytes: Vec = len.to_be_bytes().into_iter().skip_while(|b| *b == 0).collect(); + let mut result = vec![0x80 | be_bytes.len() as u8]; + result.extend(be_bytes); + result + } + } + + /// Wrap content bytes in an ASN.1 SEQUENCE (tag 0x30). + fn wrap_in_sequence(content: &[u8]) -> Vec { + let mut result = vec![0x30u8]; + result.extend(Self::der_encode_length(content.len())); + result.extend_from_slice(content); + result + } + + /// Build a DER-encoded OCSP request for the given certificate and issuer. + /// + /// Per RFC 6960 Section 4.1.1, the CertID is constructed from: + /// - SHA-1 hash of the issuer's distinguished name (DER encoding) + /// - SHA-1 hash of the issuer's public key (bit string value) + /// - The certificate's serial number + fn build_ocsp_request( + cert: &X509Certificate<'_>, + issuer: &X509Certificate<'_>, + ) -> Result> { + use ocsp::common::asn1::{CertId, Oid}; + use ocsp::oid::ALGO_SHA1_DOT; + use ocsp::request::OneReq; + use sha1::{Digest, Sha1}; + + // SHA-1 hash of the issuer's distinguished name (DER-encoded) + let issuer_name_der = issuer.subject().as_raw(); + let issuer_name_hash: Vec = Sha1::digest(issuer_name_der).to_vec(); + + // SHA-1 hash of the issuer's public key (BIT STRING value, excluding tag/length) + let issuer_pubkey_data = &issuer.public_key().subject_public_key.data; + let issuer_key_hash: Vec = Sha1::digest(issuer_pubkey_data).to_vec(); + + // Certificate serial number as raw bytes + let serial_bytes = cert.raw_serial().to_vec(); + + // Build the CertId using SHA-1 algorithm OID + let sha1_oid = Oid::new_from_dot(ALGO_SHA1_DOT) + .map_err(|e| anyhow!("Failed to create SHA-1 OID: {:?}", e))?; + let cert_id = CertId::new(sha1_oid, &issuer_name_hash, &issuer_key_hash, &serial_bytes); + + // Build the single request (no per-request extensions) + let one_req = OneReq { + certid: cert_id, + one_req_ext: None, + }; + + // DER-encode the OneReq + let one_req_der = one_req + .to_der() + .map_err(|e| anyhow!("Failed to DER-encode OneReq: {:?}", e))?; + + // Wrap in requestList SEQUENCE, then TBSRequest SEQUENCE, then OCSPRequest SEQUENCE + let request_list = Self::wrap_in_sequence(&one_req_der); + let tbs_request = Self::wrap_in_sequence(&request_list); + let ocsp_request = Self::wrap_in_sequence(&tbs_request); + + debug!( + "Built OCSP request ({} bytes) for certificate SN {:X}", + ocsp_request.len(), + cert.serial + ); + Ok(ocsp_request) + } + + /// Parse a DER-encoded OCSP response to extract the certificate status. + /// + /// The OCSP response structure (RFC 6960 Section 4.2.1): + /// + /// OCSPResponse ::= SEQUENCE { + /// responseStatus OCSPResponseStatus, + /// responseBytes [0] EXPLICIT ResponseBytes OPTIONAL } + /// + /// We extract the response status first, then scan for the certificate + /// status tag within the response data: + /// - 0x80 0x00 = Good + /// - 0xa1 ... = Revoked (with RevokedInfo) + /// - 0x82 0x00 = Unknown + fn parse_ocsp_response(response_bytes: &[u8], cert: &X509Certificate<'_>) -> Result { + if response_bytes.len() < 5 { + return Err(anyhow!( + "OCSP response too short ({} bytes)", + response_bytes.len() + )); + } + + let first_byte = response_bytes.first().copied().unwrap_or(0); + if first_byte != 0x30 { + return Err(anyhow!( + "OCSP response does not start with SEQUENCE tag (got 0x{:02x})", + first_byte + )); + } + + let resp_status = Self::find_response_status(response_bytes)?; + + match resp_status { + 0 => debug!("OCSP response status: successful"), + 1 => return Err(anyhow!("OCSP responder: malformed request")), + 2 => return Err(anyhow!("OCSP responder: internal error")), + 3 => return Err(anyhow!("OCSP responder: try later")), + 5 => return Err(anyhow!("OCSP responder: signature required")), + 6 => return Err(anyhow!("OCSP responder: unauthorized")), + _ => return Err(anyhow!("OCSP responder: unknown status {}", resp_status)), + } + + let serial_bytes = cert.raw_serial(); + let cert_status = Self::find_cert_status_in_response(response_bytes, serial_bytes)?; + + debug!("OCSP certificate status for SN {:X}: {:?}", cert.serial, cert_status); + Ok(cert_status) + } + + /// Extract the OCSPResponseStatus ENUMERATED value from the response. + fn find_response_status(data: &[u8]) -> Result { + let content = Self::parse_tlv_content(data) + .context("Failed to parse OCSP response outer SEQUENCE")?; + + if content.len() < 3 { + return Err(anyhow!("OCSP response content too short for status")); + } + let tag = content.first().copied().unwrap_or(0); + if tag != 0x0a { + return Err(anyhow!("Expected ENUMERATED tag (0x0a) but got 0x{:02x}", tag)); + } + let len = content.get(1).copied().unwrap_or(0) as usize; + if len != 1 || content.len() < 3 { + return Err(anyhow!("Invalid ENUMERATED length in OCSP response status")); + } + content + .get(2) + .copied() + .ok_or_else(|| anyhow!("Missing OCSP response status value")) + } + + /// Parse a DER TLV (Tag-Length-Value) and return the value (content) portion. + fn parse_tlv_content(data: &[u8]) -> Result<&[u8]> { + if data.len() < 2 { + return Err(anyhow!("TLV data too short")); + } + let len_byte = data.get(1).copied().unwrap_or(0); + if len_byte & 0x80 == 0 { + let content_start = 2; + let content_len = len_byte as usize; + if data.len() < content_start + content_len { + return Err(anyhow!("TLV data truncated (short form)")); + } + Ok(data.get(content_start..content_start + content_len).unwrap_or(&[])) + } else { + let num_len_bytes = (len_byte & 0x7f) as usize; + if num_len_bytes == 0 || num_len_bytes > 4 { + return Err(anyhow!("Invalid long-form length byte count: {}", num_len_bytes)); + } + let content_start = 2 + num_len_bytes; + if data.len() < content_start { + return Err(anyhow!("TLV data truncated (long form header)")); + } + let mut content_len: usize = 0; + for i in 0..num_len_bytes { + content_len = (content_len << 8) + | data + .get(2 + i) + .copied() + .ok_or_else(|| anyhow!("TLV long form length truncated"))? + as usize; + } + if data.len() < content_start + content_len { + return Err(anyhow!("TLV data truncated (long form content)")); + } + Ok(data.get(content_start..content_start + content_len).unwrap_or(&[])) + } + } + + /// Search the OCSP response body for the certificate status associated with our serial. + fn find_cert_status_in_response(data: &[u8], serial: &[u8]) -> Result { + // Build the DER-encoded serial INTEGER to search for + let mut serial_der = vec![0x02u8]; // INTEGER tag + if serial.len() < 128 { + serial_der.push(serial.len() as u8); + } else { + return Err(anyhow!("Serial number too long for OCSP response parsing")); + } + serial_der.extend_from_slice(serial); + + // Find the serial in the response data + let serial_pos = Self::find_subsequence(data, &serial_der).ok_or_else(|| { + OCSP_RESPONSE_VALIDATION_FAILURES.inc(); + anyhow!("Certificate serial number not found in OCSP response") + })?; + + // After the serial INTEGER, scan forward for the cert status tag + let search_start = serial_pos + serial_der.len(); + let search_window = data.get(search_start..).unwrap_or(&[]); + let max_scan = std::cmp::min(search_window.len(), 128); + + for i in 0..max_scan { + let tag = search_window.get(i).copied().unwrap_or(0); + match tag { + // Good: context-specific [0] IMPLICIT NULL = 0x80 0x00 + 0x80 => { + if search_window.get(i + 1).copied().unwrap_or(0xFF) == 0x00 { + return Ok(OcspStatus::Good); + } + } + // Revoked: context-specific [1] CONSTRUCTED = 0xa1 with nonzero length + 0xa1 => { + if search_window.get(i + 1).copied().unwrap_or(0) > 0 { + return Ok(OcspStatus::Revoked); + } + } + // Unknown: context-specific [2] IMPLICIT NULL = 0x82 0x00 + 0x82 => { + if search_window.get(i + 1).copied().unwrap_or(0xFF) == 0x00 { + return Ok(OcspStatus::Unknown); + } + } + _ => {} + } + } + + OCSP_RESPONSE_VALIDATION_FAILURES.inc(); + Err(anyhow!("Could not determine certificate status from OCSP response")) + } + + /// Find a subsequence (needle) within a byte slice (haystack). + fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack.windows(needle.len()).position(|window| window == needle) + } /// Get cache statistics for health monitoring pub fn get_cache_stats(&self) -> CacheStats {