Files
PatchProbe-Server/PatchProbe.Cli/Auth/DpapiCredentialStore.cs
2026-05-25 10:29:38 +08:00

56 lines
2.0 KiB
C#

using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace PatchProbe.Cli.Auth;
internal sealed class DpapiCredentialStore : IDeviceCredentialStore
{
// %ProgramData%\PatchProbe\device.cred — machine-scoped, survives user changes
internal static readonly string StorePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"PatchProbe", "device.cred");
public bool IsEnrolled => File.Exists(StorePath);
public DeviceCredentials Load()
{
var cipher = File.ReadAllBytes(StorePath);
var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.LocalMachine);
return JsonSerializer.Deserialize<DeviceCredentials>(Encoding.UTF8.GetString(plain))
?? throw new InvalidOperationException("Credential file is corrupt or empty.");
}
public void Save(DeviceCredentials credentials)
{
var dir = Path.GetDirectoryName(StorePath)!;
Directory.CreateDirectory(dir);
var plain = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(credentials));
var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.LocalMachine);
File.WriteAllBytes(StorePath, cipher);
RestrictToAdmins(StorePath);
}
public void Delete()
{
if (File.Exists(StorePath))
File.Delete(StorePath);
}
private static void RestrictToAdmins(string path)
{
var fi = new FileInfo(path);
var acl = fi.GetAccessControl();
// Break inheritance; grant only SYSTEM and Administrators full control
acl.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
acl.AddAccessRule(new FileSystemAccessRule(
"SYSTEM", FileSystemRights.FullControl, AccessControlType.Allow));
acl.AddAccessRule(new FileSystemAccessRule(
"Administrators", FileSystemRights.FullControl, AccessControlType.Allow));
fi.SetAccessControl(acl);
}
}