From e55a1b01f4a3e341ce6ddf9365c47dfc70c27ca0 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 18 Feb 2026 21:06:22 +0100 Subject: [PATCH] Migrate current password hashing to Argon2id --- API.md | 2 +- GameList.Tests/AuthTests.cs | 1 + GameList.Tests/HelperTests.cs | 1 + GameList.csproj | 1 + Infrastructure/PasswordHasher.cs | 40 ++++++++++++++++++++++++-------- README.md | 6 ++--- TESTS.md | 2 +- wwwroot/data/i18n/faq/de.md | 4 ++++ wwwroot/data/i18n/faq/en.md | 4 ++++ 9 files changed, 46 insertions(+), 15 deletions(-) diff --git a/API.md b/API.md index ff733bc..484daf8 100644 --- a/API.md +++ b/API.md @@ -58,4 +58,4 @@ Owner restrictions: owner role/admin status cannot be changed, and owner account - CSP is tightened to disallow inline styles and insecure image origins (`img-src` excludes `http:`). - In production, HTTPS redirection and HSTS are enabled. - Screenshot URL validation rejects private/reserved address ranges and pins outbound connections to validated public IPs. -- Password hashing is versioned; legacy hashes are transparently upgraded on successful login/admin password confirmation. +- Password hashing is versioned with Argon2id as current; legacy hashes are transparently upgraded on successful login/admin password confirmation. diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index 6107cc5..678b35e 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -34,6 +34,7 @@ public class AuthTests Assert.True(player.DisplayName!.Length <= 16); Assert.NotEqual(Array.Empty(), player.PasswordHash); Assert.NotEqual(Array.Empty(), player.PasswordSalt); + Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion); }); } diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 1f1a935..02c9011 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -27,6 +27,7 @@ public class HelperTests var (legacyHash, legacySalt) = PasswordHasher.HashPassword("secret", PasswordHasher.LegacyVersion); Assert.True(PasswordHasher.Verify("secret", legacyHash, legacySalt, PasswordHasher.LegacyVersion, out var legacyNeedsRehash)); Assert.True(legacyNeedsRehash); + Assert.False(PasswordHasher.Verify("secret", hash, salt, 999, out _)); Assert.False(PasswordHasher.Verify("other", hash, salt)); Assert.Throws(() => PasswordHasher.HashPassword("")); diff --git a/GameList.csproj b/GameList.csproj index 9ad5e5c..f246219 100644 --- a/GameList.csproj +++ b/GameList.csproj @@ -7,6 +7,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Infrastructure/PasswordHasher.cs b/Infrastructure/PasswordHasher.cs index e760ad9..c065e91 100644 --- a/Infrastructure/PasswordHasher.cs +++ b/Infrastructure/PasswordHasher.cs @@ -1,17 +1,22 @@ using System.Security.Cryptography; using System.Text; +using Konscious.Security.Cryptography; namespace GameList.Infrastructure; public static class PasswordHasher { public const int LegacyVersion = 1; - public const int CurrentVersion = 2; + public const int Pbkdf2Version = 2; + public const int CurrentVersion = 3; private const int SaltSize = 16; private const int KeySize = 32; private const int IterationsV1 = 210_000; private const int IterationsV2 = 350_000; + private const int Argon2Iterations = 2; + private const int Argon2MemoryKiB = 19_456; + private const int Argon2DegreeOfParallelism = 1; public static (byte[] Hash, byte[] Salt) HashPassword(string password) => HashPassword(password, CurrentVersion); @@ -23,12 +28,12 @@ public static class PasswordHasher var normalizedVersion = NormalizeHashVersion(version); var salt = RandomNumberGenerator.GetBytes(SaltSize); - var hash = PBKDF2(password, salt, normalizedVersion); + var hash = Derive(password, salt, normalizedVersion); return (hash, salt); } public static bool Verify(string password, byte[] hash, byte[] salt) - => Verify(password, hash, salt, LegacyVersion, out _); + => Verify(password, hash, salt, CurrentVersion, out _); public static bool Verify(string password, byte[] hash, byte[] salt, int version, out bool needsRehash) { @@ -40,7 +45,7 @@ public static class PasswordHasher if (normalizedVersion == 0) return false; - var computed = PBKDF2(password, salt, normalizedVersion); + var computed = Derive(password, salt, normalizedVersion); var verified = CryptographicOperations.FixedTimeEquals(computed, hash); needsRehash = verified && normalizedVersion < CurrentVersion; @@ -52,6 +57,7 @@ public static class PasswordHasher return version switch { <= LegacyVersion => LegacyVersion, + Pbkdf2Version => Pbkdf2Version, CurrentVersion => CurrentVersion, _ => throw new ArgumentOutOfRangeException(nameof(version), "Unsupported password hash version.") }; @@ -62,24 +68,38 @@ public static class PasswordHasher return version switch { <= LegacyVersion => LegacyVersion, + Pbkdf2Version => Pbkdf2Version, CurrentVersion => CurrentVersion, _ => 0 }; } - private static int ResolveIterations(int version) + private static byte[] Derive(string password, byte[] salt, int version) { return version switch { - LegacyVersion => IterationsV1, - CurrentVersion => IterationsV2, - _ => IterationsV1 + LegacyVersion => PBKDF2(password, salt, IterationsV1), + Pbkdf2Version => PBKDF2(password, salt, IterationsV2), + CurrentVersion => Argon2id(password, salt), + _ => throw new ArgumentOutOfRangeException(nameof(version), "Unsupported password hash version.") }; } - private static byte[] PBKDF2(string password, byte[] salt, int version) + private static byte[] PBKDF2(string password, byte[] salt, int iterations) { - var iterations = ResolveIterations(version); return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, KeySize); } + + private static byte[] Argon2id(string password, byte[] salt) + { + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = salt, + Iterations = Argon2Iterations, + MemorySize = Argon2MemoryKiB, + DegreeOfParallelism = Argon2DegreeOfParallelism + }; + + return argon2.GetBytes(KeySize); + } } diff --git a/README.md b/README.md index 80e5412..e946835 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS - Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. - CSRF baseline: authenticated mutating API requests require same-origin `Origin`/`Referer` headers. -- Password hashes are versioned and upgraded on successful login/admin-password verification; current rollout upgrades legacy PBKDF2 parameters and prepares further migration hardening. +- Password hashes are versioned and upgraded on successful login/admin-password verification; current rollout uses Argon2id for new hashes while transparently upgrading legacy PBKDF2 hashes. ## Password Hash Migration Plan 1. Existing hashes remain valid under versioned verification (`LegacyVersion=1`). -2. Successful authentication transparently rehashes credentials to `CurrentVersion=2` and persists the upgraded hash metadata. -3. Future migration can introduce Argon2id as a new version without breaking existing users, then retire legacy versions after full rollout. +2. Successful authentication transparently rehashes credentials to `CurrentVersion=3` (Argon2id) and persists the upgraded hash metadata. +3. Legacy versions can be retired after full rollout once no remaining accounts depend on them. ## Module Ownership diff --git a/TESTS.md b/TESTS.md index 49a4c3b..478ee0d 100644 --- a/TESTS.md +++ b/TESTS.md @@ -38,7 +38,7 @@ stateDiagram-v2 - Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks. - `/api/auth/options` reports owner presence for registration UI behavior. - Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits. -- Successful login upgrades legacy password-hash versions to current hash parameters. +- Successful login upgrades legacy password-hash versions to current Argon2id parameters. - Logout clears cookie. - EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth. - Cookie contains admin claim; non-admin cookie cannot access admin routes (401/403 via filter). diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 9a4a8aa..5d54a55 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -11,6 +11,10 @@ Registriere dich mit: Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen und Bewertungen. +### Wie werden Passwörter geschützt? + +Passwörter werden niemals im Klartext gespeichert. Pick'n'Play speichert gesalzene, versionierte Passwort-Hashes. Neue und aktualisierte Hashes verwenden Argon2id, während ältere Hash-Versionen nach erfolgreicher Anmeldung oder Admin-Passwort-Bestätigung transparent aktualisiert werden. + ### Brauche ich Admin-Rechte? Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden. diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index 8e289a7..bb3a3a2 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -11,6 +11,10 @@ Register with: Your display name is required ‒ it appears next to all of your suggestions and scores. +### How are passwords protected? + +Passwords are never stored in plain text. Pick'n'Play stores salted, versioned password hashes. New and upgraded hashes use Argon2id, while older hash versions are transparently upgraded after successful sign-in or admin-password confirmation. + ### Do I need admin privileges? If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.