feat(batch9): implement f1 auth and dirstore foundations
This commit is contained in:
@@ -833,6 +833,58 @@ public sealed class DirJwtStore : IDisposable
|
||||
// Private static helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Validates the supplied path exists, and enforces whether it must be a
|
||||
/// directory or regular file.
|
||||
/// Mirrors Go <c>validatePathExists</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the supplied path exists and is a directory.
|
||||
/// Mirrors Go <c>validateDirPath</c>.
|
||||
/// </summary>
|
||||
internal static string ValidateDirPath(string path)
|
||||
{
|
||||
return ValidatePathExists(path, dir: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that <paramref name="dirPath"/> exists and is a directory, optionally
|
||||
/// creating it when <paramref name="create"/> is true.
|
||||
@@ -841,31 +893,18 @@ public sealed class DirJwtStore : IDisposable
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1044,6 +1083,7 @@ internal sealed class ExpirationTracker
|
||||
{
|
||||
// Min-heap ordered by expiration (Unix nanoseconds stored as ticks for TimeSpan compatibility).
|
||||
private readonly PriorityQueue<JwtItem, long> _heap;
|
||||
private readonly List<JwtItem> _compatHeap;
|
||||
|
||||
// Index from publicKey to JwtItem for O(1) lookup and hash tracking.
|
||||
private readonly Dictionary<string, JwtItem> _idx;
|
||||
@@ -1068,6 +1108,7 @@ internal sealed class ExpirationTracker
|
||||
EvictOnLimit = evictOnLimit;
|
||||
Ttl = ttl;
|
||||
_heap = new PriorityQueue<JwtItem, long>();
|
||||
_compatHeap = [];
|
||||
_idx = new Dictionary<string, JwtItem>(StringComparer.Ordinal);
|
||||
_lru = new LinkedList<string>();
|
||||
_hash = new byte[SHA256.HashSizeInBytes];
|
||||
@@ -1075,6 +1116,55 @@ internal sealed class ExpirationTracker
|
||||
|
||||
internal void SetTimer(Timer timer) => _timer = timer;
|
||||
|
||||
/// <summary>Returns the number of items in the compatibility heap.</summary>
|
||||
/// <remarks>Mirrors Go <c>expirationTracker.Len</c>.</remarks>
|
||||
internal int Len() => _compatHeap.Count;
|
||||
|
||||
/// <summary>Returns true when item <paramref name="i"/> expires before <paramref name="j"/>.</summary>
|
||||
/// <remarks>Mirrors Go <c>expirationTracker.Less</c>.</remarks>
|
||||
internal bool Less(int i, int j)
|
||||
{
|
||||
return _compatHeap[i].Expiration < _compatHeap[j].Expiration;
|
||||
}
|
||||
|
||||
/// <summary>Swaps two compatibility heap items and updates their indexes.</summary>
|
||||
/// <remarks>Mirrors Go <c>expirationTracker.Swap</c>.</remarks>
|
||||
internal void Swap(int i, int j)
|
||||
{
|
||||
(_compatHeap[i], _compatHeap[j]) = (_compatHeap[j], _compatHeap[i]);
|
||||
_compatHeap[i].Index = i;
|
||||
_compatHeap[j].Index = j;
|
||||
}
|
||||
|
||||
/// <summary>Adds an item to the compatibility heap and index maps.</summary>
|
||||
/// <remarks>Mirrors Go <c>expirationTracker.Push</c>.</remarks>
|
||||
internal void Push(JwtItem item)
|
||||
{
|
||||
item.Index = _compatHeap.Count;
|
||||
_compatHeap.Add(item);
|
||||
_idx[item.PublicKey] = item;
|
||||
_lru.AddLast(item.PublicKey);
|
||||
}
|
||||
|
||||
/// <summary>Removes and returns the last compatibility heap item.</summary>
|
||||
/// <remarks>Mirrors Go <c>expirationTracker.Pop</c>.</remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates tracking for <paramref name="publicKey"/>.
|
||||
/// 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
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the current TLS peer certificate matches one of the pinned
|
||||
/// SPKI SHA-256 key identifiers.
|
||||
/// Mirrors Go <c>client.matchesPinnedCert</c>.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
Reference in New Issue
Block a user