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,122 @@
|
||||
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(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user