Security F1: Wallet-Keys/API-Secrets at-rest verschluesselt (AES-256-GCM, portabler Master-Key)
Behebt den kritischsten Befund (Klartext-Private-Keys in remote-gehosteter MySQL): - SecretProtection (Core/Security): AES-256-GCM, authenticated. Master-Key AUSSERHALB der DB (env POLYTRADER_MASTER_KEY, sonst gitignorierte master.key). Format enc:v1:base64(nonce|tag|ct). Alt-Klartext (ohne Praefix) wird gelesen und beim Speichern verschluesselt (selbstheilend). Ohne Master-Key: Passthrough + deutliche Startwarnung (kein stiller Sicherheitsverlust). - EncryptedStringConverter (EF ValueConverter) auf core_accounts.PrivateKey/ApiSecret/ApiPassphrase; Spalten 256->512 verbreitert (Migration EncryptAccountSecretsWidenColumns, offline generiert). - Program.cs: Master-Key vor der Hydration laden; nach Start einmalige/idempotente Re-Encryption vorhandener Klartext-Credentials. Auch in --smoke-ui verdrahtet. - CoreDbContextFactory nutzt jetzt fixe Server-Version (offline-Migrationsgenerierung, kein DB-Zugriff). 13 neue Krypto-Tests (Round-Trip, Nonce-Frische, Manipulations-/Falscher-Key-Erkennung, Passthrough, Key-Formate). Build 0 Fehler, 324 Tests gruen, --smoke-ui ok (Warnung ohne Key wie erwartet). AKTIVIERUNG (im Zielland): POLYTRADER_MASTER_KEY setzen (zufaelliger 32-Byte-Base64-Key, SEPARAT sichern!) + Migration anwenden (dotnet ef database update --context CoreDbContext). Danach Alchemy-/Mullvad-Secrets aus F3 rotieren. WICHTIG: Master-Key-Verlust = Kein Zugriff auf die Keys mehr. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
32fda4e70f
commit
f3ed63cf9d
@@ -30,6 +30,7 @@ MongoDB/
|
|||||||
|
|
||||||
# Gitea Personal Access Token für Pushes – niemals committen
|
# Gitea Personal Access Token für Pushes – niemals committen
|
||||||
.gitea-token
|
.gitea-token
|
||||||
|
master.key
|
||||||
|
|
||||||
# ── Logs & temporäre Dateien ─────────────────────
|
# ── Logs & temporäre Dateien ─────────────────────
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+58
@@ -1,7 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using PolyTrader.Core.Persistence;
|
||||||
|
using PolyTrader.Core.Security;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -131,6 +134,10 @@ internal static class Program
|
|||||||
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
||||||
}).Build();
|
}).Build();
|
||||||
|
|
||||||
|
// F1 (Sicherheit): Master-Key VOR jeder Credential-Entschlüsselung (Hydration) laden.
|
||||||
|
// Quelle: Umgebungsvariable POLYTRADER_MASTER_KEY, sonst gitignorierte master.key im App-Ordner.
|
||||||
|
ConfigureSecretProtection(AppHost.Services.GetRequiredService<TerminalLogger>());
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen
|
// Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen
|
||||||
@@ -150,6 +157,9 @@ internal static class Program
|
|||||||
|
|
||||||
AppHost.Start();
|
AppHost.Start();
|
||||||
|
|
||||||
|
// F1: einmalige, idempotente Verschlüsselung evtl. vorhandener Klartext-Credentials in der DB.
|
||||||
|
ReencryptAccountCredentials(AppHost.Services);
|
||||||
|
|
||||||
// Shell-Views registrieren (Core-App-Views; Module folgen via module.RegisterUi).
|
// Shell-Views registrieren (Core-App-Views; Module folgen via module.RegisterUi).
|
||||||
var uiHost = AppHost.Services.GetRequiredService<PolyTraderSharp.Ui.ShellUiHost>();
|
var uiHost = AppHost.Services.GetRequiredService<PolyTraderSharp.Ui.ShellUiHost>();
|
||||||
var viewServices = AppHost.Services;
|
var viewServices = AppHost.Services;
|
||||||
@@ -292,6 +302,51 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F1: Lädt den Master-Key (POLYTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert die
|
||||||
|
/// at-rest-Verschlüsselung. Ohne Key läuft die App wie bisher mit Klartext – mit deutlicher Warnung
|
||||||
|
/// (kein stiller Sicherheitsverlust). Muss VOR jeder Credential-Entschlüsselung laufen.
|
||||||
|
/// </summary>
|
||||||
|
private static void ConfigureSecretProtection(TerminalLogger logger)
|
||||||
|
{
|
||||||
|
string? masterKey = Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY");
|
||||||
|
if (string.IsNullOrWhiteSpace(masterKey))
|
||||||
|
{
|
||||||
|
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
|
||||||
|
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim();
|
||||||
|
}
|
||||||
|
SecretProtection.Configure(masterKey);
|
||||||
|
|
||||||
|
if (SecretProtection.IsConfigured)
|
||||||
|
logger.Info("🔐 Secret-Verschlüsselung aktiv – Account-Credentials werden at-rest verschlüsselt (AES-256-GCM).");
|
||||||
|
else
|
||||||
|
logger.Warning("⚠️ SICHERHEIT: Kein POLYTRADER_MASTER_KEY gesetzt – Account-Credentials liegen UNVERSCHLÜSSELT in der DB. " +
|
||||||
|
"Master-Key setzen (env POLYTRADER_MASTER_KEY oder master.key), siehe docs/sicherheit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F1: Verschlüsselt einmalig/idempotent evtl. vorhandene Klartext-Credentials in der DB (Alt-Bestand
|
||||||
|
/// wird beim Re-Save durch den EF-Converter verschlüsselt). Nur wenn ein Master-Key gesetzt ist.
|
||||||
|
/// Fehler blockieren den Start nicht (Log); künftige Saves verschlüsseln ohnehin.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReencryptAccountCredentials(IServiceProvider services)
|
||||||
|
{
|
||||||
|
if (!SecretProtection.IsConfigured) return;
|
||||||
|
var logger = services.GetRequiredService<TerminalLogger>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var accountRepo = services.GetRequiredService<IAccountRepository>();
|
||||||
|
var accounts = accountRepo.GetAll(); // Converter entschlüsselt/passt Klartext durch
|
||||||
|
foreach (var acc in accounts) accountRepo.Upsert(acc); // Re-Save → Converter verschlüsselt
|
||||||
|
logger.Info($"🔐 Account-Credentials at-rest gesichert ({accounts.Count} Account(s)).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.Error($"Re-Encryption der Account-Credentials fehlgeschlagen: {ex.Message}. " +
|
||||||
|
"Stimmt der Master-Key mit den bereits verschlüsselten Daten überein?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Headless-Smoke-Test: baut einen minimalen Host (Persistenz + State + Modul-Registrierung
|
/// Headless-Smoke-Test: baut einen minimalen Host (Persistenz + State + Modul-Registrierung
|
||||||
/// + Shell/Launcher), hydriert den State aus MySQL und konstruiert jede registrierte View
|
/// + Shell/Launcher), hydriert den State aus MySQL und konstruiert jede registrierte View
|
||||||
@@ -330,6 +385,9 @@ internal static class Program
|
|||||||
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
||||||
}).Build();
|
}).Build();
|
||||||
|
|
||||||
|
// F1: Master-Key laden, damit die Hydration verschlüsselte Credentials entschlüsseln kann.
|
||||||
|
ConfigureSecretProtection(host.Services.GetRequiredService<TerminalLogger>());
|
||||||
|
|
||||||
// State aus MySQL laden (Accounts/Trader/Settings), ohne die BackgroundServices zu starten.
|
// State aus MySQL laden (Accounts/Trader/Settings), ohne die BackgroundServices zu starten.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PolyTrader.Core.Security;
|
||||||
using PolyTraderSharp.Models;
|
using PolyTraderSharp.Models;
|
||||||
|
|
||||||
namespace PolyTrader.Core.Persistence.Ef
|
namespace PolyTrader.Core.Persistence.Ef
|
||||||
@@ -28,9 +29,12 @@ namespace PolyTrader.Core.Persistence.Ef
|
|||||||
e.Property(x => x.Name).HasMaxLength(200);
|
e.Property(x => x.Name).HasMaxLength(200);
|
||||||
e.Property(x => x.WalletAddress).HasMaxLength(128);
|
e.Property(x => x.WalletAddress).HasMaxLength(128);
|
||||||
e.Property(x => x.ApiKey).HasMaxLength(256);
|
e.Property(x => x.ApiKey).HasMaxLength(256);
|
||||||
e.Property(x => x.ApiSecret).HasMaxLength(256);
|
// F1: sensible Secrets at-rest verschlüsselt (AES-256-GCM, Master-Key außerhalb der DB).
|
||||||
e.Property(x => x.ApiPassphrase).HasMaxLength(256);
|
// Spalten verbreitert (Ciphertext+Base64 länger als Klartext). Alt-Klartext bleibt lesbar
|
||||||
e.Property(x => x.PrivateKey).HasMaxLength(256);
|
// und wird beim nächsten Speichern verschlüsselt (selbstheilend).
|
||||||
|
e.Property(x => x.ApiSecret).HasMaxLength(512).HasConversion(new EncryptedStringConverter());
|
||||||
|
e.Property(x => x.ApiPassphrase).HasMaxLength(512).HasConversion(new EncryptedStringConverter());
|
||||||
|
e.Property(x => x.PrivateKey).HasMaxLength(512).HasConversion(new EncryptedStringConverter());
|
||||||
e.Property(x => x.PayoutAddress).HasMaxLength(128);
|
e.Property(x => x.PayoutAddress).HasMaxLength(128);
|
||||||
e.Property(x => x.TotalBalance).HasPrecision(18, 6);
|
e.Property(x => x.TotalBalance).HasPrecision(18, 6);
|
||||||
e.Property(x => x.AvailableBalance).HasPrecision(18, 6);
|
e.Property(x => x.AvailableBalance).HasPrecision(18, 6);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using PolyTrader.Core.Configuration;
|
||||||
|
|
||||||
namespace PolyTrader.Core.Persistence.Ef
|
namespace PolyTrader.Core.Persistence.Ef
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Design-Time-Factory für EF-Tooling (dotnet ef migrations/database).
|
/// Design-Time-Factory für EF-Tooling (dotnet ef migrations/database).
|
||||||
/// Liest den Connection-String aus der Umgebungsvariable POLYTRADER_MYSQL,
|
/// Liest den Connection-String aus der Umgebungsvariable POLYTRADER_MYSQL,
|
||||||
/// damit keine Zugangsdaten im Code/Repo landen.
|
/// damit keine Zugangsdaten im Code/Repo landen. Nutzt die fest gepinnte Server-Version
|
||||||
|
/// (statt AutoDetect), damit Migrations-Scaffolding OHNE DB-Verbindung funktioniert.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CoreDbContextFactory : IDesignTimeDbContextFactory<CoreDbContext>
|
public class CoreDbContextFactory : IDesignTimeDbContextFactory<CoreDbContext>
|
||||||
{
|
{
|
||||||
@@ -17,7 +19,7 @@ namespace PolyTrader.Core.Persistence.Ef
|
|||||||
?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;";
|
?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;";
|
||||||
|
|
||||||
var options = new DbContextOptionsBuilder<CoreDbContext>()
|
var options = new DbContextOptionsBuilder<CoreDbContext>()
|
||||||
.UseMySql(conn, ServerVersion.AutoDetect(conn))
|
.UseMySql(conn, DatabaseServerVersion.Value)
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
return new CoreDbContext(options);
|
return new CoreDbContext(options);
|
||||||
|
|||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using PolyTrader.Core.Persistence.Ef;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PolyTrader.Core.Persistence.Ef.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CoreDbContext))]
|
||||||
|
[Migration("20260714151510_EncryptAccountSecretsWidenColumns")]
|
||||||
|
partial class EncryptAccountSecretsWidenColumns
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.13")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("PolyTraderSharp.Models.AccountState", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("AccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ApiKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("ApiPassphrase")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ApiSecret")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<decimal>("AvailableBalance")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<bool>("CloseOnlyMode")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("HasOpenLimitOrders")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDemo")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("PayoutAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PayoutLimitUsd")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("PrivateKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalBalance")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("WalletAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.HasKey("AccountId");
|
||||||
|
|
||||||
|
b.ToTable("core_accounts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("varchar(120)");
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ClobTokenIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("Closed")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("ConditionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("varchar(120)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("NegRisk")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Outcomes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Question")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("varchar(300)");
|
||||||
|
|
||||||
|
b.Property<int>("TakerFeeBps")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("core_markets", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PolyTraderSharp.Models.Position", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("AccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDemo")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("varchar(120)");
|
||||||
|
|
||||||
|
b.Property<decimal>("AmountUsd")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("ConditionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("varchar(120)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentValueUsd")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpiryDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketQuestion")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketSlug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("varchar(300)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Outcome")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Side")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("varchar(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Size")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceTraderAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<int>("SourceTraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SourceTraderName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.HasKey("AccountId", "IsDemo", "TokenId");
|
||||||
|
|
||||||
|
b.ToTable("core_positions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PolyTraderSharp.Models.TradeRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<int>("AccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ClosedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("ExitPrice")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitReason")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDemo")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketQuestion")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("ModuleName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Outcome")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnlPercent")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnl")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("Side")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("varchar(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Size")
|
||||||
|
.HasPrecision(18, 6)
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("varchar(120)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId");
|
||||||
|
|
||||||
|
b.HasIndex("ClosedAt");
|
||||||
|
|
||||||
|
b.HasIndex("ModuleName");
|
||||||
|
|
||||||
|
b.ToTable("core_trade_log", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PolyTrader.Core.Persistence.Ef.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class EncryptAccountSecretsWidenColumns : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "PrivateKey",
|
||||||
|
table: "core_accounts",
|
||||||
|
type: "varchar(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(256)",
|
||||||
|
oldMaxLength: 256)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "ApiSecret",
|
||||||
|
table: "core_accounts",
|
||||||
|
type: "varchar(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(256)",
|
||||||
|
oldMaxLength: 256)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "ApiPassphrase",
|
||||||
|
table: "core_accounts",
|
||||||
|
type: "varchar(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(256)",
|
||||||
|
oldMaxLength: 256)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "PrivateKey",
|
||||||
|
table: "core_accounts",
|
||||||
|
type: "varchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(512)",
|
||||||
|
oldMaxLength: 512)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "ApiSecret",
|
||||||
|
table: "core_accounts",
|
||||||
|
type: "varchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(512)",
|
||||||
|
oldMaxLength: 512)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "ApiPassphrase",
|
||||||
|
table: "core_accounts",
|
||||||
|
type: "varchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(512)",
|
||||||
|
oldMaxLength: 512)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,13 +34,13 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
|
|||||||
|
|
||||||
b.Property<string>("ApiPassphrase")
|
b.Property<string>("ApiPassphrase")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("varchar(256)");
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
b.Property<string>("ApiSecret")
|
b.Property<string>("ApiSecret")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("varchar(256)");
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
b.Property<decimal>("AvailableBalance")
|
b.Property<decimal>("AvailableBalance")
|
||||||
.HasPrecision(18, 6)
|
.HasPrecision(18, 6)
|
||||||
@@ -74,8 +74,8 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
|
|||||||
|
|
||||||
b.Property<string>("PrivateKey")
|
b.Property<string>("PrivateKey")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("varchar(256)");
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
b.Property<decimal>("TotalBalance")
|
b.Property<decimal>("TotalBalance")
|
||||||
.HasPrecision(18, 6)
|
.HasPrecision(18, 6)
|
||||||
|
|||||||
@@ -25,4 +25,11 @@
|
|||||||
<ProjectReference Include="..\..\libs\Threema-MsgApi-Net-Core\IcgSoftware.Threema.CoreMsgApi\IcgSoftware.Threema.CoreMsgApi.csproj" />
|
<ProjectReference Include="..\..\libs\Threema-MsgApi-Net-Core\IcgSoftware.Threema.CoreMsgApi\IcgSoftware.Threema.CoreMsgApi.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Erlaubt dem Testprojekt, interne Helfer (z.B. SecretProtection.Reset) zu testen. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>PolyTrader.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace PolyTrader.Core.Security
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// EF-Core-ValueConverter, der einen String beim Speichern über <see cref="SecretProtection"/>
|
||||||
|
/// verschlüsselt und beim Laden entschlüsselt. Transparent für den restlichen Code (die
|
||||||
|
/// Property bleibt ein normaler String). Auf sensible Spalten in <c>CoreDbContext</c> angewandt.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EncryptedStringConverter : ValueConverter<string, string>
|
||||||
|
{
|
||||||
|
public EncryptedStringConverter()
|
||||||
|
: base(v => SecretProtection.Protect(v), v => SecretProtection.Unprotect(v))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace PolyTrader.Core.Security
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verschlüsselung sensibler Felder (Wallet-Private-Keys, API-Secrets) at-rest mit einem
|
||||||
|
/// portablen Master-Key (AES-256-GCM, authenticated). Der Master-Key liegt AUSSERHALB der DB
|
||||||
|
/// (Umgebungsvariable <c>POLYTRADER_MASTER_KEY</c> bzw. eine gitignorierte Key-Datei) — ein
|
||||||
|
/// DB-Leak/Backup ist damit ohne den Master-Key wertlos (Befund F1, siehe docs/sicherheit).
|
||||||
|
///
|
||||||
|
/// Speicherformat: <c>enc:v1:base64(nonce(12) || tag(16) || ciphertext)</c>. Werte OHNE dieses
|
||||||
|
/// Präfix gelten als Alt-Klartext und werden beim nächsten Speichern automatisch verschlüsselt
|
||||||
|
/// (selbstheilende Migration). Ist kein Master-Key konfiguriert, arbeitet die App wie bisher mit
|
||||||
|
/// Klartext — mit deutlicher Startwarnung (kein stiller Sicherheitsverlust).
|
||||||
|
///
|
||||||
|
/// Statischer Zugriff, damit der EF-<see cref="EncryptedStringConverter"/> ihn nutzen kann;
|
||||||
|
/// <see cref="Configure"/> wird einmalig beim Start aufgerufen.
|
||||||
|
/// </summary>
|
||||||
|
public static class SecretProtection
|
||||||
|
{
|
||||||
|
public const string Prefix = "enc:v1:";
|
||||||
|
private static byte[]? _key; // 32 Byte, null = nicht konfiguriert
|
||||||
|
|
||||||
|
/// <summary>True, wenn ein Master-Key gesetzt ist (Verschlüsselung aktiv).</summary>
|
||||||
|
public static bool IsConfigured => _key != null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Konfiguriert den Master-Key aus dem Rohwert (Env/Datei). Akzeptiert 32-Byte-Schlüssel als
|
||||||
|
/// Base64 oder Hex; jeder andere String wird per SHA-256 zu 32 Byte abgeleitet (Passphrase-Komfort –
|
||||||
|
/// empfohlen ist ein zufälliger 32-Byte-Base64-Key). Leerer/nuller Wert = nicht konfiguriert.
|
||||||
|
/// </summary>
|
||||||
|
public static void Configure(string? rawKey)
|
||||||
|
{
|
||||||
|
_key = string.IsNullOrWhiteSpace(rawKey) ? null : DeriveKey(rawKey.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Nur für Tests: Zustand zurücksetzen.</summary>
|
||||||
|
internal static void Reset() => _key = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verschlüsselt einen Klartext → <c>enc:v1:…</c>. Leerstring bleibt leer. Ohne Master-Key wird
|
||||||
|
/// der Klartext unverändert zurückgegeben (Passthrough; die App hat beim Start gewarnt).
|
||||||
|
/// </summary>
|
||||||
|
public static string Protect(string? plaintext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(plaintext)) return plaintext ?? string.Empty;
|
||||||
|
if (plaintext.StartsWith(Prefix, StringComparison.Ordinal)) return plaintext; // schon verschlüsselt
|
||||||
|
if (_key == null) return plaintext; // nicht konfiguriert → Klartext
|
||||||
|
|
||||||
|
byte[] pt = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize); // 12
|
||||||
|
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; // 16
|
||||||
|
byte[] ct = new byte[pt.Length];
|
||||||
|
using (var aes = new AesGcm(_key, tag.Length))
|
||||||
|
aes.Encrypt(nonce, pt, ct, tag);
|
||||||
|
|
||||||
|
byte[] packed = new byte[nonce.Length + tag.Length + ct.Length];
|
||||||
|
Buffer.BlockCopy(nonce, 0, packed, 0, nonce.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, packed, nonce.Length, tag.Length);
|
||||||
|
Buffer.BlockCopy(ct, 0, packed, nonce.Length + tag.Length, ct.Length);
|
||||||
|
return Prefix + Convert.ToBase64String(packed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entschlüsselt <c>enc:v1:…</c>-Werte. Werte ohne Präfix (Alt-Klartext) werden unverändert
|
||||||
|
/// zurückgegeben. Fehlt für einen verschlüsselten Wert der Master-Key oder ist er falsch/manipuliert,
|
||||||
|
/// wird eine <see cref="InvalidOperationException"/> geworfen (kein stilles Fehlverhalten).
|
||||||
|
/// </summary>
|
||||||
|
public static string Unprotect(string? stored)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(stored)) return stored ?? string.Empty;
|
||||||
|
if (!stored.StartsWith(Prefix, StringComparison.Ordinal)) return stored; // Alt-Klartext
|
||||||
|
if (_key == null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Verschlüsselte Account-Credentials, aber kein Master-Key gesetzt (POLYTRADER_MASTER_KEY). Entschlüsselung nicht möglich.");
|
||||||
|
|
||||||
|
byte[] packed;
|
||||||
|
try { packed = Convert.FromBase64String(stored.Substring(Prefix.Length)); }
|
||||||
|
catch (FormatException ex) { throw new InvalidOperationException("Beschädigter verschlüsselter Wert (Base64).", ex); }
|
||||||
|
|
||||||
|
int nonceLen = AesGcm.NonceByteSizes.MaxSize; // 12
|
||||||
|
int tagLen = AesGcm.TagByteSizes.MaxSize; // 16
|
||||||
|
if (packed.Length < nonceLen + tagLen)
|
||||||
|
throw new InvalidOperationException("Beschädigter verschlüsselter Wert (zu kurz).");
|
||||||
|
|
||||||
|
byte[] nonce = new byte[nonceLen];
|
||||||
|
byte[] tag = new byte[tagLen];
|
||||||
|
byte[] ct = new byte[packed.Length - nonceLen - tagLen];
|
||||||
|
Buffer.BlockCopy(packed, 0, nonce, 0, nonceLen);
|
||||||
|
Buffer.BlockCopy(packed, nonceLen, tag, 0, tagLen);
|
||||||
|
Buffer.BlockCopy(packed, nonceLen + tagLen, ct, 0, ct.Length);
|
||||||
|
|
||||||
|
byte[] pt = new byte[ct.Length];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var aes = new AesGcm(_key, tag.Length);
|
||||||
|
aes.Decrypt(nonce, ct, tag, pt); // wirft CryptographicException bei falschem Key/Manipulation
|
||||||
|
}
|
||||||
|
catch (CryptographicException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Entschlüsselung fehlgeschlagen (falscher Master-Key oder manipulierte Daten).", ex);
|
||||||
|
}
|
||||||
|
return Encoding.UTF8.GetString(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True, wenn der Wert bereits im verschlüsselten Format vorliegt.</summary>
|
||||||
|
public static bool IsEncrypted(string? value) =>
|
||||||
|
!string.IsNullOrEmpty(value) && value!.StartsWith(Prefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static byte[] DeriveKey(string raw)
|
||||||
|
{
|
||||||
|
// 32-Byte-Key als Base64?
|
||||||
|
try { var b = Convert.FromBase64String(raw); if (b.Length == 32) return b; } catch { /* kein Base64 */ }
|
||||||
|
// 32-Byte-Key als Hex (64 Zeichen)?
|
||||||
|
if (raw.Length == 64 && IsHex(raw))
|
||||||
|
{
|
||||||
|
var b = new byte[32];
|
||||||
|
for (int i = 0; i < 32; i++) b[i] = Convert.ToByte(raw.Substring(i * 2, 2), 16);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
// sonst: aus beliebiger Passphrase 32 Byte ableiten (Komfort; besser echten 32-Byte-Key nutzen)
|
||||||
|
return SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHex(string s)
|
||||||
|
{
|
||||||
|
foreach (char c in s)
|
||||||
|
if (!Uri.IsHexDigit(c)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using PolyTrader.Core.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sicherheitsnetz für die at-rest-Verschlüsselung sensibler Felder (F1): Round-Trip, Alt-Klartext-
|
||||||
|
/// Passthrough, Manipulations-/Falscher-Key-Erkennung (AES-GCM), Verhalten ohne Master-Key.
|
||||||
|
/// Geldkritisch – ein Fehler hier macht Wallet-Keys unlesbar.
|
||||||
|
/// </summary>
|
||||||
|
public class SecretProtectionTests : IDisposable
|
||||||
|
{
|
||||||
|
// 32-Byte-Testschlüssel als Base64.
|
||||||
|
private const string KeyA = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; // 0..31
|
||||||
|
private const string KeyB = "/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4=";
|
||||||
|
|
||||||
|
public SecretProtectionTests() => SecretProtection.Reset();
|
||||||
|
public void Dispose() => SecretProtection.Reset();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_encrypts_and_decrypts()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
const string secret = "0x1234567890abcdef_private_key";
|
||||||
|
|
||||||
|
string enc = SecretProtection.Protect(secret);
|
||||||
|
|
||||||
|
Assert.StartsWith(SecretProtection.Prefix, enc);
|
||||||
|
Assert.DoesNotContain(secret, enc); // Klartext nicht sichtbar
|
||||||
|
Assert.Equal(secret, SecretProtection.Unprotect(enc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Protect_uses_fresh_nonce_each_time()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
Assert.NotEqual(SecretProtection.Protect("same"), SecretProtection.Protect("same"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_stays_empty()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
Assert.Equal("", SecretProtection.Protect(""));
|
||||||
|
Assert.Equal("", SecretProtection.Unprotect(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_plaintext_passes_through_on_read()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
Assert.Equal("legacy-plain", SecretProtection.Unprotect("legacy-plain")); // kein Präfix
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Double_protect_does_not_wrap_twice()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
string once = SecretProtection.Protect("x");
|
||||||
|
Assert.Equal(once, SecretProtection.Protect(once)); // schon verschlüsselt -> unverändert
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_master_key_plaintext_passthrough()
|
||||||
|
{
|
||||||
|
// nicht konfiguriert
|
||||||
|
Assert.False(SecretProtection.IsConfigured);
|
||||||
|
Assert.Equal("plain", SecretProtection.Protect("plain")); // kein Zwang zu Klartextverlust, aber Passthrough
|
||||||
|
Assert.Equal("plain", SecretProtection.Unprotect("plain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_master_key_encrypted_value_throws()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
string enc = SecretProtection.Protect("secret");
|
||||||
|
SecretProtection.Reset(); // Key entfernt
|
||||||
|
Assert.Throws<InvalidOperationException>(() => SecretProtection.Unprotect(enc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wrong_key_throws()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
string enc = SecretProtection.Protect("secret");
|
||||||
|
SecretProtection.Configure(KeyB);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => SecretProtection.Unprotect(enc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tampered_ciphertext_throws()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
string enc = SecretProtection.Protect("secret");
|
||||||
|
// letztes Base64-Zeichen kippen
|
||||||
|
char last = enc[^1];
|
||||||
|
string tampered = enc.Substring(0, enc.Length - 1) + (last == 'A' ? 'B' : 'A');
|
||||||
|
Assert.Throws<InvalidOperationException>(() => SecretProtection.Unprotect(tampered));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")] // Base64 32 Byte
|
||||||
|
[InlineData("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")] // Hex 64
|
||||||
|
[InlineData("eine-beliebige-passphrase")] // abgeleitet via SHA-256
|
||||||
|
public void Various_key_formats_roundtrip(string key)
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(key);
|
||||||
|
string enc = SecretProtection.Protect("value");
|
||||||
|
Assert.Equal("value", SecretProtection.Unprotect(enc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEncrypted_detects_prefix()
|
||||||
|
{
|
||||||
|
SecretProtection.Configure(KeyA);
|
||||||
|
Assert.True(SecretProtection.IsEncrypted(SecretProtection.Protect("x")));
|
||||||
|
Assert.False(SecretProtection.IsEncrypted("plain"));
|
||||||
|
Assert.False(SecretProtection.IsEncrypted(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user