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
|
// 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>
|
/// <summary>
|
||||||
/// Validates that <paramref name="dirPath"/> exists and is a directory, optionally
|
/// Validates that <paramref name="dirPath"/> exists and is a directory, optionally
|
||||||
/// creating it when <paramref name="create"/> is true.
|
/// creating it when <paramref name="create"/> is true.
|
||||||
@@ -841,31 +893,18 @@ public sealed class DirJwtStore : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static string NewDir(string dirPath, bool create)
|
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));
|
return ValidateDirPath(dirPath);
|
||||||
}
|
|
||||||
|
|
||||||
if (Directory.Exists(dirPath))
|
|
||||||
{
|
|
||||||
return Path.GetFullPath(dirPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!create)
|
if (!create)
|
||||||
{
|
{
|
||||||
throw new DirectoryNotFoundException(
|
return ValidateDirPath(dirPath);
|
||||||
$"The path [{dirPath}] doesn't exist");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(dirPath);
|
Directory.CreateDirectory(dirPath);
|
||||||
|
return ValidateDirPath(dirPath);
|
||||||
if (!Directory.Exists(dirPath))
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException(
|
|
||||||
$"Failed to create directory [{dirPath}]");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.GetFullPath(dirPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1044,6 +1083,7 @@ internal sealed class ExpirationTracker
|
|||||||
{
|
{
|
||||||
// Min-heap ordered by expiration (Unix nanoseconds stored as ticks for TimeSpan compatibility).
|
// Min-heap ordered by expiration (Unix nanoseconds stored as ticks for TimeSpan compatibility).
|
||||||
private readonly PriorityQueue<JwtItem, long> _heap;
|
private readonly PriorityQueue<JwtItem, long> _heap;
|
||||||
|
private readonly List<JwtItem> _compatHeap;
|
||||||
|
|
||||||
// Index from publicKey to JwtItem for O(1) lookup and hash tracking.
|
// Index from publicKey to JwtItem for O(1) lookup and hash tracking.
|
||||||
private readonly Dictionary<string, JwtItem> _idx;
|
private readonly Dictionary<string, JwtItem> _idx;
|
||||||
@@ -1068,6 +1108,7 @@ internal sealed class ExpirationTracker
|
|||||||
EvictOnLimit = evictOnLimit;
|
EvictOnLimit = evictOnLimit;
|
||||||
Ttl = ttl;
|
Ttl = ttl;
|
||||||
_heap = new PriorityQueue<JwtItem, long>();
|
_heap = new PriorityQueue<JwtItem, long>();
|
||||||
|
_compatHeap = [];
|
||||||
_idx = new Dictionary<string, JwtItem>(StringComparer.Ordinal);
|
_idx = new Dictionary<string, JwtItem>(StringComparer.Ordinal);
|
||||||
_lru = new LinkedList<string>();
|
_lru = new LinkedList<string>();
|
||||||
_hash = new byte[SHA256.HashSizeInBytes];
|
_hash = new byte[SHA256.HashSizeInBytes];
|
||||||
@@ -1075,6 +1116,55 @@ internal sealed class ExpirationTracker
|
|||||||
|
|
||||||
internal void SetTimer(Timer timer) => _timer = timer;
|
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>
|
/// <summary>
|
||||||
/// Adds or updates tracking for <paramref name="publicKey"/>.
|
/// Adds or updates tracking for <paramref name="publicKey"/>.
|
||||||
/// When an entry already exists its expiration and hash are updated.
|
/// When an entry already exists its expiration and hash are updated.
|
||||||
@@ -1259,6 +1349,7 @@ internal sealed class ExpirationTracker
|
|||||||
internal void Reset()
|
internal void Reset()
|
||||||
{
|
{
|
||||||
_heap.Clear();
|
_heap.Clear();
|
||||||
|
_compatHeap.Clear();
|
||||||
_idx.Clear();
|
_idx.Clear();
|
||||||
_lru.Clear();
|
_lru.Clear();
|
||||||
Array.Clear(_hash);
|
Array.Clear(_hash);
|
||||||
@@ -1352,6 +1443,7 @@ internal sealed class ExpirationTracker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class JwtItem
|
internal sealed class JwtItem
|
||||||
{
|
{
|
||||||
|
internal int Index { get; set; }
|
||||||
internal string PublicKey { get; }
|
internal string PublicKey { get; }
|
||||||
internal long Expiration { get; set; }
|
internal long Expiration { get; set; }
|
||||||
internal byte[] Hash { get; set; }
|
internal byte[] Hash { get; set; }
|
||||||
@@ -1367,6 +1459,7 @@ internal sealed class JwtItem
|
|||||||
|
|
||||||
internal JwtItem(string publicKey, long expiration, byte[] hash)
|
internal JwtItem(string publicKey, long expiration, byte[] hash)
|
||||||
{
|
{
|
||||||
|
Index = -1;
|
||||||
PublicKey = publicKey;
|
PublicKey = publicKey;
|
||||||
Expiration = expiration;
|
Expiration = expiration;
|
||||||
Hash = hash;
|
Hash = hash;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ using System.Net;
|
|||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
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)
|
internal void SetAccount(INatsAccount? acc)
|
||||||
{
|
{
|
||||||
lock (_mu) { Account = acc; }
|
lock (_mu) { Account = acc; }
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
// Adapted from server/reload.go in the NATS server Go source.
|
// Adapted from server/reload.go in the NATS server Go source.
|
||||||
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ZB.MOM.NatsNet.Server.Auth;
|
using ZB.MOM.NatsNet.Server.Auth;
|
||||||
using ZB.MOM.NatsNet.Server.Internal;
|
using ZB.MOM.NatsNet.Server.Internal;
|
||||||
@@ -1331,26 +1330,7 @@ public sealed partial class NatsServer
|
|||||||
|
|
||||||
private static bool MatchesPinnedCert(ClientConnection client, PinnedCertSet? pinnedCerts)
|
private static bool MatchesPinnedCert(ClientConnection client, PinnedCertSet? pinnedCerts)
|
||||||
{
|
{
|
||||||
if (pinnedCerts == null || pinnedCerts.Count == 0)
|
return client.MatchesPinnedCert(pinnedCerts);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -767,4 +767,47 @@ public sealed class DirectoryStoreTests : IDisposable
|
|||||||
foreach (var s in stores) try { s?.Dispose(); } catch { /* best-effort */ }
|
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 Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.NatsNet.Server.Auth;
|
using ZB.MOM.NatsNet.Server.Auth;
|
||||||
|
using ZB.MOM.NatsNet.Server.Internal;
|
||||||
|
|
||||||
namespace ZB.MOM.NatsNet.Server.Tests;
|
namespace ZB.MOM.NatsNet.Server.Tests;
|
||||||
|
|
||||||
@@ -218,6 +219,21 @@ public sealed class ServerTests
|
|||||||
err.ShouldNotBeNull();
|
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
|
// GetServerProto
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
Reference in New Issue
Block a user