fix(terminal): secure local state and background IPC

This commit is contained in:
Ochenstarik 2026-09-24 21:13:46 +07:00
parent 96a28f575f
commit 4fe2f8799a
12 changed files with 369 additions and 49 deletions

View file

@ -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<string, long> _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,

View file

@ -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("Иван \"Повар\"; смена"));
}
}

View file

@ -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));
}
}

View file

@ -35,6 +35,7 @@
<Compile Include="..\KronaStaffTerminal\Services\AttendanceSyncService.cs" Link="Services\AttendanceSyncService.cs" />
<Compile Include="..\KronaStaffTerminal\Services\AttendanceTracker.cs" Link="Services\AttendanceTracker.cs" />
<Compile Include="..\KronaStaffTerminal\Services\CrashLogService.cs" Link="Services\CrashLogService.cs" />
<Compile Include="..\KronaStaffTerminal\Services\CsvCellEncoder.cs" Link="Services\CsvCellEncoder.cs" />
<Compile Include="..\KronaStaffTerminal\Services\FingerprintScanner.cs" Link="Services\FingerprintScanner.cs" />
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
<Compile Include="..\KronaStaffTerminal\Services\TerminalIpc.cs" Link="Services\TerminalIpc.cs" />

View file

@ -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<bool> AuthorizeExitAsync()
{
if (_stateLoadFailed)

View file

@ -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; } = "";

View file

@ -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("\"", "\"\"")}\"";
}
}

View file

@ -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<bool> 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;
}
}
}

View file

@ -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<TerminalState> 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<IdentityReference> 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();

View file

@ -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"

View file

@ -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"');
});
});

View file

@ -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');
});
});