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]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 22:12:15 +02:00
co-authored by Claude Opus 5.5
parent ae6f651f4c
commit 756486d1fe
8 changed files with 283 additions and 46 deletions
+84 -32
View File
@@ -1,37 +1,89 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:io';
class LiveKitTokenGenerator {
// Your LiveKit Credentials
static const String apiKey = 'LKMatrixPi';
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
import 'package:http/http.dart' as http;
static String generate({
required String roomName,
required String identity,
String? displayName,
int ttlSeconds = 3600, // Valid for 1 hour by default
}) {
final jwt = JWT(
{
'video': {
'roomJoin': true,
'room': roomName,
'canPublish': true,
'canSubscribe': true,
'canPublishData': true,
},
'metadata': displayName ?? identity,
},
issuer: apiKey,
subject: identity,
);
/// LiveKit-Beitritts-Token vom Pyramid-Server holen.
///
/// Früher hat die App die Tokens selbst signiert – dafür steckte das
/// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
/// Raum und jede Identität ein Token ausstellen. Jetzt signiert nur noch der
/// Server (`/api/livekit-token` im Dashboard-Server auf dem Pi): Er prüft den
/// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
/// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
class LiveKitTokenService {
static const endpoint =
'https://dashboard.steggi-matrix.work/api/livekit-token';
static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
static const _timeout = Duration(seconds: 15);
// Sign the token with your secret
final token = jwt.sign(
SecretKey(apiSecret),
expiresIn: Duration(seconds: ttlSeconds),
);
return token;
/// Liefert Token und LiveKit-Adresse für [room]. Wirft
/// [LiveKitTokenException] mit einer verständlichen Meldung.
static Future<({String token, String url})> fetch({
required String room,
required String matrixToken,
http.Client? httpClient,
}) async {
final client = httpClient ?? http.Client();
try {
final res = await client
.post(
Uri.parse(endpoint),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
)
.timeout(_timeout);
if (res.statusCode == 401) {
throw const LiveKitTokenException(
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
);
}
if (res.statusCode != 200) {
throw LiveKitTokenException(
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
);
}
final data = jsonDecode(res.body);
final token = data is Map ? data['token'] : null;
if (token is! String || token.isEmpty) {
throw const LiveKitTokenException(
'Anrufserver hat kein Zugangstoken geliefert.',
);
}
final url = (data as Map)['url'];
return (
token: token,
url: url is String && url.startsWith('wss://') ? url : fallbackUrl,
);
} on LiveKitTokenException {
rethrow;
} on TimeoutException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar (Zeitüberschreitung).',
);
} on SocketException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on http.ClientException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on FormatException {
throw const LiveKitTokenException(
'Anrufserver hat eine unlesbare Antwort geschickt.',
);
} finally {
if (httpClient == null) client.close();
}
}
}
class LiveKitTokenException implements Exception {
final String message;
const LiveKitTokenException(this.message);
@override
String toString() => message;
}