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; } }