diff --git a/.changes/add-certificate-pinning b/.changes/add-certificate-pinning new file mode 100644 index 000000000..764682c31 --- /dev/null +++ b/.changes/add-certificate-pinning @@ -0,0 +1 @@ +minor type="added" "Add native certificate pinning for SDK-owned connections" diff --git a/README.md b/README.md index 1bbbce27f..e8c3b4c9f 100644 --- a/README.md +++ b/README.md @@ -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 \ + | 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: diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index f274a0289..92f540748 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -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'; @@ -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; @@ -218,17 +218,17 @@ class Room extends DisposableChangeNotifier with EventsEmittable { 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'); @@ -274,7 +274,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { } 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); } diff --git a/lib/src/core/signal_client.dart b/lib/src/core/signal_client.dart index f579ea13e..5f584f914 100644 --- a/lib/src/core/signal_client.dart +++ b/lib/src/core/signal_client.dart @@ -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'; @@ -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'; @@ -163,6 +163,7 @@ class SignalClient extends Disposable with EventsEmittable { headers: { 'Authorization': 'Bearer $token', }, + networkOptions: roomOptions.networkOptions, ); future = future.timeout(connectOptions.timeouts.connection); _ws = await future; @@ -170,6 +171,11 @@ class SignalClient extends Disposable with EventsEmittable { _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; @@ -186,11 +192,12 @@ class SignalClient extends Disposable with EventsEmittable { 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, diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index f14ba9640..bae9a4819 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -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 { diff --git a/lib/src/options.dart b/lib/src/options.dart index 1149b2815..ea9c2c44f 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -46,6 +46,113 @@ class FastConnectOptions { final TrackOption 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 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 hosts; + + /// Primary SHA-256 SPKI pins, formatted as `sha256/`. + final List primaryPins; + + /// Backup SHA-256 SPKI pins, formatted as `sha256/`. + /// + /// Backup pins are accepted the same way as primary pins and are intended + /// for certificate rotation. + final List 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> 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> trustedCertificateBytes; + + const CertificatePinningRule({ + this.hosts = const [], + this.primaryPins = const [], + this.backupPins = const [], + this.pinnedLeafCertificateBytes = const [], + this.trustedCertificateBytes = const [], + }); + + List 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 @@ -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') @@ -140,6 +250,7 @@ class RoomOptions { this.encryption, this.enableVisualizer = false, this.fastPublish = true, + this.networkOptions = const NetworkOptions(), }); RoomOptions copyWith({ @@ -155,6 +266,7 @@ class RoomOptions { E2EEOptions? e2eeOptions, E2EEOptions? encryption, bool? fastPublish, + NetworkOptions? networkOptions, }) { return RoomOptions( defaultCameraCaptureOptions: defaultCameraCaptureOptions ?? this.defaultCameraCaptureOptions, @@ -170,6 +282,7 @@ class RoomOptions { e2eeOptions: e2eeOptions ?? this.e2eeOptions, encryption: encryption ?? this.encryption, fastPublish: fastPublish ?? this.fastPublish, + networkOptions: networkOptions ?? this.networkOptions, ); } } diff --git a/lib/src/support/certificate_pinning.dart b/lib/src/support/certificate_pinning.dart new file mode 100644 index 000000000..c1257e915 --- /dev/null +++ b/lib/src/support/certificate_pinning.dart @@ -0,0 +1,228 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:asn1lib/asn1lib.dart'; +import 'package:crypto/crypto.dart'; + +import '../exceptions.dart'; +import '../options.dart'; + +class CertificatePinValidator { + final CertificatePinningOptions? _options; + + const CertificatePinValidator(this._options); + + bool get isEnabled => _options?.isEnabled ?? false; + + void validate({ + required Uri uri, + required List? certificateDer, + }) { + if (!isEnabled || (!uri.isScheme('https') && !uri.isScheme('wss'))) { + return; + } + + final host = uri.host.toLowerCase(); + final acceptedPins = rulesForHost(host) + .where((rule) => rule.hasSpkiPins) + .expand((rule) => rule.allPins) + .map(_normalizeSha256Pin) + .where((pin) => pin.isNotEmpty) + .toSet(); + if (acceptedPins.isEmpty) { + return; + } + + if (certificateDer == null) { + throw CertificatePinningException( + 'No peer certificate was available for $host', + host: host, + ); + } + + late final String presentedPin; + try { + presentedPin = certificateSpkiSha256Pin(certificateDer); + } catch (error) { + throw CertificatePinningException( + 'Could not parse peer certificate for $host: $error', + host: host, + ); + } + + if (!acceptedPins.contains(presentedPin)) { + throw CertificatePinningException( + 'Certificate pin mismatch for $host', + host: host, + presentedPin: presentedPin, + ); + } + } + + void validatePinnedLeafCertificate({ + required Uri uri, + required List? certificateDer, + }) { + if (!isEnabled || (!uri.isScheme('https') && !uri.isScheme('wss'))) { + return; + } + + final host = uri.host.toLowerCase(); + final pinnedCertificates = rulesForHost(host) + .where((rule) => rule.hasPinnedLeafCertificates) + .expand((rule) => rule.pinnedLeafCertificateBytes) + .expand(certificateDerCertificates) + .toList(); + if (pinnedCertificates.isEmpty) { + return; + } + + if (certificateDer == null) { + throw CertificatePinningException( + 'No peer certificate was available for $host', + host: host, + ); + } + + if (!pinnedCertificates.any((pinnedCertificate) => _bytesEqual(pinnedCertificate, certificateDer))) { + throw CertificatePinningException( + 'Certificate mismatch for $host', + host: host, + ); + } + } + + List rulesForHost(String host) => [ + for (final rule in _options?.rules ?? const []) + if (rule.hosts.isEmpty || rule.hosts.any((pattern) => _hostMatches(host.toLowerCase(), pattern))) rule, + ]; +} + +String certificateSpkiSha256Pin(List certificateDer) { + final subjectPublicKeyInfo = _extractSubjectPublicKeyInfo(Uint8List.fromList(certificateDer)); + final digest = sha256.convert(subjectPublicKeyInfo); + return 'sha256/${base64Encode(digest.bytes)}'; +} + +Iterable> certificateDerCertificates(List certificateBytes) sync* { + final text = utf8.decode(certificateBytes, allowMalformed: true); + final pemMatches = _certificatePemPattern.allMatches(text); + var foundPem = false; + for (final match in pemMatches) { + foundPem = true; + yield base64Decode(match.group(1)!.replaceAll(RegExp(r'\s'), '')); + } + if (!foundPem) { + yield certificateBytes; + } +} + +List certificatePemBytes(List certificateBytes) { + final text = utf8.decode(certificateBytes, allowMalformed: true); + if (_certificatePemPattern.hasMatch(text)) { + return certificateBytes; + } + + final base64Certificate = base64Encode(certificateBytes); + final lines = []; + for (var offset = 0; offset < base64Certificate.length; offset += 64) { + final end = offset + 64; + lines.add(base64Certificate.substring(offset, end > base64Certificate.length ? base64Certificate.length : end)); + } + return ascii.encode('-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----\n'); +} + +final _certificatePemPattern = RegExp( + r'-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----', + dotAll: true, +); + +String _normalizeSha256Pin(String pin) { + final trimmed = pin.trim(); + final lower = trimmed.toLowerCase(); + if (lower.startsWith('sha256/')) { + return 'sha256/${trimmed.substring(7).trim()}'; + } + if (lower.startsWith('sha256:')) { + return 'sha256/${trimmed.substring(7).trim()}'; + } + return trimmed.isEmpty ? '' : 'sha256/$trimmed'; +} + +bool _hostMatches(String host, String pattern) { + final normalizedPattern = pattern.trim().toLowerCase(); + if (normalizedPattern == '*') { + return true; + } + if (normalizedPattern.startsWith('*.')) { + final suffix = normalizedPattern.substring(2); + if (host == suffix || !host.endsWith('.$suffix')) { + return false; + } + final prefix = host.substring(0, host.length - suffix.length - 1); + return !prefix.contains('.'); + } + return host == normalizedPattern; +} + +bool _bytesEqual(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +Uint8List _extractSubjectPublicKeyInfo(Uint8List certificateDer) { + final certificate = _asn1Sequence( + ASN1Parser(certificateDer).nextObject(), + 'certificate', + ); + final tbsCertificate = _asn1Sequence( + _asn1Element(certificate, 0, 'TBSCertificate'), + 'TBSCertificate', + ); + + var fieldIndex = 0; + if (_asn1Element(tbsCertificate, fieldIndex, 'TBSCertificate first field').tag == 0xa0) { + fieldIndex++; + } + + final subjectPublicKeyInfo = _asn1Sequence( + _asn1Element(tbsCertificate, fieldIndex + 5, 'SubjectPublicKeyInfo'), + 'SubjectPublicKeyInfo', + ); + return subjectPublicKeyInfo.encodedBytes; +} + +ASN1Object _asn1Element(ASN1Sequence sequence, int index, String name) { + if (index >= sequence.elements.length) { + throw FormatException('Missing $name'); + } + return sequence.elements[index]; +} + +ASN1Sequence _asn1Sequence(ASN1Object object, String name) { + if (object is! ASN1Sequence) { + throw FormatException('Expected $name sequence, got tag 0x${object.tag.toRadixString(16)}'); + } + return object; +} diff --git a/lib/src/support/http_client.dart b/lib/src/support/http_client.dart new file mode 100644 index 000000000..22f5c1a22 --- /dev/null +++ b/lib/src/support/http_client.dart @@ -0,0 +1,46 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:http/http.dart' as http; + +import '../options.dart'; +import 'http_client/io.dart' if (dart.library.js_interop) 'http_client/web.dart' as impl; + +http.Client createSdkHttpClient(NetworkOptions networkOptions) => impl.createSdkHttpClient(networkOptions); + +Future sdkHttpGet( + Uri uri, { + Map? headers, + NetworkOptions networkOptions = const NetworkOptions(), +}) async { + final client = createSdkHttpClient(networkOptions); + try { + return await client.get(uri, headers: headers); + } finally { + client.close(); + } +} + +Future sdkHttpHead( + Uri uri, { + Map? headers, + NetworkOptions networkOptions = const NetworkOptions(), +}) async { + final client = createSdkHttpClient(networkOptions); + try { + return await client.head(uri, headers: headers); + } finally { + client.close(); + } +} diff --git a/lib/src/support/http_client/io.dart b/lib/src/support/http_client/io.dart new file mode 100644 index 000000000..824be1b43 --- /dev/null +++ b/lib/src/support/http_client/io.dart @@ -0,0 +1,128 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io' as io; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart' as http_io; + +import '../../exceptions.dart'; +import '../../options.dart'; +import '../certificate_pinning.dart'; + +http.Client createSdkHttpClient(NetworkOptions networkOptions) => + http_io.IOClient(createSdkIoHttpClient(networkOptions)); + +io.HttpClient createSdkIoHttpClient(NetworkOptions networkOptions) { + final validator = CertificatePinValidator(networkOptions.certificatePinning); + final client = io.HttpClient(); + if (!validator.isEnabled) { + return client; + } + + client.connectionFactory = _CertificatePinningConnectionFactory(validator).connect; + return client; +} + +class _CertificatePinningConnectionFactory { + final CertificatePinValidator _validator; + + const _CertificatePinningConnectionFactory(this._validator); + + Future> connect( + Uri url, + String? proxyHost, + int? proxyPort, + ) async { + if (proxyHost != null || proxyPort != null) { + throw UnsupportedError('Certificate pinning through HTTP proxies is not supported'); + } + + if (!_isTlsScheme(url.scheme)) { + return io.Socket.startConnect(url.host, _portFor(url)); + } + + final rules = _validator.rulesForHost(url.host); + final validatePinnedLeafCertificate = rules.any((rule) => rule.hasPinnedLeafCertificates); + final context = _securityContextFor(rules, allowPinnedCertificateBypass: validatePinnedLeafCertificate); + CertificatePinningException? pinnedLeafCertificateFailure; + final task = await io.SecureSocket.startConnect( + url.host, + _portFor(url), + context: context, + onBadCertificate: validatePinnedLeafCertificate && !rules.any((rule) => rule.hasTrustedCertificates) + ? (certificate) { + try { + _validator.validatePinnedLeafCertificate( + uri: url, + certificateDer: certificate.der, + ); + return true; + } on CertificatePinningException catch (error) { + pinnedLeafCertificateFailure = error; + return false; + } + } + : null, + ); + + final socket = task.socket.catchError((Object error) { + final failure = pinnedLeafCertificateFailure; + if (failure != null) { + throw failure; + } + throw error; + }).then((socket) { + if (validatePinnedLeafCertificate) { + _validator.validatePinnedLeafCertificate( + uri: url, + certificateDer: socket.peerCertificate?.der, + ); + } + _validator.validate( + uri: url, + certificateDer: socket.peerCertificate?.der, + ); + return socket; + }); + + return io.ConnectionTask.fromSocket(socket, task.cancel); + } + + io.SecurityContext? _securityContextFor( + List rules, { + required bool allowPinnedCertificateBypass, + }) { + final trustedCertificateBytes = + rules.where((rule) => rule.hasTrustedCertificates).expand((rule) => rule.trustedCertificateBytes).toList(); + if (trustedCertificateBytes.isEmpty) { + return allowPinnedCertificateBypass ? io.SecurityContext(withTrustedRoots: false) : null; + } + + final context = io.SecurityContext(withTrustedRoots: false); + for (final certificateBytes in trustedCertificateBytes) { + context.setTrustedCertificatesBytes(certificatePemBytes(certificateBytes)); + } + return context; + } +} + +bool _isTlsScheme(String scheme) => scheme == 'https' || scheme == 'wss'; + +int _portFor(Uri uri) { + if (uri.hasPort) { + return uri.port; + } + return _isTlsScheme(uri.scheme) ? 443 : 80; +} diff --git a/lib/src/support/http_client/web.dart b/lib/src/support/http_client/web.dart new file mode 100644 index 000000000..39c2be132 --- /dev/null +++ b/lib/src/support/http_client/web.dart @@ -0,0 +1,24 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:http/http.dart' as http; + +import '../../options.dart'; + +http.Client createSdkHttpClient(NetworkOptions networkOptions) { + if (networkOptions.certificatePinning?.isEnabled ?? false) { + throw UnsupportedError('Certificate pinning is not supported on Flutter web'); + } + return http.Client(); +} diff --git a/lib/src/support/region_url_provider.dart b/lib/src/support/region_url_provider.dart index bac8d3b03..6c5045362 100644 --- a/lib/src/support/region_url_provider.dart +++ b/lib/src/support/region_url_provider.dart @@ -1,17 +1,20 @@ import 'dart:convert' show json; import 'package:fixnum/fixnum.dart'; -import 'package:http/http.dart' as http; import '../exceptions.dart'; import '../logger.dart'; +import '../options.dart'; import '../proto/livekit_rtc.pb.dart' as lk_models; +import 'http_client.dart'; class RegionUrlProvider { Uri serverUrl; String token; + final NetworkOptions networkOptions; + lk_models.RegionSettings? regionSettings; List attemptedRegions = []; @@ -20,7 +23,11 @@ class RegionUrlProvider { int settingsCacheTime = 5000; // 5 seconds - RegionUrlProvider({required String url, required this.token}) : serverUrl = Uri.parse(url); + RegionUrlProvider({ + required String url, + required this.token, + this.networkOptions = const NetworkOptions(), + }) : serverUrl = Uri.parse(url); void updateToken(String token) { this.token = token; @@ -59,9 +66,13 @@ class RegionUrlProvider { /* @internal */ Future fetchRegionSettings() async { final url = '${getCloudConfigUrl(serverUrl)}/regions'; - final http.Response regionSettingsResponse = await http.get(Uri.parse(url), headers: { - 'authorization': 'Bearer $token', - }); + final regionSettingsResponse = await sdkHttpGet( + Uri.parse(url), + headers: { + 'authorization': 'Bearer $token', + }, + networkOptions: networkOptions, + ); if (regionSettingsResponse.statusCode == 200) { final mapData = json.decode(regionSettingsResponse.body); final regions = (mapData['regions'] as List) diff --git a/lib/src/support/websocket.dart b/lib/src/support/websocket.dart index 9c79013ef..7d0555c4c 100644 --- a/lib/src/support/websocket.dart +++ b/lib/src/support/websocket.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import '../options.dart'; import '../support/disposable.dart'; import 'websocket/io.dart' if (dart.library.js_interop) 'websocket/web.dart'; @@ -41,6 +42,7 @@ typedef WebSocketConnector = Future Function( Uri uri, { WebSocketEventHandlers? options, Map? headers, + NetworkOptions? networkOptions, }); abstract class LiveKitWebSocket extends Disposable { @@ -50,6 +52,7 @@ abstract class LiveKitWebSocket extends Disposable { Uri uri, { WebSocketEventHandlers? options, Map? headers, + NetworkOptions? networkOptions = const NetworkOptions(), }) => - lkWebSocketConnect(uri, options: options, headers: headers); + lkWebSocketConnect(uri, options: options, headers: headers, networkOptions: networkOptions); } diff --git a/lib/src/support/websocket/io.dart b/lib/src/support/websocket/io.dart index be65c1d86..db774a969 100644 --- a/lib/src/support/websocket/io.dart +++ b/lib/src/support/websocket/io.dart @@ -15,16 +15,20 @@ import 'dart:async'; import 'dart:io' as io; +import '../../exceptions.dart'; import '../../extensions.dart'; import '../../logger.dart'; +import '../../options.dart'; +import '../http_client/io.dart'; import '../websocket.dart'; Future lkWebSocketConnect( Uri uri, { WebSocketEventHandlers? options, Map? headers, + NetworkOptions? networkOptions = const NetworkOptions(), }) => - LiveKitWebSocketIO.connect(uri, options: options, headers: headers); + LiveKitWebSocketIO.connect(uri, options: options, headers: headers, networkOptions: networkOptions); class LiveKitWebSocketIO extends LiveKitWebSocket { final io.WebSocket _ws; @@ -74,15 +78,23 @@ class LiveKitWebSocketIO extends LiveKitWebSocket { Uri uri, { WebSocketEventHandlers? options, Map? headers, + NetworkOptions? networkOptions = const NetworkOptions(), }) async { logger.fine('[WebSocketIO] Connecting(uri: ${uri.toString()})...'); + final resolvedNetworkOptions = networkOptions ?? const NetworkOptions(); + final useCustomClient = resolvedNetworkOptions.certificatePinning?.isEnabled ?? false; + final customClient = useCustomClient ? createSdkIoHttpClient(resolvedNetworkOptions) : null; try { - final ws = await io.WebSocket.connect(uri.toString(), headers: headers); + final ws = await io.WebSocket.connect(uri.toString(), headers: headers, customClient: customClient); logger.fine('[WebSocketIO] Connected'); return LiveKitWebSocketIO._(ws, options); + } on CertificatePinningException { + rethrow; } catch (err) { logger.severe('[WebSocketIO] did throw $err'); throw WebSocketException('Failed to connect', err); + } finally { + customClient?.close(); } } } diff --git a/lib/src/support/websocket/web.dart b/lib/src/support/websocket/web.dart index 2be79e57b..cfed87cfe 100644 --- a/lib/src/support/websocket/web.dart +++ b/lib/src/support/websocket/web.dart @@ -20,6 +20,7 @@ import 'package:web/web.dart' as web; import '../../extensions.dart'; import '../../logger.dart'; +import '../../options.dart'; import '../websocket.dart'; // ignore: avoid_web_libraries_in_flutter @@ -28,8 +29,9 @@ Future lkWebSocketConnect( Uri uri, { WebSocketEventHandlers? options, Map? headers, // |headers| will be ignored on web + NetworkOptions? networkOptions = const NetworkOptions(), }) => - LiveKitWebSocketWeb.connect(uri, options); + LiveKitWebSocketWeb.connect(uri, options: options, networkOptions: networkOptions); class LiveKitWebSocketWeb extends LiveKitWebSocket { final web.WebSocket _ws; @@ -71,9 +73,14 @@ class LiveKitWebSocketWeb extends LiveKitWebSocket { } static Future connect( - Uri uri, [ + Uri uri, { WebSocketEventHandlers? options, - ]) async { + NetworkOptions? networkOptions = const NetworkOptions(), + }) async { + if (networkOptions?.certificatePinning?.isEnabled ?? false) { + throw UnsupportedError('Certificate pinning is not supported on Flutter web'); + } + final completer = Completer(); final ws = web.WebSocket(uri.toString()); ws.onOpen.listen((_) => completer.complete(LiveKitWebSocketWeb._(ws, options))); diff --git a/pubspec.lock b/pubspec.lock index 91eec853f..4335d6e28 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: "direct main" + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: "direct main" description: @@ -178,7 +186,7 @@ packages: source: hosted version: "3.1.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index b5b1fc537..9e4bfb17e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,9 +27,11 @@ dependencies: sdk: flutter flutter: sdk: flutter + asn1lib: ^1.6.5 async: ^2.13.0 collection: ^1.19.1 connectivity_plus: ^7.0.0 + crypto: ^3.0.0 fixnum: ^1.1.1 meta: ^1.17.0 http: ^1.6.0 diff --git a/test/core/certificate_pinning_test.dart b/test/core/certificate_pinning_test.dart new file mode 100644 index 000000000..4ce20aa52 --- /dev/null +++ b/test/core/certificate_pinning_test.dart @@ -0,0 +1,88 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@Timeout(Duration(seconds: 5)) +library; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/core/signal_client.dart'; +import 'package:livekit_client/src/internal/events.dart'; +import '../mock/websocket_mock.dart'; + +const exampleUri = 'ws://www.example.com'; +const token = 'token'; + +void main() { + const connectOptions = ConnectOptions(); + const networkOptions = NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: ['www.example.com'], + primaryPins: ['sha256/primary-pin'], + backupPins: ['sha256/backup-pin'], + ), + ], + ), + ); + const roomOptions = RoomOptions(networkOptions: networkOptions); + + late SignalClient client; + late MockWebSocketConnector connector; + + setUp(() { + connector = MockWebSocketConnector(); + client = SignalClient(connector.connect); + }); + + test('passes configured network options to signaling websocket connector', () async { + await client.connect( + exampleUri, + token, + connectOptions: connectOptions, + roomOptions: roomOptions, + ); + + expect( + connector.uri, + Uri.parse( + 'ws://www.example.com/rtc?auto_subscribe=1&adaptive_stream=0&protocol=16&sdk=flutter&version=2.7.0&network=wifi&os=test')); + expect(connector.headers, {'Authorization': 'Bearer $token'}); + expect(connector.networkOptions, same(roomOptions.networkOptions)); + }); + + test('fails fast on certificate pinning failures', () async { + connector.connectError = CertificatePinningException('Certificate pin mismatch', host: 'www.example.com'); + + expect( + client.events.streamCtrl.stream, + emitsInOrder([ + isA(), + isA(), + ]), + ); + + await expectLater( + client.connect( + exampleUri, + token, + connectOptions: connectOptions, + roomOptions: roomOptions, + ), + throwsA(isA()), + ); + }); +} diff --git a/test/mock/websocket_mock.dart b/test/mock/websocket_mock.dart index 5aede3d4a..0917de753 100644 --- a/test/mock/websocket_mock.dart +++ b/test/mock/websocket_mock.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:livekit_client/src/options.dart'; import 'package:livekit_client/src/support/websocket.dart'; class MockWebSocket extends LiveKitWebSocket { @@ -21,6 +22,10 @@ class MockWebSocket extends LiveKitWebSocket { class MockWebSocketConnector { WebSocketEventHandlers? handlers; + Uri? uri; + Map? headers; + NetworkOptions? networkOptions; + Object? connectError; WebSocketOnData get onData => handlers!.onData!; @@ -32,7 +37,17 @@ class MockWebSocketConnector { Uri uri, { WebSocketEventHandlers? options, Map? headers, + NetworkOptions? networkOptions = const NetworkOptions(), }) async { + this.uri = uri; + this.headers = headers; + this.networkOptions = networkOptions; + + final error = connectError; + if (error != null) { + throw error; + } + handlers = options; return MockWebSocket(); } diff --git a/test/support/certificate_pinning_io_test.dart b/test/support/certificate_pinning_io_test.dart new file mode 100644 index 000000000..8855e1472 --- /dev/null +++ b/test/support/certificate_pinning_io_test.dart @@ -0,0 +1,380 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/exceptions.dart'; +import 'package:livekit_client/src/options.dart'; +import 'package:livekit_client/src/support/certificate_pinning.dart'; +import 'package:livekit_client/src/support/http_client.dart'; +import 'package:livekit_client/src/support/websocket.dart'; + +void main() { + test('allows exact pinned leaf certificates without SPKI pins', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + final response = await sdkHttpGet( + Uri.parse('https://localhost:${server.port}/settings'), + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], + ), + ], + ), + ), + ); + + expect(response.statusCode, 200); + expect(response.body, 'OK'); + expect(server.receivedText, contains('GET /settings HTTP/1.1')); + }); + + test('allows trusted leaf certificate stores without SPKI pins', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + final response = await sdkHttpGet( + Uri.parse('https://localhost:${server.port}/settings'), + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + trustedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + ), + ], + ), + ), + ); + + expect(response.statusCode, 200); + expect(response.body, 'OK'); + expect(server.receivedText, contains('GET /settings HTTP/1.1')); + }); + + test('allows trusted CA certificate stores without SPKI pins', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + final response = await sdkHttpGet( + Uri.parse('https://localhost:${server.port}/settings'), + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + trustedCertificateBytes: [_pemBytes(_trustedCaCertificatePem)], + ), + ], + ), + ), + ); + + expect(response.statusCode, 200); + expect(response.body, 'OK'); + expect(server.receivedText, contains('GET /settings HTTP/1.1')); + }); + + test('validates SPKI pins before sending HTTP request bytes', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + await expectLater( + sdkHttpGet( + Uri.parse('https://localhost:${server.port}/rtc'), + headers: const {'Authorization': 'Bearer token'}, + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + primaryPins: const ['sha256/not-the-presented-pin'], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], + ), + ], + ), + ), + ), + throwsA(isA()), + ); + + await server.waitForConnection(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(server.receivedBytes, isEmpty); + }); + + test('validates SPKI pins with trusted stores before sending HTTP request bytes', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + await expectLater( + sdkHttpGet( + Uri.parse('https://localhost:${server.port}/rtc'), + headers: const {'Authorization': 'Bearer token'}, + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + primaryPins: const ['sha256/not-the-presented-pin'], + trustedCertificateBytes: [_pemBytes(_trustedCaCertificatePem)], + ), + ], + ), + ), + ), + throwsA(isA()), + ); + + await server.waitForConnection(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(server.receivedBytes, isEmpty); + }); + + test('validates SPKI pins before sending WSS request bytes', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + await expectLater( + LiveKitWebSocket.connect( + Uri.parse('wss://localhost:${server.port}/rtc'), + headers: const {'Authorization': 'Bearer token'}, + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + primaryPins: const ['sha256/not-the-presented-pin'], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], + ), + ], + ), + ), + ), + throwsA(isA()), + ); + + await server.waitForConnection(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(server.receivedBytes, isEmpty); + }); + + test('allows matching pinned leaf certificates and SPKI pins together', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + final response = await sdkHttpGet( + Uri.parse('https://localhost:${server.port}/rtc'), + headers: const {'Authorization': 'Bearer token'}, + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + primaryPins: [certificateSpkiSha256Pin(_certificateDerFromPem(_localhostCertificatePem))], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], + ), + ], + ), + ), + ); + + expect(response.statusCode, 200); + expect(server.receivedText, contains('authorization: Bearer token')); + }); + + test('allows matching trusted certificate stores and SPKI pins together', () async { + final server = await _TlsTestServer.start(); + addTearDown(server.close); + + final response = await sdkHttpGet( + Uri.parse('https://localhost:${server.port}/rtc'), + headers: const {'Authorization': 'Bearer token'}, + networkOptions: NetworkOptions( + certificatePinning: CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['localhost'], + primaryPins: [certificateSpkiSha256Pin(_certificateDerFromPem(_localhostCertificatePem))], + trustedCertificateBytes: [_pemBytes(_trustedCaCertificatePem)], + ), + ], + ), + ), + ); + + expect(response.statusCode, 200); + expect(server.receivedText, contains('authorization: Bearer token')); + }); +} + +class _TlsTestServer { + final io.SecureServerSocket _server; + final _connected = Completer(); + final _receivedBytes = []; + final _sockets = []; + late final StreamSubscription _subscription; + + _TlsTestServer._(this._server) { + _subscription = _server.listen(_handleSocket); + } + + int get port => _server.port; + + List get receivedBytes => List.unmodifiable(_receivedBytes); + + String get receivedText => ascii.decode(_receivedBytes, allowInvalid: true); + + static Future<_TlsTestServer> start() async { + final context = io.SecurityContext() + ..useCertificateChainBytes(_pemBytes(_localhostCertificatePem)) + ..usePrivateKeyBytes(_pemBytes(_localhostPrivateKeyPem)); + final server = await io.SecureServerSocket.bind( + io.InternetAddress.loopbackIPv4, + 0, + context, + ); + return _TlsTestServer._(server); + } + + Future waitForConnection() async { + if (_connected.isCompleted) { + return; + } + await _connected.future.timeout(const Duration(seconds: 1), onTimeout: () {}); + } + + Future close() async { + await _subscription.cancel(); + for (final socket in _sockets) { + socket.destroy(); + } + await _server.close(); + } + + void _handleSocket(io.SecureSocket socket) { + _sockets.add(socket); + if (!_connected.isCompleted) { + _connected.complete(); + } + + socket.listen((data) { + _receivedBytes.addAll(data); + if (receivedText.contains('\r\n\r\n')) { + socket.add(ascii.encode('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK')); + unawaited(socket.flush().then((_) => socket.close())); + } + }); + } +} + +List _pemBytes(String pem) => ascii.encode(pem); + +List _certificateDerFromPem(String pem) { + final base64Body = pem.split('\n').where((line) => line.isNotEmpty && !line.startsWith('-----')).join(); + return base64Decode(base64Body); +} + +const _trustedCaCertificatePem = ''' +-----BEGIN CERTIFICATE----- +MIIDojCCAoqgAwIBAgIUXrZwbSCNTAZ2+2lUbPTSb9dYaJIwDQYJKoZIhvcNAQEL +BQAwVzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRAwDgYDVQQKDAdMaXZlS2l0 +MQ0wCwYDVQQLDARUZXN0MRowGAYDVQQDDBFMaXZlS2l0IFRlc3QgQ0EgNjAeFw0y +NjA1MDUwMjA0MjdaFw0yNzA1MDUwMjA0MjdaMFcxCzAJBgNVBAYTAlVTMQswCQYD +VQQIDAJDQTEQMA4GA1UECgwHTGl2ZUtpdDENMAsGA1UECwwEVGVzdDEaMBgGA1UE +AwwRTGl2ZUtpdCBUZXN0IENBIDYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDLS6cQd3FuzOj88FQqIlXndmjhgXhGDiCipj1jEZ4MgVR3cbm+FfwEL2KC +TOLTJpBmMGL5DyRGHFZjD/+h3IxRGeO2nlAn8o2lVoXvCWfJfZMkZN9660oWGQDr ++HM3TmoQqAkwBnTynv3embsgOGhhtBQ9FADa1x4KeNU4NRUQQbCDDuU1wPcS+3Rd +/LgJOtfMG+tD6ACEaYV/SskHxASxEVPL4kpxzgNSGju4Hyo/v1bA9jvMCgFcp951 +YQ9nkVreNpbIQ3N8exfTVFPrh0aWtg7RM52SQ1bVZP0/y3yMV4UqJwus0wzNNza4 +89PBnZ4yVqr13zNGWRzXXJx4hNjtAgMBAAGjZjBkMBIGA1UdEwEB/wQIMAYBAf8C +AQEwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSD7seTcG6sMU5MYiZJH1hXZzMD +vjAfBgNVHSMEGDAWgBSD7seTcG6sMU5MYiZJH1hXZzMDvjANBgkqhkiG9w0BAQsF +AAOCAQEAfM6fhcnDnNAenSkX2Bj4y0G3gYD5yvJykmE5uBbLB1sCteott1bqCAI0 +rWwWthtrMqOgIy+E3AWRD5Dbh/RutrCKvM+bwWI6nuOTxKyD0Eg4Q7LJci6kBaZP +uHfu4D+4hQUbPVZu9MEzd4h7VV21goLs/Toj772NY5gsgNGT1ZEaSdalvtm2Aprq +Bht1zaNWX64rpTVlj4EInRMtXXoJym+KWx9UGzXSuEffCko3Bjyj7XxDpLHjCe3t +xHbBuvt8/X9G6LmM2XHenHs3R8fE+MR+q+J7+ydc5iYe/TF5so2l5k6OPMDASdaw +tx3DB1buXwYfqvJMfxUHHDcBB9fh1Q== +-----END CERTIFICATE----- +'''; + +const _localhostCertificatePem = ''' +-----BEGIN CERTIFICATE----- +MIIDxzCCAq+gAwIBAgIUGhRL7309IUNTm6hvItsQIT62H2gwDQYJKoZIhvcNAQEL +BQAwVzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRAwDgYDVQQKDAdMaXZlS2l0 +MQ0wCwYDVQQLDARUZXN0MRowGAYDVQQDDBFMaXZlS2l0IFRlc3QgQ0EgNjAeFw0y +NjA1MDUwMjA0MzNaFw0yNzA1MDUwMjA0MzNaME8xCzAJBgNVBAYTAlVTMQswCQYD +VQQIDAJDQTEQMA4GA1UECgwHTGl2ZUtpdDENMAsGA1UECwwEVGVzdDESMBAGA1UE +AwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvYrq +bAiPJeD0XiRzQ5R1sc5nXAkD0H/OetANRi/UzLu7CxyjhqUltGxKuIHLBdDi/s7Y +EYB2rb3ZP83NDVrgwaQo0doMcDg75DxT4/XgLst9yqAVH85UvvKC/RqR4TUtjZm8 +omKma7/E8DBk7fswydWigMV9x/xMZmPi3v+U9oTo20xrx33z14DhMS8H5VqAoLf8 +cHJRiRv/LU69ZWzSxjRSYlQS95/KmmfWYdEAu+oDmhEBtQ5ipD/7GeUt7QcziGyU +SBrma62Oun6m60UABR4DoMpJh2dhfmEaTF5owYpw5UpwIDDNDecrncbShe/TJGb7 +b4Q4PwRbuhKR4IfyiQIDAQABo4GSMIGPMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcE +fwAAATAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA4GA1UdDwEB +/wQEAwIFoDAdBgNVHQ4EFgQUuGJ7ZDy7LJqU1pk1gC8ml05KgWcwHwYDVR0jBBgw +FoAUg+7Hk3BurDFOTGImSR9YV2czA74wDQYJKoZIhvcNAQELBQADggEBAELuskBJ +vmvmtwVgQBjV+XP5cMAo9K0niLEtiSTVbIb82Zn8td5paIHLtdCUWo47FsXGcEka +xjHF7F+c+xSmLcmyscwIoueMlMznCMV9pd2Q9VKbGt/2H/YJKFkq151l3+DVrRNN +CxyX1bjWBvpPpwVVVtz9Ydrp5Uvmzd4IrtYJRz/Ty62y2YKmqEVmsfBqBvdxbF5R +/3Ss8AN3k/+SeRj2LFDg+0ekEAkzx08wG2Zhoj6kS98fldpao90JCOiSEn2DHcv6 +jOt2XQ4kR0oSVkU+KyVyGtMhNjjQnWjJOuVpo/rdhtEKz4/9B4ofKYgoaeATqoQg +Jioy3puXYIMud+Y= +-----END CERTIFICATE----- +'''; + +const _localhostPrivateKeyPem = ''' +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC9iupsCI8l4PRe +JHNDlHWxzmdcCQPQf8560A1GL9TMu7sLHKOGpSW0bEq4gcsF0OL+ztgRgHatvdk/ +zc0NWuDBpCjR2gxwODvkPFPj9eAuy33KoBUfzlS+8oL9GpHhNS2NmbyiYqZrv8Tw +MGTt+zDJ1aKAxX3H/ExmY+Le/5T2hOjbTGvHffPXgOExLwflWoCgt/xwclGJG/8t +Tr1lbNLGNFJiVBL3n8qaZ9Zh0QC76gOaEQG1DmKkP/sZ5S3tBzOIbJRIGuZrrY66 +fqbrRQAFHgOgykmHZ2F+YRpMXmjBinDlSnAgMM0N5yudxtKF79MkZvtvhDg/BFu6 +EpHgh/KJAgMBAAECggEACWn34KvAKFp26KIY03dxLQaaXZjZBqcCY1kn/59qi0yb +qp6ehJZ5O+/Q+j8ADWblj1BIrP3bZx+xxZh8IbisxxFXMa0Jxx0T5G8Wn5DbtJdI +xSKUSgMedGlpFhcWvb+9ZnYHR21s5Jceues9aBB8yNmCe7DTYXZneQJnBzpcdK3p +QH5h+w5H3Eol9aT0omaOcuJNUGW1YtOBHyiJT0HIccI4rwBr++E/w/WjNoAghuFK +RDQEs4kj8uj3A85QfFZSwAlYO6kPKaqvGKglLzinrmW5aj98FGufzUWLoI2MPPhK +5yDBbgD052Coql0TgvDFItHAXtSLt4WX2Wgi0dL0zQKBgQDzvBlbgJgtYgBW4CdO +Sy6XaDkSJBRAXbZtqUbOvXKpzF2BBbBA8cXhZF9vFrQJQf8+rh+c+OuCzdMqtvBc +BGOnb3A0RouyKaSrrXJgJjcMnKueB/opcEUAKxpEzF0+Jg6CiJDuGyQ7KbrVqIfR +tu8fhIlb5V6ttBWEJLVd3jLU7wKBgQDHFLB4X2pN2K1FhAA7Q+Eg3naQHMiS5aT2 +hs+BMa7WqtLt5MWPxv4yGgCaj6rss2xeuANBEE1ijAfV6PJwBON7PRPyw2eALM/O +k1wyDnIoBQDsFmwPHBzHVxpn200oNQrYTsCK2OWlcsv9AUyyoYWcdJ43wcHt8uGt +vhriiF3gBwKBgCetrn8b7yosMxvxf9SaHqqdV/UhFH7qAqHVleZgJwOHdo1jjK71 +7R3lRjgCfSqoqNHebN0UFNsFgOQKRhTkzgha9uw7s9A8QUeFhAItFnciJjoi2FHY +qhL98VfT4TYV4fTUIKvylTJgd78CoaG9Yy5BWE8yhvhGQd5yT2hJnQLXAoGAR0eA +G8lF/ZNsDqzBjHa0X5lnaBf2NKpmkyIXn9FTIWdOWIEFv4HnN7cZqj1wXImtboiC +GcSlgHhUweFDFJqbfF+VCeGu6DSjPvqCEyYa93s7Jkys6ggNwc3NFYxupsu/E023 +IL+iEcf1g6P4eyjb9vXGRH5qWjERXqznYV6kBfcCgYAnl/r8/ysuIuiUQ9d1CMAq +F6n8iC9IWl51SvrZZV85FgsR2MifmajE9AxiDHlROK5Cx0hWdDUEqkNXOX4iEhNW +aUeJreqbZQNpNjs5DeG2PydwP0anBkQKr3T0g4Uwt+CdRo1qBvX0uNprgthbsKy0 +R86q1fzRj1MGMGbJ/r6Xeg== +-----END PRIVATE KEY----- +'''; diff --git a/test/support/certificate_pinning_test.dart b/test/support/certificate_pinning_test.dart new file mode 100644 index 000000000..4451f60f2 --- /dev/null +++ b/test/support/certificate_pinning_test.dart @@ -0,0 +1,282 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/exceptions.dart'; +import 'package:livekit_client/src/options.dart'; +import 'package:livekit_client/src/support/certificate_pinning.dart'; + +void main() { + test('computes SHA-256 SPKI certificate pins', () { + final spki = _subjectPublicKeyInfo([1, 2, 3, 4]); + final certificate = _certificate(spki); + final expectedPin = 'sha256/${base64Encode(sha256.convert(spki).bytes)}'; + + expect(certificateSpkiSha256Pin(certificate), expectedPin); + }); + + test('computes SHA-256 SPKI certificate pins for a real X.509 certificate', () { + expect( + certificateSpkiSha256Pin(_realCertificateDer()), + 'sha256/vt3l7OSChC7JPeBz2uCokjLybmg/Kv+SoBW84d40XdM=', + ); + }); + + test('wraps DER certificates as PEM bytes for SecurityContext', () { + final pemBytes = certificatePemBytes(_realCertificateDer()); + final pemText = ascii.decode(pemBytes); + + expect(pemText, startsWith('-----BEGIN CERTIFICATE-----\n')); + expect(pemText, endsWith('-----END CERTIFICATE-----\n')); + expect(certificateDerCertificates(pemBytes).single, _realCertificateDer()); + }); + + test('accepts primary and backup pins for matching hosts', () { + final certificate = _certificate(_subjectPublicKeyInfo([1, 2, 3, 4])); + final backupCertificate = _certificate(_subjectPublicKeyInfo([5, 6, 7, 8])); + final primaryPin = certificateSpkiSha256Pin(certificate); + final backupPin = certificateSpkiSha256Pin(backupCertificate); + + final validator = CertificatePinValidator(CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['*.livekit.cloud'], + primaryPins: [primaryPin], + backupPins: [backupPin], + ), + ], + )); + + expect( + () => validator.validate( + uri: Uri.parse('https://project.livekit.cloud'), + certificateDer: certificate, + ), + returnsNormally, + ); + expect( + () => validator.validate( + uri: Uri.parse('https://project.livekit.cloud'), + certificateDer: backupCertificate, + ), + returnsNormally, + ); + }); + + test('merges all matching rules by check type', () { + final certificate = _certificate(_subjectPublicKeyInfo([1, 2, 3, 4])); + final backupCertificate = _certificate(_subjectPublicKeyInfo([5, 6, 7, 8])); + final otherCertificate = _certificate(_subjectPublicKeyInfo([9, 10, 11, 12])); + final validator = CertificatePinValidator(CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['*'], + primaryPins: [certificateSpkiSha256Pin(certificate)], + ), + CertificatePinningRule( + hosts: const ['livekit.example.com'], + backupPins: [certificateSpkiSha256Pin(backupCertificate)], + ), + ], + )); + + expect( + () => validator.validate( + uri: Uri.parse('https://livekit.example.com'), + certificateDer: backupCertificate, + ), + returnsNormally, + ); + expect( + () => validator.validate( + uri: Uri.parse('https://livekit.example.com'), + certificateDer: otherCertificate, + ), + throwsA(isA()), + ); + }); + + test('enforces each configured check type for matching rules', () { + final certificate = _certificate(_subjectPublicKeyInfo([1, 2, 3, 4])); + final otherCertificate = _certificate(_subjectPublicKeyInfo([5, 6, 7, 8])); + final validator = CertificatePinValidator(CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['livekit.example.com'], + pinnedLeafCertificateBytes: [certificate], + ), + CertificatePinningRule( + hosts: const ['*.example.com'], + primaryPins: [certificateSpkiSha256Pin(otherCertificate)], + ), + ], + )); + + expect( + () => validator.validatePinnedLeafCertificate( + uri: Uri.parse('https://livekit.example.com'), + certificateDer: certificate, + ), + returnsNormally, + ); + expect( + () => validator.validate( + uri: Uri.parse('https://livekit.example.com'), + certificateDer: certificate, + ), + throwsA(isA()), + ); + }); + + test('rejects pin mismatches', () { + final certificate = _certificate(_subjectPublicKeyInfo([1, 2, 3, 4])); + final otherCertificate = _certificate(_subjectPublicKeyInfo([5, 6, 7, 8])); + final validator = CertificatePinValidator(CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['livekit.example.com'], + primaryPins: [certificateSpkiSha256Pin(certificate)], + ), + ], + )); + + expect( + () => validator.validate( + uri: Uri.parse('https://livekit.example.com'), + certificateDer: otherCertificate, + ), + throwsA(isA()), + ); + }); + + test('ignores hosts without a matching rule', () { + final certificate = _certificate(_subjectPublicKeyInfo([1, 2, 3, 4])); + final validator = CertificatePinValidator(const CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: ['livekit.example.com'], + primaryPins: ['sha256/not-a-real-pin'], + ), + ], + )); + + expect( + () => validator.validate( + uri: Uri.parse('https://other.example.com'), + certificateDer: certificate, + ), + returnsNormally, + ); + }); + + test('wildcard hosts match only a single label', () { + final certificate = _certificate(_subjectPublicKeyInfo([1, 2, 3, 4])); + final validator = CertificatePinValidator(CertificatePinningOptions( + rules: [ + CertificatePinningRule( + hosts: const ['*.livekit.cloud'], + primaryPins: [certificateSpkiSha256Pin(certificate)], + ), + ], + )); + + expect( + () => validator.validate( + uri: Uri.parse('https://project.livekit.cloud'), + certificateDer: certificate, + ), + returnsNormally, + ); + expect( + () => validator.validate( + uri: Uri.parse('https://a.b.livekit.cloud'), + certificateDer: _certificate(_subjectPublicKeyInfo([5, 6, 7, 8])), + ), + returnsNormally, + ); + }); +} + +List _certificate(List subjectPublicKeyInfo) { + final tbsCertificate = _sequence([ + ..._explicitVersion(), + ..._integer(1), + ..._sequence(const []), + ..._sequence(const []), + ..._sequence(const []), + ..._sequence(const []), + ...subjectPublicKeyInfo, + ]); + + return _sequence([ + ...tbsCertificate, + ..._sequence(const []), + ..._bitString(const [0]), + ]); +} + +List _realCertificateDer() => base64Decode(''' +MIIDxzCCAq+gAwIBAgIUGhRL7309IUNTm6hvItsQIT62H2gwDQYJKoZIhvcNAQEL +BQAwVzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRAwDgYDVQQKDAdMaXZlS2l0 +MQ0wCwYDVQQLDARUZXN0MRowGAYDVQQDDBFMaXZlS2l0IFRlc3QgQ0EgNjAeFw0y +NjA1MDUwMjA0MzNaFw0yNzA1MDUwMjA0MzNaME8xCzAJBgNVBAYTAlVTMQswCQYD +VQQIDAJDQTEQMA4GA1UECgwHTGl2ZUtpdDENMAsGA1UECwwEVGVzdDESMBAGA1UE +AwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvYrq +bAiPJeD0XiRzQ5R1sc5nXAkD0H/OetANRi/UzLu7CxyjhqUltGxKuIHLBdDi/s7Y +EYB2rb3ZP83NDVrgwaQo0doMcDg75DxT4/XgLst9yqAVH85UvvKC/RqR4TUtjZm8 +omKma7/E8DBk7fswydWigMV9x/xMZmPi3v+U9oTo20xrx33z14DhMS8H5VqAoLf8 +cHJRiRv/LU69ZWzSxjRSYlQS95/KmmfWYdEAu+oDmhEBtQ5ipD/7GeUt7QcziGyU +SBrma62Oun6m60UABR4DoMpJh2dhfmEaTF5owYpw5UpwIDDNDecrncbShe/TJGb7 +b4Q4PwRbuhKR4IfyiQIDAQABo4GSMIGPMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcE +fwAAATAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA4GA1UdDwEB +/wQEAwIFoDAdBgNVHQ4EFgQUuGJ7ZDy7LJqU1pk1gC8ml05KgWcwHwYDVR0jBBgw +FoAUg+7Hk3BurDFOTGImSR9YV2czA74wDQYJKoZIhvcNAQELBQADggEBAELuskBJ +vmvmtwVgQBjV+XP5cMAo9K0niLEtiSTVbIb82Zn8td5paIHLtdCUWo47FsXGcEka +xjHF7F+c+xSmLcmyscwIoueMlMznCMV9pd2Q9VKbGt/2H/YJKFkq151l3+DVrRNN +CxyX1bjWBvpPpwVVVtz9Ydrp5Uvmzd4IrtYJRz/Ty62y2YKmqEVmsfBqBvdxbF5R +/3Ss8AN3k/+SeRj2LFDg+0ekEAkzx08wG2Zhoj6kS98fldpao90JCOiSEn2DHcv6 +jOt2XQ4kR0oSVkU+KyVyGtMhNjjQnWjJOuVpo/rdhtEKz4/9B4ofKYgoaeATqoQg +Jioy3puXYIMud+Y= +''' + .replaceAll(RegExp(r'\s'), '')); + +List _subjectPublicKeyInfo(List publicKeyBytes) => _sequence([ + ..._sequence(const []), + ..._bitString(publicKeyBytes), + ]); + +List _explicitVersion() => _element(0xa0, _integer(2)); + +List _integer(int value) => _element(0x02, [value]); + +List _sequence(List value) => _element(0x30, value); + +List _bitString(List value) => _element(0x03, [0, ...value]); + +List _element(int tag, List value) => [ + tag, + ..._length(value.length), + ...value, + ]; + +List _length(int length) { + if (length < 0x80) { + return [length]; + } + return [0x81, length]; +}