445 lines
16 KiB
C#
445 lines
16 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// OCSP client implementation following RFC 6960.
|
|
/// </summary>
|
|
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<OcspClient> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="OcspClient"/> class.
|
|
/// </summary>
|
|
public OcspClient(
|
|
IHttpClientFactory httpClientFactory,
|
|
IMemoryCache cache,
|
|
ILogger<OcspClient> logger)
|
|
{
|
|
_httpClientFactory = httpClientFactory;
|
|
_cache = cache;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks the revocation status of a certificate via OCSP.
|
|
/// </summary>
|
|
public async Task<CertificateStatusResult> 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a pre-fetched OCSP response.
|
|
/// </summary>
|
|
public CertificateStatusResult ParseStapledResponse(
|
|
ReadOnlyMemory<byte> responseBytes,
|
|
X509Certificate2 certificate)
|
|
{
|
|
return ParseOcspResponse(responseBytes.ToArray(), certificate, null, null);
|
|
}
|
|
|
|
private async Task<byte[]> 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
|
|
}
|
|
}
|