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
+27 -4
View File
@@ -1,9 +1,32 @@
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen) # LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät – **Status (2026-10-07):** Server-Route UND Client umgestellt.
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 - Server: `POST /api/livekit-token` im Dashboard-Server auf dem Pi
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`. (`/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 ## Problem
@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/main.dart' as app;
/// Echter Beitritt zu LiveKit mit einem vom SERVER ausgestellten Token
/// (docs/LIVEKIT_TOKEN_MIGRATION.md) – über denselben Weg wie die App
/// (voiceChannelProvider.startCall), nur mit dem TEST-Konto.
///
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil,
/// z. B. pyramidtest1) – nie gegen Bernds echtes Profil:
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/livekit_token_connect_test.dart -d windows
///
/// Betritt einen eigenen Wegwerf-Raum („pyramid-tokentest“), nur Ton, und
/// legt sofort wieder auf. Ersetzt NICHT den Anruftest auf dem Handy.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
(tester) async {
app.main();
final end = DateTime.now().add(const Duration(seconds: 90));
while (find.byType(MaterialApp).evaluate().isEmpty &&
DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
}
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
final client = await tester.runAsync(
() => container.read(matrixClientProvider.future),
);
expect(client!.isLogged(), isTrue, reason: 'Testprofil nicht angemeldet');
final call = container.read(voiceChannelProvider);
await tester.runAsync(() => call.startCall(
roomName: 'pyramid-tokentest',
roomDisplayName: 'Token-Test',
identity: 'wird-vom-server-ignoriert',
audioOnly: true,
matrixClient: client,
));
await tester.pump(const Duration(milliseconds: 500));
expect(call.error, isNull, reason: 'Beitritt fehlgeschlagen');
expect(call.room, isNotNull, reason: 'keine LiveKit-Verbindung');
// Der Server bestimmt die Identität aus dem geprüften Matrix-Login –
// der vom Client übergebene Wert spielt keine Rolle mehr.
expect(call.room!.localParticipant?.identity, client.userID);
await tester.runAsync(() => call.hangUp());
await tester.pump(const Duration(milliseconds: 500));
expect(call.room, isNull);
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 3)),
);
}
+83 -31
View File
@@ -1,37 +1,89 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'dart:async';
import 'dart:convert';
import 'dart:io';
class LiveKitTokenGenerator { import 'package:http/http.dart' as http;
// Your LiveKit Credentials
static const String apiKey = 'LKMatrixPi';
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
static String generate({ /// LiveKit-Beitritts-Token vom Pyramid-Server holen.
required String roomName, ///
required String identity, /// Früher hat die App die Tokens selbst signiert – dafür steckte das
String? displayName, /// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
int ttlSeconds = 3600, // Valid for 1 hour by default /// 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
final jwt = JWT( /// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
{ /// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
'video': { class LiveKitTokenService {
'roomJoin': true, static const endpoint =
'room': roomName, 'https://dashboard.steggi-matrix.work/api/livekit-token';
'canPublish': true, static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
'canSubscribe': true, static const _timeout = Duration(seconds: 15);
'canPublishData': true,
}, /// Liefert Token und LiveKit-Adresse für [room]. Wirft
'metadata': displayName ?? identity, /// [LiveKitTokenException] mit einer verständlichen Meldung.
}, static Future<({String token, String url})> fetch({
issuer: apiKey, required String room,
subject: identity, 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.',
); );
}
// Sign the token with your secret if (res.statusCode != 200) {
final token = jwt.sign( throw LiveKitTokenException(
SecretKey(apiSecret), 'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
expiresIn: Duration(seconds: ttlSeconds),
); );
}
return token; 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;
}
+3 -1
View File
@@ -186,8 +186,10 @@ class _ChatHeader extends ConsumerWidget {
await ref.read(voiceChannelProvider).startCall( await ref.read(voiceChannelProvider).startCall(
roomName: roomId, roomName: roomId,
roomDisplayName: room.getLocalizedDisplayname(), roomDisplayName: room.getLocalizedDisplayname(),
identity: 'user_${DateTime.now().millisecondsSinceEpoch}', identity: room.client.userID ?? '',
audioOnly: !cam, audioOnly: !cam,
// Für das Beitritts-Token (Server prüft den Matrix-Login).
matrixClient: room.client,
); );
} }
} }
@@ -173,8 +173,6 @@ class LiveKitCallManager extends VoiceChannelService {
if (isActive) return; if (isActive) return;
final gen = ++_callGeneration; final gen = ++_callGeneration;
final url = 'wss://livekit.steggi-matrix.work';
isConnecting = true; isConnecting = true;
isVoiceChannel = voiceChannel; isVoiceChannel = voiceChannel;
error = null; error = null;
@@ -191,12 +189,21 @@ class LiveKitCallManager extends VoiceChannelService {
notifyListeners(); notifyListeners();
try { try {
final token = LiveKitTokenGenerator.generate( // Token holt der Server (prüft den Matrix-Login, Identität = eigene
roomName: roomName, // Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App.
identity: identity, // [identity] wird dafür nicht mehr gebraucht.
displayName: identity, final accessToken = matrixClient?.accessToken;
ttlSeconds: 21600, if (accessToken == null || accessToken.isEmpty) {
throw const LiveKitTokenException(
'Nicht angemeldet – Beitritt nicht möglich.');
}
final lk = await LiveKitTokenService.fetch(
room: roomName,
matrixToken: accessToken,
); );
if (gen != _callGeneration) return; // inzwischen aufgelegt/gewechselt
final token = lk.token;
final url = lk.url;
final room = Room( final room = Room(
roomOptions: const RoomOptions( roomOptions: const RoomOptions(
+1 -1
View File
@@ -202,7 +202,7 @@ packages:
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
dart_jsonwebtoken: dart_jsonwebtoken:
dependency: "direct main" dependency: transitive
description: description:
name: dart_jsonwebtoken name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
-1
View File
@@ -26,7 +26,6 @@ dependencies:
livekit_client: ^2.4.0 livekit_client: ^2.4.0
flutter_webrtc: ^1.4.1 flutter_webrtc: ^1.4.1
webrtc_interface: ^1.5.1 webrtc_interface: ^1.5.1
dart_jsonwebtoken: ^3.3.1
# State management & routing # State management & routing
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
+87
View File
@@ -0,0 +1,87 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pyramid/core/livekit_token.dart';
/// Beitritts-Token kommt vom Server; das LiveKit-Secret steckt nicht mehr
/// in der App (docs/LIVEKIT_TOKEN_MIGRATION.md).
void main() {
test('schickt Raum + Matrix-Token und liefert Token und Adresse', () async {
late Map<String, dynamic> sent;
final client = MockClient((req) async {
expect(req.url.toString(), LiveKitTokenService.endpoint);
sent = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response(
jsonEncode({'token': 'abc.def.ghi', 'url': 'wss://lk.example'}),
200,
);
});
final r = await LiveKitTokenService.fetch(
room: '!raum:server',
matrixToken: 'mt',
httpClient: client,
);
expect(sent, {'room': '!raum:server', 'matrix_token': 'mt'});
expect(r.token, 'abc.def.ghi');
expect(r.url, 'wss://lk.example');
});
test('ohne brauchbare Adresse: Standard-LiveKit', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode({'token': 't'}), 200),
);
final r = await LiveKitTokenService.fetch(
room: 'r',
matrixToken: 'm',
httpClient: client,
);
expect(r.url, LiveKitTokenService.fallbackUrl);
});
Future<String> failureFor(MockClient client) async {
try {
await LiveKitTokenService.fetch(
room: 'r',
matrixToken: 'm',
httpClient: client,
);
} on LiveKitTokenException catch (e) {
return e.message;
}
fail('LiveKitTokenException erwartet');
}
test('Fehler werden verständlich gemeldet statt still geschluckt', () async {
expect(
await failureFor(MockClient((_) async => http.Response('{}', 401))),
contains('neu anmelden'),
);
expect(
await failureFor(MockClient((_) async => http.Response('kaputt', 500))),
contains('HTTP 500'),
);
expect(
await failureFor(MockClient((_) async => http.Response('{}', 200))),
contains('kein Zugangstoken'),
);
expect(
await failureFor(MockClient((_) async => http.Response('<html>', 200))),
contains('unlesbare'),
);
expect(
await failureFor(
MockClient((_) async => throw const SocketException('offline')),
),
contains('nicht erreichbar'),
);
});
test('App-Code enthält kein LiveKit-Secret und signiert nicht selbst', () {
final src = File('lib/core/livekit_token.dart').readAsStringSync();
expect(src, isNot(contains('apiSecret')));
expect(src, isNot(contains('jsonwebtoken')));
});
}