Migrate current password hashing to Argon2id
This commit is contained in:
2
API.md
2
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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
2
TESTS.md
2
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user