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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user