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.Cryptography;
using System.Security.Principal;
using System.Text.Json; using System.Text.Json;
using KronaStaffTerminal.Models; using KronaStaffTerminal.Models;
using Microsoft.Win32; using Microsoft.Win32;
@ -13,18 +16,41 @@ public sealed class TerminalStore
private const string InitializedValueName = "StateInitialized"; private const string InitializedValueName = "StateInitialized";
private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12); private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12);
private readonly SemaphoreSlim _saveGate = new(1, 1); 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), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona", "Krona",
"StaffTerminal", "StaffTerminal",
"terminal-state.dat"); "terminal-state.dat");
public string FilePath => _filePath;
public string? RecoveryNotice { get; private set; } public string? RecoveryNotice { get; private set; }
public TerminalStore(string? filePath = null)
{
_filePath = filePath ?? MachineFilePath;
}
public async Task<TerminalState> LoadAsync() public async Task<TerminalState> LoadAsync()
{ {
RecoveryNotice = null; 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) if (sourcePath is null)
{ {
var recovered = await TryRecoverFromBackupAsync(); var recovered = await TryRecoverFromBackupAsync();
@ -47,7 +73,7 @@ public sealed class TerminalStore
try try
{ {
var state = await ReadStateAsync(sourcePath); var state = await ReadStateAsync(sourcePath);
var migrated = MigrateFingerprints(state); var migrated = MigrateEmployees(state);
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{ {
await SaveAsync(state); await SaveAsync(state);
@ -108,7 +134,7 @@ public sealed class TerminalStore
try try
{ {
var recovered = await ReadStateAsync(backupPath); var recovered = await ReadStateAsync(backupPath);
MigrateFingerprints(recovered); MigrateEmployees(recovered);
return recovered; return recovered;
} }
catch (Exception backupException) when (IsCorruptStateException(backupException)) catch (Exception backupException) when (IsCorruptStateException(backupException))
@ -125,7 +151,18 @@ public sealed class TerminalStore
private static async Task<TerminalState> ReadStateAsync(string path) private static async Task<TerminalState> ReadStateAsync(string path)
{ {
var encrypted = await File.ReadAllBytesAsync(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) var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
?? throw new InvalidDataException("Файл состояния не содержит данных."); ?? throw new InvalidDataException("Файл состояния не содержит данных.");
if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null) 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; return state;
} }
private static bool MigrateFingerprints(TerminalState state) private static bool MigrateEmployees(TerminalState state)
{ {
var changed = false; var changed = false;
foreach (var employee in state.Employees) foreach (var employee in state.Employees)
{ {
employee.Fingerprints ??= []; 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 var parts = employee.DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
{ if (parts.Length > 0) employee.LastName = parts[0];
Id = employee.FingerprintId, if (parts.Length > 1) employee.FirstName = parts[1];
Template = employee.FingerprintTemplate, if (parts.Length > 2) employee.Patronymic = string.Join(" ", parts.Skip(2));
Label = "Отпечаток 1" changed = true;
});
} }
employee.FingerprintId = 0;
employee.FingerprintTemplate = null;
changed = true;
} }
return changed; return changed;
} }
@ -168,9 +214,10 @@ public sealed class TerminalStore
try try
{ {
var directory = Path.GetDirectoryName(_filePath)!; var directory = Path.GetDirectoryName(_filePath)!;
Directory.CreateDirectory(directory); EnsureDirectoryWithRestrictedAcl(directory);
var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); 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"; var temporaryPath = _filePath + ".tmp";
await using (var stream = new FileStream( await using (var stream = new FileStream(
temporaryPath, 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) private bool ShouldCreateRecoveryPoint(bool requested)
{ {
var latestBackup = BackupPaths().First(); var latestBackup = BackupPaths().First();
@ -244,12 +331,25 @@ public sealed class TerminalStore
private static bool WasPreviouslyInitialized() private static bool WasPreviouslyInitialized()
{ {
using var key = Registry.CurrentUser.OpenSubKey(RegistryPath); using var lmKey = Registry.LocalMachine.OpenSubKey(RegistryPath);
return key?.GetValue(InitializedValueName) is int value && value == 1; 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() 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) using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true)
?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала."); ?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала.");
key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);