Files
pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
T
Bernd SteckmeisterandClaude Opus 5.5 756486d1fe security: LiveKit-Token vom Server statt Secret in der App
Die App signierte LiveKit-Tokens selbst – dafür steckte das SFU-Secret im
App-Code (jeder mit der APK konnte sich Tokens für beliebige Räume und
Identitäten ausstellen). Jetzt holt die App das Token von der bereits
vorhandenen Server-Route /api/livekit-token (whoami-Prüfung, Identität =
geprüfte Matrix-ID). Secret und JWT-Signieren sind aus dem Client entfernt.

- Lesbare Fehlermeldungen (offline, 401, Serverfehler) statt stiller Fehler.
- Gruppenanruf aus dem Chat-Kopf übergibt jetzt den Matrix-Client.
- Tests: Unit-Test mit Mock-HTTP + echter LiveKit-Beitritt (Testkonto).
- Offen: Handy-Anruftest; danach Secret-Rotation durch Bernd (Doku).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:12:15 +02:00

8.4 KiB
Raw Blame History

LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)

Status (2026-10-07): Server-Route UND Client umgestellt.

  • Server: POST /api/livekit-token im Dashboard-Server auf dem Pi (/home/steggi/matrix/server.py) – whoami-Prüfung, Identität = geprüfte Matrix-ID, Secret aus livekit.yaml. Geprüft: ungültiger Token → 401 (öffentlich über dashboard.steggi-matrix.work), gültiger Token → JWT mit sub = Matrix-ID und denselben Grants wie früher.
  • Client: lib/core/livekit_token.dart holt das Token per LiveKitTokenService.fetch (kein Secret, kein JWT-Signieren mehr in der App); livekit_call_manager.dart nutzt es für alle LiveKit-Beitritte. Fehler (offline, 401, Serverfehler) erscheinen als lesbare Meldung im Anruf.
  • Tests: test/livekit_token_test.dart (Anfrage, Fehlerpfade, kein Secret im Code) und integration_test/livekit_token_connect_test.dart (ECHTER LiveKit-Beitritt mit Server-Token, Testkonto pyramidtest1, Wegwerf-Raum) – grün am 2026-10-07.

Offen:

  1. Anruf/Sprachkanal auf dem echten Handy prüfen (Ton, Video, Bildschirm teilen, zweiter Teilnehmer, Verlassen/Wiederbeitreten).
  2. Erst wenn ALLE genutzten Geräte (Bernd + Uta) die neue Version haben: Secret rotieren (siehe „Rotation“ unten) – das macht Bernd, nicht Claude. Bis dahin funktionieren alte und neue App-Versionen parallel.
  3. Neue Abhängigkeit beachten: Anrufe brauchen jetzt zusätzlich den Dashboard-Server (matrix-stats.service). Ist er aus, meldet die App „Anrufserver nicht erreichbar“.

Die ursprüngliche Planung (2026-07-04) folgt unverändert.

Problem

lib/core/livekit_token.dart mintet die LiveKit-JWTs im Client. Dafür muss das LiveKit-apiSecret im App-Code liegen (livekit_token.dart:6) – d. h. jeder App-Nutzer (auch Uta) trägt das SFU-Secret im Gerät. Wer es hat, kann sich für jeden Raum und jede Identität ein Beitritts-Token ausstellen. Das ist ein Architekturproblem, kein bloßer Repo-Leak: Selbst nach einem History-Rewrite bliebe das Secret im ausgelieferten Binary.

Aktueller Fluss (lib/core/livekit_call_manager.dart:154):

final token = LiveKitTokenGenerator.generate(
  roomName: roomName,
  identity: identity,
  displayName: identity,
  ttlSeconds: 21600,   // 6 h
);

Erzeugtes JWT (HS256, livekit_token.dart:14-33): Grants video mit roomJoin/room/canPublish/canSubscribe/canPublishData, metadata = Anzeigename, iss = apiKey (LKMatrixPi), sub = identity, exp = jetzt+ttl.

Ziel

Das apiSecret liegt nur noch auf dem Pi. Der Client holt sich für einen konkreten Raum ein fertiges JWT von einem kleinen Token-Endpoint und übergibt es unverändert an Room.connect.

Server-Seite (Pi)

Es gibt bereits einen schlanken Dashboard-Server ohne Fremd-Abhängigkeiten: /home/steggi/matrix/server.py (stdlib http.server, läuft als matrix-stats.service auf 0.0.0.0:8080, nach außen dashboard.steggi-matrix.work). Der Token-Endpoint kann dort als weitere Route POST /api/livekit-token andocken – kein neuer Dienst nötig. Das LiveKit-apiSecret steht schon auf dem Pi in /home/steggi/matrix/livekit.yaml (keys: LKMatrixPi: <secret>); der Server liest es von dort, statt es erneut zu hinterlegen (eine Quelle der Wahrheit).

