feat: establish dotnet engineering foundation

This commit is contained in:
2026-07-27 15:00:14 +08:00
parent 60a376ea0b
commit 28e9a9fa41
36 changed files with 17301 additions and 89 deletions

View File

@@ -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);

View 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}");
}

View File

@@ -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");
}
}