using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; namespace Tiku.Infrastructure.Commerce; public sealed class TenantSecretEncryptionOptions { public const string SectionName = "Security:TenantSecrets"; public const string DevelopmentMasterKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; public string KeyId { get; set; } = string.Empty; public string MasterKey { get; set; } = string.Empty; public static bool BeValid(TenantSecretEncryptionOptions options) { if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.MasterKey)) return false; try { return Convert.FromBase64String(options.MasterKey).Length == 32; } catch (FormatException) { return false; } } public static bool IsDevelopmentDefault(TenantSecretEncryptionOptions options) { return string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal); } } public interface ITenantSecretProtector { ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload); JsonElement Unprotect( Guid tenantId, string secretRef, string keyId, byte[] ciphertext, byte[] nonce, byte[] tag); } public sealed record ProtectedTenantSecret( string KeyId, byte[] Ciphertext, byte[] Nonce, byte[] Tag); public sealed class TenantSecretProtector( IOptions options) : ITenantSecretProtector { private readonly TenantSecretEncryptionOptions options = options.Value; public ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload) { var key = Convert.FromBase64String(options.MasterKey); var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload); var nonce = RandomNumberGenerator.GetBytes(12); var ciphertext = new byte[plaintext.Length]; var tag = new byte[16]; var associatedData = GetAssociatedData(tenantId, secretRef, options.KeyId); try { using var aes = new AesGcm(key, tag.Length); aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData); return new ProtectedTenantSecret(options.KeyId, ciphertext, nonce, tag); } finally { CryptographicOperations.ZeroMemory(key); CryptographicOperations.ZeroMemory(plaintext); } } public JsonElement Unprotect( Guid tenantId, string secretRef, string keyId, byte[] ciphertext, byte[] nonce, byte[] tag) { if (!string.Equals(keyId, options.KeyId, StringComparison.Ordinal)) throw new InvalidOperationException( $"Tenant secret uses unknown encryption key '{keyId}'."); var key = Convert.FromBase64String(options.MasterKey); var plaintext = new byte[ciphertext.Length]; var associatedData = GetAssociatedData(tenantId, secretRef, keyId); try { using var aes = new AesGcm(key, tag.Length); aes.Decrypt(nonce, ciphertext, tag, plaintext, associatedData); using var document = JsonDocument.Parse(plaintext); return document.RootElement.Clone(); } finally { CryptographicOperations.ZeroMemory(key); CryptographicOperations.ZeroMemory(plaintext); } } private static byte[] GetAssociatedData(Guid tenantId, string secretRef, string keyId) { return Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}"); } }