feat(store): configure DPAPI LocalMachine scope with restricted ACL and migration support
This commit is contained in:
parent
c41bdd8654
commit
14ce259a5b
1 changed files with 122 additions and 22 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue