From 770ef00a2866269666234b62288a36d6f38c5d9c Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:19:03 +0700 Subject: [PATCH] feat(terminal): add Argon2id PIN hashing with lockout, license port, and model extensions --- .../KronaStaffTerminal.csproj | 1 + .../Models/TerminalModels.cs | 27 +++- .../Services/AdminPinService.cs | 125 ++++++++++++++++-- .../Services/LicenseStatus.cs | 25 ++++ 4 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 apps/KronaStaffTerminal/Services/LicenseStatus.cs diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj index 9852ddd..20eddb7 100644 --- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj +++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj @@ -71,6 +71,7 @@ + diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs index 368ca82..8dfabd0 100644 --- a/apps/KronaStaffTerminal/Models/TerminalModels.cs +++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs @@ -6,7 +6,21 @@ public sealed class EmployeeRecord { public string Id { get; set; } = UlidGenerator.New(); public string PersonnelNumber { get; set; } = ""; - public string DisplayName { get; set; } = ""; + public string LastName { get; set; } = ""; + public string FirstName { get; set; } = ""; + public string Patronymic { get; set; } = ""; + private string _displayName = ""; + public string DisplayName + { + get + { + if (!string.IsNullOrWhiteSpace(_displayName)) return _displayName; + var parts = new[] { LastName, FirstName, Patronymic }.Where(p => !string.IsNullOrWhiteSpace(p)); + var combined = string.Join(" ", parts); + return string.IsNullOrWhiteSpace(combined) ? "" : combined; + } + set => _displayName = value; + } public string Position { get; set; } = ""; public string Gender { get; set; } = ""; public bool IsAdministrator { get; set; } @@ -17,6 +31,15 @@ public sealed class EmployeeRecord public string VkPeerId { get; set; } = ""; public string MaxUserId { get; set; } = ""; + public void SetFullName(string lastName, string firstName, string patronymic) + { + LastName = lastName?.Trim() ?? ""; + FirstName = firstName?.Trim() ?? ""; + Patronymic = patronymic?.Trim() ?? ""; + var parts = new[] { LastName, FirstName, Patronymic }.Where(p => !string.IsNullOrWhiteSpace(p)); + _displayName = string.Join(" ", parts); + } + // Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state. public int FingerprintId { get; set; } public string? FingerprintTemplate { get; set; } @@ -59,6 +82,7 @@ public sealed class FingerprintRecord public int Id { get; set; } public string Template { get; set; } = ""; public string Label { get; set; } = ""; + public DateTimeOffset EnrolledAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class AttendanceRecord @@ -69,6 +93,7 @@ public sealed class AttendanceRecord public DateTimeOffset OccurredAtUtc { get; set; } public int ReaderIndex { get; set; } public string EventType { get; set; } = "Приход"; + public bool IsAutomatic { get; set; } public bool NotificationDelivered { get; set; } public string? NotificationError { get; set; } public bool ServerSynced { get; set; } diff --git a/apps/KronaStaffTerminal/Services/AdminPinService.cs b/apps/KronaStaffTerminal/Services/AdminPinService.cs index bc55a16..a93295f 100644 --- a/apps/KronaStaffTerminal/Services/AdminPinService.cs +++ b/apps/KronaStaffTerminal/Services/AdminPinService.cs @@ -1,16 +1,32 @@ using System.Security.Cryptography; +using System.Text; +using Konscious.Security.Cryptography; +using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public static class AdminPinService { - public const int CurrentIterations = 600_000; + 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); - var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, CurrentIterations, HashAlgorithmName.SHA256, 32); - return (Convert.ToBase64String(salt), Convert.ToBase64String(hash), CurrentIterations); + 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) @@ -23,18 +39,111 @@ public static class AdminPinService try { var salt = Convert.FromBase64String(saltBase64); - var expected = Convert.FromBase64String(hashBase64); - var actual = Rfc2898DeriveBytes.Pbkdf2( + + 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, CurrentIterations), + Math.Clamp(iterations, 120_000, LegacyPbkdf2Iterations), HashAlgorithmName.SHA256, - expected.Length); - return CryptographicOperations.FixedTimeEquals(actual, expected); + 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; + } } diff --git a/apps/KronaStaffTerminal/Services/LicenseStatus.cs b/apps/KronaStaffTerminal/Services/LicenseStatus.cs new file mode 100644 index 0000000..c0f46fd --- /dev/null +++ b/apps/KronaStaffTerminal/Services/LicenseStatus.cs @@ -0,0 +1,25 @@ +namespace KronaStaffTerminal.Services; + +public enum LicenseStatus +{ + Unknown, + Active, + GracePeriod, + Expired, + Unavailable +} + +public interface ILicenseStatusPort +{ + LicenseStatus GetStatus(); +} + +/// +/// Temporary standalone adapter returning Active for the pilot period. +/// Invariant: Standalone adapters are compatible, migratable, and disableable; +/// they do not become a second permanent source of truth (AGENTS.md #15). +/// +public sealed class StandaloneLicenseAdapter : ILicenseStatusPort +{ + public LicenseStatus GetStatus() => LicenseStatus.Active; +}