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:
@@ -44,6 +44,63 @@ public static class SyncCrypto
|
||||
public static byte[] DecryptKeyWithCode(string encryptedB64, string code) =>
|
||||
Decrypt(Convert.FromBase64String(encryptedB64), DeriveKeyFromCode(code));
|
||||
|
||||
// ── Selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────────
|
||||
//
|
||||
// Anders als der Pairing-Code (kurzlebig, einmal serverseitig hinterlegt, siehe
|
||||
// SnapshotService) verlässt dieser Weg den Client nie: der Nutzer sichert die verschlüsselte
|
||||
// Schlüsseldatei selbst (z.B. USB-Stick, eigene Cloud) und merkt sich den Code getrennt davon
|
||||
// — kein neuer dauerhafter Server-seitiger Speicher für ein sicherheitsrelevantes Geheimnis.
|
||||
// Eigene Ableitung (eigenes Salt) statt Wiederverwendung von DeriveKeyFromCode, damit ein
|
||||
// künftiger Wechsel am kurzlebigen Pairing-Schema diesen langlebigen Anwendungsfall nicht
|
||||
// versehentlich mit beeinflusst (und umgekehrt).
|
||||
|
||||
private const string RecoveryCodeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // RFC 4648 Base32
|
||||
|
||||
/// <summary>120 Bit Zufall (15 Byte, exakt 24 Base32-Zeichen ohne Padding), in 4er-Gruppen
|
||||
/// formatiert. Vom Programm zufällig erzeugt (kein Nutzer-Passwort) — 120 Bit sind auch ohne
|
||||
/// PBKDF2-Härtung praktisch nicht erratbar; PBKDF2 dient hier vor allem der gleichförmigen
|
||||
/// Schlüsselableitung, nicht dem Schutz vor schwachen Eingaben.</summary>
|
||||
public static string GenerateRecoveryCode()
|
||||
{
|
||||
var bytes = new byte[15];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
var raw = ToBase32(bytes);
|
||||
return string.Join("-", Enumerable.Range(0, raw.Length / 4).Select(i => raw.Substring(i * 4, 4)));
|
||||
}
|
||||
|
||||
public static byte[] DeriveRecoveryKeyFromCode(string code)
|
||||
{
|
||||
var normalized = new string(code.Where(c => !char.IsWhiteSpace(c) && c != '-').ToArray())
|
||||
.ToUpperInvariant();
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
var salt = Encoding.UTF8.GetBytes("LehrerApp-RecoveryCode-v1");
|
||||
return Rfc2898DeriveBytes.Pbkdf2(bytes, salt, Pbkdf2Iter, HashAlgorithmName.SHA256, 32);
|
||||
}
|
||||
|
||||
public static string EncryptKeyWithRecoveryCode(byte[] syncKey, string code) =>
|
||||
Convert.ToBase64String(Encrypt(syncKey, DeriveRecoveryKeyFromCode(code)));
|
||||
|
||||
public static byte[] DecryptKeyWithRecoveryCode(string encryptedB64, string code) =>
|
||||
Decrypt(Convert.FromBase64String(encryptedB64), DeriveRecoveryKeyFromCode(code));
|
||||
|
||||
private static string ToBase32(byte[] data)
|
||||
{
|
||||
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
|
||||
int bits = 0, value = 0;
|
||||
foreach (var b in data)
|
||||
{
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5)
|
||||
{
|
||||
sb.Append(RecoveryCodeAlphabet[(value >> (bits - 5)) & 0x1F]);
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) sb.Append(RecoveryCodeAlphabet[(value << (5 - bits)) & 0x1F]);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Ver-/Entschlüsselung ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Format: [Nonce 12B][Ciphertext][Tag 16B]</summary>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Selbstverwalteter Wiederherstellungsweg für den lokalen Sync-Schlüssel (TODO 10.3.2). Anders
|
||||
/// als das Geräte-Pairing (<see cref="SnapshotService"/>) läuft das komplett offline, ohne
|
||||
/// Server-Beteiligung: der Nutzer sichert die verschlüsselte Schlüsseldatei selbst (z.B.
|
||||
/// USB-Stick, eigene Cloud) und merkt sich den Code getrennt davon — Datei und Code zusammen
|
||||
/// ergeben erst den Schlüssel.
|
||||
/// </summary>
|
||||
public class SyncKeyRecoveryService(byte[] syncKey, string keyPath)
|
||||
{
|
||||
public string CreateRecoveryFile(string code) =>
|
||||
JsonSerializer.Serialize(new SyncKeyRecoveryFile(
|
||||
SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, code), DateTime.UtcNow));
|
||||
|
||||
/// <exception cref="System.Security.Cryptography.CryptographicException">
|
||||
/// Falscher Code oder beschädigte/manipulierte Datei.</exception>
|
||||
/// <exception cref="InvalidOperationException">Datei ist kein gültiges Wiederherstellungsformat.</exception>
|
||||
public void RestoreFromFile(string fileContent, string code)
|
||||
{
|
||||
var file = JsonSerializer.Deserialize<SyncKeyRecoveryFile>(fileContent);
|
||||
if (file is null || string.IsNullOrWhiteSpace(file.WrappedKey))
|
||||
throw new InvalidOperationException("Ungültige Wiederherstellungsdatei.");
|
||||
var restoredKey = SyncCrypto.DecryptKeyWithRecoveryCode(file.WrappedKey, code);
|
||||
SyncCrypto.SaveKey(restoredKey, keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
public record SyncKeyRecoveryFile(string WrappedKey, DateTime CreatedAt);
|
||||
Reference in New Issue
Block a user