diff --git a/src/NATS.Server/Auth/ClientPermissions.cs b/src/NATS.Server/Auth/ClientPermissions.cs new file mode 100644 index 0000000..63aa3b1 --- /dev/null +++ b/src/NATS.Server/Auth/ClientPermissions.cs @@ -0,0 +1,121 @@ +using System.Collections.Concurrent; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Auth; + +public sealed class ClientPermissions : IDisposable +{ + private readonly PermissionSet? _publish; + private readonly PermissionSet? _subscribe; + private readonly ConcurrentDictionary _pubCache = new(StringComparer.Ordinal); + + private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe) + { + _publish = publish; + _subscribe = subscribe; + } + + public static ClientPermissions? Build(Permissions? permissions) + { + if (permissions == null) + return null; + + var pub = PermissionSet.Build(permissions.Publish); + var sub = PermissionSet.Build(permissions.Subscribe); + + if (pub == null && sub == null) + return null; + + return new ClientPermissions(pub, sub); + } + + public bool IsPublishAllowed(string subject) + { + if (_publish == null) + return true; + + return _pubCache.GetOrAdd(subject, s => _publish.IsAllowed(s)); + } + + public bool IsSubscribeAllowed(string subject, string? queue = null) + { + if (_subscribe == null) + return true; + + return _subscribe.IsAllowed(subject); + } + + public void Dispose() + { + _publish?.Dispose(); + _subscribe?.Dispose(); + } +} + +public sealed class PermissionSet : IDisposable +{ + private readonly SubList? _allow; + private readonly SubList? _deny; + + private PermissionSet(SubList? allow, SubList? deny) + { + _allow = allow; + _deny = deny; + } + + public static PermissionSet? Build(SubjectPermission? permission) + { + if (permission == null) + return null; + + bool hasAllow = permission.Allow is { Count: > 0 }; + bool hasDeny = permission.Deny is { Count: > 0 }; + + if (!hasAllow && !hasDeny) + return null; + + SubList? allow = null; + SubList? deny = null; + + if (hasAllow) + { + allow = new SubList(); + foreach (var subject in permission.Allow!) + allow.Insert(new Subscription { Subject = subject, Sid = "_perm_" }); + } + + if (hasDeny) + { + deny = new SubList(); + foreach (var subject in permission.Deny!) + deny.Insert(new Subscription { Subject = subject, Sid = "_perm_" }); + } + + return new PermissionSet(allow, deny); + } + + public bool IsAllowed(string subject) + { + bool allowed = true; + + if (_allow != null) + { + var result = _allow.Match(subject); + allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0; + } + + if (allowed && _deny != null) + { + var result = _deny.Match(subject); + allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0; + } + + return allowed; + } + + public void Dispose() + { + _allow?.Dispose(); + _deny?.Dispose(); + } +} diff --git a/src/NATS.Server/Auth/NKeyAuthenticator.cs b/src/NATS.Server/Auth/NKeyAuthenticator.cs new file mode 100644 index 0000000..69c1129 --- /dev/null +++ b/src/NATS.Server/Auth/NKeyAuthenticator.cs @@ -0,0 +1,66 @@ +using NATS.NKeys; + +namespace NATS.Server.Auth; + +/// +/// Authenticates clients using NKey (Ed25519) public-key signature verification. +/// The server sends a random nonce in the INFO message. The client signs the nonce +/// with their private key and sends the public key + base64-encoded signature in CONNECT. +/// The server verifies the signature against the registered NKey users. +/// +/// +/// Reference: golang/nats-server/server/auth.go — checkNKeyAuth +/// +public sealed class NKeyAuthenticator(IEnumerable nkeyUsers) : IAuthenticator +{ + private readonly Dictionary _nkeys = nkeyUsers.ToDictionary( + u => u.Nkey, + u => u, + StringComparer.Ordinal); + + public AuthResult? Authenticate(ClientAuthContext context) + { + var clientNkey = context.Opts.Nkey; + if (string.IsNullOrEmpty(clientNkey)) + return null; + + if (!_nkeys.TryGetValue(clientNkey, out var nkeyUser)) + return null; + + var clientSig = context.Opts.Sig; + if (string.IsNullOrEmpty(clientSig)) + return null; + + try + { + // Decode base64 signature (handle both standard and URL-safe base64) + byte[] sigBytes; + try + { + sigBytes = Convert.FromBase64String(clientSig); + } + catch (FormatException) + { + // Try URL-safe base64 by converting to standard base64 + var padded = clientSig.Replace('-', '+').Replace('_', '/'); + padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + sigBytes = Convert.FromBase64String(padded); + } + + var kp = KeyPair.FromPublicKey(clientNkey); + if (!kp.Verify(context.Nonce, sigBytes)) + return null; + } + catch + { + return null; + } + + return new AuthResult + { + Identity = clientNkey, + AccountName = nkeyUser.Account, + Permissions = nkeyUser.Permissions, + }; + } +} diff --git a/src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs b/src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs new file mode 100644 index 0000000..28387ed --- /dev/null +++ b/src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs @@ -0,0 +1,61 @@ +using System.Security.Cryptography; +using System.Text; + +namespace NATS.Server.Auth; + +/// +/// Authenticates a single username/password pair configured on the server. +/// Supports plain-text and bcrypt-hashed passwords. +/// Uses constant-time comparison for both username and password to prevent timing attacks. +/// Reference: golang/nats-server/server/auth.go checkClientAuth for single user. +/// +public sealed class SimpleUserPasswordAuthenticator : IAuthenticator +{ + private readonly byte[] _expectedUsername; + private readonly string _serverPassword; + + public SimpleUserPasswordAuthenticator(string username, string password) + { + _expectedUsername = Encoding.UTF8.GetBytes(username); + _serverPassword = password; + } + + public AuthResult? Authenticate(ClientAuthContext context) + { + var clientUsername = context.Opts.Username; + if (string.IsNullOrEmpty(clientUsername)) + return null; + + var clientUsernameBytes = Encoding.UTF8.GetBytes(clientUsername); + if (!CryptographicOperations.FixedTimeEquals(clientUsernameBytes, _expectedUsername)) + return null; + + var clientPassword = context.Opts.Password ?? string.Empty; + + if (!ComparePasswords(_serverPassword, clientPassword)) + return null; + + return new AuthResult { Identity = clientUsername }; + } + + private static bool ComparePasswords(string serverPassword, string clientPassword) + { + // Bcrypt hashes start with "$2" (e.g., $2a$, $2b$, $2y$) + if (serverPassword.StartsWith("$2")) + { + try + { + return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword); + } + catch + { + return false; + } + } + + // Plain-text: constant-time comparison to prevent timing attacks + var serverBytes = Encoding.UTF8.GetBytes(serverPassword); + var clientBytes = Encoding.UTF8.GetBytes(clientPassword); + return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes); + } +} diff --git a/src/NATS.Server/Auth/UserPasswordAuthenticator.cs b/src/NATS.Server/Auth/UserPasswordAuthenticator.cs new file mode 100644 index 0000000..0b78c5f --- /dev/null +++ b/src/NATS.Server/Auth/UserPasswordAuthenticator.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; + +namespace NATS.Server.Auth; + +/// +/// Authenticates clients by looking up username in a dictionary and comparing +/// the password using bcrypt (for $2-prefixed hashes) or constant-time comparison +/// (for plain text passwords). +/// Reference: golang/nats-server/server/auth.go checkClientPassword. +/// +public sealed class UserPasswordAuthenticator : IAuthenticator +{ + private readonly Dictionary _users; + + public UserPasswordAuthenticator(IEnumerable users) + { + _users = new Dictionary(StringComparer.Ordinal); + foreach (var user in users) + _users[user.Username] = user; + } + + public AuthResult? Authenticate(ClientAuthContext context) + { + var username = context.Opts.Username; + if (string.IsNullOrEmpty(username)) + return null; + + if (!_users.TryGetValue(username, out var user)) + return null; + + var clientPassword = context.Opts.Password ?? string.Empty; + + if (!ComparePasswords(user.Password, clientPassword)) + return null; + + return new AuthResult + { + Identity = user.Username, + AccountName = user.Account, + Permissions = user.Permissions, + Expiry = user.ConnectionDeadline, + }; + } + + private static bool ComparePasswords(string serverPassword, string clientPassword) + { + if (IsBcrypt(serverPassword)) + { + try + { + return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword); + } + catch + { + return false; + } + } + + var serverBytes = Encoding.UTF8.GetBytes(serverPassword); + var clientBytes = Encoding.UTF8.GetBytes(clientPassword); + return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes); + } + + private static bool IsBcrypt(string password) => password.StartsWith("$2"); +} diff --git a/tests/NATS.Server.Tests/ClientPermissionsTests.cs b/tests/NATS.Server.Tests/ClientPermissionsTests.cs new file mode 100644 index 0000000..d2882c0 --- /dev/null +++ b/tests/NATS.Server.Tests/ClientPermissionsTests.cs @@ -0,0 +1,107 @@ +using NATS.Server.Auth; + +namespace NATS.Server.Tests; + +public class ClientPermissionsTests +{ + [Fact] + public void No_permissions_allows_everything() + { + var perms = ClientPermissions.Build(null); + perms.ShouldBeNull(); + } + + [Fact] + public void Publish_allow_list_only() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>", "bar.*"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + perms.IsPublishAllowed("foo.bar.baz").ShouldBeTrue(); + perms.IsPublishAllowed("bar.one").ShouldBeTrue(); + perms.IsPublishAllowed("baz.one").ShouldBeFalse(); + } + + [Fact] + public void Publish_deny_list_only() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission { Deny = ["secret.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + perms.IsPublishAllowed("secret.data").ShouldBeFalse(); + perms.IsPublishAllowed("secret.nested.deep").ShouldBeFalse(); + } + + [Fact] + public void Publish_allow_and_deny() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission + { + Allow = ["events.>"], + Deny = ["events.internal.>"], + }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("events.public.data").ShouldBeTrue(); + perms.IsPublishAllowed("events.internal.secret").ShouldBeFalse(); + } + + [Fact] + public void Subscribe_allow_list() + { + var perms = ClientPermissions.Build(new Permissions + { + Subscribe = new SubjectPermission { Allow = ["data.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsSubscribeAllowed("data.updates").ShouldBeTrue(); + perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse(); + } + + [Fact] + public void Subscribe_deny_list() + { + var perms = ClientPermissions.Build(new Permissions + { + Subscribe = new SubjectPermission { Deny = ["admin.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsSubscribeAllowed("data.updates").ShouldBeTrue(); + perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse(); + } + + [Fact] + public void Publish_cache_returns_same_result() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + perms.IsPublishAllowed("baz.bar").ShouldBeFalse(); + perms.IsPublishAllowed("baz.bar").ShouldBeFalse(); + } + + [Fact] + public void Empty_permissions_object_allows_everything() + { + var perms = ClientPermissions.Build(new Permissions()); + perms.ShouldBeNull(); + } +} diff --git a/tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs b/tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs new file mode 100644 index 0000000..d7941f6 --- /dev/null +++ b/tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs @@ -0,0 +1,130 @@ +using NATS.NKeys; +using NATS.Server.Auth; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests; + +public class NKeyAuthenticatorTests +{ + private static (string PublicKey, string SignatureBase64) CreateSignedNonce(byte[] nonce) + { + var kp = KeyPair.CreatePair(PrefixByte.User); + var publicKey = kp.GetPublicKey(); + var sig = new byte[64]; + kp.Sign(nonce, sig); + var sigBase64 = Convert.ToBase64String(sig); + return (publicKey, sigBase64); + } + + private static string SignNonce(KeyPair kp, byte[] nonce) + { + var sig = new byte[64]; + kp.Sign(nonce, sig); + return Convert.ToBase64String(sig); + } + + [Fact] + public void Returns_result_for_valid_signature() + { + var kp = KeyPair.CreatePair(PrefixByte.User); + var publicKey = kp.GetPublicKey(); + var nonce = "test-nonce-123"u8.ToArray(); + var sigBase64 = SignNonce(kp, nonce); + + var nkeyUser = new NKeyUser { Nkey = publicKey }; + var auth = new NKeyAuthenticator([nkeyUser]); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 }, + Nonce = nonce, + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe(publicKey); + } + + [Fact] + public void Returns_null_for_invalid_signature() + { + var kp = KeyPair.CreatePair(PrefixByte.User); + var publicKey = kp.GetPublicKey(); + var nonce = "test-nonce-123"u8.ToArray(); + + var nkeyUser = new NKeyUser { Nkey = publicKey }; + var auth = new NKeyAuthenticator([nkeyUser]); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Nkey = publicKey, Sig = Convert.ToBase64String(new byte[64]) }, + Nonce = nonce, + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_for_unknown_nkey() + { + var kp = KeyPair.CreatePair(PrefixByte.User); + var publicKey = kp.GetPublicKey(); + var nonce = "test-nonce-123"u8.ToArray(); + var sigBase64 = SignNonce(kp, nonce); + + var auth = new NKeyAuthenticator([]); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 }, + Nonce = nonce, + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_when_no_nkey_provided() + { + var kp = KeyPair.CreatePair(PrefixByte.User); + var publicKey = kp.GetPublicKey(); + var nkeyUser = new NKeyUser { Nkey = publicKey }; + var auth = new NKeyAuthenticator([nkeyUser]); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions(), + Nonce = "nonce"u8.ToArray(), + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_permissions_from_nkey_user() + { + var kp = KeyPair.CreatePair(PrefixByte.User); + var publicKey = kp.GetPublicKey(); + var nonce = "test-nonce"u8.ToArray(); + var sigBase64 = SignNonce(kp, nonce); + + var perms = new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>"] }, + }; + var nkeyUser = new NKeyUser { Nkey = publicKey, Permissions = perms }; + var auth = new NKeyAuthenticator([nkeyUser]); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 }, + Nonce = nonce, + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Permissions.ShouldBe(perms); + } +} diff --git a/tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs b/tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs new file mode 100644 index 0000000..fc31e13 --- /dev/null +++ b/tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs @@ -0,0 +1,116 @@ +using NATS.Server.Auth; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests; + +public class SimpleUserPasswordAuthenticatorTests +{ + [Fact] + public void Returns_result_for_correct_credentials() + { + var auth = new SimpleUserPasswordAuthenticator("admin", "password123"); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "admin", Password = "password123" }, + Nonce = [], + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe("admin"); + } + + [Fact] + public void Returns_null_for_wrong_username() + { + var auth = new SimpleUserPasswordAuthenticator("admin", "password123"); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "wrong", Password = "password123" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_for_wrong_password() + { + var auth = new SimpleUserPasswordAuthenticator("admin", "password123"); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "admin", Password = "wrong" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_for_null_username() + { + var auth = new SimpleUserPasswordAuthenticator("admin", "password123"); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = null, Password = "password123" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_for_empty_username() + { + var auth = new SimpleUserPasswordAuthenticator("admin", "password123"); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "", Password = "password123" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_for_null_password() + { + var auth = new SimpleUserPasswordAuthenticator("admin", "password123"); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "admin", Password = null }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Supports_bcrypt_password() + { + var hash = BCrypt.Net.BCrypt.HashPassword("secret"); + var auth = new SimpleUserPasswordAuthenticator("admin", hash); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "admin", Password = "secret" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldNotBeNull(); + } + + [Fact] + public void Rejects_wrong_password_with_bcrypt() + { + var hash = BCrypt.Net.BCrypt.HashPassword("secret"); + var auth = new SimpleUserPasswordAuthenticator("admin", hash); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "admin", Password = "wrongpassword" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } +} diff --git a/tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs b/tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs new file mode 100644 index 0000000..2a70a85 --- /dev/null +++ b/tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs @@ -0,0 +1,120 @@ +using NATS.Server.Auth; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests; + +public class UserPasswordAuthenticatorTests +{ + private static UserPasswordAuthenticator CreateAuth(params User[] users) + { + return new UserPasswordAuthenticator(users); + } + + [Fact] + public void Returns_result_for_correct_plain_password() + { + var auth = CreateAuth(new User { Username = "alice", Password = "secret" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "secret" }, + Nonce = [], + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe("alice"); + } + + [Fact] + public void Returns_result_for_correct_bcrypt_password() + { + var hash = BCrypt.Net.BCrypt.HashPassword("secret"); + var auth = CreateAuth(new User { Username = "bob", Password = hash }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "bob", Password = "secret" }, + Nonce = [], + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe("bob"); + } + + [Fact] + public void Returns_null_for_wrong_password() + { + var auth = CreateAuth(new User { Username = "alice", Password = "secret" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "wrong" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_for_unknown_user() + { + var auth = CreateAuth(new User { Username = "alice", Password = "secret" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "unknown", Password = "secret" }, + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_null_when_no_username_provided() + { + var auth = CreateAuth(new User { Username = "alice", Password = "secret" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions(), + Nonce = [], + }; + + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Returns_permissions_from_user() + { + var perms = new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>"] }, + }; + var auth = CreateAuth(new User { Username = "alice", Password = "secret", Permissions = perms }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "secret" }, + Nonce = [], + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Permissions.ShouldBe(perms); + } + + [Fact] + public void Returns_account_name_from_user() + { + var auth = CreateAuth(new User { Username = "alice", Password = "secret", Account = "myaccount" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "secret" }, + Nonce = [], + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.AccountName.ShouldBe("myaccount"); + } +}