feat: implement secure config and machine id key generator

This commit is contained in:
Stevan Freeborn
2026-03-30 08:03:27 -05:00
parent 43d64a1855
commit e05e959378
8 changed files with 321 additions and 5 deletions
@@ -1,3 +1,5 @@
using System.Text.Json;
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
@@ -5,7 +7,7 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
internal interface ISecureConfig
{
Task DeleteAsync<t>(string key, CancellationToken ct = default);
Task<bool> DeleteAsync(string key, CancellationToken ct = default);
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, CancellationToken ct = default);
}
@@ -23,16 +25,42 @@ internal sealed class SecureConfig(
public async Task SetAsync<T>(string key, T value, CancellationToken ct = default)
{
throw new NotImplementedException();
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentNullException(nameof(key));
}
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}
var json = JsonSerializer.Serialize(value);
var encryptedValue = _cryptoProvider.Encrypt(json);
await _storageProvider.WriteAsync(key, encryptedValue, ct).ConfigureAwait(false);
}
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
{
throw new NotImplementedException();
if (string.IsNullOrEmpty(key))
{
throw new ArgumentNullException(nameof(key));
}
public async Task DeleteAsync<t>(string key, CancellationToken ct = default)
var encryptedData = await _storageProvider.ReadAsync(key, ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(encryptedData))
{
throw new NotImplementedException();
return default;
}
var data = _cryptoProvider.Decrypt(encryptedData);
return JsonSerializer.Deserialize<T>(data);
}
public Task<bool> DeleteAsync(string key, CancellationToken ct = default)
{
return _storageProvider.DeleteAsync(key, ct);
}
}
@@ -0,0 +1,114 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
internal interface IMachineIdKeyGenerator
{
string GetId();
}
internal sealed class MachineIdKeyGenerator : IMachineIdKeyGenerator
{
private const string IOPlatformUUID = nameof(IOPlatformUUID);
private const string MachineGuid = nameof(MachineGuid);
private const string WinRegistryPath = @"SOFTWARE\Microsoft\Cryptography";
private readonly ILogger<MachineIdKeyGenerator> _logger;
private readonly Lazy<string> _machineId;
public MachineIdKeyGenerator(ILogger<MachineIdKeyGenerator> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_machineId = new(GenerateMachineId);
}
public string GetId()
{
return _machineId.Value;
}
private string GenerateMachineId()
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(WinRegistryPath);
var guid = key?.GetValue(MachineGuid)?.ToString();
if (string.IsNullOrWhiteSpace(guid) is false)
{
return guid;
}
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
const string machineIdPath = "/etc/machine-id";
if (File.Exists(machineIdPath))
{
return File.ReadAllText(machineIdPath).Trim();
}
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
var startInfo = new ProcessStartInfo
{
FileName = "ioreg",
Arguments = "-rd1 -c IOPlatformExpertDevice",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
using var reader = process?.StandardOutput;
var output = reader?.ReadToEnd();
if (output != null && output.Contains(IOPlatformUUID, StringComparison.OrdinalIgnoreCase))
{
var parts = output.Split([IOPlatformUUID], StringSplitOptions.None);
if (parts.Length > 1)
{
var idPart = parts[1].Split('\"');
if (idPart.Length > 3)
{
return idPart[3];
}
}
}
}
}
#pragma warning disable CA1031
catch (Exception ex)
#pragma warning restore CA1031
{
_logger.LogFailedRetrievingMachineId(ex);
}
_logger.LogUsingFallbackStrategy();
return $"{Environment.MachineName}_{Environment.UserName}";
}
}
internal static partial class LogMessages
{
[LoggerMessage(
EventId = 1,
Level = LogLevel.Warning,
Message = "Failed to retrieve hardware-specific machine ID. Falling back to environment variables."
)]
public static partial void LogFailedRetrievingMachineId(this ILogger logger, Exception ex);
[LoggerMessage(
EventId = 2,
Level = LogLevel.Information,
Message = "Using fallback strategy for Machine ID generation."
)]
public static partial void LogUsingFallbackStrategy(this ILogger logger);
}
@@ -0,0 +1,5 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
internal sealed class MachineIdKeyProvider
{
}
@@ -16,6 +16,13 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Text.Json" Version="10.0.5" />
</ItemGroup>
@@ -11,6 +11,7 @@
<PackageReference Include="AwesomeAssertions" Version="9.4.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -0,0 +1,122 @@
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
using System.Text.Json;
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration;
public class SecureConfigTests
{
private readonly Mock<ICryptoProvider> _mockCryptoProvider = new();
private readonly Mock<ISecureStorageProvider> _mockStorageProvider = new();
private readonly SecureConfig _sut;
public SecureConfigTests()
{
_sut = new(_mockStorageProvider.Object, _mockCryptoProvider.Object);
}
[Fact]
public void Constructor_WhenCalledWithNullStorageProvider_ItShouldThrowArgumentNullException()
{
var act = () => new SecureConfig(null!, _mockCryptoProvider.Object);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Constructor_WhenCalledWithNullCryptoProvider_ItShouldThrowArgumentNullException()
{
var act = () => new SecureConfig(_mockStorageProvider.Object, null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public async Task SetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
{
var act = async () => await _sut.SetAsync(null!, string.Empty);
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task SetAsync_WhenCalledWithNullValue_ItShouldThrowArgumentNullException()
{
var act = async () => await _sut.SetAsync<string>("Key", null!);
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task SetAsync_WhenCalled_ItShouldSerializeGivenValueAndEncryptIt()
{
var key = "Database";
var config = new DummyConfig("localhost", 9999);
var encryptedString = "encryptedString";
var json = JsonSerializer.Serialize(config);
_mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString);
await _sut.SetAsync(key, config);
_mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString), Times.Once());
}
[Fact]
public async Task GetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
{
var act = async () => await _sut.GetAsync<DummyConfig>(null!);
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task GetAsync_WhenKeyExists_ItShouldReadDecryptAndDeserializeTheValue()
{
var key = "Database";
var expectedConfig = new DummyConfig("localhost", 9999);
var encryptedString = "encryptedString";
var json = JsonSerializer.Serialize(expectedConfig);
_mockStorageProvider.Setup(m => m.ReadAsync(key)).ReturnsAsync(encryptedString);
_mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json);
var result = await _sut.GetAsync<DummyConfig>(key);
result.Should().BeEquivalentTo(expectedConfig);
}
[Fact]
public async Task GetAsync_WhenKeyDoesNotExist_ItShouldReturnDefaultValue()
{
var key = "Database";
var expectedConfig = new DummyConfig("localhost", 9999);
var json = JsonSerializer.Serialize(expectedConfig);
_mockStorageProvider.Setup(m => m.ReadAsync(key)).ReturnsAsync(string.Empty);
var result = await _sut.GetAsync<DummyConfig>(key);
result.Should().BeNull();
}
[Fact]
public async Task DeleteAsync_WhenCalled_ItShouldRemoveValue()
{
var key = "Database";
_mockStorageProvider.Setup(m => m.DeleteAsync(key)).ReturnsAsync(true);
var result = await _sut.DeleteAsync(key);
result.Should().BeTrue();
_mockStorageProvider.Verify(m => m.DeleteAsync(key), Times.Once());
}
private sealed record DummyConfig(string Host, int Port);
}
@@ -0,0 +1,33 @@
using Microsoft.Extensions.Logging;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography;
public class MachineIdKeyGeneratorTests
{
private readonly Mock<ILogger<MachineIdKeyGenerator>> _mockLogger = new();
private readonly MachineIdKeyGenerator _sut;
public MachineIdKeyGeneratorTests()
{
_sut = new(_mockLogger.Object);
}
[Fact]
public void GetId_WhenCalled_ItShouldReturnNonEmptyString()
{
_sut.GetId().Should().NotBeEmpty();
}
[Fact]
public void GetId_WhenCalledMultipleTimes_ItShouldReturnConsistentId()
{
var resultOne = _sut.GetId();
var resultTwo = _sut.GetId();
resultTwo.Should().Be(resultOne);
}
}
@@ -0,0 +1,6 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography;
public class MachineIdKeyProviderTests
{
}