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, '='); }