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)));
}
}