diff --git a/Libraries/SPTarkov.Server.Web/Pages/UserCredentialsPage.razor b/Libraries/SPTarkov.Server.Web/Pages/UserCredentialsPage.razor index 29e49fbba..fb175ef85 100644 --- a/Libraries/SPTarkov.Server.Web/Pages/UserCredentialsPage.razor +++ b/Libraries/SPTarkov.Server.Web/Pages/UserCredentialsPage.razor @@ -271,7 +271,13 @@ private void RefreshCredentials() { _credentials.Clear(); - _credentials.AddRange(AuthService.Credentials.OrderBy(credential => credential.Username)); + + // The panel only needs the username and role. Empty the password. + _credentials.AddRange( + AuthService.Credentials + .OrderBy(credential => credential.Username) + .Select(credential => credential with { Password = string.Empty }) + ); if (_selectedUsername is not null && _credentials.All(credential => credential.Username != _selectedUsername)) { @@ -282,7 +288,7 @@ private async Task CreateCredential() { var username = _newUsername.Trim(); - var password = _newPassword.Trim(); + var password = _newPassword; var isAdministrator = _newIsAdministrator; if (string.IsNullOrWhiteSpace(username)) @@ -306,7 +312,7 @@ } _selectedUsername = username; - _editPassword = password; + _editPassword = string.Empty; _newUsername = string.Empty; _newPassword = string.Empty; _newIsAdministrator = true; @@ -322,7 +328,7 @@ } var username = SelectedCredential.Username; - var password = _editPassword.Trim(); + var password = _editPassword; await RunCredentialAction(async () => { @@ -410,7 +416,7 @@ } _selectedUsername = args.Item.Username; - _editPassword = args.Item.Password; + _editPassword = string.Empty; _deleteConfirmation = string.Empty; return Task.CompletedTask; diff --git a/Libraries/SPTarkov.Server.Web/SPTWeb.cs b/Libraries/SPTarkov.Server.Web/SPTWeb.cs index 55022e6fc..1eb3faeb7 100644 --- a/Libraries/SPTarkov.Server.Web/SPTWeb.cs +++ b/Libraries/SPTarkov.Server.Web/SPTWeb.cs @@ -163,14 +163,15 @@ private static async Task HandleLogin(HttpContext context, AuthService var username = form["username"].ToString(); var password = form["password"].ToString(); - if (!authService.TryValidateCredentials(username, password, context, out var principal) || principal is null) + var authenticatedUser = await authService.ValidateCredentialsAsync(username, password, context); + if (authenticatedUser is null) { return Results.Redirect(AuthService.AddLoginError(failureUrl)); } await context.SignInAsync( AuthService.AuthenticationScheme, - principal, + authenticatedUser, new AuthenticationProperties { IsPersistent = true, AllowRefresh = true } ); diff --git a/Libraries/SPTarkov.Server.Web/SPTarkov.Server.Web.csproj b/Libraries/SPTarkov.Server.Web/SPTarkov.Server.Web.csproj index 9a4c2bfa6..88317953c 100644 --- a/Libraries/SPTarkov.Server.Web/SPTarkov.Server.Web.csproj +++ b/Libraries/SPTarkov.Server.Web/SPTarkov.Server.Web.csproj @@ -21,6 +21,7 @@ + diff --git a/Libraries/SPTarkov.Server.Web/Services/Argon2idPasswordHasher.cs b/Libraries/SPTarkov.Server.Web/Services/Argon2idPasswordHasher.cs new file mode 100644 index 000000000..a13999b4d --- /dev/null +++ b/Libraries/SPTarkov.Server.Web/Services/Argon2idPasswordHasher.cs @@ -0,0 +1,82 @@ +using Argon2Sharp; +using SPTarkov.DI.Annotations; + +namespace SPTarkov.Server.Web.Services; + +[Injectable(InjectionType.Singleton)] +public class Argon2idPasswordHasher : IPasswordHasher +{ + // Cost parameters for newly created hashes. RFC 9106's memory-constrained "uniformly safe" recommendation is currently (2026-06-15) + // 64 MiB of memory, 3 iterations, 4 lanes, a 128-bit salt, and a 256-bit tag. + private const int MemorySizeKB = 65536; + private const int Iterations = 3; + private const int Parallelism = 4; + private const int HashLength = 32; + private const int SaltLength = 16; + private const Argon2Type Variant = Argon2Type.Argon2id; + + // Immutable cost template reused across calls. The salt is a placeholder. + private readonly Argon2Parameters _parameters = Argon2Parameters + .CreateBuilder() + .WithType(Variant) + .WithMemorySizeKB(MemorySizeKB) + .WithIterations(Iterations) + .WithParallelism(Parallelism) + .WithHashLength(HashLength) + .WithRandomSalt(SaltLength) + .Build(); + + public Argon2idPasswordHasher() + { + DummyHash = Hash(Guid.NewGuid().ToString("N")); + } + + public string DummyHash { get; } + + public string Hash(string password) + { + return Argon2PhcFormat.HashToPhcStringWithAutoSalt(password, _parameters, SaltLength); + } + + public bool IsEncodedHash(string value) + { + // Argon2 hashes are always strings that start with the variant marker, e.g. "$argon2id$". + return !string.IsNullOrEmpty(value) && value.StartsWith("$argon2", StringComparison.Ordinal); + } + + public bool Verify(string password, string encodedHash, out bool needsRehash) + { + needsRehash = false; + + if (string.IsNullOrEmpty(encodedHash) || string.IsNullOrEmpty(password)) + { + return false; + } + + bool isValid; + Argon2Parameters? stored; + try + { + (isValid, stored) = Argon2PhcFormat.VerifyPhcString(password, encodedHash); + } + catch + { + return false; // Always fail closed. + } + + if (!isValid || stored is null) + { + return false; + } + + // Flag the hash for upgrade on the next login when it was produced with cost parameters other than current. + needsRehash = + stored.MemorySizeKB != MemorySizeKB + || stored.Iterations != Iterations + || stored.Parallelism != Parallelism + || stored.HashLength != HashLength + || stored.Type != Variant; + + return true; + } +} diff --git a/Libraries/SPTarkov.Server.Web/Services/AuthService.cs b/Libraries/SPTarkov.Server.Web/Services/AuthService.cs index bcc7c1157..3d5e533e5 100644 --- a/Libraries/SPTarkov.Server.Web/Services/AuthService.cs +++ b/Libraries/SPTarkov.Server.Web/Services/AuthService.cs @@ -1,7 +1,5 @@ using System.Net; using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; using SPTarkov.Common.Models.Logging; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.DI; @@ -11,7 +9,7 @@ namespace SPTarkov.Server.Web.Services; [Injectable(InjectionType.Singleton)] -public class AuthService(ISptLogger logger, HttpConfig httpConfig, JsonUtil jsonUtil) : IOnLoad +public class AuthService(ISptLogger logger, HttpConfig httpConfig, JsonUtil jsonUtil, IPasswordHasher passwordHasher) : IOnLoad { internal const string AuthenticationScheme = "SptWebCookie"; internal const string AdministratorPolicy = "Administrator"; @@ -71,7 +69,7 @@ internal async Task TryCreateUser(string username, string password, bool i new AuthUserCredential() { Username = username, - Password = password, + Password = passwordHasher.Hash(password), IsAdministrator = isAdministrator, } ); @@ -87,7 +85,7 @@ internal async Task TryUpdateUserPassword(string username, string password return false; } - credential.Password = password; + credential.Password = passwordHasher.Hash(password); await SaveCredentials(); return true; @@ -122,15 +120,13 @@ internal async Task TryUpdateUserAdministrator(string username, bool isAdm return true; } - internal bool TryValidateCredentials(string username, string password, HttpContext httpContext, out ClaimsPrincipal? principal) + internal async Task ValidateCredentialsAsync(string username, string password, HttpContext httpContext) { - principal = null; var authenticationConfig = httpConfig.WebAuthenticationConfig; if (!authenticationConfig.Enabled) { - principal = CreateDefaultPrincipal(); - return true; + return CreateDefaultPrincipal(); } var defaultUser = authenticationConfig.DefaultUser; @@ -138,30 +134,45 @@ internal bool TryValidateCredentials(string username, string password, HttpConte { if (!authenticationConfig.EnableDefaultUser) { - return false; + return null; } if (!authenticationConfig.AllowDefaultUserFromAnyIp && !IsLocalRequest(httpContext)) { - return false; + return null; } } if (!TryGetCredentials(username, out var credentials) || credentials is null) { - return false; + // Spend the same work on a missing user as a real verification. + _ = passwordHasher.Verify(password, passwordHasher.DummyHash, out _); + + return null; } - var usernameMatches = SecureEquals(username, credentials.Username); - var passwordMatches = SecureEquals(password, credentials.Password); + if (!passwordHasher.Verify(password, credentials.Password, out var needsRehash)) + { + return null; + } - if (!(usernameMatches & passwordMatches)) + // Upgrade hashes created with out-of-date parameters on the next successful login. + // ReSharper disable once InvertIf + if (needsRehash) { - return false; + credentials.Password = passwordHasher.Hash(password); + + try + { + await SaveCredentials(); + } + catch (Exception exception) + { + logger.Warning($"Failed to persist rehashed web credential for '{credentials.Username}': {exception.Message}"); + } } - principal = CreatePrincipal(credentials.Username, credentials.IsAdministrator); - return true; + return CreatePrincipal(credentials.Username, credentials.IsAdministrator); } internal static string GetSafeReturnUrl(string? returnUrl) @@ -229,14 +240,6 @@ private static bool IsLocalRequest(HttpContext httpContext) return localIpAddress is not null && remoteIpAddress.Equals(localIpAddress); } - private static bool SecureEquals(string suppliedValue, string expectedValue) - { - var suppliedBytes = Encoding.UTF8.GetBytes(suppliedValue); - var expectedBytes = Encoding.UTF8.GetBytes(expectedValue); - - return CryptographicOperations.FixedTimeEquals(suppliedBytes, expectedBytes); - } - private async Task CreateOrLoadUserCredentials() { var fullPath = Path.GetFullPath(UserCredentialsPath); @@ -253,13 +256,48 @@ private async Task CreateOrLoadUserCredentials() await jsonUtil.DeserializeFromFileAsync>(credentialsPath) ?? throw new NullReferenceException("Could not deserialize credentials.json"); + await MigratePlaintextCredentials(); + return; } - _credentials = new List([httpConfig.WebAuthenticationConfig.DefaultUser]); + // Seed from the config default user, hashing its plaintext password before it touches disk. + var defaultUser = httpConfig.WebAuthenticationConfig.DefaultUser; + _credentials = + [ + new AuthUserCredential + { + Username = defaultUser.Username, + Password = passwordHasher.Hash(defaultUser.Password), + IsAdministrator = defaultUser.IsAdministrator, + }, + ]; await SaveCredentials(); } + private async Task MigratePlaintextCredentials() + { + // Unhashed, plaintext passwords are hashed in place. This allows server administrators to manually set account passwords and have + // them hashed on next server reload. Empty entries are left alone as they cannot be meaningfully hashed. + var migratedCount = 0; + foreach (var credential in _credentials) + { + if (string.IsNullOrEmpty(credential.Password) || passwordHasher.IsEncodedHash(credential.Password)) + { + continue; + } + + credential.Password = passwordHasher.Hash(credential.Password); + migratedCount++; + } + + if (migratedCount > 0) + { + await SaveCredentials(); + logger.Warning($"Migrated {migratedCount} plaintext web credentials to hashes."); + } + } + private async Task SaveCredentials(List? credentials = null) { var text = jsonUtil.Serialize(credentials ?? _credentials); diff --git a/Libraries/SPTarkov.Server.Web/Services/IPasswordHasher.cs b/Libraries/SPTarkov.Server.Web/Services/IPasswordHasher.cs new file mode 100644 index 000000000..61bd46737 --- /dev/null +++ b/Libraries/SPTarkov.Server.Web/Services/IPasswordHasher.cs @@ -0,0 +1,36 @@ +namespace SPTarkov.Server.Web.Services; + +/// +/// Hashes and verifies web-panel account passwords. +/// +public interface IPasswordHasher +{ + /// + /// Hashes a plaintext password into a hash suitable for storage. + /// + string Hash(string password); + + /// + /// Verifies a plaintext password against a previously stored encoded hash. Returns false for a malformed or + /// corrupted hash. + /// + /// The plaintext password supplied at login. + /// The stored encoded hash to verify against. + /// + /// True when the password was correct but the stored hash was produced with cost parameters that differ from the + /// current ones, so the caller should re-hash and persist it. Always false on a failed verification. + /// + bool Verify(string password, string encodedHash, out bool needsRehash); + + /// + /// Returns true when the supplied value is already one of this hasher's encoded hashes, rather than a plaintext + /// secret. Used to detect pre-hashing credentials that still need migrating. + /// + bool IsEncodedHash(string value); + + /// + /// A throwaway hash whose verification cost matches a real one. Verify a failed login against this so response + /// timing is the same whether the supplied username exists. + /// + string DummyHash { get; } +} diff --git a/Testing/UnitTests/Tests/Services/Argon2idPasswordHasherTests.cs b/Testing/UnitTests/Tests/Services/Argon2idPasswordHasherTests.cs new file mode 100644 index 000000000..59f8a28b0 --- /dev/null +++ b/Testing/UnitTests/Tests/Services/Argon2idPasswordHasherTests.cs @@ -0,0 +1,101 @@ +using Argon2Sharp; +using NUnit.Framework; +using SPTarkov.Server.Web.Services; + +namespace UnitTests.Tests.Services; + +[TestFixture] +public class Argon2idPasswordHasherTests +{ + private Argon2idPasswordHasher _hasher; + + [OneTimeSetUp] + public void Initialize() + { + _hasher = new Argon2idPasswordHasher(); + } + + [Test] + public void HashProducesArgon2idEncodedString() + { + var hash = _hasher.Hash("correct horse battery staple"); + + Assert.That(hash, Does.StartWith("$argon2id$")); + } + + [Test] + public void VerifyReturnsTrueForCorrectPassword() + { + const string password = "correct horse battery staple"; + var hash = _hasher.Hash(password); + + Assert.That(_hasher.Verify(password, hash, out _), Is.True); + } + + [Test] + public void VerifyReturnsFalseForWrongPassword() + { + var hash = _hasher.Hash("correct horse battery staple"); + + Assert.That(_hasher.Verify("staple battery horse correct", hash, out _), Is.False); + } + + [Test] + public void VerifyDoesNotFlagRehashForCurrentParameters() + { + const string password = "correct horse battery staple"; + var hash = _hasher.Hash(password); + var ok = _hasher.Verify(password, hash, out var needsRehash); + + Assert.That(ok, Is.True); + Assert.That(needsRehash, Is.False); + } + + [Test] + public void VerifyFlagsRehashWhenStoredParametersDiffer() + { + const string password = "correct horse battery staple"; + + // A hash produced with weaker cost parameters than the hasher's current settings should verify but also be flagged for upgrade. + var staleHash = Argon2PhcFormat.HashToPhcStringWithAutoSalt(password); + + var ok = _hasher.Verify(password, staleHash, out var needsRehash); + + Assert.That(ok, Is.True); + Assert.That(needsRehash, Is.True); + } + + [Test] + public void HashUsesRandomSaltSoEqualPasswordsHashDifferently() + { + const string password = "correct horse battery staple"; + + Assert.That(_hasher.Hash(password), Is.Not.EqualTo(_hasher.Hash(password))); + } + + [Test] + public void VerifyFailsClosedOnMalformedHash() + { + Assert.That(_hasher.Verify("correct horse battery staple", "not a hash", out _), Is.False); + Assert.That(_hasher.Verify("correct horse battery staple", string.Empty, out _), Is.False); + } + + [Test] + public void DummyHashIsAValidArgon2idHash() + { + Assert.That(_hasher.DummyHash, Does.StartWith("$argon2id$")); + } + + [Test] + public void IsEncodedHashRecognizesOwnOutput() + { + Assert.That(_hasher.IsEncodedHash(_hasher.Hash("correct horse battery staple")), Is.True); + } + + [Test] + public void IsEncodedHashRejectsPlaintextAndEmpty() + { + Assert.That(_hasher.IsEncodedHash("correct horse battery staple"), Is.False); + Assert.That(_hasher.IsEncodedHash(string.Empty), Is.False); + } +}