Initial Commit

This commit is contained in:
2026-05-25 10:29:38 +08:00
commit c42c9aea2a
64 changed files with 5919 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
namespace PatchProbe.Cli.Auth;
internal sealed record DeviceCredentials(
string DeviceId,
string PrivateKeyPkcs8,
string ServerUrl);

View File

@@ -0,0 +1,55 @@
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);
}
}

View File

@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text;
namespace PatchProbe.Cli.Auth;
internal sealed class EcdsaRequestAuthenticator(IDeviceCredentialStore store) : IRequestAuthenticator
{
public Task AuthenticateAsync(HttpRequestMessage request, string requestBody, CancellationToken ct = default)
{
if (!store.IsEnrolled)
return Task.CompletedTask;
var creds = store.Load();
using var ecdsa = ECDsa.Create();
ecdsa.ImportPkcs8PrivateKey(Convert.FromBase64String(creds.PrivateKeyPkcs8), out _);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
var bodyHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(requestBody)));
// Signed message: deviceId LF timestamp LF sha256(body)
var message = Encoding.UTF8.GetBytes($"{creds.DeviceId}\n{timestamp}\n{bodyHash}");
var signature = Convert.ToBase64String(ecdsa.SignData(message, HashAlgorithmName.SHA256));
request.Headers.Add("X-Device-Id", creds.DeviceId);
request.Headers.Add("X-Timestamp", timestamp);
request.Headers.Add("X-Signature", signature);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
namespace PatchProbe.Cli.Auth;
internal interface IDeviceCredentialStore
{
bool IsEnrolled { get; }
DeviceCredentials Load();
void Save(DeviceCredentials credentials);
void Delete();
}

View File

@@ -0,0 +1,7 @@
namespace PatchProbe.Cli.Auth;
internal interface IRequestAuthenticator
{
/// <summary>Adds authentication headers to an outgoing HTTP request.</summary>
Task AuthenticateAsync(HttpRequestMessage request, string requestBody, CancellationToken ct = default);
}