Files
PolyTraderSharp/src/PolyTrader.Core/Security/SettingsLock.cs
T
RichardandClaude Opus 5 86b497bdbf
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
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>
2026-09-13 14:34:32 +02:00

314 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)));
}
}