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;
+}