149 lines
5.5 KiB
C#
149 lines
5.5 KiB
C#
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;
|
|
}
|
|
}
|