feat: add authenticators, Account, and ClientPermissions (Tasks 3-7, 9)

- Account: per-account SubList and client tracking
- IAuthenticator interface, AuthResult, ClientAuthContext
- TokenAuthenticator: constant-time token comparison
- UserPasswordAuthenticator: multi-user with bcrypt/plain support
- SimpleUserPasswordAuthenticator: single user/pass config
- NKeyAuthenticator: Ed25519 nonce signature verification
- ClientPermissions: SubList-based publish/subscribe authorization
This commit is contained in:
Joseph Doherty
2026-02-22 22:41:45 -05:00
parent 562f89744d
commit 6ebe791c6d
8 changed files with 787 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
/// <summary>
/// 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.
/// </summary>
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);
}
}