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.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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue