feat(store): configure DPAPI LocalMachine scope with restricted ACL and migration support

This commit is contained in:
Ochenstarik 2026-09-24 16:20:00 +07:00
parent c41bdd8654
commit 14ce259a5b

View file

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