sprints work.

This commit is contained in:
master
2026-01-20 00:45:38 +02:00
parent b34bde89fa
commit 4903395618
275 changed files with 52785 additions and 79 deletions

View File

@@ -0,0 +1,106 @@
// -----------------------------------------------------------------------------
// CertificateStatusOptions.cs
// Sprint: SPRINT_20260119_008 Certificate Status Provider
// Task: CSP-001 - Core Abstractions
// Description: Configuration options for certificate status checking.
// -----------------------------------------------------------------------------
namespace StellaOps.Cryptography.CertificateStatus.Abstractions;
/// <summary>
/// Options for certificate revocation status checking.
/// </summary>
public sealed record CertificateStatusOptions
{
/// <summary>
/// Gets whether to prefer OCSP over CRL.
/// </summary>
public bool PreferOcsp { get; init; } = true;
/// <summary>
/// Gets whether revocation checking is required.
/// </summary>
public bool RequireRevocationCheck { get; init; } = true;
/// <summary>
/// Gets whether to only accept stapled/cached responses (offline mode).
/// </summary>
public bool OfflineOnly { get; init; } = false;
/// <summary>
/// Gets whether to allow unknown status to pass.
/// </summary>
public bool AllowUnknownStatus { get; init; } = false;
/// <summary>
/// Gets the maximum age for OCSP responses.
/// </summary>
public TimeSpan MaxOcspAge { get; init; } = TimeSpan.FromDays(7);
/// <summary>
/// Gets the maximum age for CRL responses.
/// </summary>
public TimeSpan MaxCrlAge { get; init; } = TimeSpan.FromDays(30);
/// <summary>
/// Gets the timeout for online requests.
/// </summary>
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets whether to include OCSP nonce for replay protection.
/// </summary>
public bool IncludeOcspNonce { get; init; } = true;
/// <summary>
/// Gets whether to cache responses.
/// </summary>
public bool EnableCaching { get; init; } = true;
/// <summary>
/// Gets whether to check the entire certificate chain.
/// </summary>
public bool CheckFullChain { get; init; } = true;
/// <summary>
/// Gets whether to skip root certificate revocation check.
/// </summary>
public bool SkipRootCheck { get; init; } = true;
/// <summary>
/// Gets the default options.
/// </summary>
public static CertificateStatusOptions Default { get; } = new();
/// <summary>
/// Gets strict options (all checks required, short validity).
/// </summary>
public static CertificateStatusOptions Strict { get; } = new()
{
PreferOcsp = true,
RequireRevocationCheck = true,
AllowUnknownStatus = false,
MaxOcspAge = TimeSpan.FromDays(1),
MaxCrlAge = TimeSpan.FromDays(7),
IncludeOcspNonce = true
};
/// <summary>
/// Gets offline-only options (stapled/cached responses only).
/// </summary>
public static CertificateStatusOptions OfflineOnlyMode { get; } = new()
{
OfflineOnly = true,
RequireRevocationCheck = true,
EnableCaching = true
};
/// <summary>
/// Gets permissive options (revocation check optional).
/// </summary>
public static CertificateStatusOptions Permissive { get; } = new()
{
RequireRevocationCheck = false,
AllowUnknownStatus = true
};
}

View File

