fix: Geräte-Pairing - Schlüssel-Code stimmte nie mit dem angezeigten Code überein
SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch: Schritt 1 holt einen Code vom Server, Schritt 2 verschlüsselt den Sync-Schlüssel mit diesem Code und lädt erneut hoch. SnapshotStore.Store() vergab bei jedem Aufruf bedingungslos einen neuen Zufallscode - der dem Nutzer am Ende angezeigte Code war dadurch nie derselbe, mit dem der Schlüssel tatsächlich verschlüsselt wurde. Jede Kopplung musste deterministisch an der Schlüssel-Entschlüsselung scheitern. SnapshotUploadRequest bekommt ein optionales Code-Feld; Store() aktualisiert bei vorhandenem, passendem Code denselben Eintrag statt einen neuen mit neuem Code anzulegen. Betrifft LehrerApp.Api - der Server muss neu deployt werden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class SnapshotStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Store_ZweiterAufrufOhneCode_ErzeugtNeuenCode()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
|
||||||
|
var first = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p1" });
|
||||||
|
var second = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p2" });
|
||||||
|
|
||||||
|
Assert.NotEqual(first.Code, second.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch — Schritt 1
|
||||||
|
/// ohne EncryptedSyncKey (holt einen Code vom Server), Schritt 2 mit dem gerade erst mit
|
||||||
|
/// diesem Code verschlüsselten Schlüssel. Ohne den zurückgereichten Code erzeugte Store() bei
|
||||||
|
/// jedem Aufruf einen NEUEN Zufallscode — der an den Nutzer angezeigte Code (aus Schritt 2)
|
||||||
|
/// passte dann nie zu dem Code, mit dem der Schlüssel tatsächlich verschlüsselt wurde, und das
|
||||||
|
/// Einlösen auf dem zweiten Gerät schlug beim Schlüssel-Entschlüsseln immer fehl.
|
||||||
|
[Fact]
|
||||||
|
public void Store_ZweiterAufrufMitCodeDesErstenSchritts_AktualisiertDenselbenEintrag()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
|
||||||
|
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
|
||||||
|
var step2 = temp.Store.Store("user1", new SnapshotUploadRequest
|
||||||
|
{
|
||||||
|
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(step1.Code, step2.Code);
|
||||||
|
|
||||||
|
var retrieved = temp.Store.Retrieve("user1", step2.Code);
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.Equal("encrypted-key", retrieved!.EncryptedSyncKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Store_CodeGehoertZuAnderemNutzer_ErzeugtStattdessenNeuenEintrag()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
|
||||||
|
|
||||||
|
var result = temp.Store.Store("user2", new SnapshotUploadRequest
|
||||||
|
{
|
||||||
|
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.NotEqual(step1.Code, result.Code);
|
||||||
|
Assert.NotNull(temp.Store.Retrieve("user2", result.Code));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retrieve_LoeschtDenEintragNachDemErstenAbruf()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
var uploaded = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
|
||||||
|
|
||||||
|
var first = temp.Store.Retrieve("user1", uploaded.Code);
|
||||||
|
var second = temp.Store.Retrieve("user1", uploaded.Code);
|
||||||
|
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.Null(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempSnapshotStore : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(
|
||||||
|
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
|
||||||
|
public LehrerApp.Api.SnapshotStore Store { get; }
|
||||||
|
|
||||||
|
public TempSnapshotStore()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
Store = new LehrerApp.Api.SnapshotStore(_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Store.Dispose();
|
||||||
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,22 @@ public class SnapshotStore(string dataPath) : IDisposable
|
|||||||
|
|
||||||
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
|
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
|
||||||
{
|
{
|
||||||
|
// Zweiter Upload-Schritt (Schlüssel nachreichen, mit dem im ersten Schritt vergebenen Code
|
||||||
|
// verschlüsselt): denselben Eintrag aktualisieren statt einen neuen mit neuem Code
|
||||||
|
// anzulegen — sonst würde der Schlüssel dauerhaft mit dem falschen Code verknüpft bleiben
|
||||||
|
// und die Entschlüsselung auf dem Empfängergerät schlägt fehl (Bug, siehe TODO.md 10.3.1).
|
||||||
|
if (!string.IsNullOrEmpty(req.Code))
|
||||||
|
{
|
||||||
|
var existing = Col.FindOne(e => e.UserId == userId && e.Code == req.Code.ToUpperInvariant());
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.EncryptedPayload = req.EncryptedPayload;
|
||||||
|
existing.EncryptedSyncKey = req.EncryptedSyncKey;
|
||||||
|
Col.Update(existing);
|
||||||
|
return new() { Code = existing.Code, ExpiresAt = existing.ExpiresAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Col.DeleteMany(e => e.UserId == userId);
|
Col.DeleteMany(e => e.UserId == userId);
|
||||||
var code = NewCode();
|
var code = NewCode();
|
||||||
var entry = new SnapshotEntry { Code = code, UserId = userId,
|
var entry = new SnapshotEntry { Code = code, UserId = userId,
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ public class SnapshotUploadRequest
|
|||||||
public string EncryptedPayload { get; init; } = "";
|
public string EncryptedPayload { get; init; } = "";
|
||||||
public string EncryptedSyncKey { get; init; } = "";
|
public string EncryptedSyncKey { get; init; } = "";
|
||||||
public DeviceType DeviceType { get; init; }
|
public DeviceType DeviceType { get; init; }
|
||||||
|
/// Gesetzt beim zweiten Upload-Schritt (Schlüssel nachreichen, siehe SnapshotService.
|
||||||
|
/// CreateAndUploadAsync) — muss dem im ersten Schritt vom Server vergebenen Code entsprechen,
|
||||||
|
/// damit derselbe Eintrag aktualisiert statt ein neuer (mit neuem Code) angelegt wird.
|
||||||
|
public string? Code { get; init; }
|
||||||
}
|
}
|
||||||
public class SnapshotUploadResponse
|
public class SnapshotUploadResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ public class SnapshotService(
|
|||||||
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
|
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
|
||||||
var r2 = await http.PostAsJsonAsync("/api/snapshot/upload",
|
var r2 = await http.PostAsJsonAsync("/api/snapshot/upload",
|
||||||
new SnapshotUploadRequest { EncryptedPayload = encPayload,
|
new SnapshotUploadRequest { EncryptedPayload = encPayload,
|
||||||
EncryptedSyncKey = encKey, DeviceType = deviceType }, ct);
|
EncryptedSyncKey = encKey, DeviceType = deviceType,
|
||||||
|
Code = init.Code }, ct);
|
||||||
r2.EnsureSuccessStatusCode();
|
r2.EnsureSuccessStatusCode();
|
||||||
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||||
|
|||||||
@@ -1605,6 +1605,24 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
|
|||||||
SDK-Host (`dotnet`/`dotnet.exe`) statt auf die App selbst, ein Neustart ohne Argumente hätte
|
SDK-Host (`dotnet`/`dotnet.exe`) statt auf die App selbst, ein Neustart ohne Argumente hätte
|
||||||
dort nur die dotnet-CLI-Hilfe gezeigt statt die App neu zu starten — die ursprünglichen
|
dort nur die dotnet-CLI-Hilfe gezeigt statt die App neu zu starten — die ursprünglichen
|
||||||
Kommandozeilenargumente werden in diesem Fall jetzt erneut mitgegeben.
|
Kommandozeilenargumente werden in diesem Fall jetzt erneut mitgegeben.
|
||||||
|
|
||||||
|
**Nachtrag (eigentlicher Bugfix — Codes stimmten nie überein):** Der obige Dialog machte
|
||||||
|
den Fehlschlag zwar sichtbar, aber er trat *immer* auf — auch bei korrekt eingegebenem, noch
|
||||||
|
gültigem Code. Ursache, vom Nutzer selbst bis in den `catch`-Block von `SyncCrypto.
|
||||||
|
DecryptKeyWithCode` zurückverfolgt: `SnapshotService.CreateAndUploadAsync` lädt in zwei
|
||||||
|
Schritten hoch — Schritt 1 (ohne Schlüssel) holt vom Server einen frisch vergebenen Code,
|
||||||
|
Schritt 2 verschlüsselt den Sync-Schlüssel mit *diesem* Code und lädt erneut hoch. Beide
|
||||||
|
Schritte trafen serverseitig auf `SnapshotStore.Store()`, das aber bei **jedem** Aufruf
|
||||||
|
bedingungslos einen neuen `NewCode()` vergab und den vorherigen Eintrag löschte. Der dem
|
||||||
|
Nutzer am Ende angezeigte Code (aus Schritt 2) war damit nie derselbe, mit dem der Schlüssel
|
||||||
|
tatsächlich verschlüsselt wurde (aus Schritt 1) — jede Kopplung musste zwingend an der
|
||||||
|
Schlüssel-Entschlüsselung scheitern, unabhängig von Tippfehlern, Ablaufzeit oder
|
||||||
|
Netzwerkproblemen. Behoben: `SnapshotUploadRequest` bekommt ein optionales `Code`-Feld;
|
||||||
|
`SnapshotStore.Store()` aktualisiert bei vorhandenem, zum Nutzer passendem Code denselben
|
||||||
|
Eintrag (`Col.Update`) statt einen neuen mit neuem Code anzulegen; `CreateAndUploadAsync`
|
||||||
|
reicht den in Schritt 1 erhaltenen Code in Schritt 2 zurück. **Wichtig für den Rollout:**
|
||||||
|
diese Änderung betrifft `LehrerApp.Api` (Server) — ein reines Neubauen des Desktop-Clients
|
||||||
|
reicht nicht, der Server muss neu deployt werden.
|
||||||
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
|
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
|
||||||
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
|
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
|
||||||
personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus
|
personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus
|
||||||
|
|||||||
Reference in New Issue
Block a user