Files
pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
Bernd Steckmeister 78e4174658 docs: LiveKit-Token-Minting-Migrationsplan (apiSecret raus aus Client)
Fertiger Umsetzungsplan fuer den offenen M0-Punkt: Token-Endpoint am
Dashboard-Server mintet LiveKit-JWTs server-seitig (Matrix-whoami-Auth,
stdlib-HMAC), Client holt nur das JWT. Braucht echten Call-Test -> nur Plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:06:53 +02:00

7.2 KiB
Raw Permalink Blame History

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

Status: geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten kann. Analog zu docs/SQLCIPHER_MIGRATION.md.

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.