diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs index 2b7fd41..43d966e 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/ApiOptions.cs @@ -3,4 +3,4 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; public sealed record ApiOptions { public string ApiKey { get; init; } = string.Empty; -} \ No newline at end of file +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs index dd63a7a..29eccc9 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/AppJsonContext.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization; namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; [JsonSerializable(typeof(ApiOptions))] +[JsonSerializable(typeof(DatabaseSettings))] +[JsonSerializable(typeof(SmtpSettings))] internal partial class AppJsonContext : JsonSerializerContext { -} \ No newline at end of file +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs new file mode 100644 index 0000000..c627a77 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/DatabaseSettings.cs @@ -0,0 +1,8 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; + +public sealed record DatabaseSettings +{ + public string ConnectionString { get; init; } = string.Empty; + public int Timeout { get; init; } + public int RetryCount { get; init; } +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs index e58234a..cdfe47e 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/Program.cs @@ -1,59 +1,441 @@ -using Microsoft.Extensions.Configuration; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StevanFreeborn.Extensions.Configuration.Secure; using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; using StevanFreeborn.Extensions.Configuration.Secure.Sample; using StevanFreeborn.Extensions.Configuration.Secure.Storage; -Action configure = builder => -{ - builder - .WithMachineIdKey() - .WithAesCryptoProvider() - .UseJsonFileStorage(new JsonStorageOptions()) - .AddJsonAotContext(AppJsonContext.Default); -}; +Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ StevanFreeborn.Extensions.Configuration.Secure Sample ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); +Console.WriteLine(); -var builder = Host.CreateDefaultBuilder() - .ConfigureAppConfiguration((_, b) => +await Demo1_BasicFileStorageWithBase64Key(); +await Demo2_MachineIdKeyDerivation(); +await Demo3_HostAndIOptionsIntegration(); +await Demo4_ConfigurationProviderPattern(); +await Demo5_CustomStorageProvider(); +await Demo6_CustomKeyProvider(); +await Demo7_TypedAOTOverloads(); + +Console.WriteLine("All demos completed."); + +static async Task Demo1_BasicFileStorageWithBase64Key() +{ + PrintHeader("Demo 1: Basic File Storage with Base64 Key"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => { - b.AddSecureConfig(configure); - }) - .ConfigureServices((ctx, s) => - { - s.Configure(ctx.Configuration.GetSection(nameof(ApiOptions))); - s.AddSecureConfig(configure); + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo1.json", + }) + .AddJsonAotContext(AppJsonContext.Default); }); -var app = builder.Build(); + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); -var options = app.Services.GetRequiredService>(); -var optionsMonitor = app.Services.GetRequiredService>(); -var secureConfig = app.Services.GetRequiredService(); -var scopeFactory = app.Services.GetRequiredService(); + var dbSettings = new DatabaseSettings + { + ConnectionString = "Server=localhost;Database=MyDb;Trusted_Connection=True;", + Timeout = 30, + RetryCount = 3, + }; -var firstScope = scopeFactory.CreateScope(); -var firstSnapshot = firstScope.ServiceProvider.GetRequiredService>(); + Console.WriteLine(" Storing DatabaseSettings..."); + await secureConfig.SetAsync("Database", dbSettings); -var originalValue = await secureConfig.GetAsync(nameof(ApiOptions)); -Console.WriteLine($"Config: {originalValue}"); -Console.WriteLine($"IOptions: {options.Value}"); -Console.WriteLine($"IOptionsSnapshot 1: {firstSnapshot.Value}"); -Console.WriteLine($"IOptionsMonitor: {optionsMonitor.CurrentValue}"); + var retrieved = await secureConfig.GetAsync("Database"); + Console.WriteLine($" Retrieved: ConnectionString={retrieved!.ConnectionString}"); + Console.WriteLine($" Retrieved: Timeout={retrieved.Timeout}, RetryCount={retrieved.RetryCount}"); -await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = Guid.NewGuid().ToString() }); + var smtpSettings = new SmtpSettings + { + Host = "smtp.example.com", + Port = 587, + Username = "user@example.com", + Password = "s3cretP@ssw0rd!", + UseSsl = true, + }; -var config = (IConfigurationRoot)app.Services.GetRequiredService(); -config.Reload(); + Console.WriteLine(" Storing SmtpSettings (sensitive data)..."); + await secureConfig.SetAsync("Smtp", smtpSettings); -var updatedValue = await secureConfig.GetAsync(nameof(ApiOptions)); -var secondScope = scopeFactory.CreateScope(); -var secondSnapshot = secondScope.ServiceProvider.GetRequiredService>(); + var smtp = await secureConfig.GetAsync("Smtp"); + Console.WriteLine($" Retrieved: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}"); + Console.WriteLine($" Retrieved: Username={smtp.Username}, Password={smtp.Password}"); -Console.WriteLine($"Config: {updatedValue}"); -Console.WriteLine($"IOptions: {options.Value}"); -Console.WriteLine($"IOptionsSnapshot 2: {secondSnapshot.Value}"); -Console.WriteLine($"IOptionsMonitor: {optionsMonitor.CurrentValue}"); \ No newline at end of file + Console.WriteLine(" Deleting Smtp settings..."); + var deleted = await secureConfig.DeleteAsync("Smtp"); + Console.WriteLine($" Deleted: {deleted}"); + + var missing = await secureConfig.GetAsync("Smtp"); + Console.WriteLine($" After delete: {(missing is null ? "null (as expected)" : "still present")}"); + + Console.WriteLine(); +} + +static async Task Demo2_MachineIdKeyDerivation() +{ + PrintHeader("Demo 2: Machine ID Key Derivation"); + + using var tempDir = new TempDirectory(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithMachineIdKey() + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo2.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + Console.WriteLine(" Storing config encrypted with machine-derived key..."); + await secureConfig.SetAsync("MachineLocked", new ApiOptions { ApiKey = "machine-specific-secret" }); + + var value = await secureConfig.GetAsync("MachineLocked"); + Console.WriteLine($" Retrieved on same machine: ApiKey={value!.ApiKey}"); + Console.WriteLine(" (This data would NOT be decryptable on a different machine)"); + + Console.WriteLine(); +} + +static async Task Demo3_HostAndIOptionsIntegration() +{ + PrintHeader("Demo 3: Host + IOptions / IOptionsSnapshot / IOptionsMonitor"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + Action configure = builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo3.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }; + + var host = await Host.CreateDefaultBuilder() + .ConfigureAppConfiguration((_, b) => b.AddSecureConfig(configure)) + .ConfigureServices((ctx, s) => + { + s.Configure(ctx.Configuration.GetSection(nameof(ApiOptions))); + s.AddSecureConfig(configure); + }) + .StartAsync(); + + var secureConfig = host.Services.GetRequiredService(); + var options = host.Services.GetRequiredService>(); + var monitor = host.Services.GetRequiredService>(); + var config = (IConfigurationRoot)host.Services.GetRequiredService(); + + Console.WriteLine(" Setting initial ApiOptions..."); + await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "initial-key-001" }); + config.Reload(); + + Console.WriteLine($" IOptions: {options.Value.ApiKey}"); + Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey}"); + + Console.WriteLine(" Updating ApiOptions and reloading config..."); + await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "updated-key-002" }); + config.Reload(); + + Console.WriteLine($" IOptions: {options.Value.ApiKey} (unchanged - singleton)"); + Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey} (updated)"); + + using (var scope1 = host.Services.CreateScope()) + { + var snapshot1 = scope1.ServiceProvider.GetRequiredService>(); + Console.WriteLine($" IOptionsSnapshot 1: {snapshot1.Value.ApiKey} (new scope, sees update)"); + } + + await host.StopAsync(); + Console.WriteLine(); +} + +static async Task Demo4_ConfigurationProviderPattern() +{ + PrintHeader("Demo 4: IConfigurationBuilder Pattern (No DI)"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + var seedServices = new ServiceCollection(); + seedServices.AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo4.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var seedProvider = seedServices.BuildServiceProvider(); + var secureConfig = seedProvider.GetRequiredService(); + + Console.WriteLine(" Seeding data via ISecureConfig..."); + await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "from-config-builder" }); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo4.json", + }); + }) + .Build(); + + var apiKey = configuration["ApiOptions:ApiKey"]; + Console.WriteLine($" Read via IConfiguration: ApiOptions:ApiKey = {apiKey}"); + + Console.WriteLine(); +} + +static async Task Demo5_CustomStorageProvider() +{ + PrintHeader("Demo 5: Custom Storage Provider (In-Memory)"); + + var key = GenerateBase64Key(); + var memoryStore = new Dictionary(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseCustomStorage(new InMemoryStorageProvider(memoryStore)) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + Console.WriteLine(" Storing ApiOptions in in-memory storage..."); + await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "in-memory-secret" }); + + Console.WriteLine($" Memory store now has {memoryStore.Count} encrypted entry(ies)"); + Console.WriteLine($" Encrypted value starts with: {memoryStore["ApiOptions"][..40]}..."); + + var retrieved = await secureConfig.GetAsync("ApiOptions"); + Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}"); + + Console.WriteLine(); +} + +static async Task Demo6_CustomKeyProvider() +{ + PrintHeader("Demo 6: Custom Key Provider"); + + using var tempDir = new TempDirectory(); + + var customKeyProvider = new EnvironmentVariableKeyProvider("MY_SECURE_CONFIG_KEY"); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithCustomKeyProvider(customKeyProvider) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo6.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + Console.WriteLine(" Storing ApiOptions with custom key from environment variable..."); + await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "env-var-protected" }); + + var retrieved = await secureConfig.GetAsync("ApiOptions"); + Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}"); + + Console.WriteLine(); +} + +static async Task Demo7_TypedAOTOverloads() +{ + PrintHeader("Demo 7: Typed AOT Overloads (Native AOT Support)"); + + using var tempDir = new TempDirectory(); + var key = GenerateBase64Key(); + + var services = new ServiceCollection(); + services.AddSecureConfig(builder => + { + builder + .WithBase64EncryptionKey(key) + .WithAesCryptoProvider() + .UseJsonFileStorage(new JsonStorageOptions + { + DirectoryPath = tempDir.Path, + FileName = "demo7.json", + }) + .AddJsonAotContext(AppJsonContext.Default); + }); + + var provider = services.BuildServiceProvider(); + var secureConfig = provider.GetRequiredService(); + + JsonTypeInfo dbTypeInfo = AppJsonContext.Default.DatabaseSettings; + JsonTypeInfo smtpTypeInfo = AppJsonContext.Default.SmtpSettings; + + Console.WriteLine(" Setting values using explicit JsonTypeInfo overloads..."); + await secureConfig.SetAsync( + "Database", + new DatabaseSettings { ConnectionString = "Server=prod;Database=Main;", Timeout = 60, RetryCount = 5 }, + dbTypeInfo + ); + + await secureConfig.SetAsync( + "Smtp", + new SmtpSettings { Host = "smtp.prod.com", Port = 465, Username = "admin", Password = "prod!", UseSsl = true }, + smtpTypeInfo + ); + + Console.WriteLine(" Getting values using explicit JsonTypeInfo overloads..."); + var db = await secureConfig.GetAsync("Database", dbTypeInfo); + var smtp = await secureConfig.GetAsync("Smtp", smtpTypeInfo); + + Console.WriteLine($" Database: ConnectionString={db!.ConnectionString}, Timeout={db.Timeout}"); + Console.WriteLine($" Smtp: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}"); + + Console.WriteLine(); +} + +static string GenerateBase64Key() +{ + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + return Convert.ToBase64String(key); +} + +static void PrintHeader(string title) +{ + Console.WriteLine($"── {title} ──"); +} + +sealed class TempDirectory : IDisposable +{ + public string Path { get; } + + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } +} + +sealed class InMemoryStorageProvider : ISecureStorageProvider +{ + private readonly Dictionary _store; + + public InMemoryStorageProvider(Dictionary store) + { + _store = store; + } + + public event EventHandler? StorageChanged; + + public Task ReadAsync(string key, CancellationToken ct = default) + { + return _store.TryGetValue(key, out var value) ? Task.FromResult(value) : Task.FromResult(string.Empty); + } + + public Task> ReadAllAsync(CancellationToken ct = default) + { + return Task.FromResult>(new Dictionary(_store)); + } + + public Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) + { + _store[key] = encryptedData; + StorageChanged?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + + public Task DeleteAsync(string key, CancellationToken ct = default) + { + return Task.FromResult(_store.Remove(key)); + } + + public void Dispose() + { + } +} + +sealed class EnvironmentVariableKeyProvider : IEncryptionKeyProvider +{ + private readonly string _variableName; + + public EnvironmentVariableKeyProvider(string variableName) + { + _variableName = variableName; + } + + public byte[] GetKey() + { + var value = Environment.GetEnvironmentVariable(_variableName); + + if (string.IsNullOrWhiteSpace(value)) + { + Console.WriteLine($" [Warning] Environment variable '{_variableName}' not set. Using SHA256 hash of variable name as key."); + value = _variableName; + } + + return SHA256.HashData(Encoding.UTF8.GetBytes(value)); + } +} diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs new file mode 100644 index 0000000..7d52e0d --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure.Sample/SmtpSettings.cs @@ -0,0 +1,10 @@ +namespace StevanFreeborn.Extensions.Configuration.Secure.Sample; + +public sealed record SmtpSettings +{ + public string Host { get; init; } = string.Empty; + public int Port { get; init; } + public string Username { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; + public bool UseSsl { get; init; } +}