Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/add-certificate-pinning
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minor type="added" "Add native certificate pinning for SDK-owned connections"
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,99 @@ try {
await room.localParticipant.setMicrophoneEnabled(true);
```

### Certificate pinning

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 Flutter web, WebRTC media, TURN, or application-owned token endpoints.

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.

Rules are selected by host. Exact hosts like `project.livekit.cloud`, single-label wildcards like `*.livekit.cloud`, and `*` are supported. `*.livekit.cloud` matches `project.livekit.cloud`, but not `a.b.livekit.cloud`. Rules with empty `hosts` apply to every SDK-owned TLS connection.

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.

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.

```dart
final roomOptions = RoomOptions(
networkOptions: NetworkOptions(
certificatePinning: CertificatePinningOptions(
rules: [
CertificatePinningRule(
hosts: ['*.livekit.cloud'],
primaryPins: ['sha256/current-public-key-pin'],
backupPins: [
'sha256/next-public-key-pin-1',
'sha256/next-public-key-pin-2',
],
),
],
),
),
);

final room = Room(roomOptions: roomOptions);
await room.connect(url, token);
```

To generate an SPKI pin:

```bash
openssl s_client -connect your-host:443 -servername your-host </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl base64
```

Prefix the output with `sha256/` before passing it to `primaryPins` or `backupPins`.

Certificate rules can also enforce exact leaf certificates or a custom TLS trust store.

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.

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.

```dart
final certificate = await CertificateBytes.fromAsset(
'assets/livekit_leaf_cert.pem',
);

final roomOptions = RoomOptions(
networkOptions: NetworkOptions(
certificatePinning: CertificatePinningOptions(
rules: [
CertificatePinningRule(
hosts: ['my-project.livekit.cloud'],
pinnedLeafCertificates: [certificate],
trustedCertificates: [certificate],
),
],
),
),
);
```

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.

```dart
final certificate = await CertificateBytes.fromAsset(
'assets/livekit_intermediate_ca.pem',
);

final roomOptions = RoomOptions(
networkOptions: NetworkOptions(
certificatePinning: CertificatePinningOptions(
rules: [
CertificatePinningRule(
hosts: ['*.livekit.cloud'],
trustedCertificates: [certificate],
),
],
),
),
);
```

### Screen sharing

Screen sharing is supported across all platforms. You can enable it with:
Expand Down
10 changes: 5 additions & 5 deletions lib/src/core/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import 'dart:async';
import 'dart:typed_data' show Uint8List;

import 'package:collection/collection.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';

import '../audio/audio_manager.dart';
Expand All @@ -40,6 +39,7 @@ import '../preconnect/pre_connect_audio_buffer.dart';
import '../proto/livekit_models.pb.dart' as lk_models;
import '../proto/livekit_rtc.pb.dart' as lk_rtc;
import '../support/disposable.dart';
import '../support/http_client.dart';
import '../support/platform.dart';
import '../support/region_url_provider.dart';
import '../support/websocket.dart' show WebSocketException;
Expand Down Expand Up @@ -222,17 +222,17 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
logger.info('prepareConnection to $url');
try {
if (isCloudUrl(Uri.parse(url)) && token != null) {
_regionUrlProvider = RegionUrlProvider(token: token, url: url);
_regionUrlProvider = RegionUrlProvider(token: token, url: url, networkOptions: roomOptions.networkOptions);
final regionUrl = await _regionUrlProvider!.getNextBestRegionUrl();
// we will not replace the regionUrl if an attempt had already started
// to avoid overriding regionUrl after a new connection attempt had started
if (regionUrl != null && connectionState == ConnectionState.disconnected) {
_regionUrl = regionUrl;
await http.head(Uri.parse(toHttpUrl(regionUrl)));
await sdkHttpHead(Uri.parse(toHttpUrl(regionUrl)), networkOptions: roomOptions.networkOptions);
logger.fine('prepared connection to ${regionUrl}');
}
} else {
await http.head(Uri.parse(toHttpUrl(url)));
await sdkHttpHead(Uri.parse(toHttpUrl(url)), networkOptions: roomOptions.networkOptions);
}
} catch (e) {
logger.warning('could not prepare connection');
Expand Down Expand Up @@ -278,7 +278,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
}
if (isCloudUrl(Uri.parse(url))) {
if (_regionUrlProvider == null) {
_regionUrlProvider = RegionUrlProvider(url: url, token: token);
_regionUrlProvider = RegionUrlProvider(url: url, token: token, networkOptions: roomOptions.networkOptions);
} else {
_regionUrlProvider?.updateToken(token);
}
Expand Down
12 changes: 10 additions & 2 deletions lib/src/core/signal_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';

import '../events.dart';
Expand All @@ -33,6 +32,7 @@ import '../options.dart';
import '../proto/livekit_models.pb.dart' as lk_models;
import '../proto/livekit_rtc.pb.dart' as lk_rtc;
import '../support/disposable.dart';
import '../support/http_client.dart';
import '../support/platform.dart';
import '../support/websocket.dart';
import '../types/other.dart';
Expand Down Expand Up @@ -163,13 +163,20 @@ class SignalClient extends Disposable with EventsEmittable<SignalEvent> {
headers: {
'Authorization': 'Bearer $token',
},
networkOptions: roomOptions.networkOptions,
);
future = future.timeout(connectOptions.timeouts.connection);
_ws = await future;
// Successful connection
_connectionState = ConnectionState.connected;
events.emit(const SignalConnectedEvent());
} catch (socketError) {
if (socketError is CertificatePinningException) {
_connectionState = ConnectionState.disconnected;
events.emit(SignalDisconnectedEvent(reason: DisconnectReason.signalingConnectionFailure));
rethrow;
}
Comment thread
Copilot marked this conversation as resolved.

// Skip validation if reconnect mode
if (reconnect) rethrow;

Expand All @@ -186,11 +193,12 @@ class SignalClient extends Disposable with EventsEmittable<SignalEvent> {
forceSecure: rtcUri.isSecureScheme,
);

final validateResponse = await http.get(
final validateResponse = await sdkHttpGet(
validateUri,
headers: {
'Authorization': 'Bearer $token',
},
networkOptions: roomOptions.networkOptions,
);
if (validateResponse.statusCode != 200) {
finalError = ConnectException(validateResponse.body,
Expand Down
12 changes: 12 additions & 0 deletions lib/src/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ class MediaConnectException extends LiveKitException {
MediaConnectException([String msg = 'Ice connection failed']) : super._(msg);
}

/// Certificate pinning validation failed for an SDK-owned TLS connection.
class CertificatePinningException extends LiveKitException {
final String host;
final String? presentedPin;

CertificatePinningException(
String msg, {
required this.host,
this.presentedPin,
}) : super._(msg);
}

/// An internal state of the SDK is not correct and can not continue to execute.
/// This should not occur frequently.
class UnexpectedStateException extends LiveKitException {
Expand Down
Loading
Loading