From 78d222a86d5fd498989b6275434c9cbe76bf342e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 12:12:50 -0500 Subject: [PATCH] feat(batch9): implement f1 auth and dirstore foundations --- .../Accounts/DirJwtStore.cs | 127 +++++++++++++++--- .../ZB.MOM.NatsNet.Server/ClientConnection.cs | 42 ++++++ .../NatsServer.Reload.cs | 22 +-- .../Accounts/DirectoryStoreTests.cs | 43 ++++++ .../ServerTests.cs | 16 +++ porting.db | Bin 6524928 -> 6529024 bytes 6 files changed, 212 insertions(+), 38 deletions(-) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs index ad045e4..127a3c3 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs @@ -833,6 +833,58 @@ public sealed class DirJwtStore : IDisposable // Private static helpers // --------------------------------------------------------------------------- + /// + /// Validates the supplied path exists, and enforces whether it must be a + /// directory or regular file. + /// Mirrors Go validatePathExists. + /// + internal static string ValidatePathExists(string path, bool dir) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("path is not specified", nameof(path)); + } + + string absolutePath; + try + { + absolutePath = Path.GetFullPath(path); + } + catch (Exception ex) + { + throw new InvalidOperationException($"error parsing path [{path}]: {ex.Message}", ex); + } + + if (!File.Exists(absolutePath) && !Directory.Exists(absolutePath)) + { + throw new InvalidOperationException($"the path [{absolutePath}] doesn't exist"); + } + + var attributes = File.GetAttributes(absolutePath); + var isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory; + + if (dir && !isDirectory) + { + throw new InvalidOperationException($"the path [{absolutePath}] is not a directory"); + } + + if (!dir && isDirectory) + { + throw new InvalidOperationException($"the path [{absolutePath}] is not a file"); + } + + return absolutePath; + } + + /// + /// Validates the supplied path exists and is a directory. + /// Mirrors Go validateDirPath. + /// + internal static string ValidateDirPath(string path) + { + return ValidatePathExists(path, dir: true); + } + /// /// Validates that exists and is a directory, optionally /// creating it when is true. @@ -841,31 +893,18 @@ public sealed class DirJwtStore : IDisposable /// private static string NewDir(string dirPath, bool create) { - if (string.IsNullOrEmpty(dirPath)) + if (Directory.Exists(dirPath) || File.Exists(dirPath)) { - throw new ArgumentException("Path is not specified", nameof(dirPath)); - } - - if (Directory.Exists(dirPath)) - { - return Path.GetFullPath(dirPath); + return ValidateDirPath(dirPath); } if (!create) { - throw new DirectoryNotFoundException( - $"The path [{dirPath}] doesn't exist"); + return ValidateDirPath(dirPath); } Directory.CreateDirectory(dirPath); - - if (!Directory.Exists(dirPath)) - { - throw new DirectoryNotFoundException( - $"Failed to create directory [{dirPath}]"); - } - - return Path.GetFullPath(dirPath); + return ValidateDirPath(dirPath); } /// @@ -1044,6 +1083,7 @@ internal sealed class ExpirationTracker { // Min-heap ordered by expiration (Unix nanoseconds stored as ticks for TimeSpan compatibility). private readonly PriorityQueue _heap; + private readonly List _compatHeap; // Index from publicKey to JwtItem for O(1) lookup and hash tracking. private readonly Dictionary _idx; @@ -1068,6 +1108,7 @@ internal sealed class ExpirationTracker EvictOnLimit = evictOnLimit; Ttl = ttl; _heap = new PriorityQueue(); + _compatHeap = []; _idx = new Dictionary(StringComparer.Ordinal); _lru = new LinkedList(); _hash = new byte[SHA256.HashSizeInBytes]; @@ -1075,6 +1116,55 @@ internal sealed class ExpirationTracker internal void SetTimer(Timer timer) => _timer = timer; + /// Returns the number of items in the compatibility heap. + /// Mirrors Go expirationTracker.Len. + internal int Len() => _compatHeap.Count; + + /// Returns true when item expires before . + /// Mirrors Go expirationTracker.Less. + internal bool Less(int i, int j) + { + return _compatHeap[i].Expiration < _compatHeap[j].Expiration; + } + + /// Swaps two compatibility heap items and updates their indexes. + /// Mirrors Go expirationTracker.Swap. + internal void Swap(int i, int j) + { + (_compatHeap[i], _compatHeap[j]) = (_compatHeap[j], _compatHeap[i]); + _compatHeap[i].Index = i; + _compatHeap[j].Index = j; + } + + /// Adds an item to the compatibility heap and index maps. + /// Mirrors Go expirationTracker.Push. + internal void Push(JwtItem item) + { + item.Index = _compatHeap.Count; + _compatHeap.Add(item); + _idx[item.PublicKey] = item; + _lru.AddLast(item.PublicKey); + } + + /// Removes and returns the last compatibility heap item. + /// Mirrors Go expirationTracker.Pop. + internal JwtItem Pop() + { + var n = _compatHeap.Count; + var item = _compatHeap[n - 1]; + _compatHeap.RemoveAt(n - 1); + item.Index = -1; + + var node = _lru.Find(item.PublicKey); + if (node != null) + { + _lru.Remove(node); + } + + _idx.Remove(item.PublicKey); + return item; + } + /// /// Adds or updates tracking for . /// When an entry already exists its expiration and hash are updated. @@ -1259,6 +1349,7 @@ internal sealed class ExpirationTracker internal void Reset() { _heap.Clear(); + _compatHeap.Clear(); _idx.Clear(); _lru.Clear(); Array.Clear(_hash); @@ -1352,6 +1443,7 @@ internal sealed class ExpirationTracker /// internal sealed class JwtItem { + internal int Index { get; set; } internal string PublicKey { get; } internal long Expiration { get; set; } internal byte[] Hash { get; set; } @@ -1367,6 +1459,7 @@ internal sealed class JwtItem internal JwtItem(string publicKey, long expiration, byte[] hash) { + Index = -1; PublicKey = publicKey; Expiration = expiration; Hash = hash; diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs index 7f526d9..2faba27 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs @@ -17,6 +17,7 @@ using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; @@ -875,6 +876,47 @@ public sealed partial class ClientConnection } } + /// + /// Returns true when the current TLS peer certificate matches one of the pinned + /// SPKI SHA-256 key identifiers. + /// Mirrors Go client.matchesPinnedCert. + /// + internal bool MatchesPinnedCert(PinnedCertSet? tlsPinnedCerts) + { + if (tlsPinnedCerts == null) + { + return true; + } + + var certificate = GetTlsCertificate(); + if (certificate == null) + { + Debugf("Failed pinned cert test as client did not provide a certificate"); + return false; + } + + byte[] subjectPublicKeyInfo; + try + { + subjectPublicKeyInfo = certificate.PublicKey.ExportSubjectPublicKeyInfo(); + } + catch + { + subjectPublicKeyInfo = certificate.GetPublicKey(); + } + + var sha = SHA256.HashData(subjectPublicKeyInfo); + var keyId = Convert.ToHexString(sha).ToLowerInvariant(); + + if (!tlsPinnedCerts.Contains(keyId)) + { + Debugf("Failed pinned cert test for key id: {0}", keyId); + return false; + } + + return true; + } + internal void SetAccount(INatsAccount? acc) { lock (_mu) { Account = acc; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Reload.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Reload.cs index b6d5b51..c483b1e 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Reload.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Reload.cs @@ -14,7 +14,6 @@ // Adapted from server/reload.go in the NATS server Go source. using System.Reflection; -using System.Security.Cryptography; using System.Text.Json; using ZB.MOM.NatsNet.Server.Auth; using ZB.MOM.NatsNet.Server.Internal; @@ -1331,26 +1330,7 @@ public sealed partial class NatsServer private static bool MatchesPinnedCert(ClientConnection client, PinnedCertSet? pinnedCerts) { - if (pinnedCerts == null || pinnedCerts.Count == 0) - return true; - - var certificate = client.GetTlsCertificate(); - if (certificate == null) - return false; - - byte[] keyBytes; - try - { - keyBytes = certificate.PublicKey.ExportSubjectPublicKeyInfo(); - } - catch - { - keyBytes = certificate.GetPublicKey(); - } - - var hash = SHA256.HashData(keyBytes); - var hex = Convert.ToHexString(hash).ToLowerInvariant(); - return pinnedCerts.Contains(hex); + return client.MatchesPinnedCert(pinnedCerts); } } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/DirectoryStoreTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/DirectoryStoreTests.cs index 24bc2ae..4dc95e3 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/DirectoryStoreTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/DirectoryStoreTests.cs @@ -767,4 +767,47 @@ public sealed class DirectoryStoreTests : IDisposable foreach (var s in stores) try { s?.Dispose(); } catch { /* best-effort */ } } } + + [Fact] + public void ValidateDirPath_ExistingDirectory_ReturnsAbsolutePath() + { + var dir = MakeTempDir(); + + var validated = DirJwtStore.ValidateDirPath(dir); + + validated.ShouldBe(Path.GetFullPath(dir)); + } + + [Fact] + public void ValidatePathExists_PathIsFileWhenDirectoryExpected_Throws() + { + var dir = MakeTempDir(); + var file = Path.Combine(dir, "token.jwt"); + File.WriteAllText(file, "jwt"); + + Should.Throw(() => DirJwtStore.ValidatePathExists(file, dir: true)); + } + + [Fact] + public void ExpirationTracker_HeapPrimitives_MaintainIndexAndTracking() + { + var tracker = new ExpirationTracker(limit: 10, evictOnLimit: true, ttl: TimeSpan.Zero); + var a = new JwtItem("A", expiration: 10, hash: [1, 2, 3]); + var b = new JwtItem("B", expiration: 20, hash: [4, 5, 6]); + + tracker.Push(a); + tracker.Push(b); + + tracker.Len().ShouldBe(2); + tracker.Less(0, 1).ShouldBeTrue(); + + tracker.Swap(0, 1); + tracker.Less(0, 1).ShouldBeFalse(); + + var popped = tracker.Pop(); + popped.PublicKey.ShouldBe("A"); + tracker.IsTracked("A").ShouldBeFalse(); + tracker.IsTracked("B").ShouldBeTrue(); + tracker.Len().ShouldBe(1); + } } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ServerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ServerTests.cs index 2e4ca56..649e6f1 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ServerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ServerTests.cs @@ -22,6 +22,7 @@ using NSubstitute.ExceptionExtensions; using Shouldly; using Xunit; using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; namespace ZB.MOM.NatsNet.Server.Tests; @@ -218,6 +219,21 @@ public sealed class ServerTests err.ShouldNotBeNull(); } + [Fact] + public void MatchesPinnedCert_NullPinnedSet_ReturnsTrue() + { + var client = new ClientConnection(ClientKind.Client, nc: new MemoryStream()); + client.MatchesPinnedCert(null).ShouldBeTrue(); + } + + [Fact] + public void MatchesPinnedCert_NoTlsCertificate_ReturnsFalse() + { + var client = new ClientConnection(ClientKind.Client, nc: new MemoryStream()); + var pinned = new PinnedCertSet([new string('a', 64)]); + client.MatchesPinnedCert(pinned).ShouldBeFalse(); + } + // ========================================================================= // GetServerProto // ========================================================================= diff --git a/porting.db b/porting.db index fa78b0f4e1dd53416129e43a29e6cc1dd86c1003..dc3392b242e322858de779e52893af3461aba1ec 100644 GIT binary patch delta 3185 zcmb`}Yiv_h902fp&h7iOx7`c6j&22Uj6HjATh?_5+n7TEVX!$~L)Nl8*07gtae{nc zK-d&PsD$%DG#$}^*(1y-H!2Vu%Tzw_h>s8wa2O;3jY5JS1QYzX?9thMQTpMRoc}$& z|9{Wvx&Papp#$vBa|hUiY4S#fQMe9S7NW+1x{?Cq*xg+qbr+zzlWN%o=8~)t%T80y zZI~umMvxk@{jw0X8gLnHyNPO~p_`})J9VrXZ)$CBYZ3z@>ww#`$0IvLGsYtuMBI2J z(8gQHqg7s} z%t=BiUae-AG1c_qKErgnd7q(z-dTd{GqO-qz1Sf7d}2MzlMpjwJTgJV(Wa%?m`H9j z)#KM~5LHM=mf|wc0@lveGV}d`tsd3}BOx*pV3ZXZ@i0n{j7%^}j*L=aqzR9Zmrm=) zT^co;f+I5y=|n%SMd)WLKZ*auS_2&^#YTGLvO!5x2kUEt1c0}Q#+RONNtaC_zU&cjR1zai$+B%D`W55C#kOKu!0u@k8#n&~% zgE^XECXsP7+%PBTwje#@)}GPoNxgLV77)4~_Jc)`V6>0f=Mih90H8lB$WPcovWrTNFmcDWQd zL9Sr>VLus6gr$6tz@S1MUyy`RN?}0~MZIJ?ZSEmCRDVsG`lQTo56M7ITHZsdsi%q? z%^toQm7V_4Lu%-*quSAFfA8e)_I@u+2iLG>YUm?;^o_+K@--bfP7X`Bo4C+nnL<*W zBu0Y@d6`}8ZtM;3LM_c4ByZ7Ut*rUTF+%9oL6Q@lM0cKqXxaXxC$m4CC;3sc(ynvl zBh<+a5s4epe%eH5eU@gX2g}nF>3yp%I!|XNuLGQv$?I>NMR%zDo_@8A+$LA4q0*2{ z+wSR&8ghZ0CjBHtf>PH#eL0e@-Pg<1m^np99_r85u#WIk`I_kSHhAH<+b2b~Ns%xq z;wMF>Nl|L&gGYK`00|%wB!Og*0;YlKzzD*hHGwoR1DHWN$N&~F6U+k7fZ5<#kO}61 zEHD>jgB*|x@<2W)09L>Q0oZ^YIKVtm2%Mk@xIi(O51s=Hz(P<0ND$ znMy{#oI9$!t8;4ilJChZO`H0xI!*PCidD8NepPIgeQ<-j>Z4pZMxlFEm7pKL8p0mWHO*cFWjD^A^xif~+Pv6` zf@3Vrf3KJ^QIVgbB2V2d{1l56_&Ag-3|b>4w@untQ>3KnU;FBG+Tv2NV2X;V)U8^d z8&lE#6l=7MZR>Nvd=fr89c=U>;Vl&|Nq27JlZn%5|Nl!HKQXFco;qA;MN0d`8FkQZ zgIicvWC^(aTZHh*^998EVv9HY7ou5g34{-3v%9Sgj%BkLXl$+bTUuJ%1*#WR0)Y<_sp#&3V-#^&cE`Pas0|FdHA(}Z{3`SfwV zTOOz1cus7M)OhYuW=wu~6vDs3qB!i4l=kMPUldv${LjH$7o#udx}=$P8+LHv7Vem$ pyE($G#zaGBJc+Sg)$tUMe^ghsPf^!SZ=Ui@(N&+O<0wZv&OhC!EpkfMj9vr{4a0k`G zjk)1M$8=P*Pjj@6TI+|((5z=Q$AmH3{KMgDt+i;-%+1yc+xjDOtG5S|-|h9?`}2IB z&+|OTubwd-kDW0Mxy@~gVkvzia|>&`7^)~>>-t9voxB8FOPaf0JbEP36VW?S<*rm6sM1%ec2tg)s*T!j zvaoJ9d3Z~tH4=?RTI6>(S(VkRw<^j*V-ZyUkF`kuZ|qvV+Q+9ADi88pDyrZvhiDfE z#18SMI3ZpU-&17;pO-~J-k_Oc${xBtXrxlxpixD)xAPY2>os($?K2!y*K4HH+CIZ0 zV>P@aT&{OI8kMr{o|nUV2U1ZywIi*Mr#2*SJhdVv$5RB!7EdjdGR9BZ@Z6>Pu>5$8 zH!`+B=5hWP%?EiJ{WNP>$$gPudixkJmFgwlpTN$_lV9;+#(B2%Ug5Wr5?-|jaEPUp z^jZG9yt~U8)CU4{9892s87yFh1khw)&NegUw|=3xlz@qG>%0=M|81|)-xYs|O2-Gb z)7p<(UP6!6Fke;&O<`V!8y^1X@CEU;oEZ~-CchjNy=K+fq2$u;)hwHe#>B8J{z%;5 z6gemAiGQYg$oNDg(yenMgBFKOf{gQGFHIe@EH~adFEW=J6ZadgDohk!5Wc0ga13jI zev`Vqc|0amx*Zeg%SVlhI*OisaL3T-gFDnu#ZFMl}ttGSUawJtr8D2rfv1WOxKpAQjSJ6|4p~+@JG+7cw9dvS1CY zg-2l>WJ31Uf6UZ n&8E2(=T66n{a1U5{<`>9tg#){CN#Iq3N`(kAl;{$f3f@r-jC0f