From c1932246f30f2f8c8a7537b6c1d5045b71dd742f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 6 Feb 2026 06:46:35 -0600 Subject: [PATCH] feat(infra): create initial encryption implementations --- src/FiscalOS.Infra/FiscalOS.Infra.csproj | 1 + src/FiscalOS.Infra/Security/Encryptor.cs | 105 ++++++++++++++++++ src/FiscalOS.Infra/Security/FileKeyRing.cs | 90 +++++++++++++++ .../Security/FileKeyRingOptions.cs | 23 ++++ src/FiscalOS.Infra/Usings.cs | 4 + 5 files changed, 223 insertions(+) create mode 100644 src/FiscalOS.Infra/Security/Encryptor.cs create mode 100644 src/FiscalOS.Infra/Security/FileKeyRing.cs create mode 100644 src/FiscalOS.Infra/Security/FileKeyRingOptions.cs diff --git a/src/FiscalOS.Infra/FiscalOS.Infra.csproj b/src/FiscalOS.Infra/FiscalOS.Infra.csproj index 877c74d..3d93af5 100644 --- a/src/FiscalOS.Infra/FiscalOS.Infra.csproj +++ b/src/FiscalOS.Infra/FiscalOS.Infra.csproj @@ -4,6 +4,7 @@ + diff --git a/src/FiscalOS.Infra/Security/Encryptor.cs b/src/FiscalOS.Infra/Security/Encryptor.cs new file mode 100644 index 0000000..0d8d45b --- /dev/null +++ b/src/FiscalOS.Infra/Security/Encryptor.cs @@ -0,0 +1,105 @@ + +namespace FiscalOS.Infra.Security; + +public sealed class Encryptor : IEncryptor +{ + private readonly IKeyRing _keyRing; + + private Encryptor(IKeyRing keyRing) + { + _keyRing = keyRing; + } + + public static Encryptor From(IServiceProvider serviceProvider) + { + var keyRing = serviceProvider.GetRequiredService(); + return new Encryptor(keyRing); + } + + public static Encryptor From(IKeyRing keyRing) + { + return new Encryptor(keyRing); + } + + private KeyRingEntry PrimaryKey => _keyRing.GetPrimaryKey(); + + private static async Task DecryptCoreAsync(string key, string cipherText, CancellationToken ct) + { + var cipherTextBytes = Convert.FromBase64String(cipherText); + + using var aes = Aes.Create(); + aes.Key = Convert.FromBase64String(key); + + var iv = new byte[16]; + Array.Copy(cipherTextBytes, 0, iv, 0, 16); + aes.IV = iv; + + var decryptor = aes.CreateDecryptor(); + + using var memoryStream = new MemoryStream(cipherTextBytes, 16, cipherTextBytes.Length - 16); + using var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read); + using var streamReader = new StreamReader(cryptoStream); + + return await streamReader.ReadToEndAsync(ct).ConfigureAwait(false); + } + + public async Task DecryptAsync(string cipherText, CancellationToken ct) + { + return await DecryptCoreAsync(PrimaryKey.Key, cipherText, ct).ConfigureAwait(false); + } + + public async Task DecryptAsyncFor(User user, string plainText, CancellationToken ct) + { + var decryptedKey = await DecryptCoreAsync(PrimaryKey.Key, user.EncryptedDataKey, ct).ConfigureAwait(false); + return await DecryptCoreAsync(decryptedKey, plainText, ct).ConfigureAwait(false); + } + + private static async Task EncryptCoreAsync(string key, string plainText, CancellationToken ct) + { + using var aes = Aes.Create(); + aes.Key = Convert.FromBase64String(key); + + using var memoryStream = new MemoryStream(); + aes.GenerateIV(); + await memoryStream.WriteAsync(aes.IV, ct).ConfigureAwait(false); + + var encryptor = aes.CreateEncryptor(); + + using var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write); + using var streamWriter = new StreamWriter(cryptoStream); + + await streamWriter.WriteAsync(plainText.ToCharArray(), ct).ConfigureAwait(false); + await streamWriter.FlushAsync(ct).ConfigureAwait(false); + streamWriter.Close(); + + return Convert.ToBase64String(memoryStream.ToArray()); + } + + public async Task EncryptAsync(string plainText, CancellationToken ct) + { + return await EncryptCoreAsync(PrimaryKey.Key, plainText, ct).ConfigureAwait(false); + } + + public async Task EncryptAsyncFor(User user, string plainText, CancellationToken ct) + { + var decryptedKey = await DecryptCoreAsync(PrimaryKey.Key, user.EncryptedDataKey, ct).ConfigureAwait(false); + return await EncryptCoreAsync(decryptedKey, plainText, ct).ConfigureAwait(false); + } + + public string GenerateKey() + { + using var aes = Aes.Create(); + aes.GenerateKey(); + + return Convert.ToBase64String(aes.Key); + } + + public async Task GenerateEncryptedKeyAsync(CancellationToken ct) + { + var key = GenerateKey(); + var keyUsed = PrimaryKey; + var encryptedKey = await EncryptCoreAsync(keyUsed.Key, key, ct).ConfigureAwait(false); + return EncryptedDataKey.From(keyUsed.KeyId, encryptedKey); + } + +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Security/FileKeyRing.cs b/src/FiscalOS.Infra/Security/FileKeyRing.cs new file mode 100644 index 0000000..bc6b2e2 --- /dev/null +++ b/src/FiscalOS.Infra/Security/FileKeyRing.cs @@ -0,0 +1,90 @@ + +namespace FiscalOS.Infra.Security; + +public sealed class FileKeyRing : IKeyRing, IDisposable +{ + private const string KeyFileExtension = ".key"; + private FileKeyRingOptions _options; + private readonly IFileSystem _fileSystem; + private readonly IDisposable? _optionsChangeHandler; + private readonly Dictionary _keys = []; + private string KeyRingPath => _fileSystem.Path.GetFullPath(_options.KeysDirectoryPath, AppContext.BaseDirectory); + + private FileKeyRing(IOptionsMonitor options, IFileSystem fileSystem) + { + _fileSystem = fileSystem; + _options = options.CurrentValue; + + LoadKeys(); + + _optionsChangeHandler = options.OnChange(options => + { + _options = options; + LoadKeys(); + }); + } + + public static FileKeyRing From(IServiceProvider serviceProvider) + { + var options = serviceProvider.GetRequiredService>(); + var fileSystem = serviceProvider.GetRequiredService(); + + return new(options, fileSystem); + } + + public static FileKeyRing From(IOptionsMonitor options, IFileSystem fileSystem) + { + return new(options, fileSystem); + } + + public KeyRingEntry GetKey(string keyId) + { + if (_keys.TryGetValue(keyId, out var key)) + { + return key; + } + + throw new KeyNotFoundException($"Key with ID '{keyId}' not found in the key ring."); + } + + public KeyRingEntry GetPrimaryKey() + { + return GetKey(_options.PrimaryKeyId); + } + + private void LoadKeys() + { + _keys.Clear(); + + if (_fileSystem.Directory.Exists(KeyRingPath)) + { + var keyFiles = _fileSystem.Directory.GetFiles(KeyRingPath, $"*{KeyFileExtension}"); + + foreach (var keyFile in keyFiles) + { + var keyId = _fileSystem.Path.GetFileNameWithoutExtension(keyFile); + var key = _fileSystem.File.ReadAllText(keyFile).Trim(); + + if (string.IsNullOrEmpty(key) is false) + { + _keys[keyId] = KeyRingEntry.From(keyId, key); + } + } + } + } + + public void Dispose() + { + _optionsChangeHandler?.Dispose(); + } + + public async Task SaveKeyAsync(string key) + { + var id = Guid.NewGuid().ToString(); + var entry = KeyRingEntry.From(id, key); + var filename = id + KeyFileExtension; + var entryPath = _fileSystem.Path.Combine(KeyRingPath, filename); + await _fileSystem.File.WriteAllTextAsync(entryPath, key).ConfigureAwait(false); + return entry; + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Security/FileKeyRingOptions.cs b/src/FiscalOS.Infra/Security/FileKeyRingOptions.cs new file mode 100644 index 0000000..0daad64 --- /dev/null +++ b/src/FiscalOS.Infra/Security/FileKeyRingOptions.cs @@ -0,0 +1,23 @@ +namespace FiscalOS.Infra.Security; + +public sealed record FileKeyRingOptions +{ + public string KeysDirectoryPath { get; init; } = string.Empty; + public string PrimaryKeyId { get; init; } = string.Empty; +} + +public sealed record class FileKeyRingOptionsSetup : IConfigureOptions +{ + private const string SectionName = nameof(FileKeyRingOptions); + private readonly IConfiguration _configuration; + + public FileKeyRingOptionsSetup(IConfiguration configuration) + { + _configuration = configuration; + } + + public void Configure(FileKeyRingOptions options) + { + _configuration.GetSection(SectionName).Bind(options); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Usings.cs b/src/FiscalOS.Infra/Usings.cs index b428c29..a681a67 100644 --- a/src/FiscalOS.Infra/Usings.cs +++ b/src/FiscalOS.Infra/Usings.cs @@ -1,4 +1,5 @@ global using System.IdentityModel.Tokens.Jwt; +global using System.IO.Abstractions; global using System.Security.Claims; global using System.Security.Cryptography; global using System.Text; @@ -7,10 +8,13 @@ global using System.Text.Json; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Data; global using FiscalOS.Core.Identity; +global using FiscalOS.Core.Security; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authorization; global using FiscalOS.Infra.Data; +global using FiscalOS.Infra.Security; +global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Authorization.Policy; global using Microsoft.AspNetCore.Http;