From 0a54d342baf984a030685932a1962fe4df2b23e8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 26 Feb 2026 12:27:33 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20port=20session=2006=20=E2=80=94=20Authe?= =?UTF-8?q?ntication=20&=20JWT=20types,=20validators,=20cipher=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port independently-testable auth functions from auth.go, ciphersuites.go, and jwt.go. Server-dependent methods (configureAuthorization, checkAuthentication, auth callout, etc.) are stubbed for later sessions. - AuthTypes: User, NkeyUser, SubjectPermission, ResponsePermission, Permissions, RoutePermissions, Account — all with deep Clone() methods - AuthHandler: IsBcrypt, ComparePasswords, ValidateResponsePermissions, ValidateAllowedConnectionTypes, ValidateNoAuthUser, ValidateAuth, DnsAltNameLabels, DnsAltNameMatches, WipeSlice, ConnectionTypes constants - CipherSuites: CipherMap, CipherMapById, DefaultCipherSuites, CurvePreferenceMap, DefaultCurvePreferences - JwtProcessor: JwtPrefix, WipeSlice, ValidateSrc (CIDR matching), ValidateTimes (time-of-day ranges), TimeRange type - ServerOptions: added Users, Nkeys, TrustedOperators properties - 67 new unit tests (all 328 tests pass) - DB: 18 features complete, 25 stubbed; 6 Go tests complete, 125 stubbed --- .../ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs | 273 +++++++++++++ .../ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs | 176 ++++++++ .../Auth/CipherSuites.cs | 110 +++++ .../Auth/JwtProcessor.cs | 192 +++++++++ .../ZB.MOM.NatsNet.Server/ServerOptions.cs | 4 + .../Auth/AuthHandlerTests.cs | 384 ++++++++++++++++++ .../Auth/AuthTypesTests.cs | 246 +++++++++++ .../Auth/CipherSuitesTests.cs | 82 ++++ .../Auth/JwtProcessorTests.cs | 184 +++++++++ porting.db | Bin 2469888 -> 2469888 bytes reports/current.md | 16 +- reports/report_ed78a10.md | 39 ++ 12 files changed, 1698 insertions(+), 8 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthHandlerTests.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CipherSuitesTests.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/JwtProcessorTests.cs create mode 100644 reports/report_ed78a10.md 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 7f3048b8d8e936b1403181d22556ccb9dc9fb83c..8f96c4cc918d0a1e88dd74cecc13f7a974b80846 100644 GIT binary patch delta 16928 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 HV#fSGtUJQQ 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%)**