using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
namespace Flaro.Core.Users;
/// Passwörter als Argon2id, im PHC-Format abgelegt.
///
///
///
/// Argon2id ist speicherhart: ein Angreifer mit Grafikkarten gewinnt dadurch
/// viel weniger, als er es bei PBKDF2 täte. Die Kennwerte folgen der
/// OWASP-Empfehlung — 19 MiB, zwei Durchgänge, eine Spur.
///
///
/// Die Kennwerte stehen im Hash selbst, nicht nur hier als Konstante.
/// Werden sie später angehoben, lassen sich alte Passwörter weiterhin prüfen,
/// statt alle Konten auf einen Schlag auszusperren. Genau daran scheitern
/// solche Umstellungen sonst.
///
///
public static class PasswordHasher
{
private const int MemoryKiB = 19 * 1024;
private const int Iterations = 2;
private const int Parallelism = 1;
private const int SaltBytes = 16;
private const int HashBytes = 32;
public static string Hash(string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var hash = Derive(password, salt, MemoryKiB, Iterations, Parallelism, HashBytes);
return $"$argon2id$v=19$m={MemoryKiB},t={Iterations},p={Parallelism}$" +
$"{Convert.ToBase64String(salt).TrimEnd('=')}$" +
$"{Convert.ToBase64String(hash).TrimEnd('=')}";
}
/// Prüft ein Passwort gegen einen abgelegten Hash.
///
///
/// Gibt bei unlesbarem Hash false zurück statt zu werfen: eine
/// beschädigte Zeile in der Datenbank soll die Anmeldung ablehnen, nicht
/// den Server umwerfen.
///
public static bool Verify(string password, string stored)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(stored)) return false;
var teile = stored.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (teile.Length != 5 || teile[0] != "argon2id") return false;
var kennwerte = teile[2].Split(',');
if (kennwerte.Length != 3) return false;
if (!TryLesen(kennwerte[0], "m=", out var speicher) ||
!TryLesen(kennwerte[1], "t=", out var durchgänge) ||
!TryLesen(kennwerte[2], "p=", out var spuren)) return false;
byte[] salt, erwartet;
try
{
salt = Convert.FromBase64String(Auffüllen(teile[3]));
erwartet = Convert.FromBase64String(Auffüllen(teile[4]));
}
catch (FormatException) { return false; }
if (erwartet.Length == 0) return false;
var gerechnet = Derive(password, salt, speicher, durchgänge, spuren, erwartet.Length);
return CryptographicOperations.FixedTimeEquals(gerechnet, erwartet);
}
/// Ob dieser Hash mit schwächeren Kennwerten entstand.
///
///
/// Damit lässt sich beim nächsten erfolgreichen Anmelden still neu hashen.
/// Ohne diese Frage bliebe ein Konto von 2026 für immer bei den Kennwerten
/// von 2026, auch wenn die längst zu schwach wären.
///
public static bool NeedsRehash(string stored)
{
var teile = stored.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (teile.Length != 5 || teile[0] != "argon2id") return true;
var kennwerte = teile[2].Split(',');
if (kennwerte.Length != 3) return true;
return !TryLesen(kennwerte[0], "m=", out var speicher) || speicher < MemoryKiB
|| !TryLesen(kennwerte[1], "t=", out var durchgänge) || durchgänge < Iterations;
}
private static byte[] Derive(string password, byte[] salt, int speicher, int durchgänge, int spuren, int länge)
{
using var argon = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
MemorySize = speicher,
Iterations = durchgänge,
DegreeOfParallelism = spuren,
};
return argon.GetBytes(länge);
}
private static bool TryLesen(string stück, string vorsatz, out int wert)
{
wert = 0;
return stück.StartsWith(vorsatz, StringComparison.Ordinal)
&& int.TryParse(stück.AsSpan(vorsatz.Length), out wert)
&& wert > 0;
}
/// PHC lässt die Base64-Auffüllung weg; .NET besteht darauf.
private static string Auffüllen(string roh) => roh.PadRight((roh.Length + 3) / 4 * 4, '=');
}