Files
PolyTraderSharp/tests/PolyTrader.Tests/SecretProtectionTests.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

123 lines
4.6 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 PolyTrader.Core.Security;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für die at-rest-Verschlüsselung sensibler Felder (F1): Round-Trip, Alt-Klartext-
/// Passthrough, Manipulations-/Falscher-Key-Erkennung (AES-GCM), Verhalten ohne Master-Key.
/// Geldkritisch ein Fehler hier macht Wallet-Keys unlesbar.
/// </summary>
public class SecretProtectionTests : IDisposable
{
// 32-Byte-Testschlüssel als Base64.
private const string KeyA = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; // 0..31
private const string KeyB = "/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4=";
public SecretProtectionTests() => SecretProtection.Reset();
public void Dispose() => SecretProtection.Reset();
[Fact]
public void Roundtrip_encrypts_and_decrypts()
{
SecretProtection.Configure(KeyA);
const string secret = "0x1234567890abcdef_private_key";
string enc = SecretProtection.Protect(secret);
Assert.StartsWith(SecretProtection.Prefix, enc);
Assert.DoesNotContain(secret, enc); // Klartext nicht sichtbar
Assert.Equal(secret, SecretProtection.Unprotect(enc));
}
[Fact]
public void Protect_uses_fresh_nonce_each_time()
{
SecretProtection.Configure(KeyA);
Assert.NotEqual(SecretProtection.Protect("same"), SecretProtection.Protect("same"));
}
[Fact]
public void Empty_stays_empty()
{
SecretProtection.Configure(KeyA);
Assert.Equal("", SecretProtection.Protect(""));
Assert.Equal("", SecretProtection.Unprotect(""));
}
[Fact]
public void Legacy_plaintext_passes_through_on_read()
{
SecretProtection.Configure(KeyA);
Assert.Equal("legacy-plain", SecretProtection.Unprotect("legacy-plain")); // kein Präfix
}
[Fact]
public void Double_protect_does_not_wrap_twice()
{
SecretProtection.Configure(KeyA);
string once = SecretProtection.Protect("x");
Assert.Equal(once, SecretProtection.Protect(once)); // schon verschlüsselt -> unverändert
}
[Fact]
public void Without_master_key_plaintext_passthrough()
{
// nicht konfiguriert
Assert.False(SecretProtection.IsConfigured);
Assert.Equal("plain", SecretProtection.Protect("plain")); // kein Zwang zu Klartextverlust, aber Passthrough
Assert.Equal("plain", SecretProtection.Unprotect("plain"));
}
[Fact]
public void Without_master_key_encrypted_value_throws()
{
SecretProtection.Configure(KeyA);
string enc = SecretProtection.Protect("secret");
SecretProtection.Reset(); // Key entfernt
Assert.Throws<InvalidOperationException>(() => SecretProtection.Unprotect(enc));
}
[Fact]
public void Wrong_key_throws()
{
SecretProtection.Configure(KeyA);
string enc = SecretProtection.Protect("secret");
SecretProtection.Configure(KeyB);
Assert.Throws<InvalidOperationException>(() => SecretProtection.Unprotect(enc));
}
[Fact]
public void Tampered_ciphertext_throws()
{
SecretProtection.Configure(KeyA);
string enc = SecretProtection.Protect("secret");
// letztes Base64-Zeichen kippen
char last = enc[^1];
string tampered = enc.Substring(0, enc.Length - 1) + (last == 'A' ? 'B' : 'A');
Assert.Throws<InvalidOperationException>(() => SecretProtection.Unprotect(tampered));
}
[Theory]
[InlineData("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")] // Base64 32 Byte
[InlineData("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")] // Hex 64
[InlineData("eine-beliebige-passphrase")] // abgeleitet via SHA-256
public void Various_key_formats_roundtrip(string key)
{
SecretProtection.Configure(key);
string enc = SecretProtection.Protect("value");
Assert.Equal("value", SecretProtection.Unprotect(enc));
}
[Fact]
public void IsEncrypted_detects_prefix()
{
SecretProtection.Configure(KeyA);
Assert.True(SecretProtection.IsEncrypted(SecretProtection.Protect("x")));
Assert.False(SecretProtection.IsEncrypted("plain"));
Assert.False(SecretProtection.IsEncrypted(""));
}
}
}