feat: port session 06 — Authentication & JWT types, validators, cipher suites

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
This commit is contained in:
Joseph Doherty
2026-02-26 12:27:33 -05:00
parent ed78a100e2
commit 0a54d342ba
12 changed files with 1698 additions and 8 deletions

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public static partial class AuthHandler
{
/// <summary>
/// Regex matching valid bcrypt password prefixes ($2a$, $2b$, $2x$, $2y$).
/// Mirrors Go <c>validBcryptPrefix</c>.
/// </summary>
private static readonly Regex ValidBcryptPrefix = ValidBcryptPrefixRegex();
[GeneratedRegex(@"^\$2[abxy]\$\d{2}\$.*")]
private static partial Regex ValidBcryptPrefixRegex();
/// <summary>
/// Checks if a password string is a bcrypt hash.
/// Mirrors Go <c>isBcrypt</c>.
/// </summary>
public static bool IsBcrypt(string password)
{
if (password.StartsWith('$'))
{
return ValidBcryptPrefix.IsMatch(password);
}
return false;
}
/// <summary>
/// Compares a server password (possibly bcrypt-hashed) against a client-provided password.
/// Uses constant-time comparison for plaintext passwords.
/// Mirrors Go <c>comparePasswords</c>.
/// </summary>
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);
}
/// <summary>
/// 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 <c>validateResponsePermissions</c>.
/// </summary>
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;
}
}
/// <summary>
/// Known connection type strings (uppercased).
/// Mirrors Go jwt.ConnectionType* constants.
/// </summary>
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<string> Known =
[
Standard,
Websocket,
Leafnode,
LeafnodeWs,
Mqtt,
MqttWs,
InProcess,
];
public static bool IsKnown(string ct) => Known.Contains(ct);
}
/// <summary>
/// Validates allowed connection type map entries. Normalises to uppercase
/// and rejects unknown types.
/// Mirrors Go <c>validateAllowedConnectionTypes</c>.
/// </summary>
public static Exception? ValidateAllowedConnectionTypes(HashSet<string>? 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;
}
/// <summary>
/// Validates the no_auth_user setting against configured users/nkeys.
/// Mirrors Go <c>validateNoAuthUser</c>.
/// </summary>
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");
}
/// <summary>
/// Validates the auth section of options: pinned certs, connection types, and no_auth_user.
/// Mirrors Go <c>validateAuth</c>.
/// </summary>
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);
}
/// <summary>
/// Splits a DNS alt name into lowercase labels.
/// Mirrors Go <c>dnsAltNameLabels</c>.
/// </summary>
public static string[] DnsAltNameLabels(string dnsAltName)
{
return dnsAltName.ToLowerInvariant().Split('.');
}
/// <summary>
/// Checks if DNS alt name labels match any of the provided URLs (RFC 6125).
/// The wildcard '*' only matches the leftmost label.
/// Mirrors Go <c>dnsAltNameMatches</c>.
/// </summary>
public static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> 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;
}
/// <summary>
/// Wipes a byte slice by filling with 'x'. Used for clearing sensitive data.
/// Mirrors Go <c>wipeSlice</c>.
/// </summary>
public static void WipeSlice(Span<byte> buf)
{
buf.Fill((byte)'x');
}
}

View File

@@ -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;
/// <summary>
/// Represents a user authenticated via NKey.
/// Mirrors Go <c>NkeyUser</c> struct in auth.go.
/// </summary>
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<string>? AllowedConnectionTypes { get; set; }
public bool ProxyRequired { get; set; }
/// <summary>
/// Deep-clones this NkeyUser. Account is shared by reference.
/// Mirrors Go <c>NkeyUser.clone()</c>.
/// </summary>
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<string>(AllowedConnectionTypes);
}
return clone;
}
}
/// <summary>
/// Represents a user with username/password credentials.
/// Mirrors Go <c>User</c> struct in auth.go.
/// </summary>
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<string>? AllowedConnectionTypes { get; set; }
public bool ProxyRequired { get; set; }
/// <summary>
/// Deep-clones this User. Account is shared by reference.
/// Mirrors Go <c>User.clone()</c>.
/// </summary>
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<string>(AllowedConnectionTypes);
}
return clone;
}
}
/// <summary>
/// Subject-level allow/deny permission.
/// Mirrors Go <c>SubjectPermission</c> in auth.go.
/// </summary>
public class SubjectPermission
{
public List<string>? Allow { get; set; }
public List<string>? Deny { get; set; }
/// <summary>
/// Deep-clones this SubjectPermission.
/// Mirrors Go <c>SubjectPermission.clone()</c>.
/// </summary>
public SubjectPermission Clone()
{
var clone = new SubjectPermission();
if (Allow != null)
{
clone.Allow = new List<string>(Allow);
}
if (Deny != null)
{
clone.Deny = new List<string>(Deny);
}
return clone;
}
}
/// <summary>
/// Response permission for request-reply patterns.
/// Mirrors Go <c>ResponsePermission</c> in auth.go.
/// </summary>
public class ResponsePermission
{
public int MaxMsgs { get; set; }
public TimeSpan Expires { get; set; }
}
/// <summary>
/// Publish/subscribe permissions container.
/// Mirrors Go <c>Permissions</c> in auth.go.
/// </summary>
public class Permissions
{
public SubjectPermission? Publish { get; set; }
public SubjectPermission? Subscribe { get; set; }
public ResponsePermission? Response { get; set; }
/// <summary>
/// Deep-clones this Permissions struct.
/// Mirrors Go <c>Permissions.clone()</c>.
/// </summary>
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;
}
}
/// <summary>
/// Route-level import/export permissions.
/// Mirrors Go <c>RoutePermissions</c> in auth.go.
/// </summary>
public class RoutePermissions
{
public SubjectPermission? Import { get; set; }
public SubjectPermission? Export { get; set; }
}
/// <summary>
/// Stub for Account type. Full implementation in later sessions.
/// Mirrors Go <c>Account</c> struct.
/// </summary>
public class Account
{
public string Name { get; set; } = string.Empty;
}

