From 38daead9b28cf7d84311d18ebd6f617ca52900a5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:46:45 -0500 Subject: [PATCH 01/55] feat: implement read and write methods for json file storage provider --- .editorconfig | 15 +++-- StevanFreeborn.SecureConfig.slnx | 6 ++ .../ApiOptions.cs | 6 ++ .../Program.cs | 38 +++++++++++++ ...ensions.Configuration.Secure.Sample.csproj | 18 ++++++ ...orn.Extensions.Configuration.Secure.csproj | 15 +++++ .../Storage/JsonFileStorageProvider.cs | 56 +++++++++++++++++++ .../Storage/JsonStorageOptions.cs | 8 +++ tests/.editorconfig | 3 + ...tensions.Configuration.Secure.Tests.csproj | 27 +++++++++ .../Storage/JsonFileStorageProviderTests.cs | 44 +++++++++++++++ .../Unit/Storage/JsonStorageOptionsTests.cs | 36 ++++++++++++ 12 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs create mode 100644 tests/.editorconfig create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonStorageOptionsTests.cs diff --git a/.editorconfig b/.editorconfig index af3d700..8296f0a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,8 +25,8 @@ indent_size = 2 #### Core EditorConfig Options #### # Indentation and spacing -indent_size = 4 -tab_width = 4 +indent_size = 2 +tab_width = 2 # New line preferences insert_final_newline = false @@ -70,7 +70,7 @@ dotnet_style_prefer_auto_properties = true:suggestion dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:silent; dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_inferred_tuple_names = true:suggestion @@ -90,10 +90,13 @@ dotnet_remove_unnecessary_suppression_exclusions = none #### C# Coding Conventions #### [*.cs] +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0100.severity = none + # var preferences -csharp_style_var_elsewhere = false:silent -csharp_style_var_for_built_in_types = false:silent -csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion # Expression-bodied members csharp_style_expression_bodied_accessors = true:silent diff --git a/StevanFreeborn.SecureConfig.slnx b/StevanFreeborn.SecureConfig.slnx index ba788ff..f129078 100644 --- a/StevanFreeborn.SecureConfig.slnx +++ b/StevanFreeborn.SecureConfig.slnx @@ -1,2 +1,8 @@ + + + + + + diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs new file mode 100644 index 0000000..2b7fd41 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs @@ -0,0 +1,6 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; + +public sealed record ApiOptions +{ + public string ApiKey { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs new file mode 100644 index 0000000..ab33fba --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +using StevanFreeborn.Extensions.Configuration.Secure.Sample; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +var builder = Host.CreateApplicationBuilder(); + +const string configFileName = "appsettings.json"; +var opts = new JsonStorageOptions() +{ + FileName = configFileName, +}; + +var storageProvider = new JsonFileStorageProvider(opts); + +builder.Configuration.AddSecureConfig(storageProvider); + +builder.Services.Configure( + builder.Configuration.GetSection(nameof(ApiOptions)) +); + +builder.Services.AddSecureConfig() + .UseJsonFileStorage(opt => opt.FileName = configFileName); + +var app = builder.Build(); + +var options = app.Services.GetRequiredService>(); +var secureConfig = app.Services.GetRequiredService(); + +Console.WriteLine(options.Value); + +await secureConfig.SetAsync( + nameof(ApiOptions), + new ApiOptions { ApiKey = "apiKey" } +); + diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj new file mode 100644 index 0000000..94ae0dc --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj @@ -0,0 +1,18 @@ + + + + Exe + net11.0 + enable + enable + + + + + + + + + + + diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj new file mode 100644 index 0000000..c2b6789 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.1 + enable + enable + latest + true + + + + + + + diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs new file mode 100644 index 0000000..9e24588 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -0,0 +1,56 @@ +using System.Text.Json; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; + +public sealed class JsonFileStorageProvider(JsonStorageOptions options) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + }; + private readonly JsonStorageOptions _options = options + ?? throw new ArgumentNullException(nameof(options)); + + public async Task ReadAsync(string key, CancellationToken ct = default) + { + if (File.Exists(_options.FullPath) is false) + { + return string.Empty; + } + + using var stream = new FileStream(_options.FullPath, FileMode.Open, FileAccess.Read); + + if (stream.Length is 0) + { + return string.Empty; + } + + var data = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct); + + if (data is not null && data.TryGetValue(key, out var v)) + { + return v; + } + + return string.Empty; + } + + public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) + { + Directory.CreateDirectory(_options.DirectoryPath); + + var data = new Dictionary + { + [key] = encryptedData + }; + + using var stream = new FileStream( + _options.FullPath, + FileMode.Create, + FileAccess.Write, + FileShare.None + ); + + await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct); + } +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs new file mode 100644 index 0000000..c40a93a --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs @@ -0,0 +1,8 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; + +public sealed class JsonStorageOptions +{ + public string FileName { get; set; } = string.Empty; + public string DirectoryPath { get; set; } = AppContext.BaseDirectory; + public string FullPath => Path.Combine(DirectoryPath, FileName); +} \ No newline at end of file diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..ccb26d4 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,3 @@ +[*.cs] + +dotnet_diagnostic.CA1707.severity = none 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 new file mode 100644 index 0000000..33fa446 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj @@ -0,0 +1,27 @@ + + + + net11.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs new file mode 100644 index 0000000..71fcfd7 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs @@ -0,0 +1,44 @@ +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage; + +public class JsonFileStorageProviderTests +{ + private readonly string _tmpDirectory; + private readonly JsonStorageOptions _options; + private readonly JsonFileStorageProvider _sut; + + public JsonFileStorageProviderTests() + { + _tmpDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tmpDirectory); + + _options = new() + { + FileName = "testsettings.json" + }; + + _sut = new(_options); + } + + [Fact] + public void Constructor_WhenCalledWithNullOptions_ItShouldThrowArgumentNullException() + { + var act = static () => new JsonFileStorageProvider(null!); + + act.Should().Throw(); + } + + [Fact] + public async Task WriteAsync_And_ReadAsync_WhenCalled_ItShouldBeAbleToPersistAndRetrieveValues() + { + var configKey = "Key"; + var configValue = "Value"; + + await _sut.WriteAsync(configKey, configValue); + var result = await _sut.ReadAsync(configKey); + + result.Should().Be(configValue); + File.Exists(_options.FullPath).Should().BeTrue(); + } +} diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonStorageOptionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonStorageOptionsTests.cs new file mode 100644 index 0000000..948e90c --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonStorageOptionsTests.cs @@ -0,0 +1,36 @@ +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage; + +public class JsonStorageOptionsTests +{ + [Fact] + public void Path_WhenCalledWithFileNameSet_ItShouldReturnExpectedPath() + { + var fileName = "appsettings.json"; + var expectedPath = Path.Combine(AppContext.BaseDirectory, fileName); + + var opts = new JsonStorageOptions() + { + FileName = fileName, + }; + + opts.FullPath.Should().Be(expectedPath); + } + + [Fact] + public void Path_WhenCalledWithFileNameAndDirectoryPathSet_ItShouldReturnExpectedPath() + { + var fileName = "appsettings.json"; + var directoryPath = @"C:\Path\To\Some\Directory"; + var expectedPath = Path.Combine(directoryPath, fileName); + + var opts = new JsonStorageOptions() + { + FileName = fileName, + DirectoryPath = directoryPath, + }; + + opts.FullPath.Should().Be(expectedPath); + } +} \ No newline at end of file From a8774aefe670add3c9b1684c75d99c71e80ed040 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:07:48 -0500 Subject: [PATCH 02/55] feat: implement readallasync method --- .../Storage/JsonFileStorageProvider.cs | 44 ++++++++++++------- .../Storage/JsonFileStorageProviderTests.cs | 32 ++++++++++++-- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 9e24588..8caf22a 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -13,19 +13,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) public async Task ReadAsync(string key, CancellationToken ct = default) { - if (File.Exists(_options.FullPath) is false) - { - return string.Empty; - } - - using var stream = new FileStream(_options.FullPath, FileMode.Open, FileAccess.Read); - - if (stream.Length is 0) - { - return string.Empty; - } - - var data = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct); + var data = await LoadAsync(ct); if (data is not null && data.TryGetValue(key, out var v)) { @@ -35,14 +23,17 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) return string.Empty; } + public Task> ReadAllAsync(CancellationToken ct = default) + { + return LoadAsync(ct); + } + public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) { Directory.CreateDirectory(_options.DirectoryPath); - var data = new Dictionary - { - [key] = encryptedData - }; + var data = await LoadAsync(ct); + data[key] = encryptedData; using var stream = new FileStream( _options.FullPath, @@ -53,4 +44,23 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct); } + + private async Task> LoadAsync(CancellationToken ct) + { + if (File.Exists(_options.FullPath) is false) + { + return []; + } + + using var stream = new FileStream(_options.FullPath, FileMode.Open, FileAccess.Read); + + if (stream.Length is 0) + { + return []; + } + + var data = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct); + + return data ?? []; + } } \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs index 71fcfd7..ed35f14 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs @@ -2,7 +2,7 @@ using StevanFreeborn.Extensions.Configuration.Secure.Storage; namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage; -public class JsonFileStorageProviderTests +public class JsonFileStorageProviderTests : IDisposable { private readonly string _tmpDirectory; private readonly JsonStorageOptions _options; @@ -15,7 +15,8 @@ public class JsonFileStorageProviderTests _options = new() { - FileName = "testsettings.json" + FileName = "testsettings.json", + DirectoryPath = _tmpDirectory, }; _sut = new(_options); @@ -32,7 +33,7 @@ public class JsonFileStorageProviderTests [Fact] public async Task WriteAsync_And_ReadAsync_WhenCalled_ItShouldBeAbleToPersistAndRetrieveValues() { - var configKey = "Key"; + var configKey = "KeyA"; var configValue = "Value"; await _sut.WriteAsync(configKey, configValue); @@ -41,4 +42,29 @@ public class JsonFileStorageProviderTests result.Should().Be(configValue); File.Exists(_options.FullPath).Should().BeTrue(); } + + [Fact] + public async Task ReadAllAsync_WhenCalled_ItShouldReturnAllStoredKeys() + { + await _sut.WriteAsync("Key1", "Val1"); + await _sut.WriteAsync("Key2", "Val2"); + + var allData = await _sut.ReadAllAsync(); + + allData.Should().BeEquivalentTo(new Dictionary() + { + ["Key1"] = "Val1", + ["Key2"] = "Val2", + }); + } + + public void Dispose() + { + if (Directory.Exists(_tmpDirectory)) + { + Directory.Delete(_tmpDirectory, true); + } + + GC.SuppressFinalize(this); + } } From 8453381fe09f5598489a249cb5eb21f3e192f26b Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:14:37 -0500 Subject: [PATCH 03/55] feat: implement delete async method on json storage provider --- .../Storage/JsonFileStorageProvider.cs | 32 +++++++++++++------ .../Storage/JsonFileStorageProviderTests.cs | 14 +++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 8caf22a..9fcb45f 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -30,19 +30,17 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) { - Directory.CreateDirectory(_options.DirectoryPath); - var data = await LoadAsync(ct); data[key] = encryptedData; + await SaveAsync(data, ct); + } - using var stream = new FileStream( - _options.FullPath, - FileMode.Create, - FileAccess.Write, - FileShare.None - ); - - await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct); + public async Task DeleteAsync(string key, CancellationToken ct = default) + { + var data = await LoadAsync(ct); + var result = data.Remove(key); + await SaveAsync(data, ct); + return result; } private async Task> LoadAsync(CancellationToken ct) @@ -63,4 +61,18 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) return data ?? []; } + + private async Task SaveAsync(Dictionary data, CancellationToken ct) + { + Directory.CreateDirectory(_options.DirectoryPath); + + using var stream = new FileStream( + _options.FullPath, + FileMode.Create, + FileAccess.Write, + FileShare.None + ); + + await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct); + } } \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs index ed35f14..69eac9f 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs @@ -58,6 +58,18 @@ public class JsonFileStorageProviderTests : IDisposable }); } + [Fact] + public async Task DeleteAsync_WhenCalled_ItShouldRemoveKey() + { + await _sut.WriteAsync("KeyToDelete", "SomeValue"); + + var deleteResult = await _sut.DeleteAsync("KeyToDelete"); + var readResult = await _sut.ReadAsync("KeyToDelete"); + + deleteResult.Should().BeTrue(); + readResult.Should().BeEmpty(); + } + public void Dispose() { if (Directory.Exists(_tmpDirectory)) @@ -67,4 +79,4 @@ public class JsonFileStorageProviderTests : IDisposable GC.SuppressFinalize(this); } -} +} \ No newline at end of file From c8dbde8dc8f6cabd5071151856aa945666661c42 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:25:39 -0500 Subject: [PATCH 04/55] feat: support concurrency in json file storage provider --- .../Storage/JsonFileStorageProvider.cs | 51 +++++++++++++++---- .../Storage/JsonFileStorageProviderTests.cs | 19 +++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 9fcb45f..8fd8482 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -4,6 +4,7 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; public sealed class JsonFileStorageProvider(JsonStorageOptions options) { + private static readonly SemaphoreSlim FileLock = new(1, 1); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -13,7 +14,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) public async Task ReadAsync(string key, CancellationToken ct = default) { - var data = await LoadAsync(ct); + var data = await LoadWithLockAsync(ct); if (data is not null && data.TryGetValue(key, out var v)) { @@ -25,22 +26,54 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) public Task> ReadAllAsync(CancellationToken ct = default) { - return LoadAsync(ct); + return LoadWithLockAsync(ct); } public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) { - var data = await LoadAsync(ct); - data[key] = encryptedData; - await SaveAsync(data, ct); + await FileLock.WaitAsync(ct); + + try + { + var data = await LoadAsync(ct); + data[key] = encryptedData; + await SaveAsync(data, ct); + } + finally + { + FileLock.Release(); + } } public async Task DeleteAsync(string key, CancellationToken ct = default) { - var data = await LoadAsync(ct); - var result = data.Remove(key); - await SaveAsync(data, ct); - return result; + await FileLock.WaitAsync(ct); + + try + { + var data = await LoadAsync(ct); + var result = data.Remove(key); + await SaveAsync(data, ct); + return result; + } + finally + { + FileLock.Release(); + } + } + + private async Task> LoadWithLockAsync(CancellationToken ct) + { + await FileLock.WaitAsync(ct); + + try + { + return await LoadAsync(ct); + } + finally + { + FileLock.Release(); + } } private async Task> LoadAsync(CancellationToken ct) diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs index 69eac9f..922e827 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs @@ -79,4 +79,23 @@ public class JsonFileStorageProviderTests : IDisposable GC.SuppressFinalize(this); } + + [Fact] + public async Task WriteAsync_WhenCalledConcurrently_ItShouldNotThrowFileInUseException() + { + const int numberOfWrites = 50; + var tasks = new List(); + + foreach (var index in Enumerable.Range(0, numberOfWrites)) + { + tasks.Add(Task.Run(() => _sut.WriteAsync($"Key{index}", $"Val{index}"))); + } + + var act = async () => await Task.WhenAll(tasks); + + await act.Should().NotThrowAsync(); + + var allData = await _sut.ReadAllAsync(); + allData.Should().HaveCount(numberOfWrites); + } } \ No newline at end of file From c39efb698b0f928d0fcb3108690b7eefaa0b8bf8 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:40:39 -0500 Subject: [PATCH 05/55] chore: add build props --- .../StevanFreeborn.Extensions.Configuration.Secure.csproj | 7 +++++++ 1 file changed, 7 insertions(+) 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 c2b6789..6c920f2 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj @@ -6,6 +6,13 @@ enable latest true + true + + latest + All + true + true + true From dbb56a21dd2d9191ede1f925e24051f777708fa9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:41:22 -0500 Subject: [PATCH 06/55] fix: address analyzer warnings - call configureawait(false) where applicable - add missing xml comments --- .../Storage/JsonFileStorageProvider.cs | 72 +++++++++++++++---- .../Storage/JsonStorageOptions.cs | 14 ++++ 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 8fd8482..ce280ab 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -2,6 +2,10 @@ using System.Text.Json; namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; +/// +/// Provides a mechanism to store and retrieve secure configuration data in a JSON file. +/// +/// The configuring the storage provider, including file paths. public sealed class JsonFileStorageProvider(JsonStorageOptions options) { private static readonly SemaphoreSlim FileLock = new(1, 1); @@ -12,9 +16,15 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) private readonly JsonStorageOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + /// + /// Reads the value associated with the specified key from the JSON file asynchronously. + /// + /// The key of the configuration value to read. + /// A cancellation token that can be used to cancel the read operation. + /// A task that represents the asynchronous read operation. The task result contains the value associated with the specified key, or an empty string if the key is not found. public async Task ReadAsync(string key, CancellationToken ct = default) { - var data = await LoadWithLockAsync(ct); + var data = await AcquireLockAndLoadAsync(ct).ConfigureAwait(false); if (data is not null && data.TryGetValue(key, out var v)) { @@ -24,20 +34,32 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) return string.Empty; } + /// + /// Reads all configuration values from the JSON file asynchronously. + /// + /// A cancellation token that can be used to cancel the read operation. + /// A task that represents the asynchronous read operation. The task result contains a dictionary of all configuration keys and their values. public Task> ReadAllAsync(CancellationToken ct = default) { - return LoadWithLockAsync(ct); + return AcquireLockAndLoadAsync(ct); } + /// + /// Writes the specified key and encrypted data to the JSON file asynchronously. + /// + /// The key of the configuration value to write. + /// The encrypted configuration data to write. + /// A cancellation token that can be used to cancel the write operation. + /// A task that represents the asynchronous write operation. public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) { - await FileLock.WaitAsync(ct); + await FileLock.WaitAsync(ct).ConfigureAwait(false); try { - var data = await LoadAsync(ct); + var data = await LoadAsync(ct).ConfigureAwait(false); data[key] = encryptedData; - await SaveAsync(data, ct); + await SaveAsync(data, ct).ConfigureAwait(false); } finally { @@ -45,15 +67,21 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) } } + /// + /// Deletes the configuration value associated with the specified key from the JSON file asynchronously. + /// + /// The key of the configuration value to delete. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous delete operation. The task result contains true if the value was successfully deleted; otherwise, false. public async Task DeleteAsync(string key, CancellationToken ct = default) { - await FileLock.WaitAsync(ct); + await FileLock.WaitAsync(ct).ConfigureAwait(false); try { - var data = await LoadAsync(ct); + var data = await LoadAsync(ct).ConfigureAwait(false); var result = data.Remove(key); - await SaveAsync(data, ct); + await SaveAsync(data, ct).ConfigureAwait(false); return result; } finally @@ -62,13 +90,18 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) } } - private async Task> LoadWithLockAsync(CancellationToken ct) + /// + /// Acquires an exclusive lock and loads the configuration data from the JSON file. + /// + /// A cancellation token to observe while waiting for the lock or during the load operation. + /// A dictionary containing the loaded configuration data. + private async Task> AcquireLockAndLoadAsync(CancellationToken ct) { - await FileLock.WaitAsync(ct); + await FileLock.WaitAsync(ct).ConfigureAwait(false); try { - return await LoadAsync(ct); + return await LoadAsync(ct).ConfigureAwait(false); } finally { @@ -76,6 +109,11 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) } } + /// + /// Loads the configuration data from the JSON file. + /// + /// A cancellation token to observe while loading the data. + /// A dictionary containing the loaded configuration data, or an empty dictionary if the file does not exist or is empty. private async Task> LoadAsync(CancellationToken ct) { if (File.Exists(_options.FullPath) is false) @@ -90,11 +128,18 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) return []; } - var data = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct); + var data = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct) + .ConfigureAwait(false); return data ?? []; } + /// + /// Saves the configuration data to the JSON file. + /// + /// The configuration data to save. + /// A cancellation token to observe while saving the data. + /// A task that represents the asynchronous save operation. private async Task SaveAsync(Dictionary data, CancellationToken ct) { Directory.CreateDirectory(_options.DirectoryPath); @@ -106,6 +151,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) FileShare.None ); - await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct); + await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct) + .ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs index c40a93a..4abac49 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs @@ -1,8 +1,22 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; +/// +/// Options for configuring the . +/// public sealed class JsonStorageOptions { + /// + /// Gets or sets the name of the JSON file used for storage. + /// public string FileName { get; set; } = string.Empty; + + /// + /// Gets or sets the directory path where the JSON file is located. Defaults to the base directory of the application. + /// public string DirectoryPath { get; set; } = AppContext.BaseDirectory; + + /// + /// Gets the full, combined path to the JSON file, including the directory and file name. + /// public string FullPath => Path.Combine(DirectoryPath, FileName); } \ No newline at end of file From d57bc9f5ad64fe3b44c540e83db9859ad69897cd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:41:41 -0500 Subject: [PATCH 07/55] style: remove whitespace --- .../Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs index ab33fba..57d8840 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs @@ -35,4 +35,3 @@ await secureConfig.SetAsync( nameof(ApiOptions), new ApiOptions { ApiKey = "apiKey" } ); - From a8439763190396d614e3c3c9a1be7cd2de75c153 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:01:44 -0500 Subject: [PATCH 08/55] refactor: extract interface for storage provider --- .../Storage/ISecureStorageProvider.cs | 39 +++++++++++++++++++ .../Storage/JsonFileStorageProvider.cs | 6 +-- 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs new file mode 100644 index 0000000..8b5e0db --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs @@ -0,0 +1,39 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; + +/// +/// Defines a contract for a provider that stores and retrieves secure configuration data. +/// +public interface ISecureStorageProvider +{ + /// + /// Reads the value associated with the specified key asynchronously. + /// + /// The key of the configuration value to read. + /// A cancellation token that can be used to cancel the read operation. + /// A task that represents the asynchronous read operation. The task result contains the value associated with the specified key, or an empty string if the key is not found. + Task ReadAsync(string key, CancellationToken ct = default); + + /// + /// Reads all configuration values asynchronously. + /// + /// A cancellation token that can be used to cancel the read operation. + /// A task that represents the asynchronous read operation. The task result contains a dictionary of all configuration keys and their encrypted values. + Task> ReadAllAsync(CancellationToken ct = default); + + /// + /// Writes the specified key and encrypted data asynchronously. + /// + /// The key of the configuration value to write. + /// The encrypted configuration data to write. + /// A cancellation token that can be used to cancel the write operation. + /// A task that represents the asynchronous write operation. + Task WriteAsync(string key, string encryptedData, CancellationToken ct = default); + + /// + /// Deletes the configuration value associated with the specified key asynchronously. + /// + /// The key of the configuration value to delete. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous delete operation. The task result contains true if the value was successfully deleted; otherwise, false. + Task DeleteAsync(string key, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index ce280ab..19add1f 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -6,7 +6,7 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; /// Provides a mechanism to store and retrieve secure configuration data in a JSON file. /// /// The configuring the storage provider, including file paths. -public sealed class JsonFileStorageProvider(JsonStorageOptions options) +public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecureStorageProvider { private static readonly SemaphoreSlim FileLock = new(1, 1); private static readonly JsonSerializerOptions JsonOptions = new() @@ -39,7 +39,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) /// /// A cancellation token that can be used to cancel the read operation. /// A task that represents the asynchronous read operation. The task result contains a dictionary of all configuration keys and their values. - public Task> ReadAllAsync(CancellationToken ct = default) + public Task> ReadAllAsync(CancellationToken ct = default) { return AcquireLockAndLoadAsync(ct); } @@ -95,7 +95,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) /// /// A cancellation token to observe while waiting for the lock or during the load operation. /// A dictionary containing the loaded configuration data. - private async Task> AcquireLockAndLoadAsync(CancellationToken ct) + private async Task> AcquireLockAndLoadAsync(CancellationToken ct) { await FileLock.WaitAsync(ct).ConfigureAwait(false); From 43d64a185537fc3d902aad26fbfe4c89eba70150 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:02:07 -0500 Subject: [PATCH 09/55] feat: stub out secure config with dependencies --- .../Configuration/SecureConfig.cs | 38 +++++++++++++++++++ .../Cryptography/ICryptoProvider.cs | 7 ++++ 2 files changed, 45 insertions(+) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs new file mode 100644 index 0000000..6fe2ad7 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs @@ -0,0 +1,38 @@ +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal interface ISecureConfig +{ + Task DeleteAsync(string key, CancellationToken ct = default); + Task GetAsync(string key, CancellationToken ct = default); + Task SetAsync(string key, T value, CancellationToken ct = default); +} + +internal sealed class SecureConfig( + ISecureStorageProvider storageProvider, + ICryptoProvider cryptoProvider +) : ISecureConfig +{ + private readonly ISecureStorageProvider _storageProvider = storageProvider ?? + throw new ArgumentNullException(nameof(storageProvider)); + + private readonly ICryptoProvider _cryptoProvider = cryptoProvider ?? + throw new ArgumentNullException(nameof(cryptoProvider)); + + public async Task SetAsync(string key, T value, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public async Task GetAsync(string key, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public async Task DeleteAsync(string key, CancellationToken ct = default) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs new file mode 100644 index 0000000..4009509 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs @@ -0,0 +1,7 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +internal interface ICryptoProvider +{ + string Encrypt(string plainText); + string Decrypt(string cipherText); +} \ No newline at end of file From e05e959378e55222484ab3c234850f2ef01aa53c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:03:27 -0500 Subject: [PATCH 10/55] feat: implement secure config and machine id key generator --- .../Configuration/SecureConfig.cs | 38 +++++- .../Cryptography/MachineIdKeyGenerator.cs | 114 ++++++++++++++++ .../Cryptography/MachineIdKeyProvider.cs | 5 + ...orn.Extensions.Configuration.Secure.csproj | 7 + ...tensions.Configuration.Secure.Tests.csproj | 1 + .../Unit/Configuration/SecureConfigTests.cs | 122 ++++++++++++++++++ .../MachineIdKeyGeneratorTests.cs | 33 +++++ .../Cryptography/MachineIdKeyProviderTests.cs | 6 + 8 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyGeneratorTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs 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 From 2915c3227485004496d2893c8ceca11e7685b3e5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 31 Mar 2026 07:15:03 -0500 Subject: [PATCH 11/55] feat: implement machine key provider - implement logic for hashing machine key - extracted interfaces --- .../Cryptography/IMachineIdKeyGenerator.cs | 6 ++ .../Cryptography/IMachineIdKeyProvider.cs | 6 ++ .../Cryptography/MachineIdKeyGenerator.cs | 7 +- .../Cryptography/MachineIdKeyProvider.cs | 14 +++- .../Cryptography/MachineIdKeyProviderTests.cs | 72 +++++++++++++++++++ 5 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyProvider.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs new file mode 100644 index 0000000..53a05a8 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs @@ -0,0 +1,6 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +internal interface IMachineIdKeyGenerator +{ + string GetId(); +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyProvider.cs new file mode 100644 index 0000000..9bcd2c4 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyProvider.cs @@ -0,0 +1,6 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +internal interface IEncryptionKeyProvider +{ + byte[] GetKey(); +} \ 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 index 971a7ca..93e4673 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs @@ -5,11 +5,6 @@ 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); @@ -111,4 +106,4 @@ internal static partial class LogMessages Message = "Using fallback strategy for Machine ID generation." )] public static partial void LogUsingFallbackStrategy(this ILogger logger); -} +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs index 088f671..cc17a29 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs @@ -1,5 +1,17 @@ +using System.Security.Cryptography; +using System.Text; + namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; -internal sealed class MachineIdKeyProvider +internal sealed class MachineIdKeyProvider(IMachineIdKeyGenerator generator) : IEncryptionKeyProvider { + private readonly IMachineIdKeyGenerator _generator = generator; + + public byte[] GetKey() + { + var machineId = _generator.GetId(); + var @bytes = Encoding.UTF8.GetBytes(machineId); + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(@bytes); + } } \ 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 index bc6af7b..ba80069 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs @@ -1,6 +1,78 @@ +using System.Security.Cryptography; +using System.Text; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; public class MachineIdKeyProviderTests { + private readonly Mock _mockKeyGenerator = new(); + private readonly MachineIdKeyProvider _sut; + public MachineIdKeyProviderTests() + { + _sut = new(_mockKeyGenerator.Object); + } + + [Fact] + public void GetKey_WhenCalled_ItShouldReturn32ByteKey() + { + _mockKeyGenerator.Setup(static m => m.GetId()).Returns("random-stuff"); + + var result = _sut.GetKey(); + + result.Length.Should().Be(32); + } + + [Fact] + public void GetKey_WhenCalledForSameInput_ItShouldProduceConsistentHash() + { + var input = "some-machine-id"; + + var mockInstanceOne = new Mock(); + mockInstanceOne.Setup(static m => m.GetId()).Returns(input); + + var instanceOne = new MachineIdKeyProvider(mockInstanceOne.Object); + var resultOne = instanceOne.GetKey(); + + var mockInstanceTwo = new Mock(); + mockInstanceTwo.Setup(static m => m.GetId()).Returns(input); + + var instanceTwo = new MachineIdKeyProvider(mockInstanceTwo.Object); + var resultTwo = instanceTwo.GetKey(); + + resultOne.Should().BeEquivalentTo(resultTwo); + } + + [Fact] + public void GetKey_WhenCalledWithDifferentInput_ItShouldProduceDifferentHashes() + { + var inputOne = "inputOne"; + var inputTwo = "inputTwo"; + + _mockKeyGenerator.SetupSequence(static m => m.GetId()) + .Returns(inputOne) + .Returns(inputTwo); + + var resultOne = _sut.GetKey(); + var resultTwo = _sut.GetKey(); + + resultOne.Should().NotBeEquivalentTo(resultTwo); + } + + [Fact] + public void GetKey_WhenCalled_ItShouldUseSHA256Hash() + { + var rawId = "rawId"; + var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(rawId)); + + _mockKeyGenerator.Setup(static m => m.GetId()).Returns(rawId); + + var result = _sut.GetKey(); + + result.Should().BeEquivalentTo(expectedHash); + } } \ No newline at end of file From b843cf9f0858d030e9f1b5a9655ae828e5e0f8ec Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 31 Mar 2026 07:15:13 -0500 Subject: [PATCH 12/55] feat: wip on aes crypto provider --- .../Cryptography/AesCryptoProvider.cs | 20 +++++++++++++ .../Cryptography/AesCryptoProviderTests.cs | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs new file mode 100644 index 0000000..c75220f --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs @@ -0,0 +1,20 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +internal sealed class AesCryptoProvider(IEncryptionKeyProvider keyProvider) : ICryptoProvider +{ + private readonly IEncryptionKeyProvider _keyProvider = keyProvider + ?? throw new ArgumentNullException(nameof(keyProvider)); + + // TODO: Implement this shit + + public string Decrypt(string cipherText) + { + _ = _keyProvider.GetKey(); + throw new NotImplementedException(); + } + + public string Encrypt(string plainText) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs new file mode 100644 index 0000000..f1a2dd1 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs @@ -0,0 +1,28 @@ +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; + +public class AesCryptoProviderTests +{ + private readonly Mock _mockKeyProvider = new(); + private readonly AesCryptoProvider _sut; + + public AesCryptoProviderTests() + { + _sut = new(_mockKeyProvider.Object); + } + + [Fact] + public void EncryptAndDecrypt_WhenCalled_ItShouldReturnOriginalString() + { + var originalText = "SuperDuperSecret"; + + var encryptedText = _sut.Encrypt(originalText); + var decryptedText = _sut.Decrypt(encryptedText); + + encryptedText.Should().NotBe(originalText); + decryptedText.Should().Be(originalText); + } +} \ No newline at end of file From 923aeb4ec17ba3d2ddec722f3e0794c64cf93f7a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:12:49 -0500 Subject: [PATCH 13/55] feat: implement AEAD encryption using AES api --- .../Cryptography/AesCryptoProvider.cs | 65 ++++++++++++++++--- .../Cryptography/AesCryptoProviderTests.cs | 31 +++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs index c75220f..8cf4e77 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs @@ -1,20 +1,67 @@ +using System.Security.Cryptography; +using System.Text; + namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; internal sealed class AesCryptoProvider(IEncryptionKeyProvider keyProvider) : ICryptoProvider { + private const int NonceSize = 12; + private const int TagSize = 16; + private readonly IEncryptionKeyProvider _keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider)); - // TODO: Implement this shit - - public string Decrypt(string cipherText) - { - _ = _keyProvider.GetKey(); - throw new NotImplementedException(); - } - public string Encrypt(string plainText) { - throw new NotImplementedException(); + if (string.IsNullOrWhiteSpace(plainText)) + { + return plainText; + } + + var key = _keyProvider.GetKey(); + var plainBytes = Encoding.UTF8.GetBytes(plainText); + + var nonce = new byte[NonceSize].AsSpan(); + RandomNumberGenerator.Fill(nonce); + + var tag = new byte[TagSize].AsSpan(); + + var cipherBytes = new byte[plainBytes.Length]; + + using var aesGcm = new AesGcm(key); + aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); + + var combinedBytes = new byte[NonceSize + TagSize + plainBytes.Length]; + nonce.CopyTo(combinedBytes.AsSpan(0, NonceSize)); + tag.CopyTo(combinedBytes.AsSpan(NonceSize, TagSize)); + cipherBytes.CopyTo(combinedBytes.AsSpan(NonceSize + TagSize)); + + return Convert.ToBase64String(combinedBytes); + } + + public string Decrypt(string cipherText) + { + if (string.IsNullOrWhiteSpace(cipherText)) + { + return cipherText; + } + + var key = _keyProvider.GetKey(); + var combinedBytes = Convert.FromBase64String(cipherText).AsSpan(); + + if (combinedBytes.Length < NonceSize + TagSize) + { + throw new CryptographicException($"Invalid playload. {nameof(cipherText)} is not of expected length"); + } + + var nonce = combinedBytes[..NonceSize]; + var tag = combinedBytes.Slice(NonceSize, TagSize); + var cipherBytes = combinedBytes[(NonceSize + TagSize)..]; + var plainBytes = new byte[cipherBytes.Length]; + + using var aesGcm = new AesGcm(key); + aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); + + return Encoding.UTF8.GetString(plainBytes); } } \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs index f1a2dd1..ca66927 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; + using Moq; using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; @@ -11,6 +13,10 @@ public class AesCryptoProviderTests public AesCryptoProviderTests() { + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + _mockKeyProvider.Setup(static m => m.GetKey()).Returns(key); + _sut = new(_mockKeyProvider.Object); } @@ -25,4 +31,29 @@ public class AesCryptoProviderTests encryptedText.Should().NotBe(originalText); decryptedText.Should().Be(originalText); } + + [Fact] + public void Encrypt_WhenCalledWithSameInputTwitch_ItShouldProduceDifferentCipherText() + { + var plainText = "identicalInput"; + + var cipherTextOne = _sut.Encrypt(plainText); + var cipherTextTwo = _sut.Encrypt(plainText); + + cipherTextOne.Should().NotBe(cipherTextTwo); + } + + [Fact] + public void Decrypt_WhenCalledWithTamperedData_ItShouldThrowCrytographicException() + { + var cipherText = _sut.Encrypt("some data"); + var rawBytes = Convert.FromBase64String(cipherText); + rawBytes[^1] = (byte)(rawBytes[^1] ^ 0xFF); + + var tamperedText = Convert.ToBase64String(rawBytes); + + var act = () => _sut.Decrypt(tamperedText); + + act.Should().Throw(); + } } \ No newline at end of file From 0aa480e25e97ed1f0367bc27725994b167e945b2 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:34:35 -0500 Subject: [PATCH 14/55] tests: update deps --- ...born.Extensions.Configuration.Secure.Tests.csproj | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 67d83b6..7f9cc0f 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 @@ -9,11 +9,17 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + From 25d13bb24bfb8701fde6eed0573c7930700861bb Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:36:14 -0500 Subject: [PATCH 15/55] refactor: rename encryption key provider --- .../{IMachineIdKeyProvider.cs => IEncryptionKeyProvider.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/{IMachineIdKeyProvider.cs => IEncryptionKeyProvider.cs} (100%) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IEncryptionKeyProvider.cs similarity index 100% rename from src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyProvider.cs rename to src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IEncryptionKeyProvider.cs From f69317efe5bb35110688cf93048de70752c61452 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:45:57 -0500 Subject: [PATCH 16/55] feat: implement static key provider --- .../Cryptography/StaticKeyProvider.cs | 37 ++++++++++++++++++ .../Cryptography/StaticKeyProviderTests.cs | 39 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs new file mode 100644 index 0000000..799caf3 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs @@ -0,0 +1,37 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +internal sealed class StaticKeyProvider : IEncryptionKeyProvider +{ + private readonly byte[] _key; + + public StaticKeyProvider(string base64Key) + { + if (string.IsNullOrWhiteSpace(base64Key)) + { + throw new ArgumentNullException(nameof(base64Key)); + } + + byte[] decodedKey; + + try + { + decodedKey = Convert.FromBase64String(base64Key); + } + catch (FormatException ex) + { + throw new ArgumentException("The provided key is not a valid Base64 string.", nameof(base64Key), ex); + } + + if (decodedKey.Length != 32) + { + throw new ArgumentException("The encryption key must be exactly 32 bytes (256 bits) for AES-256 encryption.", nameof(base64Key)); + } + + _key = decodedKey; + } + + public byte[] GetKey() + { + return _key; + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs new file mode 100644 index 0000000..08cfeee --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs @@ -0,0 +1,39 @@ +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; + +public class StaticKeyProviderTests +{ + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenKeyIsNull() + { + var act = () => new StaticKeyProvider(null!); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndKeyIsNot32Bytes_ItShouldThrowArgumentException() + { + var shortKey = Convert.ToBase64String(new byte[16]); + + var act = () => new StaticKeyProvider(shortKey); + + act.Should().Throw().WithMessage("*must be exactly 32 bytes*"); + } + + [Fact] + public void GetKey_WhenCalled_ItShouldReturnValid32ByteArray() + { + var expectedBytes = new byte[32]; + Random.Shared.NextBytes(expectedBytes); + var base64Key = Convert.ToBase64String(expectedBytes); + + var provider = new StaticKeyProvider(base64Key); + + var result = provider.GetKey(); + + result.Should().BeEquivalentTo(expectedBytes); + result.Length.Should().Be(32); + } +} \ No newline at end of file From af7ba5feab3a655b80bf83396ceb46681aa452bc Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:46:21 -0500 Subject: [PATCH 17/55] refactor: extract interface to separate file --- .../Configuration/ISecureConfig.cs | 8 ++++++++ .../Configuration/SecureConfig.cs | 7 ------- 2 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs new file mode 100644 index 0000000..8b370df --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs @@ -0,0 +1,8 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal interface ISecureConfig +{ + Task DeleteAsync(string key, CancellationToken ct = default); + Task GetAsync(string key, CancellationToken ct = default); + Task SetAsync(string key, T value, CancellationToken ct = default); +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs index 2f5b8fc..bc8974c 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs @@ -5,13 +5,6 @@ using StevanFreeborn.Extensions.Configuration.Secure.Storage; namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; -internal interface ISecureConfig -{ - Task DeleteAsync(string key, CancellationToken ct = default); - Task GetAsync(string key, CancellationToken ct = default); - Task SetAsync(string key, T value, CancellationToken ct = default); -} - internal sealed class SecureConfig( ISecureStorageProvider storageProvider, ICryptoProvider cryptoProvider From c4ec57f402ed14226c86fec04f458285170451bd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:46:36 -0500 Subject: [PATCH 18/55] feat: add builder interface --- .../Configuration/ISecureConfigBuilder.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs new file mode 100644 index 0000000..f940423 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +/// +/// Provides a builder interface for configuring secure configuration services. +/// +public interface ISecureConfigBuilder +{ + /// + /// Gets the used to register secure configuration services. + /// + IServiceCollection Services { get; } +} \ No newline at end of file From 8180d744c4c8cef0f50ebb41ee73510813d8a8c9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:41:54 -0500 Subject: [PATCH 19/55] chore: whitelist word --- .vscode/settings.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..02a0f23 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "ioreg" + ] +} \ No newline at end of file From 17314375cb20f82a2348bbf690f485af9dbe38b5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:42:06 -0500 Subject: [PATCH 20/55] chore: add dependency --- .../StevanFreeborn.Extensions.Configuration.Secure.csproj | 1 + 1 file changed, 1 insertion(+) 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 1c54fc1..889f189 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj @@ -21,6 +21,7 @@ + From 995d61cd10088e28d73dc0d9f36f6bd3dc3ea932 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:45:53 -0500 Subject: [PATCH 21/55] feat: implement secure config provider with load method --- .../Configuration/SecureConfigProvider.cs | 87 +++ .../SecureConfigProviderTests.cs | 514 ++++++++++++++++++ 2 files changed, 601 insertions(+) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs new file mode 100644 index 0000000..3c1a635 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs @@ -0,0 +1,87 @@ +using System.Globalization; +using System.Text.Json; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal class SecureConfigProvider( + ISecureStorageProvider storageProvider, + ICryptoProvider cryptoProvider, + ILogger logger +) : ConfigurationProvider +{ + private readonly ISecureStorageProvider _storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider)); + private readonly ICryptoProvider _cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(cryptoProvider)); + + public override void Load() + { + var encryptedData = _storageProvider.ReadAllAsync().GetAwaiter().GetResult(); + var flattenedData = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in encryptedData) + { + try + { + var decryptedJson = _cryptoProvider.Decrypt(kvp.Value); + + using var document = JsonDocument.Parse(decryptedJson); + FlattenJsonElement(flattenedData, document.RootElement, kvp.Key); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogDecryptionFailure(ex, kvp.Key); + } + } + + Data = flattenedData; + } + + private static void FlattenJsonElement(IDictionary data, JsonElement element, string currentKey) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newKey = string.IsNullOrEmpty(currentKey) ? property.Name : ConfigurationPath.Combine(currentKey, property.Name); + FlattenJsonElement(data, property.Value, newKey); + } + break; + case JsonValueKind.Array: + var index = 0; + foreach (var arrayElement in element.EnumerateArray()) + { + var newKey = ConfigurationPath.Combine(currentKey, index.ToString(CultureInfo.InvariantCulture)); + FlattenJsonElement(data, arrayElement, newKey); + index++; + } + break; + case JsonValueKind.String: + data[currentKey] = element.GetString(); + break; + case JsonValueKind.Number: + data[currentKey] = element.GetRawText(); + break; + case JsonValueKind.True: + data[currentKey] = "true"; + break; + case JsonValueKind.False: + data[currentKey] = "false"; + break; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + default: + data[currentKey] = null; + break; + } + } +} + diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs new file mode 100644 index 0000000..cb3c152 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs @@ -0,0 +1,514 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration; + +public class SecureConfigProviderTests +{ + private readonly Mock _mockStorage = new(); + private readonly Mock _mockCrypto = new(); + private readonly Mock> _mockLogger = new(); + private readonly SecureConfigProvider _sut; + + public SecureConfigProviderTests() + { + _sut = new(_mockStorage.Object, _mockCrypto.Object, _mockLogger.Object); + } + + private static string Key(params string[] segments) => ConfigurationPath.Combine(segments); + + [Fact] + public void Load_WhenCalled_ItShouldReadDecryptAndFlattenJsonData() + { + var rootKey = "DatabaseOptions"; + var rawJson = @"{ ""Host"": ""localhost"", ""Port"": 5432 }"; + var encryptedString = "encrypted_payload"; + + var storedData = new Dictionary + { + { rootKey, encryptedString }, + }; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(storedData); + _mockCrypto.Setup(m => m.Decrypt(encryptedString)).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("DatabaseOptions", "Host"), out var hostVal).Should().BeTrue(); + hostVal.Should().Be("localhost"); + + _sut.TryGet(Key("DatabaseOptions", "Port"), out var portVal).Should().BeTrue(); + portVal.Should().Be("5432"); + } + + [Fact] + public void Load_WhenStorageIsEmpty_ItShouldNotThrow() + { + _mockStorage.Setup(s => s.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary()); + + var act = _sut.Load; + act.Should().NotThrow(); + } + + [Fact] + public void Load_WhenDecryptionFails_ItShouldLogErrorAndContinue() + { + var storedData = new Dictionary + { + { "ValidKey", "encrypted_valid" }, + { "BadKey", "encrypted_bad" }, + }; + + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(storedData); + _mockCrypto.Setup(m => m.Decrypt("encrypted_valid")).Returns(@"{ ""Name"": ""test"" }"); + _mockCrypto.Setup(m => m.Decrypt("encrypted_bad")).Throws(new InvalidOperationException("Decryption failed")); + + _sut.Load(); + + _sut.TryGet(Key("ValidKey", "Name"), out var val).Should().BeTrue(); + val.Should().Be("test"); + + _mockLogger.Verify(logger => logger.Log( + LogLevel.Error, + It.Is(id => id.Id == 3), + It.Is((state, type) => state.ToString()!.Contains("Failed to decrypt value for key")), + It.IsAny(), + It.IsAny>() + ), + Times.Once() + ); + } + + [Fact] + public void Load_WhenCalledWithDeeplyNestedObject_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""Level1"": { + ""Level2"": { + ""Level3"": { + ""Value"": ""deep_value"" + } + } + } + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Root", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Root", "Level1", "Level2", "Level3", "Value"), out var val).Should().BeTrue(); + val.Should().Be("deep_value"); + } + + [Fact] + public void Load_WhenCalledWithArrayOfPrimitives_ItShouldFlattenWithIndices() + { + var rawJson = @"{ ""Tags"": [""alpha"", ""beta"", ""gamma""] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Config", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Config", "Tags", "0"), out var v0).Should().BeTrue(); + v0.Should().Be("alpha"); + + _sut.TryGet(Key("Config", "Tags", "1"), out var v1).Should().BeTrue(); + v1.Should().Be("beta"); + + _sut.TryGet(Key("Config", "Tags", "2"), out var v2).Should().BeTrue(); + v2.Should().Be("gamma"); + } + + [Fact] + public void Load_WhenCalledWithArrayOfObjects_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""Servers"": [ + { ""Host"": ""srv1"", ""Port"": 8080 }, + { ""Host"": ""srv2"", ""Port"": 9090 } + ] + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "App", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("App", "Servers", "0", "Host"), out var h0).Should().BeTrue(); + h0.Should().Be("srv1"); + + _sut.TryGet(Key("App", "Servers", "0", "Port"), out var p0).Should().BeTrue(); + p0.Should().Be("8080"); + + _sut.TryGet(Key("App", "Servers", "1", "Host"), out var h1).Should().BeTrue(); + h1.Should().Be("srv2"); + + _sut.TryGet(Key("App", "Servers", "1", "Port"), out var p1).Should().BeTrue(); + p1.Should().Be("9090"); + } + + [Fact] + public void Load_WhenCalledWithNestedArrays_ItShouldFlattenCorrectly() + { + var rawJson = @"{ ""Matrix"": [[1, 2], [3, 4]] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Data", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Data", "Matrix", "0", "0"), out var v00).Should().BeTrue(); + v00.Should().Be("1"); + + _sut.TryGet(Key("Data", "Matrix", "0", "1"), out var v01).Should().BeTrue(); + v01.Should().Be("2"); + + _sut.TryGet(Key("Data", "Matrix", "1", "0"), out var v10).Should().BeTrue(); + v10.Should().Be("3"); + + _sut.TryGet(Key("Data", "Matrix", "1", "1"), out var v11).Should().BeTrue(); + v11.Should().Be("4"); + } + + [Fact] + public void Load_WhenCalledWithMixedArrayTypes_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""Mixed"": [ + ""string_val"", + 42, + true, + null, + { ""Nested"": ""obj"" } + ] + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Mixed", "0"), out var s).Should().BeTrue(); + s.Should().Be("string_val"); + + _sut.TryGet(Key("Cfg", "Mixed", "1"), out var n).Should().BeTrue(); + n.Should().Be("42"); + + _sut.TryGet(Key("Cfg", "Mixed", "2"), out var b).Should().BeTrue(); + b.Should().Be("true"); + + _sut.TryGet(Key("Cfg", "Mixed", "3"), out var nl).Should().BeTrue(); + nl.Should().BeNull(); + + _sut.TryGet(Key("Cfg", "Mixed", "4", "Nested"), out var o).Should().BeTrue(); + o.Should().Be("obj"); + } + + [Fact] + public void Load_WhenCalledWithBooleanValues_ItShouldFlattenAsStrings() + { + var rawJson = @"{ ""Enabled"": true, ""Disabled"": false }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Flags", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Flags", "Enabled"), out var en).Should().BeTrue(); + en.Should().Be("true"); + + _sut.TryGet(Key("Flags", "Disabled"), out var dis).Should().BeTrue(); + dis.Should().Be("false"); + } + + [Fact] + public void Load_WhenCalledWithNullValue_ItShouldFlattenAsNull() + { + var rawJson = @"{ ""NullableField"": null }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Opts", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Opts", "NullableField"), out var val).Should().BeTrue(); + val.Should().BeNull(); + } + + [Fact] + public void Load_WhenCalledWithNumberFormats_ItShouldPreserveRawText() + { + var rawJson = @"{ + ""Integer"": 42, + ""Float"": 3.14, + ""Negative"": -7, + ""Scientific"": 1.5e10, + ""Zero"": 0 + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Nums", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Nums", "Integer"), out var i).Should().BeTrue(); + i.Should().Be("42"); + + _sut.TryGet(Key("Nums", "Float"), out var f).Should().BeTrue(); + f.Should().Be("3.14"); + + _sut.TryGet(Key("Nums", "Negative"), out var n).Should().BeTrue(); + n.Should().Be("-7"); + + _sut.TryGet(Key("Nums", "Scientific"), out var s).Should().BeTrue(); + s.Should().Be("1.5e10"); + + _sut.TryGet(Key("Nums", "Zero"), out var z).Should().BeTrue(); + z.Should().Be("0"); + } + + [Fact] + public void Load_WhenCalledWithEmptyObject_ItShouldNotAddKeys() + { + var rawJson = @"{ ""Empty"": {} }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Empty"), out _).Should().BeFalse(); + } + + [Fact] + public void Load_WhenCalledWithEmptyArray_ItShouldNotAddKeys() + { + var rawJson = @"{ ""Empty"": [] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Empty"), out _).Should().BeFalse(); + } + + [Fact] + public void Load_WhenCalledWithEmptyString_ItShouldFlattenAsEmptyString() + { + var rawJson = @"{ ""Blank"": """" }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Blank"), out var val).Should().BeTrue(); + val.Should().Be(""); + } + + [Fact] + public void Load_WhenCalledWithMultipleEncryptedKeys_ItShouldMergeData() + { + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary + { + { "Db", "enc1" }, + { "Cache", "enc2" }, + }); + _mockCrypto.Setup(m => m.Decrypt("enc1")).Returns(@"{ ""Host"": ""db.local"" }"); + _mockCrypto.Setup(m => m.Decrypt("enc2")).Returns(@"{ ""Host"": ""cache.local"", ""Ttl"": 300 }"); + + _sut.Load(); + + _sut.TryGet(Key("Db", "Host"), out var dbHost).Should().BeTrue(); + dbHost.Should().Be("db.local"); + + _sut.TryGet(Key("Cache", "Host"), out var cacheHost).Should().BeTrue(); + cacheHost.Should().Be("cache.local"); + + _sut.TryGet(Key("Cache", "Ttl"), out var ttl).Should().BeTrue(); + ttl.Should().Be("300"); + } + + [Fact] + public void Load_WhenCalledWithCaseInsensitiveKeys_ItShouldRetrieveCorrectly() + { + var rawJson = @"{ ""MyKey"": ""value"" }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("cfg", "mykey"), out var val).Should().BeTrue(); + val.Should().Be("value"); + + _sut.TryGet(Key("CFG", "MYKEY"), out val).Should().BeTrue(); + val.Should().Be("value"); + } + + [Fact] + public void Load_WhenCalledWithComplexNestedStructure_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""App"": { + ""Name"": ""MyApp"", + ""Version"": ""1.0.0"", + ""Features"": { + ""EnableLogging"": true, + ""LogLevel"": ""Debug"", + ""Targets"": [""Console"", ""File""] + }, + ""Database"": { + ""Primary"": { + ""ConnectionString"": ""Server=db1;Database=app"", + ""PoolSize"": 10, + ""Replicas"": [ + { ""Host"": ""replica1"", ""Port"": 5432, ""Active"": true }, + { ""Host"": ""replica2"", ""Port"": 5433, ""Active"": false } + ] + }, + ""ReadOnly"": { + ""ConnectionString"": ""Server=db2;Database=app_ro"", + ""PoolSize"": 5 + } + }, + ""Metadata"": null, + ""Tags"": [""prod"", ""v1"", { ""Region"": ""us-east"" }] + } + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Root", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Root", "App", "Name"), out var name).Should().BeTrue(); + name.Should().Be("MyApp"); + + _sut.TryGet(Key("Root", "App", "Version"), out var ver).Should().BeTrue(); + ver.Should().Be("1.0.0"); + + _sut.TryGet(Key("Root", "App", "Features", "EnableLogging"), out var log).Should().BeTrue(); + log.Should().Be("true"); + + _sut.TryGet(Key("Root", "App", "Features", "LogLevel"), out var lvl).Should().BeTrue(); + lvl.Should().Be("Debug"); + + _sut.TryGet(Key("Root", "App", "Features", "Targets", "0"), out var t0).Should().BeTrue(); + t0.Should().Be("Console"); + + _sut.TryGet(Key("Root", "App", "Features", "Targets", "1"), out var t1).Should().BeTrue(); + t1.Should().Be("File"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "ConnectionString"), out var cs).Should().BeTrue(); + cs.Should().Be("Server=db1;Database=app"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "PoolSize"), out var ps).Should().BeTrue(); + ps.Should().Be("10"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "0", "Host"), out var rh0).Should().BeTrue(); + rh0.Should().Be("replica1"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "0", "Port"), out var rp0).Should().BeTrue(); + rp0.Should().Be("5432"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "0", "Active"), out var ra0).Should().BeTrue(); + ra0.Should().Be("true"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "1", "Host"), out var rh1).Should().BeTrue(); + rh1.Should().Be("replica2"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "1", "Active"), out var ra1).Should().BeTrue(); + ra1.Should().Be("false"); + + _sut.TryGet(Key("Root", "App", "Database", "ReadOnly", "ConnectionString"), out var csRo).Should().BeTrue(); + csRo.Should().Be("Server=db2;Database=app_ro"); + + _sut.TryGet(Key("Root", "App", "Database", "ReadOnly", "PoolSize"), out var psRo).Should().BeTrue(); + psRo.Should().Be("5"); + + _sut.TryGet(Key("Root", "App", "Metadata"), out var meta).Should().BeTrue(); + meta.Should().BeNull(); + + _sut.TryGet(Key("Root", "App", "Tags", "0"), out var tag0).Should().BeTrue(); + tag0.Should().Be("prod"); + + _sut.TryGet(Key("Root", "App", "Tags", "1"), out var tag1).Should().BeTrue(); + tag1.Should().Be("v1"); + + _sut.TryGet(Key("Root", "App", "Tags", "2", "Region"), out var region).Should().BeTrue(); + region.Should().Be("us-east"); + } + + [Fact] + public void Load_WhenCalledWithSpecialCharactersInStrings_ItShouldPreserveContent() + { + var rawJson = @"{ + ""Connection"": ""Server=localhost;Database=test;User=admin;Password=p@$$w0rd!"", + ""Path"": ""C:\\Program Files\\App\\config.json"", + ""JsonInString"": ""{\""inner\"": \""value\""}"", + ""Unicode"": ""Hello \u4e16\u754c"" + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Connection"), out var conn).Should().BeTrue(); + conn.Should().Be("Server=localhost;Database=test;User=admin;Password=p@$$w0rd!"); + + _sut.TryGet(Key("Cfg", "Path"), out var path).Should().BeTrue(); + path.Should().Be("C:\\Program Files\\App\\config.json"); + + _sut.TryGet(Key("Cfg", "JsonInString"), out var jis).Should().BeTrue(); + jis.Should().Be("{\"inner\": \"value\"}"); + + _sut.TryGet(Key("Cfg", "Unicode"), out var uni).Should().BeTrue(); + uni.Should().Be("Hello 世界"); + } + + [Fact] + public void Load_WhenCalledWithArrayOfEmptyObjects_ItShouldNotAddKeys() + { + var rawJson = @"{ ""Items"": [{}, {}] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Items", "0"), out _).Should().BeFalse(); + _sut.TryGet(Key("Cfg", "Items", "1"), out _).Should().BeFalse(); + } + + [Fact] + public void Load_WhenCalledWithInvalidJson_ItShouldLogErrorAndContinue() + { + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Bad", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns("not valid json{{{"); + + _sut.Load(); + + _mockLogger.Verify(x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("Bad")), + It.IsAny(), + It.IsAny>() + ), + Times.Once() + ); + } +} From 2babe2ce681c6b0a9bec02eddb49e7eb8327a60e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:46:05 -0500 Subject: [PATCH 22/55] refactor: place log messages in same file --- .../Cryptography/MachineIdKeyGenerator.cs | 17 ------------- .../Logging/LogMessages.cs | 25 +++++++++++++++++++ 2 files changed, 25 insertions(+), 17 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs index 93e4673..239ea90 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs @@ -89,21 +89,4 @@ internal sealed class MachineIdKeyGenerator : IMachineIdKeyGenerator _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); } \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs new file mode 100644 index 0000000..65afb63 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Logging; + +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); + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Error, + Message = "Failed to decrypt value for key {Key}" + )] + public static partial void LogDecryptionFailure(this ILogger logger, Exception ex, string key); +} \ No newline at end of file From 5ee9c1bf86c07bfc8f602390e28dcbcb9bfaa3ed Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:48:04 -0500 Subject: [PATCH 23/55] chore: whitelist word --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 02a0f23..e5079f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ - "ioreg" + "ioreg", + "netstandard" ] } \ No newline at end of file From 84f7ce43ed301bd2a3d4e3b906f544d183a0a07d Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:48:17 -0500 Subject: [PATCH 24/55] chore: update target frameworks --- .../StevanFreeborn.Extensions.Configuration.Secure.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 889f189..165831f 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj @@ -1,7 +1,7 @@  - netstandard2.1 + netstandard2.1;net8.0;net10.0; enable enable latest From e3e9d68853059a2c2c83b77cd2e39034a56d846e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:48:45 -0500 Subject: [PATCH 25/55] feat: allow specifying json type info explicity or resolving from registered options instance --- .../Configuration/ISecureConfig.cs | 10 ++-- .../Configuration/SecureConfig.cs | 46 +++++++++++++++---- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs index 8b370df..9aba129 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs @@ -1,8 +1,12 @@ +using System.Text.Json.Serialization.Metadata; + namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; internal interface ISecureConfig { - Task DeleteAsync(string key, CancellationToken ct = default); - Task GetAsync(string key, CancellationToken ct = default); Task SetAsync(string key, T value, CancellationToken ct = default); -} + Task SetAsync(string key, T value, JsonTypeInfo typeInfo, CancellationToken ct = default); + Task GetAsync(string key, CancellationToken ct = default); + Task GetAsync(string key, JsonTypeInfo typeInfo, CancellationToken ct = default); + Task DeleteAsync(string key, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs index bc8974c..d744d44 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; using StevanFreeborn.Extensions.Configuration.Secure.Storage; @@ -7,7 +8,8 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; internal sealed class SecureConfig( ISecureStorageProvider storageProvider, - ICryptoProvider cryptoProvider + ICryptoProvider cryptoProvider, + JsonSerializerOptions jsonOptions ) : ISecureConfig { private readonly ISecureStorageProvider _storageProvider = storageProvider ?? @@ -16,11 +18,20 @@ internal sealed class SecureConfig( private readonly ICryptoProvider _cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider)); - public async Task SetAsync(string key, T value, CancellationToken ct = default) + private readonly JsonSerializerOptions _jsonOptions = jsonOptions ?? + throw new ArgumentNullException(nameof(jsonOptions)); + + public Task SetAsync(string key, T value, CancellationToken ct = default) + { + var typeInfo = GetTypeInfo(); + return SetAsync(key, value, typeInfo, ct); + } + + public async Task SetAsync(string key, T value, JsonTypeInfo typeInfo, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(key)) { - throw new ArgumentNullException(nameof(key)); + throw new ArgumentException("Cannot be null or whitespace.", nameof(key)); } if (value is null) @@ -28,17 +39,23 @@ internal sealed class SecureConfig( throw new ArgumentNullException(nameof(value)); } - var json = JsonSerializer.Serialize(value); + var json = JsonSerializer.Serialize(value, typeInfo); var encryptedValue = _cryptoProvider.Encrypt(json); await _storageProvider.WriteAsync(key, encryptedValue, ct).ConfigureAwait(false); } - public async Task GetAsync(string key, CancellationToken ct = default) + public Task GetAsync(string key, CancellationToken ct = default) { - if (string.IsNullOrEmpty(key)) + var typeInfo = GetTypeInfo(); + return GetAsync(key, typeInfo, ct); + } + + public async Task GetAsync(string key, JsonTypeInfo typeInfo, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(key)) { - throw new ArgumentNullException(nameof(key)); + throw new ArgumentException("Cannot be null or whitespace.", nameof(key)); } var encryptedData = await _storageProvider.ReadAsync(key, ct).ConfigureAwait(false); @@ -49,11 +66,24 @@ internal sealed class SecureConfig( } var data = _cryptoProvider.Decrypt(encryptedData); - return JsonSerializer.Deserialize(data); + return JsonSerializer.Deserialize(data, typeInfo); } public Task DeleteAsync(string key, CancellationToken ct = default) { return _storageProvider.DeleteAsync(key, ct); } + + private JsonTypeInfo GetTypeInfo() + { + try + { + return (JsonTypeInfo?)_jsonOptions.GetTypeInfo(typeof(T)) + ?? throw new InvalidOperationException($"AOT metadata for type '{typeof(T).Name}' is missing. Did you forget to register it via {nameof(SecureConfigBuilder.AddJsonAotContext)}()?"); + } + catch (NotSupportedException ex) + { + throw new InvalidOperationException($"AOT metadata for type '{typeof(T).Name}' is missing. Did you forget to register it via {nameof(SecureConfigBuilder.AddJsonAotContext)}()?", ex); + } + } } \ No newline at end of file From 33fb8bdf547c67db4c559c7b4cc65a49a2172e4b Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:49:55 -0500 Subject: [PATCH 26/55] chore: dotnet format --- .../Configuration/SecureConfigProvider.cs | 3 +-- .../Cryptography/IMachineIdKeyGenerator.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs index 3c1a635..622aadc 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs @@ -83,5 +83,4 @@ internal class SecureConfigProvider( break; } } -} - +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs index 53a05a8..bba0a56 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IMachineIdKeyGenerator.cs @@ -3,4 +3,4 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; internal interface IMachineIdKeyGenerator { string GetId(); -} +} \ No newline at end of file From d6e22f9f244dcc0eafd3527a3d93e56914a41122 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:50:47 -0500 Subject: [PATCH 27/55] fix: add preprocessor directives --- .../Cryptography/AesCryptoProvider.cs | 12 +++++++++++- .../Cryptography/MachineIdKeyProvider.cs | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs index 8cf4e77..e5c6830 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs @@ -28,7 +28,12 @@ internal sealed class AesCryptoProvider(IEncryptionKeyProvider keyProvider) : IC var cipherBytes = new byte[plainBytes.Length]; +#if NETSTANDARD2_1 using var aesGcm = new AesGcm(key); +#else + using var aesGcm = new AesGcm(key, TagSize); +#endif + aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); var combinedBytes = new byte[NonceSize + TagSize + plainBytes.Length]; @@ -51,7 +56,7 @@ internal sealed class AesCryptoProvider(IEncryptionKeyProvider keyProvider) : IC if (combinedBytes.Length < NonceSize + TagSize) { - throw new CryptographicException($"Invalid playload. {nameof(cipherText)} is not of expected length"); + throw new CryptographicException($"Invalid payload. {nameof(cipherText)} is not of expected length"); } var nonce = combinedBytes[..NonceSize]; @@ -59,7 +64,12 @@ internal sealed class AesCryptoProvider(IEncryptionKeyProvider keyProvider) : IC var cipherBytes = combinedBytes[(NonceSize + TagSize)..]; var plainBytes = new byte[cipherBytes.Length]; +#if NETSTANDARD2_1 using var aesGcm = new AesGcm(key); +#else + using var aesGcm = new AesGcm(key, TagSize); +#endif + aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); return Encoding.UTF8.GetString(plainBytes); diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs index cc17a29..a42bc19 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs @@ -11,7 +11,11 @@ internal sealed class MachineIdKeyProvider(IMachineIdKeyGenerator generator) : I { var machineId = _generator.GetId(); var @bytes = Encoding.UTF8.GetBytes(machineId); +#if NET5_0_OR_GREATER + return SHA256.HashData(@bytes); +#else using var sha256 = SHA256.Create(); return sha256.ComputeHash(@bytes); +#endif } } \ No newline at end of file From a819a5ee2579a59dd0b020edecb100419a95d431 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:51:25 -0500 Subject: [PATCH 28/55] fix: use proper exception type --- .../Cryptography/StaticKeyProvider.cs | 4 ++-- .../Unit/Cryptography/StaticKeyProviderTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs index 799caf3..cf2c8a0 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/StaticKeyProvider.cs @@ -8,7 +8,7 @@ internal sealed class StaticKeyProvider : IEncryptionKeyProvider { if (string.IsNullOrWhiteSpace(base64Key)) { - throw new ArgumentNullException(nameof(base64Key)); + throw new ArgumentException("Cannot be null or whitespace.", nameof(base64Key)); } byte[] decodedKey; @@ -22,7 +22,7 @@ internal sealed class StaticKeyProvider : IEncryptionKeyProvider throw new ArgumentException("The provided key is not a valid Base64 string.", nameof(base64Key), ex); } - if (decodedKey.Length != 32) + if (decodedKey.Length is not 32) { throw new ArgumentException("The encryption key must be exactly 32 bytes (256 bits) for AES-256 encryption.", nameof(base64Key)); } diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs index 08cfeee..a9cf60c 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs @@ -9,7 +9,7 @@ public class StaticKeyProviderTests { var act = () => new StaticKeyProvider(null!); - act.Should().Throw(); + act.Should().Throw(); } [Fact] From 344d3239b17cabec81b46a835cc5b71900383ed0 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:51:43 -0500 Subject: [PATCH 29/55] feat: make interfaces public --- .../Cryptography/ICryptoProvider.cs | 16 +++++++++++++++- .../Cryptography/IEncryptionKeyProvider.cs | 9 ++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs index 4009509..b9ff163 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs @@ -1,7 +1,21 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; -internal interface ICryptoProvider +/// +/// Defines the contract for cryptographic operations. +/// +public interface ICryptoProvider { + /// + /// Encrypts the provided plain text. + /// + /// The unencrypted string to be encrypted. + /// The encrypted cipher text. string Encrypt(string plainText); + + /// + /// Decrypts the provided cipher text. + /// + /// The encrypted string to be decrypted. + /// The decrypted plain text. string Decrypt(string cipherText); } \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IEncryptionKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IEncryptionKeyProvider.cs index 9bcd2c4..2886c5a 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IEncryptionKeyProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IEncryptionKeyProvider.cs @@ -1,6 +1,13 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography; -internal interface IEncryptionKeyProvider +/// +/// Defines a contract for providing encryption keys used to secure configuration data. +/// +public interface IEncryptionKeyProvider { + /// + /// Retrieves the encryption key as a byte array. + /// + /// A byte array containing the encryption key. byte[] GetKey(); } \ No newline at end of file From f59bbb3cacb94171934d8ba7864f98990d871437 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:52:07 -0500 Subject: [PATCH 30/55] fix: use internal json context --- .../Storage/JsonFileStorageProvider.cs | 10 +++------- .../Storage/SecureConfigJsonContext.cs | 6 ++++++ 2 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Storage/SecureConfigJsonContext.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 19add1f..309abb2 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -9,10 +9,6 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecureStorageProvider { private static readonly SemaphoreSlim FileLock = new(1, 1); - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - }; private readonly JsonStorageOptions _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -128,7 +124,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur return []; } - var data = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct) + var data = await JsonSerializer.DeserializeAsync(stream, SecureConfigJsonContext.Default.DictionaryStringString, ct) .ConfigureAwait(false); return data ?? []; @@ -151,7 +147,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur FileShare.None ); - await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct) + await JsonSerializer.SerializeAsync(stream, data, SecureConfigJsonContext.Default.DictionaryStringString, ct) .ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/SecureConfigJsonContext.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/SecureConfigJsonContext.cs new file mode 100644 index 0000000..25ff2be --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/SecureConfigJsonContext.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Serialization; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; + +[JsonSerializable(typeof(Dictionary))] +internal sealed partial class SecureConfigJsonContext : JsonSerializerContext; \ No newline at end of file From 6c00cef1f7dec683d53ec45ec7988fd392371dde Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:52:28 -0500 Subject: [PATCH 31/55] fix: give json storage file default name --- .../Storage/JsonStorageOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs index 4abac49..bc92fe3 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs @@ -8,7 +8,7 @@ public sealed class JsonStorageOptions /// /// Gets or sets the name of the JSON file used for storage. /// - public string FileName { get; set; } = string.Empty; + public string FileName { get; set; } = "secure_config.json"; /// /// Gets or sets the directory path where the JSON file is located. Defaults to the base directory of the application. From ca0a6dda870b20a5596b112ec872bd06eae490ae Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:52:45 -0500 Subject: [PATCH 32/55] fix: use proper error level --- .../Logging/LogMessages.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs index 65afb63..b8f056a 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs @@ -18,7 +18,7 @@ internal static partial class LogMessages [LoggerMessage( EventId = 3, - Level = LogLevel.Error, + Level = LogLevel.Warning, Message = "Failed to decrypt value for key {Key}" )] public static partial void LogDecryptionFailure(this ILogger logger, Exception ex, string key); From 7c501fdf55baf5252faa2f43c8667e36c403d00e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:52:58 -0500 Subject: [PATCH 33/55] feat: implement config builder, source, and extensions --- .../Configuration/ISecureConfigBuilder.cs | 77 +- .../Configuration/SecureConfigBuilder.cs | 103 +++ .../Configuration/SecureConfigSource.cs | 29 + .../SecureConfigExtensions.cs | 134 ++++ ...tensions.Configuration.Secure.Tests.csproj | 2 +- .../Configuration/SecureConfigBuilderTests.cs | 331 ++++++++ .../SecureConfigProviderTests.cs | 10 +- .../Configuration/SecureConfigSourceTests.cs | 140 ++++ .../Unit/Configuration/SecureConfigTests.cs | 119 ++- .../Unit/SecureConfigExtensionsTests.cs | 748 ++++++++++++++++++ 10 files changed, 1664 insertions(+), 29 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs index f940423..d8ff72d 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs @@ -1,14 +1,83 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Logging; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; /// -/// Provides a builder interface for configuring secure configuration services. +/// Provides a fluent builder interface for configuring secure configuration storage and encryption. /// public interface ISecureConfigBuilder { /// - /// Gets the used to register secure configuration services. + /// Configures JSON file-based storage using the provided options instance. /// - IServiceCollection Services { get; } + /// The JSON storage configuration options. + /// The current instance for method chaining. + ISecureConfigBuilder UseJsonFileStorage(JsonStorageOptions options); + + /// + /// Configures JSON file-based storage using an action to configure the options. + /// + /// An action to configure the . + /// The current instance for method chaining. + ISecureConfigBuilder UseJsonFileStorage(Action configure); + + /// + /// Registers a JSON AOT source-generated context for serializing complex types. + /// + /// A context that implements that will be used for JSON serialization. + /// The current instance for method chaining. + ISecureConfigBuilder AddJsonAotContext(IJsonTypeInfoResolver context); + + /// + /// Configures a custom storage provider for secure configuration data. + /// + /// The custom storage provider implementation. + /// The current instance for method chaining. + ISecureConfigBuilder UseCustomStorage(ISecureStorageProvider provider); + + /// + /// Configures encryption using a Base64-encoded encryption key. + /// + /// The Base64-encoded encryption key string. + /// The current instance for method chaining. + ISecureConfigBuilder WithBase64EncryptionKey(string key); + + /// + /// Configures encryption using a key derived from the machine id. + /// + /// The current instance for method chaining. + ISecureConfigBuilder WithMachineIdKey(); + + + /// + /// Configures a custom encryption key provider. + /// + /// The custom encryption key provider implementation. + /// The current instance for method chaining. + ISecureConfigBuilder WithCustomKeyProvider(IEncryptionKeyProvider keyProvider); + + /// + /// Configures logging using the provided logger factory. + /// + /// The logger factory to use for logging operations. + /// The current instance for method chaining. + ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory); + + /// + /// Configures AES crypto provider for encryption and decryption + /// + /// The current instance for method chaining. + ISecureConfigBuilder WithAesCryptoProvider(); + + /// + /// Configures the factory function that will be used to create the crypto provider for encryption and decryption + /// + /// The crypto provider factor to use for encryption and decryption operations. + /// The current instance for method chaining. + ISecureConfigBuilder WithCustomCryptoProvider(Func cryptoProviderFactory); } \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs new file mode 100644 index 0000000..cba3650 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal sealed class SecureConfigBuilder : ISecureConfigBuilder +{ + internal ISecureStorageProvider? StorageProvider { get; private set; } + internal Func? CryptoProviderFactory { get; private set; } + internal IEncryptionKeyProvider? KeyProvider { get; private set; } + internal ILoggerFactory LoggerFactory { get; private set; } = NullLoggerFactory.Instance; + internal JsonSerializerOptions SerializerOptions { get; } = new() + { + PropertyNameCaseInsensitive = true, + }; + + public ISecureConfigBuilder UseJsonFileStorage(JsonStorageOptions options) + { + StorageProvider = new JsonFileStorageProvider(options); + return this; + } + + public ISecureConfigBuilder UseJsonFileStorage(Action configure) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(configure); +#else + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } +#endif + + var options = new JsonStorageOptions(); + configure.Invoke(options); + StorageProvider = new JsonFileStorageProvider(options); + return this; + } + + public ISecureConfigBuilder AddJsonAotContext(IJsonTypeInfoResolver context) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(context); +#else + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } +#endif + + SerializerOptions.TypeInfoResolverChain.Insert(0, context); + return this; + } + + public ISecureConfigBuilder UseCustomStorage(ISecureStorageProvider provider) + { + StorageProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + return this; + } + + public ISecureConfigBuilder WithBase64EncryptionKey(string key) + { + KeyProvider = new StaticKeyProvider(key); + return this; + } + + public ISecureConfigBuilder WithMachineIdKey() + { + var logger = LoggerFactory.CreateLogger(); + KeyProvider = new MachineIdKeyProvider(new MachineIdKeyGenerator(logger)); + return this; + } + + public ISecureConfigBuilder WithCustomKeyProvider(IEncryptionKeyProvider provider) + { + KeyProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + return this; + } + + public ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory) + { + LoggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + return this; + } + + public ISecureConfigBuilder WithAesCryptoProvider() + { + CryptoProviderFactory = (kp) => new AesCryptoProvider(kp); + return this; + } + + public ISecureConfigBuilder WithCustomCryptoProvider(Func cryptoProviderFactory) + { + CryptoProviderFactory = cryptoProviderFactory; + return this; + } +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs new file mode 100644 index 0000000..b5b1088 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal sealed class SecureConfigSource( + ISecureStorageProvider storageProvider, + ICryptoProvider cryptoProvider, + ILoggerFactory loggerFactory +) : IConfigurationSource +{ + private readonly ISecureStorageProvider _storageProvider = storageProvider + ?? throw new ArgumentNullException(nameof(storageProvider)); + + private readonly ICryptoProvider _cryptoProvider = cryptoProvider + ?? throw new ArgumentNullException(nameof(cryptoProvider)); + + private readonly ILoggerFactory _loggerFactory = loggerFactory + ?? throw new ArgumentNullException(nameof(loggerFactory)); + + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + var logger = _loggerFactory.CreateLogger(); + return new SecureConfigProvider(_storageProvider, _cryptoProvider, logger); + } +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs new file mode 100644 index 0000000..3221b9a --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs @@ -0,0 +1,134 @@ +using System.Text.Json; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure; + +/// +/// Provides extension methods for configuring secure configuration in .NET applications. +/// +public static class SecureConfigExtensions +{ + private const string JsonSerializerOptionsKey = "SecureConfigJsonSerializerOptions"; + + /// + /// Adds secure configuration to the configuration builder. + /// + /// The configuration builder to add secure configuration to. + /// An action to configure the secure configuration builder. + /// The configuration builder with secure configuration added. + /// Thrown when or is null. + /// Thrown when a required provider is not configured. + public static IConfigurationBuilder AddSecureConfig( + this IConfigurationBuilder builder, + Action configure + ) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); +#else + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } +#endif + + var configBuilder = new SecureConfigBuilder(); + + configure.Invoke(configBuilder); + + if (configBuilder.StorageProvider is null) + { + throw new InvalidOperationException("A storage provider must be configured."); + } + + if (configBuilder.KeyProvider is null) + { + throw new InvalidOperationException("A key provider must be configured"); + } + + if (configBuilder.CryptoProviderFactory is null) + { + throw new InvalidOperationException("A crypto provider must be configured."); + } + + var cryptoProvider = configBuilder.CryptoProviderFactory.Invoke(configBuilder.KeyProvider); + + var source = new SecureConfigSource(configBuilder.StorageProvider, cryptoProvider, configBuilder.LoggerFactory); + return builder.Add(source); + } + + /// + /// Adds secure configuration services to the service collection. + /// + /// The service collection to add secure configuration services to. + /// An action to configure the secure configuration builder. + /// The service collection with secure configuration services added. + /// Thrown when or is null. + /// Thrown when a required provider is not configured. + public static IServiceCollection AddSecureConfig( + this IServiceCollection services, + Action configure + ) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); +#else + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } +#endif + + var configBuilder = new SecureConfigBuilder(); + + configure(configBuilder); + + if (configBuilder.StorageProvider is null) + { + throw new InvalidOperationException("A storage provider must be configured."); + } + + if (configBuilder.KeyProvider is null) + { + throw new InvalidOperationException("A key provider must be configured"); + } + + if (configBuilder.CryptoProviderFactory is null) + { + throw new InvalidOperationException("A crypto provider must be configured."); + } + + services.TryAddKeyedSingleton(JsonSerializerOptionsKey, configBuilder.SerializerOptions); + services.TryAddSingleton(configBuilder.StorageProvider); + services.TryAddSingleton(configBuilder.KeyProvider); + services.TryAddSingleton(configBuilder.CryptoProviderFactory.Invoke(configBuilder.KeyProvider)); + services.TryAddSingleton(sp => + { + var storageProvider = sp.GetRequiredService(); + var cryptoProvider = sp.GetRequiredService(); + var serializerOptions = sp.GetRequiredKeyedService(JsonSerializerOptionsKey); + return new SecureConfig(storageProvider, cryptoProvider, serializerOptions); + }); + + return services; + } +} \ No newline at end of file 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 7f9cc0f..9474eab 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 @@ -1,7 +1,7 @@  - net11.0 + net8.0;net10.0; enable enable false diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs new file mode 100644 index 0000000..8a05a77 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs @@ -0,0 +1,331 @@ +using System.Security.Cryptography; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Logging; + +using Microsoft.Extensions.Logging.Abstractions; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration; +public class SecureConfigBuilderTests +{ + private readonly SecureConfigBuilder _sut = new(); + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeWithDefaultValues() + { + _sut.StorageProvider.Should().BeNull(); + _sut.CryptoProviderFactory.Should().BeNull(); + _sut.KeyProvider.Should().BeNull(); + _sut.LoggerFactory.Should().Be(NullLoggerFactory.Instance); + _sut.SerializerOptions.PropertyNameCaseInsensitive.Should().BeTrue(); + } + + [Fact] + public void UseJsonFileStorage_WithOptions_ItShouldSetStorageProvider() + { + var options = new JsonStorageOptions + { + FileName = "test_config.json", + DirectoryPath = "/tmp/config" + }; + + var result = _sut.UseJsonFileStorage(options); + + result.Should().BeSameAs(_sut); + _sut.StorageProvider.Should().NotBeNull(); + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithOptions_ItShouldUseProvidedOptions() + { + var options = new JsonStorageOptions + { + FileName = "custom.json", + DirectoryPath = "/custom/path" + }; + + _sut.UseJsonFileStorage(options); + + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithConfigureAction_ItShouldSetStorageProvider() + { + var result = _sut.UseJsonFileStorage(opt => opt.FileName = "test.json"); + + result.Should().BeSameAs(_sut); + _sut.StorageProvider.Should().NotBeNull(); + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithConfigureAction_ItShouldApplyConfiguration() + { + _sut.UseJsonFileStorage(opt => + { + opt.FileName = "configured.json"; + opt.DirectoryPath = "/configured/path"; + }); + + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithNullConfigureAction_ItShouldThrowArgumentNullException() + { + var act = () => _sut.UseJsonFileStorage((Action)null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddJsonAotContext_WithContext_ItShouldAddToResolverChain() + { + var mockContext = new Mock(); + + var result = _sut.AddJsonAotContext(mockContext.Object); + + result.Should().BeSameAs(_sut); + _sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext.Object); + } + + [Fact] + public void AddJsonAotContext_WithNullContext_ItShouldThrowArgumentNullException() + { + var act = () => _sut.AddJsonAotContext(null!); + + act.Should().Throw() + .WithParameterName("context"); + } + + [Fact] + public void AddJsonAotContext_WhenCalledMultipleTimes_ItShouldAddAllToChain() + { + var mockContext1 = new Mock(); + var mockContext2 = new Mock(); + + _sut.AddJsonAotContext(mockContext1.Object); + _sut.AddJsonAotContext(mockContext2.Object); + + _sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext1.Object); + _sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext2.Object); + } + + [Fact] + public void UseCustomStorage_WithProvider_ItShouldSetStorageProvider() + { + var mockProvider = new Mock(); + + var result = _sut.UseCustomStorage(mockProvider.Object); + + result.Should().BeSameAs(_sut); + _sut.StorageProvider.Should().BeSameAs(mockProvider.Object); + } + + [Fact] + public void UseCustomStorage_WithNullProvider_ItShouldThrowArgumentNullException() + { + var act = () => _sut.UseCustomStorage(null!); + + act.Should().Throw() + .WithParameterName("provider"); + } + + [Fact] + public void WithBase64EncryptionKey_WithValidKey_ItShouldSetKeyProvider() + { + var validKey = Convert.ToBase64String(new byte[32]); + + var result = _sut.WithBase64EncryptionKey(validKey); + + result.Should().BeSameAs(_sut); + _sut.KeyProvider.Should().NotBeNull(); + _sut.KeyProvider.Should().BeOfType(); + } + + [Fact] + public void WithBase64EncryptionKey_WithValidKey_ItShouldReturnCorrectKey() + { + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + + var validKey = Convert.ToBase64String(keyBytes); + + _sut.WithBase64EncryptionKey(validKey); + + _sut.KeyProvider!.GetKey().Should().Equal(keyBytes); + } + + [Fact] + public void WithMachineIdKey_WhenCalled_ItShouldSetKeyProvider() + { + var result = _sut.WithMachineIdKey(); + + result.Should().BeSameAs(_sut); + _sut.KeyProvider.Should().NotBeNull(); + _sut.KeyProvider.Should().BeOfType(); + } + + [Fact] + public void WithMachineIdKey_WhenCalled_ItShouldUseLoggerFactory() + { + var mockLoggerFactory = new Mock(); + var mockLogger = new Mock(); + + mockLoggerFactory.Setup(f => f.CreateLogger(typeof(MachineIdKeyGenerator).FullName!)) + .Returns(mockLogger.Object); + + _sut.WithLoggerFactory(mockLoggerFactory.Object); + + _sut.WithMachineIdKey(); + + mockLoggerFactory.Verify(f => f.CreateLogger(typeof(MachineIdKeyGenerator).FullName!), Times.Once()); + } + + [Fact] + public void WithCustomKeyProvider_WithProvider_ItShouldSetKeyProvider() + { + var mockProvider = new Mock(); + + var result = _sut.WithCustomKeyProvider(mockProvider.Object); + + result.Should().BeSameAs(_sut); + _sut.KeyProvider.Should().BeSameAs(mockProvider.Object); + } + + [Fact] + public void WithCustomKeyProvider_WithNullProvider_ItShouldThrowArgumentNullException() + { + var act = () => _sut.WithCustomKeyProvider(null!); + + act.Should().Throw() + .WithParameterName("provider"); + } + + [Fact] + public void WithLoggerFactory_WithFactory_ItShouldSetLoggerFactory() + { + var mockFactory = new Mock(); + + var result = _sut.WithLoggerFactory(mockFactory.Object); + + result.Should().BeSameAs(_sut); + _sut.LoggerFactory.Should().BeSameAs(mockFactory.Object); + } + + [Fact] + public void WithLoggerFactory_WithNullFactory_ItShouldSetNullLoggerFactory() + { + var mockFactory = new Mock(); + _sut.WithLoggerFactory(mockFactory.Object); + + _sut.WithLoggerFactory(null!); + + _sut.LoggerFactory.Should().Be(NullLoggerFactory.Instance); + } + + [Fact] + public void WithAesCryptoProvider_WhenCalled_ItShouldSetCryptoProviderFactory() + { + var mockKeyProvider = new Mock(); + + var result = _sut.WithAesCryptoProvider(); + + result.Should().BeSameAs(_sut); + _sut.CryptoProviderFactory.Should().NotBeNull(); + + var cryptoProvider = _sut.CryptoProviderFactory!(mockKeyProvider.Object); + cryptoProvider.Should().BeOfType(); + } + + [Fact] + public void WithCustomCryptoProvider_WithFactory_ItShouldSetCryptoProviderFactory() + { + var mockCryptoProvider = new Mock(); + var mockKeyProvider = new Mock(); + Func factory = (kp) => mockCryptoProvider.Object; + + var result = _sut.WithCustomCryptoProvider(factory); + + result.Should().BeSameAs(_sut); + _sut.CryptoProviderFactory.Should().NotBeNull(); + + var cryptoProvider = _sut.CryptoProviderFactory!(mockKeyProvider.Object); + cryptoProvider.Should().BeSameAs(mockCryptoProvider.Object); + } + + [Fact] + public void WithCustomCryptoProvider_WithNullFactory_ItShouldSetNull() + { + _sut.WithCustomCryptoProvider(null!); + + _sut.CryptoProviderFactory.Should().BeNull(); + } + + [Fact] + public void MethodChaining_WhenAllMethodsCalled_ItShouldConfigureAllProperties() + { + var mockStorageProvider = new Mock(); + var mockKeyProvider = new Mock(); + var mockLoggerFactory = new Mock(); + var mockCryptoProvider = new Mock(); + + _sut.UseCustomStorage(mockStorageProvider.Object) + .WithCustomKeyProvider(mockKeyProvider.Object) + .WithLoggerFactory(mockLoggerFactory.Object) + .WithCustomCryptoProvider((kp) => mockCryptoProvider.Object); + + _sut.StorageProvider.Should().BeSameAs(mockStorageProvider.Object); + _sut.KeyProvider.Should().BeSameAs(mockKeyProvider.Object); + _sut.LoggerFactory.Should().BeSameAs(mockLoggerFactory.Object); + _sut.CryptoProviderFactory.Should().NotBeNull(); + + var provider = _sut.CryptoProviderFactory!(mockKeyProvider.Object); + provider.Should().BeSameAs(mockCryptoProvider.Object); + } + + [Fact] + public void MethodChaining_WhenOverridingStorage_ItShouldUseLastSetProvider() + { + var mockProvider1 = new Mock(); + var mockProvider2 = new Mock(); + + _sut.UseCustomStorage(mockProvider1.Object) + .UseCustomStorage(mockProvider2.Object); + + _sut.StorageProvider.Should().BeSameAs(mockProvider2.Object); + } + + [Fact] + public void MethodChaining_WhenOverridingKeyProvider_ItShouldUseLastSetProvider() + { + var mockProvider1 = new Mock(); + var mockProvider2 = new Mock(); + + _sut.WithCustomKeyProvider(mockProvider1.Object) + .WithCustomKeyProvider(mockProvider2.Object); + + _sut.KeyProvider.Should().BeSameAs(mockProvider2.Object); + } + + [Fact] + public void MethodChaining_WhenOverridingLoggerFactory_ItShouldUseLastSetFactory() + { + var mockFactory1 = new Mock(); + var mockFactory2 = new Mock(); + + _sut.WithLoggerFactory(mockFactory1.Object) + .WithLoggerFactory(mockFactory2.Object); + + _sut.LoggerFactory.Should().BeSameAs(mockFactory2.Object); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs index cb3c152..d943da9 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs @@ -65,7 +65,7 @@ public class SecureConfigProviderTests { "BadKey", "encrypted_bad" }, }; - _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Warning)).Returns(true); _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(storedData); _mockCrypto.Setup(m => m.Decrypt("encrypted_valid")).Returns(@"{ ""Name"": ""test"" }"); _mockCrypto.Setup(m => m.Decrypt("encrypted_bad")).Throws(new InvalidOperationException("Decryption failed")); @@ -76,7 +76,7 @@ public class SecureConfigProviderTests val.Should().Be("test"); _mockLogger.Verify(logger => logger.Log( - LogLevel.Error, + LogLevel.Warning, It.Is(id => id.Id == 3), It.Is((state, type) => state.ToString()!.Contains("Failed to decrypt value for key")), It.IsAny(), @@ -495,14 +495,14 @@ public class SecureConfigProviderTests [Fact] public void Load_WhenCalledWithInvalidJson_ItShouldLogErrorAndContinue() { - _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Warning)).Returns(true); _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Bad", "enc" } }); _mockCrypto.Setup(m => m.Decrypt("enc")).Returns("not valid json{{{"); _sut.Load(); _mockLogger.Verify(x => x.Log( - LogLevel.Error, + LogLevel.Warning, It.IsAny(), It.Is((v, _) => v.ToString()!.Contains("Bad")), It.IsAny(), @@ -511,4 +511,4 @@ public class SecureConfigProviderTests Times.Once() ); } -} +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs new file mode 100644 index 0000000..7a29345 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration; + +public class SecureConfigSourceTests +{ + private readonly Mock _mockStorage = new(); + private readonly Mock _mockCrypto = new(); + private readonly Mock _mockLoggerFactory = new(); + + [Fact] + public void Constructor_WhenStorageProviderIsNull_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfigSource( + storageProvider: null!, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + act.Should().Throw() + .WithParameterName("storageProvider"); + } + + [Fact] + public void Constructor_WhenCryptoProviderIsNull_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: null!, + loggerFactory: _mockLoggerFactory.Object + ); + + act.Should().Throw() + .WithParameterName("cryptoProvider"); + } + + [Fact] + public void Constructor_WhenLoggerFactoryIsNull_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: null! + ); + + act.Should().Throw() + .WithParameterName("loggerFactory"); + } + + [Fact] + public void Constructor_WhenAllDependenciesAreProvided_ItShouldNotThrow() + { + var act = () => new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + act.Should().NotThrow(); + } + + [Fact] + public void Build_WhenCalled_ItShouldReturnSecureConfigProvider() + { + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var mockBuilder = new Mock(); + + var result = sut.Build(mockBuilder.Object); + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public void Build_WhenCalled_ItShouldCreateLoggerFromFactory() + { + var mockLogger = new Mock>(); + _mockLoggerFactory + .Setup(f => f.CreateLogger(typeof(SecureConfigProvider).FullName!)) + .Returns(mockLogger.Object); + + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var mockBuilder = new Mock(); + sut.Build(mockBuilder.Object); + + _mockLoggerFactory.Verify( + f => f.CreateLogger(typeof(SecureConfigProvider).FullName!), + Times.Once() + ); + } + + [Fact] + public void Build_WhenCalledMultipleTimes_ItShouldReturnNewProviderInstance() + { + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var mockBuilder = new Mock(); + + var result1 = sut.Build(mockBuilder.Object); + var result2 = sut.Build(mockBuilder.Object); + + result1.Should().NotBeSameAs(result2); + } + + [Fact] + public void Build_WhenCalledWithNullBuilder_ItShouldStillReturnProvider() + { + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var result = sut.Build(builder: null!); + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs index 029d9ca..b684c6a 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs @@ -1,28 +1,31 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + 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 JsonSerializerOptions _jsonSerializerOptions = new(); private readonly SecureConfig _sut; public SecureConfigTests() { - _sut = new(_mockStorageProvider.Object, _mockCryptoProvider.Object); + _sut = new(_mockStorageProvider.Object, _mockCryptoProvider.Object, _jsonSerializerOptions); } [Fact] public void Constructor_WhenCalledWithNullStorageProvider_ItShouldThrowArgumentNullException() { - var act = () => new SecureConfig(null!, _mockCryptoProvider.Object); + var act = () => new SecureConfig(null!, _mockCryptoProvider.Object, _jsonSerializerOptions); act.Should().Throw(); } @@ -30,7 +33,15 @@ public class SecureConfigTests [Fact] public void Constructor_WhenCalledWithNullCryptoProvider_ItShouldThrowArgumentNullException() { - var act = () => new SecureConfig(_mockStorageProvider.Object, null!); + var act = () => new SecureConfig(_mockStorageProvider.Object, null!, _jsonSerializerOptions); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithNullJsonOptions_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfig(_mockStorageProvider.Object, _mockCryptoProvider.Object, null!); act.Should().Throw(); } @@ -38,22 +49,22 @@ public class SecureConfigTests [Fact] public async Task SetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException() { - var act = async () => await _sut.SetAsync(null!, string.Empty); + var act = async () => await _sut.SetAsync(null!, string.Empty, SecureConfigTestsJsonContext.Default.String); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] public async Task SetAsync_WhenCalledWithNullValue_ItShouldThrowArgumentNullException() { - var act = async () => await _sut.SetAsync("Key", null!); + var act = async () => await _sut.SetAsync("Key", null!, SecureConfigTestsJsonContext.Default.String); await act.Should().ThrowAsync(); } [Fact] - public async Task SetAsync_WhenCalled_ItShouldSerializeGivenValueAndEncryptIt() + public async Task SetAsync_WhenCalledWithoutJsonContextSet_ItShouldSerializeGivenValueAndEncryptIt() { var key = "Database"; var config = new DummyConfig("localhost", 9999); @@ -62,17 +73,81 @@ public class SecureConfigTests _mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString); + var act = async () => await _sut.SetAsync(key, config); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SetAsync_WhenCalledWithJsonContextSet_ItShouldSerializeGivenValueAndEncryptIt() + { + var key = "Database"; + var config = new DummyConfig("localhost", 9999); + var encryptedString = "encryptedString"; + var json = JsonSerializer.Serialize(config); + + _jsonSerializerOptions.TypeInfoResolverChain.Insert(0, SecureConfigTestsJsonContext.Default); + _mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString); + await _sut.SetAsync(key, config); - _mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString), Times.Once()); + _mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task SetAsync_WhenCalledWithTypeInfo_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, SecureConfigTestsJsonContext.Default.DummyConfig); + + _mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString, It.IsAny()), Times.Once()); } [Fact] public async Task GetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException() { - var act = async () => await _sut.GetAsync(null!); + var act = async () => await _sut.GetAsync(null!, SecureConfigTestsJsonContext.Default.DummyConfig); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAsync_WhenKeyExistsAndJsonContextNotSet_ItShouldReadDecryptAndDeserializeTheValue() + { + var key = "Database"; + var expectedConfig = new DummyConfig("localhost", 9999); + var encryptedString = "encryptedString"; + var json = JsonSerializer.Serialize(expectedConfig); + + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(encryptedString); + _mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json); + + var act = async () => await _sut.GetAsync(key); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAsync_WhenKeyExistsAndJsonContextIsSet_ItShouldReadDecryptAndDeserializeTheValue() + { + var key = "Database"; + var expectedConfig = new DummyConfig("localhost", 9999); + var encryptedString = "encryptedString"; + var json = JsonSerializer.Serialize(expectedConfig); + + _jsonSerializerOptions.TypeInfoResolverChain.Insert(0, SecureConfigTestsJsonContext.Default); + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(encryptedString); + _mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json); + + var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig); + + result.Should().BeEquivalentTo(expectedConfig); } [Fact] @@ -83,10 +158,10 @@ public class SecureConfigTests var encryptedString = "encryptedString"; var json = JsonSerializer.Serialize(expectedConfig); - _mockStorageProvider.Setup(m => m.ReadAsync(key)).ReturnsAsync(encryptedString); + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(encryptedString); _mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json); - var result = await _sut.GetAsync(key); + var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig); result.Should().BeEquivalentTo(expectedConfig); } @@ -98,9 +173,9 @@ public class SecureConfigTests var expectedConfig = new DummyConfig("localhost", 9999); var json = JsonSerializer.Serialize(expectedConfig); - _mockStorageProvider.Setup(m => m.ReadAsync(key)).ReturnsAsync(string.Empty); + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(string.Empty); - var result = await _sut.GetAsync(key); + var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig); result.Should().BeNull(); } @@ -110,13 +185,19 @@ public class SecureConfigTests { var key = "Database"; - _mockStorageProvider.Setup(m => m.DeleteAsync(key)).ReturnsAsync(true); + _mockStorageProvider.Setup(m => m.DeleteAsync(key, It.IsAny())).ReturnsAsync(true); var result = await _sut.DeleteAsync(key); result.Should().BeTrue(); - _mockStorageProvider.Verify(m => m.DeleteAsync(key), Times.Once()); + _mockStorageProvider.Verify(m => m.DeleteAsync(key, It.IsAny()), Times.Once()); } +} - private sealed record DummyConfig(string Host, int Port); +internal sealed record DummyConfig(string Host, int Port); + +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(DummyConfig))] +internal partial class SecureConfigTestsJsonContext : JsonSerializerContext +{ } \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs new file mode 100644 index 0000000..592af45 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs @@ -0,0 +1,748 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit; + +public class SecureConfigExtensionsTests +{ + private readonly Mock _mockStorageProvider = new(); + private readonly Mock _mockKeyProvider = new(); + private readonly Mock _mockCryptoProvider = new(); + private readonly Mock _mockLoggerFactory = new(); + + [Fact] + public void AddSecureConfig_WithNullBuilder_ItShouldThrowArgumentNullException() + { + IConfigurationBuilder builder = null!; + + var act = () => builder.AddSecureConfig(config => { }); + + act.Should().Throw() + .WithParameterName("builder"); + } + + [Fact] + public void AddSecureConfig_WithNullConfigure_ItShouldThrowArgumentNullException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddSecureConfig_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A storage provider must be configured."); + } + + [Fact] + public void AddSecureConfig_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A key provider must be configured"); + } + + [Fact] + public void AddSecureConfig_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + }); + + act.Should().Throw() + .WithMessage("A crypto provider must be configured."); + } + + [Fact] + public void AddSecureConfig_WhenProperlyConfigured_ItShouldAddSecureConfigSource() + { + var builder = new ConfigurationBuilder(); + + var result = builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + result.Should().BeSameAs(builder); + builder.Sources.Should().ContainSingle(s => s is SecureConfigSource); + } + + [Fact] + public void AddSecureConfig_WhenProperlyConfigured_ItShouldBuildConfigurationProvider() + { + var encryptedValue = "encrypted_value"; + var decryptedJson = @"{ ""Setting"": ""Value"", ""Number"": 42 }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "MySection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration.GetSection("MySection")["Setting"].Should().Be("Value"); + configuration.GetSection("MySection")["Number"].Should().Be("42"); + } + + [Fact] + public void AddSecureConfig_WithJsonFileStorage_ItShouldWorkEndToEnd() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var filePath = Path.Combine(tempDir, "test_config.json"); + + try + { + Directory.CreateDirectory(tempDir); + + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var keyProvider = new StaticKeyProvider(Convert.ToBase64String(keyBytes)); + var cryptoProvider = new AesCryptoProvider(keyProvider); + + var originalData = @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }"; + var encryptedData = cryptoProvider.Encrypt(originalData); + + File.WriteAllText(filePath, $"{{\"Settings\":\"{encryptedData}\"}}"); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir; + options.FileName = "test_config.json"; + }); + config.WithBase64EncryptionKey(Convert.ToBase64String(keyBytes)); + config.WithAesCryptoProvider(); + }) + .Build(); + + configuration["Settings:AppName"].Should().Be("TestApp"); + configuration["Settings:Version"].Should().Be("1.0.0"); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void AddSecureConfig_WithLoggerFactory_ItShouldUseProvidedLoggerFactory() + { + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var mockLogger = new Mock>(); + + _mockLoggerFactory + .Setup(f => f.CreateLogger(typeof(SecureConfigProvider).FullName!)) + .Returns(mockLogger.Object); + + var builder = new ConfigurationBuilder(); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + config.WithLoggerFactory(_mockLoggerFactory.Object); + }) + .Build(); + + _mockLoggerFactory.Verify( + f => f.CreateLogger(typeof(SecureConfigProvider).FullName!), + Times.Once() + ); + } + + [Fact] + public void AddSecureConfig_WithMultipleCalls_ItShouldAddMultipleSources() + { + var builder = new ConfigurationBuilder(); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + builder.Sources.Should().HaveCount(2); + builder.Sources.Should().AllBeOfType(); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WithNullServices_ItShouldThrowArgumentNullException() + { + IServiceCollection services = null!; + + var act = () => services.AddSecureConfig(config => { }); + + act.Should().Throw() + .WithParameterName("services"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WithNullConfigure_ItShouldThrowArgumentNullException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A storage provider must be configured."); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A key provider must be configured"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + }); + + act.Should().Throw() + .WithMessage("A crypto provider must be configured."); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenProperlyConfigured_ItShouldRegisterServices() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + services.Should().Contain(s => s.ServiceType == typeof(ISecureStorageProvider)); + services.Should().Contain(s => s.ServiceType == typeof(IEncryptionKeyProvider)); + services.Should().Contain(s => s.ServiceType == typeof(ICryptoProvider)); + services.Should().Contain(s => s.ServiceType == typeof(ISecureConfig)); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldReturnServiceCollection() + { + var services = new ServiceCollection(); + + var result = services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + result.Should().BeSameAs(services); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterStorageProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var storageDescriptor = services.First(s => s.ServiceType == typeof(ISecureStorageProvider)); + storageDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterKeyProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var keyDescriptor = services.First(s => s.ServiceType == typeof(IEncryptionKeyProvider)); + keyDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterCryptoProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var cryptoDescriptor = services.First(s => s.ServiceType == typeof(ICryptoProvider)); + cryptoDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterSecureConfigAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var secureConfigDescriptor = services.First(s => s.ServiceType == typeof(ISecureConfig)); + secureConfigDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSecureConfigFromDI() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetService(); + + secureConfig.Should().NotBeNull(); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameSecureConfigInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(instance2); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameStorageProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockStorageProvider.Object); + instance2.Should().BeSameAs(_mockStorageProvider.Object); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameKeyProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockKeyProvider.Object); + instance2.Should().BeSameAs(_mockKeyProvider.Object); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameCryptoProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockCryptoProvider.Object); + instance2.Should().BeSameAs(_mockCryptoProvider.Object); + } + + [Fact] + public async Task AddSecureConfig_ServiceCollection_WithRealAesCryptoProvider_ItShouldWorkEndToEnd() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var filePath = Path.Combine(tempDir, "test_config.json"); + + try + { + Directory.CreateDirectory(tempDir); + + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var base64Key = Convert.ToBase64String(keyBytes); + + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir; + options.FileName = "test_config.json"; + }); + config.WithBase64EncryptionKey(base64Key); + config.WithAesCryptoProvider(); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); + var storageProvider = serviceProvider.GetRequiredService(); + + var testObject = new TestConfig { Name = "TestName", Value = 123 }; + await secureConfig.SetAsync("TestKey", testObject); + + var retrievedObject = await secureConfig.GetAsync("TestKey"); + + retrievedObject.Should().NotBeNull(); + retrievedObject!.Name.Should().Be("TestName"); + retrievedObject.Value.Should().Be(123); + + File.Exists(filePath).Should().BeTrue(); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task AddSecureConfig_ServiceCollection_WithMachineIdKey_ItShouldWorkEndToEnd() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + try + { + Directory.CreateDirectory(tempDir); + + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir; + options.FileName = "test_config.json"; + }); + config.WithMachineIdKey(); + config.WithAesCryptoProvider(); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); + + var testObject = new TestConfig { Name = "MachineIdTest", Value = 456 }; + await secureConfig.SetAsync("MachineTest", testObject); + + var retrievedObject = await secureConfig.GetAsync("MachineTest"); + + retrievedObject.Should().NotBeNull(); + retrievedObject!.Name.Should().Be("MachineIdTest"); + retrievedObject.Value.Should().Be(456); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterJsonSerializerOptions() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var keyedService = serviceProvider.GetKeyedService("SecureConfigJsonSerializerOptions"); + + keyedService.Should().NotBeNull(); + keyedService!.PropertyNameCaseInsensitive.Should().BeTrue(); + } + + [Fact] + public void AddSecureConfig_CombinedWithOtherProviders_ItShouldMergeConfiguration() + { + var encryptedValue = "encrypted_value"; + var decryptedJson = @"{ ""SecureSetting"": ""SecureValue"" }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "SecureSection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["RegularSetting"] = "RegularValue", + ["AnotherSetting"] = "AnotherValue" + }) + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["RegularSetting"].Should().Be("RegularValue"); + configuration["AnotherSetting"].Should().Be("AnotherValue"); + configuration["SecureSection:SecureSetting"].Should().Be("SecureValue"); + } + + [Fact] + public void AddSecureConfig_WithNestedConfiguration_ItShouldFlattenCorrectly() + { + var encryptedValue = "encrypted_nested"; + var decryptedJson = @"{ + ""Level1"": { + ""Level2"": { + ""Setting"": ""NestedValue"" + } + } + }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "Nested", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["Nested:Level1:Level2:Setting"].Should().Be("NestedValue"); + } + + [Fact] + public void AddSecureConfig_WithArrays_ItShouldIndexCorrectly() + { + var encryptedValue = "encrypted_array"; + var decryptedJson = @"{ ""Items"": [""First"", ""Second"", ""Third""] }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "ArraySection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["ArraySection:Items:0"].Should().Be("First"); + configuration["ArraySection:Items:1"].Should().Be("Second"); + configuration["ArraySection:Items:2"].Should().Be("Third"); + } + + [Fact] + public void AddSecureConfig_WithMultipleSections_ItShouldLoadAllSections() + { + var encrypted1 = "encrypted1"; + var encrypted2 = "encrypted2"; + var decrypted1 = @"{ ""Setting1"": ""Value1"" }"; + var decrypted2 = @"{ ""Setting2"": ""Value2"" }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary + { + { "Section1", encrypted1 }, + { "Section2", encrypted2 } + }); + + _mockCryptoProvider + .Setup(c => c.Decrypt("encrypted1")) + .Returns(decrypted1); + + _mockCryptoProvider + .Setup(c => c.Decrypt("encrypted2")) + .Returns(decrypted2); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["Section1:Setting1"].Should().Be("Value1"); + configuration["Section2:Setting2"].Should().Be("Value2"); + } + + private static string GetValidBase64Key() + { + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + return Convert.ToBase64String(keyBytes); + } +} + +internal sealed class TestConfig +{ + public string Name { get; set; } = string.Empty; + public int Value { get; set; } +} + +[JsonSerializable(typeof(TestConfig))] +internal partial class SecureConfigExtensionsTestsJsonContext : JsonSerializerContext +{ +} \ No newline at end of file From ec424749fe01dd77540742375e07a7d7d334fedb Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:29:41 -0500 Subject: [PATCH 34/55] tests: fix lsp issues --- .../Unit/SecureConfigExtensionsTests.cs | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs index 592af45..4bdea46 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs @@ -108,7 +108,7 @@ public class SecureConfigExtensionsTests public void AddSecureConfig_WhenProperlyConfigured_ItShouldBuildConfigurationProvider() { var encryptedValue = "encrypted_value"; - var decryptedJson = @"{ ""Setting"": ""Value"", ""Number"": 42 }"; + var decryptedJson = /*lang=json,strict*/ @"{ ""Setting"": ""Value"", ""Number"": 42 }"; _mockStorageProvider .Setup(s => s.ReadAllAsync(It.IsAny())) @@ -146,7 +146,7 @@ public class SecureConfigExtensionsTests var keyProvider = new StaticKeyProvider(Convert.ToBase64String(keyBytes)); var cryptoProvider = new AesCryptoProvider(keyProvider); - var originalData = @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }"; + var originalData = /*lang=json,strict*/ @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }"; var encryptedData = cryptoProvider.Encrypt(originalData); File.WriteAllText(filePath, $"{{\"Settings\":\"{encryptedData}\"}}"); @@ -169,8 +169,15 @@ public class SecureConfigExtensionsTests } finally { - if (File.Exists(filePath)) File.Delete(filePath); - if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } } } @@ -526,15 +533,22 @@ public class SecureConfigExtensionsTests var retrievedObject = await secureConfig.GetAsync("TestKey"); retrievedObject.Should().NotBeNull(); - retrievedObject!.Name.Should().Be("TestName"); + retrievedObject.Name.Should().Be("TestName"); retrievedObject.Value.Should().Be(123); File.Exists(filePath).Should().BeTrue(); } finally { - if (File.Exists(filePath)) File.Delete(filePath); - if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } } } @@ -570,12 +584,15 @@ public class SecureConfigExtensionsTests var retrievedObject = await secureConfig.GetAsync("MachineTest"); retrievedObject.Should().NotBeNull(); - retrievedObject!.Name.Should().Be("MachineIdTest"); + retrievedObject.Name.Should().Be("MachineIdTest"); retrievedObject.Value.Should().Be(456); } finally { - if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } } } @@ -595,14 +612,14 @@ public class SecureConfigExtensionsTests var keyedService = serviceProvider.GetKeyedService("SecureConfigJsonSerializerOptions"); keyedService.Should().NotBeNull(); - keyedService!.PropertyNameCaseInsensitive.Should().BeTrue(); + keyedService.PropertyNameCaseInsensitive.Should().BeTrue(); } [Fact] public void AddSecureConfig_CombinedWithOtherProviders_ItShouldMergeConfiguration() { var encryptedValue = "encrypted_value"; - var decryptedJson = @"{ ""SecureSetting"": ""SecureValue"" }"; + var decryptedJson = /*lang=json,strict*/ @"{ ""SecureSetting"": ""SecureValue"" }"; _mockStorageProvider .Setup(s => s.ReadAllAsync(It.IsAny())) @@ -635,7 +652,7 @@ public class SecureConfigExtensionsTests public void AddSecureConfig_WithNestedConfiguration_ItShouldFlattenCorrectly() { var encryptedValue = "encrypted_nested"; - var decryptedJson = @"{ + var decryptedJson = /*lang=json,strict*/ @"{ ""Level1"": { ""Level2"": { ""Setting"": ""NestedValue"" @@ -667,7 +684,7 @@ public class SecureConfigExtensionsTests public void AddSecureConfig_WithArrays_ItShouldIndexCorrectly() { var encryptedValue = "encrypted_array"; - var decryptedJson = @"{ ""Items"": [""First"", ""Second"", ""Third""] }"; + var decryptedJson = /*lang=json,strict*/ @"{ ""Items"": [""First"", ""Second"", ""Third""] }"; _mockStorageProvider .Setup(s => s.ReadAllAsync(It.IsAny())) @@ -696,8 +713,8 @@ public class SecureConfigExtensionsTests { var encrypted1 = "encrypted1"; var encrypted2 = "encrypted2"; - var decrypted1 = @"{ ""Setting1"": ""Value1"" }"; - var decrypted2 = @"{ ""Setting2"": ""Value2"" }"; + var decrypted1 = /*lang=json,strict*/ @"{ ""Setting1"": ""Value1"" }"; + var decrypted2 = /*lang=json,strict*/ @"{ ""Setting2"": ""Value2"" }"; _mockStorageProvider .Setup(s => s.ReadAllAsync(It.IsAny())) From 3f1131623e83bcee84ec76b54aa5c8bec9093305 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:54:23 -0500 Subject: [PATCH 35/55] refactor: make isecureconfig interface public --- .../Configuration/ISecureConfig.cs | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs index 9aba129..cdcd00e 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs @@ -2,11 +2,61 @@ using System.Text.Json.Serialization.Metadata; namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; -internal interface ISecureConfig +/// +/// Provides secure storage, retrieval, and deletion of configuration values. +/// All values are automatically encrypted before storage and decrypted on retrieval. +/// +public interface ISecureConfig { + /// + /// Serializes, encrypts, and stores a value for the specified key. + /// Resolves from the registered serializer options. + /// + /// The type of the value to store. + /// The configuration key. + /// The value to store. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation. Task SetAsync(string key, T value, CancellationToken ct = default); + + /// + /// Serializes, encrypts, and stores a value for the specified key using the provided type metadata. + /// This overload supports Native AOT by accepting pre-compiled . + /// + /// The type of the value to store. + /// The configuration key. + /// The value to store. + /// The JSON type metadata for source-generated serialization. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation. Task SetAsync(string key, T value, JsonTypeInfo typeInfo, CancellationToken ct = default); + + /// + /// Reads, decrypts, and deserializes a value for the specified key. + /// Resolves from the registered serializer options. + /// + /// The type of the value to retrieve. + /// The configuration key. + /// A token to monitor for cancellation requests. + /// The deserialized value, or default if the key does not exist. Task GetAsync(string key, CancellationToken ct = default); + + /// + /// Reads, decrypts, and deserializes a value for the specified key using the provided type metadata. + /// This overload supports Native AOT by accepting pre-compiled . + /// + /// The type of the value to retrieve. + /// The configuration key. + /// The JSON type metadata for source-generated serialization. + /// A token to monitor for cancellation requests. + /// The deserialized value, or default if the key does not exist. Task GetAsync(string key, JsonTypeInfo typeInfo, CancellationToken ct = default); + + /// + /// Deletes the value associated with the specified key from secure storage. + /// + /// The configuration key. + /// A token to monitor for cancellation requests. + /// true if the value was deleted; otherwise, false. Task DeleteAsync(string key, CancellationToken ct = default); } \ No newline at end of file From c2d158c872a0a7051ab34a749581308248947223 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:54:46 -0500 Subject: [PATCH 36/55] feat: allow secure config provider to handle reloading config --- .../Configuration/SecureConfigProvider.cs | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs index 622aadc..3c234c1 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs @@ -9,19 +9,58 @@ using StevanFreeborn.Extensions.Configuration.Secure.Storage; namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; -internal class SecureConfigProvider( - ISecureStorageProvider storageProvider, - ICryptoProvider cryptoProvider, - ILogger logger -) : ConfigurationProvider +internal class SecureConfigProvider : ConfigurationProvider, IDisposable { - private readonly ISecureStorageProvider _storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider)); - private readonly ICryptoProvider _cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider)); - private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(cryptoProvider)); + private readonly ISecureStorageProvider _storageProvider; + private readonly ICryptoProvider _cryptoProvider; + private readonly ILogger _logger; + + public SecureConfigProvider( + ISecureStorageProvider storageProvider, + ICryptoProvider cryptoProvider, + ILogger logger + ) + { + _storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider)); + _cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(cryptoProvider)); + + _storageProvider.StorageChanged += HandleStorageChangedAsync; + } public override void Load() { var encryptedData = _storageProvider.ReadAllAsync().GetAwaiter().GetResult(); + Data = ProcessAndDecryptData(encryptedData); + } + + public void Dispose() + { + _storageProvider.StorageChanged -= HandleStorageChangedAsync; + } + + private async void HandleStorageChangedAsync(object? sender, EventArgs e) + { + try + { + var encryptedData = await _storageProvider.ReadAllAsync().ConfigureAwait(false); + + var newData = ProcessAndDecryptData(encryptedData); + + Data = newData; + + OnReload(); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogFailedToReloadSecureConfig(ex); + } + } + + private Dictionary ProcessAndDecryptData(IDictionary encryptedData) + { var flattenedData = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in encryptedData) @@ -29,7 +68,6 @@ internal class SecureConfigProvider( try { var decryptedJson = _cryptoProvider.Decrypt(kvp.Value); - using var document = JsonDocument.Parse(decryptedJson); FlattenJsonElement(flattenedData, document.RootElement, kvp.Key); } @@ -41,7 +79,7 @@ internal class SecureConfigProvider( } } - Data = flattenedData; + return flattenedData; } private static void FlattenJsonElement(IDictionary data, JsonElement element, string currentKey) From f1a9596363e2bed7c6c8d3226eb302d44a5af73c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:54:59 -0500 Subject: [PATCH 37/55] feat: add log message for when reload fails --- .../Logging/LogMessages.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs index b8f056a..4d15e60 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs @@ -22,4 +22,11 @@ internal static partial class LogMessages Message = "Failed to decrypt value for key {Key}" )] public static partial void LogDecryptionFailure(this ILogger logger, Exception ex, string key); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "Failed to asynchronously reload secure configuration. The previous configuration state will be maintained." + )] + public static partial void LogFailedToReloadSecureConfig(this ILogger logger, Exception ex); } \ No newline at end of file From 4e8d3b88657256354fcbddd38394e0f9f6377f37 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:55:48 -0500 Subject: [PATCH 38/55] feat: allow secure storage provider to raise event when storage changes and implement physical file provider in json storage provider to detect when changes occur --- .../Storage/ISecureStorageProvider.cs | 7 +- .../Storage/JsonFileStorageProvider.cs | 86 ++++++++++++++----- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs index 8b5e0db..9c45878 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs @@ -3,8 +3,13 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; /// /// Defines a contract for a provider that stores and retrieves secure configuration data. /// -public interface ISecureStorageProvider +public interface ISecureStorageProvider : IDisposable { + /// + /// + /// + event EventHandler StorageChanged; + /// /// Reads the value associated with the specified key asynchronously. /// diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 309abb2..4884c3a 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -1,16 +1,52 @@ using System.Text.Json; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; /// /// Provides a mechanism to store and retrieve secure configuration data in a JSON file. /// -/// The configuring the storage provider, including file paths. -public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecureStorageProvider +public sealed class JsonFileStorageProvider : ISecureStorageProvider { private static readonly SemaphoreSlim FileLock = new(1, 1); - private readonly JsonStorageOptions _options = options - ?? throw new ArgumentNullException(nameof(options)); + private readonly PhysicalFileProvider? _fileProvider; + private readonly IDisposable? _changeTokenRegistration; + private readonly JsonStorageOptions _options; + + /// + public event EventHandler? StorageChanged; + + /// + /// Provides a mechanism to store and retrieve secure configuration data in a JSON file. + /// + /// The configuring the storage provider, including file paths. + public JsonFileStorageProvider(JsonStorageOptions options) + { + _options = options + ?? throw new ArgumentNullException(nameof(options)); + + var directory = Path.GetDirectoryName(_options.FullPath); + + if (string.IsNullOrWhiteSpace(directory) is false && Directory.Exists(directory)) + { + _fileProvider = new PhysicalFileProvider(directory) + { + UseActivePolling = true, + UsePollingFileWatcher = true, + }; + + _changeTokenRegistration = ChangeToken.OnChange( + () => _fileProvider.Watch(_options.FileName), + () => + { + Thread.Sleep(250); + StorageChanged?.Invoke(this, EventArgs.Empty); + } + ); + } + } /// /// Reads the value associated with the specified key from the JSON file asynchronously. @@ -87,10 +123,14 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur } /// - /// Acquires an exclusive lock and loads the configuration data from the JSON file. + /// /// - /// A cancellation token to observe while waiting for the lock or during the load operation. - /// A dictionary containing the loaded configuration data. + public void Dispose() + { + _changeTokenRegistration?.Dispose(); + _fileProvider?.Dispose(); + } + private async Task> AcquireLockAndLoadAsync(CancellationToken ct) { await FileLock.WaitAsync(ct).ConfigureAwait(false); @@ -105,11 +145,6 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur } } - /// - /// Loads the configuration data from the JSON file. - /// - /// A cancellation token to observe while loading the data. - /// A dictionary containing the loaded configuration data, or an empty dictionary if the file does not exist or is empty. private async Task> LoadAsync(CancellationToken ct) { if (File.Exists(_options.FullPath) is false) @@ -117,7 +152,16 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur return []; } - using var stream = new FileStream(_options.FullPath, FileMode.Open, FileAccess.Read); + var stream = new FileStream( + _options.FullPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan + ); + + await using var _ = stream.ConfigureAwait(false); if (stream.Length is 0) { @@ -130,24 +174,22 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur return data ?? []; } - /// - /// Saves the configuration data to the JSON file. - /// - /// The configuration data to save. - /// A cancellation token to observe while saving the data. - /// A task that represents the asynchronous save operation. private async Task SaveAsync(Dictionary data, CancellationToken ct) { Directory.CreateDirectory(_options.DirectoryPath); - using var stream = new FileStream( + var stream = new FileStream( _options.FullPath, FileMode.Create, FileAccess.Write, - FileShare.None + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous ); + await using var _ = stream.ConfigureAwait(false); + await JsonSerializer.SerializeAsync(stream, data, SecureConfigJsonContext.Default.DictionaryStringString, ct) .ConfigureAwait(false); } -} +} \ No newline at end of file From d0cf2224a5465ab3434f50b323de4c0caab5f8da Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:56:09 -0500 Subject: [PATCH 39/55] chore: add physical file provider package as dep --- .../StevanFreeborn.Extensions.Configuration.Secure.csproj | 1 + 1 file changed, 1 insertion(+) 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 165831f..7816f9e 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj @@ -22,6 +22,7 @@ + From d9affe2f75f539f691ad052f34407154602e1697 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:56:35 -0500 Subject: [PATCH 40/55] chore: update sample app to show options pattern usage and verify reload of config works --- .../AppJsonContext.cs | 8 +++ .../Program.cs | 61 +++++++++++++------ ...ensions.Configuration.Secure.Sample.csproj | 2 +- 3 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs new file mode 100644 index 0000000..dd63a7a --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; + +[JsonSerializable(typeof(ApiOptions))] +internal partial class AppJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs index 57d8840..e508c0f 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs @@ -1,37 +1,62 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using StevanFreeborn.Extensions.Configuration.Secure; +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; using StevanFreeborn.Extensions.Configuration.Secure.Sample; using StevanFreeborn.Extensions.Configuration.Secure.Storage; -var builder = Host.CreateApplicationBuilder(); - -const string configFileName = "appsettings.json"; -var opts = new JsonStorageOptions() +Action configure = builder => { - FileName = configFileName, + builder + .WithMachineIdKey() + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions()) + .AddJsonAotContext(AppJsonContext.Default); }; -var storageProvider = new JsonFileStorageProvider(opts); - -builder.Configuration.AddSecureConfig(storageProvider); - -builder.Services.Configure( - builder.Configuration.GetSection(nameof(ApiOptions)) -); - -builder.Services.AddSecureConfig() - .UseJsonFileStorage(opt => opt.FileName = configFileName); +var builder = Host.CreateDefaultBuilder() + .ConfigureAppConfiguration((_, b) => + { + b.AddSecureConfig(configure); + }) + .ConfigureServices((ctx, s) => + { + s.Configure(ctx.Configuration.GetSection(nameof(ApiOptions))); + s.AddSecureConfig(configure); + }); var app = builder.Build(); var options = app.Services.GetRequiredService>(); +var optionsMonitor = app.Services.GetRequiredService>(); var secureConfig = app.Services.GetRequiredService(); +var scopeFactory = app.Services.GetRequiredService(); -Console.WriteLine(options.Value); +var firstScope = scopeFactory.CreateScope(); +var firstSnapshot = firstScope.ServiceProvider.GetRequiredService>(); + +var originalValue = await secureConfig.GetAsync(nameof(ApiOptions)); +Console.WriteLine($"Config: {originalValue}"); +Console.WriteLine($"IOptions: {options.Value}"); +Console.WriteLine($"IOptionsSnapshot 1: {firstSnapshot.Value}"); +Console.WriteLine($"IOptionsMonitor: {optionsMonitor.CurrentValue}"); await secureConfig.SetAsync( nameof(ApiOptions), - new ApiOptions { ApiKey = "apiKey" } + new ApiOptions { ApiKey = Guid.NewGuid().ToString() } ); + +var config = (IConfigurationRoot)app.Services.GetRequiredService(); +config.Reload(); + +var updatedValue = await secureConfig.GetAsync(nameof(ApiOptions)); +var secondScope = scopeFactory.CreateScope(); +var secondSnapshot = secondScope.ServiceProvider.GetRequiredService>(); + +Console.WriteLine($"Config: {updatedValue}"); +Console.WriteLine($"IOptions: {options.Value}"); +Console.WriteLine($"IOptionsSnapshot 2: {secondSnapshot.Value}"); +Console.WriteLine($"IOptionsMonitor: {optionsMonitor.CurrentValue}"); diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj index 94ae0dc..76e9b7c 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj @@ -2,7 +2,7 @@ Exe - net11.0 + net10.0 enable enable From 24f5f11a34b78d7da593663f6a2d3c9b11c0c98a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:57:21 -0500 Subject: [PATCH 41/55] chore: add global.json file and add sample project to solution now that it builds --- StevanFreeborn.SecureConfig.slnx | 1 + global.json | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 global.json diff --git a/StevanFreeborn.SecureConfig.slnx b/StevanFreeborn.SecureConfig.slnx index f129078..27522a1 100644 --- a/StevanFreeborn.SecureConfig.slnx +++ b/StevanFreeborn.SecureConfig.slnx @@ -1,5 +1,6 @@ + diff --git a/global.json b/global.json new file mode 100644 index 0000000..8287d38 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.201", + "rollForward": "latestFeature" + } +} From af7f53bc37024eec09b5a56cf5bc8eec50492b10 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:00:16 -0500 Subject: [PATCH 42/55] refactor: make json file storage provider internal --- .../Storage/JsonFileStorageProvider.cs | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 4884c3a..5e336b0 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -5,23 +5,15 @@ using Microsoft.Extensions.Primitives; namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; -/// -/// Provides a mechanism to store and retrieve secure configuration data in a JSON file. -/// -public sealed class JsonFileStorageProvider : ISecureStorageProvider +internal sealed class JsonFileStorageProvider : ISecureStorageProvider { private static readonly SemaphoreSlim FileLock = new(1, 1); private readonly PhysicalFileProvider? _fileProvider; private readonly IDisposable? _changeTokenRegistration; private readonly JsonStorageOptions _options; - /// public event EventHandler? StorageChanged; - /// - /// Provides a mechanism to store and retrieve secure configuration data in a JSON file. - /// - /// The configuring the storage provider, including file paths. public JsonFileStorageProvider(JsonStorageOptions options) { _options = options @@ -48,12 +40,6 @@ public sealed class JsonFileStorageProvider : ISecureStorageProvider } } - /// - /// Reads the value associated with the specified key from the JSON file asynchronously. - /// - /// The key of the configuration value to read. - /// A cancellation token that can be used to cancel the read operation. - /// A task that represents the asynchronous read operation. The task result contains the value associated with the specified key, or an empty string if the key is not found. public async Task ReadAsync(string key, CancellationToken ct = default) { var data = await AcquireLockAndLoadAsync(ct).ConfigureAwait(false); @@ -66,23 +52,11 @@ public sealed class JsonFileStorageProvider : ISecureStorageProvider return string.Empty; } - /// - /// Reads all configuration values from the JSON file asynchronously. - /// - /// A cancellation token that can be used to cancel the read operation. - /// A task that represents the asynchronous read operation. The task result contains a dictionary of all configuration keys and their values. public Task> ReadAllAsync(CancellationToken ct = default) { return AcquireLockAndLoadAsync(ct); } - /// - /// Writes the specified key and encrypted data to the JSON file asynchronously. - /// - /// The key of the configuration value to write. - /// The encrypted configuration data to write. - /// A cancellation token that can be used to cancel the write operation. - /// A task that represents the asynchronous write operation. public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) { await FileLock.WaitAsync(ct).ConfigureAwait(false); @@ -99,12 +73,6 @@ public sealed class JsonFileStorageProvider : ISecureStorageProvider } } - /// - /// Deletes the configuration value associated with the specified key from the JSON file asynchronously. - /// - /// The key of the configuration value to delete. - /// A cancellation token that can be used to cancel the delete operation. - /// A task that represents the asynchronous delete operation. The task result contains true if the value was successfully deleted; otherwise, false. public async Task DeleteAsync(string key, CancellationToken ct = default) { await FileLock.WaitAsync(ct).ConfigureAwait(false); @@ -122,9 +90,6 @@ public sealed class JsonFileStorageProvider : ISecureStorageProvider } } - /// - /// - /// public void Dispose() { _changeTokenRegistration?.Dispose(); From 5568793a8ef98c9b836733bd0e98f3c813fde92f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:03:32 -0500 Subject: [PATCH 43/55] chore: run dotnet format --- .../Program.cs | 2 +- .../Unit/Configuration/SecureConfigBuilderTests.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs index e508c0f..ab32228 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs @@ -59,4 +59,4 @@ var secondSnapshot = secondScope.ServiceProvider.GetRequiredService Date: Thu, 2 Apr 2026 20:31:06 -0500 Subject: [PATCH 44/55] tests: add integration tests - refactored to use temp directory helper class - consolidated test key generation to helper class --- .../Common/KeyGenerator.cs | 13 + .../Common/TempDirectory.cs | 20 ++ .../SecureConfigExtensionsTests.cs | 252 +++++++++++++++++ .../SecureConfigOptionsIntegrationTests.cs | 261 ++++++++++++++++++ ...tensions.Configuration.Secure.Tests.csproj | 1 + .../Unit/SecureConfigExtensionsTests.cs | 204 +++++--------- .../Storage/JsonFileStorageProviderTests.cs | 14 +- 7 files changed, 627 insertions(+), 138 deletions(-) create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/KeyGenerator.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/TempDirectory.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigExtensionsTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigOptionsIntegrationTests.cs diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/KeyGenerator.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/KeyGenerator.cs new file mode 100644 index 0000000..f4f2be2 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/KeyGenerator.cs @@ -0,0 +1,13 @@ +using System.Security.Cryptography; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; + +internal static class KeyGenerator +{ + public static string GetValidBase64Key() + { + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + return Convert.ToBase64String(key); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/TempDirectory.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/TempDirectory.cs new file mode 100644 index 0000000..0ccb65e --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/TempDirectory.cs @@ -0,0 +1,20 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; + +internal sealed class TempDirectory : IDisposable +{ + public string Path { get; } + + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } +} diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigExtensionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigExtensionsTests.cs new file mode 100644 index 0000000..b1e8952 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigExtensionsTests.cs @@ -0,0 +1,252 @@ +using System.Security.Cryptography; +using System.Text.Json.Serialization; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; +using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Integration; + +public class SecureConfigExtensionsTests : IDisposable +{ + private readonly TempDirectory _tempDir = new(); + + public void Dispose() + { + _tempDir.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public void AddSecureConfig_ToConfigurationBuilder_ItShouldReturnConfigurationBuilder() + { + var builder = new ConfigurationBuilder(); + + var result = builder.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + result.Should().BeSameAs(builder); + } + + [Fact] + public void AddSecureConfig_ToConfigurationBuilder_ItShouldAddSecureConfigSource() + { + var builder = new ConfigurationBuilder(); + + builder.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + builder.Sources.Should().ContainSingle(s => s is SecureConfigSource); + } + + [Fact] + public void AddSecureConfig_ToServiceCollection_ItShouldReturnServiceCollection() + { + var services = new ServiceCollection(); + + var result = services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + result.Should().BeSameAs(services); + } + + [Fact] + public void AddSecureConfig_ToServiceCollection_ItShouldRegisterISecureConfig() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + var descriptor = services.Should().ContainSingle(d => d.ServiceType == typeof(ISecureConfig)).Subject; + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ToServiceCollection_ItShouldRegisterAllDependencies() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + services.Should().Contain(d => d.ServiceType == typeof(ISecureStorageProvider)); + services.Should().Contain(d => d.ServiceType == typeof(IEncryptionKeyProvider)); + services.Should().Contain(d => d.ServiceType == typeof(ICryptoProvider)); + services.Should().Contain(d => d.ServiceType == typeof(ISecureConfig)); + } + + [Fact] + public void AddSecureConfig_ToServiceCollection_ItShouldNotDuplicateExistingRegistrations() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + services.Count(d => d.ServiceType == typeof(ISecureConfig)).Should().Be(1); + services.Count(d => d.ServiceType == typeof(ISecureStorageProvider)).Should().Be(1); + services.Count(d => d.ServiceType == typeof(IEncryptionKeyProvider)).Should().Be(1); + services.Count(d => d.ServiceType == typeof(ICryptoProvider)).Should().Be(1); + } + + [Fact] + public async Task AddSecureConfig_WithRealProviders_ItShouldResolveISecureConfig() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + secureConfig.Should().NotBeNull(); + secureConfig.Should().BeOfType(); + } + + [Fact] + public async Task AddSecureConfig_WithRealProviders_ItShouldSetAndGetValue() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + var testValue = new IntegrationTestConfig { Name = "TestName", Value = 42 }; + await secureConfig.SetAsync("test-key", testValue); + + var result = await secureConfig.GetAsync("test-key"); + + result.Should().NotBeNull(); + result!.Name.Should().Be("TestName"); + result.Value.Should().Be(42); + } + + [Fact] + public async Task AddSecureConfig_WithRealProviders_ItShouldDeleteValue() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + await secureConfig.SetAsync("delete-key", new IntegrationTestConfig { Name = "ToDelete", Value = 1 }); + + var deleted = await secureConfig.DeleteAsync("delete-key"); + + deleted.Should().BeTrue(); + + var result = await secureConfig.GetAsync("delete-key"); + result.Should().BeNull(); + } + + [Fact] + public async Task AddSecureConfig_WithRealProviders_ItShouldReturnDefaultForMissingKey() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + var result = await secureConfig.GetAsync("nonexistent-key"); + + result.Should().BeNull(); + } + + [Fact] + public async Task AddSecureConfig_WithRealProviders_ItShouldResolveAllServicesFromContainer() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + var provider = services.BuildServiceProvider(); + + var storageProvider = provider.GetRequiredService(); + var cryptoProvider = provider.GetRequiredService(); + var secureConfig = provider.GetRequiredService(); + + storageProvider.Should().NotBeNull(); + cryptoProvider.Should().NotBeNull(); + secureConfig.Should().NotBeNull(); + + storageProvider.Should().BeOfType(); + cryptoProvider.Should().BeOfType(); + } + + [Fact] + public async Task AddSecureConfig_WithMachineIdKey_ItShouldWorkEndToEnd() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(builder => + { + builder + .WithMachineIdKey() + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = _tempDir.Path, + FileName = "secure-config.json", + }) + .AddJsonAotContext(IntegrationTestJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + var testValue = new IntegrationTestConfig { Name = "MachineIdTest", Value = 99 }; + await secureConfig.SetAsync("machine-key", testValue); + + var result = await secureConfig.GetAsync("machine-key"); + + result.Should().NotBeNull(); + result!.Name.Should().Be("MachineIdTest"); + result.Value.Should().Be(99); + } + + [Fact] + public async Task AddSecureConfig_WithTypedOverload_ItShouldSerializeAndDeserializeCorrectly() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path)); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + var testValue = new IntegrationTestConfig { Name = "TypedTest", Value = 777 }; + await secureConfig.SetAsync("typed-key", testValue, IntegrationTestJsonContext.Default.IntegrationTestConfig); + + var result = await secureConfig.GetAsync("typed-key", IntegrationTestJsonContext.Default.IntegrationTestConfig); + + result.Should().NotBeNull(); + result!.Name.Should().Be("TypedTest"); + result.Value.Should().Be(777); + } + + private static Action CreateDefaultConfig(string tempDir) => builder => + { + builder + .WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions { DirectoryPath = tempDir, FileName = "secure-config.json" }) + .AddJsonAotContext(IntegrationTestJsonContext.Default); + }; +} + +internal sealed class IntegrationTestConfig +{ + public string Name { get; init; } = string.Empty; + public int Value { get; init; } +} + +[JsonSerializable(typeof(IntegrationTestConfig))] +internal partial class IntegrationTestJsonContext : JsonSerializerContext +{ +} diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigOptionsIntegrationTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigOptionsIntegrationTests.cs new file mode 100644 index 0000000..a087e5c --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigOptionsIntegrationTests.cs @@ -0,0 +1,261 @@ +using System.Security.Cryptography; +using System.Text.Json.Serialization; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; +using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Integration; + +public class SecureConfigOptionsIntegrationTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _base64Key; + + public SecureConfigOptionsIntegrationTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + _base64Key = KeyGenerator.GetValidBase64Key(); + } + + [Fact] + public async Task ConfigureSecureConfig_WithIOptions_ItShouldResolveOptions() + { + var host = await CreateHostAsync(); + + using var scope = host.Services.CreateScope(); + var secureConfig = scope.ServiceProvider.GetRequiredService(); + + await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "initial-key" }); + + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + config.Reload(); + + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.Should().NotBeNull(); + options.Value.ApiKey.Should().Be("initial-key"); + } + + [Fact] + public async Task ConfigureSecureConfig_WithIOptionsSnapshot_ItShouldResolveNewInstancePerScope() + { + var host = await CreateHostAsync(); + + using var scope1 = host.Services.CreateScope(); + var snapshot1 = scope1.ServiceProvider.GetRequiredService>(); + + using var scope2 = host.Services.CreateScope(); + var snapshot2 = scope2.ServiceProvider.GetRequiredService>(); + + snapshot1.Should().NotBeSameAs(snapshot2); + snapshot1.Value.Should().BeEquivalentTo(snapshot2.Value); + } + + [Fact] + public async Task ConfigureSecureConfig_WithIOptionsMonitor_ItShouldResolveMonitor() + { + var host = await CreateHostAsync(); + + var monitor = host.Services.GetRequiredService>(); + + monitor.Should().NotBeNull(); + monitor.CurrentValue.Should().NotBeNull(); + } + + [Fact] + public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldReflectInIOptionsMonitor() + { + var host = await CreateHostAsync(); + + var secureConfig = host.Services.GetRequiredService(); + var monitor = host.Services.GetRequiredService>(); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + + var originalValue = monitor.CurrentValue.ApiKey; + + await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "updated-key" }); + + config.Reload(); + + monitor.CurrentValue.ApiKey.Should().Be("updated-key"); + monitor.CurrentValue.ApiKey.Should().NotBe(originalValue); + } + + [Fact] + public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldReflectInNewIOptionsSnapshot() + { + var host = await CreateHostAsync(); + + using var scope1 = host.Services.CreateScope(); + var snapshotBefore = scope1.ServiceProvider.GetRequiredService>(); + var originalValue = snapshotBefore.Value.ApiKey; + + var secureConfig = host.Services.GetRequiredService(); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + + await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "snapshot-updated-key" }); + + config.Reload(); + + using var scope2 = host.Services.CreateScope(); + var snapshotAfter = scope2.ServiceProvider.GetRequiredService>(); + + snapshotAfter.Value.ApiKey.Should().Be("snapshot-updated-key"); + snapshotAfter.Value.ApiKey.Should().NotBe(originalValue); + } + + [Fact] + public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldNotUpdateExistingIOptions() + { + var host = await CreateHostAsync(); + + var secureConfig = host.Services.GetRequiredService(); + var options = host.Services.GetRequiredService>(); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + + var originalValue = options.Value.ApiKey; + + await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "should-not-update" }); + + config.Reload(); + + options.Value.ApiKey.Should().Be(originalValue); + } + + [Fact] + public async Task ConfigureSecureConfig_WithNestedOptions_ItShouldBindCorrectly() + { + var host = await CreateHostAsync(); + + var secureConfig = host.Services.GetRequiredService(); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + + await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "nested-key" }); + + config.Reload(); + + var options = host.Services.GetRequiredService>(); + + options.Value.ApiKey.Should().Be("nested-key"); + } + + [Fact] + public async Task ConfigureSecureConfig_WithFullHost_ItShouldStartAndResolveAllServices() + { + var host = await CreateHostAsync(); + + var secureConfig = host.Services.GetRequiredService(); + var options = host.Services.GetRequiredService>(); + var snapshotFactory = host.Services.GetRequiredService(); + var monitor = host.Services.GetRequiredService>(); + var configuration = host.Services.GetRequiredService(); + + secureConfig.Should().NotBeNull(); + options.Should().NotBeNull(); + snapshotFactory.Should().NotBeNull(); + monitor.Should().NotBeNull(); + configuration.Should().NotBeNull(); + + using var scope = snapshotFactory.CreateScope(); + var snapshot = scope.ServiceProvider.GetRequiredService>(); + snapshot.Should().NotBeNull(); + } + + [Fact] + public async Task ConfigureSecureConfig_WhenValueUpdated_ItShouldPropagateThroughEntirePipeline() + { + var host = await CreateHostAsync(); + + var secureConfig = host.Services.GetRequiredService(); + var options = host.Services.GetRequiredService>(); + var monitor = host.Services.GetRequiredService>(); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + + var originalOptionsValue = options.Value.ApiKey; + var originalMonitorValue = monitor.CurrentValue.ApiKey; + + await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "pipeline-updated-key" }); + + config.Reload(); + + monitor.CurrentValue.ApiKey.Should().Be("pipeline-updated-key"); + + using var scope = host.Services.CreateScope(); + var snapshot = scope.ServiceProvider.GetRequiredService>(); + snapshot.Value.ApiKey.Should().Be("pipeline-updated-key"); + + options.Value.ApiKey.Should().Be(originalOptionsValue); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + + GC.SuppressFinalize(this); + } + + private Action CreateConfigBuilder() => builder => + { + builder + .WithBase64EncryptionKey(_base64Key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = _tempDir, + FileName = "secure-config.json" + }) + .AddJsonAotContext(OptionsTestJsonContext.Default); + }; + + private async Task CreateHostAsync() + { + var configure = CreateConfigBuilder(); + + var hostBuilder = Host.CreateDefaultBuilder() + .ConfigureLogging(c => c.ClearProviders()) + .ConfigureAppConfiguration((_, builder) => + { + builder.AddSecureConfig(configure); + }) + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection(nameof(TestApiOptions))); + services.AddSecureConfig(configure); + }); + + var host = hostBuilder.Build(); + await host.StartAsync(); + + var secureConfig = host.Services.GetRequiredService(); + + if (string.IsNullOrEmpty((await secureConfig.GetAsync(nameof(TestApiOptions)))?.ApiKey)) + { + await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "default-key" }); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + config.Reload(); + } + + return host; + } +} + +internal sealed class TestApiOptions +{ + public string ApiKey { get; init; } = string.Empty; +} + +[JsonSerializable(typeof(TestApiOptions))] +internal partial class OptionsTestJsonContext : JsonSerializerContext +{ +} 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 9474eab..a330012 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 @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs index 4bdea46..9dbd4cb 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs @@ -11,6 +11,7 @@ using Moq; using StevanFreeborn.Extensions.Configuration.Secure.Configuration; using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; using StevanFreeborn.Extensions.Configuration.Secure.Storage; +using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit; @@ -50,7 +51,7 @@ public class SecureConfigExtensionsTests var act = () => builder.AddSecureConfig(config => { - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); config.WithAesCryptoProvider(); }); @@ -81,7 +82,7 @@ public class SecureConfigExtensionsTests var act = () => builder.AddSecureConfig(config => { config.UseCustomStorage(_mockStorageProvider.Object); - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); }); act.Should().Throw() @@ -96,7 +97,7 @@ public class SecureConfigExtensionsTests var result = builder.AddSecureConfig(config => { config.UseCustomStorage(_mockStorageProvider.Object); - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); config.WithAesCryptoProvider(); }); @@ -134,51 +135,34 @@ public class SecureConfigExtensionsTests [Fact] public void AddSecureConfig_WithJsonFileStorage_ItShouldWorkEndToEnd() { - var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var filePath = Path.Combine(tempDir, "test_config.json"); + using var tempDir = new TempDirectory(); + var filePath = Path.Combine(tempDir.Path, "test_config.json"); - try - { - Directory.CreateDirectory(tempDir); + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var keyProvider = new StaticKeyProvider(Convert.ToBase64String(keyBytes)); + var cryptoProvider = new AesCryptoProvider(keyProvider); - var keyBytes = new byte[32]; - RandomNumberGenerator.Fill(keyBytes); - var keyProvider = new StaticKeyProvider(Convert.ToBase64String(keyBytes)); - var cryptoProvider = new AesCryptoProvider(keyProvider); + var originalData = /*lang=json,strict*/ @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }"; + var encryptedData = cryptoProvider.Encrypt(originalData); - var originalData = /*lang=json,strict*/ @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }"; - var encryptedData = cryptoProvider.Encrypt(originalData); + File.WriteAllText(filePath, $"{{\"Settings\":\"{encryptedData}\"}}"); - File.WriteAllText(filePath, $"{{\"Settings\":\"{encryptedData}\"}}"); - - var configuration = new ConfigurationBuilder() - .AddSecureConfig(config => + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseJsonFileStorage(options => { - config.UseJsonFileStorage(options => - { - options.DirectoryPath = tempDir; - options.FileName = "test_config.json"; - }); - config.WithBase64EncryptionKey(Convert.ToBase64String(keyBytes)); - config.WithAesCryptoProvider(); - }) - .Build(); + options.DirectoryPath = tempDir.Path; + options.FileName = "test_config.json"; + }); + config.WithBase64EncryptionKey(Convert.ToBase64String(keyBytes)); + config.WithAesCryptoProvider(); + }) + .Build(); - configuration["Settings:AppName"].Should().Be("TestApp"); - configuration["Settings:Version"].Should().Be("1.0.0"); - } - finally - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, true); - } - } + configuration["Settings:AppName"].Should().Be("TestApp"); + configuration["Settings:Version"].Should().Be("1.0.0"); } [Fact] @@ -199,7 +183,7 @@ public class SecureConfigExtensionsTests builder.AddSecureConfig(config => { config.UseCustomStorage(_mockStorageProvider.Object); - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); config.WithAesCryptoProvider(); config.WithLoggerFactory(_mockLoggerFactory.Object); }) @@ -219,14 +203,14 @@ public class SecureConfigExtensionsTests builder.AddSecureConfig(config => { config.UseCustomStorage(_mockStorageProvider.Object); - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); config.WithAesCryptoProvider(); }); builder.AddSecureConfig(config => { config.UseCustomStorage(_mockStorageProvider.Object); - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); config.WithAesCryptoProvider(); }); @@ -263,7 +247,7 @@ public class SecureConfigExtensionsTests var act = () => services.AddSecureConfig(config => { - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); config.WithAesCryptoProvider(); }); @@ -294,7 +278,7 @@ public class SecureConfigExtensionsTests var act = () => services.AddSecureConfig(config => { config.UseCustomStorage(_mockStorageProvider.Object); - config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); }); act.Should().Throw() @@ -498,102 +482,73 @@ public class SecureConfigExtensionsTests [Fact] public async Task AddSecureConfig_ServiceCollection_WithRealAesCryptoProvider_ItShouldWorkEndToEnd() { - var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var filePath = Path.Combine(tempDir, "test_config.json"); + using var tempDir = new TempDirectory(); + var filePath = Path.Combine(tempDir.Path, "test_config.json"); - try + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var base64Key = Convert.ToBase64String(keyBytes); + + var services = new ServiceCollection(); + + services.AddSecureConfig(config => { - Directory.CreateDirectory(tempDir); - - var keyBytes = new byte[32]; - RandomNumberGenerator.Fill(keyBytes); - var base64Key = Convert.ToBase64String(keyBytes); - - var services = new ServiceCollection(); - - services.AddSecureConfig(config => + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => { - config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); - config.UseJsonFileStorage(options => - { - options.DirectoryPath = tempDir; - options.FileName = "test_config.json"; - }); - config.WithBase64EncryptionKey(base64Key); - config.WithAesCryptoProvider(); + options.DirectoryPath = tempDir.Path; + options.FileName = "test_config.json"; }); + config.WithBase64EncryptionKey(base64Key); + config.WithAesCryptoProvider(); + }); - var serviceProvider = services.BuildServiceProvider(); - var secureConfig = serviceProvider.GetRequiredService(); - var storageProvider = serviceProvider.GetRequiredService(); + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); + var storageProvider = serviceProvider.GetRequiredService(); - var testObject = new TestConfig { Name = "TestName", Value = 123 }; - await secureConfig.SetAsync("TestKey", testObject); + var testObject = new TestConfig { Name = "TestName", Value = 123 }; + await secureConfig.SetAsync("TestKey", testObject); - var retrievedObject = await secureConfig.GetAsync("TestKey"); + var retrievedObject = await secureConfig.GetAsync("TestKey"); - retrievedObject.Should().NotBeNull(); - retrievedObject.Name.Should().Be("TestName"); - retrievedObject.Value.Should().Be(123); + retrievedObject.Should().NotBeNull(); + retrievedObject.Name.Should().Be("TestName"); + retrievedObject.Value.Should().Be(123); - File.Exists(filePath).Should().BeTrue(); - } - finally - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, true); - } - } + File.Exists(filePath).Should().BeTrue(); } [Fact] public async Task AddSecureConfig_ServiceCollection_WithMachineIdKey_ItShouldWorkEndToEnd() { - var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + using var tempDir = new TempDirectory(); - try + var services = new ServiceCollection(); + + services.AddSecureConfig(config => { - Directory.CreateDirectory(tempDir); - - var services = new ServiceCollection(); - - services.AddSecureConfig(config => + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => { - config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); - config.UseJsonFileStorage(options => - { - options.DirectoryPath = tempDir; - options.FileName = "test_config.json"; - }); - config.WithMachineIdKey(); - config.WithAesCryptoProvider(); + options.DirectoryPath = tempDir.Path; + options.FileName = "test_config.json"; }); + config.WithMachineIdKey(); + config.WithAesCryptoProvider(); + }); - var serviceProvider = services.BuildServiceProvider(); - var secureConfig = serviceProvider.GetRequiredService(); + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); - var testObject = new TestConfig { Name = "MachineIdTest", Value = 456 }; - await secureConfig.SetAsync("MachineTest", testObject); + var testObject = new TestConfig { Name = "MachineIdTest", Value = 456 }; + await secureConfig.SetAsync("MachineTest", testObject); - var retrievedObject = await secureConfig.GetAsync("MachineTest"); + var retrievedObject = await secureConfig.GetAsync("MachineTest"); - retrievedObject.Should().NotBeNull(); - retrievedObject.Name.Should().Be("MachineIdTest"); - retrievedObject.Value.Should().Be(456); - } - finally - { - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, true); - } - } + retrievedObject.Should().NotBeNull(); + retrievedObject.Name.Should().Be("MachineIdTest"); + retrievedObject.Value.Should().Be(456); } [Fact] @@ -744,13 +699,6 @@ public class SecureConfigExtensionsTests configuration["Section1:Setting1"].Should().Be("Value1"); configuration["Section2:Setting2"].Should().Be("Value2"); } - - private static string GetValidBase64Key() - { - var keyBytes = new byte[32]; - RandomNumberGenerator.Fill(keyBytes); - return Convert.ToBase64String(keyBytes); - } } internal sealed class TestConfig diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs index 922e827..9235e7e 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs @@ -1,22 +1,20 @@ using StevanFreeborn.Extensions.Configuration.Secure.Storage; +using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage; public class JsonFileStorageProviderTests : IDisposable { - private readonly string _tmpDirectory; + private readonly TempDirectory _tempDir = new(); private readonly JsonStorageOptions _options; private readonly JsonFileStorageProvider _sut; public JsonFileStorageProviderTests() { - _tmpDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - Directory.CreateDirectory(_tmpDirectory); - _options = new() { FileName = "testsettings.json", - DirectoryPath = _tmpDirectory, + DirectoryPath = _tempDir.Path, }; _sut = new(_options); @@ -72,11 +70,7 @@ public class JsonFileStorageProviderTests : IDisposable public void Dispose() { - if (Directory.Exists(_tmpDirectory)) - { - Directory.Delete(_tmpDirectory, true); - } - + _tempDir.Dispose(); GC.SuppressFinalize(this); } From 12672c9b21bffe133084be5243ed68ac5e7f7119 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:48:55 -0500 Subject: [PATCH 45/55] fix: correct nameof parameter in SecureConfigProvider null check --- .../Configuration/SecureConfigProvider.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs index 3c234c1..16c1dea 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs @@ -23,14 +23,16 @@ internal class SecureConfigProvider : ConfigurationProvider, IDisposable { _storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider)); _cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(cryptoProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _storageProvider.StorageChanged += HandleStorageChangedAsync; } public override void Load() { +#pragma warning disable CA1849 // Call async methods when in an async method var encryptedData = _storageProvider.ReadAllAsync().GetAwaiter().GetResult(); +#pragma warning restore CA1849 // Call async methods when in an async method Data = ProcessAndDecryptData(encryptedData); } From 04041d0401e5b98e90a25196bcd757753555a539 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:48:58 -0500 Subject: [PATCH 46/55] docs: document empty/whitespace passthrough behavior in ICryptoProvider --- .../Cryptography/ICryptoProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs index b9ff163..90a9263 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs @@ -9,13 +9,13 @@ public interface ICryptoProvider /// Encrypts the provided plain text. /// /// The unencrypted string to be encrypted. - /// The encrypted cipher text. + /// The encrypted cipher text. If is null, empty, or whitespace, it is returned unchanged. string Encrypt(string plainText); /// /// Decrypts the provided cipher text. /// /// The encrypted string to be decrypted. - /// The decrypted plain text. + /// The decrypted plain text. If is null, empty, or whitespace, it is returned unchanged. string Decrypt(string cipherText); } \ No newline at end of file From 54abcb672d8984da74ceff3119c2af7684b75b18 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:01 -0500 Subject: [PATCH 47/55] perf: replace static shared lock with per-instance lock and async delay --- .../Storage/JsonFileStorageProvider.cs | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 5e336b0..1368331 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -7,7 +7,7 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; internal sealed class JsonFileStorageProvider : ISecureStorageProvider { - private static readonly SemaphoreSlim FileLock = new(1, 1); + private readonly SemaphoreSlim _fileLock = new(1, 1); private readonly PhysicalFileProvider? _fileProvider; private readonly IDisposable? _changeTokenRegistration; private readonly JsonStorageOptions _options; @@ -31,11 +31,7 @@ internal sealed class JsonFileStorageProvider : ISecureStorageProvider _changeTokenRegistration = ChangeToken.OnChange( () => _fileProvider.Watch(_options.FileName), - () => - { - Thread.Sleep(250); - StorageChanged?.Invoke(this, EventArgs.Empty); - } + () => _ = NotifyStorageChangedAsync() ); } } @@ -59,7 +55,7 @@ internal sealed class JsonFileStorageProvider : ISecureStorageProvider public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) { - await FileLock.WaitAsync(ct).ConfigureAwait(false); + await _fileLock.WaitAsync(ct).ConfigureAwait(false); try { @@ -69,13 +65,13 @@ internal sealed class JsonFileStorageProvider : ISecureStorageProvider } finally { - FileLock.Release(); + _fileLock.Release(); } } public async Task DeleteAsync(string key, CancellationToken ct = default) { - await FileLock.WaitAsync(ct).ConfigureAwait(false); + await _fileLock.WaitAsync(ct).ConfigureAwait(false); try { @@ -86,7 +82,7 @@ internal sealed class JsonFileStorageProvider : ISecureStorageProvider } finally { - FileLock.Release(); + _fileLock.Release(); } } @@ -94,11 +90,12 @@ internal sealed class JsonFileStorageProvider : ISecureStorageProvider { _changeTokenRegistration?.Dispose(); _fileProvider?.Dispose(); + _fileLock.Dispose(); } private async Task> AcquireLockAndLoadAsync(CancellationToken ct) { - await FileLock.WaitAsync(ct).ConfigureAwait(false); + await _fileLock.WaitAsync(ct).ConfigureAwait(false); try { @@ -106,10 +103,16 @@ internal sealed class JsonFileStorageProvider : ISecureStorageProvider } finally { - FileLock.Release(); + _fileLock.Release(); } } + private async Task NotifyStorageChangedAsync() + { + await Task.Delay(250).ConfigureAwait(false); + StorageChanged?.Invoke(this, EventArgs.Empty); + } + private async Task> LoadAsync(CancellationToken ct) { if (File.Exists(_options.FullPath) is false) From ed2a1f519476bf357c1ce5c5d9e2ac6360ba8e58 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:11 -0500 Subject: [PATCH 48/55] fix: add null guard to WithCustomCryptoProvider in SecureConfigBuilder --- .../Configuration/SecureConfigBuilder.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs index cba3650..b248bdf 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs @@ -91,12 +91,20 @@ internal sealed class SecureConfigBuilder : ISecureConfigBuilder public ISecureConfigBuilder WithAesCryptoProvider() { - CryptoProviderFactory = (kp) => new AesCryptoProvider(kp); + CryptoProviderFactory = keyProvider => new AesCryptoProvider(keyProvider); return this; } public ISecureConfigBuilder WithCustomCryptoProvider(Func cryptoProviderFactory) { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(cryptoProviderFactory); +#else + if (cryptoProviderFactory is null) + { + throw new ArgumentNullException(nameof(cryptoProviderFactory)); + } +#endif CryptoProviderFactory = cryptoProviderFactory; return this; } From 9faf29a65789fe261b0179a533cee21d618c5678 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:14 -0500 Subject: [PATCH 49/55] docs: fix typo and formatting in ISecureConfigBuilder XML comments --- .../Configuration/ISecureConfigBuilder.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs index d8ff72d..b3621a5 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs @@ -53,7 +53,6 @@ public interface ISecureConfigBuilder /// The current instance for method chaining. ISecureConfigBuilder WithMachineIdKey(); - /// /// Configures a custom encryption key provider. /// @@ -69,7 +68,7 @@ public interface ISecureConfigBuilder ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory); /// - /// Configures AES crypto provider for encryption and decryption + /// Configures AES crypto provider for encryption and decryption. /// /// The current instance for method chaining. ISecureConfigBuilder WithAesCryptoProvider(); @@ -77,7 +76,7 @@ public interface ISecureConfigBuilder /// /// Configures the factory function that will be used to create the crypto provider for encryption and decryption /// - /// The crypto provider factor to use for encryption and decryption operations. + /// The crypto provider factory to use for encryption and decryption operations. /// The current instance for method chaining. ISecureConfigBuilder WithCustomCryptoProvider(Func cryptoProviderFactory); } \ No newline at end of file From a15e01146761b249b9a53e7b0d61a1915db7350e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:17 -0500 Subject: [PATCH 50/55] docs: fill in empty XML comment for StorageChanged event --- .../Storage/ISecureStorageProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs index 9c45878..e82ff2c 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs @@ -6,7 +6,7 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; public interface ISecureStorageProvider : IDisposable { /// - /// + /// Occurs when the underlying storage has changed and the configuration should be reloaded. /// event EventHandler StorageChanged; From 95e2cf5916326c26dd393d6a668e7bbe1f145ac2 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:20 -0500 Subject: [PATCH 51/55] style: remove unnecessary @ prefix from bytes variable --- .../Cryptography/MachineIdKeyProvider.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs index a42bc19..29b6b87 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs @@ -10,12 +10,12 @@ internal sealed class MachineIdKeyProvider(IMachineIdKeyGenerator generator) : I public byte[] GetKey() { var machineId = _generator.GetId(); - var @bytes = Encoding.UTF8.GetBytes(machineId); + var bytes = Encoding.UTF8.GetBytes(machineId); #if NET5_0_OR_GREATER - return SHA256.HashData(@bytes); + return SHA256.HashData(bytes); #else using var sha256 = SHA256.Create(); - return sha256.ComputeHash(@bytes); + return sha256.ComputeHash(bytes); #endif } } \ No newline at end of file From 258c3dfed5553fe1d49a6ace4604e8a7ace590fe Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:23 -0500 Subject: [PATCH 52/55] style: use consistent .Invoke style for action invocation --- .../SecureConfigExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs index 3221b9a..d8ea9db 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs @@ -100,7 +100,7 @@ public static class SecureConfigExtensions var configBuilder = new SecureConfigBuilder(); - configure(configBuilder); + configure.Invoke(configBuilder); if (configBuilder.StorageProvider is null) { From 0eefe1ce6ad17d607a0e8fdc5ca8efa5029347c6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:25 -0500 Subject: [PATCH 53/55] test: update WithCustomCryptoProvider null test to expect ArgumentNullException --- .../Unit/Configuration/SecureConfigBuilderTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs index 2ebe46b..f2219df 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs @@ -265,11 +265,12 @@ public class SecureConfigBuilderTests } [Fact] - public void WithCustomCryptoProvider_WithNullFactory_ItShouldSetNull() + public void WithCustomCryptoProvider_WithNullFactory_ItShouldThrowArgumentNullException() { - _sut.WithCustomCryptoProvider(null!); + var act = () => _sut.WithCustomCryptoProvider(null!); - _sut.CryptoProviderFactory.Should().BeNull(); + act.Should().Throw() + .WithParameterName("cryptoProviderFactory"); } [Fact] From b61454b118815ec6584681405c25503b68ef3c6b Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:28 -0500 Subject: [PATCH 54/55] style: format SetAsync call to single line in sample Program --- .../Program.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs index ab32228..e58234a 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs @@ -44,10 +44,7 @@ Console.WriteLine($"IOptions: {options.Value}"); Console.WriteLine($"IOptionsSnapshot 1: {firstSnapshot.Value}"); Console.WriteLine($"IOptionsMonitor: {optionsMonitor.CurrentValue}"); -await secureConfig.SetAsync( - nameof(ApiOptions), - new ApiOptions { ApiKey = Guid.NewGuid().ToString() } -); +await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = Guid.NewGuid().ToString() }); var config = (IConfigurationRoot)app.Services.GetRequiredService(); config.Reload(); From 30e392020b688761cba5144b5c6bd7c66d12b2d4 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:54:28 -0500 Subject: [PATCH 55/55] docs: flush out sample console app --- .../ApiOptions.cs | 2 +- .../AppJsonContext.cs | 4 +- .../DatabaseSettings.cs | 8 + .../Program.cs | 460 ++++++++++++++++-- .../SmtpSettings.cs | 10 + 5 files changed, 443 insertions(+), 41 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs index 2b7fd41..43d966e 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs @@ -3,4 +3,4 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; public sealed record ApiOptions { public string ApiKey { get; init; } = string.Empty; -} \ No newline at end of file +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs index dd63a7a..29eccc9 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization; namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; [JsonSerializable(typeof(ApiOptions))] +[JsonSerializable(typeof(DatabaseSettings))] +[JsonSerializable(typeof(SmtpSettings))] internal partial class AppJsonContext : JsonSerializerContext { -} \ No newline at end of file +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs new file mode 100644 index 0000000..c627a77 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs @@ -0,0 +1,8 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; + +public sealed record DatabaseSettings +{ + public string ConnectionString { get; init; } = string.Empty; + public int Timeout { get; init; } + public int RetryCount { get; init; } +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs index e58234a..cdfe47e 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs @@ -1,59 +1,441 @@ -using Microsoft.Extensions.Configuration; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StevanFreeborn.Extensions.Configuration.Secure; using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; using StevanFreeborn.Extensions.Configuration.Secure.Sample; using StevanFreeborn.Extensions.Configuration.Secure.Storage; -Action configure = builder => -{ - builder - .WithMachineIdKey() - .WithAesCryptoProvider() - .UseJsonFileStorage(new JsonStorageOptions()) - .AddJsonAotContext(AppJsonContext.Default); -}; +Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ StevanFreeborn.Extensions.Configuration.Secure Sample ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); +Console.WriteLine(); -var builder = Host.CreateDefaultBuilder() - .ConfigureAppConfiguration((_, b) => +await Demo1_BasicFileStorageWithBase64Key(); +await Demo2_MachineIdKeyDerivation(); +await Demo3_HostAndIOptionsIntegration(); +await Demo4_ConfigurationProviderPattern(); +await Demo5_CustomStorageProvider(); +await Demo6_CustomKeyProvider(); +await Demo7_TypedAOTOverloads(); + +Console.WriteLine("All demos completed."); + +static async Task Demo1_BasicFileStorageWithBase64Key() +{ + PrintHeader("Demo 1: Basic File Storage with Base64 Key"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => { - b.AddSecureConfig(configure); - }) - .ConfigureServices((ctx, s) => - { - s.Configure(ctx.Configuration.GetSection(nameof(ApiOptions))); - s.AddSecureConfig(configure); + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo1.json", + }) + .AddJsonAotContext(AppJsonContext.Default); }); -var app = builder.Build(); + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); -var options = app.Services.GetRequiredService>(); -var optionsMonitor = app.Services.GetRequiredService>(); -var secureConfig = app.Services.GetRequiredService(); -var scopeFactory = app.Services.GetRequiredService(); + var dbSettings = new DatabaseSettings + { + ConnectionString = "Server=localhost;Database=MyDb;Trusted_Connection=True;", + Timeout = 30, + RetryCount = 3, + }; -var firstScope = scopeFactory.CreateScope(); -var firstSnapshot = firstScope.ServiceProvider.GetRequiredService>(); + Console.WriteLine(" Storing DatabaseSettings..."); + await secureConfig.SetAsync("Database", dbSettings); -var originalValue = await secureConfig.GetAsync(nameof(ApiOptions)); -Console.WriteLine($"Config: {originalValue}"); -Console.WriteLine($"IOptions: {options.Value}"); -Console.WriteLine($"IOptionsSnapshot 1: {firstSnapshot.Value}"); -Console.WriteLine($"IOptionsMonitor: {optionsMonitor.CurrentValue}"); + var retrieved = await secureConfig.GetAsync("Database"); + Console.WriteLine($" Retrieved: ConnectionString={retrieved!.ConnectionString}"); + Console.WriteLine($" Retrieved: Timeout={retrieved.Timeout}, RetryCount={retrieved.RetryCount}"); -await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = Guid.NewGuid().ToString() }); + var smtpSettings = new SmtpSettings + { + Host = "smtp.example.com", + Port = 587, + Username = "user@example.com", + Password = "s3cretP@ssw0rd!", + UseSsl = true, + }; -var config = (IConfigurationRoot)app.Services.GetRequiredService(); -config.Reload(); + Console.WriteLine(" Storing SmtpSettings (sensitive data)..."); + await secureConfig.SetAsync("Smtp", smtpSettings); -var updatedValue = await secureConfig.GetAsync(nameof(ApiOptions)); -var secondScope = scopeFactory.CreateScope(); -var secondSnapshot = secondScope.ServiceProvider.GetRequiredService>(); + var smtp = await secureConfig.GetAsync("Smtp"); + Console.WriteLine($" Retrieved: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}"); + Console.WriteLine($" Retrieved: Username={smtp.Username}, Password={smtp.Password}"); -Console.WriteLine($"Config: {updatedValue}"); -Console.WriteLine($"IOptions: {options.Value}"); -Console.WriteLine($"IOptionsSnapshot 2: {secondSnapshot.Value}"); -Console.WriteLine($"IOptionsMonitor: {optionsMonitor.CurrentValue}"); \ No newline at end of file + Console.WriteLine(" Deleting Smtp settings..."); + var deleted = await secureConfig.DeleteAsync("Smtp"); + Console.WriteLine($" Deleted: {deleted}"); + + var missing = await secureConfig.GetAsync("Smtp"); + Console.WriteLine($" After delete: {(missing is null ? "null (as expected)" : "still present")}"); + + Console.WriteLine(); +} + +static async Task Demo2_MachineIdKeyDerivation() +{ + PrintHeader("Demo 2: Machine ID Key Derivation"); + + using var tempDir = new TempDirectory(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithMachineIdKey() + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo2.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + Console.WriteLine(" Storing config encrypted with machine-derived key..."); + await secureConfig.SetAsync("MachineLocked", new ApiOptions { ApiKey = "machine-specific-secret" }); + + var value = await secureConfig.GetAsync("MachineLocked"); + Console.WriteLine($" Retrieved on same machine: ApiKey={value!.ApiKey}"); + Console.WriteLine(" (This data would NOT be decryptable on a different machine)"); + + Console.WriteLine(); +} + +static async Task Demo3_HostAndIOptionsIntegration() +{ + PrintHeader("Demo 3: Host + IOptions / IOptionsSnapshot / IOptionsMonitor"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + Action configure = builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo3.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }; + + var host = await Host.CreateDefaultBuilder() + .ConfigureAppConfiguration((_, b) => b.AddSecureConfig(configure)) + .ConfigureServices((ctx, s) => + { + s.Configure(ctx.Configuration.GetSection(nameof(ApiOptions))); + s.AddSecureConfig(configure); + }) + .StartAsync(); + + var secureConfig = host.Services.GetRequiredService(); + var options = host.Services.GetRequiredService>(); + var monitor = host.Services.GetRequiredService>(); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + + Console.WriteLine(" Setting initial ApiOptions..."); + await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "initial-key-001" }); + config.Reload(); + + Console.WriteLine($" IOptions: {options.Value.ApiKey}"); + Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey}"); + + Console.WriteLine(" Updating ApiOptions and reloading config..."); + await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "updated-key-002" }); + config.Reload(); + + Console.WriteLine($" IOptions: {options.Value.ApiKey} (unchanged - singleton)"); + Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey} (updated)"); + + using (var scope1 = host.Services.CreateScope()) + { + var snapshot1 = scope1.ServiceProvider.GetRequiredService>(); + Console.WriteLine($" IOptionsSnapshot 1: {snapshot1.Value.ApiKey} (new scope, sees update)"); + } + + await host.StopAsync(); + Console.WriteLine(); +} + +static async Task Demo4_ConfigurationProviderPattern() +{ + PrintHeader("Demo 4: IConfigurationBuilder Pattern (No DI)"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + var seedServices = new ServiceCollection(); + seedServices.AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo4.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var seedProvider = seedServices.BuildServiceProvider(); + var secureConfig = seedProvider.GetRequiredService(); + + Console.WriteLine(" Seeding data via ISecureConfig..."); + await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "from-config-builder" }); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo4.json", + }); + }) + .Build(); + + var apiKey = configuration["ApiOptions:ApiKey"]; + Console.WriteLine($" Read via IConfiguration: ApiOptions:ApiKey = {apiKey}"); + + Console.WriteLine(); +} + +static async Task Demo5_CustomStorageProvider() +{ + PrintHeader("Demo 5: Custom Storage Provider (In-Memory)"); + + var key = GenerateBase64Key(); + var memoryStore = new Dictionary(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseCustomStorage(new InMemoryStorageProvider(memoryStore)) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + Console.WriteLine(" Storing ApiOptions in in-memory storage..."); + await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "in-memory-secret" }); + + Console.WriteLine($" Memory store now has {memoryStore.Count} encrypted entry(ies)"); + Console.WriteLine($" Encrypted value starts with: {memoryStore["ApiOptions"][..40]}..."); + + var retrieved = await secureConfig.GetAsync("ApiOptions"); + Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}"); + + Console.WriteLine(); +} + +static async Task Demo6_CustomKeyProvider() +{ + PrintHeader("Demo 6: Custom Key Provider"); + + using var tempDir = new TempDirectory(); + + var customKeyProvider = new EnvironmentVariableKeyProvider("MY_SECURE_CONFIG_KEY"); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithCustomKeyProvider(customKeyProvider) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo6.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + Console.WriteLine(" Storing ApiOptions with custom key from environment variable..."); + await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "env-var-protected" }); + + var retrieved = await secureConfig.GetAsync("ApiOptions"); + Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}"); + + Console.WriteLine(); +} + +static async Task Demo7_TypedAOTOverloads() +{ + PrintHeader("Demo 7: Typed AOT Overloads (Native AOT Support)"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo7.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + JsonTypeInfo dbTypeInfo = AppJsonContext.Default.DatabaseSettings; + JsonTypeInfo smtpTypeInfo = AppJsonContext.Default.SmtpSettings; + + Console.WriteLine(" Setting values using explicit JsonTypeInfo overloads..."); + await secureConfig.SetAsync( + "Database", + new DatabaseSettings { ConnectionString = "Server=prod;Database=Main;", Timeout = 60, RetryCount = 5 }, + dbTypeInfo + ); + + await secureConfig.SetAsync( + "Smtp", + new SmtpSettings { Host = "smtp.prod.com", Port = 465, Username = "admin", Password = "prod!", UseSsl = true }, + smtpTypeInfo + ); + + Console.WriteLine(" Getting values using explicit JsonTypeInfo overloads..."); + var db = await secureConfig.GetAsync("Database", dbTypeInfo); + var smtp = await secureConfig.GetAsync("Smtp", smtpTypeInfo); + + Console.WriteLine($" Database: ConnectionString={db!.ConnectionString}, Timeout={db.Timeout}"); + Console.WriteLine($" Smtp: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}"); + + Console.WriteLine(); +} + +static string GenerateBase64Key() +{ + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + return Convert.ToBase64String(key); +} + +static void PrintHeader(string title) +{ + Console.WriteLine($"── {title} ──"); +} + +sealed class TempDirectory : IDisposable +{ + public string Path { get; } + + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } +} + +sealed class InMemoryStorageProvider : ISecureStorageProvider +{ + private readonly Dictionary _store; + + public InMemoryStorageProvider(Dictionary store) + { + _store = store; + } + + public event EventHandler? StorageChanged; + + public Task ReadAsync(string key, CancellationToken ct = default) + { + return _store.TryGetValue(key, out var value) ? Task.FromResult(value) : Task.FromResult(string.Empty); + } + + public Task> ReadAllAsync(CancellationToken ct = default) + { + return Task.FromResult>(new Dictionary(_store)); + } + + public Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) + { + _store[key] = encryptedData; + StorageChanged?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + + public Task DeleteAsync(string key, CancellationToken ct = default) + { + return Task.FromResult(_store.Remove(key)); + } + + public void Dispose() + { + } +} + +sealed class EnvironmentVariableKeyProvider : IEncryptionKeyProvider +{ + private readonly string _variableName; + + public EnvironmentVariableKeyProvider(string variableName) + { + _variableName = variableName; + } + + public byte[] GetKey() + { + var value = Environment.GetEnvironmentVariable(_variableName); + + if (string.IsNullOrWhiteSpace(value)) + { + Console.WriteLine($" [Warning] Environment variable '{_variableName}' not set. Using SHA256 hash of variable name as key."); + value = _variableName; + } + + return SHA256.HashData(Encoding.UTF8.GetBytes(value)); + } +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs new file mode 100644 index 0000000..7d52e0d --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs @@ -0,0 +1,10 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; + +public sealed record SmtpSettings +{ + public string Host { get; init; } = string.Empty; + public int Port { get; init; } + public string Username { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; + public bool UseSsl { get; init; } +}