feat: Warnung + selbstverwalteter Wiederherstellungscode für verlorenen Sync-Schlüssel

Der Sync-Schlüssel verlässt nie den Server im Klartext - verliert das einzige Gerät sync.key,
erzeugte AppBootstrapper bisher stillschweigend einen neuen, unabhängigen Schlüssel, ohne dass
der Nutzer etwas davon merkt. Bereits synchronisierte Server-Daten werden dadurch dauerhaft
unlesbar.

AppBootstrapper erkennt jetzt, ob ein fehlender Schlüssel bei einem bereits eingeloggten Gerät
auftritt (SyncKeyStatus) - SettingsViewModel zeigt dafür ein Warnbanner im Synchronisation-Tab mit
der Empfehlung, im Zweifel über ein noch intaktes zweites Gerät neu zu koppeln.

Zusätzlich ein komplett offline laufender, selbstverwalteter Wiederherstellungscode (kein neuer
dauerhafter Server-Speicher für ein sicherheitsrelevantes Geheimnis): SyncCrypto bekommt einen
neuen, vom Pairing-Code kryptografisch unabhängigen Recovery-Code (120 Bit, Base32), mit dem der
Sync-Schlüssel lokal verschlüsselt und als Datei durch den Nutzer selbst gesichert werden kann.
Neuer SyncKeyRecoveryService kapselt Erzeugen/Einlösen; Redeem schreibt den wiederhergestellten
Schlüssel und setzt vorsorglich Pull-Cursor/Versionsverfolgung zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:30:55 +02:00
co-authored by Claude Sonnet 5
parent c5a1157a7d
commit 4cce84c7dd
12 changed files with 662 additions and 7 deletions
@@ -737,6 +737,15 @@
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Gleicht Daten zwischen mehreren eigenen Geräten über einen selbst betriebenen Server ab. Erfordert einen bereits eingerichteten Server und einen dort angelegten Nutzer."/>
<Border Background="#3DE53935" BorderBrush="#E53935" BorderThickness="1" CornerRadius="6"
Padding="12,10" IsVisible="{Binding SyncKeyWasRegenerated}">
<StackPanel Spacing="4">
<TextBlock Text="Neuer Sync-Schlüssel erzeugt" FontSize="13" FontWeight="Bold"/>
<TextBlock FontSize="12" TextWrapping="Wrap"
Text="Der bisherige Sync-Schlüssel dieses Geräts fehlte beim Start und wurde automatisch durch einen neuen ersetzt. Bereits synchronisierte Daten unter dem alten Schlüssel sind für dieses Gerät dadurch nicht mehr lesbar. Hast du noch ein anderes Gerät mit dem alten Schlüssel, koppele dieses Gerät DAMIT neu (unten „Gerät koppeln“), statt fortzufahren. Ansonsten hilft nur ein zuvor gesicherter Wiederherstellungscode (unten)."/>
</StackPanel>
</Border>
<StackPanel Spacing="4">
<TextBlock Text="Server-Adresse" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncServerUrl}" PlaceholderText="https://sync.example.com"/>
@@ -786,6 +795,36 @@
IsVisible="{Binding SyncForceResyncStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
<Separator/>
<TextBlock Text="Wiederherstellungscode" FontSize="14" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Der Sync-Schlüssel verlässt nie den Server im Klartext — geht er auf allen Geräten verloren, sind bereits synchronisierte Daten ohne einen vorher gesicherten Code unwiederbringlich. Der Code läuft komplett offline, ohne Server."/>
<StackPanel Spacing="6">
<TextBlock Text="Code erzeugen und Datei sichern" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichert eine verschlüsselte Schlüsseldatei an einem Ort deiner Wahl (z. B. USB-Stick, eigene Cloud). Den anschließend angezeigten Code getrennt davon notieren und sicher aufbewahren — er wird nirgends gespeichert."/>
<Button Content="Code erzeugen" Command="{Binding CreateRecoveryCodeCommand}"
IsEnabled="{Binding !RecoveryBusy}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding RecoveryCode}" FontSize="18" FontWeight="Bold"
FontFamily="Monospace" TextWrapping="Wrap"
IsVisible="{Binding RecoveryCode, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Wiederherstellungscode einlösen" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Ersetzt den Sync-Schlüssel DIESES Geräts durch den aus der gesicherten Datei — nur nötig, wenn der eigene Schlüssel verloren ging."/>
<TextBox Text="{Binding RecoveryCodeInput}" PlaceholderText="Code eingeben"/>
<Button Content="Datei auswählen und wiederherstellen" Command="{Binding RedeemRecoveryCodeCommand}"
IsEnabled="{Binding !RecoveryBusy}" HorizontalAlignment="Left"/>
</StackPanel>
<TextBlock Text="{Binding RecoveryStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding RecoveryStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
<Separator/>
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>