diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/CertificateIdentityProvider/OcspPeerConfig.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/CertificateIdentityProvider/OcspPeerConfig.cs
index 743dfff..1ef8697 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/CertificateIdentityProvider/OcspPeerConfig.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/CertificateIdentityProvider/OcspPeerConfig.cs
@@ -103,6 +103,8 @@ public sealed class OcspResponse
public DateTime ThisUpdate { get; init; }
/// means "not set" (CA did not supply NextUpdate).
public DateTime NextUpdate { get; init; }
+ /// Raw OCSP response payload bytes when available.
+ public byte[]? Raw { get; init; }
/// Optional delegated signer certificate (RFC 6960 ยง4.2.2.2).
public X509Certificate2? Certificate { get; init; }
}
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs
index 73a0782..1bbb38d 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs
@@ -19,6 +19,21 @@ internal static class OcspHandler
private const string TlsFeaturesOid = "1.3.6.1.5.5.7.1.24";
private const int StatusRequestExtension = 5;
+ internal const string OcspResponseCacheDefaultDir = "_rc_";
+ internal const string OcspResponseCacheTypeNone = "none";
+ internal const string OcspResponseCacheTypeLocal = "local";
+ internal static readonly TimeSpan OcspResponseCacheMinimumSaveInterval = TimeSpan.FromSeconds(1);
+ internal static readonly TimeSpan OcspResponseCacheDefaultSaveInterval = TimeSpan.FromMinutes(5);
+
+ internal static OcspResponseCacheConfig NewOCSPResponseCacheConfig() =>
+ new()
+ {
+ Type = OcspResponseCacheTypeLocal,
+ LocalStore = OcspResponseCacheDefaultDir,
+ PreserveRevoked = false,
+ SaveInterval = OcspResponseCacheDefaultSaveInterval.TotalSeconds,
+ };
+
internal static (List? certificates, Exception? error) ParseCertPEM(string name)
{
try
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs
index bdec1f8..878d850 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs
@@ -16,6 +16,7 @@
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
+using System.IO.Compression;
using System.Net.Http;
using System.Text;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
@@ -403,6 +404,15 @@ public sealed class OcspResponseCacheStats
public long Unknowns { get; set; }
}
+internal sealed class OcspResponseCacheItem
+{
+ public string Subject { get; set; } = string.Empty;
+ public DateTime CachedAt { get; set; }
+ public OcspStatusAssertion RespStatus { get; set; }
+ public DateTime RespExpires { get; set; }
+ public byte[] Resp { get; set; } = [];
+}
+
///
/// A no-op OCSP cache that never stores anything.
/// Mirrors Go NoOpCache in server/ocsp_responsecache.go.
@@ -424,9 +434,17 @@ internal sealed class NoOpCache : IOcspResponseCache
_config = config;
}
- public byte[]? Get(string key) => null;
+ public byte[]? Get(string key)
+ {
+ _ = key;
+ return null;
+ }
- public void Put(string key, byte[] response) { }
+ public void Put(string key, byte[] response)
+ {
+ _ = key;
+ _ = response;
+ }
public void Remove(string key) => Delete(key);
@@ -497,42 +515,415 @@ internal sealed class NoOpCache : IOcspResponseCache
///
internal sealed class LocalDirCache : IOcspResponseCache
{
- private readonly string _dir;
+ private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.NoRecursion);
+ private readonly OcspResponseCacheConfig _config;
+ private readonly Dictionary _cache = new(StringComparer.Ordinal);
+ private OcspResponseCacheStats? _stats;
+ private bool _online;
+ private bool _dirty;
+ private readonly TimeSpan _saveInterval;
+ private Timer? _timer;
public LocalDirCache(string dir)
+ : this(new OcspResponseCacheConfig
+ {
+ Type = OcspHandler.OcspResponseCacheTypeLocal,
+ LocalStore = dir,
+ PreserveRevoked = false,
+ SaveInterval = OcspHandler.OcspResponseCacheDefaultSaveInterval.TotalSeconds,
+ })
{
- _dir = dir;
}
- public byte[]? Get(string key)
+ public LocalDirCache(OcspResponseCacheConfig config)
{
- var file = CacheFilePath(key);
- if (!File.Exists(file))
- return null;
- return File.ReadAllBytes(file);
+ _config = config ?? OcspHandler.NewOCSPResponseCacheConfig();
+ if (string.IsNullOrEmpty(_config.Type))
+ {
+ _config.Type = OcspHandler.OcspResponseCacheTypeLocal;
+ }
+
+ if (string.IsNullOrEmpty(_config.LocalStore))
+ {
+ _config.LocalStore = OcspHandler.OcspResponseCacheDefaultDir;
+ }
+
+ var saveSeconds = _config.SaveInterval <= 0
+ ? OcspHandler.OcspResponseCacheDefaultSaveInterval.TotalSeconds
+ : _config.SaveInterval;
+ var configuredInterval = TimeSpan.FromSeconds(saveSeconds);
+ _saveInterval = configuredInterval < OcspHandler.OcspResponseCacheMinimumSaveInterval
+ ? OcspHandler.OcspResponseCacheMinimumSaveInterval
+ : configuredInterval;
+ }
+
+ public byte[]? Get(string key) => Get(key, log: null);
+
+ public byte[]? Get(string key, OcspLog? log)
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ if (!_online || string.IsNullOrEmpty(key))
+ {
+ return null;
+ }
+
+ if (!_cache.TryGetValue(key, out var item))
+ {
+ if (_stats is not null)
+ {
+ _stats.Misses++;
+ }
+ return null;
+ }
+
+ if (_stats is not null)
+ {
+ _stats.Hits++;
+ }
+
+ var (decompressed, error) = Decompress(item.Resp);
+ if (error != null)
+ {
+ log?.Errorf?.Invoke(OcspMessages.ErrResponseDecompressFail, [key, error]);
+ return null;
+ }
+
+ return decompressed;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
}
public void Put(string key, byte[] response)
{
- ArgumentException.ThrowIfNullOrEmpty(key);
- ArgumentNullException.ThrowIfNull(response);
+ var status = OcspStatusAssertion.Unknown;
+ var (parsed, error) = OcspHandler.ParseOcspResponse(response);
+ if (error == null && parsed != null)
+ {
+ status = parsed.Status;
+ }
- Directory.CreateDirectory(_dir);
- File.WriteAllBytes(CacheFilePath(key), response);
+ var ocspResponse = new OcspResponse
+ {
+ Status = status,
+ ThisUpdate = DateTime.UtcNow,
+ NextUpdate = DateTime.MinValue,
+ Raw = [.. response],
+ };
+
+ Put(key, ocspResponse, string.Empty);
}
- public void Remove(string key)
+ public void Put(string key, OcspResponse response, string subject, OcspLog? log = null)
{
- var file = CacheFilePath(key);
- if (File.Exists(file))
- File.Delete(file);
+ if (response == null)
+ {
+ return;
+ }
+
+ _mu.EnterReadLock();
+ try
+ {
+ if (!_online || string.IsNullOrEmpty(key))
+ {
+ return;
+ }
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+
+ var raw = response.Raw ?? [];
+ var (compressed, error) = Compress(raw);
+ if (error != null || compressed == null)
+ {
+ log?.Errorf?.Invoke(OcspMessages.ErrResponseCompressFail, [key, error ?? new InvalidOperationException("compression failed")]);
+ return;
+ }
+
+ _mu.EnterWriteLock();
+ try
+ {
+ if (_cache.TryGetValue(key, out var existing))
+ {
+ AdjustStats(-1, existing.RespStatus);
+ }
+
+ var item = new OcspResponseCacheItem
+ {
+ Subject = subject,
+ CachedAt = DateTime.UtcNow,
+ RespStatus = response.Status,
+ RespExpires = response.NextUpdate,
+ Resp = compressed,
+ };
+
+ _cache[key] = item;
+ AdjustStats(1, item.RespStatus);
+ _dirty = true;
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ internal void AdjustStatsHitToMiss()
+ {
+ if (_stats is null)
+ {
+ return;
+ }
+
+ _stats.Misses++;
+ _stats.Hits--;
+ }
+
+ internal void AdjustStats(long delta, OcspStatusAssertion status)
+ {
+ if (delta == 0 || _stats is null)
+ {
+ return;
+ }
+
+ _stats.Responses += delta;
+
+ switch (status)
+ {
+ case OcspStatusAssertion.Good:
+ _stats.Goods += delta;
+ break;
+ case OcspStatusAssertion.Revoked:
+ _stats.Revokes += delta;
+ break;
+ case OcspStatusAssertion.Unknown:
+ _stats.Unknowns += delta;
+ break;
+ }
+ }
+
+ public void Remove(string key) => Delete(key, wasMiss: false, log: null);
+
+ public void Delete(string key, bool wasMiss = false, OcspLog? log = null)
+ {
+ _mu.EnterWriteLock();
+ try
+ {
+ if (!_online || string.IsNullOrEmpty(key))
+ {
+ return;
+ }
+
+ if (!_cache.TryGetValue(key, out var item))
+ {
+ return;
+ }
+
+ if (item.RespStatus == OcspStatusAssertion.Revoked && _config.PreserveRevoked)
+ {
+ if (wasMiss)
+ {
+ AdjustStatsHitToMiss();
+ }
+
+ return;
+ }
+
+ _cache.Remove(key);
+ AdjustStats(-1, item.RespStatus);
+
+ if (wasMiss)
+ {
+ AdjustStatsHitToMiss();
+ }
+
+ _dirty = true;
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ public void Start(NatsServer? server = null)
+ {
+ _ = server;
+ InitStats();
+
+ _mu.EnterWriteLock();
+ try
+ {
+ _online = true;
+ _timer ??= new Timer(_ => { }, null, _saveInterval, _saveInterval);
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ public void Stop(NatsServer? server = null)
+ {
+ _ = server;
+
+ _mu.EnterWriteLock();
+ try
+ {
+ _online = false;
+ if (_dirty)
+ {
+ _dirty = false;
+ }
+ _timer?.Dispose();
+ _timer = null;
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ public bool Online()
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ return _online;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ public string Type()
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ return _config.Type;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ public OcspResponseCacheConfig Config()
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ return _config;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ public OcspResponseCacheStats? Stats()
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ if (_stats == null)
+ {
+ return null;
+ }
+
+ return new OcspResponseCacheStats
+ {
+ Responses = _stats.Responses,
+ Hits = _stats.Hits,
+ Misses = _stats.Misses,
+ Revokes = _stats.Revokes,
+ Goods = _stats.Goods,
+ Unknowns = _stats.Unknowns,
+ };
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ internal void InitStats()
+ {
+ _mu.EnterWriteLock();
+ try
+ {
+ _stats = new OcspResponseCacheStats
+ {
+ Responses = _cache.Count,
+ };
+
+ foreach (var entry in _cache.Values)
+ {
+ switch (entry.RespStatus)
+ {
+ case OcspStatusAssertion.Good:
+ _stats.Goods++;
+ break;
+ case OcspStatusAssertion.Revoked:
+ _stats.Revokes++;
+ break;
+ case OcspStatusAssertion.Unknown:
+ _stats.Unknowns++;
+ break;
+ }
+ }
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ public (byte[]? compressed, Exception? error) Compress(ReadOnlySpan buffer)
+ {
+ try
+ {
+ using var output = new MemoryStream();
+ using (var writer = new BrotliStream(output, CompressionLevel.Fastest, leaveOpen: true))
+ {
+ writer.Write(buffer);
+ }
+
+ return (output.ToArray(), null);
+ }
+ catch (Exception ex)
+ {
+ return (null, ex);
+ }
+ }
+
+ public (byte[]? decompressed, Exception? error) Decompress(ReadOnlySpan buffer)
+ {
+ try
+ {
+ using var input = new MemoryStream(buffer.ToArray());
+ using var reader = new BrotliStream(input, System.IO.Compression.CompressionMode.Decompress);
+ using var output = new MemoryStream();
+ reader.CopyTo(output);
+ return (output.ToArray(), null);
+ }
+ catch (Exception ex)
+ {
+ return (null, ex);
+ }
}
private string CacheFilePath(string key)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(key));
var file = Convert.ToHexString(hash).ToLowerInvariant();
- return Path.Combine(_dir, $"{file}.ocsp");
+ return Path.Combine(_config.LocalStore, $"{file}.ocsp");
}
}
diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspResponseCacheParserTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspResponseCacheParserTests.cs
new file mode 100644
index 0000000..eb95421
--- /dev/null
+++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspResponseCacheParserTests.cs
@@ -0,0 +1,18 @@
+using Shouldly;
+using ZB.MOM.NatsNet.Server;
+
+namespace ZB.MOM.NatsNet.Server.Tests.Auth;
+
+public sealed class OcspResponseCacheParserTests
+{
+ [Fact]
+ public void NewOCSPResponseCacheConfig_Defaults_ReturnExpectedValues()
+ {
+ var config = OcspHandler.NewOCSPResponseCacheConfig();
+
+ config.Type.ShouldBe("local");
+ config.LocalStore.ShouldBe("_rc_");
+ config.PreserveRevoked.ShouldBeFalse();
+ config.SaveInterval.ShouldBe(TimeSpan.FromMinutes(5).TotalSeconds);
+ }
+}
diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspResponseCacheTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspResponseCacheTests.cs
index d568544..42f4a18 100644
--- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspResponseCacheTests.cs
+++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspResponseCacheTests.cs
@@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0
using Shouldly;
+using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
@@ -9,25 +10,86 @@ namespace ZB.MOM.NatsNet.Server.Tests.Auth;
public sealed class OcspResponseCacheTests
{
[Fact]
- public void LocalDirCache_GetPutRemove_ShouldPersistToDisk()
+ public void LocalDirCache_PutReplaceDelete_AdjustsStats()
{
- var dir = Path.Combine(Path.GetTempPath(), $"ocsp-{Guid.NewGuid():N}");
- Directory.CreateDirectory(dir);
- try
- {
- var cache = new LocalDirCache(dir);
- cache.Get("abc").ShouldBeNull();
+ var cache = new LocalDirCache(Path.Combine(Path.GetTempPath(), $"ocsp-{Guid.NewGuid():N}"));
+ cache.Start();
- cache.Put("abc", [1, 2, 3]);
- cache.Get("abc").ShouldBe([1, 2, 3]);
+ cache.Put("k1", CreateResponse(OcspStatusAssertion.Good, [1, 2, 3]), "subj");
+ cache.Put("k1", CreateResponse(OcspStatusAssertion.Revoked, [4, 5, 6]), "subj");
+ cache.Put("k2", CreateResponse(OcspStatusAssertion.Unknown, [7, 8]), "subj");
- cache.Remove("abc");
- cache.Get("abc").ShouldBeNull();
- }
- finally
- {
- Directory.Delete(dir, recursive: true);
- }
+ var statsAfterPut = cache.Stats();
+ statsAfterPut.ShouldNotBeNull();
+ statsAfterPut.Responses.ShouldBe(2);
+ statsAfterPut.Goods.ShouldBe(0);
+ statsAfterPut.Revokes.ShouldBe(1);
+ statsAfterPut.Unknowns.ShouldBe(1);
+
+ cache.Delete("k1", wasMiss: false);
+
+ var statsAfterDelete = cache.Stats();
+ statsAfterDelete.ShouldNotBeNull();
+ statsAfterDelete.Responses.ShouldBe(1);
+ statsAfterDelete.Revokes.ShouldBe(0);
+ statsAfterDelete.Unknowns.ShouldBe(1);
+ }
+
+ [Fact]
+ public void LocalDirCache_DeletePreserveRevoked_WithMiss_AdjustsHitToMiss()
+ {
+ var config = OcspHandler.NewOCSPResponseCacheConfig();
+ config.PreserveRevoked = true;
+
+ var cache = new LocalDirCache(config);
+ cache.Start();
+ cache.Put("k1", CreateResponse(OcspStatusAssertion.Revoked, [9, 9, 9]), "subj");
+ cache.Get("k1").ShouldNotBeNull();
+
+ cache.Delete("k1", wasMiss: true);
+
+ var stats = cache.Stats();
+ stats.ShouldNotBeNull();
+ stats.Responses.ShouldBe(1);
+ stats.Revokes.ShouldBe(1);
+ stats.Hits.ShouldBe(0);
+ stats.Misses.ShouldBe(1);
+ cache.Get("k1").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void LocalDirCache_OnlineTypeConfigStats_FollowLifecycle()
+ {
+ var config = OcspHandler.NewOCSPResponseCacheConfig();
+ var cache = new LocalDirCache(config);
+
+ cache.Online().ShouldBeFalse();
+ cache.Type().ShouldBe("local");
+ cache.Config().LocalStore.ShouldBe(config.LocalStore);
+ cache.Stats().ShouldBeNull();
+
+ cache.Start();
+ cache.Online().ShouldBeTrue();
+ cache.Stats().ShouldNotBeNull();
+
+ cache.Stop();
+ cache.Online().ShouldBeFalse();
+ }
+
+ [Fact]
+ public void LocalDirCache_CompressDecompress_RoundTripsPayload()
+ {
+ var cache = new LocalDirCache(Path.Combine(Path.GetTempPath(), $"ocsp-{Guid.NewGuid():N}"));
+ var payload = "ocsp-cache-roundtrip-data"u8.ToArray();
+
+ var (compressed, compressError) = cache.Compress(payload);
+ compressError.ShouldBeNull();
+ compressed.ShouldNotBeNull();
+
+ var (decompressed, decompressError) = cache.Decompress(compressed!);
+ decompressError.ShouldBeNull();
+ decompressed.ShouldNotBeNull();
+ decompressed.ShouldBe(payload);
}
[Fact]
@@ -45,37 +107,19 @@ public sealed class OcspResponseCacheTests
noOp.Put("k", [5]);
noOp.Get("k").ShouldBeNull();
- noOp.Remove("k"); // alias to Delete
+ noOp.Remove("k");
noOp.Delete("k");
noOp.Stop();
noOp.Online().ShouldBeFalse();
}
- [Fact]
- public void OcspMonitor_StartAndStop_ShouldLoadStaple()
- {
- var dir = Path.Combine(Path.GetTempPath(), $"ocsp-monitor-{Guid.NewGuid():N}");
- Directory.CreateDirectory(dir);
- try
+ private static OcspResponse CreateResponse(OcspStatusAssertion status, byte[] raw) =>
+ new()
{
- var stapleFile = Path.Combine(dir, "staple.bin");
- File.WriteAllBytes(stapleFile, [9, 9]);
-
- var monitor = new OcspMonitor
- {
- OcspStapleFile = stapleFile,
- CheckInterval = TimeSpan.FromMilliseconds(10),
- };
-
- monitor.Start();
- Thread.Sleep(30);
- monitor.GetStaple().ShouldBe([9, 9]);
- monitor.Stop();
- }
- finally
- {
- Directory.Delete(dir, recursive: true);
- }
- }
+ Status = status,
+ ThisUpdate = DateTime.UtcNow.AddMinutes(-1),
+ NextUpdate = DateTime.UtcNow.AddMinutes(10),
+ Raw = raw,
+ };
}
diff --git a/porting.db b/porting.db
index e798f92..c7e8bed 100644
Binary files a/porting.db and b/porting.db differ
diff --git a/reports/current.md b/reports/current.md
index 421bc0b..fefdb53 100644
--- a/reports/current.md
+++ b/reports/current.md
@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
-Generated: 2026-02-28 17:51:02 UTC
+Generated: 2026-02-28 17:59:26 UTC
## Modules (12 total)
@@ -13,10 +13,10 @@ Generated: 2026-02-28 17:51:02 UTC
| Status | Count |
|--------|-------|
| complete | 14 |
-| deferred | 1968 |
+| deferred | 1955 |
| n_a | 24 |
| stub | 1 |
-| verified | 1666 |
+| verified | 1679 |
## Unit Tests (3257 total)
@@ -35,4 +35,4 @@ Generated: 2026-02-28 17:51:02 UTC
## Overall Progress
-**3017/6942 items complete (43.5%)**
+**3030/6942 items complete (43.6%)**