diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs index 6fe2ad7..2f5b8fc 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs @@ -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(string key, CancellationToken ct = default); + Task DeleteAsync(string key, CancellationToken ct = default); Task GetAsync(string key, CancellationToken ct = default); Task SetAsync(string key, T value, CancellationToken ct = default); } @@ -23,16 +25,42 @@ internal sealed class SecureConfig( public async Task SetAsync(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 GetAsync(string key, CancellationToken ct = default) { - throw new NotImplementedException(); + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + var encryptedData = await _storageProvider.ReadAsync(key, ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(encryptedData)) + { + return default; + } + + var data = _cryptoProvider.Decrypt(encryptedData); + return JsonSerializer.Deserialize(data); } - public async Task DeleteAsync(string key, CancellationToken ct = default) + public Task DeleteAsync(string key, CancellationToken ct = default) { - throw new NotImplementedException(); + return _storageProvider.DeleteAsync(key, ct); } } \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs new file mode 100644 index 0000000..971a7ca --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs @@ -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 _logger; + private readonly Lazy _machineId; + + public MachineIdKeyGenerator(ILogger 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); +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs new file mode 100644 index 0000000..088f671 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs @@ -0,0 +1,5 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +internal sealed class MachineIdKeyProvider +{ +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj index 6c920f2..1c54fc1 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj @@ -16,6 +16,13 @@ + + + + + + + diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj index 33fa446..67d83b6 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs new file mode 100644 index 0000000..029d9ca --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs @@ -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 _mockCryptoProvider = new(); + private readonly Mock _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(); + } + + [Fact] + public void Constructor_WhenCalledWithNullCryptoProvider_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfig(_mockStorageProvider.Object, null!); + + act.Should().Throw(); + } + + [Fact] + public async Task SetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException() + { + var act = async () => await _sut.SetAsync(null!, string.Empty); + + await act.Should().ThrowAsync(); + } + + + [Fact] + public async Task SetAsync_WhenCalledWithNullValue_ItShouldThrowArgumentNullException() + { + var act = async () => await _sut.SetAsync("Key", null!); + + await act.Should().ThrowAsync(); + } + + [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(null!); + + await act.Should().ThrowAsync(); + } + + [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(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(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); +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyGeneratorTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyGeneratorTests.cs new file mode 100644 index 0000000..9fe881f --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyGeneratorTests.cs @@ -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> _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); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs new file mode 100644 index 0000000..bc6af7b --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs @@ -0,0 +1,6 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; + +public class MachineIdKeyProviderTests +{ + +} \ No newline at end of file