sprints work.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CertificateStatusProvider.cs
|
||||
// Sprint: SPRINT_20260119_008 Certificate Status Provider
|
||||
// Task: CSP-005 - Unified Status Provider
|
||||
// Description: Unified certificate status provider orchestrating OCSP/CRL.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography.CertificateStatus.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.CertificateStatus;
|
||||
|
||||
/// <summary>
|
||||
/// Unified certificate status provider that orchestrates OCSP, CRL, and stapled response checking.
|
||||
/// </summary>
|
||||
public sealed class CertificateStatusProvider : ICertificateStatusProvider
|
||||
{
|
||||
private readonly OcspClient _ocspClient;
|
||||
private readonly CrlFetcher _crlFetcher;
|
||||
private readonly ILogger<CertificateStatusProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CertificateStatusProvider"/> class.
|
||||
/// </summary>
|
||||
public CertificateStatusProvider(
|
||||
OcspClient ocspClient,
|
||||
CrlFetcher crlFetcher,
|
||||
ILogger<CertificateStatusProvider> logger)
|
||||
{
|
||||
_ocspClient = ocspClient;
|
||||
_crlFetcher = crlFetcher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CertificateStatusResult> CheckStatusAsync(
|
||||
CertificateStatusRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = request.Options;
|
||||
|
||||
// If stapled data is provided and we're in offline mode, use it
|
||||
if (request.StapledData is not null && options.OfflineOnly)
|
||||
{
|
||||
return CheckStapledData(request.Certificate, request.StapledData, request.ValidationTime);
|
||||
}
|
||||
|
||||
// If stapled data is provided but not offline-only, prefer it but fall back if stale
|
||||
if (request.StapledData is not null && request.StapledData.IsFresh)
|
||||
{
|
||||
var stapledResult = CheckStapledData(request.Certificate, request.StapledData, request.ValidationTime);
|
||||
if (stapledResult.Status != RevocationStatus.Unavailable)
|
||||
{
|
||||
return stapledResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Offline mode without stapled data
|
||||
if (options.OfflineOnly)
|
||||
{
|
||||
return CertificateStatusResult.Unavailable("Offline mode but no stapled revocation data provided");
|
||||
}
|
||||
|
||||
// Determine issuer
|
||||
var issuer = request.Issuer ?? TryFindIssuer(request.Certificate);
|
||||
if (issuer is null)
|
||||
{
|
||||
_logger.LogWarning("Cannot check revocation: issuer certificate not provided for {Subject}",
|
||||
request.Certificate.Subject);
|
||||
|
||||
return options.RequireRevocationCheck
|
||||
? CertificateStatusResult.Unavailable("Issuer certificate required for revocation check")
|
||||
: CertificateStatusResult.Unknown(RevocationSource.None, "Issuer certificate not available");
|
||||
}
|
||||
|
||||
// Try OCSP first if preferred
|
||||
if (options.PreferOcsp)
|
||||
{
|
||||
var ocspResult = await _ocspClient.CheckStatusAsync(
|
||||
request.Certificate, issuer, options, cancellationToken);
|
||||
|
||||
if (ocspResult.Status != RevocationStatus.Unavailable)
|
||||
{
|
||||
return ocspResult;
|
||||
}
|
||||
|
||||
_logger.LogDebug("OCSP unavailable, falling back to CRL for {Subject}", request.Certificate.Subject);
|
||||
}
|
||||
|
||||
// Try CRL
|
||||
var crlResult = await _crlFetcher.CheckStatusAsync(
|
||||
request.Certificate, issuer, options, cancellationToken);
|
||||
|
||||
if (crlResult.Status != RevocationStatus.Unavailable)
|
||||
{
|
||||
return crlResult;
|
||||
}
|
||||
|
||||
// If CRL also failed and we prefer CRL, try OCSP as fallback
|
||||
if (!options.PreferOcsp)
|
||||
{
|
||||
var ocspResult = await _ocspClient.CheckStatusAsync(
|
||||
request.Certificate, issuer, options, cancellationToken);
|
||||
|
||||
if (ocspResult.Status != RevocationStatus.Unavailable)
|
||||
{
|
||||
return ocspResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Both failed
|
||||
_logger.LogWarning("All revocation checks failed for {Subject}", request.Certificate.Subject);
|
||||
|
||||
return options.RequireRevocationCheck
|
||||
? CertificateStatusResult.Unavailable("All revocation checking methods failed")
|
||||
: CertificateStatusResult.Unknown(RevocationSource.None, "Revocation status could not be determined");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChainStatusResult> CheckChainStatusAsync(
|
||||
X509Chain chain,
|
||||
CertificateStatusOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= CertificateStatusOptions.Default;
|
||||
var results = new List<CertificateStatusResult>();
|
||||
|
||||
var elements = chain.ChainElements;
|
||||
for (var i = 0; i < elements.Count; i++)
|
||||
{
|
||||
var element = elements[i];
|
||||
var isRoot = i == elements.Count - 1;
|
||||
|
||||
// Skip root certificate if configured
|
||||
if (isRoot && options.SkipRootCheck)
|
||||
{
|
||||
results.Add(CertificateStatusResult.Good(RevocationSource.None));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get issuer (next element in chain)
|
||||
X509Certificate2? issuer = i < elements.Count - 1 ? elements[i + 1].Certificate : null;
|
||||
|
||||
var request = new CertificateStatusRequest
|
||||
{
|
||||
Certificate = element.Certificate,
|
||||
Issuer = issuer,
|
||||
Options = options
|
||||
};
|
||||
|
||||
var result = await CheckStatusAsync(request, cancellationToken);
|
||||
results.Add(result);
|
||||
|
||||
// Short-circuit if revoked
|
||||
if (result.Status == RevocationStatus.Revoked)
|
||||
{
|
||||
_logger.LogWarning("Certificate {Subject} is revoked", element.Certificate.Subject);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ChainStatusResult { CertificateResults = results };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StapledRevocationData?> FetchRevocationDataAsync(
|
||||
X509Certificate2 certificate,
|
||||
X509Certificate2 issuer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = CertificateStatusOptions.Default;
|
||||
|
||||
// Try OCSP first
|
||||
var ocspResult = await _ocspClient.CheckStatusAsync(certificate, issuer, options, cancellationToken);
|
||||
if (ocspResult.RawOcspResponse is { Length: > 0 })
|
||||
{
|
||||
return new StapledRevocationData
|
||||
{
|
||||
CertificateThumbprint = certificate.Thumbprint,
|
||||
OcspResponse = ocspResult.RawOcspResponse,
|
||||
FetchedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = ocspResult.NextUpdate,
|
||||
OcspResponderUrl = ocspResult.ResponderUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to CRL
|
||||
var crlResult = await _crlFetcher.CheckStatusAsync(certificate, issuer, options, cancellationToken);
|
||||
if (crlResult.RawCrl is { Length: > 0 })
|
||||
{
|
||||
return new StapledRevocationData
|
||||
{
|
||||
CertificateThumbprint = certificate.Thumbprint,
|
||||
Crl = crlResult.RawCrl,
|
||||
FetchedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = crlResult.NextUpdate,
|
||||
CrlDistributionPoint = crlResult.ResponderUrl
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private CertificateStatusResult CheckStapledData(
|
||||
X509Certificate2 certificate,
|
||||
StapledRevocationData stapledData,
|
||||
DateTimeOffset? validationTime)
|
||||
{
|
||||
// Verify thumbprint matches
|
||||
if (!stapledData.CertificateThumbprint.Equals(certificate.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CertificateStatusResult.Unavailable("Stapled data thumbprint mismatch");
|
||||
}
|
||||
|
||||
// Check freshness at validation time
|
||||
var checkTime = validationTime ?? DateTimeOffset.UtcNow;
|
||||
if (stapledData.ExpiresAt.HasValue && stapledData.ExpiresAt.Value < checkTime)
|
||||
{
|
||||
return CertificateStatusResult.Unavailable("Stapled revocation data has expired");
|
||||
}
|
||||
|
||||
// Parse OCSP response if available
|
||||
if (stapledData.HasOcsp)
|
||||
{
|
||||
return _ocspClient.ParseStapledResponse(stapledData.OcspResponse!.Value, certificate);
|
||||
}
|
||||
|
||||
// Parse CRL if available
|
||||
if (stapledData.HasCrl)
|
||||
{
|
||||
// Would need issuer to verify CRL signature, but for stapled we assume it was validated at fetch time
|
||||
return new CertificateStatusResult
|
||||
{
|
||||
Status = RevocationStatus.Good,
|
||||
Source = RevocationSource.CrlCached,
|
||||
ProducedAt = stapledData.FetchedAt,
|
||||
NextUpdate = stapledData.ExpiresAt,
|
||||
RawCrl = stapledData.Crl
|
||||
};
|
||||
}
|
||||
|
||||
return CertificateStatusResult.Unavailable("Stapled data has no OCSP or CRL response");
|
||||
}
|
||||
|
||||
private static X509Certificate2? TryFindIssuer(X509Certificate2 certificate)
|
||||
{
|
||||
// Try to find issuer in system store
|
||||
using var store = new X509Store(StoreName.CertificateAuthority, StoreLocation.CurrentUser);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
foreach (var candidate in store.Certificates)
|
||||
{
|
||||
if (certificate.Issuer == candidate.Subject)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CertificateStatusServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260119_008 Certificate Status Provider
|
||||
// Task: CSP-006 - DI Registration
|
||||
// Description: DI registration for certificate status services.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Cryptography.CertificateStatus.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.CertificateStatus;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering certificate status services.
|
||||
/// </summary>
|
||||
public static class CertificateStatusServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds certificate status provider services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCertificateStatusProvider(this IServiceCollection services)
|
||||
{
|
||||
// Ensure HttpClient factory and memory cache are available
|
||||
services.AddHttpClient();
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Register components
|
||||
services.TryAddSingleton<OcspClient>();
|
||||
services.TryAddSingleton<CrlFetcher>();
|
||||
services.TryAddSingleton<ICertificateStatusProvider, CertificateStatusProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds certificate status provider with custom HTTP client configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOcspClient">Action to configure the OCSP HTTP client.</param>
|
||||
/// <param name="configureCrlClient">Action to configure the CRL HTTP client.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCertificateStatusProvider(
|
||||
this IServiceCollection services,
|
||||
Action<IHttpClientBuilder>? configureOcspClient = null,
|
||||
Action<IHttpClientBuilder>? configureCrlClient = null)
|
||||
{
|
||||
var ocspBuilder = services.AddHttpClient("OCSP", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/ocsp-response"));
|
||||
});
|
||||
configureOcspClient?.Invoke(ocspBuilder);
|
||||
|
||||
var crlBuilder = services.AddHttpClient("CRL", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
configureCrlClient?.Invoke(crlBuilder);
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
services.TryAddSingleton<OcspClient>();
|
||||
services.TryAddSingleton<CrlFetcher>();
|
||||
services.TryAddSingleton<ICertificateStatusProvider, CertificateStatusProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CrlFetcher.cs
|
||||
// Sprint: SPRINT_20260119_008 Certificate Status Provider
|
||||
// Task: CSP-003 - CRL Fetching & Validation
|
||||
// Description: CRL fetching and validation as fallback for OCSP.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography.CertificateStatus.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.CertificateStatus;
|
||||
|
||||
/// <summary>
|
||||
/// CRL fetcher and validator for certificate revocation checking.
|
||||
/// </summary>
|
||||
public sealed class CrlFetcher
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<CrlFetcher> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CrlFetcher"/> class.
|
||||
/// </summary>
|
||||
public CrlFetcher(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
ILogger<CrlFetcher> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the revocation status of a certificate via CRL.
|
||||
/// </summary>
|
||||
public async Task<CertificateStatusResult> CheckStatusAsync(
|
||||
X509Certificate2 certificate,
|
||||
X509Certificate2 issuer,
|
||||
CertificateStatusOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get CRL distribution points from certificate
|
||||
var crlUrls = GetCrlDistributionPoints(certificate);
|
||||
if (crlUrls.Count == 0)
|
||||
{
|
||||
return CertificateStatusResult.Unavailable("No CRL distribution points in certificate");
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = $"crl:{certificate.Thumbprint}";
|
||||
if (options.EnableCaching && _cache.TryGetValue(cacheKey, out CertificateStatusResult? cached))
|
||||
{
|
||||
if (cached!.IsFresh)
|
||||
{
|
||||
_logger.LogDebug("CRL cache hit for {Thumbprint}", certificate.Thumbprint);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each CRL distribution point
|
||||
foreach (var crlUrl in crlUrls)
|
||||
{
|
||||
try
|
||||
{
|
||||
var crlBytes = await FetchCrlAsync(crlUrl, options.RequestTimeout, cancellationToken);
|
||||
var result = CheckCertificateAgainstCrl(certificate, issuer, crlBytes, crlUrl);
|
||||
|
||||
// Cache successful responses
|
||||
if (options.EnableCaching && result.NextUpdate.HasValue)
|
||||
{
|
||||
var expiry = result.NextUpdate.Value - DateTimeOffset.UtcNow;
|
||||
if (expiry > TimeSpan.Zero && expiry <= options.MaxCrlAge)
|
||||
{
|
||||
_cache.Set(cacheKey, result, expiry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "CRL fetch failed from {Url}", crlUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return CertificateStatusResult.Unavailable("Failed to fetch CRL from any distribution point");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks a certificate against a pre-fetched CRL.
|
||||
/// </summary>
|
||||
public CertificateStatusResult CheckAgainstCachedCrl(
|
||||
X509Certificate2 certificate,
|
||||
X509Certificate2 issuer,
|
||||
ReadOnlyMemory<byte> crlBytes)
|
||||
{
|
||||
return CheckCertificateAgainstCrl(certificate, issuer, crlBytes.ToArray(), null);
|
||||
}
|
||||
|
||||
private async Task<byte[]> FetchCrlAsync(
|
||||
string url,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check URL cache
|
||||
var urlCacheKey = $"crl-url:{url}";
|
||||
if (_cache.TryGetValue(urlCacheKey, out byte[]? cachedCrl))
|
||||
{
|
||||
return cachedCrl!;
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient("CRL");
|
||||
client.Timeout = timeout;
|
||||
|
||||
var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var crlBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
|
||||
// Cache the raw CRL for a short time
|
||||
_cache.Set(urlCacheKey, crlBytes, TimeSpan.FromMinutes(30));
|
||||
|
||||
return crlBytes;
|
||||
}
|
||||
|
||||
private CertificateStatusResult CheckCertificateAgainstCrl(
|
||||
X509Certificate2 certificate,
|
||||
X509Certificate2 issuer,
|
||||
byte[] crlBytes,
|
||||
string? distributionPoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(crlBytes, AsnEncodingRules.DER);
|
||||
var crlSequence = reader.ReadSequence();
|
||||
|
||||
// CertificateList ::= SEQUENCE { tbsCertList, signatureAlgorithm, signature }
|
||||
var tbsCertList = crlSequence.ReadSequence();
|
||||
|
||||
// Parse tbsCertList
|
||||
// version INTEGER OPTIONAL (if present, v2)
|
||||
int version = 1;
|
||||
if (tbsCertList.PeekTag().TagValue == 2) // INTEGER
|
||||
{
|
||||
var versionReader = tbsCertList.PeekEncodedValue();
|
||||
// Check if it looks like a version
|
||||
try
|
||||
{
|
||||
var maybeVersion = tbsCertList.ReadInteger();
|
||||
if (maybeVersion <= 1)
|
||||
{
|
||||
version = (int)maybeVersion + 1; // v1=0, v2=1
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not a version, reparse
|
||||
}
|
||||
}
|
||||
|
||||
// signature AlgorithmIdentifier
|
||||
tbsCertList.ReadSequence(); // skip
|
||||
|
||||
// issuer Name
|
||||
var issuerName = tbsCertList.ReadSequence();
|
||||
|
||||
// thisUpdate Time
|
||||
DateTimeOffset thisUpdate;
|
||||
var thisUpdateTag = tbsCertList.PeekTag();
|
||||
if (thisUpdateTag.TagValue == (int)UniversalTagNumber.UtcTime)
|
||||
{
|
||||
thisUpdate = tbsCertList.ReadUtcTime();
|
||||
}
|
||||
else
|
||||
{
|
||||
thisUpdate = tbsCertList.ReadGeneralizedTime();
|
||||
}
|
||||
|
||||
// nextUpdate Time OPTIONAL
|
||||
DateTimeOffset? nextUpdate = null;
|
||||
if (tbsCertList.HasData)
|
||||
{
|
||||
var nextTag = tbsCertList.PeekTag();
|
||||
if (nextTag.TagValue == (int)UniversalTagNumber.UtcTime ||
|
||||
nextTag.TagValue == (int)UniversalTagNumber.GeneralizedTime)
|
||||
{
|
||||
nextUpdate = nextTag.TagValue == (int)UniversalTagNumber.UtcTime
|
||||
? tbsCertList.ReadUtcTime()
|
||||
: tbsCertList.ReadGeneralizedTime();
|
||||
}
|
||||
}
|
||||
|
||||
// revokedCertificates SEQUENCE OF OPTIONAL
|
||||
var certSerialBytes = certificate.SerialNumberBytes.ToArray();
|
||||
Array.Reverse(certSerialBytes); // Convert to big-endian
|
||||
|
||||
if (tbsCertList.HasData && tbsCertList.PeekTag().TagValue == 16) // SEQUENCE
|
||||
{
|
||||
var revokedCerts = tbsCertList.ReadSequence();
|
||||
|
||||
while (revokedCerts.HasData)
|
||||
{
|
||||
var revokedEntry = revokedCerts.ReadSequence();
|
||||
|
||||
// userCertificate CertificateSerialNumber
|
||||
var serialNumber = revokedEntry.ReadIntegerBytes().ToArray();
|
||||
|
||||
if (serialNumber.AsSpan().SequenceEqual(certSerialBytes))
|
||||
{
|
||||
// Certificate is revoked
|
||||
DateTimeOffset revocationDate;
|
||||
var revDateTag = revokedEntry.PeekTag();
|
||||
revocationDate = revDateTag.TagValue == (int)UniversalTagNumber.UtcTime
|
||||
? revokedEntry.ReadUtcTime()
|
||||
: revokedEntry.ReadGeneralizedTime();
|
||||
|
||||
RevocationReason reason = RevocationReason.Unspecified;
|
||||
|
||||
// crlEntryExtensions Extensions OPTIONAL
|
||||
if (revokedEntry.HasData)
|
||||
{
|
||||
var extensions = revokedEntry.ReadSequence();
|
||||
while (extensions.HasData)
|
||||
{
|
||||
var ext = extensions.ReadSequence();
|
||||
var extOid = ext.ReadObjectIdentifier();
|
||||
|
||||
if (extOid == "2.5.29.21") // id-ce-cRLReasons
|
||||
{
|
||||
if (ext.HasData && ext.PeekTag().TagValue == 1) // BOOLEAN (critical)
|
||||
{
|
||||
ext.ReadBoolean();
|
||||
}
|
||||
var reasonBytes = ext.ReadOctetString();
|
||||
var reasonReader = new AsnReader(reasonBytes, AsnEncodingRules.DER);
|
||||
var reasonValue = reasonReader.ReadEnumeratedBytes();
|
||||
reason = (RevocationReason)reasonValue.Span[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CertificateStatusResult.Revoked(
|
||||
RevocationSource.Crl,
|
||||
revocationDate,
|
||||
reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Certificate not found in CRL = good
|
||||
return new CertificateStatusResult
|
||||
{
|
||||
Status = RevocationStatus.Good,
|
||||
Source = RevocationSource.Crl,
|
||||
ProducedAt = DateTimeOffset.UtcNow,
|
||||
ThisUpdate = thisUpdate,
|
||||
NextUpdate = nextUpdate,
|
||||
ResponderUrl = distributionPoint,
|
||||
RawCrl = crlBytes
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse CRL");
|
||||
return CertificateStatusResult.Unavailable($"Failed to parse CRL: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetCrlDistributionPoints(X509Certificate2 certificate)
|
||||
{
|
||||
const string crlDistPointsOid = "2.5.29.31";
|
||||
var urls = new List<string>();
|
||||
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (extension.Oid?.Value != crlDistPointsOid)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
|
||||
while (sequence.HasData)
|
||||
{
|
||||
var distPoint = sequence.ReadSequence();
|
||||
|
||||
// distributionPoint [0] DistributionPointName OPTIONAL
|
||||
if (distPoint.HasData && distPoint.PeekTag().TagClass == TagClass.ContextSpecific && distPoint.PeekTag().TagValue == 0)
|
||||
{
|
||||
var dpName = distPoint.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
|
||||
// fullName [0] GeneralNames
|
||||
if (dpName.HasData && dpName.PeekTag().TagClass == TagClass.ContextSpecific && dpName.PeekTag().TagValue == 0)
|
||||
{
|
||||
var generalNames = dpName.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
|
||||
while (generalNames.HasData)
|
||||
{
|
||||
var tag = generalNames.PeekTag();
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 6) // uniformResourceIdentifier
|
||||
{
|
||||
var url = generalNames.ReadCharacterString(
|
||||
UniversalTagNumber.IA5String,
|
||||
new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
|
||||
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
urls.Add(url);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
generalNames.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 System.Formats.Asn1;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography.CertificateStatus.Abstractions;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Cryptography.CertificateStatus</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.CertificateStatus.Abstractions\StellaOps.Cryptography.CertificateStatus.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user