using System.Security.Cryptography; using System.Text; namespace Nexus.Api.Services; public static class PasswordSecurity { private const int Iterations = 210_000; private const int SaltSize = 16; private const int HashSize = 32; private const string Version = "v1"; public static string Hash(string password) { ArgumentException.ThrowIfNullOrWhiteSpace(password); var salt = RandomNumberGenerator.GetBytes(SaltSize); var hash = Rfc2898DeriveBytes.Pbkdf2( password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); return string.Join('.', Version, Iterations, Convert.ToBase64String(salt), Convert.ToBase64String(hash)); } public static bool Verify(string password, string encodedHash, out bool needsUpgrade) { needsUpgrade = false; if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(encodedHash)) return false; var parts = encodedHash.Split('.'); if (parts.Length == 4 && parts[0] == Version && int.TryParse(parts[1], out var iterations)) { try { var salt = Convert.FromBase64String(parts[2]); var expected = Convert.FromBase64String(parts[3]); var actual = Rfc2898DeriveBytes.Pbkdf2( password, salt, iterations, HashAlgorithmName.SHA256, expected.Length); needsUpgrade = iterations < Iterations; return CryptographicOperations.FixedTimeEquals(actual, expected); } catch (FormatException) { return false; } } if (encodedHash.Length == 64 && encodedHash.All(Uri.IsHexDigit)) { var legacy = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password))); needsUpgrade = true; return CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(legacy), Encoding.ASCII.GetBytes(encodedHash.ToUpperInvariant())); } return false; } }