Desktop: Passwortabfrage vor Konten, Geheimnissen und WebUI-Zugang
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s

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>
This commit is contained in:
Richard
2026-09-13 14:34:32 +02:00
co-authored by Claude Opus 5
parent 1a09d99a63
commit 86b497bdbf
14 changed files with 913 additions and 24 deletions
+224
View File
@@ -0,0 +1,224 @@
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));
}
}
}