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>
This commit is contained in:
@@ -37,6 +37,8 @@ MongoDB/
|
|||||||
.gitea-token
|
.gitea-token
|
||||||
master.key
|
master.key
|
||||||
openrouter.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
|
# Packager-Zugangsdaten (FTP, updateservice:publish-Token) – Vorlage in deploy/packager.config.example.json
|
||||||
deploy/packager.config.json
|
deploy/packager.config.json
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ namespace PolyTrader.App.Avalonia.Controls
|
|||||||
public SettingsEditor() => AvaloniaXamlLoader.Load(this);
|
public SettingsEditor() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
/// <summary>Setzt das anzuzeigende Objekt (oder <c>null</c>, um zu leeren).</summary>
|
/// <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")!;
|
var list = this.FindControl<ItemsControl>("sections")!;
|
||||||
list.ItemsSource = target == null ? null : SettingsModelBuilder.Build(target);
|
list.ItemsSource = target == null ? null : SettingsModelBuilder.Build(target, includeSensitive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,14 @@ namespace PolyTrader.App.Avalonia
|
|||||||
// Einstellungsdatei zur Laufzeit fortzuschreiben - gemeinsam fuer Desktop und WebUI.
|
// Einstellungsdatei zur Laufzeit fortzuschreiben - gemeinsam fuer Desktop und WebUI.
|
||||||
services.AddSingleton(new KnownModules(allModules));
|
services.AddSingleton(new KnownModules(allModules));
|
||||||
services.AddSingleton(new ServerSettingsFile(ServerSettingsPath));
|
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(logger);
|
||||||
services.AddSingleton<TradingState>();
|
services.AddSingleton<TradingState>();
|
||||||
services.AddSingleton<JobManager>();
|
services.AddSingleton<JobManager>();
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
|||||||
services.GetService<WatchdogHeartbeatService>(),
|
services.GetService<WatchdogHeartbeatService>(),
|
||||||
services.GetService<MullvadVpnService>(),
|
services.GetService<MullvadVpnService>(),
|
||||||
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>(),
|
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
|
host.RegisterView(new ModuleView
|
||||||
|
|||||||
@@ -244,12 +244,17 @@ namespace PolyTrader.App.Avalonia.ViewModels
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SettingsModelBuilder
|
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()
|
var fields = target.GetType()
|
||||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||||
.Where(p => p.GetIndexParameters().Length == 0)
|
.Where(p => p.GetIndexParameters().Length == 0)
|
||||||
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
|
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
|
||||||
|
.Where(p => includeSensitive || p.GetCustomAttribute<PolyTrader.Core.Security.SensitiveSettingAttribute>() == null)
|
||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
Property = p,
|
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." />
|
<TextBlock Name="lblStatus" Text="Bereit." />
|
||||||
</Border>
|
</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>
|
<TabControl>
|
||||||
|
|
||||||
<TabItem Header="Allgemeine Einstellungen">
|
<TabItem Header="Allgemeine Einstellungen">
|
||||||
@@ -44,7 +53,18 @@
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Polymarket Accounts">
|
<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">
|
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<Button Name="btnAccNew" Content="Neuer Account" />
|
<Button Name="btnAccNew" Content="Neuer Account" />
|
||||||
@@ -68,6 +88,7 @@
|
|||||||
<controls:SettingsEditor Name="editorAccount" Grid.Column="2" />
|
<controls:SettingsEditor Name="editorAccount" Grid.Column="2" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
</Panel>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="WebUI">
|
<TabItem Header="WebUI">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -44,12 +46,17 @@ namespace PolyTrader.App.Avalonia.Views
|
|||||||
private DateTime? _loadedStamp;
|
private DateTime? _loadedStamp;
|
||||||
private bool _settingsReadable = true;
|
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() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
|
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
|
||||||
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
|
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
|
||||||
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null,
|
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null,
|
||||||
WebUiOptions? webOptions = null) : this()
|
WebUiOptions? webOptions = null, SettingsLock? settingsLock = null) : this()
|
||||||
{
|
{
|
||||||
_accountRepo = accountRepo;
|
_accountRepo = accountRepo;
|
||||||
_state = state;
|
_state = state;
|
||||||
@@ -58,6 +65,19 @@ namespace PolyTrader.App.Avalonia.Views
|
|||||||
_vpn = vpn;
|
_vpn = vpn;
|
||||||
_dcOptions = dcOptions;
|
_dcOptions = dcOptions;
|
||||||
_webOptions = webOptions ?? Program.LoadWebUiOptions();
|
_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")!;
|
var grid = this.FindControl<DataGrid>("gridAccounts")!;
|
||||||
grid.ItemsSource = _accounts;
|
grid.ItemsSource = _accounts;
|
||||||
@@ -66,30 +86,139 @@ namespace PolyTrader.App.Avalonia.Views
|
|||||||
|
|
||||||
this.FindControl<Button>("btnSave")!.Click += (_, _) => SaveServerSettings();
|
this.FindControl<Button>("btnSave")!.Click += (_, _) => SaveServerSettings();
|
||||||
this.FindControl<Button>("btnReload")!.Click += (_, _) => ReloadServerSettings();
|
this.FindControl<Button>("btnReload")!.Click += (_, _) => ReloadServerSettings();
|
||||||
this.FindControl<Button>("btnGenMasterKey")!.Click += async (_, _) => await GenerateMasterKeyAsync();
|
// #567: Geheimnis-Aktionen nur entsperrt. Lizenzpruefung und Test-Heartbeat zeigen keinen
|
||||||
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync();
|
// Schluessel und bleiben frei.
|
||||||
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync();
|
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>("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>("btnCheckLicense")!.Click += async (_, _) => await CheckLicenseAsync();
|
||||||
|
|
||||||
this.FindControl<Button>("btnAccNew")!.Click += (_, _) => AddAccount();
|
this.FindControl<Button>("btnAccNew")!.Click += async (_, _) => await Gate("Polymarket-Konto anlegen", () => { AddAccount(); return Task.CompletedTask; });
|
||||||
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
|
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await Gate("Polymarket-Konto löschen", DeleteAccountAsync);
|
||||||
this.FindControl<Button>("btnAccSave")!.Click += (_, _) => SaveSelectedAccount();
|
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();
|
// Der WebUI-Zugang ist ein Geheimnis: "Speichern" schreibt den Feldinhalt als Token - gesperrt und
|
||||||
this.FindControl<Button>("btnWebUiToggleShowToken")!.Click += (_, _) => ToggleWebUiTokenVisibility();
|
// leer haette es das Token geloescht.
|
||||||
this.FindControl<Button>("btnWebUiCopyToken")!.Click += async (_, _) => await CopyWebUiTokenAsync();
|
this.FindControl<Button>("btnWebUiGenToken")!.Click += async (_, _) => await Gate("Neues WebUI-Token", () => { GenerateWebUiToken(); return Task.CompletedTask; });
|
||||||
this.FindControl<Button>("btnSaveWebUi")!.Click += (_, _) => SaveWebUiSettings();
|
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();
|
ReloadServerSettings();
|
||||||
LoadAccounts();
|
RefreshLockUi();
|
||||||
LoadWebUiSettings();
|
|
||||||
UpdateMasterKeyButtonState();
|
UpdateMasterKeyButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
|
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 =====
|
// ===== Server-Settings =====
|
||||||
|
|
||||||
private void ReloadServerSettings()
|
private void ReloadServerSettings()
|
||||||
@@ -101,7 +230,7 @@ namespace PolyTrader.App.Avalonia.Views
|
|||||||
_settings = new ServerSettingsFile(SettingsPath).Read();
|
_settings = new ServerSettingsFile(SettingsPath).Read();
|
||||||
_settingsReadable = true;
|
_settingsReadable = true;
|
||||||
_loadedStamp = SettingsStamp();
|
_loadedStamp = SettingsStamp();
|
||||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
ShowServerEditor();
|
||||||
Status("Einstellungen geladen.");
|
Status("Einstellungen geladen.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -183,7 +312,12 @@ namespace PolyTrader.App.Avalonia.Views
|
|||||||
|
|
||||||
private void LoadAccounts()
|
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();
|
_accounts.Clear();
|
||||||
foreach (var a in _state.Accounts.Values.OrderBy(a => a.AccountId)) _accounts.Add(a);
|
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);
|
WriteSingleField(s => s.WatchdogToken = newToken);
|
||||||
_settings.WatchdogToken = newToken;
|
_settings.WatchdogToken = newToken;
|
||||||
_watchdog?.ReloadSettings();
|
_watchdog?.ReloadSettings();
|
||||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
ShowServerEditor();
|
||||||
|
|
||||||
if (_settings.WatchdogToken.Length == 0)
|
if (_settings.WatchdogToken.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -416,7 +550,7 @@ namespace PolyTrader.App.Avalonia.Views
|
|||||||
: SecretProtection.Protect(key.Trim());
|
: SecretProtection.Protect(key.Trim());
|
||||||
WriteSingleField(s => s.LicenseKey = newKey);
|
WriteSingleField(s => s.LicenseKey = newKey);
|
||||||
_settings.LicenseKey = newKey;
|
_settings.LicenseKey = newKey;
|
||||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
ShowServerEditor();
|
||||||
|
|
||||||
if (_settings.LicenseKey.Length == 0)
|
if (_settings.LicenseKey.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -519,7 +653,16 @@ namespace PolyTrader.App.Avalonia.Views
|
|||||||
chkEnabled.IsChecked = _webOptions.Enabled;
|
chkEnabled.IsChecked = _webOptions.Enabled;
|
||||||
numPort.Value = _webOptions.Port;
|
numPort.Value = _webOptions.Port;
|
||||||
chkLocal.IsChecked = _webOptions.LocalOnly;
|
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)
|
if (_webOptions.Enabled)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ namespace PolyTraderSharp.Models
|
|||||||
public bool VpnEnabled { get; set; } = false;
|
public bool VpnEnabled { get; set; } = false;
|
||||||
|
|
||||||
[Category("Mullvad VPN")]
|
[Category("Mullvad VPN")]
|
||||||
|
[PolyTrader.Core.Security.SensitiveSetting] // #567: im Desktop nur entsperrt sichtbar
|
||||||
[DisplayName("Mullvad Account")]
|
[DisplayName("Mullvad Account")]
|
||||||
[Description("Account ID for Mullvad VPN. In server_settings.xml (gitignored) hinterlegen – NICHT im Code committen.")]
|
[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
|
public string MullvadAccount { get; set; } = ""; // F3: Secret nicht mehr im Quellcode
|
||||||
@@ -80,6 +81,7 @@ namespace PolyTraderSharp.Models
|
|||||||
public bool EnableBlockchainListener { get; set; } = true;
|
public bool EnableBlockchainListener { get; set; } = true;
|
||||||
|
|
||||||
[Category("Blockchain Listener")]
|
[Category("Blockchain Listener")]
|
||||||
|
[PolyTrader.Core.Security.SensitiveSetting] // #567: enthaelt den Alchemy-Key
|
||||||
[DisplayName("Polygon RPC URL")]
|
[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>")]
|
[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
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user