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/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..e5079f4
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,6 @@
+{
+ "cSpell.words": [
+ "ioreg",
+ "netstandard"
+ ]
+}
\ No newline at end of file
diff --git a/StevanFreeborn.SecureConfig.slnx b/StevanFreeborn.SecureConfig.slnx
index ba788ff..27522a1 100644
--- a/StevanFreeborn.SecureConfig.slnx
+++ b/StevanFreeborn.SecureConfig.slnx
@@ -1,2 +1,9 @@
+
+
+
+
+
+
+
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..8287d38
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.201",
+ "rollForward": "latestFeature"
+ }
+}
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..43d966e
--- /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;
+}
diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs
new file mode 100644
index 0000000..29eccc9
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
+
+[JsonSerializable(typeof(ApiOptions))]
+[JsonSerializable(typeof(DatabaseSettings))]
+[JsonSerializable(typeof(SmtpSettings))]
+internal partial class AppJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs
new file mode 100644
index 0000000..c627a77
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs
@@ -0,0 +1,8 @@
+namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
+
+public sealed record DatabaseSettings
+{
+ public string ConnectionString { get; init; } = string.Empty;
+ public int Timeout { get; init; }
+ public int RetryCount { get; init; }
+}
diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs
new file mode 100644
index 0000000..cdfe47e
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs
@@ -0,0 +1,441 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+using StevanFreeborn.Extensions.Configuration.Secure;
+using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
+using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
+using StevanFreeborn.Extensions.Configuration.Secure.Sample;
+using StevanFreeborn.Extensions.Configuration.Secure.Storage;
+
+Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
+Console.WriteLine("║ StevanFreeborn.Extensions.Configuration.Secure Sample ║");
+Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
+Console.WriteLine();
+
+await Demo1_BasicFileStorageWithBase64Key();
+await Demo2_MachineIdKeyDerivation();
+await Demo3_HostAndIOptionsIntegration();
+await Demo4_ConfigurationProviderPattern();
+await Demo5_CustomStorageProvider();
+await Demo6_CustomKeyProvider();
+await Demo7_TypedAOTOverloads();
+
+Console.WriteLine("All demos completed.");
+
+static async Task Demo1_BasicFileStorageWithBase64Key()
+{
+ PrintHeader("Demo 1: Basic File Storage with Base64 Key");
+
+ using var tempDir = new TempDirectory();
+ var key = GenerateBase64Key();
+
+ var services = new ServiceCollection();
+ services.AddSecureConfig(builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(key)
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = tempDir.Path,
+ FileName = "demo1.json",
+ })
+ .AddJsonAotContext(AppJsonContext.Default);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ var dbSettings = new DatabaseSettings
+ {
+ ConnectionString = "Server=localhost;Database=MyDb;Trusted_Connection=True;",
+ Timeout = 30,
+ RetryCount = 3,
+ };
+
+ Console.WriteLine(" Storing DatabaseSettings...");
+ await secureConfig.SetAsync("Database", dbSettings);
+
+ var retrieved = await secureConfig.GetAsync("Database");
+ Console.WriteLine($" Retrieved: ConnectionString={retrieved!.ConnectionString}");
+ Console.WriteLine($" Retrieved: Timeout={retrieved.Timeout}, RetryCount={retrieved.RetryCount}");
+
+ var smtpSettings = new SmtpSettings
+ {
+ Host = "smtp.example.com",
+ Port = 587,
+ Username = "user@example.com",
+ Password = "s3cretP@ssw0rd!",
+ UseSsl = true,
+ };
+
+ Console.WriteLine(" Storing SmtpSettings (sensitive data)...");
+ await secureConfig.SetAsync("Smtp", smtpSettings);
+
+ var smtp = await secureConfig.GetAsync("Smtp");
+ Console.WriteLine($" Retrieved: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}");
+ Console.WriteLine($" Retrieved: Username={smtp.Username}, Password={smtp.Password}");
+
+ Console.WriteLine(" Deleting Smtp settings...");
+ var deleted = await secureConfig.DeleteAsync("Smtp");
+ Console.WriteLine($" Deleted: {deleted}");
+
+ var missing = await secureConfig.GetAsync("Smtp");
+ Console.WriteLine($" After delete: {(missing is null ? "null (as expected)" : "still present")}");
+
+ Console.WriteLine();
+}
+
+static async Task Demo2_MachineIdKeyDerivation()
+{
+ PrintHeader("Demo 2: Machine ID Key Derivation");
+
+ using var tempDir = new TempDirectory();
+
+ var services = new ServiceCollection();
+ services.AddSecureConfig(builder =>
+ {
+ builder
+ .WithMachineIdKey()
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = tempDir.Path,
+ FileName = "demo2.json",
+ })
+ .AddJsonAotContext(AppJsonContext.Default);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ Console.WriteLine(" Storing config encrypted with machine-derived key...");
+ await secureConfig.SetAsync("MachineLocked", new ApiOptions { ApiKey = "machine-specific-secret" });
+
+ var value = await secureConfig.GetAsync("MachineLocked");
+ Console.WriteLine($" Retrieved on same machine: ApiKey={value!.ApiKey}");
+ Console.WriteLine(" (This data would NOT be decryptable on a different machine)");
+
+ Console.WriteLine();
+}
+
+static async Task Demo3_HostAndIOptionsIntegration()
+{
+ PrintHeader("Demo 3: Host + IOptions / IOptionsSnapshot / IOptionsMonitor");
+
+ using var tempDir = new TempDirectory();
+ var key = GenerateBase64Key();
+
+ Action configure = builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(key)
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = tempDir.Path,
+ FileName = "demo3.json",
+ })
+ .AddJsonAotContext(AppJsonContext.Default);
+ };
+
+ var host = await Host.CreateDefaultBuilder()
+ .ConfigureAppConfiguration((_, b) => b.AddSecureConfig(configure))
+ .ConfigureServices((ctx, s) =>
+ {
+ s.Configure(ctx.Configuration.GetSection(nameof(ApiOptions)));
+ s.AddSecureConfig(configure);
+ })
+ .StartAsync();
+
+ var secureConfig = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>();
+ var monitor = host.Services.GetRequiredService>();
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+
+ Console.WriteLine(" Setting initial ApiOptions...");
+ await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "initial-key-001" });
+ config.Reload();
+
+ Console.WriteLine($" IOptions: {options.Value.ApiKey}");
+ Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey}");
+
+ Console.WriteLine(" Updating ApiOptions and reloading config...");
+ await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "updated-key-002" });
+ config.Reload();
+
+ Console.WriteLine($" IOptions: {options.Value.ApiKey} (unchanged - singleton)");
+ Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey} (updated)");
+
+ using (var scope1 = host.Services.CreateScope())
+ {
+ var snapshot1 = scope1.ServiceProvider.GetRequiredService>();
+ Console.WriteLine($" IOptionsSnapshot 1: {snapshot1.Value.ApiKey} (new scope, sees update)");
+ }
+
+ await host.StopAsync();
+ Console.WriteLine();
+}
+
+static async Task Demo4_ConfigurationProviderPattern()
+{
+ PrintHeader("Demo 4: IConfigurationBuilder Pattern (No DI)");
+
+ using var tempDir = new TempDirectory();
+ var key = GenerateBase64Key();
+
+ var seedServices = new ServiceCollection();
+ seedServices.AddSecureConfig(builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(key)
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = tempDir.Path,
+ FileName = "demo4.json",
+ })
+ .AddJsonAotContext(AppJsonContext.Default);
+ });
+
+ var seedProvider = seedServices.BuildServiceProvider();
+ var secureConfig = seedProvider.GetRequiredService();
+
+ Console.WriteLine(" Seeding data via ISecureConfig...");
+ await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "from-config-builder" });
+
+ var configuration = new ConfigurationBuilder()
+ .AddSecureConfig(builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(key)
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = tempDir.Path,
+ FileName = "demo4.json",
+ });
+ })
+ .Build();
+
+ var apiKey = configuration["ApiOptions:ApiKey"];
+ Console.WriteLine($" Read via IConfiguration: ApiOptions:ApiKey = {apiKey}");
+
+ Console.WriteLine();
+}
+
+static async Task Demo5_CustomStorageProvider()
+{
+ PrintHeader("Demo 5: Custom Storage Provider (In-Memory)");
+
+ var key = GenerateBase64Key();
+ var memoryStore = new Dictionary();
+
+ var services = new ServiceCollection();
+ services.AddSecureConfig(builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(key)
+ .WithAesCryptoProvider()
+ .UseCustomStorage(new InMemoryStorageProvider(memoryStore))
+ .AddJsonAotContext(AppJsonContext.Default);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ Console.WriteLine(" Storing ApiOptions in in-memory storage...");
+ await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "in-memory-secret" });
+
+ Console.WriteLine($" Memory store now has {memoryStore.Count} encrypted entry(ies)");
+ Console.WriteLine($" Encrypted value starts with: {memoryStore["ApiOptions"][..40]}...");
+
+ var retrieved = await secureConfig.GetAsync("ApiOptions");
+ Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}");
+
+ Console.WriteLine();
+}
+
+static async Task Demo6_CustomKeyProvider()
+{
+ PrintHeader("Demo 6: Custom Key Provider");
+
+ using var tempDir = new TempDirectory();
+
+ var customKeyProvider = new EnvironmentVariableKeyProvider("MY_SECURE_CONFIG_KEY");
+
+ var services = new ServiceCollection();
+ services.AddSecureConfig(builder =>
+ {
+ builder
+ .WithCustomKeyProvider(customKeyProvider)
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = tempDir.Path,
+ FileName = "demo6.json",
+ })
+ .AddJsonAotContext(AppJsonContext.Default);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ Console.WriteLine(" Storing ApiOptions with custom key from environment variable...");
+ await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "env-var-protected" });
+
+ var retrieved = await secureConfig.GetAsync("ApiOptions");
+ Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}");
+
+ Console.WriteLine();
+}
+
+static async Task Demo7_TypedAOTOverloads()
+{
+ PrintHeader("Demo 7: Typed AOT Overloads (Native AOT Support)");
+
+ using var tempDir = new TempDirectory();
+ var key = GenerateBase64Key();
+
+ var services = new ServiceCollection();
+ services.AddSecureConfig(builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(key)
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = tempDir.Path,
+ FileName = "demo7.json",
+ })
+ .AddJsonAotContext(AppJsonContext.Default);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ JsonTypeInfo dbTypeInfo = AppJsonContext.Default.DatabaseSettings;
+ JsonTypeInfo smtpTypeInfo = AppJsonContext.Default.SmtpSettings;
+
+ Console.WriteLine(" Setting values using explicit JsonTypeInfo overloads...");
+ await secureConfig.SetAsync(
+ "Database",
+ new DatabaseSettings { ConnectionString = "Server=prod;Database=Main;", Timeout = 60, RetryCount = 5 },
+ dbTypeInfo
+ );
+
+ await secureConfig.SetAsync(
+ "Smtp",
+ new SmtpSettings { Host = "smtp.prod.com", Port = 465, Username = "admin", Password = "prod!", UseSsl = true },
+ smtpTypeInfo
+ );
+
+ Console.WriteLine(" Getting values using explicit JsonTypeInfo overloads...");
+ var db = await secureConfig.GetAsync("Database", dbTypeInfo);
+ var smtp = await secureConfig.GetAsync("Smtp", smtpTypeInfo);
+
+ Console.WriteLine($" Database: ConnectionString={db!.ConnectionString}, Timeout={db.Timeout}");
+ Console.WriteLine($" Smtp: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}");
+
+ Console.WriteLine();
+}
+
+static string GenerateBase64Key()
+{
+ var key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return Convert.ToBase64String(key);
+}
+
+static void PrintHeader(string title)
+{
+ Console.WriteLine($"── {title} ──");
+}
+
+sealed class TempDirectory : IDisposable
+{
+ public string Path { get; }
+
+ public TempDirectory()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(Path);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, true);
+ }
+ }
+}
+
+sealed class InMemoryStorageProvider : ISecureStorageProvider
+{
+ private readonly Dictionary _store;
+
+ public InMemoryStorageProvider(Dictionary store)
+ {
+ _store = store;
+ }
+
+ public event EventHandler? StorageChanged;
+
+ public Task ReadAsync(string key, CancellationToken ct = default)
+ {
+ return _store.TryGetValue(key, out var value) ? Task.FromResult(value) : Task.FromResult(string.Empty);
+ }
+
+ public Task> ReadAllAsync(CancellationToken ct = default)
+ {
+ return Task.FromResult>(new Dictionary(_store));
+ }
+
+ public Task WriteAsync(string key, string encryptedData, CancellationToken ct = default)
+ {
+ _store[key] = encryptedData;
+ StorageChanged?.Invoke(this, EventArgs.Empty);
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteAsync(string key, CancellationToken ct = default)
+ {
+ return Task.FromResult(_store.Remove(key));
+ }
+
+ public void Dispose()
+ {
+ }
+}
+
+sealed class EnvironmentVariableKeyProvider : IEncryptionKeyProvider
+{
+ private readonly string _variableName;
+
+ public EnvironmentVariableKeyProvider(string variableName)
+ {
+ _variableName = variableName;
+ }
+
+ public byte[] GetKey()
+ {
+ var value = Environment.GetEnvironmentVariable(_variableName);
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ Console.WriteLine($" [Warning] Environment variable '{_variableName}' not set. Using SHA256 hash of variable name as key.");
+ value = _variableName;
+ }
+
+ return SHA256.HashData(Encoding.UTF8.GetBytes(value));
+ }
+}
diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs
new file mode 100644
index 0000000..7d52e0d
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs
@@ -0,0 +1,10 @@
+namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
+
+public sealed record SmtpSettings
+{
+ public string Host { get; init; } = string.Empty;
+ public int Port { get; init; }
+ public string Username { get; init; } = string.Empty;
+ public string Password { get; init; } = string.Empty;
+ public bool UseSsl { get; init; }
+}
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..76e9b7c
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
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..cdcd00e
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfig.cs
@@ -0,0 +1,62 @@
+using System.Text.Json.Serialization.Metadata;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
+
+///
+/// Provides secure storage, retrieval, and deletion of configuration values.
+/// All values are automatically encrypted before storage and decrypted on retrieval.
+///
+public interface ISecureConfig
+{
+ ///
+ /// Serializes, encrypts, and stores a value for the specified key.
+ /// Resolves from the registered serializer options.
+ ///
+ /// The type of the value to store.
+ /// The configuration key.
+ /// The value to store.
+ /// A token to monitor for cancellation requests.
+ /// A task representing the asynchronous operation.
+ Task SetAsync(string key, T value, CancellationToken ct = default);
+
+ ///
+ /// Serializes, encrypts, and stores a value for the specified key using the provided type metadata.
+ /// This overload supports Native AOT by accepting pre-compiled .
+ ///
+ /// The type of the value to store.
+ /// The configuration key.
+ /// The value to store.
+ /// The JSON type metadata for source-generated serialization.
+ /// A token to monitor for cancellation requests.
+ /// A task representing the asynchronous operation.
+ Task SetAsync(string key, T value, JsonTypeInfo typeInfo, CancellationToken ct = default);
+
+ ///
+ /// Reads, decrypts, and deserializes a value for the specified key.
+ /// Resolves from the registered serializer options.
+ ///
+ /// The type of the value to retrieve.
+ /// The configuration key.
+ /// A token to monitor for cancellation requests.
+ /// The deserialized value, or default if the key does not exist.
+ Task GetAsync(string key, CancellationToken ct = default);
+
+ ///
+ /// Reads, decrypts, and deserializes a value for the specified key using the provided type metadata.
+ /// This overload supports Native AOT by accepting pre-compiled .
+ ///
+ /// The type of the value to retrieve.
+ /// The configuration key.
+ /// The JSON type metadata for source-generated serialization.
+ /// A token to monitor for cancellation requests.
+ /// The deserialized value, or default if the key does not exist.
+ Task GetAsync(string key, JsonTypeInfo typeInfo, CancellationToken ct = default);
+
+ ///
+ /// Deletes the value associated with the specified key from secure storage.
+ ///
+ /// The configuration key.
+ /// A token to monitor for cancellation requests.
+ /// true if the value was deleted; otherwise, false.
+ Task DeleteAsync(string key, CancellationToken ct = default);
+}
\ No newline at end of file
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..b3621a5
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs
@@ -0,0 +1,82 @@
+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 fluent builder interface for configuring secure configuration storage and encryption.
+///
+public interface ISecureConfigBuilder
+{
+ ///
+ /// Configures JSON file-based storage using the provided options instance.
+ ///
+ /// 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 factory 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/SecureConfig.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs
new file mode 100644
index 0000000..d744d44
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfig.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
+using StevanFreeborn.Extensions.Configuration.Secure.Storage;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
+
+internal sealed class SecureConfig(
+ ISecureStorageProvider storageProvider,
+ ICryptoProvider cryptoProvider,
+ JsonSerializerOptions jsonOptions
+) : ISecureConfig
+{
+ private readonly ISecureStorageProvider _storageProvider = storageProvider ??
+ throw new ArgumentNullException(nameof(storageProvider));
+
+ private readonly ICryptoProvider _cryptoProvider = cryptoProvider ??
+ throw new ArgumentNullException(nameof(cryptoProvider));
+
+ 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 ArgumentException("Cannot be null or whitespace.", nameof(key));
+ }
+
+ if (value is null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+
+ var json = JsonSerializer.Serialize(value, typeInfo);
+ var encryptedValue = _cryptoProvider.Encrypt(json);
+
+ await _storageProvider.WriteAsync(key, encryptedValue, ct).ConfigureAwait(false);
+ }
+
+ public Task GetAsync(string key, CancellationToken ct = default)
+ {
+ 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 ArgumentException("Cannot be null or whitespace.", 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, 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
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..b248bdf
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs
@@ -0,0 +1,111 @@
+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 = keyProvider => new AesCryptoProvider(keyProvider);
+ return this;
+ }
+
+ public ISecureConfigBuilder WithCustomCryptoProvider(Func cryptoProviderFactory)
+ {
+#if NET6_0_OR_GREATER
+ ArgumentNullException.ThrowIfNull(cryptoProviderFactory);
+#else
+ if (cryptoProviderFactory is null)
+ {
+ throw new ArgumentNullException(nameof(cryptoProviderFactory));
+ }
+#endif
+ CryptoProviderFactory = cryptoProviderFactory;
+ return this;
+ }
+}
\ No newline at end of file
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..16c1dea
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs
@@ -0,0 +1,126 @@
+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 : ConfigurationProvider, IDisposable
+{
+ private readonly ISecureStorageProvider _storageProvider;
+ private readonly ICryptoProvider _cryptoProvider;
+ private readonly ILogger _logger;
+
+ public SecureConfigProvider(
+ ISecureStorageProvider storageProvider,
+ ICryptoProvider cryptoProvider,
+ ILogger logger
+ )
+ {
+ _storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider));
+ _cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ _storageProvider.StorageChanged += HandleStorageChangedAsync;
+ }
+
+ public override void Load()
+ {
+#pragma warning disable CA1849 // Call async methods when in an async method
+ var encryptedData = _storageProvider.ReadAllAsync().GetAwaiter().GetResult();
+#pragma warning restore CA1849 // Call async methods when in an async method
+ Data = ProcessAndDecryptData(encryptedData);
+ }
+
+ public void Dispose()
+ {
+ _storageProvider.StorageChanged -= HandleStorageChangedAsync;
+ }
+
+ private async void HandleStorageChangedAsync(object? sender, EventArgs e)
+ {
+ try
+ {
+ var encryptedData = await _storageProvider.ReadAllAsync().ConfigureAwait(false);
+
+ var newData = ProcessAndDecryptData(encryptedData);
+
+ Data = newData;
+
+ OnReload();
+ }
+#pragma warning disable CA1031 // Do not catch general exception types
+ catch (Exception ex)
+#pragma warning restore CA1031 // Do not catch general exception types
+ {
+ _logger.LogFailedToReloadSecureConfig(ex);
+ }
+ }
+
+ private Dictionary ProcessAndDecryptData(IDictionary encryptedData)
+ {
+ var flattenedData = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var kvp in encryptedData)
+ {
+ 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);
+ }
+ }
+
+ return 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;
+ }
+ }
+}
\ 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/Cryptography/AesCryptoProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs
new file mode 100644
index 0000000..e5c6830
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/AesCryptoProvider.cs
@@ -0,0 +1,77 @@
+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));
+
+ public string Encrypt(string plainText)
+ {
+ 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];
+
+#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];
+ 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 payload. {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];
+
+#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);
+ }
+}
\ 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..90a9263
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/ICryptoProvider.cs
@@ -0,0 +1,21 @@
+namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
+
+///
+/// Defines the contract for cryptographic operations.
+///
+public interface ICryptoProvider
+{
+ ///
+ /// Encrypts the provided plain text.
+ ///
+ /// The unencrypted string to be encrypted.
+ /// The encrypted cipher text. If is null, empty, or whitespace, it is returned unchanged.
+ string Encrypt(string plainText);
+
+ ///
+ /// Decrypts the provided cipher text.
+ ///
+ /// The encrypted string to be decrypted.
+ /// The decrypted plain text. If is null, empty, or whitespace, it is returned unchanged.
+ 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
new file mode 100644
index 0000000..2886c5a
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/IEncryptionKeyProvider.cs
@@ -0,0 +1,13 @@
+namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
+
+///
+/// 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
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..bba0a56
--- /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();
+}
\ 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..239ea90
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyGenerator.cs
@@ -0,0 +1,92 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+using Microsoft.Extensions.Logging;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
+
+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}";
+ }
+}
\ 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
new file mode 100644
index 0000000..29b6b87
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Cryptography/MachineIdKeyProvider.cs
@@ -0,0 +1,21 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
+
+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);
+#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
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..cf2c8a0
--- /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 ArgumentException("Cannot be null or whitespace.", 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 is not 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/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs
new file mode 100644
index 0000000..4d15e60
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Logging/LogMessages.cs
@@ -0,0 +1,32 @@
+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.Warning,
+ Message = "Failed to decrypt value for key {Key}"
+ )]
+ public static partial void LogDecryptionFailure(this ILogger logger, Exception ex, string key);
+
+ [LoggerMessage(
+ EventId = 4,
+ Level = LogLevel.Warning,
+ Message = "Failed to asynchronously reload secure configuration. The previous configuration state will be maintained."
+ )]
+ public static partial void LogFailedToReloadSecureConfig(this ILogger logger, Exception ex);
+}
\ No newline at end of file
diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs
new file mode 100644
index 0000000..d8ea9db
--- /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.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.");
+ }
+
+ 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/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..7816f9e
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj
@@ -0,0 +1,31 @@
+
+
+
+ netstandard2.1;net8.0;net10.0;
+ enable
+ enable
+ latest
+ true
+ true
+
+ latest
+ All
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..e82ff2c
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs
@@ -0,0 +1,44 @@
+namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
+
+///
+/// Defines a contract for a provider that stores and retrieves secure configuration data.
+///
+public interface ISecureStorageProvider : IDisposable
+{
+ ///
+ /// Occurs when the underlying storage has changed and the configuration should be reloaded.
+ ///
+ event EventHandler StorageChanged;
+
+ ///
+ /// 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
new file mode 100644
index 0000000..1368331
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs
@@ -0,0 +1,163 @@
+using System.Text.Json;
+
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Primitives;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
+
+internal sealed class JsonFileStorageProvider : ISecureStorageProvider
+{
+ private readonly SemaphoreSlim _fileLock = new(1, 1);
+ private readonly PhysicalFileProvider? _fileProvider;
+ private readonly IDisposable? _changeTokenRegistration;
+ private readonly JsonStorageOptions _options;
+
+ public event EventHandler? StorageChanged;
+
+ public JsonFileStorageProvider(JsonStorageOptions options)
+ {
+ _options = options
+ ?? throw new ArgumentNullException(nameof(options));
+
+ var directory = Path.GetDirectoryName(_options.FullPath);
+
+ if (string.IsNullOrWhiteSpace(directory) is false && Directory.Exists(directory))
+ {
+ _fileProvider = new PhysicalFileProvider(directory)
+ {
+ UseActivePolling = true,
+ UsePollingFileWatcher = true,
+ };
+
+ _changeTokenRegistration = ChangeToken.OnChange(
+ () => _fileProvider.Watch(_options.FileName),
+ () => _ = NotifyStorageChangedAsync()
+ );
+ }
+ }
+
+ public async Task ReadAsync(string key, CancellationToken ct = default)
+ {
+ var data = await AcquireLockAndLoadAsync(ct).ConfigureAwait(false);
+
+ if (data is not null && data.TryGetValue(key, out var v))
+ {
+ return v;
+ }
+
+ return string.Empty;
+ }
+
+ public Task> ReadAllAsync(CancellationToken ct = default)
+ {
+ return AcquireLockAndLoadAsync(ct);
+ }
+
+ public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default)
+ {
+ await _fileLock.WaitAsync(ct).ConfigureAwait(false);
+
+ try
+ {
+ var data = await LoadAsync(ct).ConfigureAwait(false);
+ data[key] = encryptedData;
+ await SaveAsync(data, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ _fileLock.Release();
+ }
+ }
+
+ public async Task DeleteAsync(string key, CancellationToken ct = default)
+ {
+ await _fileLock.WaitAsync(ct).ConfigureAwait(false);
+
+ try
+ {
+ var data = await LoadAsync(ct).ConfigureAwait(false);
+ var result = data.Remove(key);
+ await SaveAsync(data, ct).ConfigureAwait(false);
+ return result;
+ }
+ finally
+ {
+ _fileLock.Release();
+ }
+ }
+
+ public void Dispose()
+ {
+ _changeTokenRegistration?.Dispose();
+ _fileProvider?.Dispose();
+ _fileLock.Dispose();
+ }
+
+ private async Task> AcquireLockAndLoadAsync(CancellationToken ct)
+ {
+ await _fileLock.WaitAsync(ct).ConfigureAwait(false);
+
+ try
+ {
+ return await LoadAsync(ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ _fileLock.Release();
+ }
+ }
+
+ private async Task NotifyStorageChangedAsync()
+ {
+ await Task.Delay(250).ConfigureAwait(false);
+ StorageChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private async Task> LoadAsync(CancellationToken ct)
+ {
+ if (File.Exists(_options.FullPath) is false)
+ {
+ return [];
+ }
+
+ var stream = new FileStream(
+ _options.FullPath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite,
+ bufferSize: 4096,
+ FileOptions.Asynchronous | FileOptions.SequentialScan
+ );
+
+ await using var _ = stream.ConfigureAwait(false);
+
+ if (stream.Length is 0)
+ {
+ return [];
+ }
+
+ var data = await JsonSerializer.DeserializeAsync(stream, SecureConfigJsonContext.Default.DictionaryStringString, ct)
+ .ConfigureAwait(false);
+
+ return data ?? [];
+ }
+
+ private async Task SaveAsync(Dictionary data, CancellationToken ct)
+ {
+ Directory.CreateDirectory(_options.DirectoryPath);
+
+ var stream = new FileStream(
+ _options.FullPath,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.Asynchronous
+ );
+
+ await using var _ = stream.ConfigureAwait(false);
+
+ await JsonSerializer.SerializeAsync(stream, data, SecureConfigJsonContext.Default.DictionaryStringString, ct)
+ .ConfigureAwait(false);
+ }
+}
\ No newline at end of file
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..bc92fe3
--- /dev/null
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonStorageOptions.cs
@@ -0,0 +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; } = "secure_config.json";
+
+ ///
+ /// 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
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
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/Common/KeyGenerator.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/KeyGenerator.cs
new file mode 100644
index 0000000..f4f2be2
--- /dev/null
+++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/KeyGenerator.cs
@@ -0,0 +1,13 @@
+using System.Security.Cryptography;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
+
+internal static class KeyGenerator
+{
+ public static string GetValidBase64Key()
+ {
+ var key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return Convert.ToBase64String(key);
+ }
+}
\ No newline at end of file
diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/TempDirectory.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/TempDirectory.cs
new file mode 100644
index 0000000..0ccb65e
--- /dev/null
+++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Common/TempDirectory.cs
@@ -0,0 +1,20 @@
+namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
+
+internal sealed class TempDirectory : IDisposable
+{
+ public string Path { get; }
+
+ public TempDirectory()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(Path);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, true);
+ }
+ }
+}
diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigExtensionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigExtensionsTests.cs
new file mode 100644
index 0000000..b1e8952
--- /dev/null
+++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigExtensionsTests.cs
@@ -0,0 +1,252 @@
+using System.Security.Cryptography;
+using System.Text.Json.Serialization;
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
+using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
+using StevanFreeborn.Extensions.Configuration.Secure.Storage;
+using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Integration;
+
+public class SecureConfigExtensionsTests : IDisposable
+{
+ private readonly TempDirectory _tempDir = new();
+
+ public void Dispose()
+ {
+ _tempDir.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public void AddSecureConfig_ToConfigurationBuilder_ItShouldReturnConfigurationBuilder()
+ {
+ var builder = new ConfigurationBuilder();
+
+ var result = builder.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ result.Should().BeSameAs(builder);
+ }
+
+ [Fact]
+ public void AddSecureConfig_ToConfigurationBuilder_ItShouldAddSecureConfigSource()
+ {
+ var builder = new ConfigurationBuilder();
+
+ builder.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ builder.Sources.Should().ContainSingle(s => s is SecureConfigSource);
+ }
+
+ [Fact]
+ public void AddSecureConfig_ToServiceCollection_ItShouldReturnServiceCollection()
+ {
+ var services = new ServiceCollection();
+
+ var result = services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ result.Should().BeSameAs(services);
+ }
+
+ [Fact]
+ public void AddSecureConfig_ToServiceCollection_ItShouldRegisterISecureConfig()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ var descriptor = services.Should().ContainSingle(d => d.ServiceType == typeof(ISecureConfig)).Subject;
+ descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
+ }
+
+ [Fact]
+ public void AddSecureConfig_ToServiceCollection_ItShouldRegisterAllDependencies()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ services.Should().Contain(d => d.ServiceType == typeof(ISecureStorageProvider));
+ services.Should().Contain(d => d.ServiceType == typeof(IEncryptionKeyProvider));
+ services.Should().Contain(d => d.ServiceType == typeof(ICryptoProvider));
+ services.Should().Contain(d => d.ServiceType == typeof(ISecureConfig));
+ }
+
+ [Fact]
+ public void AddSecureConfig_ToServiceCollection_ItShouldNotDuplicateExistingRegistrations()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ services.Count(d => d.ServiceType == typeof(ISecureConfig)).Should().Be(1);
+ services.Count(d => d.ServiceType == typeof(ISecureStorageProvider)).Should().Be(1);
+ services.Count(d => d.ServiceType == typeof(IEncryptionKeyProvider)).Should().Be(1);
+ services.Count(d => d.ServiceType == typeof(ICryptoProvider)).Should().Be(1);
+ }
+
+ [Fact]
+ public async Task AddSecureConfig_WithRealProviders_ItShouldResolveISecureConfig()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ secureConfig.Should().NotBeNull();
+ secureConfig.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task AddSecureConfig_WithRealProviders_ItShouldSetAndGetValue()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ var testValue = new IntegrationTestConfig { Name = "TestName", Value = 42 };
+ await secureConfig.SetAsync("test-key", testValue);
+
+ var result = await secureConfig.GetAsync("test-key");
+
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("TestName");
+ result.Value.Should().Be(42);
+ }
+
+ [Fact]
+ public async Task AddSecureConfig_WithRealProviders_ItShouldDeleteValue()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ await secureConfig.SetAsync("delete-key", new IntegrationTestConfig { Name = "ToDelete", Value = 1 });
+
+ var deleted = await secureConfig.DeleteAsync("delete-key");
+
+ deleted.Should().BeTrue();
+
+ var result = await secureConfig.GetAsync("delete-key");
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task AddSecureConfig_WithRealProviders_ItShouldReturnDefaultForMissingKey()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ var result = await secureConfig.GetAsync("nonexistent-key");
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task AddSecureConfig_WithRealProviders_ItShouldResolveAllServicesFromContainer()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ var provider = services.BuildServiceProvider();
+
+ var storageProvider = provider.GetRequiredService();
+ var cryptoProvider = provider.GetRequiredService();
+ var secureConfig = provider.GetRequiredService();
+
+ storageProvider.Should().NotBeNull();
+ cryptoProvider.Should().NotBeNull();
+ secureConfig.Should().NotBeNull();
+
+ storageProvider.Should().BeOfType();
+ cryptoProvider.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task AddSecureConfig_WithMachineIdKey_ItShouldWorkEndToEnd()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(builder =>
+ {
+ builder
+ .WithMachineIdKey()
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = _tempDir.Path,
+ FileName = "secure-config.json",
+ })
+ .AddJsonAotContext(IntegrationTestJsonContext.Default);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ var testValue = new IntegrationTestConfig { Name = "MachineIdTest", Value = 99 };
+ await secureConfig.SetAsync("machine-key", testValue);
+
+ var result = await secureConfig.GetAsync("machine-key");
+
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("MachineIdTest");
+ result.Value.Should().Be(99);
+ }
+
+ [Fact]
+ public async Task AddSecureConfig_WithTypedOverload_ItShouldSerializeAndDeserializeCorrectly()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
+
+ var provider = services.BuildServiceProvider();
+ var secureConfig = provider.GetRequiredService();
+
+ var testValue = new IntegrationTestConfig { Name = "TypedTest", Value = 777 };
+ await secureConfig.SetAsync("typed-key", testValue, IntegrationTestJsonContext.Default.IntegrationTestConfig);
+
+ var result = await secureConfig.GetAsync("typed-key", IntegrationTestJsonContext.Default.IntegrationTestConfig);
+
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("TypedTest");
+ result.Value.Should().Be(777);
+ }
+
+ private static Action CreateDefaultConfig(string tempDir) => builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key())
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions { DirectoryPath = tempDir, FileName = "secure-config.json" })
+ .AddJsonAotContext(IntegrationTestJsonContext.Default);
+ };
+}
+
+internal sealed class IntegrationTestConfig
+{
+ public string Name { get; init; } = string.Empty;
+ public int Value { get; init; }
+}
+
+[JsonSerializable(typeof(IntegrationTestConfig))]
+internal partial class IntegrationTestJsonContext : JsonSerializerContext
+{
+}
diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigOptionsIntegrationTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigOptionsIntegrationTests.cs
new file mode 100644
index 0000000..a087e5c
--- /dev/null
+++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Integration/SecureConfigOptionsIntegrationTests.cs
@@ -0,0 +1,261 @@
+using System.Security.Cryptography;
+using System.Text.Json.Serialization;
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
+using StevanFreeborn.Extensions.Configuration.Secure.Storage;
+using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
+
+namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Integration;
+
+public class SecureConfigOptionsIntegrationTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string _base64Key;
+
+ public SecureConfigOptionsIntegrationTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(_tempDir);
+ _base64Key = KeyGenerator.GetValidBase64Key();
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WithIOptions_ItShouldResolveOptions()
+ {
+ var host = await CreateHostAsync();
+
+ using var scope = host.Services.CreateScope();
+ var secureConfig = scope.ServiceProvider.GetRequiredService();
+
+ await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "initial-key" });
+
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+ config.Reload();
+
+ var options = scope.ServiceProvider.GetRequiredService>();
+
+ options.Value.Should().NotBeNull();
+ options.Value.ApiKey.Should().Be("initial-key");
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WithIOptionsSnapshot_ItShouldResolveNewInstancePerScope()
+ {
+ var host = await CreateHostAsync();
+
+ using var scope1 = host.Services.CreateScope();
+ var snapshot1 = scope1.ServiceProvider.GetRequiredService>();
+
+ using var scope2 = host.Services.CreateScope();
+ var snapshot2 = scope2.ServiceProvider.GetRequiredService>();
+
+ snapshot1.Should().NotBeSameAs(snapshot2);
+ snapshot1.Value.Should().BeEquivalentTo(snapshot2.Value);
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WithIOptionsMonitor_ItShouldResolveMonitor()
+ {
+ var host = await CreateHostAsync();
+
+ var monitor = host.Services.GetRequiredService>();
+
+ monitor.Should().NotBeNull();
+ monitor.CurrentValue.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldReflectInIOptionsMonitor()
+ {
+ var host = await CreateHostAsync();
+
+ var secureConfig = host.Services.GetRequiredService();
+ var monitor = host.Services.GetRequiredService>();
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+
+ var originalValue = monitor.CurrentValue.ApiKey;
+
+ await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "updated-key" });
+
+ config.Reload();
+
+ monitor.CurrentValue.ApiKey.Should().Be("updated-key");
+ monitor.CurrentValue.ApiKey.Should().NotBe(originalValue);
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldReflectInNewIOptionsSnapshot()
+ {
+ var host = await CreateHostAsync();
+
+ using var scope1 = host.Services.CreateScope();
+ var snapshotBefore = scope1.ServiceProvider.GetRequiredService>();
+ var originalValue = snapshotBefore.Value.ApiKey;
+
+ var secureConfig = host.Services.GetRequiredService();
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+
+ await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "snapshot-updated-key" });
+
+ config.Reload();
+
+ using var scope2 = host.Services.CreateScope();
+ var snapshotAfter = scope2.ServiceProvider.GetRequiredService>();
+
+ snapshotAfter.Value.ApiKey.Should().Be("snapshot-updated-key");
+ snapshotAfter.Value.ApiKey.Should().NotBe(originalValue);
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldNotUpdateExistingIOptions()
+ {
+ var host = await CreateHostAsync();
+
+ var secureConfig = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>();
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+
+ var originalValue = options.Value.ApiKey;
+
+ await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "should-not-update" });
+
+ config.Reload();
+
+ options.Value.ApiKey.Should().Be(originalValue);
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WithNestedOptions_ItShouldBindCorrectly()
+ {
+ var host = await CreateHostAsync();
+
+ var secureConfig = host.Services.GetRequiredService();
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+
+ await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "nested-key" });
+
+ config.Reload();
+
+ var options = host.Services.GetRequiredService>();
+
+ options.Value.ApiKey.Should().Be("nested-key");
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WithFullHost_ItShouldStartAndResolveAllServices()
+ {
+ var host = await CreateHostAsync();
+
+ var secureConfig = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>();
+ var snapshotFactory = host.Services.GetRequiredService();
+ var monitor = host.Services.GetRequiredService>();
+ var configuration = host.Services.GetRequiredService();
+
+ secureConfig.Should().NotBeNull();
+ options.Should().NotBeNull();
+ snapshotFactory.Should().NotBeNull();
+ monitor.Should().NotBeNull();
+ configuration.Should().NotBeNull();
+
+ using var scope = snapshotFactory.CreateScope();
+ var snapshot = scope.ServiceProvider.GetRequiredService>();
+ snapshot.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task ConfigureSecureConfig_WhenValueUpdated_ItShouldPropagateThroughEntirePipeline()
+ {
+ var host = await CreateHostAsync();
+
+ var secureConfig = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>();
+ var monitor = host.Services.GetRequiredService>();
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+
+ var originalOptionsValue = options.Value.ApiKey;
+ var originalMonitorValue = monitor.CurrentValue.ApiKey;
+
+ await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "pipeline-updated-key" });
+
+ config.Reload();
+
+ monitor.CurrentValue.ApiKey.Should().Be("pipeline-updated-key");
+
+ using var scope = host.Services.CreateScope();
+ var snapshot = scope.ServiceProvider.GetRequiredService>();
+ snapshot.Value.ApiKey.Should().Be("pipeline-updated-key");
+
+ options.Value.ApiKey.Should().Be(originalOptionsValue);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, true);
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ private Action CreateConfigBuilder() => builder =>
+ {
+ builder
+ .WithBase64EncryptionKey(_base64Key)
+ .WithAesCryptoProvider()
+ .UseJsonFileStorage(new JsonStorageOptions
+ {
+ DirectoryPath = _tempDir,
+ FileName = "secure-config.json"
+ })
+ .AddJsonAotContext(OptionsTestJsonContext.Default);
+ };
+
+ private async Task CreateHostAsync()
+ {
+ var configure = CreateConfigBuilder();
+
+ var hostBuilder = Host.CreateDefaultBuilder()
+ .ConfigureLogging(c => c.ClearProviders())
+ .ConfigureAppConfiguration((_, builder) =>
+ {
+ builder.AddSecureConfig(configure);
+ })
+ .ConfigureServices((context, services) =>
+ {
+ services.Configure(context.Configuration.GetSection(nameof(TestApiOptions)));
+ services.AddSecureConfig(configure);
+ });
+
+ var host = hostBuilder.Build();
+ await host.StartAsync();
+
+ var secureConfig = host.Services.GetRequiredService();
+
+ if (string.IsNullOrEmpty((await secureConfig.GetAsync(nameof(TestApiOptions)))?.ApiKey))
+ {
+ await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "default-key" });
+ var config = (IConfigurationRoot)host.Services.GetRequiredService();
+ config.Reload();
+ }
+
+ return host;
+ }
+}
+
+internal sealed class TestApiOptions
+{
+ public string ApiKey { get; init; } = string.Empty;
+}
+
+[JsonSerializable(typeof(TestApiOptions))]
+internal partial class OptionsTestJsonContext : JsonSerializerContext
+{
+}
diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj
new file mode 100644
index 0000000..a330012
--- /dev/null
+++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net8.0;net10.0;
+ enable
+ enable
+ false
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
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..f2219df
--- /dev/null
+++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs
@@ -0,0 +1,333 @@
+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_ItShouldThrowArgumentNullException()
+ {
+ var act = () => _sut.WithCustomCryptoProvider(null!);
+
+ act.Should().Throw()
+ .WithParameterName("cryptoProviderFactory");
+ }
+
+ [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
new file mode 100644
index 0000000..d943da9
--- /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.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"));
+
+ _sut.Load();
+
+ _sut.TryGet(Key("ValidKey", "Name"), out var val).Should().BeTrue();
+ val.Should().Be("test");
+
+ _mockLogger.Verify(logger => logger.Log(
+ LogLevel.Warning,
+ 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.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.Warning,
+ It.IsAny(),
+ It.Is((v, _) => v.ToString()!.Contains("Bad")),
+ It.IsAny(),
+ It.IsAny>()
+ ),
+ 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
new file mode 100644
index 0000000..b684c6a
--- /dev/null
+++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs
@@ -0,0 +1,203 @@
+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;
+
+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, _jsonSerializerOptions);
+ }
+
+ [Fact]
+ public void Constructor_WhenCalledWithNullStorageProvider_ItShouldThrowArgumentNullException()
+ {
+ var act = () => new SecureConfig(null!, _mockCryptoProvider.Object, _jsonSerializerOptions);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_WhenCalledWithNullCryptoProvider_ItShouldThrowArgumentNullException()
+ {
+ 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();
+ }
+
+ [Fact]
+ public async Task SetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
+ {
+ var act = async () => await _sut.SetAsync(null!, string.Empty, SecureConfigTestsJsonContext.Default.String);
+
+ await act.Should().ThrowAsync();
+ }
+
+
+ [Fact]
+ public async Task SetAsync_WhenCalledWithNullValue_ItShouldThrowArgumentNullException()
+ {
+ var act = async () => await _sut.SetAsync("Key", null!, SecureConfigTestsJsonContext.Default.String);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task SetAsync_WhenCalledWithoutJsonContextSet_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);
+
+ 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, 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!, SecureConfigTestsJsonContext.Default.DummyConfig);
+
+ 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]
+ 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, 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]
+ 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, It.IsAny