using System.Security.Cryptography; using System.Text; using Konscious.Security.Cryptography; using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public static class AdminPinService { public const string Argon2Prefix = "$argon2id$"; public const int DefaultArgon2Iterations = 3; public const int DefaultMemorySizeKb = 65536; // 64 MB public const int DefaultParallelism = 2; public const int HashLength = 32; public const int LegacyPbkdf2Iterations = 600_000; public static (string Salt, string Hash, int Iterations) Hash(string pin) { var salt = RandomNumberGenerator.GetBytes(16); using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin)) { Salt = salt, DegreeOfParallelism = DefaultParallelism, Iterations = DefaultArgon2Iterations, MemorySize = DefaultMemorySizeKb }; var hash = argon2.GetBytes(HashLength); return (Convert.ToBase64String(salt), Argon2Prefix + Convert.ToBase64String(hash), DefaultArgon2Iterations); } public static bool Verify(string pin, string saltBase64, string hashBase64, int iterations) { if (string.IsNullOrWhiteSpace(pin) || string.IsNullOrWhiteSpace(saltBase64) || string.IsNullOrWhiteSpace(hashBase64)) { return false; } try { var salt = Convert.FromBase64String(saltBase64); if (hashBase64.StartsWith(Argon2Prefix, StringComparison.Ordinal)) { var rawHash = hashBase64[Argon2Prefix.Length..]; var expected = Convert.FromBase64String(rawHash); using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin)) { Salt = salt, DegreeOfParallelism = DefaultParallelism, Iterations = Math.Max(1, iterations), MemorySize = DefaultMemorySizeKb }; var actual = argon2.GetBytes(expected.Length); return CryptographicOperations.FixedTimeEquals(actual, expected); } // Backwards compatibility with legacy PBKDF2 hashes var legacyExpected = Convert.FromBase64String(hashBase64); var legacyActual = Rfc2898DeriveBytes.Pbkdf2( pin, salt, Math.Clamp(iterations, 120_000, LegacyPbkdf2Iterations), HashAlgorithmName.SHA256, legacyExpected.Length); return CryptographicOperations.FixedTimeEquals(legacyActual, legacyExpected); } catch (FormatException) { return false; } } public static TimeSpan CalculateLockout(int failedAttempts) { if (failedAttempts < 3) return TimeSpan.Zero; if (failedAttempts < 5) return TimeSpan.FromSeconds(30 * (failedAttempts - 2)); // 30s, 60s if (failedAttempts < 8) return TimeSpan.FromMinutes(5); // 5 minutes return TimeSpan.FromMinutes(15); // 15 minutes max } public static (bool Allowed, string? ErrorMessage) CheckLockout(TerminalSettings settings, DateTimeOffset nowUtc) { if (settings.AdminPinBlockedUntilUtc > nowUtc) { var remaining = settings.AdminPinBlockedUntilUtc - nowUtc; var seconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); return (false, $"Ввод PIN временно заблокирован из-за неверных попыток. Попробуйте через {seconds} сек."); } return (true, null); } public static bool VerifyAndRecord( string pin, TerminalSettings settings, DateTimeOffset nowUtc, out string? errorMessage, out bool rehashNeeded) { var (allowed, lockoutMessage) = CheckLockout(settings, nowUtc); if (!allowed) { errorMessage = lockoutMessage; rehashNeeded = false; return false; } var isMatch = Verify(pin, settings.AdminPinSalt, settings.AdminPinHash, settings.AdminPinIterations); if (isMatch) { settings.FailedAdminPinAttempts = 0; settings.AdminPinBlockedUntilUtc = DateTimeOffset.MinValue; errorMessage = null; // Automatically upgrade legacy PBKDF2 hash to Argon2id if (!settings.AdminPinHash.StartsWith(Argon2Prefix, StringComparison.Ordinal)) { var (newSalt, newHash, newIterations) = Hash(pin); settings.AdminPinSalt = newSalt; settings.AdminPinHash = newHash; settings.AdminPinIterations = newIterations; rehashNeeded = true; } else { rehashNeeded = false; } return true; } settings.FailedAdminPinAttempts++; var lockout = CalculateLockout(settings.FailedAdminPinAttempts); if (lockout > TimeSpan.Zero) { settings.AdminPinBlockedUntilUtc = nowUtc.Add(lockout); var seconds = (int)lockout.TotalSeconds; errorMessage = $"Неверный PIN. Ввод заблокирован на {seconds} сек."; } else { var remainingTries = 3 - settings.FailedAdminPinAttempts; errorMessage = $"Неверный PIN. Осталось попыток до блокировки: {remainingTries}."; } rehashNeeded = false; return false; } }