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 System.Text.Json;
|
||||||
using KronaStaffTerminal.Models;
|
using KronaStaffTerminal.Models;
|
||||||
using KronaStaffTerminal.Services;
|
using KronaStaffTerminal.Services;
|
||||||
|
|
@ -13,6 +14,7 @@ public sealed class KronaStaffWorker : BackgroundService
|
||||||
private readonly AttendanceSyncService _syncService;
|
private readonly AttendanceSyncService _syncService;
|
||||||
private readonly ILicenseStatusPort _licensePort;
|
private readonly ILicenseStatusPort _licensePort;
|
||||||
private readonly SemaphoreSlim _syncGate = new(1, 1);
|
private readonly SemaphoreSlim _syncGate = new(1, 1);
|
||||||
|
private readonly ConcurrentDictionary<string, long> _acceptedIpcNonces = new();
|
||||||
private TerminalIpcServer? _ipcServer;
|
private TerminalIpcServer? _ipcServer;
|
||||||
private bool _scannerSession0Supported;
|
private bool _scannerSession0Supported;
|
||||||
private string _scannerDiagnostic = "";
|
private string _scannerDiagnostic = "";
|
||||||
|
|
@ -132,6 +134,17 @@ public sealed class KronaStaffWorker : BackgroundService
|
||||||
{
|
{
|
||||||
try
|
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)
|
switch (request.Command)
|
||||||
{
|
{
|
||||||
case "Ping":
|
case "Ping":
|
||||||
|
|
@ -169,10 +182,30 @@ public sealed class KronaStaffWorker : BackgroundService
|
||||||
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record JSON" };
|
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();
|
await _syncGate.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var state = await _store.LoadAsync();
|
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))
|
if (state.Attendance.All(a => a.Id != record.Id))
|
||||||
{
|
{
|
||||||
state.Attendance.Insert(0, record);
|
state.Attendance.Insert(0, record);
|
||||||
|
|
@ -225,7 +258,7 @@ public sealed class KronaStaffWorker : BackgroundService
|
||||||
return new IpcServiceStatus
|
return new IpcServiceStatus
|
||||||
{
|
{
|
||||||
ServiceRunning = true,
|
ServiceRunning = true,
|
||||||
ServiceVersion = "0.3.0",
|
ServiceVersion = "0.3.1",
|
||||||
ScannerInSession0Supported = _scannerSession0Supported,
|
ScannerInSession0Supported = _scannerSession0Supported,
|
||||||
ScannerDiagnostic = _scannerDiagnostic,
|
ScannerDiagnostic = _scannerDiagnostic,
|
||||||
PendingSyncCount = pendingCount,
|
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\AttendanceSyncService.cs" Link="Services\AttendanceSyncService.cs" />
|
||||||
<Compile Include="..\KronaStaffTerminal\Services\AttendanceTracker.cs" Link="Services\AttendanceTracker.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\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\FingerprintScanner.cs" Link="Services\FingerprintScanner.cs" />
|
||||||
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
|
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
|
||||||
<Compile Include="..\KronaStaffTerminal\Services\TerminalIpc.cs" Link="Services\TerminalIpc.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))
|
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
|
||||||
{
|
{
|
||||||
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
|
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
|
||||||
csv.Append(Csv(item.EmployeeName)).Append(';')
|
csv.Append(CsvCellEncoder.Encode(item.EmployeeName)).Append(';')
|
||||||
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
|
.Append(CsvCellEncoder.Encode(employee?.PersonnelNumber)).Append(';')
|
||||||
.Append(Csv(employee?.Position ?? "")).Append(';')
|
.Append(CsvCellEncoder.Encode(employee?.Position)).Append(';')
|
||||||
.Append(Csv(item.EventType)).Append(';')
|
.Append(CsvCellEncoder.Encode(item.EventType)).Append(';')
|
||||||
.Append(Csv(item.LocalTime)).Append(';')
|
.Append(CsvCellEncoder.Encode(item.LocalTime)).Append(';')
|
||||||
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
|
.Append(CsvCellEncoder.Encode(item.DeliveryStatus)).Append("\r\n");
|
||||||
}
|
}
|
||||||
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
|
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
|
||||||
AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}");
|
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()
|
public async Task<bool> AuthorizeExitAsync()
|
||||||
{
|
{
|
||||||
if (_stateLoadFailed)
|
if (_stateLoadFailed)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace KronaStaffTerminal.Models;
|
namespace KronaStaffTerminal.Models;
|
||||||
|
|
@ -108,6 +109,7 @@ public sealed class AttendanceRecord
|
||||||
|
|
||||||
public sealed class TerminalSettings
|
public sealed class TerminalSettings
|
||||||
{
|
{
|
||||||
|
public string IpcAuthenticationKey { get; set; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
public int ReaderIndex { get; set; }
|
public int ReaderIndex { get; set; }
|
||||||
public bool StartWithWindows { get; set; }
|
public bool StartWithWindows { get; set; }
|
||||||
public string AdminPinSalt { 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.IO.Pipes;
|
||||||
using System.Security.AccessControl;
|
using System.Security.AccessControl;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
@ -11,6 +12,9 @@ public sealed class IpcRequest
|
||||||
{
|
{
|
||||||
public string Command { get; set; } = "";
|
public string Command { get; set; } = "";
|
||||||
public string? Payload { 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
|
public sealed class IpcResponse
|
||||||
|
|
@ -25,7 +29,7 @@ public sealed class IpcResponse
|
||||||
public sealed class IpcServiceStatus
|
public sealed class IpcServiceStatus
|
||||||
{
|
{
|
||||||
public bool ServiceRunning { get; set; } = true;
|
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 bool ScannerInSession0Supported { get; set; }
|
||||||
public string ScannerDiagnostic { get; set; } = "";
|
public string ScannerDiagnostic { get; set; } = "";
|
||||||
public int PendingSyncCount { get; set; }
|
public int PendingSyncCount { get; set; }
|
||||||
|
|
@ -42,6 +46,8 @@ public static class TerminalIpcConstants
|
||||||
|
|
||||||
public sealed class TerminalIpcClient
|
public sealed class TerminalIpcClient
|
||||||
{
|
{
|
||||||
|
private readonly TerminalStore _store = new();
|
||||||
|
|
||||||
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
|
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var response = await SendCommandAsync("Ping", null, cancellationToken);
|
var response = await SendCommandAsync("Ping", null, cancellationToken);
|
||||||
|
|
@ -81,7 +87,15 @@ public sealed class TerminalIpcClient
|
||||||
|
|
||||||
await pipe.ConnectAsync(timeoutCts.Token);
|
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 json = JsonSerializer.Serialize(request);
|
||||||
var bytes = Encoding.UTF8.GetBytes(json);
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
var lengthPrefix = BitConverter.GetBytes(bytes.Length);
|
var lengthPrefix = BitConverter.GetBytes(bytes.Length);
|
||||||
|
|
@ -173,7 +187,7 @@ public sealed class TerminalIpcServer : IDisposable
|
||||||
var read = await stream.ReadAsync(reqLenBytes, token);
|
var read = await stream.ReadAsync(reqLenBytes, token);
|
||||||
if (read < 4) return;
|
if (read < 4) return;
|
||||||
var reqLen = BitConverter.ToInt32(reqLenBytes);
|
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 reqBytes = new byte[reqLen];
|
||||||
var totalRead = 0;
|
var totalRead = 0;
|
||||||
|
|
@ -212,11 +226,11 @@ public sealed class TerminalIpcServer : IDisposable
|
||||||
var pipeSecurity = new PipeSecurity();
|
var pipeSecurity = new PipeSecurity();
|
||||||
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||||
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, 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(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||||
pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, 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(
|
return NamedPipeServerStreamAcl.Create(
|
||||||
@"global\" + TerminalIpcConstants.PipeName,
|
@"global\" + TerminalIpcConstants.PipeName,
|
||||||
|
|
@ -237,3 +251,29 @@ public sealed class TerminalIpcServer : IDisposable
|
||||||
_cancellation?.Dispose();
|
_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 sealed class TerminalStore
|
||||||
{
|
{
|
||||||
|
public const string OperatorGroupName = "KronaStaffTerminalOperators";
|
||||||
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
|
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
private const string RegistryPath = @"Software\Krona\StaffTerminal";
|
private const string RegistryPath = @"Software\Krona\StaffTerminal";
|
||||||
|
|
@ -67,13 +68,17 @@ public sealed class TerminalStore
|
||||||
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
|
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TerminalState();
|
return await InitializeFreshStateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!);
|
||||||
|
}
|
||||||
var state = await ReadStateAsync(sourcePath);
|
var state = await ReadStateAsync(sourcePath);
|
||||||
var migrated = MigrateEmployees(state);
|
var migrated = MigrateEmployees(state) | MigrateSecurity(state);
|
||||||
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await SaveAsync(state);
|
await SaveAsync(state);
|
||||||
|
|
@ -92,6 +97,7 @@ public sealed class TerminalStore
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.Move(_filePath, corruptPath, false);
|
File.Move(_filePath, corruptPath, false);
|
||||||
|
ApplyRestrictedFileAcl(corruptPath);
|
||||||
}
|
}
|
||||||
catch (Exception moveException)
|
catch (Exception moveException)
|
||||||
{
|
{
|
||||||
|
|
@ -208,6 +214,43 @@ public sealed class TerminalStore
|
||||||
return changed;
|
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)
|
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
|
||||||
{
|
{
|
||||||
await _saveGate.WaitAsync();
|
await _saveGate.WaitAsync();
|
||||||
|
|
@ -239,6 +282,7 @@ public sealed class TerminalStore
|
||||||
File.Copy(_filePath, previousStatePath, true);
|
File.Copy(_filePath, previousStatePath, true);
|
||||||
}
|
}
|
||||||
File.Move(temporaryPath, _filePath, true);
|
File.Move(temporaryPath, _filePath, true);
|
||||||
|
ApplyRestrictedFileAcl(_filePath);
|
||||||
MarkInitialized();
|
MarkInitialized();
|
||||||
if (previousStatePath is not null)
|
if (previousStatePath is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -260,44 +304,62 @@ public sealed class TerminalStore
|
||||||
|
|
||||||
private static void EnsureDirectoryWithRestrictedAcl(string directoryPath)
|
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
|
try
|
||||||
{
|
{
|
||||||
var directorySecurity = new DirectorySecurity();
|
operatorSid = (SecurityIdentifier)new NTAccount(Environment.MachineName, OperatorGroupName)
|
||||||
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
.Translate(typeof(SecurityIdentifier));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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)
|
private bool ShouldCreateRecoveryPoint(bool requested)
|
||||||
{
|
{
|
||||||
var latestBackup = BackupPaths().First();
|
var latestBackup = BackupPaths().First();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ SetCompressor /SOLID lzma
|
||||||
!include "x64.nsh"
|
!include "x64.nsh"
|
||||||
|
|
||||||
!ifndef APP_VERSION
|
!ifndef APP_VERSION
|
||||||
!define APP_VERSION "0.3.0"
|
!define APP_VERSION "0.3.1"
|
||||||
!endif
|
!endif
|
||||||
!ifndef PUBLISH_DIR
|
!ifndef PUBLISH_DIR
|
||||||
!error "PUBLISH_DIR is required"
|
!error "PUBLISH_DIR is required"
|
||||||
|
|
@ -21,6 +21,7 @@ SetCompressor /SOLID lzma
|
||||||
!define APP_EXE "KronaStaffTerminal.exe"
|
!define APP_EXE "KronaStaffTerminal.exe"
|
||||||
!define SERVICE_EXE "KronaStaffService.exe"
|
!define SERVICE_EXE "KronaStaffService.exe"
|
||||||
!define SERVICE_NAME "KronaStaffService"
|
!define SERVICE_NAME "KronaStaffService"
|
||||||
|
!define OPERATOR_GROUP "KronaStaffTerminalOperators"
|
||||||
!define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal"
|
!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"
|
!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
|
SetOverwrite try
|
||||||
File /r "${PUBLISH_DIR}\*"
|
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}..."
|
DetailPrint "Настройка службы ${SERVICE_NAME}..."
|
||||||
ExecWait 'sc.exe create ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto DisplayName= "Krona Staff Service"'
|
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'
|
ExecWait 'sc.exe config ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto'
|
||||||
|
|
@ -160,8 +187,8 @@ Section "Uninstall"
|
||||||
|
|
||||||
IfSilent keep_data
|
IfSilent keep_data
|
||||||
MessageBox MB_YESNO|MB_ICONQUESTION "Сохранить локальную базу данных и журнал терминала (%ProgramData%\Krona\StaffTerminal)?$\r$\n$\r$\nНажмите 'Да', чтобы сохранить данные для будущей установки.$\r$\nНажмите 'Нет', чтобы полностью удалить данные с устройства." IDYES 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 /r "$COMMONAPPDATA\Krona\StaffTerminal"
|
||||||
RMDir "$APPDATA\Krona"
|
RMDir "$COMMONAPPDATA\Krona"
|
||||||
keep_data:
|
keep_data:
|
||||||
|
|
||||||
Delete "$DESKTOP\Krona Staff Terminal.lnk"
|
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.migrate?.depends_on).toHaveProperty('db-role');
|
||||||
expect(compose.services.app?.depends_on).toHaveProperty('migrate');
|
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