From 14ce259a5bac94771d13606b2f4fdcfdb19c392f Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:20:00 +0700 Subject: [PATCH] feat(store): configure DPAPI LocalMachine scope with restricted ACL and migration support --- .../Services/TerminalStore.cs | 144 +++++++++++++++--- 1 file changed, 122 insertions(+), 22 deletions(-) diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs index e18f158..320c2cc 100644 --- a/apps/KronaStaffTerminal/Services/TerminalStore.cs +++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs @@ -1,4 +1,7 @@ +using System.IO; +using System.Security.AccessControl; using System.Security.Cryptography; +using System.Security.Principal; using System.Text.Json; using KronaStaffTerminal.Models; using Microsoft.Win32; @@ -13,18 +16,41 @@ public sealed class TerminalStore private const string InitializedValueName = "StateInitialized"; private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12); private readonly SemaphoreSlim _saveGate = new(1, 1); - private readonly string _filePath = Path.Combine( + private readonly string _filePath; + + public static string MachineFilePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Krona", + "StaffTerminal", + "terminal-state.dat"); + + public static string LegacyUserFilePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", "StaffTerminal", "terminal-state.dat"); + public string FilePath => _filePath; public string? RecoveryNotice { get; private set; } + public TerminalStore(string? filePath = null) + { + _filePath = filePath ?? MachineFilePath; + } + public async Task LoadAsync() { RecoveryNotice = null; - var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); + var sourcePath = File.Exists(_filePath) ? _filePath : null; + if (sourcePath is null && File.Exists(LegacyUserFilePath)) + { + sourcePath = LegacyUserFilePath; + } + if (sourcePath is null) + { + sourcePath = FindLegacyPackagedState(); + } + if (sourcePath is null) { var recovered = await TryRecoverFromBackupAsync(); @@ -47,7 +73,7 @@ public sealed class TerminalStore try { var state = await ReadStateAsync(sourcePath); - var migrated = MigrateFingerprints(state); + var migrated = MigrateEmployees(state); if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { await SaveAsync(state); @@ -108,7 +134,7 @@ public sealed class TerminalStore try { var recovered = await ReadStateAsync(backupPath); - MigrateFingerprints(recovered); + MigrateEmployees(recovered); return recovered; } catch (Exception backupException) when (IsCorruptStateException(backupException)) @@ -125,7 +151,18 @@ public sealed class TerminalStore private static async Task ReadStateAsync(string path) { var encrypted = await File.ReadAllBytesAsync(path); - var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); + byte[] clear; + try + { + // Primary: LocalMachine (accessible by both Service and Terminal UI on this machine) + clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.LocalMachine); + } + catch (CryptographicException) + { + // Legacy fallback: CurrentUser (if upgrading an older installation) + clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); + } + var state = JsonSerializer.Deserialize(clear, JsonOptions) ?? throw new InvalidDataException("Файл состояния не содержит данных."); if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null) @@ -135,29 +172,38 @@ public sealed class TerminalStore return state; } - private static bool MigrateFingerprints(TerminalState state) + private static bool MigrateEmployees(TerminalState state) { var changed = false; foreach (var employee in state.Employees) { employee.Fingerprints ??= []; - if (employee.FingerprintId <= 0 || string.IsNullOrWhiteSpace(employee.FingerprintTemplate)) + if (employee.FingerprintId > 0 && !string.IsNullOrWhiteSpace(employee.FingerprintTemplate)) { - continue; + if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId)) + { + employee.Fingerprints.Add(new FingerprintRecord + { + Id = employee.FingerprintId, + Template = employee.FingerprintTemplate, + Label = "Отпечаток 1", + EnrolledAtUtc = DateTimeOffset.UtcNow + }); + } + employee.FingerprintId = 0; + employee.FingerprintTemplate = null; + changed = true; } - if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId)) + // Populate FIO if empty and DisplayName is available + if (string.IsNullOrWhiteSpace(employee.LastName) && !string.IsNullOrWhiteSpace(employee.DisplayName)) { - employee.Fingerprints.Add(new FingerprintRecord - { - Id = employee.FingerprintId, - Template = employee.FingerprintTemplate, - Label = "Отпечаток 1" - }); + var parts = employee.DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) employee.LastName = parts[0]; + if (parts.Length > 1) employee.FirstName = parts[1]; + if (parts.Length > 2) employee.Patronymic = string.Join(" ", parts.Skip(2)); + changed = true; } - employee.FingerprintId = 0; - employee.FingerprintTemplate = null; - changed = true; } return changed; } @@ -168,9 +214,10 @@ public sealed class TerminalStore try { var directory = Path.GetDirectoryName(_filePath)!; - Directory.CreateDirectory(directory); + EnsureDirectoryWithRestrictedAcl(directory); + var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); - var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser); + var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.LocalMachine); var temporaryPath = _filePath + ".tmp"; await using (var stream = new FileStream( temporaryPath, @@ -211,6 +258,46 @@ public sealed class TerminalStore } } + private static void EnsureDirectoryWithRestrictedAcl(string directoryPath) + { + if (Directory.Exists(directoryPath)) return; + + 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); + } + catch + { + Directory.CreateDirectory(directoryPath); + } + } + private bool ShouldCreateRecoveryPoint(bool requested) { var latestBackup = BackupPaths().First(); @@ -244,12 +331,25 @@ public sealed class TerminalStore private static bool WasPreviouslyInitialized() { - using var key = Registry.CurrentUser.OpenSubKey(RegistryPath); - return key?.GetValue(InitializedValueName) is int value && value == 1; + using var lmKey = Registry.LocalMachine.OpenSubKey(RegistryPath); + if (lmKey?.GetValue(InitializedValueName) is int lmValue && lmValue == 1) return true; + + using var cuKey = Registry.CurrentUser.OpenSubKey(RegistryPath); + return cuKey?.GetValue(InitializedValueName) is int cuValue && cuValue == 1; } private static void MarkInitialized() { + try + { + using var lmKey = Registry.LocalMachine.CreateSubKey(RegistryPath, writable: true); + lmKey?.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); + } + catch (UnauthorizedAccessException) + { + // Best effort for non-elevated sessions + } + using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true) ?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала."); key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);