Merge pull request 'feat: initial library implementation' (#1) from stevanfreeborn/feat/initial-implementation into main

This commit is contained in:
Stevan Freeborn
2026-04-03 02:09:31 +00:00
47 changed files with 4546 additions and 6 deletions
+9 -6
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"cSpell.words": [
"ioreg",
"netstandard"
]
}
+7
View File
@@ -1,2 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj" />
<Project Path="src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj" />
</Folder>
</Solution>
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.201",
"rollForward": "latestFeature"
}
}
@@ -0,0 +1,6 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
public sealed record ApiOptions
{
public string ApiKey { get; init; } = string.Empty;
}
@@ -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
{
}
@@ -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; }
}
@@ -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<ISecureConfig>();
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<DatabaseSettings>("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<SmtpSettings>("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<SmtpSettings>("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<ISecureConfig>();
Console.WriteLine(" Storing config encrypted with machine-derived key...");
await secureConfig.SetAsync("MachineLocked", new ApiOptions { ApiKey = "machine-specific-secret" });
var value = await secureConfig.GetAsync<ApiOptions>("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<ISecureConfigBuilder> 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<ApiOptions>(ctx.Configuration.GetSection(nameof(ApiOptions)));
s.AddSecureConfig(configure);
})
.StartAsync();
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
var options = host.Services.GetRequiredService<IOptions<ApiOptions>>();
var monitor = host.Services.GetRequiredService<IOptionsMonitor<ApiOptions>>();
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
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<IOptionsSnapshot<ApiOptions>>();
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<ISecureConfig>();
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<string, string>();
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<ISecureConfig>();
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>("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<ISecureConfig>();
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>("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<ISecureConfig>();
JsonTypeInfo<DatabaseSettings> dbTypeInfo = AppJsonContext.Default.DatabaseSettings;
JsonTypeInfo<SmtpSettings> smtpTypeInfo = AppJsonContext.Default.SmtpSettings;
Console.WriteLine(" Setting values using explicit JsonTypeInfo<T> 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<T> 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<string, string> _store;
public InMemoryStorageProvider(Dictionary<string, string> store)
{
_store = store;
}
public event EventHandler? StorageChanged;
public Task<string> ReadAsync(string key, CancellationToken ct = default)
{
return _store.TryGetValue(key, out var value) ? Task.FromResult(value) : Task.FromResult(string.Empty);
}
public Task<IDictionary<string, string>> ReadAllAsync(CancellationToken ct = default)
{
return Task.FromResult<IDictionary<string, string>>(new Dictionary<string, string>(_store));
}
public Task WriteAsync(string key, string encryptedData, CancellationToken ct = default)
{
_store[key] = encryptedData;
StorageChanged?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
public Task<bool> 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));
}
}
@@ -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; }
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StevanFreeborn.Extensions.Configuration.Secure\StevanFreeborn.Extensions.Configuration.Secure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,62 @@
using System.Text.Json.Serialization.Metadata;
namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
/// <summary>
/// Provides secure storage, retrieval, and deletion of configuration values.
/// All values are automatically encrypted before storage and decrypted on retrieval.
/// </summary>
public interface ISecureConfig
{
/// <summary>
/// Serializes, encrypts, and stores a value for the specified key.
/// Resolves <see cref="JsonTypeInfo{T}"/> from the registered serializer options.
/// </summary>
/// <typeparam name="T">The type of the value to store.</typeparam>
/// <param name="key">The configuration key.</param>
/// <param name="value">The value to store.</param>
/// <param name="ct">A token to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SetAsync<T>(string key, T value, CancellationToken ct = default);
/// <summary>
/// Serializes, encrypts, and stores a value for the specified key using the provided type metadata.
/// This overload supports Native AOT by accepting pre-compiled <see cref="JsonTypeInfo{T}"/>.
/// </summary>
/// <typeparam name="T">The type of the value to store.</typeparam>
/// <param name="key">The configuration key.</param>
/// <param name="value">The value to store.</param>
/// <param name="typeInfo">The JSON type metadata for source-generated serialization.</param>
/// <param name="ct">A token to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SetAsync<T>(string key, T value, JsonTypeInfo<T> typeInfo, CancellationToken ct = default);
/// <summary>
/// Reads, decrypts, and deserializes a value for the specified key.
/// Resolves <see cref="JsonTypeInfo{T}"/> from the registered serializer options.
/// </summary>
/// <typeparam name="T">The type of the value to retrieve.</typeparam>
/// <param name="key">The configuration key.</param>
/// <param name="ct">A token to monitor for cancellation requests.</param>
/// <returns>The deserialized value, or <c>default</c> if the key does not exist.</returns>
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
/// <summary>
/// Reads, decrypts, and deserializes a value for the specified key using the provided type metadata.
/// This overload supports Native AOT by accepting pre-compiled <see cref="JsonTypeInfo{T}"/>.
/// </summary>
/// <typeparam name="T">The type of the value to retrieve.</typeparam>
/// <param name="key">The configuration key.</param>
/// <param name="typeInfo">The JSON type metadata for source-generated serialization.</param>
/// <param name="ct">A token to monitor for cancellation requests.</param>
/// <returns>The deserialized value, or <c>default</c> if the key does not exist.</returns>
Task<T?> GetAsync<T>(string key, JsonTypeInfo<T> typeInfo, CancellationToken ct = default);
/// <summary>
/// Deletes the value associated with the specified key from secure storage.
/// </summary>
/// <param name="key">The configuration key.</param>
/// <param name="ct">A token to monitor for cancellation requests.</param>
/// <returns><c>true</c> if the value was deleted; otherwise, <c>false</c>.</returns>
Task<bool> DeleteAsync(string key, CancellationToken ct = default);
}
@@ -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;
/// <summary>
/// Provides a fluent builder interface for configuring secure configuration storage and encryption.
/// </summary>
public interface ISecureConfigBuilder
{
/// <summary>
/// Configures JSON file-based storage using the provided options instance.
/// </summary>
/// <param name="options">The JSON storage configuration options.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder UseJsonFileStorage(JsonStorageOptions options);
/// <summary>
/// Configures JSON file-based storage using an action to configure the options.
/// </summary>
/// <param name="configure">An action to configure the <see cref="JsonStorageOptions"/>.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder UseJsonFileStorage(Action<JsonStorageOptions> configure);
/// <summary>
/// Registers a JSON AOT source-generated context for serializing complex types.
/// </summary>
/// <param name="context">A context that implements <see cref="IJsonTypeInfoResolver"/> that will be used for JSON serialization.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder AddJsonAotContext(IJsonTypeInfoResolver context);
/// <summary>
/// Configures a custom storage provider for secure configuration data.
/// </summary>
/// <param name="provider">The custom storage provider implementation.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder UseCustomStorage(ISecureStorageProvider provider);
/// <summary>
/// Configures encryption using a Base64-encoded encryption key.
/// </summary>
/// <param name="key">The Base64-encoded encryption key string.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder WithBase64EncryptionKey(string key);
/// <summary>
/// Configures encryption using a key derived from the machine id.
/// </summary>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder WithMachineIdKey();
/// <summary>
/// Configures a custom encryption key provider.
/// </summary>
/// <param name="keyProvider">The custom encryption key provider implementation.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder WithCustomKeyProvider(IEncryptionKeyProvider keyProvider);
/// <summary>
/// Configures logging using the provided logger factory.
/// </summary>
/// <param name="loggerFactory">The logger factory to use for logging operations.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory);
/// <summary>
/// Configures AES crypto provider for encryption and decryption.
/// </summary>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder WithAesCryptoProvider();
/// <summary>
/// Configures the factory function that will be used to create the crypto provider for encryption and decryption
/// </summary>
/// <param name="cryptoProviderFactory">The crypto provider factory to use for encryption and decryption operations.</param>
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
ISecureConfigBuilder WithCustomCryptoProvider(Func<IEncryptionKeyProvider, ICryptoProvider> cryptoProviderFactory);
}
@@ -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<T>(string key, T value, CancellationToken ct = default)
{
var typeInfo = GetTypeInfo<T>();
return SetAsync(key, value, typeInfo, ct);
}
public async Task SetAsync<T>(string key, T value, JsonTypeInfo<T> 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<T?> GetAsync<T>(string key, CancellationToken ct = default)
{
var typeInfo = GetTypeInfo<T>();
return GetAsync(key, typeInfo, ct);
}
public async Task<T?> GetAsync<T>(string key, JsonTypeInfo<T> 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<bool> DeleteAsync(string key, CancellationToken ct = default)
{
return _storageProvider.DeleteAsync(key, ct);
}
private JsonTypeInfo<T> GetTypeInfo<T>()
{
try
{
return (JsonTypeInfo<T>?)_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);
}
}
}
@@ -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<IEncryptionKeyProvider, ICryptoProvider>? 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<JsonStorageOptions> 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<MachineIdKeyGenerator>();
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<IEncryptionKeyProvider, ICryptoProvider> cryptoProviderFactory)
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(cryptoProviderFactory);
#else
if (cryptoProviderFactory is null)
{
throw new ArgumentNullException(nameof(cryptoProviderFactory));
}
#endif
CryptoProviderFactory = cryptoProviderFactory;
return this;
}
}
@@ -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<SecureConfigProvider> _logger;
public SecureConfigProvider(
ISecureStorageProvider storageProvider,
ICryptoProvider cryptoProvider,
ILogger<SecureConfigProvider> 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<string, string?> ProcessAndDecryptData(IDictionary<string, string> encryptedData)
{
var flattenedData = new Dictionary<string, string?>(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<string, string?> 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;
}
}
}
@@ -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<SecureConfigProvider>();
return new SecureConfigProvider(_storageProvider, _cryptoProvider, logger);
}
}
@@ -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);
}
}
@@ -0,0 +1,21 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
/// <summary>
/// Defines the contract for cryptographic operations.
/// </summary>
public interface ICryptoProvider
{
/// <summary>
/// Encrypts the provided plain text.
/// </summary>
/// <param name="plainText">The unencrypted string to be encrypted.</param>
/// <returns>The encrypted cipher text. If <paramref name="plainText"/> is null, empty, or whitespace, it is returned unchanged.</returns>
string Encrypt(string plainText);
/// <summary>
/// Decrypts the provided cipher text.
/// </summary>
/// <param name="cipherText">The encrypted string to be decrypted.</param>
/// <returns>The decrypted plain text. If <paramref name="cipherText"/> is null, empty, or whitespace, it is returned unchanged.</returns>
string Decrypt(string cipherText);
}
@@ -0,0 +1,13 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
/// <summary>
/// Defines a contract for providing encryption keys used to secure configuration data.
/// </summary>
public interface IEncryptionKeyProvider
{
/// <summary>
/// Retrieves the encryption key as a byte array.
/// </summary>
/// <returns>A byte array containing the encryption key.</returns>
byte[] GetKey();
}
@@ -0,0 +1,6 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
internal interface IMachineIdKeyGenerator
{
string GetId();
}
@@ -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<MachineIdKeyGenerator> _logger;
private readonly Lazy<string> _machineId;
public MachineIdKeyGenerator(ILogger<MachineIdKeyGenerator> 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}";
}
}
@@ -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
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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;
/// <summary>
/// Provides extension methods for configuring secure configuration in .NET applications.
/// </summary>
public static class SecureConfigExtensions
{
private const string JsonSerializerOptionsKey = "SecureConfigJsonSerializerOptions";
/// <summary>
/// Adds secure configuration to the configuration builder.
/// </summary>
/// <param name="builder">The configuration builder to add secure configuration to.</param>
/// <param name="configure">An action to configure the secure configuration builder.</param>
/// <returns>The configuration builder with secure configuration added.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="configure"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when a required provider is not configured.</exception>
public static IConfigurationBuilder AddSecureConfig(
this IConfigurationBuilder builder,
Action<ISecureConfigBuilder> 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);
}
/// <summary>
/// Adds secure configuration services to the service collection.
/// </summary>
/// <param name="services">The service collection to add secure configuration services to.</param>
/// <param name="configure">An action to configure the secure configuration builder.</param>
/// <returns>The service collection with secure configuration services added.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="configure"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when a required provider is not configured.</exception>
public static IServiceCollection AddSecureConfig(
this IServiceCollection services,
Action<ISecureConfigBuilder> 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<ISecureConfig>(sp =>
{
var storageProvider = sp.GetRequiredService<ISecureStorageProvider>();
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var serializerOptions = sp.GetRequiredKeyedService<JsonSerializerOptions>(JsonSerializerOptionsKey);
return new SecureConfig(storageProvider, cryptoProvider, serializerOptions);
});
return services;
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.1;net8.0;net10.0;</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>All</AnalysisMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Text.Json" Version="10.0.5" />
</ItemGroup>
</Project>
@@ -0,0 +1,44 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
/// <summary>
/// Defines a contract for a provider that stores and retrieves secure configuration data.
/// </summary>
public interface ISecureStorageProvider : IDisposable
{
/// <summary>
/// Occurs when the underlying storage has changed and the configuration should be reloaded.
/// </summary>
event EventHandler StorageChanged;
/// <summary>
/// Reads the value associated with the specified key asynchronously.
/// </summary>
/// <param name="key">The key of the configuration value to read.</param>
/// <param name="ct">A cancellation token that can be used to cancel the read operation.</param>
/// <returns>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.</returns>
Task<string> ReadAsync(string key, CancellationToken ct = default);
/// <summary>
/// Reads all configuration values asynchronously.
/// </summary>
/// <param name="ct">A cancellation token that can be used to cancel the read operation.</param>
/// <returns>A task that represents the asynchronous read operation. The task result contains a dictionary of all configuration keys and their encrypted values.</returns>
Task<IDictionary<string, string>> ReadAllAsync(CancellationToken ct = default);
/// <summary>
/// Writes the specified key and encrypted data asynchronously.
/// </summary>
/// <param name="key">The key of the configuration value to write.</param>
/// <param name="encryptedData">The encrypted configuration data to write.</param>
/// <param name="ct">A cancellation token that can be used to cancel the write operation.</param>
/// <returns>A task that represents the asynchronous write operation.</returns>
Task WriteAsync(string key, string encryptedData, CancellationToken ct = default);
/// <summary>
/// Deletes the configuration value associated with the specified key asynchronously.
/// </summary>
/// <param name="key">The key of the configuration value to delete.</param>
/// <param name="ct">A cancellation token that can be used to cancel the delete operation.</param>
/// <returns>A task that represents the asynchronous delete operation. The task result contains <c>true</c> if the value was successfully deleted; otherwise, <c>false</c>.</returns>
Task<bool> DeleteAsync(string key, CancellationToken ct = default);
}
@@ -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<string> 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<IDictionary<string, string>> 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<bool> 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<IDictionary<string, string>> 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<Dictionary<string, string>> 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<string, string> 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);
}
}
@@ -0,0 +1,22 @@
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
/// <summary>
/// Options for configuring the <see cref="JsonFileStorageProvider"/>.
/// </summary>
public sealed class JsonStorageOptions
{
/// <summary>
/// Gets or sets the name of the JSON file used for storage.
/// </summary>
public string FileName { get; set; } = "secure_config.json";
/// <summary>
/// Gets or sets the directory path where the JSON file is located. Defaults to the base directory of the application.
/// </summary>
public string DirectoryPath { get; set; } = AppContext.BaseDirectory;
/// <summary>
/// Gets the full, combined path to the JSON file, including the directory and file name.
/// </summary>
public string FullPath => Path.Combine(DirectoryPath, FileName);
}
@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
[JsonSerializable(typeof(Dictionary<string, string>))]
internal sealed partial class SecureConfigJsonContext : JsonSerializerContext;
+3
View File
@@ -0,0 +1,3 @@
[*.cs]
dotnet_diagnostic.CA1707.severity = none
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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<ISecureConfig>();
secureConfig.Should().NotBeNull();
secureConfig.Should().BeOfType<SecureConfig>();
}
[Fact]
public async Task AddSecureConfig_WithRealProviders_ItShouldSetAndGetValue()
{
var services = new ServiceCollection();
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
var provider = services.BuildServiceProvider();
var secureConfig = provider.GetRequiredService<ISecureConfig>();
var testValue = new IntegrationTestConfig { Name = "TestName", Value = 42 };
await secureConfig.SetAsync("test-key", testValue);
var result = await secureConfig.GetAsync<IntegrationTestConfig>("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<ISecureConfig>();
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<IntegrationTestConfig>("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<ISecureConfig>();
var result = await secureConfig.GetAsync<IntegrationTestConfig>("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<ISecureStorageProvider>();
var cryptoProvider = provider.GetRequiredService<ICryptoProvider>();
var secureConfig = provider.GetRequiredService<ISecureConfig>();
storageProvider.Should().NotBeNull();
cryptoProvider.Should().NotBeNull();
secureConfig.Should().NotBeNull();
storageProvider.Should().BeOfType<JsonFileStorageProvider>();
cryptoProvider.Should().BeOfType<AesCryptoProvider>();
}
[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<ISecureConfig>();
var testValue = new IntegrationTestConfig { Name = "MachineIdTest", Value = 99 };
await secureConfig.SetAsync("machine-key", testValue);
var result = await secureConfig.GetAsync<IntegrationTestConfig>("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<ISecureConfig>();
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<ISecureConfigBuilder> 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
{
}
@@ -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<ISecureConfig>();
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "initial-key" });
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
config.Reload();
var options = scope.ServiceProvider.GetRequiredService<IOptions<TestApiOptions>>();
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<IOptionsSnapshot<TestApiOptions>>();
using var scope2 = host.Services.CreateScope();
var snapshot2 = scope2.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
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<IOptionsMonitor<TestApiOptions>>();
monitor.Should().NotBeNull();
monitor.CurrentValue.Should().NotBeNull();
}
[Fact]
public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldReflectInIOptionsMonitor()
{
var host = await CreateHostAsync();
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
var monitor = host.Services.GetRequiredService<IOptionsMonitor<TestApiOptions>>();
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
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<IOptionsSnapshot<TestApiOptions>>();
var originalValue = snapshotBefore.Value.ApiKey;
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "snapshot-updated-key" });
config.Reload();
using var scope2 = host.Services.CreateScope();
var snapshotAfter = scope2.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
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<ISecureConfig>();
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
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<ISecureConfig>();
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "nested-key" });
config.Reload();
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
options.Value.ApiKey.Should().Be("nested-key");
}
[Fact]
public async Task ConfigureSecureConfig_WithFullHost_ItShouldStartAndResolveAllServices()
{
var host = await CreateHostAsync();
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
var snapshotFactory = host.Services.GetRequiredService<IServiceScopeFactory>();
var monitor = host.Services.GetRequiredService<IOptionsMonitor<TestApiOptions>>();
var configuration = host.Services.GetRequiredService<IConfiguration>();
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<IOptionsSnapshot<TestApiOptions>>();
snapshot.Should().NotBeNull();
}
[Fact]
public async Task ConfigureSecureConfig_WhenValueUpdated_ItShouldPropagateThroughEntirePipeline()
{
var host = await CreateHostAsync();
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
var monitor = host.Services.GetRequiredService<IOptionsMonitor<TestApiOptions>>();
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
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<IOptionsSnapshot<TestApiOptions>>();
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<ISecureConfigBuilder> CreateConfigBuilder() => builder =>
{
builder
.WithBase64EncryptionKey(_base64Key)
.WithAesCryptoProvider()
.UseJsonFileStorage(new JsonStorageOptions
{
DirectoryPath = _tempDir,
FileName = "secure-config.json"
})
.AddJsonAotContext(OptionsTestJsonContext.Default);
};
private async Task<IHost> CreateHostAsync()
{
var configure = CreateConfigBuilder();
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureLogging(c => c.ClearProviders())
.ConfigureAppConfiguration((_, builder) =>
{
builder.AddSecureConfig(configure);
})
.ConfigureServices((context, services) =>
{
services.Configure<TestApiOptions>(context.Configuration.GetSection(nameof(TestApiOptions)));
services.AddSecureConfig(configure);
});
var host = hostBuilder.Build();
await host.StartAsync();
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
if (string.IsNullOrEmpty((await secureConfig.GetAsync<TestApiOptions>(nameof(TestApiOptions)))?.ApiKey))
{
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "default-key" });
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
config.Reload();
}
return host;
}
}
internal sealed class TestApiOptions
{
public string ApiKey { get; init; } = string.Empty;
}
[JsonSerializable(typeof(TestApiOptions))]
internal partial class OptionsTestJsonContext : JsonSerializerContext
{
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0;</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.4.0" />
<PackageReference Include="coverlet.collector" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="AwesomeAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\StevanFreeborn.Extensions.Configuration.Secure\StevanFreeborn.Extensions.Configuration.Secure.csproj" />
</ItemGroup>
</Project>
@@ -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<JsonFileStorageProvider>();
}
[Fact]
public void UseJsonFileStorage_WithOptions_ItShouldUseProvidedOptions()
{
var options = new JsonStorageOptions
{
FileName = "custom.json",
DirectoryPath = "/custom/path"
};
_sut.UseJsonFileStorage(options);
_sut.StorageProvider.Should().BeOfType<JsonFileStorageProvider>();
}
[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<JsonFileStorageProvider>();
}
[Fact]
public void UseJsonFileStorage_WithConfigureAction_ItShouldApplyConfiguration()
{
_sut.UseJsonFileStorage(opt =>
{
opt.FileName = "configured.json";
opt.DirectoryPath = "/configured/path";
});
_sut.StorageProvider.Should().BeOfType<JsonFileStorageProvider>();
}
[Fact]
public void UseJsonFileStorage_WithNullConfigureAction_ItShouldThrowArgumentNullException()
{
var act = () => _sut.UseJsonFileStorage((Action<JsonStorageOptions>)null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("configure");
}
[Fact]
public void AddJsonAotContext_WithContext_ItShouldAddToResolverChain()
{
var mockContext = new Mock<IJsonTypeInfoResolver>();
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<ArgumentNullException>()
.WithParameterName("context");
}
[Fact]
public void AddJsonAotContext_WhenCalledMultipleTimes_ItShouldAddAllToChain()
{
var mockContext1 = new Mock<IJsonTypeInfoResolver>();
var mockContext2 = new Mock<IJsonTypeInfoResolver>();
_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<ISecureStorageProvider>();
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<ArgumentNullException>()
.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<StaticKeyProvider>();
}
[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<MachineIdKeyProvider>();
}
[Fact]
public void WithMachineIdKey_WhenCalled_ItShouldUseLoggerFactory()
{
var mockLoggerFactory = new Mock<ILoggerFactory>();
var mockLogger = new Mock<ILogger>();
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<IEncryptionKeyProvider>();
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<ArgumentNullException>()
.WithParameterName("provider");
}
[Fact]
public void WithLoggerFactory_WithFactory_ItShouldSetLoggerFactory()
{
var mockFactory = new Mock<ILoggerFactory>();
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<ILoggerFactory>();
_sut.WithLoggerFactory(mockFactory.Object);
_sut.WithLoggerFactory(null!);
_sut.LoggerFactory.Should().Be(NullLoggerFactory.Instance);
}
[Fact]
public void WithAesCryptoProvider_WhenCalled_ItShouldSetCryptoProviderFactory()
{
var mockKeyProvider = new Mock<IEncryptionKeyProvider>();
var result = _sut.WithAesCryptoProvider();
result.Should().BeSameAs(_sut);
_sut.CryptoProviderFactory.Should().NotBeNull();
var cryptoProvider = _sut.CryptoProviderFactory!(mockKeyProvider.Object);
cryptoProvider.Should().BeOfType<AesCryptoProvider>();
}
[Fact]
public void WithCustomCryptoProvider_WithFactory_ItShouldSetCryptoProviderFactory()
{
var mockCryptoProvider = new Mock<ICryptoProvider>();
var mockKeyProvider = new Mock<IEncryptionKeyProvider>();
Func<IEncryptionKeyProvider, ICryptoProvider> 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<ArgumentNullException>()
.WithParameterName("cryptoProviderFactory");
}
[Fact]
public void MethodChaining_WhenAllMethodsCalled_ItShouldConfigureAllProperties()
{
var mockStorageProvider = new Mock<ISecureStorageProvider>();
var mockKeyProvider = new Mock<IEncryptionKeyProvider>();
var mockLoggerFactory = new Mock<ILoggerFactory>();
var mockCryptoProvider = new Mock<ICryptoProvider>();
_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<ISecureStorageProvider>();
var mockProvider2 = new Mock<ISecureStorageProvider>();
_sut.UseCustomStorage(mockProvider1.Object)
.UseCustomStorage(mockProvider2.Object);
_sut.StorageProvider.Should().BeSameAs(mockProvider2.Object);
}
[Fact]
public void MethodChaining_WhenOverridingKeyProvider_ItShouldUseLastSetProvider()
{
var mockProvider1 = new Mock<IEncryptionKeyProvider>();
var mockProvider2 = new Mock<IEncryptionKeyProvider>();
_sut.WithCustomKeyProvider(mockProvider1.Object)
.WithCustomKeyProvider(mockProvider2.Object);
_sut.KeyProvider.Should().BeSameAs(mockProvider2.Object);
}
[Fact]
public void MethodChaining_WhenOverridingLoggerFactory_ItShouldUseLastSetFactory()
{
var mockFactory1 = new Mock<ILoggerFactory>();
var mockFactory2 = new Mock<ILoggerFactory>();
_sut.WithLoggerFactory(mockFactory1.Object)
.WithLoggerFactory(mockFactory2.Object);
_sut.LoggerFactory.Should().BeSameAs(mockFactory2.Object);
}
}
@@ -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<ISecureStorageProvider> _mockStorage = new();
private readonly Mock<ICryptoProvider> _mockCrypto = new();
private readonly Mock<ILogger<SecureConfigProvider>> _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<string, string>
{
{ rootKey, encryptedString },
};
_mockStorage.Setup(m => m.ReadAllAsync(It.IsAny<CancellationToken>())).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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string>());
var act = _sut.Load;
act.Should().NotThrow();
}
[Fact]
public void Load_WhenDecryptionFails_ItShouldLogErrorAndContinue()
{
var storedData = new Dictionary<string, string>
{
{ "ValidKey", "encrypted_valid" },
{ "BadKey", "encrypted_bad" },
};
_mockLogger.Setup(m => m.IsEnabled(LogLevel.Warning)).Returns(true);
_mockStorage.Setup(m => m.ReadAllAsync(It.IsAny<CancellationToken>())).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<EventId>(id => id.Id == 3),
It.Is<It.IsAnyType>((state, type) => state.ToString()!.Contains("Failed to decrypt value for key")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once()
);
}
[Fact]
public void Load_WhenCalledWithDeeplyNestedObject_ItShouldFlattenCorrectly()
{
var rawJson = @"{
""Level1"": {
""Level2"": {
""Level3"": {
""Value"": ""deep_value""
}
}
}
}";
_mockStorage.Setup(m => m.ReadAllAsync(It.IsAny<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string>
{
{ "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "Bad", "enc" } });
_mockCrypto.Setup(m => m.Decrypt("enc")).Returns("not valid json{{{");
_sut.Load();
_mockLogger.Verify(x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, _) => v.ToString()!.Contains("Bad")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once()
);
}
}
@@ -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<ISecureStorageProvider> _mockStorage = new();
private readonly Mock<ICryptoProvider> _mockCrypto = new();
private readonly Mock<ILoggerFactory> _mockLoggerFactory = new();
[Fact]
public void Constructor_WhenStorageProviderIsNull_ItShouldThrowArgumentNullException()
{
var act = () => new SecureConfigSource(
storageProvider: null!,
cryptoProvider: _mockCrypto.Object,
loggerFactory: _mockLoggerFactory.Object
);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("storageProvider");
}
[Fact]
public void Constructor_WhenCryptoProviderIsNull_ItShouldThrowArgumentNullException()
{
var act = () => new SecureConfigSource(
storageProvider: _mockStorage.Object,
cryptoProvider: null!,
loggerFactory: _mockLoggerFactory.Object
);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("cryptoProvider");
}
[Fact]
public void Constructor_WhenLoggerFactoryIsNull_ItShouldThrowArgumentNullException()
{
var act = () => new SecureConfigSource(
storageProvider: _mockStorage.Object,
cryptoProvider: _mockCrypto.Object,
loggerFactory: null!
);
act.Should().Throw<ArgumentNullException>()
.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<IConfigurationBuilder>();
var result = sut.Build(mockBuilder.Object);
result.Should().NotBeNull();
result.Should().BeOfType<SecureConfigProvider>();
}
[Fact]
public void Build_WhenCalled_ItShouldCreateLoggerFromFactory()
{
var mockLogger = new Mock<ILogger<SecureConfigProvider>>();
_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<IConfigurationBuilder>();
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<IConfigurationBuilder>();
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<SecureConfigProvider>();
}
}
@@ -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<ICryptoProvider> _mockCryptoProvider = new();
private readonly Mock<ISecureStorageProvider> _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<ArgumentNullException>();
}
[Fact]
public void Constructor_WhenCalledWithNullCryptoProvider_ItShouldThrowArgumentNullException()
{
var act = () => new SecureConfig(_mockStorageProvider.Object, null!, _jsonSerializerOptions);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Constructor_WhenCalledWithNullJsonOptions_ItShouldThrowArgumentNullException()
{
var act = () => new SecureConfig(_mockStorageProvider.Object, _mockCryptoProvider.Object, null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public async Task SetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
{
var act = async () => await _sut.SetAsync(null!, string.Empty, SecureConfigTestsJsonContext.Default.String);
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task SetAsync_WhenCalledWithNullValue_ItShouldThrowArgumentNullException()
{
var act = async () => await _sut.SetAsync("Key", null!, SecureConfigTestsJsonContext.Default.String);
await act.Should().ThrowAsync<ArgumentNullException>();
}
[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<InvalidOperationException>();
}
[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<CancellationToken>()), 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<CancellationToken>()), Times.Once());
}
[Fact]
public async Task GetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
{
var act = async () => await _sut.GetAsync(null!, SecureConfigTestsJsonContext.Default.DummyConfig);
await act.Should().ThrowAsync<ArgumentException>();
}
[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<CancellationToken>())).ReturnsAsync(encryptedString);
_mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json);
var act = async () => await _sut.GetAsync<DummyConfig>(key);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[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<CancellationToken>())).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<CancellationToken>())).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<CancellationToken>())).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<CancellationToken>())).ReturnsAsync(true);
var result = await _sut.DeleteAsync(key);
result.Should().BeTrue();
_mockStorageProvider.Verify(m => m.DeleteAsync(key, It.IsAny<CancellationToken>()), Times.Once());
}
}
internal sealed record DummyConfig(string Host, int Port);
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(DummyConfig))]
internal partial class SecureConfigTestsJsonContext : JsonSerializerContext
{
}
@@ -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<IEncryptionKeyProvider> _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<CryptographicException>();
}
}
@@ -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<ILogger<MachineIdKeyGenerator>> _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);
}
}
@@ -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<IMachineIdKeyGenerator> _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<IMachineIdKeyGenerator>();
mockInstanceOne.Setup(static m => m.GetId()).Returns(input);
var instanceOne = new MachineIdKeyProvider(mockInstanceOne.Object);
var resultOne = instanceOne.GetKey();
var mockInstanceTwo = new Mock<IMachineIdKeyGenerator>();
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);
}
}
@@ -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<ArgumentException>();
}
[Fact]
public void Constructor_WhenCalledAndKeyIsNot32Bytes_ItShouldThrowArgumentException()
{
var shortKey = Convert.ToBase64String(new byte[16]);
var act = () => new StaticKeyProvider(shortKey);
act.Should().Throw<ArgumentException>().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);
}
}
@@ -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<ISecureStorageProvider> _mockStorageProvider = new();
private readonly Mock<IEncryptionKeyProvider> _mockKeyProvider = new();
private readonly Mock<ICryptoProvider> _mockCryptoProvider = new();
private readonly Mock<ILoggerFactory> _mockLoggerFactory = new();
[Fact]
public void AddSecureConfig_WithNullBuilder_ItShouldThrowArgumentNullException()
{
IConfigurationBuilder builder = null!;
var act = () => builder.AddSecureConfig(config => { });
act.Should().Throw<ArgumentNullException>()
.WithParameterName("builder");
}
[Fact]
public void AddSecureConfig_WithNullConfigure_ItShouldThrowArgumentNullException()
{
var builder = new ConfigurationBuilder();
var act = () => builder.AddSecureConfig(null!);
act.Should().Throw<ArgumentNullException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, string>());
var mockLogger = new Mock<ILogger<SecureConfigProvider>>();
_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<SecureConfigSource>();
}
[Fact]
public void AddSecureConfig_ServiceCollection_WithNullServices_ItShouldThrowArgumentNullException()
{
IServiceCollection services = null!;
var act = () => services.AddSecureConfig(config => { });
act.Should().Throw<ArgumentNullException>()
.WithParameterName("services");
}
[Fact]
public void AddSecureConfig_ServiceCollection_WithNullConfigure_ItShouldThrowArgumentNullException()
{
var services = new ServiceCollection();
var act = () => services.AddSecureConfig(null!);
act.Should().Throw<ArgumentNullException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<ISecureConfig>();
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<ISecureConfig>();
var instance2 = serviceProvider.GetRequiredService<ISecureConfig>();
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<ISecureStorageProvider>();
var instance2 = serviceProvider.GetRequiredService<ISecureStorageProvider>();
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<IEncryptionKeyProvider>();
var instance2 = serviceProvider.GetRequiredService<IEncryptionKeyProvider>();
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<ICryptoProvider>();
var instance2 = serviceProvider.GetRequiredService<ICryptoProvider>();
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<ISecureConfig>();
var storageProvider = serviceProvider.GetRequiredService<ISecureStorageProvider>();
var testObject = new TestConfig { Name = "TestName", Value = 123 };
await secureConfig.SetAsync("TestKey", testObject);
var retrievedObject = await secureConfig.GetAsync<TestConfig>("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<ISecureConfig>();
var testObject = new TestConfig { Name = "MachineIdTest", Value = 456 };
await secureConfig.SetAsync("MachineTest", testObject);
var retrievedObject = await secureConfig.GetAsync<TestConfig>("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<JsonSerializerOptions>("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<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, string> { { "SecureSection", encryptedValue } });
_mockCryptoProvider
.Setup(c => c.Decrypt(encryptedValue))
.Returns(decryptedJson);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, string>
{
{ "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
{
}
@@ -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<ArgumentNullException>();
}
[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<string, string>()
{
["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<Task>();
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);
}
}
@@ -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);
}
}