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:
@@ -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