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