@
R6: Security - Master-Key + AES-256-GCM at-rest + TLS-Warnung - Core/Security/SecretProtection: AES-256-GCM at-rest, Master-Key aus env IBKRTRADER_MASTER_KEY bzw. gitignorierte master.key; selbstheilendes enc:v1:-Format; Passthrough ohne Key (mit Warnung) - Core/Security/EncryptedStringConverter (EF-ValueConverter, bereit fuer kuenftige Credentials) - Program: ConfigureSecretProtection (Master-Key laden) + WarnIfDbTlsNotEnforced (SslMode) beim Start - master.key gitignored - Tests: SecretProtection (Round-Trip/Idempotenz/Passthrough/Tamper/Key-Fehler) -> 56/56 gruen Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -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
|
||||
|
||||
+41
@@ -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<LoggingService>();
|
||||
ConfigureSecretProtection(startupLog);
|
||||
WarnIfDbTlsNotEnforced(AppHost.Services, startupLog);
|
||||
|
||||
// Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz (vor dem Start).
|
||||
AppHost.Services.GetRequiredService<WebApiService>()
|
||||
.SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>());
|
||||
@@ -174,6 +180,41 @@ internal static class Program
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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).");
|
||||
}
|
||||
|
||||
/// <summary>Warnt, wenn der DB-Connection-String keine TLS-Option (SslMode) enthält. Der String wird NICHT geloggt.</summary>
|
||||
private static void WarnIfDbTlsNotEnforced(IServiceProvider services, LoggingService logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var conn = services.GetService<IConfiguration>()?["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 */ }
|
||||
}
|
||||
|
||||
/// <summary>Diagnose: öffnet die DB (aus settings.json) und gibt die Serverversion aus. Kein UI.</summary>
|
||||
private static void RunDbVersion()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace IBKRTrader.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 anwenden, sobald Credentials gespeichert werden
|
||||
/// (z. B. künftige IBKR-API-Secrets): <c>e.Property(x => x.ApiSecret).HasConversion(new EncryptedStringConverter())</c>.
|
||||
/// </summary>
|
||||
public sealed class EncryptedStringConverter : ValueConverter<string, string>
|
||||
{
|
||||
public EncryptedStringConverter()
|
||||
: base(v => SecretProtection.Protect(v), v => SecretProtection.Unprotect(v))
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace IBKRTrader.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>IBKRTRADER_MASTER_KEY</c> bzw. eine gitignorierte <c>master.key</c>) –
|
||||
/// ein DB-Leak/Backup ist damit ohne den Master-Key wertlos.
|
||||
///
|
||||
/// 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
|
||||
/// (selbstheilend). Ohne Master-Key arbeitet die App mit Klartext – mit deutlicher Startwarnung.
|
||||
/// </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. Leer/null = aus.
|
||||
/// </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 Klartext → <c>enc:v1:…</c>. Ohne Master-Key: Passthrough (Klartext).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Entschlüsselt <c>enc:v1:…</c>. Alt-Klartext (ohne Präfix) wird unverändert zurückgegeben.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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).
|
||||
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,86 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Security;
|
||||
|
||||
namespace IBKRTrader.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[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<InvalidOperationException>();
|
||||
}
|
||||
finally { SecretProtection.Reset(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user