// ----------------------------------------------------------------------------- // OcspClient.cs // Sprint: SPRINT_20260119_008 Certificate Status Provider // Task: CSP-002 - OCSP Client Implementation // Description: RFC 6960 OCSP client with request generation and response parsing. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using StellaOps.Cryptography.CertificateStatus.Abstractions; using System.Formats.Asn1; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; namespace StellaOps.Cryptography.CertificateStatus; /// /// OCSP client implementation following RFC 6960. /// public sealed class OcspClient { private const string OcspRequestContentType = "application/ocsp-request"; private const string OcspResponseContentType = "application/ocsp-response"; private const int MaxGetRequestSize = 255; private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public OcspClient( IHttpClientFactory httpClientFactory, IMemoryCache cache, ILogger logger) { _httpClientFactory = httpClientFactory; _cache = cache; _logger = logger; } /// /// Checks the revocation status of a certificate via OCSP. /// public async Task CheckStatusAsync( X509Certificate2 certificate, X509Certificate2 issuer, CertificateStatusOptions options, CancellationToken cancellationToken = default) { // Get OCSP responder URL from certificate var ocspUrl = GetOcspResponderUrl(certificate); if (string.IsNullOrEmpty(ocspUrl)) { return CertificateStatusResult.Unavailable("No OCSP responder URL in certificate"); } // Check cache first var cacheKey = $"ocsp:{certificate.Thumbprint}"; if (options.EnableCaching && _cache.TryGetValue(cacheKey, out CertificateStatusResult? cached)) { if (cached!.IsFresh) { _logger.LogDebug("OCSP cache hit for {Thumbprint}", certificate.Thumbprint); return cached; } } try { // Generate OCSP request var nonce = options.IncludeOcspNonce ? GenerateNonce() : null; var ocspRequest = GenerateOcspRequest(certificate, issuer, nonce); // Send request var responseBytes = await SendOcspRequestAsync( ocspUrl, ocspRequest, options.RequestTimeout, cancellationToken); // Parse response var result = ParseOcspResponse(responseBytes, certificate, nonce, ocspUrl); // Cache successful responses if (options.EnableCaching && result.Status == RevocationStatus.Good && result.NextUpdate.HasValue) { var expiry = result.NextUpdate.Value - DateTimeOffset.UtcNow; if (expiry > TimeSpan.Zero && expiry <= options.MaxOcspAge) { _cache.Set(cacheKey, result, expiry); } } return result; } catch (Exception ex) { _logger.LogWarning(ex, "OCSP check failed for {Thumbprint}", certificate.Thumbprint); return CertificateStatusResult.Unavailable($"OCSP request failed: {ex.Message}"); } } /// /// Parses a pre-fetched OCSP response. /// public CertificateStatusResult ParseStapledResponse( ReadOnlyMemory responseBytes, X509Certificate2 certificate) { return ParseOcspResponse(responseBytes.ToArray(), certificate, null, null); } private async Task SendOcspRequestAsync( string url, byte[] request, TimeSpan timeout, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient("OCSP"); client.Timeout = timeout; HttpResponseMessage response; // Use GET for small requests, POST for larger ones if (request.Length <= MaxGetRequestSize) { var base64Url = Convert.ToBase64String(request) .Replace('+', '-') .Replace('/', '_') .TrimEnd('='); var getUrl = $"{url.TrimEnd('/')}/{Uri.EscapeDataString(base64Url)}"; response = await client.GetAsync(getUrl, cancellationToken); } else { var content = new ByteArrayContent(request); content.Headers.ContentType = new MediaTypeHeaderValue(OcspRequestContentType); response = await client.PostAsync(url, content, cancellationToken); } response.EnsureSuccessStatusCode(); var contentType = response.Content.Headers.ContentType?.MediaType; if (contentType != OcspResponseContentType) { _logger.LogWarning("Unexpected OCSP response content type: {ContentType}", contentType); } return await response.Content.ReadAsByteArrayAsync(cancellationToken); } private byte[] GenerateOcspRequest( X509Certificate2 certificate, X509Certificate2 issuer, byte[]? nonce) { var writer = new AsnWriter(AsnEncodingRules.DER); // OCSPRequest ::= SEQUENCE { tbsRequest TBSRequest, ... } using (writer.PushSequence()) { // TBSRequest ::= SEQUENCE { version, requestorName, requestList, extensions } using (writer.PushSequence()) { // requestList SEQUENCE OF Request using (writer.PushSequence()) { // Request ::= SEQUENCE { reqCert CertID, ... } using (writer.PushSequence()) { WriteCertId(writer, certificate, issuer); } } // extensions [2] EXPLICIT Extensions OPTIONAL if (nonce is not null) { using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 2))) { using (writer.PushSequence()) { // Extension for nonce using (writer.PushSequence()) { writer.WriteObjectIdentifier("1.3.6.1.5.5.7.48.1.2"); // id-pkix-ocsp-nonce writer.WriteOctetString(nonce); } } } } } } return writer.Encode(); } private static void WriteCertId(AsnWriter writer, X509Certificate2 certificate, X509Certificate2 issuer) { // CertID ::= SEQUENCE { hashAlgorithm, issuerNameHash, issuerKeyHash, serialNumber } using (writer.PushSequence()) { // hashAlgorithm AlgorithmIdentifier (SHA-256) using (writer.PushSequence()) { writer.WriteObjectIdentifier("2.16.840.1.101.3.4.2.1"); // SHA-256 writer.WriteNull(); } // issuerNameHash OCTET STRING var issuerNameHash = SHA256.HashData(issuer.SubjectName.RawData); writer.WriteOctetString(issuerNameHash); // issuerKeyHash OCTET STRING var issuerKeyHash = SHA256.HashData(GetSubjectPublicKeyInfo(issuer)); writer.WriteOctetString(issuerKeyHash); // serialNumber CertificateSerialNumber var serialBytes = certificate.SerialNumberBytes.ToArray(); // Reverse for big-endian Array.Reverse(serialBytes); writer.WriteIntegerUnsigned(serialBytes); } } private static byte[] GetSubjectPublicKeyInfo(X509Certificate2 certificate) { // Extract the public key bytes from the certificate var publicKey = certificate.PublicKey; return publicKey.EncodedKeyValue.RawData; } private CertificateStatusResult ParseOcspResponse( byte[] responseBytes, X509Certificate2 certificate, byte[]? expectedNonce, string? responderUrl) { try { var reader = new AsnReader(responseBytes, AsnEncodingRules.DER); var responseSequence = reader.ReadSequence(); // OCSPResponse ::= SEQUENCE { responseStatus, responseBytes } var statusValue = responseSequence.ReadEnumeratedBytes(); var status = (OcspResponseStatus)statusValue.Span[0]; if (status != OcspResponseStatus.Successful) { return CertificateStatusResult.Unavailable($"OCSP responder returned status: {status}"); } if (!responseSequence.HasData) { return CertificateStatusResult.Unavailable("OCSP response has no response bytes"); } // responseBytes [0] EXPLICIT ResponseBytes var responseBytesWrapper = responseSequence.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)); var responseType = responseBytesWrapper.ReadObjectIdentifier(); if (responseType != "1.3.6.1.5.5.7.48.1.1") // id-pkix-ocsp-basic { return CertificateStatusResult.Unknown(RevocationSource.Ocsp, $"Unknown OCSP response type: {responseType}"); } var basicResponseBytes = responseBytesWrapper.ReadOctetString(); return ParseBasicOcspResponse(basicResponseBytes, certificate, expectedNonce, responderUrl, responseBytes); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse OCSP response"); return CertificateStatusResult.Unavailable($"Failed to parse OCSP response: {ex.Message}"); } } private CertificateStatusResult ParseBasicOcspResponse( byte[] basicResponseBytes, X509Certificate2 certificate, byte[]? expectedNonce, string? responderUrl, byte[] rawResponse) { var reader = new AsnReader(basicResponseBytes, AsnEncodingRules.DER); var basicResponse = reader.ReadSequence(); // tbsResponseData var tbsResponseData = basicResponse.ReadSequence(); // Skip version if present [0] if (tbsResponseData.PeekTag().TagClass == TagClass.ContextSpecific && tbsResponseData.PeekTag().TagValue == 0) { tbsResponseData.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)); } // responderID (skip) tbsResponseData.ReadEncodedValue(); // producedAt GeneralizedTime var producedAt = tbsResponseData.ReadGeneralizedTime(); // responses SEQUENCE OF SingleResponse var responses = tbsResponseData.ReadSequence(); while (responses.HasData) { var singleResponse = responses.ReadSequence(); // certID CertID (skip for now, assume single cert) singleResponse.ReadSequence(); // certStatus CHOICE var certStatusTag = singleResponse.PeekTag(); RevocationStatus revocationStatus; DateTimeOffset? revocationTime = null; RevocationReason? revocationReason = null; if (certStatusTag.TagValue == 0) // good [0] IMPLICIT NULL { singleResponse.ReadNull(new Asn1Tag(TagClass.ContextSpecific, 0)); revocationStatus = RevocationStatus.Good; } else if (certStatusTag.TagValue == 1) // revoked [1] IMPLICIT RevokedInfo { var revokedInfo = singleResponse.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 1)); revocationTime = revokedInfo.ReadGeneralizedTime(); if (revokedInfo.HasData) { var reasonTag = revokedInfo.PeekTag(); if (reasonTag.TagClass == TagClass.ContextSpecific && reasonTag.TagValue == 0) { var reasonBytes = revokedInfo.ReadEnumeratedBytes(new Asn1Tag(TagClass.ContextSpecific, 0)); revocationReason = (RevocationReason)reasonBytes.Span[0]; } } revocationStatus = RevocationStatus.Revoked; } else // unknown [2] IMPLICIT NULL { singleResponse.ReadNull(new Asn1Tag(TagClass.ContextSpecific, 2)); revocationStatus = RevocationStatus.Unknown; } // thisUpdate GeneralizedTime var thisUpdate = singleResponse.ReadGeneralizedTime(); // nextUpdate [0] EXPLICIT GeneralizedTime OPTIONAL DateTimeOffset? nextUpdate = null; if (singleResponse.HasData && singleResponse.PeekTag().TagClass == TagClass.ContextSpecific) { var nextUpdateWrapper = singleResponse.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)); nextUpdate = nextUpdateWrapper.ReadGeneralizedTime(); } if (revocationStatus == RevocationStatus.Revoked) { return CertificateStatusResult.Revoked( RevocationSource.Ocsp, revocationTime ?? DateTimeOffset.UtcNow, revocationReason ?? Abstractions.RevocationReason.Unspecified); } return new CertificateStatusResult { Status = revocationStatus, Source = RevocationSource.Ocsp, ProducedAt = producedAt, ThisUpdate = thisUpdate, NextUpdate = nextUpdate, ResponderUrl = responderUrl, RawOcspResponse = rawResponse }; } return CertificateStatusResult.Unknown(RevocationSource.Ocsp, "No matching response found"); } private static string? GetOcspResponderUrl(X509Certificate2 certificate) { const string authorityInfoAccessOid = "1.3.6.1.5.5.7.1.1"; const string ocspOid = "1.3.6.1.5.5.7.48.1"; foreach (var extension in certificate.Extensions) { if (extension.Oid?.Value != authorityInfoAccessOid) continue; try { var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER); var sequence = reader.ReadSequence(); while (sequence.HasData) { var accessDescription = sequence.ReadSequence(); var method = accessDescription.ReadObjectIdentifier(); if (method == ocspOid) { // accessLocation is [6] IA5String for URI var tag = accessDescription.PeekTag(); if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 6) { var urlBytes = accessDescription.ReadCharacterString( UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)); return urlBytes; } } // Skip remaining access location if (accessDescription.HasData) { accessDescription.ReadEncodedValue(); } } } catch { // Ignore parse errors } } return null; } private static byte[] GenerateNonce() { var nonce = new byte[16]; RandomNumberGenerator.Fill(nonce); return nonce; } private enum OcspResponseStatus { Successful = 0, MalformedRequest = 1, InternalError = 2, TryLater = 3, SigRequired = 5, Unauthorized = 6 } }