Files
PolyTraderSharp/tests/PolyTrader.Tests/SettingsLockTests.cs
T
RichardandClaude Opus 5 86b497bdbf
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Desktop: Passwortabfrage vor Konten, Geheimnissen und WebUI-Zugang
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>
2026-09-13 14:34:32 +02:00

225 lines
7.5 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 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));
}
}
}