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:
co-authored by
Claude Opus 5.5
parent
ae6f651f4c
commit
756486d1fe
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
+84
-32
@@ -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,
|
|
||||||
},
|
|
||||||
'metadata': displayName ?? identity,
|
|
||||||
},
|
|
||||||
issuer: apiKey,
|
|
||||||
subject: identity,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sign the token with your secret
|
/// Liefert Token und LiveKit-Adresse für [room]. Wirft
|
||||||
final token = jwt.sign(
|
/// [LiveKitTokenException] mit einer verständlichen Meldung.
|
||||||
SecretKey(apiSecret),
|
static Future<({String token, String url})> fetch({
|
||||||
expiresIn: Duration(seconds: ttlSeconds),
|
required String room,
|
||||||
);
|
required String matrixToken,
|
||||||
|
http.Client? httpClient,
|
||||||
return token;
|
}) 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user