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
+2
View File
@@ -37,6 +37,8 @@ MongoDB/
.gitea-token
master.key
openrouter.key
# #567: Hash des Einstellungs-Passworts (PBKDF2) - gehoert zur Installation, nicht ins Repository
settings-password.json
# Packager-Zugangsdaten (FTP, updateservice:publish-Token) – Vorlage in deploy/packager.config.example.json
deploy/packager.config.json
@@ -17,10 +17,11 @@ namespace PolyTrader.App.Avalonia.Controls
public SettingsEditor() => AvaloniaXamlLoader.Load(this);
/// <summary>Setzt das anzuzeigende Objekt (oder <c>null</c>, um zu leeren).</summary>
public void Show(object? target)
/// <param name="includeSensitive"><c>false</c> blendet Geheimnisfelder aus (#567).</param>
public void Show(object? target, bool includeSensitive = true)
{
var list = this.FindControl<ItemsControl>("sections")!;
list.ItemsSource = target == null ? null : SettingsModelBuilder.Build(target);
list.ItemsSource = target == null ? null : SettingsModelBuilder.Build(target, includeSensitive);
}
}
}
+8
View File
@@ -293,6 +293,14 @@ namespace PolyTrader.App.Avalonia
// Einstellungsdatei zur Laufzeit fortzuschreiben - gemeinsam fuer Desktop und WebUI.
services.AddSingleton(new KnownModules(allModules));
services.AddSingleton(new ServerSettingsFile(ServerSettingsPath));
// #567: Passwortschutz fuer Konten, Geheimnisse und WebUI-Zugang in der Desktop-Oberflaeche.
// Eigene Datei neben master.key - nicht in server_settings.xml, die das WebUI mitschreibt.
services.AddSingleton(sp => new SettingsLock(
new SettingsPasswordStore(
Path.Combine(AppContext.BaseDirectory, "settings-password.json"),
onWarning: m => sp.GetRequiredService<TerminalLogger>().Warning(m)),
log: m => sp.GetRequiredService<TerminalLogger>().Info(m)));
services.AddSingleton(logger);
services.AddSingleton<TradingState>();
services.AddSingleton<JobManager>();
@@ -64,7 +64,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetService<WatchdogHeartbeatService>(),
services.GetService<MullvadVpnService>(),
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>(),
services.GetService<PolyTrader.Web.Configuration.WebUiOptions>())
services.GetService<PolyTrader.Web.Configuration.WebUiOptions>(),
services.GetRequiredService<PolyTrader.Core.Security.SettingsLock>())
});
host.RegisterView(new ModuleView
@@ -244,12 +244,17 @@ namespace PolyTrader.App.Avalonia.ViewModels
/// </summary>
public static class SettingsModelBuilder
{
public static IReadOnlyList<SettingsSection> Build(object target)
/// <param name="includeSensitive">
/// <c>false</c> blendet Felder mit <see cref="PolyTrader.Core.Security.SensitiveSettingAttribute"/> aus –
/// solange die geschützten Einstellungen gesperrt sind (#567).
/// </param>
public static IReadOnlyList<SettingsSection> Build(object target, bool includeSensitive = true)
{
var fields = target.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetIndexParameters().Length == 0)
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
.Where(p => includeSensitive || p.GetCustomAttribute<PolyTrader.Core.Security.SensitiveSettingAttribute>() == null)
.Select(p => new
{
Property = p,
@@ -0,0 +1,32 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PolyTrader.App.Avalonia.Views.PasswordDialog"
Icon="avares://PolyTrader.App.Avalonia/Assets/icon.png"
Title="Einstellungs-Passwort"
Width="480" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<!--
Passwortabfrage vor geschützten Einstellungen (#567): Polymarket-Konten, Geheimnisse, WebUI-Zugang.
Zwei Fälle in einem Dialog: Festlegen beim allerersten Zugriff (mit Wiederholung) und Entsperren.
Eigener Dialog statt DialogWindow.Prompt, weil das Festlegen zwei Felder und eine Fehlerzeile braucht,
die bei einem Fehlversuch stehen bleibt, ohne den Dialog zu schließen.
-->
<StackPanel Margin="20" Spacing="10">
<TextBlock Name="lblTitle" FontSize="15" FontWeight="SemiBold" />
<TextBlock Name="lblMessage" TextWrapping="Wrap" />
<TextBox Name="txtPassword" PasswordChar="•" Watermark="Einstellungs-Passwort" />
<TextBox Name="txtConfirm" PasswordChar="•" Watermark="Passwort wiederholen" IsVisible="False" />
<TextBlock Name="lblError" Classes="negative" TextWrapping="Wrap" IsVisible="False" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,6,0,0">
<Button Name="btnCancel" Content="Abbrechen" Padding="16,6" IsCancel="True" />
<Button Name="btnOk" Content="Entsperren" Padding="16,6" IsDefault="True" Classes="primary" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,76 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using PolyTrader.Core.Security;
namespace PolyTrader.App.Avalonia.Views
{
/// <summary>
/// Passwortabfrage vor geschützten Einstellungen (#567). Layout in PasswordDialog.axaml.
/// Der Dialog prüft nichts selbst – das tut <see cref="SettingsLock"/>. Er nimmt nur die Eingabe entgegen
/// und zeigt, was beim letzten Versuch schiefging.
/// </summary>
public partial class PasswordDialog : Window
{
public PasswordDialog() => AvaloniaXamlLoader.Load(this);
private void Setup(string title, string message, bool withConfirmation, string okText, string? error)
{
Title = title;
this.FindControl<TextBlock>("lblTitle")!.Text = title;
this.FindControl<TextBlock>("lblMessage")!.Text = message;
this.FindControl<TextBox>("txtConfirm")!.IsVisible = withConfirmation;
this.FindControl<Button>("btnOk")!.Content = okText;
var lblError = this.FindControl<TextBlock>("lblError")!;
lblError.Text = error ?? string.Empty;
lblError.IsVisible = !string.IsNullOrEmpty(error);
this.FindControl<Button>("btnOk")!.Click += (_, _) => Close(true);
this.FindControl<Button>("btnCancel")!.Click += (_, _) => Close(false);
Opened += (_, _) => this.FindControl<TextBox>("txtPassword")!.Focus();
}
private static Window? OwnerOf(Control owner)
{
if (TopLevel.GetTopLevel(owner) is Window window) return window;
return (global::Avalonia.Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
}
/// <summary>Entsperren. <c>null</c> bei Abbruch.</summary>
public static async Task<string?> Ask(Control owner, string purpose, string? error)
{
if (OwnerOf(owner) is not { } window) return null;
var dlg = new PasswordDialog();
dlg.Setup("Geschützte Einstellungen entsperren",
$"„{purpose}“ ist geschützt. Bitte das Einstellungs-Passwort eingeben.\n\n" +
$"Entsperrt bleibt es, bis die Einstellungen verlassen werden oder " +
$"{SettingsLock.IdleTimeout.TotalMinutes:0} Minuten lang keine geschützte Aktion kommt.",
withConfirmation: false, okText: "Entsperren", error);
bool ok = await dlg.ShowDialog<bool>(window);
return ok ? dlg.FindControl<TextBox>("txtPassword")!.Text ?? string.Empty : null;
}
/// <summary>Festlegen beim ersten Zugriff. <c>null</c> bei Abbruch.</summary>
public static async Task<(string Password, string Confirmation)?> SetNew(Control owner, string purpose, string? error)
{
if (OwnerOf(owner) is not { } window) return null;
var dlg = new PasswordDialog();
dlg.Setup("Einstellungs-Passwort festlegen",
$"„{purpose}“ ist geschützt, und es ist noch kein Einstellungs-Passwort festgelegt.\n\n" +
"Es schützt in dieser Oberfläche die Polymarket-Konten, die Geheimnisse (Master-Key, OpenRouter-, " +
"Watchdog- und Lizenzschlüssel) und den Zugang zum WebUI. " +
$"Mindestens {SettingsPasswordStore.MinLength} Zeichen.\n\n" +
"Einen Weg „Passwort vergessen“ gibt es bewusst nicht: Wer die Datei settings-password.json im " +
"Programmordner löschen kann, legt beim nächsten Zugriff ein neues fest – und wer das kann, " +
"liest die Schlüsseldateien ohnehin selbst.",
withConfirmation: true, okText: "Festlegen", error);
bool ok = await dlg.ShowDialog<bool>(window);
if (!ok) return null;
return (dlg.FindControl<TextBox>("txtPassword")!.Text ?? string.Empty,
dlg.FindControl<TextBox>("txtConfirm")!.Text ?? string.Empty);
}
}
}
@@ -14,6 +14,15 @@
<TextBlock Name="lblStatus" Text="Bereit." />
</Border>
<!-- #567: Sperrzustand der geschuetzten Einstellungen (Konten, Geheimnisse, WebUI-Zugang). -->
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Name="btnLockToggle" Content="🔒 Entsperren" />
<TextBlock Name="lblLockState" VerticalAlignment="Center" Classes="muted"
Text="Konten, Geheimnisse und WebUI-Zugang sind gesperrt." />
</StackPanel>
</Border>
<TabControl>
<TabItem Header="Allgemeine Einstellungen">
@@ -44,7 +53,18 @@
</TabItem>
<TabItem Header="Polymarket Accounts">
<DockPanel>
<Panel>
<!-- #567: Gesperrt stehen die Konten gar nicht im visuellen Baum - das Raster bleibt leer,
nicht nur verdeckt. -->
<Border Name="pnlAccountsLocked" Padding="32" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12" MaxWidth="480">
<TextBlock Text="🔒 Polymarket-Konten sind gesperrt" FontSize="16" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" Classes="muted"
Text="Die Konten enthalten Private Keys und API-Schlüssel – also Zugriff auf Geld. Sie sind erst nach Eingabe des Einstellungs-Passworts sichtbar und sperren sich nach 5 Minuten ohne geschützte Aktion wieder." />
<Button Name="btnAccUnlock" Content="Entsperren …" HorizontalAlignment="Left" Classes="primary" />
</StackPanel>
</Border>
<DockPanel Name="pnlAccounts" IsVisible="False">
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button Name="btnAccNew" Content="Neuer Account" />
@@ -68,6 +88,7 @@
<controls:SettingsEditor Name="editorAccount" Grid.Column="2" />
</Grid>
</DockPanel>
</Panel>
</TabItem>
<TabItem Header="WebUI">
@@ -1,3 +1,5 @@
using Avalonia.Threading;
using System.Threading.Tasks;
using System;
using System.Collections.ObjectModel;
using System.IO;
@@ -44,12 +46,17 @@ namespace PolyTrader.App.Avalonia.Views
private DateTime? _loadedStamp;
private bool _settingsReadable = true;
// #567: Passwortschutz. Vorbelegt, damit auch ein ohne Dienste gebautes Fenster nie ungeschuetzt ist.
private SettingsLock _lock = new(new SettingsPasswordStore(
System.IO.Path.Combine(AppContext.BaseDirectory, "settings-password.json")));
private readonly DispatcherTimer _idleTimer = new() { Interval = TimeSpan.FromSeconds(15) };
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null,
WebUiOptions? webOptions = null) : this()
WebUiOptions? webOptions = null, SettingsLock? settingsLock = null) : this()
{
_accountRepo = accountRepo;
_state = state;
@@ -58,6 +65,19 @@ namespace PolyTrader.App.Avalonia.Views
_vpn = vpn;
_dcOptions = dcOptions;
_webOptions = webOptions ?? Program.LoadWebUiOptions();
_lock = settingsLock ?? _lock;
// Zustandswechsel kommen auch aus dem Hintergrund (PBKDF2-Pruefung) - neu gezeichnet wird im UI-Thread.
_lock.Changed += () => Dispatcher.UIThread.Post(RefreshLockUi);
_idleTimer.Tick += (_, _) => _lock.ExpireIfIdle();
_idleTimer.Start();
// Wer die Einstellungen verlaesst, laesst sie nicht entsperrt zurueck.
host.OpenStateChanged += () =>
{
if (host is Shell.AvaloniaUiHost shellHost && shellHost.ActiveViewId != "core.settings")
_lock.Lock("Einstellungen verlassen");
};
var grid = this.FindControl<DataGrid>("gridAccounts")!;
grid.ItemsSource = _accounts;
@@ -66,30 +86,139 @@ namespace PolyTrader.App.Avalonia.Views
this.FindControl<Button>("btnSave")!.Click += (_, _) => SaveServerSettings();
this.FindControl<Button>("btnReload")!.Click += (_, _) => ReloadServerSettings();
this.FindControl<Button>("btnGenMasterKey")!.Click += async (_, _) => await GenerateMasterKeyAsync();
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync();
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync();
// #567: Geheimnis-Aktionen nur entsperrt. Lizenzpruefung und Test-Heartbeat zeigen keinen
// Schluessel und bleiben frei.
this.FindControl<Button>("btnGenMasterKey")!.Click += async (_, _) => await Gate("Master-Key erzeugen", GenerateMasterKeyAsync);
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await Gate("OpenRouter-Key setzen", SetOpenRouterKeyAsync);
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await Gate("Watchdog-Token setzen", SetWatchdogTokenAsync);
this.FindControl<Button>("btnTestWatchdog")!.Click += async (_, _) => await TestWatchdogAsync();
this.FindControl<Button>("btnLicenseKey")!.Click += async (_, _) => await SetLicenseKeyAsync();
this.FindControl<Button>("btnLicenseKey")!.Click += async (_, _) => await Gate("Lizenzschlüssel setzen", SetLicenseKeyAsync);
this.FindControl<Button>("btnCheckLicense")!.Click += async (_, _) => await CheckLicenseAsync();
this.FindControl<Button>("btnAccNew")!.Click += (_, _) => AddAccount();
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
this.FindControl<Button>("btnAccSave")!.Click += (_, _) => SaveSelectedAccount();
this.FindControl<Button>("btnAccNew")!.Click += async (_, _) => await Gate("Polymarket-Konto anlegen", () => { AddAccount(); return Task.CompletedTask; });
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await Gate("Polymarket-Konto löschen", DeleteAccountAsync);
this.FindControl<Button>("btnAccSave")!.Click += async (_, _) => await Gate("Polymarket-Konto speichern", () => { SaveSelectedAccount(); return Task.CompletedTask; });
this.FindControl<Button>("btnAccUnlock")!.Click += async (_, _) => await EnsureUnlockedAsync("Polymarket-Konten");
this.FindControl<Button>("btnWebUiGenToken")!.Click += (_, _) => GenerateWebUiToken();
this.FindControl<Button>("btnWebUiToggleShowToken")!.Click += (_, _) => ToggleWebUiTokenVisibility();
this.FindControl<Button>("btnWebUiCopyToken")!.Click += async (_, _) => await CopyWebUiTokenAsync();
this.FindControl<Button>("btnSaveWebUi")!.Click += (_, _) => SaveWebUiSettings();
// Der WebUI-Zugang ist ein Geheimnis: "Speichern" schreibt den Feldinhalt als Token - gesperrt und
// leer haette es das Token geloescht.
this.FindControl<Button>("btnWebUiGenToken")!.Click += async (_, _) => await Gate("Neues WebUI-Token", () => { GenerateWebUiToken(); return Task.CompletedTask; });
this.FindControl<Button>("btnWebUiToggleShowToken")!.Click += async (_, _) => await Gate("WebUI-Token anzeigen", () => { ToggleWebUiTokenVisibility(); return Task.CompletedTask; });
this.FindControl<Button>("btnWebUiCopyToken")!.Click += async (_, _) => await Gate("WebUI-Token kopieren", CopyWebUiTokenAsync);
this.FindControl<Button>("btnSaveWebUi")!.Click += async (_, _) => await Gate("WebUI-Zugang speichern", () => { SaveWebUiSettings(); return Task.CompletedTask; });
this.FindControl<Button>("btnLockToggle")!.Click += async (_, _) =>
{
if (_lock.IsUnlocked) _lock.Lock("von Hand");
else await EnsureUnlockedAsync("Geschützte Einstellungen");
};
ReloadServerSettings();
LoadAccounts();
LoadWebUiSettings();
RefreshLockUi();
UpdateMasterKeyButtonState();
}
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
// ===== Passwortschutz (#567) =====
private void ShowServerEditor() =>
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings, includeSensitive: _lock.IsUnlocked);
/// <summary>Fuehrt eine geschuetzte Aktion aus, nach Entsperren falls noetig.</summary>
private async Task Gate(string purpose, Func<Task> action)
{
if (!await EnsureUnlockedAsync(purpose)) return;
_lock.Touch();
await action();
}
/// <summary>
/// Entsperrt, falls noetig. Beim allerersten Zugriff wird das Passwort festgelegt. Die PBKDF2-Pruefung
/// dauert rund eine halbe Sekunde und laeuft deshalb nicht im UI-Thread.
/// </summary>
private async Task<bool> EnsureUnlockedAsync(string purpose)
{
if (_lock.IsUnlocked)
{
_lock.Touch();
return true;
}
if (!_lock.PasswordIsSet)
{
string? problem = null;
while (true)
{
var entry = await PasswordDialog.SetNew(this, purpose, problem);
if (entry == null) return false;
var (password, confirmation) = entry.Value;
var result = await Task.Run(() => _lock.SetInitialAndUnlock(password, confirmation));
if (result.Succeeded) return true;
problem = result.Outcome switch
{
UnlockOutcome.TooShort => $"Mindestens {SettingsPasswordStore.MinLength} Zeichen.",
UnlockOutcome.Mismatch => "Die beiden Eingaben stimmen nicht überein.",
UnlockOutcome.PasswordAlreadySet => "Inzwischen ist ein Passwort festgelegt – bitte damit entsperren.",
_ => "Das Passwort konnte nicht festgelegt werden."
};
if (result.Outcome == UnlockOutcome.PasswordAlreadySet) break;
}
}
string? message = null;
while (true)
{
string? password = await PasswordDialog.Ask(this, purpose, message);
if (password == null) return false;
var result = await Task.Run(() => _lock.TryUnlock(password));
switch (result.Outcome)
{
case UnlockOutcome.Success:
return true;
case UnlockOutcome.WrongPassword:
message = result.RetryAfter is { } wait
? $"Falsches Passwort. Nächster Versuch in {wait.TotalSeconds:0} s."
: "Falsches Passwort.";
break;
case UnlockOutcome.Blocked:
message = $"Zu viele Fehlversuche. Nächster Versuch in {Math.Ceiling(result.RetryAfter?.TotalSeconds ?? 0):0} s.";
break;
case UnlockOutcome.StoreUnreadable:
await DialogWindow.Info(this, "Passwortdatei nicht lesbar",
$"{result.Detail}\n\nDie geschützten Einstellungen bleiben gesperrt. Betroffen ist die Datei " +
"settings-password.json im Programmordner.");
return false;
default:
return false;
}
}
}
private void RefreshLockUi()
{
bool unlocked = _lock.IsUnlocked;
this.FindControl<Button>("btnLockToggle")!.Content = unlocked ? "🔓 Sperren" : "🔒 Entsperren";
this.FindControl<TextBlock>("lblLockState")!.Text = unlocked
? $"Geschützte Einstellungen entsperrt – sperren sich nach {SettingsLock.IdleTimeout.TotalMinutes:0} Minuten ohne geschützte Aktion und beim Verlassen."
: "Konten, Geheimnisse und WebUI-Zugang sind gesperrt.";
this.FindControl<Border>("pnlAccountsLocked")!.IsVisible = !unlocked;
this.FindControl<DockPanel>("pnlAccounts")!.IsVisible = unlocked;
if (!unlocked)
this.FindControl<SettingsEditor>("editorAccount")!.Show(null);
LoadAccounts();
if (_settingsReadable)
ShowServerEditor();
LoadWebUiSettings();
}
// ===== Server-Settings =====
private void ReloadServerSettings()
@@ -101,7 +230,7 @@ namespace PolyTrader.App.Avalonia.Views
_settings = new ServerSettingsFile(SettingsPath).Read();
_settingsReadable = true;
_loadedStamp = SettingsStamp();
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
ShowServerEditor();
Status("Einstellungen geladen.");
}
catch (Exception ex)
@@ -183,7 +312,12 @@ namespace PolyTrader.App.Avalonia.Views
private void LoadAccounts()
{
if (_state == null) return;
// #567: Gesperrt bleiben die Konten aus dem Raster - samt Private Keys im Editor daneben.
if (_state == null || !_lock.IsUnlocked)
{
_accounts.Clear();
return;
}
_accounts.Clear();
foreach (var a in _state.Accounts.Values.OrderBy(a => a.AccountId)) _accounts.Add(a);
@@ -341,7 +475,7 @@ namespace PolyTrader.App.Avalonia.Views
WriteSingleField(s => s.WatchdogToken = newToken);
_settings.WatchdogToken = newToken;
_watchdog?.ReloadSettings();
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
ShowServerEditor();
if (_settings.WatchdogToken.Length == 0)
{
@@ -416,7 +550,7 @@ namespace PolyTrader.App.Avalonia.Views
: SecretProtection.Protect(key.Trim());
WriteSingleField(s => s.LicenseKey = newKey);
_settings.LicenseKey = newKey;
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
ShowServerEditor();
if (_settings.LicenseKey.Length == 0)
{
@@ -519,7 +653,16 @@ namespace PolyTrader.App.Avalonia.Views
chkEnabled.IsChecked = _webOptions.Enabled;
numPort.Value = _webOptions.Port;
chkLocal.IsChecked = _webOptions.LocalOnly;
txtToken.Text = _webOptions.AuthToken ?? string.Empty;
// #567: Gesperrt steht das Token nicht einmal maskiert im Feld.
bool unlocked = _lock.IsUnlocked;
txtToken.Text = unlocked ? _webOptions.AuthToken ?? string.Empty : string.Empty;
txtToken.Watermark = unlocked ? string.Empty : "gesperrt";
txtToken.IsEnabled = unlocked;
if (!unlocked)
{
txtToken.PasswordChar = '●';
this.FindControl<Button>("btnWebUiToggleShowToken")!.Content = "Anzeigen";
}
if (_webOptions.Enabled)
{
@@ -46,6 +46,7 @@ namespace PolyTraderSharp.Models
public bool VpnEnabled { get; set; } = false;
[Category("Mullvad VPN")]
[PolyTrader.Core.Security.SensitiveSetting] // #567: im Desktop nur entsperrt sichtbar
[DisplayName("Mullvad Account")]
[Description("Account ID for Mullvad VPN. In server_settings.xml (gitignored) hinterlegen – NICHT im Code committen.")]
public string MullvadAccount { get; set; } = ""; // F3: Secret nicht mehr im Quellcode
@@ -80,6 +81,7 @@ namespace PolyTraderSharp.Models
public bool EnableBlockchainListener { get; set; } = true;
[Category("Blockchain Listener")]
[PolyTrader.Core.Security.SensitiveSetting] // #567: enthaelt den Alchemy-Key
[DisplayName("Polygon RPC URL")]
[Description("RPC-/WSS-URL inkl. Alchemy-API-Key. In server_settings.xml (gitignored) hinterlegen – der Key gehört NICHT in den Quellcode/Git. Format: wss://polygon-mainnet.g.alchemy.com/v2/<KEY>")]
public string PolygonRpcUrl { get; set; } = ""; // F3: Alchemy-Key nicht mehr im Quellcode
@@ -0,0 +1,14 @@
using System;
namespace PolyTrader.Core.Security
{
/// <summary>
/// Markiert eine Einstellung, deren Wert ein Geheimnis ist oder eines enthält (#567) – etwa die Polygon-RPC-URL
/// mit dem Alchemy-Key. Der Einstellungs-Editor des Desktops blendet solche Felder aus, solange die
/// geschützten Einstellungen gesperrt sind. Das WebUI zeigt sie ohnehin nie (<c>ServerSettingsCatalog</c>).
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class SensitiveSettingAttribute : Attribute
{
}
}
@@ -0,0 +1,313 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
namespace PolyTrader.Core.Security
{
/// <summary>
/// Das Einstellungs-Passwort des Desktops (#567): nur als PBKDF2-Hash mit Salt abgelegt, nie im Klartext.
///
/// <para><b>Eigene Datei, nicht server_settings.xml.</b> In die Einstellungsdatei schreibt seit #561 auch
/// das WebUI. Der Hash gehört an eine Stelle, die kein Netzweg berührt.</para>
///
/// <para><b>Was die Sperre leistet und was nicht.</b> Sie schützt gegen jemanden, der an einer offenen,
/// entsperrten Desktop-Sitzung sitzt. Wer Zugriff auf das Dateisystem hat, liest <c>master.key</c> ohnehin
/// selbst und kann auch diese Datei löschen. Deshalb gibt es kein "Passwort vergessen": Wer die Datei
/// entfernen kann, braucht keins.</para>
/// </summary>
public sealed class SettingsPasswordStore
{
/// <summary>OWASP-Empfehlung 2023 für PBKDF2-HMAC-SHA256. Etwa eine halbe Sekunde je Prüfung.</summary>
public const int DefaultIterations = 600_000;
public const int MinLength = 10;
private const int SaltBytes = 16;
private const int HashBytes = 32;
private const string Algorithm = "PBKDF2-HMAC-SHA256";
private readonly int _iterations;
private readonly Action<string>? _onWarning;
/// <param name="iterations">Nur für Tests kleiner. Geprüft wird immer mit der Zahl aus der Datei.</param>
public SettingsPasswordStore(string path, int iterations = DefaultIterations, Action<string>? onWarning = null)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Pfad der Passwortdatei fehlt.", nameof(path));
Path = path;
_iterations = iterations;
_onWarning = onWarning;
}
public string Path { get; }
public bool IsSet => File.Exists(Path);
/// <summary>Legt das Passwort beim ersten Mal fest. Ein vorhandenes wird nie überschrieben.</summary>
public void SetInitial(string password)
{
if (IsSet)
throw new InvalidOperationException("Es ist bereits ein Einstellungs-Passwort festgelegt.");
Write(password);
}
/// <summary>Ändert das Passwort, wenn das bisherige stimmt.</summary>
public bool Change(string current, string next)
{
if (!Verify(current))
return false;
Write(next);
return true;
}
/// <summary>
/// Prüft ein Passwort. <c>false</c>, wenn keins festgelegt ist. Eine unlesbare Datei wirft
/// <see cref="InvalidDataException"/>: Sie darf weder still entsperren noch still als "kein Passwort" gelten.
/// </summary>
public bool Verify(string password)
{
if (!IsSet)
return false;
var stored = Read();
byte[] candidate = Rfc2898DeriveBytes.Pbkdf2(password ?? string.Empty, stored.Salt, stored.Iterations,
HashAlgorithmName.SHA256, stored.Hash.Length);
return CryptographicOperations.FixedTimeEquals(candidate, stored.Hash);
}
private sealed record StoredHash(byte[] Salt, byte[] Hash, int Iterations);
private sealed class HashFile
{
public int Version { get; set; }
public string Algorithm { get; set; } = string.Empty;
public int Iterations { get; set; }
public string Salt { get; set; } = string.Empty;
public string Hash { get; set; } = string.Empty;
}
private StoredHash Read()
{
try
{
var file = JsonSerializer.Deserialize<HashFile>(File.ReadAllText(Path))
?? throw new InvalidDataException("leer");
if (file.Version != 1 || file.Algorithm != Algorithm || file.Iterations < 1)
throw new InvalidDataException("unbekanntes Format");
return new StoredHash(Convert.FromBase64String(file.Salt), Convert.FromBase64String(file.Hash), file.Iterations);
}
catch (Exception ex) when (ex is not InvalidDataException)
{
throw new InvalidDataException($"Die Passwortdatei ist nicht lesbar ({ex.Message}).", ex);
}
}
private void Write(string password)
{
if (password == null || password.Length < MinLength)
throw new ArgumentException($"Das Passwort muss mindestens {MinLength} Zeichen lang sein.", nameof(password));
byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, _iterations, HashAlgorithmName.SHA256, HashBytes);
string json = JsonSerializer.Serialize(new HashFile
{
Version = 1,
Algorithm = Algorithm,
Iterations = _iterations,
Salt = Convert.ToBase64String(salt),
Hash = Convert.ToBase64String(hash)
}, new JsonSerializerOptions { WriteIndented = true });
string full = System.IO.Path.GetFullPath(Path);
string temp = full + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllText(temp, json);
FilePermissions.RestrictToOwner(temp, _onWarning);
File.Move(temp, full, overwrite: true);
}
finally
{
if (File.Exists(temp))
{
try { File.Delete(temp); } catch { /* Aufräumen ist Komfort */ }
}
}
}
}
public enum UnlockOutcome
{
Success,
WrongPassword,
Blocked,
NoPasswordSet,
PasswordAlreadySet,
Mismatch,
TooShort,
StoreUnreadable
}
/// <summary>Ergebnis eines Entsperr- oder Festlege-Versuchs. <c>RetryAfter</c> nennt die Wartezeit nach Fehlversuchen.</summary>
public sealed record UnlockResult(UnlockOutcome Outcome, TimeSpan? RetryAfter = null, string? Detail = null)
{
public bool Succeeded => Outcome == UnlockOutcome.Success;
}
/// <summary>
/// Sperrzustand der geschützten Desktop-Einstellungen (#567): Polymarket-Konten, Geheimnisse, Zugang zum WebUI.
///
/// <para>Entscheidung Richard, 13.09.2026: Geheimnisse verwalten wir ausschließlich im Desktop, und selbst
/// dort erst nach zusätzlicher Passwortabfrage.</para>
///
/// <list type="bullet">
/// <item><b>Leerlauf:</b> Nach <see cref="IdleTimeout"/> ohne geschützte Aktion ist wieder gesperrt.</item>
/// <item><b>Fehlversuche:</b> Die ersten zwei sind frei, danach wächst die Wartezeit von 5 s bis 5 min.
/// Während der Wartezeit wird nicht einmal geprüft – auch das richtige Passwort nicht.</item>
/// <item><b>Protokoll:</b> Entsperren, Sperren und Fehlversuche, nie ein eingegebener Wert.</item>
/// </list>
///
/// <para>Die Wartezeit liegt im Speicher und beginnt nach einem Neustart von vorn. Ein Neustart verlangt
/// Zugriff auf die Maschine, und wer den hat, ist durch diese Sperre ohnehin nicht aufzuhalten.</para>
/// </summary>
public sealed class SettingsLock
{
public static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(5);
private readonly SettingsPasswordStore _store;
private readonly Func<DateTime> _utcNow;
private readonly Action<string>? _log;
private readonly object _gate = new();
private DateTime? _lastActivity;
private int _failures;
private DateTime _blockedUntil = DateTime.MinValue;
public SettingsLock(SettingsPasswordStore store, Func<DateTime>? utcNow = null, Action<string>? log = null)
{
_store = store;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
_log = log;
}
/// <summary>Gesperrt, entsperrt oder durch Leerlauf abgelaufen.</summary>
public event Action? Changed;
public bool PasswordIsSet => _store.IsSet;
public bool IsUnlocked
{
get
{
lock (_gate)
return _lastActivity.HasValue && _utcNow() - _lastActivity.Value < IdleTimeout;
}
}
/// <summary>Eine geschützte Aktion hält die Entsperrung offen.</summary>
public void Touch()
{
lock (_gate)
{
if (_lastActivity.HasValue && _utcNow() - _lastActivity.Value < IdleTimeout)
_lastActivity = _utcNow();
}
}
/// <summary>Für einen Zeitgeber der Oberfläche: meldet den Ablauf durch Leerlauf, damit sie sich neu zeichnet.</summary>
public void ExpireIfIdle()
{
bool expired;
lock (_gate)
expired = _lastActivity.HasValue && _utcNow() - _lastActivity.Value >= IdleTimeout;
if (expired)
Lock($"{IdleTimeout.TotalMinutes:0} Minuten ohne geschützte Aktion");
}
public void Lock(string reason)
{
lock (_gate)
{
if (!_lastActivity.HasValue) return;
_lastActivity = null;
}
_log?.Invoke($"🔒 Geschützte Einstellungen gesperrt ({reason}).");
Changed?.Invoke();
}
public UnlockResult TryUnlock(string password)
{
UnlockResult result;
lock (_gate)
{
var now = _utcNow();
if (now < _blockedUntil)
return new UnlockResult(UnlockOutcome.Blocked, _blockedUntil - now);
if (!_store.IsSet)
return new UnlockResult(UnlockOutcome.NoPasswordSet);
bool ok;
try
{
ok = _store.Verify(password);
}
catch (InvalidDataException ex)
{
_log?.Invoke($"⚠️ Einstellungs-Passwort nicht prüfbar: {ex.Message}");
return new UnlockResult(UnlockOutcome.StoreUnreadable, Detail: ex.Message);
}
if (!ok)
{
_failures++;
var wait = Backoff(_failures);
_blockedUntil = now + wait;
result = new UnlockResult(UnlockOutcome.WrongPassword, wait > TimeSpan.Zero ? wait : null);
_log?.Invoke(wait > TimeSpan.Zero
? $"🔒 Einstellungs-Passwort falsch (Fehlversuch {_failures}) – nächster Versuch in {wait.TotalSeconds:0} s."
: $"🔒 Einstellungs-Passwort falsch (Fehlversuch {_failures}).");
return result;
}
_failures = 0;
_blockedUntil = DateTime.MinValue;
_lastActivity = now;
result = new UnlockResult(UnlockOutcome.Success);
}
_log?.Invoke("🔓 Geschützte Einstellungen entsperrt.");
Changed?.Invoke();
return result;
}
/// <summary>Legt das Passwort beim ersten Zugriff fest und entsperrt.</summary>
public UnlockResult SetInitialAndUnlock(string password, string confirmation)
{
lock (_gate)
{
if (_store.IsSet)
return new UnlockResult(UnlockOutcome.PasswordAlreadySet);
if (password == null || password.Length < SettingsPasswordStore.MinLength)
return new UnlockResult(UnlockOutcome.TooShort);
if (!string.Equals(password, confirmation, StringComparison.Ordinal))
return new UnlockResult(UnlockOutcome.Mismatch);
_store.SetInitial(password);
_lastActivity = _utcNow();
}
_log?.Invoke("🔐 Einstellungs-Passwort festgelegt; geschützte Einstellungen entsperrt.");
Changed?.Invoke();
return new UnlockResult(UnlockOutcome.Success);
}
/// <summary>Die ersten zwei Fehlversuche sind frei, dann 5, 10, 20, 40 … Sekunden, höchstens 5 Minuten.</summary>
internal static TimeSpan Backoff(int failures) =>
failures <= 2
? TimeSpan.Zero
: TimeSpan.FromSeconds(Math.Min(300, 5 * Math.Pow(2, failures - 3)));
}
}
@@ -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);
}
}
}
+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));
}
}
}