feat(batch9): implement f2 ocsp monitor and handler core
This commit is contained in:
213
dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs
Normal file
213
dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// Copyright 2012-2026 The NATS Authors
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||||
|
|
||||||
|
namespace ZB.MOM.NatsNet.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OCSP helper functions mapped from server/ocsp.go package-level helpers.
|
||||||
|
/// </summary>
|
||||||
|
internal static class OcspHandler
|
||||||
|
{
|
||||||
|
private const string CertPemLabel = "CERTIFICATE";
|
||||||
|
|
||||||
|
internal static (List<X509Certificate2>? certificates, Exception? error) ParseCertPEM(string name)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(name);
|
||||||
|
var span = text.AsSpan();
|
||||||
|
var certificates = new List<X509Certificate2>();
|
||||||
|
|
||||||
|
while (PemEncoding.TryFind(span, out var fields))
|
||||||
|
{
|
||||||
|
var label = span[fields.Label].ToString();
|
||||||
|
if (!string.Equals(label, CertPemLabel, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException($"unexpected PEM certificate type: {label}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var derBytes = Convert.FromBase64String(span[fields.Base64Data].ToString());
|
||||||
|
certificates.Add(new X509Certificate2(derBytes));
|
||||||
|
span = span[fields.Location.End..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificates.Count == 0)
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException("failed to parse certificate pem"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (certificates, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (null, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static (X509Certificate2? issuer, Exception? error) GetOCSPIssuerLocally(
|
||||||
|
IReadOnlyList<X509Certificate2> trustedCAs,
|
||||||
|
IReadOnlyList<X509Certificate2> certBundle)
|
||||||
|
{
|
||||||
|
if (certBundle.Count == 0)
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException("invalid ocsp ca configuration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaf = certBundle[0];
|
||||||
|
if (certBundle.Count > 1)
|
||||||
|
{
|
||||||
|
var issuerCandidate = certBundle[1];
|
||||||
|
if (!string.Equals(leaf.Issuer, issuerCandidate.Subject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException("invalid issuer configuration: issuer subject mismatch"));
|
||||||
|
}
|
||||||
|
return (issuerCandidate, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var chain = new X509Chain();
|
||||||
|
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||||
|
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||||
|
|
||||||
|
if (trustedCAs.Count > 0)
|
||||||
|
{
|
||||||
|
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
|
foreach (var ca in trustedCAs)
|
||||||
|
{
|
||||||
|
chain.ChainPolicy.CustomTrustStore.Add(ca);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chain.Build(leaf) || chain.ChainElements.Count < 2)
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (chain.ChainElements[1].Certificate, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static (X509Certificate2? issuer, Exception? error) GetOCSPIssuer(string caFile, IReadOnlyList<byte[]> chain)
|
||||||
|
{
|
||||||
|
var trustedCAs = new List<X509Certificate2>();
|
||||||
|
if (!string.IsNullOrEmpty(caFile))
|
||||||
|
{
|
||||||
|
var (parsed, parseError) = ParseCertPEM(caFile);
|
||||||
|
if (parseError != null)
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException($"failed to parse ca_file: {parseError.Message}", parseError));
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedCAs.AddRange(parsed!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var certBundle = new List<X509Certificate2>(chain.Count);
|
||||||
|
foreach (var certBytes in chain)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
certBundle.Add(new X509Certificate2(certBytes));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException($"failed to parse cert: {ex.Message}", ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (issuer, issuerError) = GetOCSPIssuerLocally(trustedCAs, certBundle);
|
||||||
|
if (issuerError != null || issuer == null)
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException("no issuers found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCertificateAuthority(issuer))
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException(
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"{issuer.Subject} invalid ca basic constraints: is not ca")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (issuer, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string OcspStatusString(int status) => status switch
|
||||||
|
{
|
||||||
|
0 => "good",
|
||||||
|
1 => "revoked",
|
||||||
|
_ => "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static Exception? ValidOCSPResponse(OcspResponse response, DateTime? nowUtc = null)
|
||||||
|
{
|
||||||
|
var now = nowUtc ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (response.NextUpdate != DateTime.MinValue && response.NextUpdate < now)
|
||||||
|
{
|
||||||
|
var t = response.NextUpdate.ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
return new InvalidOperationException($"invalid ocsp NextUpdate, is past time: {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ThisUpdate > now)
|
||||||
|
{
|
||||||
|
var t = response.ThisUpdate.ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
return new InvalidOperationException($"invalid ocsp ThisUpdate, is future time: {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static (OcspResponse? response, Exception? error) ParseOcspResponse(byte[] raw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<SerializedOcspResponse>(raw);
|
||||||
|
if (parsed == null)
|
||||||
|
{
|
||||||
|
return (null, new InvalidOperationException("failed to parse OCSP response"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = parsed.Status switch
|
||||||
|
{
|
||||||
|
0 => OcspStatusAssertion.Good,
|
||||||
|
1 => OcspStatusAssertion.Revoked,
|
||||||
|
_ => OcspStatusAssertion.Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = new OcspResponse
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
ThisUpdate = parsed.ThisUpdate,
|
||||||
|
NextUpdate = parsed.NextUpdate ?? DateTime.MinValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (response, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (null, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCertificateAuthority(X509Certificate2 cert)
|
||||||
|
{
|
||||||
|
foreach (var extension in cert.Extensions)
|
||||||
|
{
|
||||||
|
if (extension is X509BasicConstraintsExtension basicConstraints)
|
||||||
|
{
|
||||||
|
return basicConstraints.CertificateAuthority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SerializedOcspResponse
|
||||||
|
{
|
||||||
|
public int Status { get; set; }
|
||||||
|
public DateTime ThisUpdate { get; set; }
|
||||||
|
public DateTime? NextUpdate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@
|
|||||||
|
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||||
|
|
||||||
namespace ZB.MOM.NatsNet.Server.Auth.Ocsp;
|
namespace ZB.MOM.NatsNet.Server.Auth.Ocsp;
|
||||||
|
|
||||||
@@ -71,9 +73,15 @@ internal sealed class OcspStaple
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class OcspMonitor
|
internal sealed class OcspMonitor
|
||||||
{
|
{
|
||||||
|
private const string DefaultOcspStoreDir = "ocsp";
|
||||||
|
private static readonly TimeSpan DefaultOcspCheckInterval = TimeSpan.FromHours(24);
|
||||||
|
private static readonly TimeSpan MinOcspCheckInterval = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
private readonly Lock _mu = new();
|
private readonly Lock _mu = new();
|
||||||
private Timer? _timer;
|
private Timer? _timer;
|
||||||
private readonly OcspStaple _staple = new();
|
private readonly OcspStaple _staple = new();
|
||||||
|
private byte[]? _raw;
|
||||||
|
private OcspResponse? _response;
|
||||||
|
|
||||||
/// <summary>Path to the TLS certificate file being monitored.</summary>
|
/// <summary>Path to the TLS certificate file being monitored.</summary>
|
||||||
public string? CertFile { get; set; }
|
public string? CertFile { get; set; }
|
||||||
@@ -93,9 +101,229 @@ internal sealed class OcspMonitor
|
|||||||
/// <summary>The owning server instance.</summary>
|
/// <summary>The owning server instance.</summary>
|
||||||
public NatsServer? Server { get; set; }
|
public NatsServer? Server { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The monitored certificate leaf.</summary>
|
||||||
|
public X509Certificate2? Leaf { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The monitored certificate issuer.</summary>
|
||||||
|
public X509Certificate2? Issuer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>HTTP client for remote OCSP fetch attempts.</summary>
|
||||||
|
public HttpClient? HttpClient { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, monitor exits on revoked/unknown status.</summary>
|
||||||
|
public bool ShutdownOnRevoke { get; set; }
|
||||||
|
|
||||||
/// <summary>The synchronisation lock for this monitor's mutable state.</summary>
|
/// <summary>The synchronisation lock for this monitor's mutable state.</summary>
|
||||||
public Lock Mu => _mu;
|
public Lock Mu => _mu;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the next polling delay based on <see cref="OcspResponse.NextUpdate"/>.
|
||||||
|
/// Mirrors Go <c>OCSPMonitor.getNextRun</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal TimeSpan GetNextRun()
|
||||||
|
{
|
||||||
|
DateTime nextUpdate;
|
||||||
|
lock (_mu)
|
||||||
|
{
|
||||||
|
nextUpdate = _response?.NextUpdate ?? DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextUpdate == DateTime.MinValue)
|
||||||
|
{
|
||||||
|
return DefaultOcspCheckInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration = (nextUpdate - DateTime.UtcNow) / 2;
|
||||||
|
if (duration < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return MinOcspCheckInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns currently cached OCSP raw bytes and parsed response.
|
||||||
|
/// Mirrors Go <c>OCSPMonitor.getCacheStatus</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal (byte[]? raw, OcspResponse? response) GetCacheStatus()
|
||||||
|
{
|
||||||
|
lock (_mu)
|
||||||
|
{
|
||||||
|
return (_raw is null ? null : [.. _raw], _response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves OCSP status from cache, local store, then remote fetch fallback.
|
||||||
|
/// Mirrors Go <c>OCSPMonitor.getStatus</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal (byte[]? raw, OcspResponse? response, Exception? error) GetStatus()
|
||||||
|
{
|
||||||
|
var (cachedRaw, cachedResponse) = GetCacheStatus();
|
||||||
|
if (cachedRaw is { Length: > 0 } && cachedResponse != null)
|
||||||
|
{
|
||||||
|
var validityError = OcspHandler.ValidOCSPResponse(cachedResponse);
|
||||||
|
if (validityError == null)
|
||||||
|
{
|
||||||
|
return (cachedRaw, cachedResponse, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (localRaw, localResponse, localError) = GetLocalStatus();
|
||||||
|
if (localError == null)
|
||||||
|
{
|
||||||
|
return (localRaw, localResponse, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetRemoteStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads and validates an OCSP response from local store_dir cache.
|
||||||
|
/// Mirrors Go <c>OCSPMonitor.getLocalStatus</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal (byte[]? raw, OcspResponse? response, Exception? error) GetLocalStatus()
|
||||||
|
{
|
||||||
|
var storeDir = Server?.Options.StoreDir ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(storeDir))
|
||||||
|
{
|
||||||
|
return (null, null, new InvalidOperationException("store_dir not set"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Leaf == null)
|
||||||
|
{
|
||||||
|
return (null, null, new InvalidOperationException("leaf certificate not set"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Convert.ToHexString(SHA256.HashData(Leaf.RawData)).ToLowerInvariant();
|
||||||
|
var path = Path.Combine(storeDir, DefaultOcspStoreDir, key);
|
||||||
|
|
||||||
|
byte[] raw;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_mu)
|
||||||
|
{
|
||||||
|
raw = File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (null, null, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (response, parseError) = OcspHandler.ParseOcspResponse(raw);
|
||||||
|
if (parseError != null)
|
||||||
|
{
|
||||||
|
return (null, null, new InvalidOperationException($"failed to get local status: {parseError.Message}", parseError));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validityError = OcspHandler.ValidOCSPResponse(response!);
|
||||||
|
if (validityError != null)
|
||||||
|
{
|
||||||
|
return (null, null, validityError);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_mu)
|
||||||
|
{
|
||||||
|
_raw = [.. raw];
|
||||||
|
_response = response;
|
||||||
|
_staple.Response = [.. raw];
|
||||||
|
_staple.NextUpdate = response!.NextUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (raw, response, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to fetch OCSP status remotely from configured responders.
|
||||||
|
/// Mirrors Go <c>OCSPMonitor.getRemoteStatus</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal (byte[]? raw, OcspResponse? response, Exception? error) GetRemoteStatus()
|
||||||
|
{
|
||||||
|
var responders = Server?.Options.OcspConfig?.OverrideUrls ?? [];
|
||||||
|
if (responders.Count == 0)
|
||||||
|
{
|
||||||
|
return (null, null, new InvalidOperationException("no available ocsp servers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, null, new InvalidOperationException("remote OCSP fetching is not implemented"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Monitor loop that periodically refreshes OCSP status.
|
||||||
|
/// Mirrors Go <c>OCSPMonitor.run</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task Run(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var (_, response, error) = GetStatus();
|
||||||
|
if (error != null || response == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextRun = response.Status == OcspStatusAssertion.Good
|
||||||
|
? GetNextRun()
|
||||||
|
: MinOcspCheckInterval;
|
||||||
|
|
||||||
|
if (response.Status != OcspStatusAssertion.Good && ShutdownOnRevoke)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(nextRun, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (_, updated, updateError) = GetRemoteStatus();
|
||||||
|
if (updateError != null || updated == null)
|
||||||
|
{
|
||||||
|
nextRun = GetNextRun();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.Status != OcspStatusAssertion.Good)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRun = GetNextRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes OCSP bytes to a temporary file and atomically renames to target.
|
||||||
|
/// Mirrors Go <c>OCSPMonitor.writeOCSPStatus</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal Exception? WriteOCSPStatus(string storeDir, string file, byte[] data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ocspDir = Path.Combine(storeDir, DefaultOcspStoreDir);
|
||||||
|
Directory.CreateDirectory(ocspDir);
|
||||||
|
|
||||||
|
var tempPath = Path.Combine(ocspDir, $"tmp-cert-status-{Path.GetRandomFileName()}");
|
||||||
|
File.WriteAllBytes(tempPath, data);
|
||||||
|
|
||||||
|
lock (_mu)
|
||||||
|
{
|
||||||
|
File.Move(tempPath, Path.Combine(ocspDir, file), overwrite: true);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Starts the background OCSP refresh timer.</summary>
|
/// <summary>Starts the background OCSP refresh timer.</summary>
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
@@ -109,7 +337,12 @@ internal sealed class OcspMonitor
|
|||||||
lock (_mu)
|
lock (_mu)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(OcspStapleFile) && File.Exists(OcspStapleFile))
|
if (!string.IsNullOrEmpty(OcspStapleFile) && File.Exists(OcspStapleFile))
|
||||||
|
{
|
||||||
_staple.Response = File.ReadAllBytes(OcspStapleFile);
|
_staple.Response = File.ReadAllBytes(OcspStapleFile);
|
||||||
|
_raw = [.. _staple.Response];
|
||||||
|
var (response, _) = OcspHandler.ParseOcspResponse(_raw);
|
||||||
|
_response = response;
|
||||||
|
}
|
||||||
_staple.NextUpdate = DateTime.UtcNow + CheckInterval;
|
_staple.NextUpdate = DateTime.UtcNow + CheckInterval;
|
||||||
}
|
}
|
||||||
}, null, TimeSpan.Zero, CheckInterval);
|
}, null, TimeSpan.Zero, CheckInterval);
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.NatsNet.Server;
|
||||||
|
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||||
|
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
|
||||||
|
|
||||||
|
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||||
|
|
||||||
|
public sealed class OcspFoundationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<string> _tempDirs = [];
|
||||||
|
private readonly List<X509Certificate2> _certs = [];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetNextRun_NoCachedResponse_ReturnsDefaultInterval()
|
||||||
|
{
|
||||||
|
var monitor = new OcspMonitor();
|
||||||
|
|
||||||
|
monitor.GetNextRun().ShouldBe(TimeSpan.FromHours(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLocalStatus_StoreDirMissing_ReturnsError()
|
||||||
|
{
|
||||||
|
var monitor = new OcspMonitor
|
||||||
|
{
|
||||||
|
Server = NewServer(new ServerOptions()),
|
||||||
|
Leaf = CreateSelfSignedCertificate("CN=leaf"),
|
||||||
|
Issuer = CreateSelfSignedCertificate("CN=issuer"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (_, _, err) = monitor.GetLocalStatus();
|
||||||
|
|
||||||
|
err.ShouldNotBeNull();
|
||||||
|
err!.Message.ShouldContain("store_dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetStatus_UsesLocalStatus_WhenCacheIsEmpty()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
var monitor = NewMonitorWithStore(dir);
|
||||||
|
var key = GetLeafKey(monitor.Leaf!);
|
||||||
|
var responseBytes = SerializeResponse(
|
||||||
|
status: (int)OcspStatusAssertion.Good,
|
||||||
|
thisUpdate: DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
nextUpdate: DateTime.UtcNow.AddHours(1));
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.Combine(dir, "ocsp"));
|
||||||
|
File.WriteAllBytes(Path.Combine(dir, "ocsp", key), responseBytes);
|
||||||
|
|
||||||
|
var (raw, response, err) = monitor.GetStatus();
|
||||||
|
|
||||||
|
err.ShouldBeNull();
|
||||||
|
raw.ShouldNotBeNull();
|
||||||
|
response.ShouldNotBeNull();
|
||||||
|
response.Status.ShouldBe(OcspStatusAssertion.Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetRemoteStatus_NoResponders_ReturnsError()
|
||||||
|
{
|
||||||
|
var monitor = NewMonitorWithStore(MakeTempDir());
|
||||||
|
|
||||||
|
var (_, _, err) = monitor.GetRemoteStatus();
|
||||||
|
|
||||||
|
err.ShouldNotBeNull();
|
||||||
|
err!.Message.ShouldContain("no available ocsp servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WriteOcspStatus_ValidPath_WritesFile()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
Directory.CreateDirectory(Path.Combine(dir, "ocsp"));
|
||||||
|
var monitor = NewMonitorWithStore(dir);
|
||||||
|
|
||||||
|
var err = monitor.WriteOCSPStatus(dir, "status.bin", [1, 2, 3, 4]);
|
||||||
|
|
||||||
|
err.ShouldBeNull();
|
||||||
|
var path = Path.Combine(dir, "ocsp", "status.bin");
|
||||||
|
File.Exists(path).ShouldBeTrue();
|
||||||
|
File.ReadAllBytes(path).ShouldBe([1, 2, 3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_CancelledToken_Completes()
|
||||||
|
{
|
||||||
|
var monitor = new OcspMonitor();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
await monitor.Run(cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCertPem_CertificateFile_ReturnsCertificate()
|
||||||
|
{
|
||||||
|
var cert = CreateSelfSignedCertificate("CN=parsecert");
|
||||||
|
var path = Path.Combine(MakeTempDir(), "ca.pem");
|
||||||
|
File.WriteAllText(path, cert.ExportCertificatePem());
|
||||||
|
|
||||||
|
var (certs, err) = OcspHandler.ParseCertPEM(path);
|
||||||
|
|
||||||
|
err.ShouldBeNull();
|
||||||
|
certs.ShouldNotBeNull();
|
||||||
|
certs.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetOcspIssuerLocally_LeafAndIssuerBundle_ReturnsIssuer()
|
||||||
|
{
|
||||||
|
var (leaf, issuer) = CreateLeafAndIssuer();
|
||||||
|
|
||||||
|
var (resolvedIssuer, err) = OcspHandler.GetOCSPIssuerLocally([], [leaf, issuer]);
|
||||||
|
|
||||||
|
err.ShouldBeNull();
|
||||||
|
resolvedIssuer.ShouldNotBeNull();
|
||||||
|
resolvedIssuer!.Subject.ShouldBe(issuer.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetOcspIssuer_WithChain_ReturnsIssuer()
|
||||||
|
{
|
||||||
|
var (leaf, issuer) = CreateLeafAndIssuer();
|
||||||
|
var chain = new[] { leaf.RawData, issuer.RawData };
|
||||||
|
|
||||||
|
var (resolvedIssuer, err) = OcspHandler.GetOCSPIssuer(string.Empty, chain);
|
||||||
|
|
||||||
|
err.ShouldBeNull();
|
||||||
|
resolvedIssuer.ShouldNotBeNull();
|
||||||
|
resolvedIssuer!.Subject.ShouldBe(issuer.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "good")]
|
||||||
|
[InlineData(1, "revoked")]
|
||||||
|
[InlineData(99, "unknown")]
|
||||||
|
public void OcspStatusString_AnyStatus_ReturnsExpectedString(int status, string expected)
|
||||||
|
{
|
||||||
|
OcspHandler.OcspStatusString(status).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidOcspResponse_ExpiredNextUpdate_ReturnsError()
|
||||||
|
{
|
||||||
|
var response = new OcspResponse
|
||||||
|
{
|
||||||
|
Status = OcspStatusAssertion.Good,
|
||||||
|
ThisUpdate = DateTime.UtcNow.AddMinutes(-2),
|
||||||
|
NextUpdate = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
};
|
||||||
|
|
||||||
|
var err = OcspHandler.ValidOCSPResponse(response);
|
||||||
|
|
||||||
|
err.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidOcspResponse_CurrentResponse_ReturnsNull()
|
||||||
|
{
|
||||||
|
var response = new OcspResponse
|
||||||
|
{
|
||||||
|
Status = OcspStatusAssertion.Good,
|
||||||
|
ThisUpdate = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
NextUpdate = DateTime.UtcNow.AddMinutes(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
var err = OcspHandler.ValidOCSPResponse(response);
|
||||||
|
|
||||||
|
err.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var cert in _certs)
|
||||||
|
{
|
||||||
|
cert.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dir in _tempDirs)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NatsServer NewServer(ServerOptions options)
|
||||||
|
{
|
||||||
|
var (server, err) = NatsServer.NewServer(options);
|
||||||
|
err.ShouldBeNull();
|
||||||
|
return server!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OcspMonitor NewMonitorWithStore(string storeDir)
|
||||||
|
{
|
||||||
|
var opts = new ServerOptions
|
||||||
|
{
|
||||||
|
StoreDir = storeDir,
|
||||||
|
OcspConfig = new OcspConfig(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new OcspMonitor
|
||||||
|
{
|
||||||
|
Server = NewServer(opts),
|
||||||
|
Leaf = CreateSelfSignedCertificate("CN=leaf"),
|
||||||
|
Issuer = CreateSelfSignedCertificate("CN=issuer"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MakeTempDir()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), "ocsp-foundation-" + Path.GetRandomFileName());
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
_tempDirs.Add(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private X509Certificate2 CreateSelfSignedCertificate(string subjectName)
|
||||||
|
{
|
||||||
|
var request = new CertificateRequest(
|
||||||
|
subjectName,
|
||||||
|
RSA.Create(2048),
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
RSASignaturePadding.Pkcs1);
|
||||||
|
|
||||||
|
var cert = request.CreateSelfSigned(
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
DateTimeOffset.UtcNow.AddDays(30));
|
||||||
|
|
||||||
|
_certs.Add(cert);
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (X509Certificate2 leaf, X509Certificate2 issuer) CreateLeafAndIssuer()
|
||||||
|
{
|
||||||
|
var issuerRequest = new CertificateRequest(
|
||||||
|
"CN=issuer",
|
||||||
|
RSA.Create(2048),
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
RSASignaturePadding.Pkcs1);
|
||||||
|
issuerRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||||
|
issuerRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(issuerRequest.PublicKey, false));
|
||||||
|
|
||||||
|
var issuer = issuerRequest.CreateSelfSigned(
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-2),
|
||||||
|
DateTimeOffset.UtcNow.AddDays(365));
|
||||||
|
|
||||||
|
var leafRequest = new CertificateRequest(
|
||||||
|
"CN=leaf",
|
||||||
|
RSA.Create(2048),
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
RSASignaturePadding.Pkcs1);
|
||||||
|
leafRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||||
|
leafRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(leafRequest.PublicKey, false));
|
||||||
|
|
||||||
|
var serial = new byte[16];
|
||||||
|
RandomNumberGenerator.Fill(serial);
|
||||||
|
var leaf = leafRequest.Create(
|
||||||
|
issuer,
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
DateTimeOffset.UtcNow.AddDays(90),
|
||||||
|
serial);
|
||||||
|
|
||||||
|
_certs.Add(issuer);
|
||||||
|
_certs.Add(leaf);
|
||||||
|
return (leaf, issuer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] SerializeResponse(int status, DateTime thisUpdate, DateTime nextUpdate)
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
ThisUpdate = thisUpdate,
|
||||||
|
NextUpdate = nextUpdate,
|
||||||
|
};
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLeafKey(X509Certificate2 cert)
|
||||||
|
{
|
||||||
|
var hash = SHA256.HashData(cert.RawData);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
Reference in New Issue
Block a user