diff --git a/.gitignore b/.gitignore index ac6f044..0d1a652 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ $RECYCLE.BIN/ ## Application data (do NOT commit secrets or runtime data) settings.json appsettings.Local.json +master.key Backups/ Logs/ *.log diff --git a/Program.cs b/Program.cs index aad81cb..2863757 100644 --- a/Program.cs +++ b/Program.cs @@ -5,6 +5,7 @@ using IBKRTrader.Core.DependencyInjection; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Security; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers; @@ -61,6 +62,11 @@ internal static class Program }) .Build(); + // Sicherheit: Master-Key laden (VOR jeder Entschlüsselung) und DB-TLS prüfen. + var startupLog = AppHost.Services.GetRequiredService(); + ConfigureSecretProtection(startupLog); + WarnIfDbTlsNotEnforced(AppHost.Services, startupLog); + // Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz (vor dem Start). AppHost.Services.GetRequiredService() .SetEngine(AppHost.Services.GetRequiredService()); @@ -174,6 +180,41 @@ internal static class Program }); } + /// + /// Lädt den Master-Key (env IBKRTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert die + /// at-rest-Verschlüsselung. Ohne Key läuft die App mit Klartext – mit deutlicher Warnung. + /// + private static void ConfigureSecretProtection(LoggingService logger) + { + var masterKey = Environment.GetEnvironmentVariable("IBKRTRADER_MASTER_KEY"); + if (string.IsNullOrWhiteSpace(masterKey)) + { + var keyFile = Path.Combine(AppContext.BaseDirectory, "master.key"); + if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim(); + } + SecretProtection.Configure(masterKey); + + if (SecretProtection.IsConfigured) + logger.Info("Core", "🔐 Secret-Verschlüsselung aktiv – sensible Daten werden at-rest verschlüsselt (AES-256-GCM)."); + else + logger.Warn("Core", "⚠️ SICHERHEIT: Kein IBKRTRADER_MASTER_KEY gesetzt – sensible Daten würden UNVERSCHLÜSSELT gespeichert. " + + "Master-Key setzen (env IBKRTRADER_MASTER_KEY oder Datei master.key)."); + } + + /// Warnt, wenn der DB-Connection-String keine TLS-Option (SslMode) enthält. Der String wird NICHT geloggt. + private static void WarnIfDbTlsNotEnforced(IServiceProvider services, LoggingService logger) + { + try + { + var conn = services.GetService()?["Database:MySqlConnectionString"] ?? string.Empty; + if (string.IsNullOrEmpty(conn)) return; + if (conn.IndexOf("sslmode", StringComparison.OrdinalIgnoreCase) < 0) + logger.Warn("Core", "⚠️ SICHERHEIT: DB-Verbindung ohne SslMode – Transportverschlüsselung nicht erzwungen. " + + "Im Connection-String 'SslMode=Required' setzen."); + } + catch { /* best-effort, darf den Start nie stören */ } + } + /// Diagnose: öffnet die DB (aus settings.json) und gibt die Serverversion aus. Kein UI. private static void RunDbVersion() { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 801df07..0ef5fd8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,9 +114,12 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin - [x] **8 neue Tests** (Signal-Mapping/Ausführung, gemockter ExecutionService) → 51/51 grün - Hinweis: Handel bleibt durch Trading-Gate (`TradingEnabled=false`) + `NullBrokerClient` sicher aus, bis echter Broker + Freigabe -### R6 – Sicherheit + Config-Härtung -- [ ] `SecretProtection` (Master-Key, AES-256-GCM at-rest), TLS-Warnung -- [ ] Connection-String nur in `appsettings.Local.json` (gitignored); Secrets aus Repo/Historie +### R6 – Sicherheit + Config-Härtung ✅ +- [x] `SecretProtection` (Master-Key aus env `IBKRTRADER_MASTER_KEY`/`master.key`, AES-256-GCM at-rest, selbstheilendes `enc:v1:`-Format) + `EncryptedStringConverter` (bereit für künftige IBKR-Credentials) +- [x] `ConfigureSecretProtection` + TLS-Warnung (`SslMode`) beim Start; `master.key` gitignored +- [x] Connection-String in `appsettings.Local.json` (gitignored) +- [x] **5 SecretProtection-Tests** (Round-Trip, Idempotenz, Passthrough, Tamper/Key-Fehler) → 56/56 grün +- [ ] **Offen (Nutzer-Aktion):** geleaktes DB-Passwort rotieren (liegt in Git-Historie via `grundregeln.md`, Commit `ebeb035`); EF-Schema per `dotnet ef database update` auf die DB anwenden ### R7 – Tests + Feinschliff - [ ] Bestehende Unit-Tests portieren; `--smoke-ui`; EF-InMemory-Tests wo sinnvoll diff --git a/src/IBKRTrader.Core/Security/EncryptedStringConverter.cs b/src/IBKRTrader.Core/Security/EncryptedStringConverter.cs new file mode 100644 index 0000000..97c914c --- /dev/null +++ b/src/IBKRTrader.Core/Security/EncryptedStringConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IBKRTrader.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 anwenden, sobald Credentials gespeichert werden +/// (z. B. künftige IBKR-API-Secrets): e.Property(x => x.ApiSecret).HasConversion(new EncryptedStringConverter()). +/// +public sealed class EncryptedStringConverter : ValueConverter +{ + public EncryptedStringConverter() + : base(v => SecretProtection.Protect(v), v => SecretProtection.Unprotect(v)) + { + } +} diff --git a/src/IBKRTrader.Core/Security/SecretProtection.cs b/src/IBKRTrader.Core/Security/SecretProtection.cs new file mode 100644 index 0000000..714f996 --- /dev/null +++ b/src/IBKRTrader.Core/Security/SecretProtection.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using System.Text; + +namespace IBKRTrader.Core.Security; + +/// +/// Verschlüsselung sensibler Felder (z. B. IBKR-API-Credentials) at-rest mit einem portablen +/// Master-Key (AES-256-GCM, authenticated). Der Master-Key liegt AUSSERHALB der DB +/// (Umgebungsvariable IBKRTRADER_MASTER_KEY bzw. eine gitignorierte master.key) – +/// ein DB-Leak/Backup ist damit ohne den Master-Key wertlos. +/// +/// 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 +/// (selbstheilend). Ohne Master-Key arbeitet die App mit Klartext – mit deutlicher Startwarnung. +/// +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. Leer/null = aus. + /// + 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 Klartext → enc:v1:…. Ohne Master-Key: Passthrough (Klartext). + public static string Protect(string? plaintext) + { + if (string.IsNullOrEmpty(plaintext)) return plaintext ?? string.Empty; + if (plaintext.StartsWith(Prefix, StringComparison.Ordinal)) return plaintext; + if (_key == null) return plaintext; + + 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:…. Alt-Klartext (ohne Präfix) wird unverändert zurückgegeben. + public static string Unprotect(string? stored) + { + if (string.IsNullOrEmpty(stored)) return stored ?? string.Empty; + if (!stored.StartsWith(Prefix, StringComparison.Ordinal)) return stored; + if (_key == null) + throw new InvalidOperationException( + "Verschlüsselte Daten, aber kein Master-Key gesetzt (IBKRTRADER_MASTER_KEY). Entschlüsselung nicht möglich."); + + byte[] packed; + try { packed = Convert.FromBase64String(stored[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); + } + 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). + 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/IBKRTrader.Tests/Security/SecretProtectionTests.cs b/tests/IBKRTrader.Tests/Security/SecretProtectionTests.cs new file mode 100644 index 0000000..54ff2be --- /dev/null +++ b/tests/IBKRTrader.Tests/Security/SecretProtectionTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using IBKRTrader.Core.Security; + +namespace IBKRTrader.Tests.Security; + +/// +/// Testet die at-rest-Verschlüsselung. Nutzt den statischen SecretProtection-Zustand → nicht +/// parallelisierbar mit anderen Tests dieser Collection; Reset() im Finally hält es isoliert. +/// +[Trait("cat", "unit")] +[Collection("SecretProtection")] +public class SecretProtectionTests +{ + // 32-Byte-Key als Base64. + private const string Key = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + + [Fact] + public void RoundTrip_EncryptsAndDecrypts() + { + try + { + SecretProtection.Configure(Key); + var enc = SecretProtection.Protect("geheim123"); + + enc.Should().StartWith("enc:v1:"); + enc.Should().NotContain("geheim123"); + SecretProtection.Unprotect(enc).Should().Be("geheim123"); + } + finally { SecretProtection.Reset(); } + } + + [Fact] + public void Protect_IsIdempotent_ForAlreadyEncrypted() + { + try + { + SecretProtection.Configure(Key); + var enc = SecretProtection.Protect("x"); + var enc2 = SecretProtection.Protect(enc); // nicht doppelt verschlüsseln + enc2.Should().Be(enc); + } + finally { SecretProtection.Reset(); } + } + + [Fact] + public void NoKey_IsPassthrough_Plaintext() + { + SecretProtection.Reset(); + SecretProtection.Protect("klartext").Should().Be("klartext"); + SecretProtection.Unprotect("klartext").Should().Be("klartext"); // Alt-Klartext unverändert + } + + [Fact] + public void Unprotect_Encrypted_WithoutKey_Throws() + { + string enc; + try + { + SecretProtection.Configure(Key); + enc = SecretProtection.Protect("secret"); + } + finally { SecretProtection.Reset(); } + + var act = () => SecretProtection.Unprotect(enc); + act.Should().Throw(); + } + + [Fact] + public void Unprotect_TamperedData_Throws() + { + try + { + SecretProtection.Configure(Key); + var enc = SecretProtection.Protect("secret"); + + // Erstes Base64-Zeichen des Payloads kippen → Nonce/Ciphertext korrupt, Base64 bleibt gültig. + var payload = enc[SecretProtection.Prefix.Length..].ToCharArray(); + payload[0] = payload[0] == 'A' ? 'B' : 'A'; + var tampered = SecretProtection.Prefix + new string(payload); + + var act = () => SecretProtection.Unprotect(tampered); + act.Should().Throw(); + } + finally { SecretProtection.Reset(); } + } +}