@@ -0,0 +1,68 @@
// -----------------------------------------------------------------------------
// CertificateStatusRequest.cs
// Sprint: SPRINT_20260119_008 Certificate Status Provider
// Task: CSP-001 - Core Abstractions
// Description: Request model for certificate status checking.
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Cryptography.CertificateStatus.Abstractions;
/// <summary>
/// Request for checking certificate revocation status.
/// </summary>
public sealed record CertificateStatusRequest
{
/// <summary>
/// Gets the certificate to check.
/// </summary>
public required X509Certificate2 Certificate { get; init; }
/// <summary>
/// Gets the issuer certificate (required for OCSP).
/// </summary>
public X509Certificate2? Issuer { get; init; }
/// <summary>
/// Gets the status check options.
/// </summary>
public CertificateStatusOptions Options { get; init; } = CertificateStatusOptions.Default;
/// <summary>
/// Gets pre-fetched stapled revocation data (for offline verification).
/// </summary>
public StapledRevocationData? StapledData { get; init; }
/// <summary>
/// Gets the validation time (for historical validation).
/// </summary>
public DateTimeOffset? ValidationTime { get; init; }
/// <summary>
/// Creates a request for a certificate with its issuer.
/// </summary>
public static CertificateStatusRequest Create(
X509Certificate2 certificate,
X509Certificate2? issuer = null,
CertificateStatusOptions? options = null) => new()
{
Certificate = certificate,
Issuer = issuer,
Options = options ?? CertificateStatusOptions.Default
};
/// <summary>
/// Creates a request for offline verification using stapled data.
/// </summary>
public static CertificateStatusRequest CreateOffline(
X509Certificate2 certificate,
StapledRevocationData stapledData,
DateTimeOffset? validationTime = null) => new()
{
Certificate = certificate,
StapledData = stapledData,
ValidationTime = validationTime,
Options = CertificateStatusOptions.OfflineOnlyMode
};
}

View File

@@ -0,0 +1,175 @@
// -----------------------------------------------------------------------------
// CertificateStatusResult.cs
// Sprint: SPRINT_20260119_008 Certificate Status Provider
// Task: CSP-001 - Core Abstractions
// Description: Result model for certificate status checking.
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Cryptography.CertificateStatus.Abstractions;
/// <summary>
/// Result of a certificate revocation status check.
/// </summary>
public sealed record CertificateStatusResult
{
/// <summary>
/// Gets the revocation status.
/// </summary>
public required RevocationStatus Status { get; init; }
/// <summary>
/// Gets the source of the revocation information.
/// </summary>
public required RevocationSource Source { get; init; }
/// <summary>
/// Gets the time the status was produced.
/// </summary>
public DateTimeOffset? ProducedAt { get; init; }
/// <summary>
/// Gets when this status information was last updated.
/// </summary>
public DateTimeOffset? ThisUpdate { get; init; }
/// <summary>
/// Gets when this status information will next be updated.
/// </summary>
public DateTimeOffset? NextUpdate { get; init; }
/// <summary>
/// Gets the revocation time if the certificate is revoked.
/// </summary>
public DateTimeOffset? RevocationTime { get; init; }
/// <summary>
/// Gets the revocation reason if the certificate is revoked.
/// </summary>
public RevocationReason? RevocationReason { get; init; }
/// <summary>
/// Gets the raw OCSP response if available.
/// </summary>
public ReadOnlyMemory<byte>? RawOcspResponse { get; init; }
/// <summary>
/// Gets the raw CRL if available.
/// </summary>
public ReadOnlyMemory<byte>? RawCrl { get; init; }
/// <summary>
/// Gets error details if the check failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Gets the responder URL that was used.
/// </summary>
public string? ResponderUrl { get; init; }
/// <summary>
/// Gets whether the status is considered valid.
/// </summary>
public bool IsValid => Status == RevocationStatus.Good;
/// <summary>
/// Gets whether the response is still fresh.
/// </summary>
public bool IsFresh => NextUpdate.HasValue && NextUpdate.Value > DateTimeOffset.UtcNow;
/// <summary>
/// Creates a result indicating the certificate is good.
/// </summary>
public static CertificateStatusResult Good(
RevocationSource source,
DateTimeOffset? thisUpdate = null,
DateTimeOffset? nextUpdate = null,
ReadOnlyMemory<byte>? rawResponse = null) => new()
{
Status = RevocationStatus.Good,
Source = source,
ProducedAt = DateTimeOffset.UtcNow,
ThisUpdate = thisUpdate,
NextUpdate = nextUpdate,
RawOcspResponse = source is RevocationSource.Ocsp or RevocationSource.OcspStapled ? rawResponse : null,
RawCrl = source is RevocationSource.Crl or RevocationSource.CrlCached ? rawResponse : null
};
/// <summary>
/// Creates a result indicating the certificate is revoked.
/// </summary>
public static CertificateStatusResult Revoked(
RevocationSource source,
DateTimeOffset revocationTime,
RevocationReason reason = Abstractions.RevocationReason.Unspecified) => new()
{
Status = RevocationStatus.Revoked,
Source = source,
ProducedAt = DateTimeOffset.UtcNow,
RevocationTime = revocationTime,
RevocationReason = reason
};
/// <summary>
/// Creates a result indicating the status is unknown.
/// </summary>
public static CertificateStatusResult Unknown(RevocationSource source, string? error = null) => new()
{
Status = RevocationStatus.Unknown,
Source = source,
ProducedAt = DateTimeOffset.UtcNow,
Error = error
};
/// <summary>
/// Creates a result indicating the check is unavailable.
/// </summary>
public static CertificateStatusResult Unavailable(string error) => new()
{
Status = RevocationStatus.Unavailable,
Source = RevocationSource.None,
ProducedAt = DateTimeOffset.UtcNow,
Error = error
};
}
/// <summary>
/// Result of checking an entire certificate chain.
/// </summary>
public sealed record ChainStatusResult
{
/// <summary>
/// Gets whether the entire chain is valid.
/// </summary>
public bool IsValid => CertificateResults.All(r => r.Status == RevocationStatus.Good);
/// <summary>
/// Gets the status results for each certificate in the chain.
/// </summary>
public required IReadOnlyList<CertificateStatusResult> CertificateResults { get; init; }
/// <summary>
/// Gets the overall chain status.
/// </summary>
public RevocationStatus OverallStatus
{
get
{
if (CertificateResults.Any(r => r.Status == RevocationStatus.Revoked))
return RevocationStatus.Revoked;
if (CertificateResults.Any(r => r.Status == RevocationStatus.Unknown))
return RevocationStatus.Unknown;
if (CertificateResults.Any(r => r.Status == RevocationStatus.Unavailable))
return RevocationStatus.Unavailable;
return RevocationStatus.Good;
}
}
/// <summary>
/// Gets the first revoked certificate result if any.
/// </summary>
public CertificateStatusResult? FirstRevoked =>
CertificateResults.FirstOrDefault(r => r.Status == RevocationStatus.Revoked);
}

