diff --git a/.gitignore b/.gitignore index c6e435c..9424cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ MongoDB/ # Gitea Personal Access Token für Pushes – niemals committen .gitea-token +master.key # ── Logs & temporäre Dateien ───────────────────── *.log diff --git a/Program.cs b/Program.cs index 6e4e4de..86115bf 100644 --- a/Program.cs +++ b/Program.cs @@ -1,7 +1,10 @@ using System; +using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Channels; +using PolyTrader.Core.Persistence; +using PolyTrader.Core.Security; using System.Windows.Forms; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -131,6 +134,10 @@ internal static class Program services.AddTransient(); }).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()); + try { // Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen @@ -150,6 +157,9 @@ internal static class Program 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). var uiHost = AppHost.Services.GetRequiredService(); var viewServices = AppHost.Services; @@ -292,6 +302,51 @@ internal static class Program } } + /// + /// 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. + /// + 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."); + } + + /// + /// 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. + /// + private static void ReencryptAccountCredentials(IServiceProvider services) + { + if (!SecretProtection.IsConfigured) return; + var logger = services.GetRequiredService(); + try + { + var accountRepo = services.GetRequiredService(); + 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?"); + } + } + /// /// Headless-Smoke-Test: baut einen minimalen Host (Persistenz + State + Modul-Registrierung /// + Shell/Launcher), hydriert den State aus MySQL und konstruiert jede registrierte View @@ -330,6 +385,9 @@ internal static class Program services.AddTransient(); }).Build(); + // F1: Master-Key laden, damit die Hydration verschlüsselte Credentials entschlüsseln kann. + ConfigureSecretProtection(host.Services.GetRequiredService()); + // State aus MySQL laden (Accounts/Trader/Settings), ohne die BackgroundServices zu starten. try { diff --git a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs index 1a81797..2e6a165 100644 --- a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs +++ b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs @@ -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); diff --git a/src/PolyTrader.Core/Persistence/Ef/CoreDbContextFactory.cs b/src/PolyTrader.Core/Persistence/Ef/CoreDbContextFactory.cs index 995fa13..c4b80f1 100644 --- a/src/PolyTrader.Core/Persistence/Ef/CoreDbContextFactory.cs +++ b/src/PolyTrader.Core/Persistence/Ef/CoreDbContextFactory.cs @@ -1,13 +1,15 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using PolyTrader.Core.Configuration; namespace PolyTrader.Core.Persistence.Ef { /// /// 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. /// public class CoreDbContextFactory : IDesignTimeDbContextFactory { @@ -17,7 +19,7 @@ namespace PolyTrader.Core.Persistence.Ef ?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;"; var options = new DbContextOptionsBuilder() - .UseMySql(conn, ServerVersion.AutoDetect(conn)) + .UseMySql(conn, DatabaseServerVersion.Value) .Options; return new CoreDbContext(options); diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/20260714151510_EncryptAccountSecretsWidenColumns.Designer.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260714151510_EncryptAccountSecretsWidenColumns.Designer.cs new file mode 100644 index 0000000..2c1e42a --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260714151510_EncryptAccountSecretsWidenColumns.Designer.cs @@ -0,0 +1,313 @@ +// +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 + { + /// + 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("AccountId") + .HasColumnType("int"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ApiPassphrase") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("ApiSecret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("AvailableBalance") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CloseOnlyMode") + .HasColumnType("tinyint(1)"); + + b.Property("HasOpenLimitOrders") + .HasColumnType("tinyint(1)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PayoutAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("PayoutLimitUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PrivateKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("TotalBalance") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WalletAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("AccountId"); + + b.ToTable("core_accounts", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b => + { + b.Property("Id") + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ClobTokenIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Closed") + .HasColumnType("tinyint(1)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("NegRisk") + .HasColumnType("tinyint(1)"); + + b.Property("Outcomes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("TakerFeeBps") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("core_markets", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.Position", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("TokenId") + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("AmountUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("CurrentPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CurrentValueUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitReason") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PnlPercent") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("RealizedPnl") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("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 + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/20260714151510_EncryptAccountSecretsWidenColumns.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260714151510_EncryptAccountSecretsWidenColumns.cs new file mode 100644 index 0000000..4b01859 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260714151510_EncryptAccountSecretsWidenColumns.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Core.Persistence.Ef.Migrations +{ + /// + public partial class EncryptAccountSecretsWidenColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + 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( + 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( + 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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + 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( + 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( + 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"); + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index 7cba92b..d418737 100644 --- a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -34,13 +34,13 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations b.Property("ApiPassphrase") .IsRequired() - .HasMaxLength(256) - .HasColumnType("varchar(256)"); + .HasMaxLength(512) + .HasColumnType("varchar(512)"); b.Property("ApiSecret") .IsRequired() - .HasMaxLength(256) - .HasColumnType("varchar(256)"); + .HasMaxLength(512) + .HasColumnType("varchar(512)"); b.Property("AvailableBalance") .HasPrecision(18, 6) @@ -74,8 +74,8 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations b.Property("PrivateKey") .IsRequired() - .HasMaxLength(256) - .HasColumnType("varchar(256)"); + .HasMaxLength(512) + .HasColumnType("varchar(512)"); b.Property("TotalBalance") .HasPrecision(18, 6) diff --git a/src/PolyTrader.Core/PolyTrader.Core.csproj b/src/PolyTrader.Core/PolyTrader.Core.csproj index 897d291..5ddecc1 100644 --- a/src/PolyTrader.Core/PolyTrader.Core.csproj +++ b/src/PolyTrader.Core/PolyTrader.Core.csproj @@ -25,4 +25,11 @@ + + + + <_Parameter1>PolyTrader.Tests + + + diff --git a/src/PolyTrader.Core/Security/EncryptedStringConverter.cs b/src/PolyTrader.Core/Security/EncryptedStringConverter.cs new file mode 100644 index 0000000..f3f559d --- /dev/null +++ b/src/PolyTrader.Core/Security/EncryptedStringConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace PolyTrader.Core.Security +{ + /// + /// EF-Core-ValueConverter, der einen String beim Speichern über + /// verschlüsselt und beim Laden entschlüsselt. Transparent für den restlichen Code (die + /// Property bleibt ein normaler String). Auf sensible Spalten in CoreDbContext angewandt. + /// + public sealed class EncryptedStringConverter : ValueConverter + { + public EncryptedStringConverter() + : base(v => SecretProtection.Protect(v), v => SecretProtection.Unprotect(v)) + { + } + } +} diff --git a/src/PolyTrader.Core/Security/SecretProtection.cs b/src/PolyTrader.Core/Security/SecretProtection.cs new file mode 100644 index 0000000..5424e3e --- /dev/null +++ b/src/PolyTrader.Core/Security/SecretProtection.cs @@ -0,0 +1,134 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace PolyTrader.Core.Security +{ + /// + /// 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 POLYTRADER_MASTER_KEY bzw. eine gitignorierte Key-Datei) — ein + /// DB-Leak/Backup ist damit ohne den Master-Key wertlos (Befund F1, siehe docs/sicherheit). + /// + /// Speicherformat: enc:v1:base64(nonce(12) || tag(16) || ciphertext). 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- ihn nutzen kann; + /// wird einmalig beim Start aufgerufen. + /// + public static class SecretProtection + { + public const string Prefix = "enc:v1:"; + private static byte[]? _key; // 32 Byte, null = nicht konfiguriert + + /// True, wenn ein Master-Key gesetzt ist (Verschlüsselung aktiv). + public static bool IsConfigured => _key != null; + + /// + /// 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. + /// + public static void Configure(string? rawKey) + { + _key = string.IsNullOrWhiteSpace(rawKey) ? null : DeriveKey(rawKey.Trim()); + } + + /// Nur für Tests: Zustand zurücksetzen. + internal static void Reset() => _key = null; + + /// + /// Verschlüsselt einen Klartext → enc:v1:…. Leerstring bleibt leer. Ohne Master-Key wird + /// der Klartext unverändert zurückgegeben (Passthrough; die App hat beim Start gewarnt). + /// + 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); + } + + /// + /// Entschlüsselt enc:v1:…-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 geworfen (kein stilles Fehlverhalten). + /// + 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); + } + + /// True, wenn der Wert bereits im verschlüsselten Format vorliegt. + 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; + } + } +} diff --git a/tests/PolyTrader.Tests/SecretProtectionTests.cs b/tests/PolyTrader.Tests/SecretProtectionTests.cs new file mode 100644 index 0000000..462cf54 --- /dev/null +++ b/tests/PolyTrader.Tests/SecretProtectionTests.cs @@ -0,0 +1,122 @@ +using System; +using PolyTrader.Core.Security; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// 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. + /// + 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(() => SecretProtection.Unprotect(enc)); + } + + [Fact] + public void Wrong_key_throws() + { + SecretProtection.Configure(KeyA); + string enc = SecretProtection.Protect("secret"); + SecretProtection.Configure(KeyB); + Assert.Throws(() => 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(() => 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("")); + } + } +}