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]>
167 lines
8.4 KiB
Markdown
167 lines
8.4 KiB
Markdown
# 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`):
|
||
|
||
```dart
|
||
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:
|
||
|
||
```python
|
||
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.
|