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> @
121 lines
5.4 KiB
C#
121 lines
5.4 KiB
C#
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;
|
||
}
|
||
}
|