From 4fe2f8799aa155c4368934c478a51e297b5f7429 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 21:13:46 +0700 Subject: [PATCH] fix(terminal): secure local state and background IPC --- apps/KronaStaffService/KronaStaffWorker.cs | 35 ++++- .../CsvCellEncoderTests.cs | 28 ++++ .../IpcRequestAuthenticatorTests.cs | 44 ++++++ .../KronaStaffTerminal.Tests.csproj | 1 + apps/KronaStaffTerminal/MainPage.xaml.cs | 14 +- .../Models/TerminalModels.cs | 2 + .../Services/CsvCellEncoder.cs | 20 +++ .../Services/TerminalIpc.cs | 50 ++++++- .../Services/TerminalStore.cs | 126 +++++++++++++----- installer/KronaStaffTerminal.nsi | 33 ++++- tests/unit/deployment.test.ts | 7 + tests/unit/security-boundaries.test.ts | 58 ++++++++ 12 files changed, 369 insertions(+), 49 deletions(-) create mode 100644 apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs create mode 100644 apps/KronaStaffTerminal.Tests/IpcRequestAuthenticatorTests.cs create mode 100644 apps/KronaStaffTerminal/Services/CsvCellEncoder.cs create mode 100644 tests/unit/security-boundaries.test.ts diff --git a/apps/KronaStaffService/KronaStaffWorker.cs b/apps/KronaStaffService/KronaStaffWorker.cs index 68ff700..dad57f7 100644 --- a/apps/KronaStaffService/KronaStaffWorker.cs +++ b/apps/KronaStaffService/KronaStaffWorker.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json; using KronaStaffTerminal.Models; using KronaStaffTerminal.Services; @@ -13,6 +14,7 @@ public sealed class KronaStaffWorker : BackgroundService private readonly AttendanceSyncService _syncService; private readonly ILicenseStatusPort _licensePort; private readonly SemaphoreSlim _syncGate = new(1, 1); + private readonly ConcurrentDictionary _acceptedIpcNonces = new(); private TerminalIpcServer? _ipcServer; private bool _scannerSession0Supported; private string _scannerDiagnostic = ""; @@ -132,6 +134,17 @@ public sealed class KronaStaffWorker : BackgroundService { try { + var authenticationState = await _store.LoadAsync(); + var now = DateTimeOffset.UtcNow; + if (!IpcRequestAuthenticator.Verify( + request, authenticationState.Settings.IpcAuthenticationKey, now)) + { + return new IpcResponse { Success = false, ErrorMessage = "Unauthorized request" }; + } + foreach (var nonce in _acceptedIpcNonces.Where(item => item.Value < now.AddMinutes(-1).ToUnixTimeSeconds())) + _acceptedIpcNonces.TryRemove(nonce.Key, out _); + if (!_acceptedIpcNonces.TryAdd(request.Nonce, request.IssuedAtUnixSeconds)) + return new IpcResponse { Success = false, ErrorMessage = "Replayed request" }; switch (request.Command) { case "Ping": @@ -169,10 +182,30 @@ public sealed class KronaStaffWorker : BackgroundService return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record JSON" }; } + if (!System.Text.RegularExpressions.Regex.IsMatch(record.Id, "^[0-9A-HJKMNP-TV-Z]{26}$") || + !System.Text.RegularExpressions.Regex.IsMatch(record.EmployeeId, "^[0-9A-HJKMNP-TV-Z]{26}$") || + record.EventType is not ("Приход" or "Уход" or "Уход автоматически") || + record.OccurredAtUtc < DateTimeOffset.UtcNow.AddDays(-180) || + record.OccurredAtUtc > DateTimeOffset.UtcNow.AddMinutes(5) || + record.ReaderIndex < 0) + { + return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record" }; + } + await _syncGate.WaitAsync(); try { var state = await _store.LoadAsync(); + var employee = state.Employees.FirstOrDefault(item => item.Id == record.EmployeeId); + if (employee is null || employee.IsBlocked) + { + return new IpcResponse { Success = false, ErrorMessage = "Employee is unavailable" }; + } + record.EmployeeName = employee.DisplayName; + record.ServerSynced = false; + record.ServerSyncError = null; + record.NotificationDelivered = false; + record.NotificationError = null; if (state.Attendance.All(a => a.Id != record.Id)) { state.Attendance.Insert(0, record); @@ -225,7 +258,7 @@ public sealed class KronaStaffWorker : BackgroundService return new IpcServiceStatus { ServiceRunning = true, - ServiceVersion = "0.3.0", + ServiceVersion = "0.3.1", ScannerInSession0Supported = _scannerSession0Supported, ScannerDiagnostic = _scannerDiagnostic, PendingSyncCount = pendingCount, diff --git a/apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs b/apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs new file mode 100644 index 0000000..6f1d023 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs @@ -0,0 +1,28 @@ +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public sealed class CsvCellEncoderTests +{ + [Theory] + [InlineData("=HYPERLINK(\"https://example.invalid\")")] + [InlineData("+cmd")] + [InlineData("-1+1")] + [InlineData("@SUM(A1:A2)")] + [InlineData(" =SUM(A1:A2)")] + [InlineData("\t=SUM(A1:A2)")] + [InlineData("\r=SUM(A1:A2)")] + public void Encode_neutralizes_spreadsheet_formulas(string value) + { + var encoded = CsvCellEncoder.Encode(value); + + Assert.StartsWith("\"'", encoded); + } + + [Fact] + public void Encode_preserves_regular_unicode_and_escapes_quotes() + { + Assert.Equal("\"Иван \"\"Повар\"\"; смена\"", CsvCellEncoder.Encode("Иван \"Повар\"; смена")); + } +} diff --git a/apps/KronaStaffTerminal.Tests/IpcRequestAuthenticatorTests.cs b/apps/KronaStaffTerminal.Tests/IpcRequestAuthenticatorTests.cs new file mode 100644 index 0000000..23591d9 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/IpcRequestAuthenticatorTests.cs @@ -0,0 +1,44 @@ +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public sealed class IpcRequestAuthenticatorTests +{ + private static readonly string Key = Convert.ToBase64String(new byte[32].Select((_, index) => (byte)index).ToArray()); + + [Fact] + public void Verify_accepts_fresh_signed_request() + { + var now = DateTimeOffset.UtcNow; + var request = new IpcRequest + { + Command = "Ping", + IssuedAtUnixSeconds = now.ToUnixTimeSeconds(), + Nonce = "00112233445566778899AABBCCDDEEFF" + }; + request.Signature = IpcRequestAuthenticator.Sign(request, Key); + + Assert.True(IpcRequestAuthenticator.Verify(request, Key, now)); + } + + [Fact] + public void Verify_rejects_tampering_and_stale_requests() + { + var now = DateTimeOffset.UtcNow; + var request = new IpcRequest + { + Command = "EnqueueAttendance", + Payload = "{}", + IssuedAtUnixSeconds = now.AddMinutes(-2).ToUnixTimeSeconds(), + Nonce = "00112233445566778899AABBCCDDEEFF" + }; + request.Signature = IpcRequestAuthenticator.Sign(request, Key); + Assert.False(IpcRequestAuthenticator.Verify(request, Key, now)); + + request.IssuedAtUnixSeconds = now.ToUnixTimeSeconds(); + request.Signature = IpcRequestAuthenticator.Sign(request, Key); + request.Payload = "{\"forged\":true}"; + Assert.False(IpcRequestAuthenticator.Verify(request, Key, now)); + } +} diff --git a/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj b/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj index 9d60a86..f865da9 100644 --- a/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj +++ b/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs index ff6febf..43f3bc4 100644 --- a/apps/KronaStaffTerminal/MainPage.xaml.cs +++ b/apps/KronaStaffTerminal/MainPage.xaml.cs @@ -1240,12 +1240,12 @@ public sealed partial class MainPage : Page foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc)) { var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId); - csv.Append(Csv(item.EmployeeName)).Append(';') - .Append(Csv(employee?.PersonnelNumber ?? "")).Append(';') - .Append(Csv(employee?.Position ?? "")).Append(';') - .Append(Csv(item.EventType)).Append(';') - .Append(Csv(item.LocalTime)).Append(';') - .Append(Csv(item.DeliveryStatus)).Append("\r\n"); + csv.Append(CsvCellEncoder.Encode(item.EmployeeName)).Append(';') + .Append(CsvCellEncoder.Encode(employee?.PersonnelNumber)).Append(';') + .Append(CsvCellEncoder.Encode(employee?.Position)).Append(';') + .Append(CsvCellEncoder.Encode(item.EventType)).Append(';') + .Append(CsvCellEncoder.Encode(item.LocalTime)).Append(';') + .Append(CsvCellEncoder.Encode(item.DeliveryStatus)).Append("\r\n"); } await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8); AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}"); @@ -1261,8 +1261,6 @@ public sealed partial class MainPage : Page } } - private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\""; - public async Task AuthorizeExitAsync() { if (_stateLoadFailed) diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs index 8dfabd0..394057e 100644 --- a/apps/KronaStaffTerminal/Models/TerminalModels.cs +++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text.Json.Serialization; namespace KronaStaffTerminal.Models; @@ -108,6 +109,7 @@ public sealed class AttendanceRecord public sealed class TerminalSettings { + public string IpcAuthenticationKey { get; set; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); public int ReaderIndex { get; set; } public bool StartWithWindows { get; set; } public string AdminPinSalt { get; set; } = ""; diff --git a/apps/KronaStaffTerminal/Services/CsvCellEncoder.cs b/apps/KronaStaffTerminal/Services/CsvCellEncoder.cs new file mode 100644 index 0000000..60a7984 --- /dev/null +++ b/apps/KronaStaffTerminal/Services/CsvCellEncoder.cs @@ -0,0 +1,20 @@ +namespace KronaStaffTerminal.Services; + +public static class CsvCellEncoder +{ + public static string Encode(string? value) + { + var safe = value ?? string.Empty; + var firstContent = safe.AsSpan().TrimStart(); + if (firstContent.Length > 0 && firstContent[0] is '=' or '+' or '-' or '@') + { + safe = "'" + safe; + } + else if (safe.Length > 0 && safe[0] is '\t' or '\r' or '\n') + { + safe = "'" + safe; + } + + return $"\"{safe.Replace("\"", "\"\"")}\""; + } +} diff --git a/apps/KronaStaffTerminal/Services/TerminalIpc.cs b/apps/KronaStaffTerminal/Services/TerminalIpc.cs index 24deeee..95dca28 100644 --- a/apps/KronaStaffTerminal/Services/TerminalIpc.cs +++ b/apps/KronaStaffTerminal/Services/TerminalIpc.cs @@ -1,5 +1,6 @@ using System.IO.Pipes; using System.Security.AccessControl; +using System.Security.Cryptography; using System.Security.Principal; using System.Text; using System.Text.Json; @@ -11,6 +12,9 @@ public sealed class IpcRequest { public string Command { get; set; } = ""; public string? Payload { get; set; } + public long IssuedAtUnixSeconds { get; set; } + public string Nonce { get; set; } = ""; + public string Signature { get; set; } = ""; } public sealed class IpcResponse @@ -25,7 +29,7 @@ public sealed class IpcResponse public sealed class IpcServiceStatus { public bool ServiceRunning { get; set; } = true; - public string ServiceVersion { get; set; } = "0.3.0"; + public string ServiceVersion { get; set; } = "0.3.1"; public bool ScannerInSession0Supported { get; set; } public string ScannerDiagnostic { get; set; } = ""; public int PendingSyncCount { get; set; } @@ -42,6 +46,8 @@ public static class TerminalIpcConstants public sealed class TerminalIpcClient { + private readonly TerminalStore _store = new(); + public async Task IsServiceAvailableAsync(CancellationToken cancellationToken = default) { var response = await SendCommandAsync("Ping", null, cancellationToken); @@ -81,7 +87,15 @@ public sealed class TerminalIpcClient await pipe.ConnectAsync(timeoutCts.Token); - var request = new IpcRequest { Command = command, Payload = payload }; + var state = await _store.LoadAsync(); + var request = new IpcRequest + { + Command = command, + Payload = payload, + IssuedAtUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)) + }; + request.Signature = IpcRequestAuthenticator.Sign(request, state.Settings.IpcAuthenticationKey); var json = JsonSerializer.Serialize(request); var bytes = Encoding.UTF8.GetBytes(json); var lengthPrefix = BitConverter.GetBytes(bytes.Length); @@ -173,7 +187,7 @@ public sealed class TerminalIpcServer : IDisposable var read = await stream.ReadAsync(reqLenBytes, token); if (read < 4) return; var reqLen = BitConverter.ToInt32(reqLenBytes); - if (reqLen is <= 0 or > 10 * 1024 * 1024) return; + if (reqLen is <= 0 or > 256 * 1024) return; var reqBytes = new byte[reqLen]; var totalRead = 0; @@ -212,11 +226,11 @@ public sealed class TerminalIpcServer : IDisposable var pipeSecurity = new PipeSecurity(); var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); - var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null); + var interactiveSid = new SecurityIdentifier(WellKnownSidType.InteractiveSid, null); pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow)); pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow)); - pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow)); + pipeSecurity.AddAccessRule(new PipeAccessRule(interactiveSid, PipeAccessRights.ReadWrite, AccessControlType.Allow)); return NamedPipeServerStreamAcl.Create( @"global\" + TerminalIpcConstants.PipeName, @@ -237,3 +251,29 @@ public sealed class TerminalIpcServer : IDisposable _cancellation?.Dispose(); } } + +public static class IpcRequestAuthenticator +{ + public static string Sign(IpcRequest request, string key) + { + var canonical = $"{request.Command}\n{request.IssuedAtUnixSeconds}\n{request.Nonce}\n{request.Payload ?? string.Empty}"; + using var hmac = new HMACSHA256(Convert.FromBase64String(key)); + return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical))); + } + + public static bool Verify(IpcRequest request, string key, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(key) || request.Nonce.Length != 32) return false; + if (Math.Abs(now.ToUnixTimeSeconds() - request.IssuedAtUnixSeconds) > 60) return false; + try + { + var actual = Convert.FromBase64String(request.Signature); + var expected = Convert.FromBase64String(Sign(request, key)); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + catch (FormatException) + { + return false; + } + } +} diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs index 320c2cc..5759689 100644 --- a/apps/KronaStaffTerminal/Services/TerminalStore.cs +++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs @@ -10,6 +10,7 @@ namespace KronaStaffTerminal.Services; public sealed class TerminalStore { + public const string OperatorGroupName = "KronaStaffTerminalOperators"; private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private const string RegistryPath = @"Software\Krona\StaffTerminal"; @@ -67,13 +68,17 @@ public sealed class TerminalStore "Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных."); } - return new TerminalState(); + return await InitializeFreshStateAsync(); } try { + if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) + { + EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!); + } var state = await ReadStateAsync(sourcePath); - var migrated = MigrateEmployees(state); + var migrated = MigrateEmployees(state) | MigrateSecurity(state); if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { await SaveAsync(state); @@ -92,6 +97,7 @@ public sealed class TerminalStore try { File.Move(_filePath, corruptPath, false); + ApplyRestrictedFileAcl(corruptPath); } catch (Exception moveException) { @@ -208,6 +214,43 @@ public sealed class TerminalStore return changed; } + private static bool MigrateSecurity(TerminalState state) + { + if (!string.IsNullOrWhiteSpace(state.Settings.IpcAuthenticationKey)) return false; + state.Settings.IpcAuthenticationKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + return true; + } + + private async Task InitializeFreshStateAsync() + { + var directory = Path.GetDirectoryName(_filePath)!; + EnsureDirectoryWithRestrictedAcl(directory); + var lockPath = _filePath + ".init.lock"; + var deadline = DateTimeOffset.UtcNow.AddSeconds(15); + FileStream? initializationLock = null; + while (initializationLock is null) + { + try + { + initializationLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, + FileShare.None, 1, FileOptions.WriteThrough); + } + catch (IOException) when (DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(100); + } + } + + await using (initializationLock) + { + ApplyRestrictedFileAcl(lockPath); + if (File.Exists(_filePath)) return await LoadAsync(); + var state = new TerminalState(); + await SaveAsync(state); + return state; + } + } + public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false) { await _saveGate.WaitAsync(); @@ -239,6 +282,7 @@ public sealed class TerminalStore File.Copy(_filePath, previousStatePath, true); } File.Move(temporaryPath, _filePath, true); + ApplyRestrictedFileAcl(_filePath); MarkInitialized(); if (previousStatePath is not null) { @@ -260,44 +304,62 @@ public sealed class TerminalStore private static void EnsureDirectoryWithRestrictedAcl(string directoryPath) { - if (Directory.Exists(directoryPath)) return; + Directory.CreateDirectory(directoryPath); + var identities = AllowedIdentities(directoryPath).ToArray(); + var directorySecurity = new DirectorySecurity(); + directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (var identity in identities) + { + directorySecurity.AddAccessRule(new FileSystemAccessRule( + identity, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + } + new DirectoryInfo(directoryPath).SetAccessControl(directorySecurity); + foreach (var stateFile in Directory.EnumerateFiles(directoryPath, "terminal-state.dat*")) + ApplyRestrictedFileAcl(stateFile); + } + private static IEnumerable AllowedIdentities(string? existingDirectory = null) + { + yield return new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + yield return new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + SecurityIdentifier? operatorSid = null; try { - var directorySecurity = new DirectorySecurity(); - var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); - var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); - var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null); - - directorySecurity.AddAccessRule(new FileSystemAccessRule( - systemSid, - FileSystemRights.FullControl, - InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, - PropagationFlags.None, - AccessControlType.Allow)); - - directorySecurity.AddAccessRule(new FileSystemAccessRule( - adminSid, - FileSystemRights.FullControl, - InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, - PropagationFlags.None, - AccessControlType.Allow)); - - directorySecurity.AddAccessRule(new FileSystemAccessRule( - usersSid, - FileSystemRights.ReadAndExecute | FileSystemRights.Modify | FileSystemRights.Write, - InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, - PropagationFlags.None, - AccessControlType.Allow)); - - FileSystemAclExtensions.CreateDirectory(directorySecurity, directoryPath); + operatorSid = (SecurityIdentifier)new NTAccount(Environment.MachineName, OperatorGroupName) + .Translate(typeof(SecurityIdentifier)); } - catch + catch (IdentityNotMappedException) { } + if (operatorSid is not null) yield return operatorSid; + + using var current = WindowsIdentity.GetCurrent(); + if (current.User is not null && !current.User.IsWellKnown(WellKnownSidType.LocalSystemSid)) + yield return current.User; + + if (existingDirectory is null || !Directory.Exists(existingDirectory)) yield break; + var existing = new DirectoryInfo(existingDirectory).GetAccessControl(AccessControlSections.Access); + foreach (FileSystemAccessRule rule in existing.GetAccessRules( + includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier))) { - Directory.CreateDirectory(directoryPath); + if (rule.AccessControlType != AccessControlType.Allow || + rule.IdentityReference is not SecurityIdentifier sid || !sid.IsAccountSid()) continue; + yield return sid; } } + private static void ApplyRestrictedFileAcl(string path) + { + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (var identity in AllowedIdentities(Path.GetDirectoryName(path))) + security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl, + AccessControlType.Allow)); + new FileInfo(path).SetAccessControl(security); + } + private bool ShouldCreateRecoveryPoint(bool requested) { var latestBackup = BackupPaths().First(); diff --git a/installer/KronaStaffTerminal.nsi b/installer/KronaStaffTerminal.nsi index 9102745..41be6b5 100644 --- a/installer/KronaStaffTerminal.nsi +++ b/installer/KronaStaffTerminal.nsi @@ -5,7 +5,7 @@ SetCompressor /SOLID lzma !include "x64.nsh" !ifndef APP_VERSION - !define APP_VERSION "0.3.0" + !define APP_VERSION "0.3.1" !endif !ifndef PUBLISH_DIR !error "PUBLISH_DIR is required" @@ -21,6 +21,7 @@ SetCompressor /SOLID lzma !define APP_EXE "KronaStaffTerminal.exe" !define SERVICE_EXE "KronaStaffService.exe" !define SERVICE_NAME "KronaStaffService" +!define OPERATOR_GROUP "KronaStaffTerminalOperators" !define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal" !define DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows" @@ -88,6 +89,32 @@ Section "Krona Staff Terminal" SecMain SetOverwrite try File /r "${PUBLISH_DIR}\*" + DetailPrint "Настройка защищённой локальной группы терминала..." + nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}"' + Pop $0 + StrCmp $0 "0" group_ready + nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" /add' + Pop $0 + StrCmp $0 "0" group_ready + MessageBox MB_ICONSTOP "Не удалось создать защищённую группу ${OPERATOR_GROUP}. Установка остановлена." + Abort +group_ready: + nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /delete' + Pop $0 + nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /add' + Pop $0 + StrCmp $0 "0" membership_ready + MessageBox MB_ICONSTOP "Не удалось выдать текущему пользователю доступ к терминалу. Установка остановлена." + Abort +membership_ready: + CreateDirectory "$COMMONAPPDATA\Krona\StaffTerminal" + nsExec::ExecToLog 'icacls.exe "$COMMONAPPDATA\Krona\StaffTerminal" /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" "$USERDOMAIN\$USERNAME:(OI)(CI)M"' + Pop $0 + StrCmp $0 "0" acl_ready + MessageBox MB_ICONSTOP "Не удалось защитить хранилище терминала. Установка остановлена." + Abort +acl_ready: + DetailPrint "Настройка службы ${SERVICE_NAME}..." ExecWait 'sc.exe create ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto DisplayName= "Krona Staff Service"' ExecWait 'sc.exe config ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto' @@ -160,8 +187,8 @@ Section "Uninstall" IfSilent keep_data MessageBox MB_YESNO|MB_ICONQUESTION "Сохранить локальную базу данных и журнал терминала (%ProgramData%\Krona\StaffTerminal)?$\r$\n$\r$\nНажмите 'Да', чтобы сохранить данные для будущей установки.$\r$\nНажмите 'Нет', чтобы полностью удалить данные с устройства." IDYES keep_data - RMDir /r "$APPDATA\Krona\StaffTerminal" - RMDir "$APPDATA\Krona" + RMDir /r "$COMMONAPPDATA\Krona\StaffTerminal" + RMDir "$COMMONAPPDATA\Krona" keep_data: Delete "$DESKTOP\Krona Staff Terminal.lnk" diff --git a/tests/unit/deployment.test.ts b/tests/unit/deployment.test.ts index 3b79609..d189ee2 100644 --- a/tests/unit/deployment.test.ts +++ b/tests/unit/deployment.test.ts @@ -24,4 +24,11 @@ describe('container deployment boundaries', () => { expect(compose.services.migrate?.depends_on).toHaveProperty('db-role'); expect(compose.services.app?.depends_on).toHaveProperty('migrate'); }); + + it('removes only the machine terminal state after explicit uninstall confirmation', () => { + const installer = readFileSync('installer/KronaStaffTerminal.nsi', 'utf8'); + expect(installer).toContain('RMDir /r "$COMMONAPPDATA\\Krona\\StaffTerminal"'); + expect(installer).not.toContain('RMDir /r "$APPDATA\\Krona\\StaffTerminal"'); + expect(installer).not.toContain('RMDir /r "$COMMONAPPDATA\\Krona"'); + }); }); diff --git a/tests/unit/security-boundaries.test.ts b/tests/unit/security-boundaries.test.ts new file mode 100644 index 0000000..58664ac --- /dev/null +++ b/tests/unit/security-boundaries.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('security boundary regressions', () => { + it('requires section-specific permissions before owner workbook writes', () => { + const source = readFileSync('src/operations.ts', 'utf8'); + const permissionCheck = source.indexOf("requiredPermissions.add('staff:org_unit:write')"); + const transaction = source.indexOf( + 'const counts = await database.tenantTransaction', + permissionCheck, + ); + expect(permissionCheck).toBeGreaterThan(0); + expect(source.slice(permissionCheck, transaction)).toContain('staff:employee:write'); + expect(source.slice(permissionCheck, transaction)).toContain('staff:metric:write'); + expect(source.slice(permissionCheck, transaction)).toContain('staff:shift:confirm'); + expect(source.slice(permissionCheck, transaction)).toContain('staff:payout:write'); + expect(source.slice(permissionCheck, transaction)).toContain('await auth.require'); + }); + + it('does not grant broad authenticated-user access to state or IPC', () => { + const store = readFileSync('apps/KronaStaffTerminal/Services/TerminalStore.cs', 'utf8'); + const ipc = readFileSync('apps/KronaStaffTerminal/Services/TerminalIpc.cs', 'utf8'); + expect(store).not.toContain('WellKnownSidType.AuthenticatedUserSid'); + expect(store).not.toContain('catch\n {\n Directory.CreateDirectory'); + expect(ipc).not.toContain('WellKnownSidType.AuthenticatedUserSid'); + expect(ipc).toContain('IpcRequestAuthenticator'); + expect(store).toContain('return await InitializeFreshStateAsync()'); + expect(store).toContain('FileShare.None'); + expect(store).toContain('await SaveAsync(state)'); + }); + + it('enforces active employee status in standalone token resolution', () => { + const portal = readFileSync('src/portal.ts', 'utf8'); + expect(portal).toContain("employee.status = 'active'"); + const patchRoute = portal.indexOf('api.patch<{ Params: { employeeId: string }'); + const accessRoute = portal.indexOf("'/employees/:employeeId/access-token'", patchRoute); + expect(portal.slice(patchRoute, accessRoute)).toContain( + 'DELETE FROM staff_standalone_employee_access', + ); + }); + + it('versions payroll formulas independently per organization unit', () => { + const service = readFileSync('src/domain/payroll/service.ts', 'utf8'); + const migration = readFileSync('migrations/0009_professions.up.sql', 'utf8'); + expect(service.match(/org_unit_id IS NOT DISTINCT FROM \$4::varchar\(26\)/g)).toHaveLength(3); + expect(service).toContain('pg_advisory_xact_lock'); + expect(migration).toContain('staff_pay_assignments_effective_version_idx'); + }); + + it('uses stable external identities for repeatable workbook imports', () => { + const importer = readFileSync('scripts/import-owner-workbook.py', 'utf8'); + const migration = readFileSync('migrations/0009_professions.up.sql', 'utf8'); + expect(importer).toContain('xlsx:staff:'); + expect(importer).not.toContain('enumerate(names'); + expect(importer).not.toContain('{row}|{date_text}'); + expect(migration).toContain('staff_employees_external_ref_idx'); + }); +});