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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user