View File

@@ -0,0 +1,50 @@
// -----------------------------------------------------------------------------
// ICertificateStatusProvider.cs
// Sprint: SPRINT_20260119_008 Certificate Status Provider
// Task: CSP-001 - Core Abstractions
// Description: Main interface for certificate revocation checking.
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Cryptography.CertificateStatus.Abstractions;
/// <summary>
/// Provides certificate revocation status checking via OCSP, CRL, or stapled responses.
/// </summary>
public interface ICertificateStatusProvider
{
/// <summary>
/// Checks the revocation status of a certificate.
/// </summary>
/// <param name="request">The status check request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The certificate status result.</returns>
Task<CertificateStatusResult> CheckStatusAsync(
CertificateStatusRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks the revocation status of a certificate chain.
/// </summary>
/// <param name="chain">The certificate chain to check.</param>
/// <param name="options">Status check options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Status results for each certificate in the chain.</returns>
Task<ChainStatusResult> CheckChainStatusAsync(
X509Chain chain,
CertificateStatusOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Fetches revocation data for stapling (OCSP response and/or CRL).
/// </summary>
/// <param name="certificate">The certificate to get revocation data for.</param>
/// <param name="issuer">The issuer certificate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Stapled revocation data for bundling.</returns>
Task<StapledRevocationData?> FetchRevocationDataAsync(
X509Certificate2 certificate,
X509Certificate2 issuer,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,126 @@
// -----------------------------------------------------------------------------
// RevocationEnums.cs
// Sprint: SPRINT_20260119_008 Certificate Status Provider
// Task: CSP-001 - Core Abstractions
// Description: Enumerations for revocation status and sources.
// -----------------------------------------------------------------------------
namespace StellaOps.Cryptography.CertificateStatus.Abstractions;
/// <summary>
/// Certificate revocation status.
/// </summary>
public enum RevocationStatus
{
/// <summary>
/// The certificate is not revoked.
/// </summary>
Good,
/// <summary>
/// The certificate has been revoked.
/// </summary>
Revoked,
/// <summary>
/// The revocation status could not be determined.
/// </summary>
Unknown,
/// <summary>
/// Revocation checking is not available.
/// </summary>
Unavailable
}
/// <summary>
/// Source of revocation information.
/// </summary>
public enum RevocationSource
{
/// <summary>
/// No revocation source was used.
/// </summary>
None,
/// <summary>
/// Online OCSP responder.
/// </summary>
Ocsp,
/// <summary>
/// Certificate Revocation List.
/// </summary>
Crl,
/// <summary>
/// Pre-fetched (stapled) OCSP response.
/// </summary>
OcspStapled,
/// <summary>
/// Cached CRL.
/// </summary>
CrlCached,
/// <summary>
/// Delta CRL applied to base CRL.
/// </summary>
DeltaCrl
}
/// <summary>
/// Certificate revocation reason codes (RFC 5280).
/// </summary>
public enum RevocationReason
{
/// <summary>
/// No reason specified.
/// </summary>
Unspecified = 0,
/// <summary>
/// The private key was compromised.
/// </summary>
KeyCompromise = 1,
/// <summary>
/// The CA's private key was compromised.
/// </summary>
CaCompromise = 2,
/// <summary>
/// The certificate holder's affiliation changed.
/// </summary>
AffiliationChanged = 3,
/// <summary>
/// The certificate has been superseded.
/// </summary>
Superseded = 4,
/// <summary>
/// The certificate is no longer needed.
/// </summary>
CessationOfOperation = 5,
/// <summary>
/// The certificate is on hold (may be unrevoked).
/// </summary>
CertificateHold = 6,
/// <summary>
/// Removed from CRL (used in delta CRLs).
/// </summary>
RemoveFromCrl = 8,
/// <summary>
/// Privilege withdrawn.
/// </summary>
PrivilegeWithdrawn = 9,
/// <summary>
/// The AA's key was compromised.
/// </summary>
AaCompromise = 10
}

View File

@@ -0,0 +1,132 @@
// -----------------------------------------------------------------------------
// StapledRevocationData.cs
// Sprint: SPRINT_20260119_008 Certificate Status Provider
// Task: CSP-001 - Core Abstractions
// Description: Model for pre-fetched revocation data for offline verification.
// -----------------------------------------------------------------------------
namespace StellaOps.Cryptography.CertificateStatus.Abstractions;
/// <summary>
/// Pre-fetched revocation data for bundling with signatures or evidence.
/// </summary>
public sealed record StapledRevocationData
{
/// <summary>
/// Gets the certificate thumbprint this data is for.
/// </summary>
public required string CertificateThumbprint { get; init; }
/// <summary>
/// Gets the OCSP response if available.
/// </summary>
public ReadOnlyMemory<byte>? OcspResponse { get; init; }
/// <summary>
/// Gets the CRL if available.
/// </summary>
public ReadOnlyMemory<byte>? Crl { get; init; }
/// <summary>
/// Gets when this data was fetched.
/// </summary>
public required DateTimeOffset FetchedAt { get; init; }
/// <summary>
/// Gets when this data expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Gets the OCSP responder URL used.
/// </summary>
public string? OcspResponderUrl { get; init; }
/// <summary>
/// Gets the CRL distribution point URL used.
/// </summary>
public string? CrlDistributionPoint { get; init; }
/// <summary>
/// Gets whether this data includes OCSP.
/// </summary>
public bool HasOcsp => OcspResponse is { Length: > 0 };
/// <summary>
/// Gets whether this data includes CRL.
/// </summary>
public bool HasCrl => Crl is { Length: > 0 };
/// <summary>
/// Gets whether this data is still fresh.
/// </summary>
public bool IsFresh => !ExpiresAt.HasValue || ExpiresAt.Value > DateTimeOffset.UtcNow;
/// <summary>
/// Creates stapled data from an OCSP response.
/// </summary>
public static StapledRevocationData FromOcsp(
string thumbprint,
ReadOnlyMemory<byte> ocspResponse,
string? responderUrl = null,
DateTimeOffset? expiresAt = null) => new()
{
CertificateThumbprint = thumbprint,
OcspResponse = ocspResponse,
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt,
OcspResponderUrl = responderUrl
};
/// <summary>
/// Creates stapled data from a CRL.
/// </summary>
public static StapledRevocationData FromCrl(
string thumbprint,
ReadOnlyMemory<byte> crl,
string? distributionPoint = null,
DateTimeOffset? expiresAt = null) => new()
{
CertificateThumbprint = thumbprint,
Crl = crl,
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt,
CrlDistributionPoint = distributionPoint
};
}
/// <summary>
/// Collection of stapled revocation data for an entire certificate chain.
/// </summary>
public sealed record StapledRevocationBundle
{
/// <summary>
/// Gets the revocation data for each certificate in the chain.
/// </summary>
public required IReadOnlyList<StapledRevocationData> Certificates { get; init; }
/// <summary>
/// Gets when this bundle was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the earliest expiration of any certificate's revocation data.
/// </summary>
public DateTimeOffset? EarliestExpiration =>
Certificates.Where(c => c.ExpiresAt.HasValue)
.OrderBy(c => c.ExpiresAt)
.FirstOrDefault()?.ExpiresAt;
/// <summary>
/// Gets whether all certificates have fresh revocation data.
/// </summary>
public bool IsComplete => Certificates.All(c => c.IsFresh && (c.HasOcsp || c.HasCrl));
/// <summary>
/// Gets the stapled data for a specific certificate thumbprint.
/// </summary>
public StapledRevocationData? GetForCertificate(string thumbprint) =>
Certificates.FirstOrDefault(c =>
c.CertificateThumbprint.Equals(thumbprint, StringComparison.OrdinalIgnoreCase));
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Cryptography.CertificateStatus.Abstractions</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -72,10 +72,9 @@ public static class VerdictInputsSerializer
{
ArgumentNullException.ThrowIfNull(inputs);
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(inputs, JsonOptions);
var canonicalBytes = version != null
? CanonJson.CanonicalizeWithVersion(jsonBytes, version)
: CanonJson.CanonicalizeVersioned(jsonBytes);
? CanonJson.CanonicalizeVersioned(inputs, JsonOptions, version)
: CanonJson.CanonicalizeVersioned(inputs, JsonOptions);
return Encoding.UTF8.GetString(canonicalBytes);
}

View File

@@ -17,4 +17,9 @@
<ProjectReference Include="..\StellaOps.IssuerDirectory.Client\StellaOps.IssuerDirectory.Client.csproj" />
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
</ItemGroup>
<!-- Exclude embedded test files - they should be in a separate test project -->
<ItemGroup>
<Compile Remove="__Tests\**\*.cs" />
</ItemGroup>
</Project>

View File

@@ -22,4 +22,8 @@
<ProjectReference Include="..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
</ItemGroup>
<!-- Exclude embedded test files - they should be in a separate test project -->
<ItemGroup>
<Compile Remove="__Tests\**\*.cs" />
</ItemGroup>
</Project>