Migrate current password hashing to Argon2id

This commit is contained in:
2026-02-18 21:06:22 +01:00
parent a130cba41a
commit e55a1b01f4
9 changed files with 46 additions and 15 deletions

2
API.md
View File

@@ -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.

View File

@@ -34,6 +34,7 @@ public class AuthTests
Assert.True(player.DisplayName!.Length <= 16);
Assert.NotEqual(Array.Empty<byte>(), player.PasswordHash);
Assert.NotEqual(Array.Empty<byte>(), player.PasswordSalt);
Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion);
});
}

View File

@@ -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<ArgumentException>(() => PasswordHasher.HashPassword(""));

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.