View File

@@ -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;
/// <summary>
/// TLS cipher suite and curve preference definitions.
/// Mirrors Go <c>ciphersuites.go</c> — cipherMap, defaultCipherSuites, curvePreferenceMap,
/// defaultCurvePreferences.
/// </summary>
public static class CipherSuites
{
/// <summary>
/// Map of cipher suite names to their <see cref="TlsCipherSuite"/> values.
/// Populated at static init time — mirrors Go <c>init()</c> + <c>cipherMap</c>.
/// </summary>
public static IReadOnlyDictionary<string, TlsCipherSuite> CipherMap { get; }
/// <summary>
/// Reverse map of cipher suite ID to name.
/// Mirrors Go <c>cipherMapByID</c>.
/// </summary>
public static IReadOnlyDictionary<TlsCipherSuite, string> 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<string, TlsCipherSuite>(StringComparer.OrdinalIgnoreCase);
var byId = new Dictionary<TlsCipherSuite, string>();
foreach (TlsCipherSuite cs in Enum.GetValues(typeof(TlsCipherSuite)))
{
var name = cs.ToString();
byName.TryAdd(name, cs);
byId.TryAdd(cs, name);
}
CipherMap = byName;
CipherMapById = byId;
}
/// <summary>
/// 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 <c>defaultCipherSuites</c>.
/// </summary>
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,
];
}
/// <summary>
/// Supported named curve / key exchange preferences.
/// Mirrors Go <c>curvePreferenceMap</c>.
/// </summary>
public static IReadOnlyDictionary<string, SslApplicationProtocol> CurvePreferenceMap { get; } =
new Dictionary<string, SslApplicationProtocol>(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"),
};
/// <summary>
/// Returns the default curve preferences, ordered highest security first.
/// Mirrors Go <c>defaultCurvePreferences</c>.
/// </summary>
public static string[] DefaultCurvePreferences()
{
return
[
"X25519",
"CurveP256",
"CurveP384",
"CurveP521",
];
}
}

View File

@@ -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;
/// <summary>
/// JWT processing utilities for NATS operator/account/user JWTs.
/// Mirrors Go <c>jwt.go</c> functions.
/// Full JWT parsing will be added when a .NET JWT library equivalent is available.
/// </summary>
public static class JwtProcessor
{
/// <summary>
/// All JWTs once encoded start with this prefix.
/// Mirrors Go <c>jwtPrefix</c>.
/// </summary>
public const string JwtPrefix = "eyJ";
/// <summary>
/// Wipes a byte slice by filling with 'x', for clearing nkey seed data.
/// Mirrors Go <c>wipeSlice</c>.
/// </summary>
public static void WipeSlice(Span<byte> buf)
{
buf.Fill((byte)'x');
}
/// <summary>
/// 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 <c>validateSrc</c>.
/// </summary>
public static bool ValidateSrc(IReadOnlyList<string>? 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;
}
/// <summary>
/// Validates that the current time falls within any of the allowed time ranges.
/// Returns (allowed, remainingDuration).
/// Mirrors Go <c>validateTimes</c>.
/// </summary>
public static (bool Allowed, TimeSpan Remaining) ValidateTimes(
IReadOnlyList<TimeRange>? 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;
}
}
/// <summary>
/// Represents a time-of-day range for user access control.
/// Mirrors Go <c>jwt.TimeRange</c>.
/// </summary>
public class TimeRange
{
public string Start { get; set; } = string.Empty;
public string End { get; set; } = string.Empty;
}

View File

@@ -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<User>? Users { get; set; }
public List<NkeyUser>? Nkeys { get; set; }
public List<object> TrustedOperators { get; set; } = [];
public IAuthentication? CustomClientAuthentication { get; set; }
public IAuthentication? CustomRouterAuthentication { get; set; }