using System; using System.IO; using System.Security.Cryptography; using System.Text.Json; namespace PolyTrader.Core.Security { /// /// Das Einstellungs-Passwort des Desktops (#567): nur als PBKDF2-Hash mit Salt abgelegt, nie im Klartext. /// /// Eigene Datei, nicht server_settings.xml. In die Einstellungsdatei schreibt seit #561 auch /// das WebUI. Der Hash gehört an eine Stelle, die kein Netzweg berührt. /// /// Was die Sperre leistet und was nicht. Sie schützt gegen jemanden, der an einer offenen, /// entsperrten Desktop-Sitzung sitzt. Wer Zugriff auf das Dateisystem hat, liest master.key ohnehin /// selbst und kann auch diese Datei löschen. Deshalb gibt es kein "Passwort vergessen": Wer die Datei /// entfernen kann, braucht keins. /// public sealed class SettingsPasswordStore { /// OWASP-Empfehlung 2023 für PBKDF2-HMAC-SHA256. Etwa eine halbe Sekunde je Prüfung. 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? _onWarning; /// Nur für Tests kleiner. Geprüft wird immer mit der Zahl aus der Datei. public SettingsPasswordStore(string path, int iterations = DefaultIterations, Action? 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); /// Legt das Passwort beim ersten Mal fest. Ein vorhandenes wird nie überschrieben. public void SetInitial(string password) { if (IsSet) throw new InvalidOperationException("Es ist bereits ein Einstellungs-Passwort festgelegt."); Write(password); } /// Ändert das Passwort, wenn das bisherige stimmt. public bool Change(string current, string next) { if (!Verify(current)) return false; Write(next); return true; } /// /// Prüft ein Passwort. false, wenn keins festgelegt ist. Eine unlesbare Datei wirft /// : Sie darf weder still entsperren noch still als "kein Passwort" gelten. /// 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(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 } /// Ergebnis eines Entsperr- oder Festlege-Versuchs. RetryAfter nennt die Wartezeit nach Fehlversuchen. public sealed record UnlockResult(UnlockOutcome Outcome, TimeSpan? RetryAfter = null, string? Detail = null) { public bool Succeeded => Outcome == UnlockOutcome.Success; } /// /// Sperrzustand der geschützten Desktop-Einstellungen (#567): Polymarket-Konten, Geheimnisse, Zugang zum WebUI. /// /// Entscheidung Richard, 13.09.2026: Geheimnisse verwalten wir ausschließlich im Desktop, und selbst /// dort erst nach zusätzlicher Passwortabfrage. /// /// /// Leerlauf: Nach ohne geschützte Aktion ist wieder gesperrt. /// Fehlversuche: 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. /// Protokoll: Entsperren, Sperren und Fehlversuche, nie ein eingegebener Wert. /// /// /// 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. /// public sealed class SettingsLock { public static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(5); private readonly SettingsPasswordStore _store; private readonly Func _utcNow; private readonly Action? _log; private readonly object _gate = new(); private DateTime? _lastActivity; private int _failures; private DateTime _blockedUntil = DateTime.MinValue; public SettingsLock(SettingsPasswordStore store, Func? utcNow = null, Action? log = null) { _store = store; _utcNow = utcNow ?? (() => DateTime.UtcNow); _log = log; } /// Gesperrt, entsperrt oder durch Leerlauf abgelaufen. public event Action? Changed; public bool PasswordIsSet => _store.IsSet; public bool IsUnlocked { get { lock (_gate) return _lastActivity.HasValue && _utcNow() - _lastActivity.Value < IdleTimeout; } } /// Eine geschützte Aktion hält die Entsperrung offen. public void Touch() { lock (_gate) { if (_lastActivity.HasValue && _utcNow() - _lastActivity.Value < IdleTimeout) _lastActivity = _utcNow(); } } /// Für einen Zeitgeber der Oberfläche: meldet den Ablauf durch Leerlauf, damit sie sich neu zeichnet. 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; } /// Legt das Passwort beim ersten Zugriff fest und entsperrt. 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); } /// Die ersten zwei Fehlversuche sind frei, dann 5, 10, 20, 40 … Sekunden, höchstens 5 Minuten. internal static TimeSpan Backoff(int failures) => failures <= 2 ? TimeSpan.Zero : TimeSpan.FromSeconds(Math.Min(300, 5 * Math.Pow(2, failures - 3))); } }