# 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.
