feat(batch9): implement f2 ocsp monitor and handler core
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user