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:
Richard
2026-07-15 08:35:59 +02:00
co-authored by Claude Opus 4.8
parent 32fda4e70f
commit f3ed63cf9d
11 changed files with 759 additions and 11 deletions
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
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.WalletAddress).HasMaxLength(128);
e.Property(x => x.ApiKey).HasMaxLength(256);
e.Property(x => x.ApiSecret).HasMaxLength(256);
e.Property(x => x.ApiPassphrase).HasMaxLength(256);
e.Property(x => x.PrivateKey).HasMaxLength(256);
// F1: sensible Secrets at-rest verschlüsselt (AES-256-GCM, Master-Key außerhalb der DB).
// Spalten verbreitert (Ciphertext+Base64 länger als Klartext). Alt-Klartext bleibt lesbar
// 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.TotalBalance).HasPrecision(18, 6);
e.Property(x => x.AvailableBalance).HasPrecision(18, 6);
@@ -1,13 +1,15 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using PolyTrader.Core.Configuration;
namespace PolyTrader.Core.Persistence.Ef
{
/// <summary>
/// Design-Time-Factory für EF-Tooling (dotnet ef migrations/database).
/// 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>
public class CoreDbContextFactory : IDesignTimeDbContextFactory<CoreDbContext>
{
@@ -17,7 +19,7 @@ namespace PolyTrader.Core.Persistence.Ef
?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;";
var options = new DbContextOptionsBuilder<CoreDbContext>()
.UseMySql(conn, ServerVersion.AutoDetect(conn))
.UseMySql(conn, DatabaseServerVersion.Value)
.Options;
return new CoreDbContext(options);
@@ -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
}
}
}
@@ -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")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ApiSecret")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<decimal>("AvailableBalance")
.HasPrecision(18, 6)
@@ -74,8 +74,8 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<decimal>("TotalBalance")
.HasPrecision(18, 6)