Files
PolyTraderSharp/src/PolyTrader.Core/Security/SecretProtection.cs
T
RichardandClaude Opus 4.8 f3ed63cf9d 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>
2026-07-15 08:35:59 +02:00

135 lines
6.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}