feat(terminal): add Argon2id PIN hashing with lockout, license port, and model extensions
This commit is contained in:
parent
3464998168
commit
770ef00a28
4 changed files with 169 additions and 9 deletions
|
|
@ -71,6 +71,7 @@
|
|||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
apps/KronaStaffTerminal/Services/LicenseStatus.cs
Normal file
25
apps/KronaStaffTerminal/Services/LicenseStatus.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
namespace KronaStaffTerminal.Services;
|
||||
|
||||
public enum LicenseStatus
|
||||
{
|
||||
Unknown,
|
||||
Active,
|
||||
GracePeriod,
|
||||
Expired,
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public interface ILicenseStatusPort
|
||||
{
|
||||
LicenseStatus GetStatus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public sealed class StandaloneLicenseAdapter : ILicenseStatusPort
|
||||
{
|
||||
public LicenseStatus GetStatus() => LicenseStatus.Active;
|
||||
}
|
||||
Loading…
Reference in a new issue