diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs new file mode 100644 index 0000000..2729484 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs @@ -0,0 +1,273 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/auth.go (standalone functions) in the NATS server Go source. + +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace ZB.MOM.NatsNet.Server.Auth; + +/// +/// Authentication helper methods ported from Go auth.go. +/// Server-dependent methods (configureAuthorization, checkAuthentication, etc.) +/// will be added in later sessions when the full Server type is available. +/// +public static partial class AuthHandler +{ + /// + /// Regex matching valid bcrypt password prefixes ($2a$, $2b$, $2x$, $2y$). + /// Mirrors Go validBcryptPrefix. + /// + private static readonly Regex ValidBcryptPrefix = ValidBcryptPrefixRegex(); + + [GeneratedRegex(@"^\$2[abxy]\$\d{2}\$.*")] + private static partial Regex ValidBcryptPrefixRegex(); + + /// + /// Checks if a password string is a bcrypt hash. + /// Mirrors Go isBcrypt. + /// + public static bool IsBcrypt(string password) + { + if (password.StartsWith('$')) + { + return ValidBcryptPrefix.IsMatch(password); + } + return false; + } + + /// + /// Compares a server password (possibly bcrypt-hashed) against a client-provided password. + /// Uses constant-time comparison for plaintext passwords. + /// Mirrors Go comparePasswords. + /// + public static bool ComparePasswords(string serverPassword, string clientPassword) + { + if (IsBcrypt(serverPassword)) + { + return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword); + } + + // Constant-time comparison for plaintext passwords. + var spass = Encoding.UTF8.GetBytes(serverPassword); + var cpass = Encoding.UTF8.GetBytes(clientPassword); + return CryptographicOperations.FixedTimeEquals(spass, cpass); + } + + /// + /// Validates the ResponsePermission defaults within a Permissions struct. + /// If Response is set but MaxMsgs/Expires are zero, applies defaults. + /// Also ensures Publish is set with an empty Allow if not already defined. + /// Mirrors Go validateResponsePermissions. + /// + public static void ValidateResponsePermissions(Permissions? p) + { + if (p?.Response == null) + { + return; + } + + p.Publish ??= new SubjectPermission(); + p.Publish.Allow ??= []; + + if (p.Response.MaxMsgs == 0) + { + p.Response.MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs; + } + if (p.Response.Expires == TimeSpan.Zero) + { + p.Response.Expires = ServerConstants.DefaultAllowResponseExpiration; + } + } + + /// + /// Known connection type strings (uppercased). + /// Mirrors Go jwt.ConnectionType* constants. + /// + public static class ConnectionTypes + { + public const string Standard = "STANDARD"; + public const string Websocket = "WEBSOCKET"; + public const string Leafnode = "LEAFNODE"; + public const string LeafnodeWs = "LEAFNODE_WS"; + public const string Mqtt = "MQTT"; + public const string MqttWs = "MQTT_WS"; + public const string InProcess = "IN_PROCESS"; + + private static readonly HashSet Known = + [ + Standard, + Websocket, + Leafnode, + LeafnodeWs, + Mqtt, + MqttWs, + InProcess, + ]; + + public static bool IsKnown(string ct) => Known.Contains(ct); + } + + /// + /// Validates allowed connection type map entries. Normalises to uppercase + /// and rejects unknown types. + /// Mirrors Go validateAllowedConnectionTypes. + /// + public static Exception? ValidateAllowedConnectionTypes(HashSet? m) + { + if (m == null) return null; + + // We must iterate a copy since we may modify the set. + var entries = m.ToList(); + foreach (var ct in entries) + { + var ctuc = ct.ToUpperInvariant(); + if (!ConnectionTypes.IsKnown(ctuc)) + { + return new ArgumentException($"unknown connection type \"{ct}\""); + } + if (ctuc != ct) + { + m.Remove(ct); + m.Add(ctuc); + } + } + return null; + } + + /// + /// Validates the no_auth_user setting against configured users/nkeys. + /// Mirrors Go validateNoAuthUser. + /// + public static Exception? ValidateNoAuthUser(ServerOptions o, string noAuthUser) + { + if (string.IsNullOrEmpty(noAuthUser)) + { + return null; + } + if (o.TrustedOperators.Count > 0) + { + return new InvalidOperationException("no_auth_user not compatible with Trusted Operator"); + } + if (o.Nkeys == null && o.Users == null) + { + return new InvalidOperationException( + $"no_auth_user: \"{noAuthUser}\" present, but users/nkeys are not defined"); + } + if (o.Users != null) + { + foreach (var u in o.Users) + { + if (u.Username == noAuthUser) return null; + } + } + if (o.Nkeys != null) + { + foreach (var u in o.Nkeys) + { + if (u.Nkey == noAuthUser) return null; + } + } + return new InvalidOperationException( + $"no_auth_user: \"{noAuthUser}\" not present as user or nkey in authorization block or account configuration"); + } + + /// + /// Validates the auth section of options: pinned certs, connection types, and no_auth_user. + /// Mirrors Go validateAuth. + /// + public static Exception? ValidateAuth(ServerOptions o) + { + // validatePinnedCerts will be added when the full server module is ported. + if (o.Users != null) + { + foreach (var u in o.Users) + { + var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes); + if (err != null) return err; + } + } + if (o.Nkeys != null) + { + foreach (var u in o.Nkeys) + { + var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes); + if (err != null) return err; + } + } + return ValidateNoAuthUser(o, o.NoAuthUser); + } + + /// + /// Splits a DNS alt name into lowercase labels. + /// Mirrors Go dnsAltNameLabels. + /// + public static string[] DnsAltNameLabels(string dnsAltName) + { + return dnsAltName.ToLowerInvariant().Split('.'); + } + + /// + /// Checks if DNS alt name labels match any of the provided URLs (RFC 6125). + /// The wildcard '*' only matches the leftmost label. + /// Mirrors Go dnsAltNameMatches. + /// + public static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList urls) + { + foreach (var url in urls) + { + if (url == null) + { + continue; + } + + var hostLabels = url.Host.ToLowerInvariant().Split('.'); + + // Following RFC 6125: wildcard never matches multiple labels, only leftmost. + if (hostLabels.Length != dnsAltNameLabels.Length) + { + continue; + } + + var i = 0; + // Only match wildcard on leftmost label. + if (dnsAltNameLabels[0] == "*") + { + i++; + } + + var matched = true; + for (; i < dnsAltNameLabels.Length; i++) + { + if (dnsAltNameLabels[i] != hostLabels[i]) + { + matched = false; + break; + } + } + if (matched) return true; + } + return false; + } + + /// + /// Wipes a byte slice by filling with 'x'. Used for clearing sensitive data. + /// Mirrors Go wipeSlice. + /// + public static void WipeSlice(Span buf) + { + buf.Fill((byte)'x'); + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs new file mode 100644 index 0000000..0396adf --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs @@ -0,0 +1,176 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/auth.go (type definitions) in the NATS server Go source. + +namespace ZB.MOM.NatsNet.Server.Auth; + +/// +/// Represents a user authenticated via NKey. +/// Mirrors Go NkeyUser struct in auth.go. +/// +public class NkeyUser +{ + public string Nkey { get; set; } = string.Empty; + public long Issued { get; set; } + public Permissions? Permissions { get; set; } + public Account? Account { get; set; } + public string SigningKey { get; set; } = string.Empty; + public HashSet? AllowedConnectionTypes { get; set; } + public bool ProxyRequired { get; set; } + + /// + /// Deep-clones this NkeyUser. Account is shared by reference. + /// Mirrors Go NkeyUser.clone(). + /// + public NkeyUser? Clone() + { + var clone = (NkeyUser)MemberwiseClone(); + // Account is not cloned because it is always by reference to an existing struct. + clone.Permissions = Permissions?.Clone(); + + if (AllowedConnectionTypes != null) + { + clone.AllowedConnectionTypes = new HashSet(AllowedConnectionTypes); + } + + return clone; + } +} + +/// +/// Represents a user with username/password credentials. +/// Mirrors Go User struct in auth.go. +/// +public class User +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public Permissions? Permissions { get; set; } + public Account? Account { get; set; } + public DateTime ConnectionDeadline { get; set; } + public HashSet? AllowedConnectionTypes { get; set; } + public bool ProxyRequired { get; set; } + + /// + /// Deep-clones this User. Account is shared by reference. + /// Mirrors Go User.clone(). + /// + public User? Clone() + { + var clone = (User)MemberwiseClone(); + // Account is not cloned because it is always by reference to an existing struct. + clone.Permissions = Permissions?.Clone(); + + if (AllowedConnectionTypes != null) + { + clone.AllowedConnectionTypes = new HashSet(AllowedConnectionTypes); + } + + return clone; + } +} + +/// +/// Subject-level allow/deny permission. +/// Mirrors Go SubjectPermission in auth.go. +/// +public class SubjectPermission +{ + public List? Allow { get; set; } + public List? Deny { get; set; } + + /// + /// Deep-clones this SubjectPermission. + /// Mirrors Go SubjectPermission.clone(). + /// + public SubjectPermission Clone() + { + var clone = new SubjectPermission(); + if (Allow != null) + { + clone.Allow = new List(Allow); + } + if (Deny != null) + { + clone.Deny = new List(Deny); + } + return clone; + } +} + +/// +/// Response permission for request-reply patterns. +/// Mirrors Go ResponsePermission in auth.go. +/// +public class ResponsePermission +{ + public int MaxMsgs { get; set; } + public TimeSpan Expires { get; set; } +} + +/// +/// Publish/subscribe permissions container. +/// Mirrors Go Permissions in auth.go. +/// +public class Permissions +{ + public SubjectPermission? Publish { get; set; } + public SubjectPermission? Subscribe { get; set; } + public ResponsePermission? Response { get; set; } + + /// + /// Deep-clones this Permissions struct. + /// Mirrors Go Permissions.clone(). + /// + public Permissions Clone() + { + var clone = new Permissions(); + if (Publish != null) + { + clone.Publish = Publish.Clone(); + } + if (Subscribe != null) + { + clone.Subscribe = Subscribe.Clone(); + } + if (Response != null) + { + clone.Response = new ResponsePermission + { + MaxMsgs = Response.MaxMsgs, + Expires = Response.Expires, + }; + } + return clone; + } +} + +/// +/// Route-level import/export permissions. +/// Mirrors Go RoutePermissions in auth.go. +/// +public class RoutePermissions +{ + public SubjectPermission? Import { get; set; } + public SubjectPermission? Export { get; set; } +} + +/// +/// Stub for Account type. Full implementation in later sessions. +/// Mirrors Go Account struct. +/// +public class Account +{ + public string Name { get; set; } = string.Empty; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs new file mode 100644 index 0000000..1d05c3c --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs @@ -0,0 +1,110 @@ +// Copyright 2016-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/ciphersuites.go in the NATS server Go source. + +using System.Net.Security; +using System.Security.Authentication; + +namespace ZB.MOM.NatsNet.Server.Auth; + +/// +/// TLS cipher suite and curve preference definitions. +/// Mirrors Go ciphersuites.go — cipherMap, defaultCipherSuites, curvePreferenceMap, +/// defaultCurvePreferences. +/// +public static class CipherSuites +{ + /// + /// Map of cipher suite names to their values. + /// Populated at static init time — mirrors Go init() + cipherMap. + /// + public static IReadOnlyDictionary CipherMap { get; } + + /// + /// Reverse map of cipher suite ID to name. + /// Mirrors Go cipherMapByID. + /// + public static IReadOnlyDictionary CipherMapById { get; } + + static CipherSuites() + { + // .NET does not have a direct equivalent of Go's tls.CipherSuites() / + // tls.InsecureCipherSuites() enumeration. We enumerate the well-known + // TLS 1.2 and 1.3 cipher suites defined in the TlsCipherSuite enum. + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byId = new Dictionary(); + + foreach (TlsCipherSuite cs in Enum.GetValues(typeof(TlsCipherSuite))) + { + var name = cs.ToString(); + byName.TryAdd(name, cs); + byId.TryAdd(cs, name); + } + + CipherMap = byName; + CipherMapById = byId; + } + + /// + /// Returns the default set of TLS 1.2 cipher suites. + /// .NET manages cipher suite selection at the OS/SChannel/OpenSSL level; + /// this list provides the preferred suites for configuration alignment with Go. + /// Mirrors Go defaultCipherSuites. + /// + public static TlsCipherSuite[] DefaultCipherSuites() + { + // Return commonly-used TLS 1.2 cipher suites in preference order. + // TLS 1.3 suites are always enabled in .NET and cannot be individually toggled. + return + [ + TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + TlsCipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + ]; + } + + /// + /// Supported named curve / key exchange preferences. + /// Mirrors Go curvePreferenceMap. + /// + public static IReadOnlyDictionary CurvePreferenceMap { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // .NET does not expose individual curve selection in the same way as Go. + // These entries exist for configuration-file compatibility and mapping. + // Actual curve negotiation is handled by the OS TLS stack. + ["X25519"] = new SslApplicationProtocol("X25519"), + ["CurveP256"] = new SslApplicationProtocol("CurveP256"), + ["CurveP384"] = new SslApplicationProtocol("CurveP384"), + ["CurveP521"] = new SslApplicationProtocol("CurveP521"), + }; + + /// + /// Returns the default curve preferences, ordered highest security first. + /// Mirrors Go defaultCurvePreferences. + /// + public static string[] DefaultCurvePreferences() + { + return + [ + "X25519", + "CurveP256", + "CurveP384", + "CurveP521", + ]; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs new file mode 100644 index 0000000..f294a6e --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs @@ -0,0 +1,192 @@ +// Copyright 2018-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/jwt.go in the NATS server Go source. + +using System.Net; + +namespace ZB.MOM.NatsNet.Server.Auth; + +/// +/// JWT processing utilities for NATS operator/account/user JWTs. +/// Mirrors Go jwt.go functions. +/// Full JWT parsing will be added when a .NET JWT library equivalent is available. +/// +public static class JwtProcessor +{ + /// + /// All JWTs once encoded start with this prefix. + /// Mirrors Go jwtPrefix. + /// + public const string JwtPrefix = "eyJ"; + + /// + /// Wipes a byte slice by filling with 'x', for clearing nkey seed data. + /// Mirrors Go wipeSlice. + /// + public static void WipeSlice(Span buf) + { + buf.Fill((byte)'x'); + } + + /// + /// Validates that the given IP host address is allowed by the user claims source CIDRs. + /// Returns true if the host is within any of the allowed CIDRs, or if no CIDRs are specified. + /// Mirrors Go validateSrc. + /// + public static bool ValidateSrc(IReadOnlyList? srcCidrs, string host) + { + if (srcCidrs == null) + { + return false; + } + if (srcCidrs.Count == 0) + { + return true; + } + if (string.IsNullOrEmpty(host)) + { + return false; + } + if (!IPAddress.TryParse(host, out var ip)) + { + return false; + } + foreach (var cidr in srcCidrs) + { + if (TryParseCidr(cidr, out var network, out var prefixLength)) + { + if (IsInSubnet(ip, network, prefixLength)) + { + return true; + } + } + else + { + return false; // invalid CIDR means invalid JWT + } + } + return false; + } + + /// + /// Validates that the current time falls within any of the allowed time ranges. + /// Returns (allowed, remainingDuration). + /// Mirrors Go validateTimes. + /// + public static (bool Allowed, TimeSpan Remaining) ValidateTimes( + IReadOnlyList? timeRanges, + string? locale = null) + { + if (timeRanges == null) + { + return (false, TimeSpan.Zero); + } + if (timeRanges.Count == 0) + { + return (true, TimeSpan.Zero); + } + + var now = DateTimeOffset.Now; + TimeZoneInfo? tz = null; + if (!string.IsNullOrEmpty(locale)) + { + try + { + tz = TimeZoneInfo.FindSystemTimeZoneById(locale); + now = TimeZoneInfo.ConvertTime(now, tz); + } + catch + { + return (false, TimeSpan.Zero); + } + } + + foreach (var timeRange in timeRanges) + { + if (!TimeSpan.TryParse(timeRange.Start, out var startTime) || + !TimeSpan.TryParse(timeRange.End, out var endTime)) + { + return (false, TimeSpan.Zero); + } + + var today = now.Date; + var start = today + startTime; + var end = today + endTime; + + // If start > end, end is on the next day (overnight range). + if (startTime > endTime) + { + end = end.AddDays(1); + } + + if (start <= now && now < end) + { + return (true, end - now); + } + } + return (false, TimeSpan.Zero); + } + + private static bool TryParseCidr(string cidr, out IPAddress network, out int prefixLength) + { + network = IPAddress.None; + prefixLength = 0; + + var slashIndex = cidr.IndexOf('/'); + if (slashIndex < 0) return false; + + var ipPart = cidr.AsSpan(0, slashIndex); + var prefixPart = cidr.AsSpan(slashIndex + 1); + + if (!IPAddress.TryParse(ipPart, out var parsedIp)) return false; + if (!int.TryParse(prefixPart, out var prefix)) return false; + + network = parsedIp; + prefixLength = prefix; + return true; + } + + private static bool IsInSubnet(IPAddress address, IPAddress network, int prefixLength) + { + var addrBytes = address.GetAddressBytes(); + var netBytes = network.GetAddressBytes(); + if (addrBytes.Length != netBytes.Length) return false; + + var fullBytes = prefixLength / 8; + var remainingBits = prefixLength % 8; + + for (var i = 0; i < fullBytes; i++) + { + if (addrBytes[i] != netBytes[i]) return false; + } + + if (remainingBits > 0 && fullBytes < addrBytes.Length) + { + var mask = (byte)(0xFF << (8 - remainingBits)); + if ((addrBytes[fullBytes] & mask) != (netBytes[fullBytes] & mask)) return false; + } + + return true; + } +} + +/// +/// Represents a time-of-day range for user access control. +/// Mirrors Go jwt.TimeRange. +/// +public class TimeRange +{ + public string Start { get; set; } = string.Empty; + public string End { get; set; } = string.Empty; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs index 22921b8..f26f6c3 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs @@ -16,6 +16,7 @@ using System.Net.Security; using System.Security.Authentication; using System.Threading; +using ZB.MOM.NatsNet.Server.Auth; namespace ZB.MOM.NatsNet.Server; @@ -109,6 +110,9 @@ public sealed partial class ServerOptions public bool NoSystemAccount { get; set; } public AuthCalloutOpts? AuthCallout { get; set; } public bool AlwaysEnableNonce { get; set; } + public List? Users { get; set; } + public List? Nkeys { get; set; } + public List TrustedOperators { get; set; } = []; public IAuthentication? CustomClientAuthentication { get; set; } public IAuthentication? CustomRouterAuthentication { get; set; } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthHandlerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthHandlerTests.cs new file mode 100644 index 0000000..4c1c048 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthHandlerTests.cs @@ -0,0 +1,384 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Shouldly; +using ZB.MOM.NatsNet.Server.Auth; + +namespace ZB.MOM.NatsNet.Server.Tests.Auth; + +/// +/// Tests for AuthHandler standalone functions. +/// Mirrors Go auth_test.go and adds unit tests for validators. +/// +public class AuthHandlerTests +{ + // ========================================================================= + // IsBcrypt + // ========================================================================= + + [Theory] + [InlineData("$2a$10$abc123", true)] + [InlineData("$2b$12$xyz", true)] + [InlineData("$2x$04$foo", true)] + [InlineData("$2y$10$bar", true)] + [InlineData("$3a$10$abc", false)] + [InlineData("plaintext", false)] + [InlineData("", false)] + [InlineData("$2a", false)] + public void IsBcrypt_DetectsCorrectly(string password, bool expected) + { + AuthHandler.IsBcrypt(password).ShouldBe(expected); + } + + // ========================================================================= + // ComparePasswords + // ========================================================================= + + [Fact] + public void ComparePasswords_PlaintextMatch() + { + AuthHandler.ComparePasswords("secret", "secret").ShouldBeTrue(); + } + + [Fact] + public void ComparePasswords_PlaintextMismatch() + { + AuthHandler.ComparePasswords("secret", "wrong").ShouldBeFalse(); + } + + [Fact] + public void ComparePasswords_BcryptMatch() + { + var hash = BCrypt.Net.BCrypt.HashPassword("mypassword"); + AuthHandler.ComparePasswords(hash, "mypassword").ShouldBeTrue(); + } + + [Fact] + public void ComparePasswords_BcryptMismatch() + { + var hash = BCrypt.Net.BCrypt.HashPassword("mypassword"); + AuthHandler.ComparePasswords(hash, "wrongpassword").ShouldBeFalse(); + } + + [Fact] + public void ComparePasswords_EmptyPasswords_Match() + { + AuthHandler.ComparePasswords("", "").ShouldBeTrue(); + } + + [Fact] + public void ComparePasswords_DifferentLengths_Mismatch() + { + AuthHandler.ComparePasswords("short", "longpassword").ShouldBeFalse(); + } + + // ========================================================================= + // ValidateResponsePermissions + // ========================================================================= + + [Fact] + public void ValidateResponsePermissions_NullPermissions_NoOp() + { + AuthHandler.ValidateResponsePermissions(null); + } + + [Fact] + public void ValidateResponsePermissions_NullResponse_NoOp() + { + var perms = new Permissions(); + AuthHandler.ValidateResponsePermissions(perms); + perms.Publish.ShouldBeNull(); + } + + [Fact] + public void ValidateResponsePermissions_SetsDefaults() + { + var perms = new Permissions + { + Response = new ResponsePermission(), + }; + AuthHandler.ValidateResponsePermissions(perms); + + perms.Response.MaxMsgs.ShouldBe(ServerConstants.DefaultAllowResponseMaxMsgs); + perms.Response.Expires.ShouldBe(ServerConstants.DefaultAllowResponseExpiration); + perms.Publish.ShouldNotBeNull(); + perms.Publish!.Allow.ShouldNotBeNull(); + perms.Publish.Allow!.Count.ShouldBe(0); + } + + [Fact] + public void ValidateResponsePermissions_PreservesExistingValues() + { + var perms = new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>"] }, + Response = new ResponsePermission + { + MaxMsgs = 10, + Expires = TimeSpan.FromMinutes(5), + }, + }; + AuthHandler.ValidateResponsePermissions(perms); + + perms.Response.MaxMsgs.ShouldBe(10); + perms.Response.Expires.ShouldBe(TimeSpan.FromMinutes(5)); + perms.Publish.Allow.ShouldBe(["foo.>"]); + } + + // ========================================================================= + // ValidateAllowedConnectionTypes + // ========================================================================= + + [Fact] + public void ValidateAllowedConnectionTypes_NullMap_ReturnsNull() + { + AuthHandler.ValidateAllowedConnectionTypes(null).ShouldBeNull(); + } + + [Fact] + public void ValidateAllowedConnectionTypes_ValidTypes_NoError() + { + var m = new HashSet { "STANDARD", "WEBSOCKET" }; + AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull(); + } + + [Fact] + public void ValidateAllowedConnectionTypes_UnknownType_ReturnsError() + { + var m = new HashSet { "STANDARD", "someNewType" }; + var err = AuthHandler.ValidateAllowedConnectionTypes(m); + err.ShouldNotBeNull(); + err!.Message.ShouldContain("connection type"); + } + + [Fact] + public void ValidateAllowedConnectionTypes_NormalizesToUppercase() + { + var m = new HashSet { "websocket", "mqtt" }; + AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull(); + m.ShouldContain("WEBSOCKET"); + m.ShouldContain("MQTT"); + m.ShouldNotContain("websocket"); + m.ShouldNotContain("mqtt"); + } + + [Fact] + public void ValidateAllowedConnectionTypes_AllKnownTypes_NoError() + { + var m = new HashSet + { + "STANDARD", "WEBSOCKET", "LEAFNODE", + "LEAFNODE_WS", "MQTT", "MQTT_WS", "IN_PROCESS", + }; + AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull(); + } + + // ========================================================================= + // ValidateNoAuthUser + // ========================================================================= + + [Fact] + public void ValidateNoAuthUser_EmptyNoAuthUser_NoError() + { + var opts = new ServerOptions(); + AuthHandler.ValidateNoAuthUser(opts, "").ShouldBeNull(); + } + + [Fact] + public void ValidateNoAuthUser_WithTrustedOperators_ReturnsError() + { + var opts = new ServerOptions + { + TrustedOperators = [new object()], + }; + var err = AuthHandler.ValidateNoAuthUser(opts, "foo"); + err.ShouldNotBeNull(); + err!.Message.ShouldContain("Trusted Operator"); + } + + [Fact] + public void ValidateNoAuthUser_NoUsersOrNkeys_ReturnsError() + { + var opts = new ServerOptions(); + var err = AuthHandler.ValidateNoAuthUser(opts, "foo"); + err.ShouldNotBeNull(); + err!.Message.ShouldContain("users/nkeys are not defined"); + } + + [Fact] + public void ValidateNoAuthUser_UserFound_NoError() + { + var opts = new ServerOptions + { + Users = [new User { Username = "foo" }], + }; + AuthHandler.ValidateNoAuthUser(opts, "foo").ShouldBeNull(); + } + + [Fact] + public void ValidateNoAuthUser_NkeyFound_NoError() + { + var opts = new ServerOptions + { + Nkeys = [new NkeyUser { Nkey = "NKEY1" }], + }; + AuthHandler.ValidateNoAuthUser(opts, "NKEY1").ShouldBeNull(); + } + + [Fact] + public void ValidateNoAuthUser_UserNotFound_ReturnsError() + { + var opts = new ServerOptions + { + Users = [new User { Username = "bar" }], + }; + var err = AuthHandler.ValidateNoAuthUser(opts, "foo"); + err.ShouldNotBeNull(); + err!.Message.ShouldContain("not present as user or nkey"); + } + + // ========================================================================= + // ValidateAuth + // ========================================================================= + + [Fact] + public void ValidateAuth_ValidConfig_NoError() + { + var opts = new ServerOptions + { + Users = + [ + new User + { + Username = "u1", + AllowedConnectionTypes = new HashSet { "STANDARD" }, + }, + ], + NoAuthUser = "u1", + }; + AuthHandler.ValidateAuth(opts).ShouldBeNull(); + } + + [Fact] + public void ValidateAuth_InvalidConnectionType_ReturnsError() + { + var opts = new ServerOptions + { + Users = + [ + new User + { + Username = "u1", + AllowedConnectionTypes = new HashSet { "STANDARD", "BAD_TYPE" }, + }, + ], + }; + var err = AuthHandler.ValidateAuth(opts); + err.ShouldNotBeNull(); + err!.Message.ShouldContain("connection type"); + } + + // ========================================================================= + // DnsAltNameLabels + DnsAltNameMatches — Go test ID 148 + // ========================================================================= + + [Theory] + [InlineData("foo", new[] { "nats://FOO" }, true)] + [InlineData("foo", new[] { "nats://.." }, false)] + [InlineData("foo", new[] { "nats://." }, false)] + [InlineData("Foo", new[] { "nats://foO" }, true)] + [InlineData("FOO", new[] { "nats://foo" }, true)] + [InlineData("foo1", new[] { "nats://bar" }, false)] + [InlineData("multi", new[] { "nats://m", "nats://mu", "nats://mul", "nats://multi" }, true)] + [InlineData("multi", new[] { "nats://multi", "nats://m", "nats://mu", "nats://mul" }, true)] + [InlineData("foo.bar", new[] { "nats://foo", "nats://foo.bar.bar", "nats://foo.baz" }, false)] + [InlineData("foo.Bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, true)] + [InlineData("foo.*", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)] + [InlineData("f*.bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)] + [InlineData("*.bar", new[] { "nats://foo.bar" }, true)] + [InlineData("*", new[] { "nats://baz.bar", "nats://bar", "nats://z.y" }, true)] + [InlineData("*", new[] { "nats://bar" }, true)] + [InlineData("*", new[] { "nats://." }, false)] + [InlineData("*", new[] { "nats://" }, true)] + // NOTE: Go test cases {"*", ["*"], true} and {"bar.*", ["bar.*"], true} are omitted + // because .NET's Uri class does not preserve '*' in hostnames the same way Go's url.Parse does. + // Similarly, cases with leading dots like ".Y.local" and "..local" are omitted + // because .NET's Uri normalizes those hostnames differently. + [InlineData("*.Y-X-red-mgmt.default.svc", new[] { "nats://A.Y-X-red-mgmt.default.svc" }, true)] + [InlineData("*.Y-X-green-mgmt.default.svc", new[] { "nats://A.Y-X-green-mgmt.default.svc" }, true)] + [InlineData("*.Y-X-blue-mgmt.default.svc", new[] { "nats://A.Y-X-blue-mgmt.default.svc" }, true)] + [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red-mgmt" }, true)] + [InlineData("Y-X-red-mgmt", new[] { "nats://X-X-red-mgmt" }, false)] + [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-green-mgmt" }, false)] + [InlineData("Y-X-red-mgmt", new[] { "nats://Y" }, false)] + [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X" }, false)] + [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red" }, false)] + [InlineData("Y-X-red-mgmt", new[] { "nats://X-red-mgmt" }, false)] + [InlineData("Y-X-green-mgmt", new[] { "nats://Y-X-green-mgmt" }, true)] + [InlineData("Y-X-blue-mgmt", new[] { "nats://Y-X-blue-mgmt" }, true)] + [InlineData("connect.Y.local", new[] { "nats://connect.Y.local" }, true)] + [InlineData("gcp.Y.local", new[] { "nats://gcp.Y.local" }, true)] + [InlineData("uswest1.gcp.Y.local", new[] { "nats://uswest1.gcp.Y.local" }, true)] + public void DnsAltNameMatches_MatchesCorrectly(string altName, string[] urlStrings, bool expected) + { + var urls = urlStrings.Select(s => new Uri(s)).ToArray(); + var labels = AuthHandler.DnsAltNameLabels(altName); + AuthHandler.DnsAltNameMatches(labels, urls).ShouldBe(expected); + } + + [Fact] + public void DnsAltNameMatches_NullUrl_Skipped() + { + var labels = AuthHandler.DnsAltNameLabels("foo"); + var urls = new Uri?[] { null, new Uri("nats://foo") }; + AuthHandler.DnsAltNameMatches(labels, urls!).ShouldBeTrue(); + } + + // ========================================================================= + // WipeSlice + // ========================================================================= + + [Fact] + public void WipeSlice_FillsWithX() + { + var buf = new byte[] { 1, 2, 3, 4, 5 }; + AuthHandler.WipeSlice(buf); + buf.ShouldAllBe(b => b == (byte)'x'); + } + + [Fact] + public void WipeSlice_EmptyBuffer_NoOp() + { + var buf = Array.Empty(); + AuthHandler.WipeSlice(buf); // should not throw + } + + // ========================================================================= + // ConnectionTypes known check + // ========================================================================= + + [Theory] + [InlineData("STANDARD", true)] + [InlineData("WEBSOCKET", true)] + [InlineData("LEAFNODE", true)] + [InlineData("LEAFNODE_WS", true)] + [InlineData("MQTT", true)] + [InlineData("MQTT_WS", true)] + [InlineData("IN_PROCESS", true)] + [InlineData("UNKNOWN", false)] + [InlineData("", false)] + public void ConnectionTypes_IsKnown_DetectsCorrectly(string ct, bool expected) + { + AuthHandler.ConnectionTypes.IsKnown(ct).ShouldBe(expected); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs new file mode 100644 index 0000000..ee01803 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs @@ -0,0 +1,246 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Shouldly; +using ZB.MOM.NatsNet.Server.Auth; + +namespace ZB.MOM.NatsNet.Server.Tests.Auth; + +/// +/// Tests for auth type cloning and validation. +/// Mirrors Go auth_test.go: TestUserClone*, TestDNSAltNameMatching, and additional unit tests. +/// +public class AuthTypesTests +{ + // ------------------------------------------------------------------------- + // TestUserCloneNilPermissions — Go test ID 142 + // ------------------------------------------------------------------------- + + [Fact] + public void UserClone_NilPermissions_ProducesDeepCopy() + { + var user = new User { Username = "foo", Password = "bar" }; + var clone = user.Clone()!; + + clone.Username.ShouldBe(user.Username); + clone.Password.ShouldBe(user.Password); + clone.Permissions.ShouldBeNull(); + + // Mutation should not affect original. + clone.Password = "baz"; + user.Password.ShouldBe("bar"); + } + + // ------------------------------------------------------------------------- + // TestUserClone — Go test ID 143 + // ------------------------------------------------------------------------- + + [Fact] + public void UserClone_WithPermissions_ProducesDeepCopy() + { + var user = new User + { + Username = "foo", + Password = "bar", + Permissions = new Permissions + { + Publish = new SubjectPermission { Allow = ["foo"] }, + Subscribe = new SubjectPermission { Allow = ["bar"] }, + }, + }; + var clone = user.Clone()!; + + clone.Username.ShouldBe("foo"); + clone.Permissions.ShouldNotBeNull(); + clone.Permissions!.Publish!.Allow.ShouldBe(["foo"]); + clone.Permissions.Subscribe!.Allow.ShouldBe(["bar"]); + + // Mutating clone should not affect original. + clone.Permissions.Subscribe.Allow = ["baz"]; + user.Permissions!.Subscribe!.Allow.ShouldBe(["bar"]); + } + + // ------------------------------------------------------------------------- + // TestUserClonePermissionsNoLists — Go test ID 144 + // ------------------------------------------------------------------------- + + [Fact] + public void UserClone_EmptyPermissions_PublishSubscribeAreNull() + { + var user = new User + { + Username = "foo", + Password = "bar", + Permissions = new Permissions(), + }; + var clone = user.Clone()!; + + clone.Permissions.ShouldNotBeNull(); + clone.Permissions!.Publish.ShouldBeNull(); + clone.Permissions.Subscribe.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // TestUserCloneNoPermissions — Go test ID 145 + // ------------------------------------------------------------------------- + + [Fact] + public void UserClone_NoPermissions_PermissionsIsNull() + { + var user = new User { Username = "foo", Password = "bar" }; + var clone = user.Clone()!; + clone.Permissions.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // TestUserCloneNil — Go test ID 146 + // ------------------------------------------------------------------------- + + [Fact] + public void UserClone_NilUser_ReturnsNull() + { + User? user = null; + var clone = user?.Clone(); + clone.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // NkeyUser clone tests (additional coverage for NkeyUser.clone) + // ------------------------------------------------------------------------- + + [Fact] + public void NkeyUserClone_WithAllowedConnectionTypes_ProducesDeepCopy() + { + var nkey = new NkeyUser + { + Nkey = "NKEY123", + SigningKey = "SK1", + AllowedConnectionTypes = new HashSet { "STANDARD", "WEBSOCKET" }, + Permissions = new Permissions + { + Publish = new SubjectPermission { Allow = ["pub.>"] }, + }, + }; + var clone = nkey.Clone()!; + + clone.Nkey.ShouldBe("NKEY123"); + clone.AllowedConnectionTypes.ShouldNotBeNull(); + clone.AllowedConnectionTypes!.Count.ShouldBe(2); + clone.Permissions!.Publish!.Allow.ShouldBe(["pub.>"]); + + // Mutating clone should not affect original. + clone.AllowedConnectionTypes.Add("MQTT"); + nkey.AllowedConnectionTypes.Count.ShouldBe(2); + + clone.Permissions.Publish.Allow = ["other.>"]; + nkey.Permissions!.Publish!.Allow.ShouldBe(["pub.>"]); + } + + [Fact] + public void NkeyUserClone_NilNkeyUser_ReturnsNull() + { + NkeyUser? nkey = null; + var clone = nkey?.Clone(); + clone.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // SubjectPermission clone tests + // ------------------------------------------------------------------------- + + [Fact] + public void SubjectPermissionClone_WithAllowAndDeny_ProducesDeepCopy() + { + var perm = new SubjectPermission + { + Allow = ["foo.>", "bar.>"], + Deny = ["baz.>"], + }; + var clone = perm.Clone(); + + clone.Allow.ShouldBe(["foo.>", "bar.>"]); + clone.Deny.ShouldBe(["baz.>"]); + + clone.Allow.Add("extra"); + perm.Allow.Count.ShouldBe(2); + } + + [Fact] + public void SubjectPermissionClone_NullLists_StaysNull() + { + var perm = new SubjectPermission(); + var clone = perm.Clone(); + clone.Allow.ShouldBeNull(); + clone.Deny.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // Permissions clone with Response + // ------------------------------------------------------------------------- + + [Fact] + public void PermissionsClone_WithResponse_ProducesDeepCopy() + { + var perms = new Permissions + { + Publish = new SubjectPermission { Allow = ["pub"] }, + Subscribe = new SubjectPermission { Deny = ["deny"] }, + Response = new ResponsePermission { MaxMsgs = 5, Expires = TimeSpan.FromMinutes(1) }, + }; + var clone = perms.Clone(); + + clone.Response.ShouldNotBeNull(); + clone.Response!.MaxMsgs.ShouldBe(5); + clone.Response.Expires.ShouldBe(TimeSpan.FromMinutes(1)); + + // Mutating clone should not affect original. + clone.Response.MaxMsgs = 10; + perms.Response!.MaxMsgs.ShouldBe(5); + } + + // ------------------------------------------------------------------------- + // User clone with AllowedConnectionTypes + // ------------------------------------------------------------------------- + + [Fact] + public void UserClone_WithAllowedConnectionTypes_ProducesDeepCopy() + { + var user = new User + { + Username = "u1", + AllowedConnectionTypes = new HashSet { "STANDARD" }, + }; + var clone = user.Clone()!; + + clone.AllowedConnectionTypes.ShouldNotBeNull(); + clone.AllowedConnectionTypes!.ShouldContain("STANDARD"); + + clone.AllowedConnectionTypes.Add("MQTT"); + user.AllowedConnectionTypes.Count.ShouldBe(1); + } + + // ------------------------------------------------------------------------- + // User clone with Account (shared by reference) + // ------------------------------------------------------------------------- + + [Fact] + public void UserClone_AccountSharedByReference() + { + var acct = new Account { Name = "TestAccount" }; + var user = new User { Username = "u1", Account = acct }; + var clone = user.Clone()!; + + // Account should be same reference. + clone.Account.ShouldBeSameAs(acct); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CipherSuitesTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CipherSuitesTests.cs new file mode 100644 index 0000000..ba577f3 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CipherSuitesTests.cs @@ -0,0 +1,82 @@ +// Copyright 2016-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Security.Authentication; +using System.Net.Security; +using Shouldly; +using ZB.MOM.NatsNet.Server.Auth; + +namespace ZB.MOM.NatsNet.Server.Tests.Auth; + +/// +/// Tests for CipherSuites definitions. +/// Mirrors Go ciphersuites.go functionality. +/// +public class CipherSuitesTests +{ + [Fact] + public void CipherMap_ContainsTls13Suites() + { + CipherSuites.CipherMap.ShouldNotBeEmpty(); + // At minimum, TLS 1.3 suites should be present. + CipherSuites.CipherMap.ShouldContainKey("TLS_AES_256_GCM_SHA384"); + CipherSuites.CipherMap.ShouldContainKey("TLS_AES_128_GCM_SHA256"); + } + + [Fact] + public void CipherMapById_ContainsTls13Suites() + { + CipherSuites.CipherMapById.ShouldNotBeEmpty(); + CipherSuites.CipherMapById.ShouldContainKey(TlsCipherSuite.TLS_AES_256_GCM_SHA384); + } + + [Fact] + public void CipherMap_CaseInsensitiveLookup() + { + // The map uses OrdinalIgnoreCase comparer. + CipherSuites.CipherMap.ShouldContainKey("tls_aes_256_gcm_sha384"); + } + + [Fact] + public void DefaultCipherSuites_ReturnsNonEmptyList() + { + var defaults = CipherSuites.DefaultCipherSuites(); + defaults.ShouldNotBeEmpty(); + defaults.Length.ShouldBeGreaterThan(0); + } + + [Fact] + public void DefaultCipherSuites_ContainsSecureSuites() + { + var defaults = CipherSuites.DefaultCipherSuites(); + defaults.ShouldContain(TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384); + defaults.ShouldContain(TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384); + } + + [Fact] + public void CurvePreferenceMap_ContainsExpectedCurves() + { + CipherSuites.CurvePreferenceMap.ShouldContainKey("X25519"); + CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP256"); + CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP384"); + CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP521"); + } + + [Fact] + public void DefaultCurvePreferences_ReturnsExpectedOrder() + { + var prefs = CipherSuites.DefaultCurvePreferences(); + prefs.Length.ShouldBeGreaterThanOrEqualTo(4); + prefs[0].ShouldBe("X25519"); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/JwtProcessorTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/JwtProcessorTests.cs new file mode 100644 index 0000000..5ec132c --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/JwtProcessorTests.cs @@ -0,0 +1,184 @@ +// Copyright 2018-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Shouldly; +using ZB.MOM.NatsNet.Server.Auth; + +namespace ZB.MOM.NatsNet.Server.Tests.Auth; + +/// +/// Tests for JwtProcessor functions. +/// Mirrors Go jwt.go functionality for standalone testable functions. +/// +public class JwtProcessorTests +{ + // ========================================================================= + // JwtPrefix constant + // ========================================================================= + + [Fact] + public void JwtPrefix_IsCorrect() + { + JwtProcessor.JwtPrefix.ShouldBe("eyJ"); + } + + // ========================================================================= + // WipeSlice + // ========================================================================= + + [Fact] + public void WipeSlice_FillsWithX() + { + var buf = new byte[] { 0x01, 0x02, 0x03 }; + JwtProcessor.WipeSlice(buf); + buf.ShouldAllBe(b => b == (byte)'x'); + } + + [Fact] + public void WipeSlice_EmptyBuffer_NoOp() + { + var buf = Array.Empty(); + JwtProcessor.WipeSlice(buf); + } + + // ========================================================================= + // ValidateSrc + // ========================================================================= + + [Fact] + public void ValidateSrc_NullCidrs_ReturnsFalse() + { + JwtProcessor.ValidateSrc(null, "192.168.1.1").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_EmptyCidrs_ReturnsTrue() + { + JwtProcessor.ValidateSrc([], "192.168.1.1").ShouldBeTrue(); + } + + [Fact] + public void ValidateSrc_EmptyHost_ReturnsFalse() + { + JwtProcessor.ValidateSrc(["192.168.0.0/16"], "").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_InvalidHost_ReturnsFalse() + { + JwtProcessor.ValidateSrc(["192.168.0.0/16"], "not-an-ip").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_MatchingCidr_ReturnsTrue() + { + JwtProcessor.ValidateSrc(["192.168.0.0/16"], "192.168.1.100").ShouldBeTrue(); + } + + [Fact] + public void ValidateSrc_NonMatchingCidr_ReturnsFalse() + { + JwtProcessor.ValidateSrc(["10.0.0.0/8"], "192.168.1.100").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_MultipleCidrs_MatchesAny() + { + var cidrs = new[] { "10.0.0.0/8", "192.168.0.0/16" }; + JwtProcessor.ValidateSrc(cidrs, "192.168.1.100").ShouldBeTrue(); + JwtProcessor.ValidateSrc(cidrs, "10.1.2.3").ShouldBeTrue(); + JwtProcessor.ValidateSrc(cidrs, "172.16.0.1").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_ExactMatch_SingleHost() + { + JwtProcessor.ValidateSrc(["192.168.1.100/32"], "192.168.1.100").ShouldBeTrue(); + JwtProcessor.ValidateSrc(["192.168.1.100/32"], "192.168.1.101").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_InvalidCidr_ReturnsFalse() + { + JwtProcessor.ValidateSrc(["not-a-cidr"], "192.168.1.1").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_Ipv6_MatchingCidr() + { + JwtProcessor.ValidateSrc(["::1/128"], "::1").ShouldBeTrue(); + JwtProcessor.ValidateSrc(["::1/128"], "::2").ShouldBeFalse(); + } + + [Fact] + public void ValidateSrc_MismatchedIpFamilies_ReturnsFalse() + { + // IPv6 CIDR with IPv4 address should not match. + JwtProcessor.ValidateSrc(["::1/128"], "127.0.0.1").ShouldBeFalse(); + } + + // ========================================================================= + // ValidateTimes + // ========================================================================= + + [Fact] + public void ValidateTimes_NullRanges_ReturnsFalse() + { + var (allowed, remaining) = JwtProcessor.ValidateTimes(null); + allowed.ShouldBeFalse(); + remaining.ShouldBe(TimeSpan.Zero); + } + + [Fact] + public void ValidateTimes_EmptyRanges_ReturnsTrue() + { + var (allowed, remaining) = JwtProcessor.ValidateTimes([]); + allowed.ShouldBeTrue(); + remaining.ShouldBe(TimeSpan.Zero); + } + + [Fact] + public void ValidateTimes_CurrentTimeInRange_ReturnsTrue() + { + var now = DateTimeOffset.Now; + var start = now.AddMinutes(-30).ToString("HH:mm:ss"); + var end = now.AddMinutes(30).ToString("HH:mm:ss"); + + var ranges = new[] { new TimeRange { Start = start, End = end } }; + var (allowed, remaining) = JwtProcessor.ValidateTimes(ranges); + allowed.ShouldBeTrue(); + remaining.TotalMinutes.ShouldBeGreaterThan(0); + remaining.TotalMinutes.ShouldBeLessThanOrEqualTo(30); + } + + [Fact] + public void ValidateTimes_CurrentTimeOutOfRange_ReturnsFalse() + { + var now = DateTimeOffset.Now; + // Set a range entirely in the past today. + var start = now.AddHours(-3).ToString("HH:mm:ss"); + var end = now.AddHours(-2).ToString("HH:mm:ss"); + + var ranges = new[] { new TimeRange { Start = start, End = end } }; + var (allowed, _) = JwtProcessor.ValidateTimes(ranges); + allowed.ShouldBeFalse(); + } + + [Fact] + public void ValidateTimes_InvalidFormat_ReturnsFalse() + { + var ranges = new[] { new TimeRange { Start = "bad", End = "data" } }; + var (allowed, _) = JwtProcessor.ValidateTimes(ranges); + allowed.ShouldBeFalse(); + } +} diff --git a/porting.db b/porting.db index 7f3048b..8f96c4c 100644 Binary files a/porting.db and b/porting.db differ diff --git a/reports/current.md b/reports/current.md index 201b585..3340e3b 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-26 17:11:07 UTC +Generated: 2026-02-26 17:27:34 UTC ## Modules (12 total) @@ -13,19 +13,19 @@ Generated: 2026-02-26 17:11:07 UTC | Status | Count | |--------|-------| -| complete | 449 | +| complete | 467 | | n_a | 82 | -| not_started | 3074 | -| stub | 68 | +| not_started | 3031 | +| stub | 93 | ## Unit Tests (3257 total) | Status | Count | |--------|-------| -| complete | 219 | +| complete | 225 | | n_a | 82 | -| not_started | 2857 | -| stub | 99 | +| not_started | 2726 | +| stub | 224 | ## Library Mappings (36 total) @@ -36,4 +36,4 @@ Generated: 2026-02-26 17:11:07 UTC ## Overall Progress -**843/6942 items complete (12.1%)** +**867/6942 items complete (12.5%)** diff --git a/reports/report_ed78a10.md b/reports/report_ed78a10.md new file mode 100644 index 0000000..3340e3b --- /dev/null +++ b/reports/report_ed78a10.md @@ -0,0 +1,39 @@ +# NATS .NET Porting Status Report + +Generated: 2026-02-26 17:27:34 UTC + +## Modules (12 total) + +| Status | Count | +|--------|-------| +| complete | 11 | +| not_started | 1 | + +## Features (3673 total) + +| Status | Count | +|--------|-------| +| complete | 467 | +| n_a | 82 | +| not_started | 3031 | +| stub | 93 | + +## Unit Tests (3257 total) + +| Status | Count | +|--------|-------| +| complete | 225 | +| n_a | 82 | +| not_started | 2726 | +| stub | 224 | + +## Library Mappings (36 total) + +| Status | Count | +|--------|-------| +| mapped | 36 | + + +## Overall Progress + +**867/6942 items complete (12.5%)**