forked from gongxuegit/tiku-backend.net
feat: establish dotnet engineering foundation
This commit is contained in:
@@ -10,7 +10,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdminService
|
||||
internal sealed class CommerceAdminService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantSecretProtector tenantSecretProtector) : ICommerceAdminService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
|
||||
CommerceAdminActor actor,
|
||||
@@ -88,11 +90,19 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
||||
secret.RotatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
var protectedPayload = tenantSecretProtector.Protect(
|
||||
actor.TenantId,
|
||||
secretRef,
|
||||
JsonObjectOrDefault(command.SecretPayload));
|
||||
|
||||
secret.Purpose = command.Purpose.Trim();
|
||||
secret.Provider = provider;
|
||||
secret.SecretKey = command.SecretKey.Trim();
|
||||
secret.Status = command.Status;
|
||||
secret.SecretPayload = JsonObjectOrDefault(command.SecretPayload);
|
||||
secret.EncryptionKeyId = protectedPayload.KeyId;
|
||||
secret.EncryptedPayload = protectedPayload.Ciphertext;
|
||||
secret.EncryptionNonce = protectedPayload.Nonce;
|
||||
secret.EncryptionTag = protectedPayload.Tag;
|
||||
secret.ExpiresAt = command.ExpiresAt;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
116
Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs
Normal file
116
Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
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) =>
|
||||
string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
internal interface ITenantSecretProtector
|
||||
{
|
||||
ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload);
|
||||
|
||||
JsonElement Unprotect(
|
||||
Guid tenantId,
|
||||
string secretRef,
|
||||
string keyId,
|
||||
byte[] ciphertext,
|
||||
byte[] nonce,
|
||||
byte[] tag);
|
||||
}
|
||||
|
||||
internal sealed record ProtectedTenantSecret(
|
||||
string KeyId,
|
||||
byte[] Ciphertext,
|
||||
byte[] Nonce,
|
||||
byte[] Tag);
|
||||
|
||||
internal sealed class TenantSecretProtector(
|
||||
Microsoft.Extensions.Options.IOptions<TenantSecretEncryptionOptions> 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) =>
|
||||
Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}");
|
||||
}
|
||||
@@ -6,7 +6,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecretService
|
||||
internal sealed class TenantSecretService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantSecretProtector tenantSecretProtector) : ITenantSecretService
|
||||
{
|
||||
public async Task<JsonElement> GetActiveSecretPayloadAsync(
|
||||
Guid tenantId,
|
||||
@@ -28,16 +30,28 @@ internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecr
|
||||
item.SecretRef == secretRef &&
|
||||
item.Status == TenantSecretStatus.Active &&
|
||||
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||
.Select(item => item.SecretPayload)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (secret.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
if (secret is null)
|
||||
{
|
||||
throw new PaymentProviderException(
|
||||
"Payment provider secret is not configured.",
|
||||
"payment_secret_not_configured");
|
||||
}
|
||||
|
||||
return secret;
|
||||
if (secret.EncryptedPayload.Length > 0)
|
||||
{
|
||||
return tenantSecretProtector.Unprotect(
|
||||
tenantId,
|
||||
secretRef,
|
||||
secret.EncryptionKeyId,
|
||||
secret.EncryptedPayload,
|
||||
secret.EncryptionNonce,
|
||||
secret.EncryptionTag);
|
||||
}
|
||||
|
||||
throw new PaymentProviderException(
|
||||
"Payment provider secret is not configured.",
|
||||
"payment_secret_not_configured");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user