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
+145
View File
@@ -0,0 +1,145 @@
using System.Security.Cryptography;
using LehrerApp.Sync.Crypto;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class SyncCryptoTests
{
[Fact]
public void GenerateKey_LiefertZweiUnterschiedlicheSchluessel()
{
var a = SyncCrypto.GenerateKey();
var b = SyncCrypto.GenerateKey();
Assert.Equal(32, a.Length);
Assert.NotEqual(a, b);
}
[Fact]
public void SaveKeyUndLoadKey_Roundtrip()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-synccrypto-tests-{Guid.NewGuid():N}.key");
try
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, path);
Assert.Equal(key, SyncCrypto.LoadKey(path));
}
finally { if (File.Exists(path)) File.Delete(path); }
}
[Fact]
public void LoadKey_DateiFehlt_LiefertNull() =>
Assert.Null(SyncCrypto.LoadKey(Path.Combine(Path.GetTempPath(), $"nie-existiert-{Guid.NewGuid():N}.key")));
[Fact]
public void EncryptUndDecrypt_Roundtrip()
{
var key = SyncCrypto.GenerateKey();
var plaintext = "Ein geheimer Sitzplan-Sync-Test"u8.ToArray();
var encrypted = SyncCrypto.Encrypt(plaintext, key);
var decrypted = SyncCrypto.Decrypt(encrypted, key);
Assert.Equal(plaintext, decrypted);
}
[Fact]
public void Decrypt_FalscherSchluessel_WirftCryptographicException()
{
var encrypted = SyncCrypto.Encrypt("geheim"u8.ToArray(), SyncCrypto.GenerateKey());
Assert.ThrowsAny<CryptographicException>(() => SyncCrypto.Decrypt(encrypted, SyncCrypto.GenerateKey()));
}
[Fact]
public void EncryptKeyWithCodeUndDecryptKeyWithCode_Roundtrip()
{
var syncKey = SyncCrypto.GenerateKey();
var wrapped = SyncCrypto.EncryptKeyWithCode(syncKey, "TIGER-42-BLAU");
Assert.Equal(syncKey, SyncCrypto.DecryptKeyWithCode(wrapped, "TIGER-42-BLAU"));
}
[Fact]
public void DecryptKeyWithCode_FalscherCode_WirftCryptographicException()
{
var syncKey = SyncCrypto.GenerateKey();
var wrapped = SyncCrypto.EncryptKeyWithCode(syncKey, "TIGER-42-BLAU");
Assert.ThrowsAny<CryptographicException>(() => SyncCrypto.DecryptKeyWithCode(wrapped, "FALSCH-99-CODE"));
}
// ── Selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────────
[Fact]
public void GenerateRecoveryCode_LiefertSechsGruppenZuJeVierZeichen()
{
var code = SyncCrypto.GenerateRecoveryCode();
var groups = code.Split('-');
Assert.Equal(6, groups.Length);
Assert.All(groups, g => Assert.Equal(4, g.Length));
// RFC 4648 Base32-Alphabet, keine Verwechslungsgefahr mit 0/1/8/9 o.ä.
Assert.All(code.Where(c => c != '-'), c => Assert.Contains(c, "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"));
}
[Fact]
public void GenerateRecoveryCode_LiefertJedesMalEinenAnderenCode()
{
var a = SyncCrypto.GenerateRecoveryCode();
var b = SyncCrypto.GenerateRecoveryCode();
Assert.NotEqual(a, b);
}
[Fact]
public void EncryptKeyWithRecoveryCodeUndDecryptKeyWithRecoveryCode_Roundtrip()
{
var syncKey = SyncCrypto.GenerateKey();
var code = SyncCrypto.GenerateRecoveryCode();
var wrapped = SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, code);
Assert.Equal(syncKey, SyncCrypto.DecryptKeyWithRecoveryCode(wrapped, code));
}
/// Der Code wird von Hand abgetippt - Groß-/Kleinschreibung und ob die Trennstriche mitgetippt
/// wurden, dürfen die Wiederherstellung nicht scheitern lassen.
[Fact]
public void DecryptKeyWithRecoveryCode_ToleriertKleinschreibungUndFehlendeTrennstriche()
{
var syncKey = SyncCrypto.GenerateKey();
var code = SyncCrypto.GenerateRecoveryCode();
var wrapped = SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, code);
var retyped = code.Replace("-", "").ToLowerInvariant();
Assert.Equal(syncKey, SyncCrypto.DecryptKeyWithRecoveryCode(wrapped, retyped));
}
[Fact]
public void DecryptKeyWithRecoveryCode_FalscherCode_WirftCryptographicException()
{
var syncKey = SyncCrypto.GenerateKey();
var wrapped = SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, SyncCrypto.GenerateRecoveryCode());
Assert.ThrowsAny<CryptographicException>(
() => SyncCrypto.DecryptKeyWithRecoveryCode(wrapped, SyncCrypto.GenerateRecoveryCode()));
}
/// Pairing-Code und Wiederherstellungscode müssen unabhängige Ableitungen sein (eigenes Salt) -
/// sonst könnte ein kurzlebiger Pairing-Code auch den langlebigen Wiederherstellungsschutz
/// aushebeln.
[Fact]
public void DeriveKeyFromCodeUndDeriveRecoveryKeyFromCode_LiefernBeiGleichemCodeUnterschiedlicheSchluessel()
{
const string code = "GEMEINSAMER-CODE-123";
Assert.NotEqual(SyncCrypto.DeriveKeyFromCode(code), SyncCrypto.DeriveRecoveryKeyFromCode(code));
}
}
@@ -0,0 +1,61 @@
using LehrerApp.Sync.Crypto;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class SyncKeyRecoveryServiceTests
{
[Fact]
public void CreateRecoveryFileUndRestoreFromFile_Roundtrip()
{
using var temp = new TempKeyFile();
var originalKey = SyncCrypto.GenerateKey();
var service = new SyncKeyRecoveryService(originalKey, temp.KeyPath);
const string code = "TEST-CODE-4711";
var fileContent = service.CreateRecoveryFile(code);
// Ein zweites Gerät (anderer aktuell geladener Schlüssel) löst dieselbe Datei ein.
var redeemer = new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), temp.KeyPath);
redeemer.RestoreFromFile(fileContent, code);
Assert.Equal(originalKey, SyncCrypto.LoadKey(temp.KeyPath));
}
[Fact]
public void RestoreFromFile_FalscherCode_WirftUndSchreibtKeyPathNicht()
{
using var temp = new TempKeyFile();
var service = new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), temp.KeyPath);
var fileContent = service.CreateRecoveryFile("RICHTIGER-CODE");
Assert.ThrowsAny<Exception>(() => service.RestoreFromFile(fileContent, "FALSCHER-CODE"));
Assert.Null(SyncCrypto.LoadKey(temp.KeyPath));
}
[Fact]
public void RestoreFromFile_UngueltigesDateiformat_WirftInvalidOperationException()
{
using var temp = new TempKeyFile();
var service = new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), temp.KeyPath);
Assert.Throws<InvalidOperationException>(() => service.RestoreFromFile("{}", "IRGENDEIN-CODE"));
}
private sealed class TempKeyFile : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-synckeyrecovery-tests-{Guid.NewGuid():N}");
public string KeyPath { get; }
public TempKeyFile()
{
Directory.CreateDirectory(_directory);
KeyPath = Path.Combine(_directory, "sync.key");
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}