Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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"
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,90 @@ 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'],
),
],
),
),
);

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

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 `pinnedLeafCertificateBytes` to require an exact peer leaf certificate. This mode trusts only the configured leaf certificate bytes for matching hosts. Renewing or changing the leaf certificate requires shipping updated certificate bytes unless SPKI pins or `trustedCertificateBytes` also allow the new certificate.

By itself, `pinnedLeafCertificateBytes` permits the exact configured leaf certificate even if the platform trust store would reject it. This matches the asset-based Flutter pattern where the app ships the certificate it trusts. Combine it with `trustedCertificateBytes` if the connection should also validate against a pinned leaf, intermediate, or root certificate trust store.

```dart
final certificate = await rootBundle.load('assets/livekit_leaf_cert.pem');

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

Use `trustedCertificateBytes` to validate TLS against a custom trust store, similar to `SecurityContext.setTrustedCertificatesBytes`. The SDK builds a per-connection trust store from these bytes 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 rootBundle.load('assets/livekit_intermediate_ca.pem');

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

### 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 '../core/signal_client.dart';
Expand All @@ -39,6 +38,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 @@ -218,17 +218,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 @@ -274,7 +274,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
11 changes: 9 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,19 @@ 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) {
events.emit(SignalDisconnectedEvent(reason: DisconnectReason.signalingConnectionFailure));
rethrow;
}

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

Expand All @@ -186,11 +192,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
113 changes: 113 additions & 0 deletions lib/src/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,113 @@ class FastConnectOptions {
final TrackOption<bool, LocalVideoTrack> screen;
}

/// Options for SDK-owned network requests.
///
/// These options apply to LiveKit signaling and internal HTTPS requests made
/// by the SDK. They do not apply to WebRTC media, TURN, or user-provided token
/// endpoints.
class NetworkOptions {
/// Certificate pinning options for native platforms.
///
/// Certificate pinning is not supported on Flutter web because browsers do
/// not expose certificate material to application code.
final CertificatePinningOptions? certificatePinning;

const NetworkOptions({
this.certificatePinning,
});
}

/// Certificate pinning configuration for SDK-owned native TLS connections.
///
/// Validation runs during TLS connection setup after the peer certificate is
/// available and before the SDK writes HTTP or WSS request bytes.
class CertificatePinningOptions {
/// Pinning rules selected by connection host.
///
/// Every rule whose [CertificatePinningRule.hosts] match the connection host
/// is applied. Within a check type, any matching value is accepted. This
/// means SPKI pins are matched as one set and exact leaf certificates are
/// matched as one set. Different check types are combined, so when a host has
/// SPKI pins and exact leaf certificates configured, both checks must pass.
final List<CertificatePinningRule> rules;

const CertificatePinningOptions({
this.rules = const [],
});

bool get isEnabled => rules.any((rule) => rule.isEnabled);
}

/// A set of accepted certificate checks for one or more host patterns.
///
/// Empty [hosts] applies the rule to every SDK-owned TLS connection. Multiple
/// rules may match the same host. Matching rules are merged by check type, so
/// rules are additive instead of first-match-wins.
class CertificatePinningRule {
/// Host patterns this rule applies to.
///
/// Use exact hosts like `example.livekit.cloud`, single-label wildcard hosts
/// like `*.livekit.cloud`, or leave this empty to apply the rule to all
/// SDK-owned TLS connections. `*.livekit.cloud` matches
/// `project.livekit.cloud`, but not `a.b.livekit.cloud`.
final List<String> hosts;

/// Primary SHA-256 SPKI pins, formatted as `sha256/<base64>`.
final List<String> primaryPins;

/// Backup SHA-256 SPKI pins, formatted as `sha256/<base64>`.
///
/// Backup pins are accepted the same way as primary pins and are intended
/// for certificate rotation.
final List<String> backupPins;

/// PEM or DER encoded leaf certificates that may exactly match the peer leaf
/// certificate for matching hosts.
///
/// This mode trusts only the configured leaf certificate bytes for matching
/// hosts. Renewing or changing the leaf certificate requires shipping updated
/// bytes unless SPKI pins or [trustedCertificateBytes] also allow the new
/// certificate.
///
/// When this is the only trust material configured for a host, an exact leaf
/// match is allowed even if the platform trust store would reject it. Combine
/// it with [trustedCertificateBytes] if the connection should also validate
/// against a pinned leaf, intermediate, or root certificate trust store.
final List<List<int>> pinnedLeafCertificateBytes;

/// PEM or DER encoded certificates to use as the TLS trust store for
/// matching hosts.
///
/// These are loaded into a per-connection SecurityContext without platform
/// trusted roots. This supports leaf, intermediate, or root certificate trust
/// in the same style as Dart's `SecurityContext.setTrustedCertificatesBytes`.
/// If this is configured with SPKI pins or exact leaf certificates, the custom
/// trust store and the other configured checks must all pass.
final List<List<int>> trustedCertificateBytes;

const CertificatePinningRule({
this.hosts = const [],
this.primaryPins = const [],
this.backupPins = const [],
this.pinnedLeafCertificateBytes = const [],
this.trustedCertificateBytes = const [],
});

List<String> get allPins => [
...primaryPins,
...backupPins,
];

bool get hasSpkiPins => allPins.isNotEmpty;

bool get hasPinnedLeafCertificates => pinnedLeafCertificateBytes.isNotEmpty;

bool get hasTrustedCertificates => trustedCertificateBytes.isNotEmpty;

bool get isEnabled => hasSpkiPins || hasPinnedLeafCertificates || hasTrustedCertificates;
}

/// Options used when connecting to the server.
class ConnectOptions {
/// Auto-subscribe to existing and new [RemoteTrackPublication]s after
Expand Down Expand Up @@ -121,6 +228,9 @@ class RoomOptions {
/// fast track publication
final bool fastPublish;

/// Options for SDK-owned network requests.
final NetworkOptions networkOptions;

/// deprecated, use [createVisualizer] instead
/// please refer to example/lib/widgets/sound_waveform.dart
@Deprecated('Use createVisualizer instead')
Expand All @@ -140,6 +250,7 @@ class RoomOptions {
this.encryption,
this.enableVisualizer = false,
this.fastPublish = true,
this.networkOptions = const NetworkOptions(),
});

RoomOptions copyWith({
Expand All @@ -155,6 +266,7 @@ class RoomOptions {
E2EEOptions? e2eeOptions,
E2EEOptions? encryption,
bool? fastPublish,
NetworkOptions? networkOptions,
}) {
return RoomOptions(
defaultCameraCaptureOptions: defaultCameraCaptureOptions ?? this.defaultCameraCaptureOptions,
Expand All @@ -170,6 +282,7 @@ class RoomOptions {
e2eeOptions: e2eeOptions ?? this.e2eeOptions,
encryption: encryption ?? this.encryption,
fastPublish: fastPublish ?? this.fastPublish,
networkOptions: networkOptions ?? this.networkOptions,
);
}
}
Expand Down
Loading
Loading