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())).ReturnsAsync(string.Empty); + + var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig); + + result.Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_WhenCalled_ItShouldRemoveValue() + { + var key = "Database"; + + _mockStorageProvider.Setup(m => m.DeleteAsync(key, It.IsAny())).ReturnsAsync(true); + + var result = await _sut.DeleteAsync(key); + + result.Should().BeTrue(); + _mockStorageProvider.Verify(m => m.DeleteAsync(key, It.IsAny()), Times.Once()); + } +} + +internal sealed record DummyConfig(string Host, int Port); + +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(DummyConfig))] +internal partial class SecureConfigTestsJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs new file mode 100644 index 0000000..ca66927 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/AesCryptoProviderTests.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; + +public class AesCryptoProviderTests +{ + private readonly Mock _mockKeyProvider = new(); + private readonly AesCryptoProvider _sut; + + public AesCryptoProviderTests() + { + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + _mockKeyProvider.Setup(static m => m.GetKey()).Returns(key); + + _sut = new(_mockKeyProvider.Object); + } + + [Fact] + public void EncryptAndDecrypt_WhenCalled_ItShouldReturnOriginalString() + { + var originalText = "SuperDuperSecret"; + + var encryptedText = _sut.Encrypt(originalText); + var decryptedText = _sut.Decrypt(encryptedText); + + encryptedText.Should().NotBe(originalText); + decryptedText.Should().Be(originalText); + } + + [Fact] + public void Encrypt_WhenCalledWithSameInputTwitch_ItShouldProduceDifferentCipherText() + { + var plainText = "identicalInput"; + + var cipherTextOne = _sut.Encrypt(plainText); + var cipherTextTwo = _sut.Encrypt(plainText); + + cipherTextOne.Should().NotBe(cipherTextTwo); + } + + [Fact] + public void Decrypt_WhenCalledWithTamperedData_ItShouldThrowCrytographicException() + { + var cipherText = _sut.Encrypt("some data"); + var rawBytes = Convert.FromBase64String(cipherText); + rawBytes[^1] = (byte)(rawBytes[^1] ^ 0xFF); + + var tamperedText = Convert.ToBase64String(rawBytes); + + var act = () => _sut.Decrypt(tamperedText); + + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyGeneratorTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyGeneratorTests.cs new file mode 100644 index 0000000..9fe881f --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyGeneratorTests.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; + +public class MachineIdKeyGeneratorTests +{ + private readonly Mock> _mockLogger = new(); + private readonly MachineIdKeyGenerator _sut; + + public MachineIdKeyGeneratorTests() + { + _sut = new(_mockLogger.Object); + } + + [Fact] + public void GetId_WhenCalled_ItShouldReturnNonEmptyString() + { + _sut.GetId().Should().NotBeEmpty(); + } + + [Fact] + public void GetId_WhenCalledMultipleTimes_ItShouldReturnConsistentId() + { + var resultOne = _sut.GetId(); + var resultTwo = _sut.GetId(); + + resultTwo.Should().Be(resultOne); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs new file mode 100644 index 0000000..ba80069 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/MachineIdKeyProviderTests.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using System.Text; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; + +public class MachineIdKeyProviderTests +{ + private readonly Mock _mockKeyGenerator = new(); + private readonly MachineIdKeyProvider _sut; + + public MachineIdKeyProviderTests() + { + _sut = new(_mockKeyGenerator.Object); + } + + [Fact] + public void GetKey_WhenCalled_ItShouldReturn32ByteKey() + { + _mockKeyGenerator.Setup(static m => m.GetId()).Returns("random-stuff"); + + var result = _sut.GetKey(); + + result.Length.Should().Be(32); + } + + [Fact] + public void GetKey_WhenCalledForSameInput_ItShouldProduceConsistentHash() + { + var input = "some-machine-id"; + + var mockInstanceOne = new Mock(); + mockInstanceOne.Setup(static m => m.GetId()).Returns(input); + + var instanceOne = new MachineIdKeyProvider(mockInstanceOne.Object); + var resultOne = instanceOne.GetKey(); + + var mockInstanceTwo = new Mock(); + mockInstanceTwo.Setup(static m => m.GetId()).Returns(input); + + var instanceTwo = new MachineIdKeyProvider(mockInstanceTwo.Object); + var resultTwo = instanceTwo.GetKey(); + + resultOne.Should().BeEquivalentTo(resultTwo); + } + + [Fact] + public void GetKey_WhenCalledWithDifferentInput_ItShouldProduceDifferentHashes() + { + var inputOne = "inputOne"; + var inputTwo = "inputTwo"; + + _mockKeyGenerator.SetupSequence(static m => m.GetId()) + .Returns(inputOne) + .Returns(inputTwo); + + var resultOne = _sut.GetKey(); + var resultTwo = _sut.GetKey(); + + resultOne.Should().NotBeEquivalentTo(resultTwo); + } + + [Fact] + public void GetKey_WhenCalled_ItShouldUseSHA256Hash() + { + var rawId = "rawId"; + var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(rawId)); + + _mockKeyGenerator.Setup(static m => m.GetId()).Returns(rawId); + + var result = _sut.GetKey(); + + result.Should().BeEquivalentTo(expectedHash); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs new file mode 100644 index 0000000..a9cf60c --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Cryptography/StaticKeyProviderTests.cs @@ -0,0 +1,39 @@ +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography; + +public class StaticKeyProviderTests +{ + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenKeyIsNull() + { + var act = () => new StaticKeyProvider(null!); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndKeyIsNot32Bytes_ItShouldThrowArgumentException() + { + var shortKey = Convert.ToBase64String(new byte[16]); + + var act = () => new StaticKeyProvider(shortKey); + + act.Should().Throw().WithMessage("*must be exactly 32 bytes*"); + } + + [Fact] + public void GetKey_WhenCalled_ItShouldReturnValid32ByteArray() + { + var expectedBytes = new byte[32]; + Random.Shared.NextBytes(expectedBytes); + var base64Key = Convert.ToBase64String(expectedBytes); + + var provider = new StaticKeyProvider(base64Key); + + var result = provider.GetKey(); + + result.Should().BeEquivalentTo(expectedBytes); + result.Length.Should().Be(32); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs new file mode 100644 index 0000000..9dbd4cb --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs @@ -0,0 +1,713 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; +using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit; + +public class SecureConfigExtensionsTests +{ + private readonly Mock _mockStorageProvider = new(); + private readonly Mock _mockKeyProvider = new(); + private readonly Mock _mockCryptoProvider = new(); + private readonly Mock _mockLoggerFactory = new(); + + [Fact] + public void AddSecureConfig_WithNullBuilder_ItShouldThrowArgumentNullException() + { + IConfigurationBuilder builder = null!; + + var act = () => builder.AddSecureConfig(config => { }); + + act.Should().Throw() + .WithParameterName("builder"); + } + + [Fact] + public void AddSecureConfig_WithNullConfigure_ItShouldThrowArgumentNullException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddSecureConfig_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A storage provider must be configured."); + } + + [Fact] + public void AddSecureConfig_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A key provider must be configured"); + } + + [Fact] + public void AddSecureConfig_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + }); + + act.Should().Throw() + .WithMessage("A crypto provider must be configured."); + } + + [Fact] + public void AddSecureConfig_WhenProperlyConfigured_ItShouldAddSecureConfigSource() + { + var builder = new ConfigurationBuilder(); + + var result = builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + result.Should().BeSameAs(builder); + builder.Sources.Should().ContainSingle(s => s is SecureConfigSource); + } + + [Fact] + public void AddSecureConfig_WhenProperlyConfigured_ItShouldBuildConfigurationProvider() + { + var encryptedValue = "encrypted_value"; + var decryptedJson = /*lang=json,strict*/ @"{ ""Setting"": ""Value"", ""Number"": 42 }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "MySection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration.GetSection("MySection")["Setting"].Should().Be("Value"); + configuration.GetSection("MySection")["Number"].Should().Be("42"); + } + + [Fact] + public void AddSecureConfig_WithJsonFileStorage_ItShouldWorkEndToEnd() + { + using var tempDir = new TempDirectory(); + var filePath = Path.Combine(tempDir.Path, "test_config.json"); + + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var keyProvider = new StaticKeyProvider(Convert.ToBase64String(keyBytes)); + var cryptoProvider = new AesCryptoProvider(keyProvider); + + var originalData = /*lang=json,strict*/ @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }"; + var encryptedData = cryptoProvider.Encrypt(originalData); + + File.WriteAllText(filePath, $"{{\"Settings\":\"{encryptedData}\"}}"); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir.Path; + options.FileName = "test_config.json"; + }); + config.WithBase64EncryptionKey(Convert.ToBase64String(keyBytes)); + config.WithAesCryptoProvider(); + }) + .Build(); + + configuration["Settings:AppName"].Should().Be("TestApp"); + configuration["Settings:Version"].Should().Be("1.0.0"); + } + + [Fact] + public void AddSecureConfig_WithLoggerFactory_ItShouldUseProvidedLoggerFactory() + { + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var mockLogger = new Mock>(); + + _mockLoggerFactory + .Setup(f => f.CreateLogger(typeof(SecureConfigProvider).FullName!)) + .Returns(mockLogger.Object); + + var builder = new ConfigurationBuilder(); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + config.WithAesCryptoProvider(); + config.WithLoggerFactory(_mockLoggerFactory.Object); + }) + .Build(); + + _mockLoggerFactory.Verify( + f => f.CreateLogger(typeof(SecureConfigProvider).FullName!), + Times.Once() + ); + } + + [Fact] + public void AddSecureConfig_WithMultipleCalls_ItShouldAddMultipleSources() + { + var builder = new ConfigurationBuilder(); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + builder.Sources.Should().HaveCount(2); + builder.Sources.Should().AllBeOfType(); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WithNullServices_ItShouldThrowArgumentNullException() + { + IServiceCollection services = null!; + + var act = () => services.AddSecureConfig(config => { }); + + act.Should().Throw() + .WithParameterName("services"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WithNullConfigure_ItShouldThrowArgumentNullException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A storage provider must be configured."); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A key provider must be configured"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key()); + }); + + act.Should().Throw() + .WithMessage("A crypto provider must be configured."); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenProperlyConfigured_ItShouldRegisterServices() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + services.Should().Contain(s => s.ServiceType == typeof(ISecureStorageProvider)); + services.Should().Contain(s => s.ServiceType == typeof(IEncryptionKeyProvider)); + services.Should().Contain(s => s.ServiceType == typeof(ICryptoProvider)); + services.Should().Contain(s => s.ServiceType == typeof(ISecureConfig)); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldReturnServiceCollection() + { + var services = new ServiceCollection(); + + var result = services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + result.Should().BeSameAs(services); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterStorageProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var storageDescriptor = services.First(s => s.ServiceType == typeof(ISecureStorageProvider)); + storageDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterKeyProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var keyDescriptor = services.First(s => s.ServiceType == typeof(IEncryptionKeyProvider)); + keyDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterCryptoProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var cryptoDescriptor = services.First(s => s.ServiceType == typeof(ICryptoProvider)); + cryptoDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterSecureConfigAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var secureConfigDescriptor = services.First(s => s.ServiceType == typeof(ISecureConfig)); + secureConfigDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSecureConfigFromDI() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetService(); + + secureConfig.Should().NotBeNull(); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameSecureConfigInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(instance2); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameStorageProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockStorageProvider.Object); + instance2.Should().BeSameAs(_mockStorageProvider.Object); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameKeyProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockKeyProvider.Object); + instance2.Should().BeSameAs(_mockKeyProvider.Object); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameCryptoProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockCryptoProvider.Object); + instance2.Should().BeSameAs(_mockCryptoProvider.Object); + } + + [Fact] + public async Task AddSecureConfig_ServiceCollection_WithRealAesCryptoProvider_ItShouldWorkEndToEnd() + { + using var tempDir = new TempDirectory(); + var filePath = Path.Combine(tempDir.Path, "test_config.json"); + + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var base64Key = Convert.ToBase64String(keyBytes); + + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir.Path; + options.FileName = "test_config.json"; + }); + config.WithBase64EncryptionKey(base64Key); + config.WithAesCryptoProvider(); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); + var storageProvider = serviceProvider.GetRequiredService(); + + var testObject = new TestConfig { Name = "TestName", Value = 123 }; + await secureConfig.SetAsync("TestKey", testObject); + + var retrievedObject = await secureConfig.GetAsync("TestKey"); + + retrievedObject.Should().NotBeNull(); + retrievedObject.Name.Should().Be("TestName"); + retrievedObject.Value.Should().Be(123); + + File.Exists(filePath).Should().BeTrue(); + } + + [Fact] + public async Task AddSecureConfig_ServiceCollection_WithMachineIdKey_ItShouldWorkEndToEnd() + { + using var tempDir = new TempDirectory(); + + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir.Path; + options.FileName = "test_config.json"; + }); + config.WithMachineIdKey(); + config.WithAesCryptoProvider(); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); + + var testObject = new TestConfig { Name = "MachineIdTest", Value = 456 }; + await secureConfig.SetAsync("MachineTest", testObject); + + var retrievedObject = await secureConfig.GetAsync("MachineTest"); + + retrievedObject.Should().NotBeNull(); + retrievedObject.Name.Should().Be("MachineIdTest"); + retrievedObject.Value.Should().Be(456); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterJsonSerializerOptions() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var keyedService = serviceProvider.GetKeyedService("SecureConfigJsonSerializerOptions"); + + keyedService.Should().NotBeNull(); + keyedService.PropertyNameCaseInsensitive.Should().BeTrue(); + } + + [Fact] + public void AddSecureConfig_CombinedWithOtherProviders_ItShouldMergeConfiguration() + { + var encryptedValue = "encrypted_value"; + var decryptedJson = /*lang=json,strict*/ @"{ ""SecureSetting"": ""SecureValue"" }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "SecureSection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["RegularSetting"] = "RegularValue", + ["AnotherSetting"] = "AnotherValue" + }) + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["RegularSetting"].Should().Be("RegularValue"); + configuration["AnotherSetting"].Should().Be("AnotherValue"); + configuration["SecureSection:SecureSetting"].Should().Be("SecureValue"); + } + + [Fact] + public void AddSecureConfig_WithNestedConfiguration_ItShouldFlattenCorrectly() + { + var encryptedValue = "encrypted_nested"; + var decryptedJson = /*lang=json,strict*/ @"{ + ""Level1"": { + ""Level2"": { + ""Setting"": ""NestedValue"" + } + } + }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "Nested", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["Nested:Level1:Level2:Setting"].Should().Be("NestedValue"); + } + + [Fact] + public void AddSecureConfig_WithArrays_ItShouldIndexCorrectly() + { + var encryptedValue = "encrypted_array"; + var decryptedJson = /*lang=json,strict*/ @"{ ""Items"": [""First"", ""Second"", ""Third""] }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "ArraySection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["ArraySection:Items:0"].Should().Be("First"); + configuration["ArraySection:Items:1"].Should().Be("Second"); + configuration["ArraySection:Items:2"].Should().Be("Third"); + } + + [Fact] + public void AddSecureConfig_WithMultipleSections_ItShouldLoadAllSections() + { + var encrypted1 = "encrypted1"; + var encrypted2 = "encrypted2"; + var decrypted1 = /*lang=json,strict*/ @"{ ""Setting1"": ""Value1"" }"; + var decrypted2 = /*lang=json,strict*/ @"{ ""Setting2"": ""Value2"" }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary + { + { "Section1", encrypted1 }, + { "Section2", encrypted2 } + }); + + _mockCryptoProvider + .Setup(c => c.Decrypt("encrypted1")) + .Returns(decrypted1); + + _mockCryptoProvider + .Setup(c => c.Decrypt("encrypted2")) + .Returns(decrypted2); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["Section1:Setting1"].Should().Be("Value1"); + configuration["Section2:Setting2"].Should().Be("Value2"); + } +} + +internal sealed class TestConfig +{ + public string Name { get; set; } = string.Empty; + public int Value { get; set; } +} + +[JsonSerializable(typeof(TestConfig))] +internal partial class SecureConfigExtensionsTestsJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs new file mode 100644 index 0000000..9235e7e --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs @@ -0,0 +1,95 @@ +using StevanFreeborn.Extensions.Configuration.Secure.Storage; +using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage; + +public class JsonFileStorageProviderTests : IDisposable +{ + private readonly TempDirectory _tempDir = new(); + private readonly JsonStorageOptions _options; + private readonly JsonFileStorageProvider _sut; + + public JsonFileStorageProviderTests() + { + _options = new() + { + FileName = "testsettings.json", + DirectoryPath = _tempDir.Path, + }; + + _sut = new(_options); + } + + [Fact] + public void Constructor_WhenCalledWithNullOptions_ItShouldThrowArgumentNullException() + { + var act = static () => new JsonFileStorageProvider(null!); + + act.Should().Throw(); + } + + [Fact] + public async Task WriteAsync_And_ReadAsync_WhenCalled_ItShouldBeAbleToPersistAndRetrieveValues() + { + var configKey = "KeyA"; + var configValue = "Value"; + + await _sut.WriteAsync(configKey, configValue); + var result = await _sut.ReadAsync(configKey); + + result.Should().Be(configValue); + File.Exists(_options.FullPath).Should().BeTrue(); + } + + [Fact] + public async Task ReadAllAsync_WhenCalled_ItShouldReturnAllStoredKeys() + { + await _sut.WriteAsync("Key1", "Val1"); + await _sut.WriteAsync("Key2", "Val2"); + + var allData = await _sut.ReadAllAsync(); + + allData.Should().BeEquivalentTo(new Dictionary() + { + ["Key1"] = "Val1", + ["Key2"] = "Val2", + }); + } + + [Fact] + public async Task DeleteAsync_WhenCalled_ItShouldRemoveKey() + { + await _sut.WriteAsync("KeyToDelete", "SomeValue"); + + var deleteResult = await _sut.DeleteAsync("KeyToDelete"); + var readResult = await _sut.ReadAsync("KeyToDelete"); + + deleteResult.Should().BeTrue(); + readResult.Should().BeEmpty(); + } + + public void Dispose() + { + _tempDir.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task WriteAsync_WhenCalledConcurrently_ItShouldNotThrowFileInUseException() + { + const int numberOfWrites = 50; + var tasks = new List(); + + foreach (var index in Enumerable.Range(0, numberOfWrites)) + { + tasks.Add(Task.Run(() => _sut.WriteAsync($"Key{index}", $"Val{index}"))); + } + + var act = async () => await Task.WhenAll(tasks); + + await act.Should().NotThrowAsync(); + + var allData = await _sut.ReadAllAsync(); + allData.Should().HaveCount(numberOfWrites); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonStorageOptionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonStorageOptionsTests.cs new file mode 100644 index 0000000..948e90c --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonStorageOptionsTests.cs @@ -0,0 +1,36 @@ +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage; + +public class JsonStorageOptionsTests +{ + [Fact] + public void Path_WhenCalledWithFileNameSet_ItShouldReturnExpectedPath() + { + var fileName = "appsettings.json"; + var expectedPath = Path.Combine(AppContext.BaseDirectory, fileName); + + var opts = new JsonStorageOptions() + { + FileName = fileName, + }; + + opts.FullPath.Should().Be(expectedPath); + } + + [Fact] + public void Path_WhenCalledWithFileNameAndDirectoryPathSet_ItShouldReturnExpectedPath() + { + var fileName = "appsettings.json"; + var directoryPath = @"C:\Path\To\Some\Directory"; + var expectedPath = Path.Combine(directoryPath, fileName); + + var opts = new JsonStorageOptions() + { + FileName = fileName, + DirectoryPath = directoryPath, + }; + + opts.FullPath.Should().Be(expectedPath); + } +} \ No newline at end of file