E2E-Verschlüsselung und SSE-Echtzeit — Vertraue keinem Server (Teil 3/6)
Das ist der Teil des Projekts, auf den ich am meisten stolz bin. Als ich mich daran machte, eine Planning-Poker-App zu bauen, hatte ich eine nicht verhandelbare Anforderung: Kein Server — einschließlich meinem eigenen — soll jemals unverschlüsselte Raumdaten sehen. Keine Spielernamen, keine Stimmwerte, keine Story-Titel. Nichts.
Lass uns darüber sprechen, wie Ende-zu-Ende-Verschlüsselung in einem Browser funktioniert und wie Echtzeit-Updates durch einen Server streamen, der nicht lesen kann, was er transportiert.
Das Sicherheitsmodell, ein Diagramm
Browser URL-Hash (#k=…) Cloudflare Worker → Upstash
────────────────────── ───────────────────── ──────────────────────────────
generateKeyMaterial() → geteilt mit Teamkollegen → speichert nur verschlüsselte Blobs
importKeyMaterial() (nur Ciphertext, kein Schlüssel)
extractable: false nie über HTTP gesendet
encryptJSON(data, key) ──────────────────────────────────────────────────────────→
Drei Regeln:
- Der Verschlüsselungsschlüssel wird in deinem Browser generiert und lebt nur im URL-Fragment (
#k=...) - Die HTTP-Spezifikation garantiert, dass Fragmente nie an Server gesendet werden
- Der
CryptoKeywird mitextractable: falseimportiert — nicht mal JavaScript kann ihn exportieren
Schritt 1: Den Schlüssel generieren
Wenn du einen Raum erstellst, passiert Folgendes:
export function generateKeyMaterial(): string {
return bufToB64(
crypto.getRandomValues(new Uint8Array(32)).buffer
);
}
32 zufällige Bytes. Das sind 256 Bit Entropie vom kryptographischen Zufallszahlengenerator des Browsers. Die Ausgabe ist base64url-kodiert — URL-sicher, keine Padding-Zeichen, perfekt zum Einbetten in ein Hash-Fragment.
Der crypto.getRandomValues()-Aufruf nutzt den CSPRNG auf OS-Ebene (kryptographisch sicherer Pseudozufallszahlengenerator). Auf macOS ist das /dev/urandom. Auf Windows BCryptGenRandom. Hardware-gestützte Zufälligkeit in einer einzigen Zeile JavaScript.
Schritt 2: Als nicht-exportierbaren CryptoKey importieren
Rohe Bytes sind nützlich zum Teilen. Aber für die eigentliche Verschlüsselung brauchen wir ein CryptoKey-Objekt:
export async function importKeyMaterial(b64: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
b64ToBuf(b64),
{ name: "AES-GCM", length: 256 },
false, // ← extractable: false
["encrypt", "decrypt"],
);
}
Dieser false-Parameter ist der Sicherheits-Dreh- und Angelpunkt. Sobald der Schlüssel importiert ist, ist er in der Krypto-Engine des Browsers eingesperrt. Du kannst ihn zum Ver- und Entschlüsseln verwenden, aber du kannst nicht crypto.subtle.exportKey() aufrufen, um die Rohdaten zurückzubekommen. Nicht aus JavaScript, nicht aus DevTools, nicht aus einer Browser-Erweiterung.
Das Schlüsselmaterial (der base64url-String) ist das, was über die URL geteilt wird. Der CryptoKey ist das, was die eigentliche Arbeit erledigt. Sie sind verwandt, aber getrennt — und der CryptoKey ist eine Einbahnstraße.
Schritt 3: Alles verschlüsseln
Jedes Datenstück, das den Browser verlässt, geht hier durch:
export async function encryptJSON(data: unknown, key: CryptoKey): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-Bit IV
const plain = new TextEncoder().encode(JSON.stringify(data));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plain
);
return `${bufToB64(iv.buffer)}.${bufToB64(ct)}`;
}
AES-GCM braucht zwei Dinge: den Schlüssel und einen 12-Byte-Initialisierungsvektor (IV). Der IV kann öffentlich sein — er muss nur pro Verschlüsselung einzigartig sein. Wir generieren jedes Mal einen neuen und stellen ihn der Ausgabe voran, getrennt durch einen Punkt.
Das Ausgabeformat ist base64url(iv).base64url(ciphertext) — ein einzelner String, der sicher als Redis-Wert gespeichert werden kann. Der Server sieht dGhpcyBp.c0FuZCB0aGlz... und hat absolut keine Ahnung, was drin steckt.
AES-GCM bietet auch Authentifizierung. Wenn jemand den Ciphertext manipuliert (sogar ein einzelnes Bit-Flip), wirft die Entschlüsselung einen Fehler. Du bekommst Vertraulichkeit und Integrität in einer Operation.
Schritt 4: Der URL-Fragment-Trick
Hier kommt der clevere Teil beim Schlüsselteilen. Wenn du einen Raum erstellst, wird der Verschlüsselungsschlüssel an die URL angehängt:
https://planning-poker.oltionzefi.workers.dev
/planning-poker/room/abc123#k=dGhpcyBpcyBhIHRlc3Qga2V5
Dieses #k=... ist ein Hash-Fragment. Laut HTTP-Spezifikation (RFC 3986, Abschnitt 3.5) werden Hash-Fragmente vollständig vom Client verarbeitet. Browser schließen sie nie in HTTP-Anfragen ein. Der Cloudflare Worker, Upstash, CDN-Proxys, Unternehmens-Firewalls — keiner von ihnen sieht jemals #k=....
Wenn ein Teamkollege diesen Link öffnet:
- Der Browser extrahiert
#k=dGhpcyBpcyBhIHRlc3Qga2V5aus der URL - Er importiert das Schlüsselmaterial als nicht-exportierbaren CryptoKey
- Er entschlüsselt die von Redis abgerufenen Raumdaten
- Alles wird im Klartext angezeigt — aber nur in seinem Browser
Schritt 5: Write-Once-Session-Storage
Das Schlüsselmaterial wird in sessionStorage mit einer strikten Write-Once-Policy gespeichert:
const SS_PREFIX = "pp:key:";
export function storeKeyInSession(roomId: string, keyB64: string): boolean {
const existing = sessionStorage.getItem(SS_PREFIX + roomId);
if (existing !== null) return false; // nie überschreiben
sessionStorage.setItem(SS_PREFIX + roomId, keyB64);
return true;
}
Warum Write-Once? Sicherheit. Wenn ein Angreifer irgendwie einen anderen Schlüssel über die URL einschleusen sollte, prüft die App, ob der gespeicherte Schlüssel übereinstimmt:
export function keyMatchesSession(roomId: string, candidateB64: string): boolean {
const stored = sessionStorage.getItem(SS_PREFIX + roomId);
if (stored === null) return true; // erstes Mal, ok
return stored === candidateB64; // muss übereinstimmen
}
Wenn die Schlüssel nicht übereinstimmen, wird der Benutzer zur Startseite weitergeleitet. Keine stille Schlüssel-Substitution. Kein schleichender Datenverlust.
Jetzt Echtzeit: SSE-Streaming
Verschlüsselung macht Daten privat. SSE macht sie live. Wenn sich etwas in einem Raum ändert — eine neue Stimme, eine Aufdeckung, ein neuer Spieler — empfängt jeder verbundene Browser das Update sofort.
Die subscribeToRoom-Methode im Adapter öffnet eine langlebige HTTP-Verbindung:
const connectSSE = async () => {
const sseUrl = `${this.mode.baseUrl}/subscribe/${encodeURIComponent(ROOM_CHANNEL(roomId))}`;
const res = await fetch(sseUrl, {
signal: controller.signal,
headers: this.authHeader
});
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!isUnsubscribed) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const blob = parseSSELine(line.slice(6).trim());
if (blob) deliverBlob(blob).catch(() => {});
}
}
};
Du fragst dich vielleicht: Warum nicht die eingebaute EventSource-API des Browsers? Weil EventSource keine Custom-Header unterstützt. Im Proxy-Modus fügt der Cloudflare Worker den Authorization-Header serverseitig hinzu. Aber im Direct-Modus (lokale Entwicklung) müssen wir den Upstash-Token selbst senden. Also lesen wir den SSE-Stream manuell über fetch + ReadableStream.
SSE-Nachrichten parsen
Das Upstash-Pub/Sub-Protokoll verpackt Nachrichten in einen Umschlag:
{
"type": "message",
"channel": "room:abc123",
"data": "{\"data\":\"dGhpcyBp.c0FuZCB0aGlz...\"}"
}
Das innere data-Feld ist unser verschlüsselter Blob. Die parseSSELine-Funktion schält beide Schichten ab:
const parseSSELine = (raw: string): string | null => {
try {
const event = JSON.parse(raw);
if (event.type !== "message" || !event.data) return null;
const inner = JSON.parse(event.data);
return inner.data ?? null;
} catch {
return null;
}
};
Der Blob wird im Browser entschlüsselt, gegen die aktuelle Raum-ID validiert und über _setRoom() in den Zustand-Store geleitet. Aus Benutzersicht: Jemand stimmt ab, und sein Kartenrücken erscheint sofort auf deinem Bildschirm.
Versions-Filterung — keine veralteten Updates
SSE kann Nachrichten in falscher Reihenfolge liefern, besonders bei Wiederverbindungen. Der Adapter trackt eine Versionsnummer, um zu verhindern, dass veraltete Updates neueren State überschreiben:
let lastDeliveredVersion = -1;
const deliverRoom = (room: Room) => {
const v = room.cmdCount ?? 0;
if (v <= lastDeliveredVersion) {
logger.warn(`Veralteten Snapshot verworfen (v${v} ≤ aktuell v${lastDeliveredVersion})`);
return;
}
lastDeliveredVersion = v;
onChange(room);
};
Das cmdCount-Feld dient gleichzeitig als Versionsnummer — es wird bei jedem Schreibvorgang inkrementiert. Wenn eine alte Nachricht nach einer neueren ankommt, wird sie still verworfen.
Das 5-Sekunden-Poll-Fallback
SSE-Verbindungen können abbrechen. Browser im Hintergrund drosseln sie. Unternehmens-Proxys beenden langlebige Verbindungen. Also gibt es ein Backup:
const POLL_INTERVAL_MS = 5_000;
const pollInterval = setInterval(async () => {
if (isUnsubscribed) return;
const blob = await this.getBlob(roomId);
if (!blob) return;
await deliverBlob(blob);
}, POLL_INTERVAL_MS);
Alle 5 Sekunden holt der Adapter den neuesten Raumzustand von Redis. Er durchläuft die gleiche deliverBlob-Pipeline, sodass die Versions-Filterung verhindert, dass er ein frischeres SSE-Update überschreibt.
Gürtel und Hosenträger. SSE für Geschwindigkeit, Polling für Zuverlässigkeit.
Wo Daten leben (und wo nicht)
| Daten | Wo | Lebensdauer |
|---|---|---|
| Spielername, Emoji | localStorage | Permanent (wird beim nächsten Besuch wiederverwendet) |
| Raum-State, Stimmen | Upstash Redis (AES-GCM-verschlüsselt) | 1h TTL, gelöscht wenn letzter Spieler geht |
| Session-History | sessionStorage | Nur Browser-Sitzung |
| Verschlüsselungs-Schlüsselmaterial | sessionStorage | Nur Browser-Sitzung, Write-Once |
| CryptoKey-Objekt | Browser-Krypto-Engine | Nur im Speicher, nicht exportierbar |
Nichts Sensibles persistiert über die Browser-Sitzung hinaus. Selbst wenn jemand Zugang zur Upstash-Datenbank bekäme, würde er nur verschlüsselte Blobs finden, die in einer Stunde ablaufen.
Was als Nächstes kommt
Wir haben ein System gebaut, in dem Daten verschlüsselt werden, bevor sie deinen Browser verlassen, durch einen Server streamen, der sie nicht lesen kann, und in Echtzeit auf den Bildschirmen deiner Teamkollegen ankommen. Zero Trust, null Klartext im Ruhezustand.
Aber dieser Server in der Mitte — der Cloudflare Worker — was genau macht er? In Teil 4 bauen wir den dünnstmöglichen Auth-Proxy: einen Hono-Worker, der SSE-Streaming, Rate-Limiting und statisches Asset-Serving übernimmt, ohne jemals deine Daten zu berühren.
Weiter voran und genieße jeden Schritt deiner Coding-Reise.
