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%)**