Initial Commit
This commit is contained in:
6
PatchProbe.Cli/Auth/DeviceCredentials.cs
Normal file
6
PatchProbe.Cli/Auth/DeviceCredentials.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal sealed record DeviceCredentials(
|
||||
string DeviceId,
|
||||
string PrivateKeyPkcs8,
|
||||
string ServerUrl);
|
||||
55
PatchProbe.Cli/Auth/DpapiCredentialStore.cs
Normal file
55
PatchProbe.Cli/Auth/DpapiCredentialStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs
Normal file
31
PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
9
PatchProbe.Cli/Auth/IDeviceCredentialStore.cs
Normal file
9
PatchProbe.Cli/Auth/IDeviceCredentialStore.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal interface IDeviceCredentialStore
|
||||
{
|
||||
bool IsEnrolled { get; }
|
||||
DeviceCredentials Load();
|
||||
void Save(DeviceCredentials credentials);
|
||||
void Delete();
|
||||
}
|
||||
7
PatchProbe.Cli/Auth/IRequestAuthenticator.cs
Normal file
7
PatchProbe.Cli/Auth/IRequestAuthenticator.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user