Skip to content

Commit 6abecc6

Browse files
committed
Merge remote-tracking branch 'origin/main' into hiroshi/microphone-mute-mode
2 parents cb4f52b + 051581b commit 6abecc6

25 files changed

Lines changed: 2037 additions & 30 deletions

.changes/add-certificate-pinning

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
minor type="added" "Add native certificate pinning for SDK-owned connections"

.changes/single-disconnect-event

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "Emit a single disconnected event when connecting fails"

README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,107 @@ try {
197197
await room.localParticipant.setMicrophoneEnabled(true);
198198
```
199199

200+
### Certificate pinning
201+
202+
Certificate pinning is available for native platforms through `RoomOptions.networkOptions`. It applies to SDK-owned WSS signaling and internal HTTPS requests. It does not apply to WebRTC media, TURN, or application-owned token endpoints.
203+
204+
Certificate pinning is not supported on Flutter web because browsers do not expose certificate material to application code. Configuring it on web is treated as a misconfiguration and fails fast: `Room.connect` throws `UnsupportedError` instead of silently connecting without pinning. Only enable `certificatePinning` on web builds if you want that behavior, otherwise leave it unset when targeting web.
205+
206+
On native platforms, validation runs during TLS connection setup after the peer certificate is available and before the SDK writes HTTP or WSS request bytes. If validation fails, request headers and bodies are not sent.
207+
208+
Rules are selected by host. Exact hosts like `project.livekit.cloud`, single-label wildcards like `*.livekit.cloud`, multi-label wildcards like `**.livekit.cloud`, and `*` are supported. `*.livekit.cloud` matches `project.livekit.cloud`, but not `a.b.livekit.cloud`. `**.livekit.cloud` matches both. Rules with empty `hosts` apply to every SDK-owned TLS connection.
209+
210+
Hosts that match no rule are connected with platform trust only, and the SDK logs a warning. Keep in mind the SDK also connects to hosts you did not write yourself: LiveKit Cloud region failover uses server-provided regional hostnames like `project.region.production.livekit.cloud`, which carry more labels than your project URL. Use `**.livekit.cloud` so pinning also covers those hosts, and make sure the pin set includes the keys the regional endpoints serve.
211+
212+
All rules that match the connection host are applied. Within one check type, any configured value may match. Across check types, each configured type must pass. For example, two matching SPKI rules are treated as one accepted pin set, while SPKI pins plus exact leaf certificates require both the SPKI check and the exact leaf certificate check to pass.
213+
214+
Use SPKI SHA-256 pins when possible. `primaryPins` and `backupPins` are both accepted. Backup pins are useful for certificate rotation because the SDK accepts either set.
215+
216+
SPKI pins are matched against the leaf certificate's public key only. Unlike OkHttp or HPKP, the rest of the chain is not checked because Dart does not expose it, so pinning an intermediate or root CA key never matches and fails every connection. Pin leaf keys, and use backup pins for the future leaf keys you plan to rotate to.
217+
218+
```dart
219+
final roomOptions = RoomOptions(
220+
networkOptions: NetworkOptions(
221+
certificatePinning: CertificatePinningOptions(
222+
rules: [
223+
CertificatePinningRule(
224+
hosts: ['**.livekit.cloud'],
225+
primaryPins: ['sha256/current-public-key-pin'],
226+
backupPins: [
227+
'sha256/next-public-key-pin-1',
228+
'sha256/next-public-key-pin-2',
229+
],
230+
),
231+
],
232+
),
233+
),
234+
);
235+
236+
final room = Room(roomOptions: roomOptions);
237+
await room.connect(url, token);
238+
```
239+
240+
To generate an SPKI pin:
241+
242+
```bash
243+
openssl s_client -connect your-host:443 -servername your-host </dev/null 2>/dev/null \
244+
| openssl x509 -pubkey -noout \
245+
| openssl pkey -pubin -outform der \
246+
| openssl dgst -sha256 -binary \
247+
| openssl base64
248+
```
249+
250+
Prefix the output with `sha256/` before passing it to `primaryPins` or `backupPins`.
251+
252+
Certificate rules can also enforce exact leaf certificates or a custom TLS trust store.
253+
254+
Use `pinnedLeafCertificates` to require an exact peer leaf certificate after TLS trust validation succeeds. Renewing or changing the leaf certificate requires shipping updated pinned certificates.
255+
256+
By itself, `pinnedLeafCertificates` does not trust private or self-signed certificates. For private PKI, also configure `trustedCertificates` with the leaf, intermediate, or root certificate that should anchor TLS validation.
257+
258+
```dart
259+
final certificate = await CertificateBytes.fromAsset(
260+
'assets/livekit_leaf_cert.pem',
261+
);
262+
263+
final roomOptions = RoomOptions(
264+
networkOptions: NetworkOptions(
265+
certificatePinning: CertificatePinningOptions(
266+
rules: [
267+
CertificatePinningRule(
268+
hosts: ['my-project.livekit.cloud'],
269+
pinnedLeafCertificates: [certificate],
270+
trustedCertificates: [certificate],
271+
),
272+
],
273+
),
274+
),
275+
);
276+
```
277+
278+
Use `trustedCertificates` to validate TLS against a custom trust store, similar to `SecurityContext.setTrustedCertificatesBytes`. The SDK builds a per-connection trust store from these certificates and does not include the platform trusted roots for that host. The bytes can contain a leaf, intermediate, or root certificate.
279+
280+
```dart
281+
final certificate = await CertificateBytes.fromAsset(
282+
'assets/livekit_intermediate_ca.pem',
283+
);
284+
285+
final roomOptions = RoomOptions(
286+
networkOptions: NetworkOptions(
287+
certificatePinning: CertificatePinningOptions(
288+
rules: [
289+
CertificatePinningRule(
290+
hosts: ['**.livekit.cloud'],
291+
trustedCertificates: [certificate],
292+
),
293+
],
294+
),
295+
),
296+
);
297+
```
298+
299+
When a pinning rule matches a host, the SDK owns TLS setup for that connection and `HttpOverrides.global` or `HttpClient.badCertificateCallback` are not consulted. An app that relies on a bad certificate callback to accept a self-signed server certificate will see a `HandshakeException` once pinning is enabled for that host. Use `trustedCertificates` to trust the self-signed or private CA certificate instead.
300+
200301
### Screen sharing
201302

202303
Screen sharing is supported across all platforms. You can enable it with:

lib/src/core/engine.dart

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,16 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
268268
} catch (error) {
269269
logger.fine('Connect Error $error');
270270

271-
events.emit(EngineDisconnectedEvent(
272-
reason: DisconnectReason.joinFailure,
273-
));
271+
// during a reconnect this connect() runs inside restartConnection and
272+
// attemptReconnect owns disconnect emission, emitting here as well
273+
// would produce two events for one failure
274+
if (!_isReconnecting && !_attemptingReconnect) {
275+
events.emit(EngineDisconnectedEvent(
276+
reason: error is CertificatePinningException
277+
? DisconnectReason.signalingConnectionFailure
278+
: DisconnectReason.joinFailure,
279+
));
280+
}
274281
rethrow;
275282
}
276283
}
@@ -1096,18 +1103,25 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
10961103
fullReconnectOnNext = true;
10971104
}
10981105

1099-
if (e is UnexpectedConnectionState) {
1106+
if (e is UnexpectedConnectionState || e is CertificatePinningException) {
1107+
// certificate pinning failures are deterministic, retrying would only
1108+
// repeat TLS handshakes against an untrusted endpoint
11001109
recoverable = false;
11011110
}
11021111

11031112
if (recoverable) {
11041113
unawaited(handleReconnect(ClientDisconnectReason.reconnectRetry));
11051114
} else {
11061115
logger.fine('attemptReconnect: disconnecting...');
1116+
// clean up before emitting, room's EngineDisconnectedEvent handler
1117+
// drops the event while fullReconnectOnNext is still true and
1118+
// cleanUp() is what resets it
1119+
await cleanUp();
11071120
events.emit(EngineDisconnectedEvent(
1108-
reason: DisconnectReason.disconnected,
1121+
reason: e is CertificatePinningException
1122+
? DisconnectReason.signalingConnectionFailure
1123+
: DisconnectReason.disconnected,
11091124
));
1110-
await cleanUp();
11111125
}
11121126
} finally {
11131127
_attemptingReconnect = false;
@@ -1216,6 +1230,15 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
12161230
_regionUrlProvider?.resetAttempts();
12171231
events.emit(const EngineRestartedEvent());
12181232
} catch (error) {
1233+
// Certificate pinning failures skip region failover. The pin set is
1234+
// client-wide config, so every region would be validated against the
1235+
// same pins and each attempt is another TLS handshake with an endpoint
1236+
// that already failed validation. Initial connect behaves the same way,
1237+
// room.connect only fails over on WebSocketException/ConnectException.
1238+
if (error is CertificatePinningException) {
1239+
_regionUrlProvider?.resetAttempts();
1240+
rethrow;
1241+
}
12191242
final nextRegionUrl = await _regionUrlProvider?.getNextBestRegionUrl();
12201243
if (nextRegionUrl != null) {
12211244
await restartConnection(regionUrl: nextRegionUrl);
@@ -1351,11 +1374,12 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
13511374
if (event.reason == DisconnectReason.disconnected && !_isClosed) {
13521375
await handleReconnect(ClientDisconnectReason.signal,
13531376
reconnectReason: lk_models.ReconnectReason.RR_SIGNAL_DISCONNECTED);
1354-
} else if (event.reason == DisconnectReason.signalingConnectionFailure) {
1355-
events.emit(EngineDisconnectedEvent(
1356-
reason: event.reason,
1357-
));
13581377
}
1378+
// signalingConnectionFailure is intentionally not relayed as
1379+
// EngineDisconnectedEvent here. The signal client emits it while the
1380+
// connect() call is failing, so connect()'s own catch (initial connect)
1381+
// or attemptReconnect (reconnect) already emits the engine event, and
1382+
// relaying here produced a duplicate disconnect per failure.
13591383
})
13601384
..on<SignalOfferEvent>((event) async {
13611385
if (subscriber == null) {

lib/src/core/room.dart

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import 'dart:async';
1616
import 'dart:typed_data' show Uint8List;
1717

1818
import 'package:collection/collection.dart';
19-
import 'package:http/http.dart' as http;
2019
import 'package:meta/meta.dart';
2120

2221
import '../audio/audio_manager.dart';
@@ -42,6 +41,7 @@ import '../proto/livekit_rtc.pb.dart' as lk_rtc;
4241
import '../rpc/rpc_client_manager.dart';
4342
import '../rpc/rpc_server_manager.dart';
4443
import '../support/disposable.dart';
44+
import '../support/http_client.dart';
4545
import '../support/platform.dart';
4646
import '../support/region_url_provider.dart';
4747
import '../support/websocket.dart' show WebSocketException;
@@ -237,20 +237,20 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
237237
logger.info('prepareConnection to $url');
238238
try {
239239
if (isCloudUrl(Uri.parse(url)) && token != null) {
240-
_regionUrlProvider = RegionUrlProvider(token: token, url: url);
240+
_regionUrlProvider = RegionUrlProvider(token: token, url: url, networkOptions: roomOptions.networkOptions);
241241
final regionUrl = await _regionUrlProvider!.getNextBestRegionUrl();
242242
// we will not replace the regionUrl if an attempt had already started
243243
// to avoid overriding regionUrl after a new connection attempt had started
244244
if (regionUrl != null && connectionState == ConnectionState.disconnected) {
245245
_regionUrl = regionUrl;
246-
await http.head(Uri.parse(toHttpUrl(regionUrl)));
246+
await sdkHttpHead(Uri.parse(toHttpUrl(regionUrl)), networkOptions: roomOptions.networkOptions);
247247
logger.fine('prepared connection to ${regionUrl}');
248248
}
249249
} else {
250-
await http.head(Uri.parse(toHttpUrl(url)));
250+
await sdkHttpHead(Uri.parse(toHttpUrl(url)), networkOptions: roomOptions.networkOptions);
251251
}
252252
} catch (e) {
253-
logger.warning('could not prepare connection');
253+
logger.warning('could not prepare connection: $e');
254254
}
255255
}
256256

@@ -262,6 +262,10 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
262262
FastConnectOptions? fastConnectOptions,
263263
}) async {
264264
var roomOptions = this.roomOptions;
265+
if (lkPlatformIs(PlatformType.web) && (roomOptions.networkOptions.certificatePinning?.isEnabled ?? false)) {
266+
throw UnsupportedError('Certificate pinning is not supported on Flutter web, '
267+
'remove certificatePinning from NetworkOptions when targeting web');
268+
}
265269
connectOptions ??= ConnectOptions();
266270
_pendingTrackQueue.updateTtl(connectOptions.timeouts.subscribe);
267271
// ignore: deprecated_member_use_from_same_package
@@ -293,7 +297,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
293297
}
294298
if (isCloudUrl(Uri.parse(url))) {
295299
if (_regionUrlProvider == null) {
296-
_regionUrlProvider = RegionUrlProvider(url: url, token: token);
300+
_regionUrlProvider = RegionUrlProvider(url: url, token: token, networkOptions: roomOptions.networkOptions);
297301
} else {
298302
_regionUrlProvider?.updateToken(token);
299303
}

lib/src/core/signal_client.dart

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import 'package:flutter/foundation.dart' show kIsWeb;
2020
import 'package:connectivity_plus/connectivity_plus.dart';
2121
import 'package:fixnum/fixnum.dart';
2222
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
23-
import 'package:http/http.dart' as http;
2423
import 'package:meta/meta.dart';
2524

2625
import '../events.dart';
@@ -33,6 +32,7 @@ import '../options.dart';
3332
import '../proto/livekit_models.pb.dart' as lk_models;
3433
import '../proto/livekit_rtc.pb.dart' as lk_rtc;
3534
import '../support/disposable.dart';
35+
import '../support/http_client.dart';
3636
import '../support/platform.dart';
3737
import '../support/websocket.dart';
3838
import '../types/other.dart';
@@ -163,13 +163,24 @@ class SignalClient extends Disposable with EventsEmittable<SignalEvent> {
163163
headers: {
164164
'Authorization': 'Bearer $token',
165165
},
166+
networkOptions: roomOptions.networkOptions,
166167
);
167168
future = future.timeout(connectOptions.timeouts.connection);
168169
_ws = await future;
169170
// Successful connection
170171
_connectionState = ConnectionState.connected;
171172
events.emit(const SignalConnectedEvent());
172173
} catch (socketError) {
174+
if (socketError is CertificatePinningException) {
175+
// In reconnect mode the engine owns state and event emission,
176+
// emitting here would race its reconnect handling.
177+
if (!reconnect) {
178+
_connectionState = ConnectionState.disconnected;
179+
events.emit(SignalDisconnectedEvent(reason: DisconnectReason.signalingConnectionFailure));
180+
}
181+
rethrow;
182+
}
183+
173184
// Skip validation if reconnect mode
174185
if (reconnect) rethrow;
175186

@@ -186,11 +197,12 @@ class SignalClient extends Disposable with EventsEmittable<SignalEvent> {
186197
forceSecure: rtcUri.isSecureScheme,
187198
);
188199

189-
final validateResponse = await http.get(
200+
final validateResponse = await sdkHttpGet(
190201
validateUri,
191202
headers: {
192203
'Authorization': 'Bearer $token',
193204
},
205+
networkOptions: roomOptions.networkOptions,
194206
);
195207
if (validateResponse.statusCode != 200) {
196208
finalError = ConnectException(validateResponse.body,

lib/src/exceptions.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ class MediaConnectException extends LiveKitException {
5151
MediaConnectException([String msg = 'Ice connection failed']) : super._(msg);
5252
}
5353

54+
/// Certificate pinning validation failed for an SDK-owned TLS connection.
55+
class CertificatePinningException extends LiveKitException {
56+
final String host;
57+
final String? presentedPin;
58+
59+
CertificatePinningException(
60+
String msg, {
61+
required this.host,
62+
this.presentedPin,
63+
}) : super._(msg);
64+
}
65+
5466
/// An internal state of the SDK is not correct and can not continue to execute.
5567
/// This should not occur frequently.
5668
class UnexpectedStateException extends LiveKitException {

0 commit comments

Comments
 (0)