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>
This commit is contained in:
Bernd Steckmeister
2026-07-04 04:06:53 +02:00
parent 925aafb2fe
commit 78e4174658
4 changed files with 170 additions and 0 deletions
+3
View File
@@ -1228,3 +1228,6 @@
2026-07-04 04:02 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
2026-07-04 04:03 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:06 [Write] /home/steggi/pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
2026-07-04 04:06 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:06 [Edit] /home/steggi/pyramid/PROGRESS.md
+22
View File
@@ -13,6 +13,28 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
---
## 2026-07-04 LiveKit-Token-Minting: Migrationsplan geschrieben (Prep, kein Code)
**Erledigt:** Für den offenen M0-Punkt „LiveKit-Token server-seitig minten"
(letzter Client-eingebetteter Secret nach der Diagnose-Entkopplung) den fertigen
Umsetzungsplan `docs/LIVEKIT_TOKEN_MIGRATION.md` geschrieben gegen den echten
Client-Code (`livekit_token.dart`, `livekit_call_manager.dart:154`) und die echte
Pi-Konfig (`livekit.yaml`, `server.py`, Continuwuity `whoami`). Kern: Token-Route
`POST /api/livekit-token` am bestehenden Dashboard-Server, die den Matrix-Access-Token
per `whoami` prüft und daraus die LiveKit-Identität ableitet (kein Spoofing), JWT
per stdlib-HMAC mintet (kein neues Paket); Client holt nur noch das fertige JWT und
`apiSecret` verschwindet aus dem App-Code. Analog zum bewährten
`SQLCIPHER_MIGRATION.md`-Muster.
**Offen/Nächster Schritt:** Umsetzung braucht echten Call-Test auf einem Gerät
(Calls „heilig", auf dem Pi kein GUI) daher bewusst nur der Plan, kein Code.
Danach Secret-Rotation zusammen mit einem Release. Verbleibende M0-Secrets
(Admin-Token, Gitea-, Giphy-Token) brauchen Bernds Konten-Zugriff.
**Stolperfallen:** Keine reine Doku.
---
## 2026-07-04 Admin-Token aus dem Client entfernt (E2EE-Diagnose entkoppelt)
**Erledigt:** Den kritischsten Teil des Secret-Befunds (k) wirklich behoben, statt
+2
View File
@@ -68,6 +68,8 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
gegen Code + Pi-Konfig geschrieben) braucht echten Call-Test auf einem Gerät.
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
(`/home/steggi/matrix/server.py`) benutzte für `/api/e2ee-diagnostics` denselben
String wie den Matrix-Admin-Token deshalb steckte er im Client. Jetzt hat das
+143
View File
@@ -0,0 +1,143 @@
# 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`):
```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.