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>
146 lines
5.0 KiB
C#
146 lines
5.0 KiB
C#
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));
|
|
}
|
|
}
|