Umsetzung von #567. Entscheidung Richard, 13.09.2026: Geheimnisse verwalten wir ausschliesslich im Desktop, und selbst dort erst nach zusaetzlicher Passwortabfrage. Bisher sah und aenderte jeder, der die Desktop-Oberflaeche offen vor sich hatte: - Polymarket-Konten mit ApiKey, ApiSecret, ApiPassphrase und PrivateKey - Master-Key, OpenRouter-Key, Watchdog-Token und Lizenzschluessel - Mullvad-Account und Polygon-RPC-URL mit Alchemy-Key, offen im Editor - das WebUI-Token, per "Anzeigen" lesbar Jetzt gesperrt, bis entsperrt: - Tab "Polymarket Accounts": Gesperrt stehen die Konten gar nicht im visuellen Baum. Das Raster bleibt leer, der Editor mit den Private Keys ebenfalls; nichts ist nur verdeckt. - Master-Key erzeugen, OpenRouter-, Watchdog- und Lizenzschluessel setzen. - Mullvad-Account und Polygon-RPC-URL: mit [SensitiveSetting] markiert und im Editor ausgeblendet. - WebUI-Zugang: Gesperrt steht das Token nicht einmal maskiert im Feld. Speichern ist mitgesperrt, weil es den Feldinhalt als Token schreibt; gesperrt und leer haette es das Token geloescht. - Frei bleiben Lizenzpruefung und Test-Heartbeat, weil sie keinen Schluessel zeigen. Kern im Core (PolyTrader.Core.Security), weil nur dort testbar: - SettingsPasswordStore: PBKDF2-HMAC-SHA256, 600 000 Iterationen, Salt, eigene Datei settings-password.json neben master.key, atomar geschrieben und nur fuer den Besitzer lesbar. Nicht in server_settings.xml, die seit #561 das WebUI mitschreibt. Ein vorhandenes Passwort wird nie ueberschrieben. - SettingsLock: - erstes Festlegen mit Wiederholung und mindestens 10 Zeichen - zwei freie Fehlversuche, danach 5 s bis 5 min Wartezeit, in der auch das richtige Passwort nicht geprueft wird - Sperre nach 5 Minuten ohne geschuetzte Aktion und beim Verlassen der Einstellungen - eine unlesbare Passwortdatei entsperrt nie und gilt auch nicht still als "kein Passwort" - Protokoll ohne eingegebene Werte Oberflaeche: PasswordDialog deklarativ in .axaml, Sperr-Leiste ueber den Tabs. Die PBKDF2-Pruefung dauert etwa eine halbe Sekunde und laeuft nicht im UI-Thread. Grenzen: - Die Sperre schuetzt gegen jemanden an einer entsperrten Desktop-Sitzung. Wer Dateizugriff hat, liest master.key selbst. Deshalb gibt es kein "Passwort vergessen": Wer settings-password.json loeschen kann, braucht keins. - Die Wartezeit nach Fehlversuchen beginnt nach einem Neustart von vorn. - "Datenbankeinstellungen": Eine Ansicht dafuer gibt es im Desktop nicht; die Verbindungszeichenfolge steht nur in appsettings.Local.json. Entsteht eine, gehoert sie hinter dieselbe Sperre. Tests: 20 fuer Passwortdatei und Sperre (Festlegen, Fehlversuche, Wartezeit, Leerlauf, unlesbare Datei, Aendern, Iterationszahl aus der Datei, Dateirechte) und 5 fuer die Markierung der Geheimnisfelder. Die Oberflaeche selbst wird auf der VM abgenommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
7.5 KiB
C#
225 lines
7.5 KiB
C#
using System;
|
||
using System.IO;
|
||
using PolyTrader.Core.Security;
|
||
using Xunit;
|
||
|
||
namespace PolyTrader.Tests
|
||
{
|
||
/// <summary>
|
||
/// Passwortschutz der geschützten Desktop-Einstellungen (#567): Konten, Geheimnisse, Zugang zum WebUI.
|
||
/// Mit wenigen PBKDF2-Iterationen, damit die Tests schnell bleiben – geprüft wird immer mit der Zahl aus der Datei.
|
||
/// </summary>
|
||
public sealed class SettingsLockTests : IDisposable
|
||
{
|
||
private const string Good = "richtig-geheim-42";
|
||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "polytrader-lock-" + Guid.NewGuid().ToString("N"));
|
||
private DateTime _now = new(2026, 9, 13, 12, 0, 0, DateTimeKind.Utc);
|
||
|
||
public SettingsLockTests() => Directory.CreateDirectory(_dir);
|
||
|
||
public void Dispose()
|
||
{
|
||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||
}
|
||
|
||
private string PasswordPath => Path.Combine(_dir, "settings-password.json");
|
||
|
||
private SettingsPasswordStore Store(int iterations = 1000) => new(PasswordPath, iterations);
|
||
|
||
private SettingsLock Lock(SettingsPasswordStore? store = null) => new(store ?? Store(), () => _now);
|
||
|
||
private SettingsLock UnlockedLock()
|
||
{
|
||
var l = Lock();
|
||
Assert.True(l.SetInitialAndUnlock(Good, Good).Succeeded);
|
||
return l;
|
||
}
|
||
|
||
// ---- Festlegen ----------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void Ohne_Passwort_ist_gesperrt_und_Entsperren_verlangt_zuerst_eines()
|
||
{
|
||
var l = Lock();
|
||
|
||
Assert.False(l.PasswordIsSet);
|
||
Assert.False(l.IsUnlocked);
|
||
Assert.Equal(UnlockOutcome.NoPasswordSet, l.TryUnlock("irgendwas").Outcome);
|
||
}
|
||
|
||
[Fact]
|
||
public void Festlegen_prueft_Laenge_und_Wiederholung_und_entsperrt()
|
||
{
|
||
var l = Lock();
|
||
|
||
Assert.Equal(UnlockOutcome.TooShort, l.SetInitialAndUnlock("kurz", "kurz").Outcome);
|
||
Assert.Equal(UnlockOutcome.Mismatch, l.SetInitialAndUnlock(Good, Good + "x").Outcome);
|
||
Assert.False(File.Exists(PasswordPath));
|
||
|
||
Assert.True(l.SetInitialAndUnlock(Good, Good).Succeeded);
|
||
Assert.True(l.IsUnlocked);
|
||
Assert.Equal(UnlockOutcome.PasswordAlreadySet, l.SetInitialAndUnlock("anderes-passwort", "anderes-passwort").Outcome);
|
||
}
|
||
|
||
[Fact]
|
||
public void Die_Datei_enthaelt_das_Passwort_nicht()
|
||
{
|
||
UnlockedLock();
|
||
|
||
string content = File.ReadAllText(PasswordPath);
|
||
Assert.DoesNotContain(Good, content);
|
||
Assert.Contains("PBKDF2-HMAC-SHA256", content);
|
||
}
|
||
|
||
[Fact]
|
||
public void Ein_vorhandenes_Passwort_wird_nie_ueberschrieben()
|
||
{
|
||
UnlockedLock();
|
||
|
||
Assert.Throws<InvalidOperationException>(() => Store().SetInitial("ganz-neues-passwort"));
|
||
Assert.True(Store().Verify(Good));
|
||
}
|
||
|
||
[Fact]
|
||
public void Dateirechte_nur_fuer_den_Besitzer()
|
||
{
|
||
if (OperatingSystem.IsWindows()) return;
|
||
|
||
UnlockedLock();
|
||
|
||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(PasswordPath));
|
||
}
|
||
|
||
// ---- Entsperren und Fehlversuche ------------------------------------------------
|
||
|
||
[Fact]
|
||
public void Richtiges_Passwort_entsperrt_falsches_nicht()
|
||
{
|
||
UnlockedLock().Lock("Test");
|
||
var l = Lock();
|
||
|
||
Assert.Equal(UnlockOutcome.WrongPassword, l.TryUnlock("falsch-falsch").Outcome);
|
||
Assert.False(l.IsUnlocked);
|
||
Assert.True(l.TryUnlock(Good).Succeeded);
|
||
Assert.True(l.IsUnlocked);
|
||
}
|
||
|
||
[Fact]
|
||
public void Nach_zwei_freien_Fehlversuchen_waechst_die_Wartezeit_und_sperrt_auch_das_richtige_Passwort()
|
||
{
|
||
UnlockedLock().Lock("Test");
|
||
var l = Lock();
|
||
|
||
Assert.Null(l.TryUnlock("falsch-1").RetryAfter);
|
||
Assert.Null(l.TryUnlock("falsch-2").RetryAfter);
|
||
var third = l.TryUnlock("falsch-3");
|
||
Assert.Equal(TimeSpan.FromSeconds(5), third.RetryAfter);
|
||
|
||
// Waehrend der Wartezeit wird gar nicht geprueft - auch das richtige Passwort nicht.
|
||
_now = _now.AddSeconds(4);
|
||
var blocked = l.TryUnlock(Good);
|
||
Assert.Equal(UnlockOutcome.Blocked, blocked.Outcome);
|
||
Assert.False(l.IsUnlocked);
|
||
|
||
_now = _now.AddSeconds(1);
|
||
Assert.True(l.TryUnlock(Good).Succeeded);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(1, 0)]
|
||
[InlineData(2, 0)]
|
||
[InlineData(3, 5)]
|
||
[InlineData(4, 10)]
|
||
[InlineData(6, 40)]
|
||
[InlineData(9, 300)]
|
||
[InlineData(20, 300)]
|
||
public void Wartezeit_verdoppelt_sich_bis_hoechstens_fuenf_Minuten(int failures, int seconds)
|
||
{
|
||
Assert.Equal(TimeSpan.FromSeconds(seconds), SettingsLock.Backoff(failures));
|
||
}
|
||
|
||
[Fact]
|
||
public void Erfolg_setzt_die_Zaehlung_zurueck()
|
||
{
|
||
UnlockedLock().Lock("Test");
|
||
var l = Lock();
|
||
l.TryUnlock("falsch-1");
|
||
l.TryUnlock("falsch-2");
|
||
Assert.True(l.TryUnlock(Good).Succeeded);
|
||
l.Lock("Test");
|
||
|
||
Assert.Null(l.TryUnlock("falsch-nach-erfolg").RetryAfter); // wieder ein freier Versuch
|
||
}
|
||
|
||
[Fact]
|
||
public void Unlesbare_Passwortdatei_entsperrt_nie()
|
||
{
|
||
File.WriteAllText(PasswordPath, "{ kaputt");
|
||
var l = Lock();
|
||
|
||
var result = l.TryUnlock(Good);
|
||
|
||
Assert.Equal(UnlockOutcome.StoreUnreadable, result.Outcome);
|
||
Assert.False(l.IsUnlocked);
|
||
Assert.True(l.PasswordIsSet); // gilt auch nicht still als "kein Passwort"
|
||
}
|
||
|
||
// ---- Leerlauf ------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void Nach_fuenf_Minuten_ohne_Aktion_ist_wieder_gesperrt()
|
||
{
|
||
var l = UnlockedLock();
|
||
int changes = 0;
|
||
l.Changed += () => changes++;
|
||
|
||
_now = _now.AddMinutes(4).AddSeconds(59);
|
||
Assert.True(l.IsUnlocked);
|
||
l.Touch();
|
||
|
||
_now = _now.AddMinutes(4);
|
||
Assert.True(l.IsUnlocked); // Touch hat die Frist verlaengert
|
||
|
||
_now = _now.AddMinutes(1).AddSeconds(1);
|
||
Assert.False(l.IsUnlocked);
|
||
l.ExpireIfIdle();
|
||
Assert.Equal(1, changes);
|
||
}
|
||
|
||
[Fact]
|
||
public void Touch_entsperrt_keine_gesperrte_Sitzung()
|
||
{
|
||
var l = UnlockedLock();
|
||
l.Lock("Ansicht verlassen");
|
||
|
||
l.Touch();
|
||
|
||
Assert.False(l.IsUnlocked);
|
||
}
|
||
|
||
// ---- Passwort ändern -----------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void Aendern_verlangt_das_bisherige_Passwort()
|
||
{
|
||
UnlockedLock();
|
||
var store = Store();
|
||
|
||
Assert.False(store.Change("falsch-falsch", "neues-passwort-99"));
|
||
Assert.True(store.Verify(Good));
|
||
|
||
Assert.True(store.Change(Good, "neues-passwort-99"));
|
||
Assert.True(store.Verify("neues-passwort-99"));
|
||
Assert.False(store.Verify(Good));
|
||
}
|
||
|
||
[Fact]
|
||
public void Geprueft_wird_mit_der_Iterationszahl_aus_der_Datei()
|
||
{
|
||
Store(iterations: 1000).SetInitial(Good);
|
||
|
||
Assert.True(new SettingsPasswordStore(PasswordPath, iterations: 50_000).Verify(Good));
|
||
}
|
||
}
|
||
}
|