Design brief for the in-page E2E encrypted chat: threat model, key derivation via URL fragment, #j2 link format, backward compatibility with old extensions, and the constraints the discarded feature/soonTMChat branch got wrong. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
17 KiB
E2E-verschlüsselter In-Page-Chat für KoalaSync
Auftrag
Baue einen privacy-first, Ende-zu-Ende-verschlüsselten Text-Chat, der direkt auf der
Streaming-Seite neben dem Video sitzt. Neuer Branch von main, Vorschlag
feature/chat-e2e.
Harte Anforderungen
- Der Server darf Nachrichten nie lesen können. Schlüssel nur clientseitig, der Relay sieht ausschließlich Ciphertext.
- Der Server speichert keine Nachrichten. Reines Live-Relay, kein RAM-Backlog, keine History. Wer zu spät kommt, verpasst den bisherigen Chat. Das ist gewollt.
- Chat läuft auf der Streaming-Seite, nicht im Extension-Popup.
- Themes werden respektiert (3 Paletten x light/dark).
- Keine Read Receipts. Bewusst gestrichen.
- Volle Abwärtskompatibilität: alte Extensions müssen weiter normal joinen können.
Bedrohungsmodell (bestimmt alle Krypto-Entscheidungen)
Das hier ist eine Video-Sync-Extension mit optionalem, flüchtigem Chat, keine sicherheitskritische Messenger-App. Ziel ist: ein bösartiger Betreiber eines Custom-Relays soll nicht einfach mitlesen können. Ob etwas mit sehr viel Aufwand theoretisch knackbar wäre, ist egal.
Daraus folgt, und das ist bindend:
- Usability und Performance haben Vorrang vor kryptografischer Maximalhärte.
- Raum-Passwörter sind kurz, zufällig generiert (z.B.
0XUK3C) und ständig neu. Wer eines knackt, kann trollen (play/pausesenden). Nervig, nicht kritisch. Deshalb nutzt der Server bewusst simples SHA256/HMAC statt bcrypt. Diese Linie beibehalten, nicht "verbessern". - Der globale
SERVER_SALTist bekannt und kein Issue. Räume sind flüchtig, es gibt keine Datenbank.
Explizite Nicht-Ziele
- Kein Chat-Tab im Popup. Nicht als Fallback, nicht übergangsweise.
- Keine serverseitige
chatHistoryin irgendeiner Form. - Die Auth nicht anfassen. Das Klartext-Passwort an den Relay ist bewusst so und wird nicht umgebaut (Begründung unten).
website/Theme-Redesign aufmainnicht anfassen.
Krypto-Design (entschieden, nicht neu diskutieren)
Woher der Schlüssel kommt
Das Raum-Passwort ist unbrauchbar als Schlüsselmaterial: der Client sendet es im
Klartext an den Relay (join_room -> payload.password, server/index.js:349), der
Server HMACt es erst selbst (hashPassword, Zeile 52-55). Der Server könnte jeden
daraus abgeleiteten Schlüssel mitberechnen.
Es zu ändern würde auch nichts bringen: Raum-Passwörter haben ~31 Bit Entropie (6 Zeichen A-Z0-9). Ein bösartiger Server könnte sie offline durchprobieren und den Chat-Schlüssel ableiten.
Lösung: ein eigenes, zufälliges Secret im URL-Fragment. Fragmente werden nie an einen Server gesendet, und das Secret hat volle Entropie per Konstruktion. Der Einladungslink transportiert bereits heute das Passwort im Fragment, der Mechanismus existiert also schon.
Primitive
- 16 zufällige Bytes (128 Bit), base64url, 22 Zeichen. Beispiel:
R5Ti1nxp0crfAFHf3gVncw - Ableitung: HKDF-SHA256(secret, salt=roomId) -> AES-256-GCM-Key. Kein PBKDF2, kein Stretching. Slow KDFs existieren nur, um schwache Menschen-Passwörter zu strecken. Das Secret ist zufällig mit voller Entropie, HKDF ist ein einziger HMAC (Mikrosekunden). Das ist dieselbe Logik wie sha256-statt-bcrypt beim Server.
- Key genau einmal pro Raum ableiten und den
CryptoKeycachen. Nicht pro Nachricht neu ableiten oder importieren. - Pro Nachricht: zufälliger 12-Byte-IV, dem Ciphertext vorangestellt.
- Alles über WebCrypto, keine Fremdabhängigkeit.
AAD: senderId gegen Umetikettierung binden (Pflicht)
senderId wird vom Server gestempelt und liegt damit außerhalb des Ciphertexts.
Ohne Gegenmaßnahme kann ein bösartiger Relay Alices Ciphertext als Bob weiterreichen.
Das ist exakt der Angreifer, gegen den dieses Feature gebaut wird.
Deshalb: AES-GCM mit AAD verschlüsseln.
AAD = `${roomId}|${senderId}` // senderId = eigene peerId beim Verschlüsseln,
// envelope.senderId beim Entschlüsseln
Etikettiert der Server um, schlägt die Auth-Tag-Prüfung fehl und die Nachricht wird verworfen. Kostet null Performance und bindet die Nachricht zusätzlich an den Raum (kein Cross-Room-Replay).
Bewusst nicht abgedeckt und akzeptiert: Nachrichtenlängen und Timing bleiben für den Server sichtbar (kein Padding). Ein Relay kann eine Nachricht innerhalb desselben Raums erneut abspielen. Für das Bedrohungsmodell irrelevant.
Sanitization wandert auf den Client (Pflicht)
Der Server sieht nur noch Ciphertext und kann Text nicht mehr prüfen. Die Bedrohung verschwindet dadurch nicht, sie verschiebt sich: entschlüsselter Text stammt von einem Peer, der den Key besitzt.
- Entschlüsselter Text ist untrusted input. Vor dem Rendern zwingend durch
escapeChatHtml/formatChatText(escapen, dann Markdown). Niemals rohesinnerHTML. - Die Längengrenze (500 Codepoints) muss der Client durchsetzen, vor dem Verschlüsseln. Der Server kann nur noch Bytes zählen.
- Bestehende Schranke beachten:
maxHttpBufferSize: 4096(server/index.js:142) gilt global pro Socket-Nachricht. Nachgerechnet: 500 Codepoints als 4-Byte-Emoji ergeben 2000 B Klartext, +16 B GCM-Tag +12 B IV = 2028 B, base64 = 2704 Zeichen, socket.io-Frame = 2731 B. Passt, Headroom 1365 B. Bei 700 Codepoints wären es 3799 B. Wer die Zeichengrenze anhebt, muss diese Rechnung neu machen, sonst reißt das Limit.
Link-Format und Abwärtskompatibilität (entschieden)
Warum das alte Format nicht erweiterbar ist
Aktuell (popup.js:559-563):
offiziell: #join:<roomId>:<password>
custom: #join:<roomId>:<password>:1:<encodedUrl>
Der Extension-Parser (popup.js:1294-1313) nimmt roomId von vorne, das Paar
(flag, url) von hinten, und alles dazwischen ist das Passwort (parts.join(':')).
Es gibt keine Position, die er verwirft. Verifiziert durch Ausführen der echten Parser
gegen echte Links:
| Link | Alte Extension | Alte Website |
|---|---|---|
#join:SILENT-EAGLE-90:30PXPD:1:wss%3A%2F%2F… (Kontrolle) |
password:"30PXPD" OK |
serverFlag:"1" OK |
Key inline: …:30PXPD:<KEY>:1:wss%3A%2F%2F… |
password:"30PXPD:R5Ti1nxp…" korrumpiert |
serverFlag:"R5Ti…", serverUrl:"1" -> falscher Server |
Zwei verschiedene, irreführende Fehler aus derselben Zeile. Deshalb: neues Präfix.
Neues Format
#j2:r=<roomId>&p=<password>&k=<key>[&u=<encodedRelayUrl>]
URLSearchParams, kein positionales Parsen. Behebt nebenbei einen bestehenden Bug: heute zerschießt ein Doppelpunkt im Passwort den Website-Parser (parts[2]), während der Extension-Parser (parts.join(':')) damit klarkommt.s=1entfällt. Es existierte nur wegen des positionalen Parsens. Mit benannten Parametern gilt:uvorhanden bedeutet Custom Relay.- Das Präfix darf
#join:nicht als Teilstring enthalten, sonst greiftincludes('#join:')in alten Extensions doch.
Beispiele:
offiziell: …/join.html#j2:r=SAPPHIRE-DUCK-49&p=0XUK3C&k=R5Ti1nxp0crfAFHf3gVncw
custom: …/join.html#j2:r=SILENT-EAGLE-90&p=30PXPD&k=R5Ti1nxp0crfAFHf3gVncw
&u=wss%3A%2F%2Fsync.shik3i.net
Warum das alte Extensions nicht bricht
Die Website ist der Hauptpfad, nicht die Extension. website/app.js:452-458 parst
das Fragment selbst, prüft via document.documentElement.dataset.koalasyncInstalled
(gesetzt von bridge.js:9), ob die Extension da ist, und dispatcht dann automatisch
(app.js:515-526, "AUTO-TRIGGER JOIN") ein KOALASYNC_JOIN_REQUEST mit fertig
geparsten, strukturierten Feldern. bridge.js reicht es als WEB_JOIN_REQUEST an
den background weiter. In diesem Pfad liest die Extension die URL nie an.
Altes bridge.js destrukturiert nur, was es kennt:
const { roomId, password, useCustomServer, serverUrl } = e.detail;
Ein zusätzliches chatKey fällt dort stillschweigend auf den Boden.
Daraus folgt die Deploy-Reihenfolge: Website zuerst. Sie ist die einzige Stelle, die das neue Format kennen muss, und sie ist sofort deploybar. Die Extension darf beliebig hinterherhinken (Store-Review, Update-Zyklen der Nutzer).
| Kombination | Ergebnis |
|---|---|
| Neue Website + neue Ext | Join + Chat |
| Neue Website + alte Ext | Join normal, kein Chat, chatKey ignoriert |
Neue Ext + alter #join:-Link |
Join, kein Key, Chat aus. Legacy-Parser bleibt erhalten |
| Alte Website + neuer Link | Präfix unbekannt, Join-Seite tot. Existiert nach dem Website-Deploy nicht mehr |
checkInviteLink() (popup.js:1289) ist nur der Komfort-Pfad "Popup auf der Join-Seite
öffnen und Felder vorausfüllen". Bei #j2: greift er in alten Extensions nicht mehr,
das Feld bleibt leer statt falsch befüllt. Die neue Extension muss dort beide
Formate parsen (#j2: und Legacy #join:).
history.replaceState auf das Legacy-Format: nicht tun. Naheliegende Idee, um das
Autofill alter Extensions zu retten, aber ein Eigentor: es entfernt k aus der
Adressleiste. Nutzer kopieren die URL aus der Adressleiste, um sie weiterzuteilen. Der
weitergegebene Link joint dann zwar, hat aber stillschweigend keinen Chat mehr. Der
Fragment-Inhalt muss unangetastet bleiben.
Lebenszyklus des Keys
- Erzeugt wird er vom Raum-Ersteller, einmal, beim Anlegen des Raums. Er lebt im
background neben
roomId/passwordund geht in den Invite-Link. - Er darf niemals in einem Relay-Payload landen. Nicht in
join_room, nirgends. Dafür einen Test schreiben, der alle ausgehenden Events gegen den Key prüft. - Krypto gehört in den background, nicht in den content script. Der Key wird damit gar nicht erst in den Kontext einer fremden Seite ausgeliefert. Das Overlay schickt Klartext an den background und bekommt Klartext zurück, verschlüsselt wird ausschließlich dort.
- Kanten, die korrekt fallen müssen:
- Raum-Ersteller hat eine alte Extension: es existiert kein Key, niemand chattet. Korrektes Verhalten, kein Fehlerfall.
- Ein Peer mit alter Extension teilt den Invite aus seinem eigenen Popup: der Link trägt keinen Key. Wer darüber joint, chattet nicht, während die anderen chatten.
- Jemand tippt Raum und Passwort manuell: kein Key, kein Chat.
- Relay ohne Chat-Capability: Chat-UI gar nicht erst anzeigen.
Vorgeschichte
Vorgängerbranch feature/soonTMChat (Stand c706513) ist verworfen, bleibt als
Referenz liegen. Gründe:
- Chat lag nur im Popup. Ein Chrome-Popup schließt beim Fokusverlust, man kann nicht gleichzeitig Video schauen und chatten. Strukturell unbrauchbar.
- Der Server speicherte bis zu 500 Klartext-Nachrichten pro Raum und schickte jedem Joiner das Backlog.
- Verifizierter Folgebug: wer einen Raum mit >120 Nachrichten Historie betrat, wurde
rausgeworfen.
renderChatHistory()feuerte eine ungebündeltechat_read-Quittung pro Nachricht und rissCHAT_READ_RATE_LIMIT(120/10s). Reproduziert beiCHAT_HISTORY_LIMIT=200, dokumentiert erlaubt sind 500.
Lehre: kein ungebündeltes Event-pro-Nachricht-Muster. Jedes Client-Verhalten gegen die Rate-Limits gegenrechnen, bevor es eingebaut wird.
Wiederverwendbar (per git checkout feature/soonTMChat -- <pfad>, kritisch prüfen)
| Datei | Was | Einschränkung |
|---|---|---|
extension/chat.js |
escapeChatHtml, formatChatText, insertEmoji, createTypingTracker, createRemoteTypingTracker |
reine Funktionen, storage-frei, getestet. createReceiptTracker weglassen |
extension/chat.test.mjs |
Unit-Tests | Receipt-Tests weg |
extension/locales/*.json |
CHAT_*-Keys, 15 Sprachen |
Receipt-Keys weg, neue Keys für Overlay nötig |
server/chat.js |
sanitizeChatUsername, canKickPeer |
sanitizeChatText/createChatMessage greifen auf Klartext zu, bei E2E unmöglich. parseChatHistoryLimit, appendChatHistory, canRelayReadReceipt entfallen |
server/rate-limiter.js |
checkChatMessageRate (10/10s) |
checkChatReadRate entfällt |
shared/constants.js |
CAPABILITIES.CHAT, Event-Namen |
muss neu hinzu. main kennt nur HOST_CONTROL und CO_HOST (shared/constants.js:78, server/index.js:174) |
Server-Design, das überleben soll: Der Server vergab Message-ID, senderId und
timestamp selbst und ignorierte Client-Angaben (Spoofing-Schutz). Bleibt richtig, auch
wenn der Text jetzt Ciphertext ist.
Technische Randbedingungen
Overlay-Kontext
content.jswird programmatisch injiziert:chrome.scripting.executeScript({ target: {tabId}, files: ['content.js'] })(background.js:1775). Nicht übermanifest.content_scripts, dort steht nurbridge.jsfürhttps://sync.koalastuff.net/*.host_permissions: ["<all_urls>"], MV3.- Das Overlay lebt im DOM fremder Seiten (Netflix, YouTube, Emby, Jellyfin). Shadow DOM ist Pflicht, sonst bluten fremde Styles rein und umgekehrt.
- Die Theme-Variablen aus
popup.htmlexistieren im Page-Kontext nicht und müssen in den Shadow Root injiziert werden. - Risiko, vorab prüfen: Netflix-Vollbild ist ein eigener Fullscreen-Element-Kontext. Ein Overlay im normalen DOM verschwindet dort möglicherweise. Das kann die Positionierung grundlegend beeinflussen.
Performance auf der Host-Seite
Das Video ist das Produkt, der Chat ist Beiwerk. Ein Overlay, das die Wiedergabe ruckeln lässt, ist schlechter als kein Overlay.
- Nur im Ziel-Tab injizieren (
currentTabIdim background), nicht auf jeder Seite. position: fixed, eigener Compositing-Layer, kein Eingriff in das Layout der Host-Seite. Kein Layout-Thrashing, keine erzwungenen Reflows während der Wiedergabe.- Nachrichtenliste beim Rendern nicht unbegrenzt wachsen lassen (DOM-Knoten deckeln).
Firefox
scripts/build-extension.cjs:199 baut ein eigenes Firefox-Target, und bridge.js
enthält bereits Firefox-spezifisches cloneInto-Handling für CustomEvent-Details
(isolierte Welten in FF MV3). Overlay, Shadow DOM und WebCrypto müssen dort ebenfalls
laufen. Neue Website-zu-Extension-Felder (chatKey) gehen durch dieselbe
cloneInto-Stelle.
Theme-System (aus main, verbindlich)
extension/theme-init.jssetzt auf<html>:data-theme(light/dark),data-palette(eucalyptus/cyber/graphite), Klassetheme-light.- Quelle:
chrome.storage.local, KeysthemeModeundthemePalette, pluschrome.storage.onChangedfür Live-Updates. Das Overlay nutzt denselben Mechanismus im Shadow Root. - Kein
prefers-color-schemefür Theme-Farben. Das System ist explizit, nur der Modussystemliest die OS-Präferenz und das erledigttheme-init.js. - Tokens:
--bg,--card,--surface-alt,--surface-deep,--accent,--text,--text-muted,--border-soft,--border-strong,--text-on-green. Text auf--accentimmer--text-on-green, niewhite. - Getönte
oklch-Schatten sind pro Palette hartcodiert und brauchenhtml[data-palette=...]-Overrides. Neutrale Schwarz-Alpha-Schatten sind palettenunabhängig und der einfachere Weg. - Bekannte Alt-Last, nicht Aufgabe dieses Branches:
--text-on-greenauf--accenterreicht in eucalyptus/light 3.76 und cyber/dark 4.24, unter WCAG AA.
Qualitäts-Gates
npm run lint,npx vitest run,npm run verifymüssen grün sein.- i18n: alle 15 Sprachen in
extension/locales/,enist Baseline-Fallback. Keine hartcodierten UI-Strings,getMessage(key, { placeholder })ausi18n.js. - Bei einem Release-Tag: Eintrag in
docs/CHANGELOG.md. - Keine Em-Dashes in nutzersichtbaren Texten.
Offene Punkte (mit dem Nutzer klären, nicht selbst entscheiden)
- Overlay-Positionierung: fest andockbar rechts/links, oder frei verschiebbar?
Position pro Seite in
chrome.storage.localmerken? Verhalten im Vollbild? - Peers ohne Key: Chat-UI ganz ausblenden mit Hinweis, oder anzeigen und Nachrichten als "nicht lesbar" markieren? (Die Fälle, in denen das eintritt, stehen oben unter Lebenszyklus.)
- Metadaten: Username bleibt heute für den Server sichtbar (er sanitized ihn). Mitverschlüsseln? Typing-Indikatoren verraten dem Server, wer wann aktiv ist. Behalten, verschlüsseln oder streichen?
- Kick-Funktion:
canKickPeerexistiert (Host/Controller-Rechte). Behalten? - Nachrichtenlängen-Limit: 500 Codepoints beibehalten, oder angesichts des 4096-B-Frames anders wählen?
Vorgehen
- Offene Punkte klären.
- Krypto- und Linkformat-Design in
docs/CHAT.mdfesthalten. Das bestehende Dokument beschreibt das verworfene Konzept und wird neu geschrieben. Chat-Events indocs/PROTOCOL.mdergänzen. - Website-Parser (
#j2:, Legacy#join:weiter unterstützen) und Bridge-FeldchatKeyzuerst, weil davon die Abwärtskompatibilität hängt. - Dann Relay (reines Relay ohne Storage), dann Krypto im background, dann Overlay, dann i18n, dann Tests.
- Verifizieren, nicht annehmen:
- Overlay auf mindestens zwei echten Seiten in allen 6 Theme-Kombinationen, nicht nur in einer isolierten HTML-Datei.
- Chrome und Firefox-Build.
- Ein Test, der beweist, dass der Key in keinem ausgehenden Relay-Payload vorkommt.
- Ein Test, der eine umetikettierte Nachricht (fremde
senderId) als ungültig zurückweist (AAD-Bindung). - Alte Extension gegen neue Website: Join funktioniert weiter, Chat ist stumm.