fix(terminal): secure local state and background IPC
This commit is contained in:
parent
96a28f575f
commit
4fe2f8799a
12 changed files with 369 additions and 49 deletions
|
|
@ -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,
|
||||
|
|
|
|||
28
apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs
Normal file
28
apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs
Normal 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("Иван \"Повар\"; смена"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; } = "";
|
||||
|
|
|
|||
20
apps/KronaStaffTerminal/Services/CsvCellEncoder.cs
Normal file
20
apps/KronaStaffTerminal/Services/CsvCellEncoder.cs
Normal 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("\"", "\"\"")}\"";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
58
tests/unit/security-boundaries.test.ts
Normal file
58
tests/unit/security-boundaries.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue