diff --git a/docs/LIVEKIT_TOKEN_MIGRATION.md b/docs/LIVEKIT_TOKEN_MIGRATION.md index f00a37a..1e9cf82 100644 --- a/docs/LIVEKIT_TOKEN_MIGRATION.md +++ b/docs/LIVEKIT_TOKEN_MIGRATION.md @@ -1,9 +1,32 @@ # 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`. +**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 diff --git a/integration_test/livekit_token_connect_test.dart b/integration_test/livekit_token_connect_test.dart new file mode 100644 index 0000000..fe46a88 --- /dev/null +++ b/integration_test/livekit_token_connect_test.dart @@ -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)), + ); +} diff --git a/lib/core/livekit_token.dart b/lib/core/livekit_token.dart index aa5e257..ff84e4d 100644 --- a/lib/core/livekit_token.dart +++ b/lib/core/livekit_token.dart @@ -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; +} diff --git a/lib/features/chat/view/chat_header.dart b/lib/features/chat/view/chat_header.dart index 4e2d553..05eef77 100644 --- a/lib/features/chat/view/chat_header.dart +++ b/lib/features/chat/view/chat_header.dart @@ -186,8 +186,10 @@ class _ChatHeader extends ConsumerWidget { await ref.read(voiceChannelProvider).startCall( roomName: roomId, roomDisplayName: room.getLocalizedDisplayname(), - identity: 'user_${DateTime.now().millisecondsSinceEpoch}', + identity: room.client.userID ?? '', audioOnly: !cam, + // Für das Beitritts-Token (Server prüft den Matrix-Login). + matrixClient: room.client, ); } } diff --git a/lib/features/voice_channel/livekit_call_manager.dart b/lib/features/voice_channel/livekit_call_manager.dart index 3be99ba..e40aebc 100644 --- a/lib/features/voice_channel/livekit_call_manager.dart +++ b/lib/features/voice_channel/livekit_call_manager.dart @@ -173,8 +173,6 @@ class LiveKitCallManager extends VoiceChannelService { if (isActive) return; final gen = ++_callGeneration; - final url = 'wss://livekit.steggi-matrix.work'; - isConnecting = true; isVoiceChannel = voiceChannel; error = null; @@ -191,12 +189,21 @@ class LiveKitCallManager extends VoiceChannelService { notifyListeners(); try { - final token = LiveKitTokenGenerator.generate( - roomName: roomName, - identity: identity, - displayName: identity, - ttlSeconds: 21600, + // Token holt der Server (prüft den Matrix-Login, Identität = eigene + // Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App. + // [identity] wird dafür nicht mehr gebraucht. + final accessToken = matrixClient?.accessToken; + 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( roomOptions: const RoomOptions( diff --git a/pubspec.lock b/pubspec.lock index 058ab30..870118d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -202,7 +202,7 @@ packages: source: hosted version: "1.0.2" dart_jsonwebtoken: - dependency: "direct main" + dependency: transitive description: name: dart_jsonwebtoken sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe diff --git a/pubspec.yaml b/pubspec.yaml index 601ebb6..e77226e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,7 +26,6 @@ dependencies: livekit_client: ^2.4.0 flutter_webrtc: ^1.4.1 webrtc_interface: ^1.5.1 - dart_jsonwebtoken: ^3.3.1 # State management & routing flutter_riverpod: ^2.6.1 diff --git a/test/livekit_token_test.dart b/test/livekit_token_test.dart new file mode 100644 index 0000000..058365a --- /dev/null +++ b/test/livekit_token_test.dart @@ -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 sent; + final client = MockClient((req) async { + expect(req.url.toString(), LiveKitTokenService.endpoint); + sent = jsonDecode(req.body) as Map; + 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 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('', 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'))); + }); +}