Wichtig – Authentifizierung: Der Endpoint darf NICHT anonym Tokens ausstellen (sonst ist nichts gewonnen). Der Client schickt seinen Matrix-Access-Token mit; der Server prüft ihn gegen Continuwuity und leitet daraus die Identität ab:

  1. POST /api/livekit-token, Body { "room": "<roomName>", "matrix_token": "<access_token>" }.
  2. Server ruft GET http://127.0.0.1:6167/_matrix/client/v3/account/whoami mit Authorization: Bearer <matrix_token> auf → liefert user_id (401 → Endpoint gibt 401 zurück, kein Token).
  3. identity = user_id (NICHT vom Client wählbar – verhindert Identitäts-Spoofing).
  4. Optional, aber empfohlen: Raummitgliedschaft prüfen (/_matrix/client/v3/rooms/ {roomId}/joined_members oder der Matrix-Raum, zu dem der Voice-Channel gehört) – nur ausstellen, wenn der Nutzer wirklich Mitglied ist. Für Phase 1 reicht die whoami-Authentifizierung; Mitgliedschaftsprüfung als Härtung nachziehen.
  5. Server mintet das LiveKit-JWT mit denselben Grants wie heute und gibt { "token": "<jwt>", "url": "wss://livekit.steggi-matrix.work" } zurück.

JWT-Minting ohne Fremd-Paket (stdlib genügt): LiveKit-Tokens sind HS256-JWTs. Mit hmac/hashlib/base64/json aus der stdlib:

import base64, hmac, hashlib, json, time

def _b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")

def mint_livekit(api_key, api_secret, identity, room, name, ttl=21600):
    now = int(time.time())
    header  = {"alg": "HS256", "typ": "JWT"}
    payload = {
        "iss": api_key, "sub": identity, "name": name,
        "nbf": now, "exp": now + ttl, "metadata": name,
        "video": {"roomJoin": True, "room": room,
                  "canPublish": True, "canSubscribe": True,
                  "canPublishData": True},
    }
    segs = [_b64url(json.dumps(header,  separators=(",", ":")).encode()),
            _b64url(json.dumps(payload, separators=(",", ":")).encode())]
    signing_input = b".".join(segs)
    sig = hmac.new(api_secret.encode(), signing_input, hashlib.sha256).digest()
    return (signing_input + b"." + _b64url(sig)).decode()

Verifikation lokal auf dem Pi (kein Gerät nötig): geminetes Token gegen den laufenden LiveKit prüfen, z. B. per LiveKit-CLI lk oder indem man das JWT auf jwt.io-Art dekodiert und Claims/Signatur vergleicht. Zusätzlich Gegentest mit dem alten Client-Pfad: dasselbe Token muss Room.connect akzeptieren.

Client-Seite

  • lib/core/livekit_token.dart: apiKey/apiSecret entfernen. Die Klasse wird entweder gelöscht oder zu einem dünnen HTTP-Client Future<String> fetchLiveKitToken({required String room, required String matrixToken}), der POST https://dashboard.steggi-matrix.work/api/livekit-token aufruft und das token-Feld zurückgibt. http-Paket ist schon Abhängigkeit (siehe update_checker.dart).
  • lib/core/livekit_call_manager.dart:154: LiveKitTokenGenerator.generate(...) → await fetchLiveKitToken(room: roomName, matrixToken: matrixClient.accessToken). Der Manager hat den Matrix-Client bereits (_matrixClient, Zeile 149) – der Access-Token ist also verfügbar. identity/displayName werden nicht mehr vom Client bestimmt (Server leitet Identität aus whoami ab); den bisherigen identity-Parameter entsprechend zurückbauen.
  • Fehlerpfade: Netzfehler / 401 / 403 sauber als Call-Fehler anzeigen (error = ...; notifyListeners()), NICHT still schlucken – sonst „Call verbindet nicht" ohne Hinweis. TTL bleibt 21600 s (6 h), Erneuerung wie bisher beim Neu-Beitreten.

Rotation (Pflicht, danach)

Das alte apiSecret rYUT2PRa… ist geleakt (Client + Git-History). Nach der Umstellung:

  1. Neues apiSecret in /home/steggi/matrix/livekit.yaml (keys:) setzen.
  2. LiveKit-Container neu starten (docker compose -f /home/steggi/matrix/docker-compose.yml restart livekit).
  3. server.py liest das Secret aus livekit.yaml – kein zweiter Ort zu pflegen.
  4. Alte Clients (Utas installierte APK) minten mit dem alten Secret → deren selbst-gemintete Tokens werden nach der Rotation abgelehnt. Deshalb Rotation zusammen mit einem neuen Release ausrollen, das den neuen Fetch-Pfad nutzt.

Reihenfolge / Testplan (PC + Gerät)

  1. server.py-Route bauen, lokal auf dem Pi verifizieren (whoami-Auth greift, geminetes Token dekodiert korrekt, Room.connect akzeptiert es).
  2. Client umstellen, flutter analyze sauber, flutter test grün.
  3. Auf echtem Gerät (Calls heilig, kein Headless-Test möglich): Voice-Channel beitreten, Audio/Video/Screenshare, Verlassen/Wieder-Beitreten, zweiter Teilnehmer. Erst wenn das steht: Secret rotieren + Release.
  4. Danach ROADMAP-M0-Punkt „LiveKit-Token server-seitig minten" abhaken und den Rotations-Teil des Secret-Punkts als erledigt markieren.

Warum nicht jetzt (auf dem Pi) gemacht

Die Server-Route ließe sich hier bauen und headless testen – aber der zugehörige Client-Umbau tauscht den heiligen Call-Pfad aus und ist auf diesem Pi ohne GUI/Gerät nicht praktisch prüfbar. Einen halben, nicht end-to-end verifizierbaren Umbau am Call-Pfad zu stapeln verbietet die CLAUDE.md-Leitplanke („keine Verhaltensänderung ohne Not", Calls heilig). Deshalb hier nur der fertige Plan.