feat(batch9): implement f2 ocsp monitor and handler core

This commit is contained in:
Joseph Doherty
2026-02-28 12:20:07 -05:00
parent 78d222a86d
commit 8f3e4a5a23
4 changed files with 733 additions and 0 deletions

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

View File

@@ -16,7 +16,9 @@
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
using System.Net.Http;
using System.Text;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
namespace ZB.MOM.NatsNet.Server.Auth.Ocsp;
@@ -71,9 +73,15 @@ internal sealed class OcspStaple
/// </summary>
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 Timer? _timer;
private readonly OcspStaple _staple = new();
private byte[]? _raw;
private OcspResponse? _response;
/// <summary>Path to the TLS certificate file being monitored.</summary>
public string? CertFile { get; set; }
@@ -93,9 +101,229 @@ internal sealed class OcspMonitor
/// <summary>The owning server instance.</summary>
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>
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>
public void Start()
{
@@ -109,7 +337,12 @@ internal sealed class OcspMonitor
lock (_mu)
{
if (!string.IsNullOrEmpty(OcspStapleFile) && File.Exists(OcspStapleFile))
{
_staple.Response = File.ReadAllBytes(OcspStapleFile);
_raw = [.. _staple.Response];
var (response, _) = OcspHandler.ParseOcspResponse(_raw);
_response = response;
}
_staple.NextUpdate = DateTime.UtcNow + CheckInterval;
}
}, null, TimeSpan.Zero, CheckInterval);