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
@@ -0,0 +1,47 @@
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Geheimnisse in den Server-Einstellungen sind entweder ganz ausgeblendet oder als
/// <see cref="SensitiveSettingAttribute"/> markiert (#567). Der Desktop-Editor zeigt markierte Felder nur
/// entsperrt; ein neues Geheimnisfeld ohne Markierung stünde dort sonst offen im Fenster.
/// </summary>
public class SensitiveSettingsTests
{
private static PropertyInfo Prop(string name) => typeof(ServerSettings).GetProperty(name)!;
[Theory]
[InlineData(nameof(ServerSettings.MullvadAccount))]
[InlineData(nameof(ServerSettings.PolygonRpcUrl))]
public void Sichtbare_Geheimnisfelder_sind_markiert(string name)
{
Assert.NotNull(Prop(name).GetCustomAttribute<SensitiveSettingAttribute>());
}
[Theory]
[InlineData(nameof(ServerSettings.WatchdogToken))]
[InlineData(nameof(ServerSettings.LicenseKey))]
public void Token_und_Lizenzschluessel_sind_ganz_ausgeblendet(string name)
{
Assert.False(Prop(name).GetCustomAttribute<BrowsableAttribute>()?.Browsable ?? true);
}
[Fact]
public void Keine_weiteren_Felder_sind_versehentlich_markiert()
{
var marked = typeof(ServerSettings).GetProperties()
.Where(p => p.GetCustomAttribute<SensitiveSettingAttribute>() != null)
.Select(p => p.Name)
.OrderBy(n => n)
.ToArray();
Assert.Equal(new[] { nameof(ServerSettings.MullvadAccount), nameof(ServerSettings.PolygonRpcUrl) }, marked);
}
}
}