From f7b58fb6ba7d0ccb65c6ee6a8f1b2b74ab413aa5 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 07:53:17 +0800 Subject: [PATCH 1/8] Add certificate pinning support 1 --- lib/src/core/room.dart | 10 +- lib/src/core/signal_client.dart | 11 +- lib/src/exceptions.dart | 12 + lib/src/options.dart | 79 +++++ lib/src/support/certificate_pinning.dart | 291 ++++++++++++++++++ lib/src/support/http_client.dart | 46 +++ lib/src/support/http_client/io.dart | 110 +++++++ lib/src/support/http_client/web.dart | 24 ++ lib/src/support/region_url_provider.dart | 21 +- lib/src/support/websocket.dart | 5 +- lib/src/support/websocket/io.dart | 16 +- lib/src/support/websocket/web.dart | 13 +- pubspec.lock | 2 +- pubspec.yaml | 1 + test/core/certificate_pinning_test.dart | 88 ++++++ test/mock/websocket_mock.dart | 15 + test/support/certificate_pinning_io_test.dart | 253 +++++++++++++++ test/support/certificate_pinning_test.dart | 149 +++++++++ 18 files changed, 1127 insertions(+), 19 deletions(-) create mode 100644 lib/src/support/certificate_pinning.dart create mode 100644 lib/src/support/http_client.dart create mode 100644 lib/src/support/http_client/io.dart create mode 100644 lib/src/support/http_client/web.dart create mode 100644 test/core/certificate_pinning_test.dart create mode 100644 test/support/certificate_pinning_io_test.dart create mode 100644 test/support/certificate_pinning_test.dart 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..3cca26423 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -46,6 +46,79 @@ 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. +class CertificatePinningOptions { + /// Pinning rules. Every rule whose [CertificatePinningRule.hosts] match the + /// connection host is applied. + final List rules; + + const CertificatePinningOptions({ + this.rules = const [], + }); + + bool get isEnabled => rules.any((rule) => rule.isEnabled); +} + +/// A set of accepted pins for one or more host patterns. +class CertificatePinningRule { + /// Host patterns this rule applies to. + /// + /// Use exact hosts like `example.livekit.cloud`, wildcard hosts like + /// `*.livekit.cloud`, or leave this empty to apply the rule to all + /// SDK-owned TLS connections. + 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 to pin for matching hosts. + /// + /// These are matched against the peer leaf certificate during the TLS + /// handshake before HTTP or WSS request bytes are sent. + final List> trustedCertificateBytes; + + const CertificatePinningRule({ + this.hosts = const [], + this.primaryPins = const [], + this.backupPins = const [], + this.trustedCertificateBytes = const [], + }); + + List get allPins => [ + ...primaryPins, + ...backupPins, + ]; + + bool get hasSpkiPins => allPins.isNotEmpty; + + bool get hasTrustedCertificates => trustedCertificateBytes.isNotEmpty; + + bool get isEnabled => hasSpkiPins || hasTrustedCertificates; +} + /// Options used when connecting to the server. class ConnectOptions { /// Auto-subscribe to existing and new [RemoteTrackPublication]s after @@ -121,6 +194,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 +216,7 @@ class RoomOptions { this.encryption, this.enableVisualizer = false, this.fastPublish = true, + this.networkOptions = const NetworkOptions(), }); RoomOptions copyWith({ @@ -155,6 +232,7 @@ class RoomOptions { E2EEOptions? e2eeOptions, E2EEOptions? encryption, bool? fastPublish, + NetworkOptions? networkOptions, }) { return RoomOptions( defaultCameraCaptureOptions: defaultCameraCaptureOptions ?? this.defaultCameraCaptureOptions, @@ -170,6 +248,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..4272605c8 --- /dev/null +++ b/lib/src/support/certificate_pinning.dart @@ -0,0 +1,291 @@ +// 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: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 validateTrustedCertificate({ + required Uri uri, + required List? certificateDer, + }) { + if (!isEnabled || (!uri.isScheme('https') && !uri.isScheme('wss'))) { + return; + } + + final host = uri.host.toLowerCase(); + final trustedCertificates = rulesForHost(host) + .where((rule) => rule.hasTrustedCertificates) + .expand((rule) => rule.trustedCertificateBytes) + .expand(certificateDerCertificates) + .toList(); + if (trustedCertificates.isEmpty) { + return; + } + + if (certificateDer == null) { + throw CertificatePinningException( + 'No peer certificate was available for $host', + host: host, + ); + } + + if (!trustedCertificates.any((trustedCertificate) => _bytesEqual(trustedCertificate, 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 = RegExp( + r'-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----', + dotAll: true, + ).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; + } +} + +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); + return host != suffix && host.endsWith('.$suffix'); + } + 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 certificateReader = _DerReader(certificateDer); + final certificate = certificateReader.readElement(); + certificate.expectTag(0x30, 'certificate'); + + final certificateContent = _DerReader( + certificateDer, + start: certificate.valueStart, + end: certificate.valueEnd, + ); + final tbsCertificate = certificateContent.readElement(); + tbsCertificate.expectTag(0x30, 'TBSCertificate'); + + final tbsContent = _DerReader( + certificateDer, + start: tbsCertificate.valueStart, + end: tbsCertificate.valueEnd, + ); + + final first = tbsContent.readElement(); + if (first.tag != 0xa0) { + tbsContent.offset = first.start; + } + + for (var i = 0; i < 5; i++) { + tbsContent.readElement(); + } + + final subjectPublicKeyInfo = tbsContent.readElement(); + subjectPublicKeyInfo.expectTag(0x30, 'SubjectPublicKeyInfo'); + return Uint8List.sublistView( + certificateDer, + subjectPublicKeyInfo.start, + subjectPublicKeyInfo.end, + ); +} + +class _DerReader { + final Uint8List bytes; + final int end; + int offset; + + _DerReader( + this.bytes, { + int start = 0, + int? end, + }) : offset = start, + end = end ?? bytes.length; + + _DerElement readElement() { + final start = offset; + if (start >= end) { + throw const FormatException('Unexpected end of DER data'); + } + + final tag = bytes[offset++]; + if (offset >= end) { + throw const FormatException('Missing DER length'); + } + + final firstLengthByte = bytes[offset++]; + final length = _readLength(firstLengthByte); + final valueStart = offset; + final valueEnd = valueStart + length; + if (valueEnd > end) { + throw const FormatException('DER length exceeds container length'); + } + + offset = valueEnd; + return _DerElement( + tag: tag, + start: start, + valueStart: valueStart, + valueEnd: valueEnd, + ); + } + + int _readLength(int firstLengthByte) { + if ((firstLengthByte & 0x80) == 0) { + return firstLengthByte; + } + + final byteCount = firstLengthByte & 0x7f; + if (byteCount == 0) { + throw const FormatException('Indefinite DER lengths are not supported'); + } + if (byteCount > 4 || offset + byteCount > end) { + throw const FormatException('Invalid DER length'); + } + + var length = 0; + for (var i = 0; i < byteCount; i++) { + length = (length << 8) | bytes[offset++]; + } + return length; + } +} + +class _DerElement { + final int tag; + final int start; + final int valueStart; + final int valueEnd; + + const _DerElement({ + required this.tag, + required this.start, + required this.valueStart, + required this.valueEnd, + }); + + int get end => valueEnd; + + void expectTag(int expectedTag, String name) { + if (tag != expectedTag) { + throw FormatException( + 'Expected $name DER tag 0x${expectedTag.toRadixString(16)}, got 0x${tag.toRadixString(16)}'); + } + } +} 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..777e76fa4 --- /dev/null +++ b/lib/src/support/http_client/io.dart @@ -0,0 +1,110 @@ +// 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 validateTrustedCertificate = rules.any((rule) => rule.hasTrustedCertificates); + CertificatePinningException? trustedCertificateFailure; + final task = await io.SecureSocket.startConnect( + url.host, + _portFor(url), + context: validateTrustedCertificate ? io.SecurityContext(withTrustedRoots: false) : null, + onBadCertificate: validateTrustedCertificate + ? (certificate) { + try { + _validator.validateTrustedCertificate( + uri: url, + certificateDer: certificate.der, + ); + return true; + } on CertificatePinningException catch (error) { + trustedCertificateFailure = error; + return false; + } + } + : null, + ); + + final socket = task.socket.catchError((Object error) { + final failure = trustedCertificateFailure; + if (failure != null) { + throw failure; + } + throw error; + }).then((socket) { + if (validateTrustedCertificate) { + _validator.validateTrustedCertificate( + uri: url, + certificateDer: socket.peerCertificate?.der, + ); + } + _validator.validate( + uri: url, + certificateDer: socket.peerCertificate?.der, + ); + return socket; + }); + + return io.ConnectionTask.fromSocket(socket, task.cancel); + } +} + +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..06df4a7f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,7 +178,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..630b61b6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: 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..9e71a47cb --- /dev/null +++ b/test/support/certificate_pinning_io_test.dart @@ -0,0 +1,253 @@ +// 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 certificate-byte trust 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('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'], + trustedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + ), + ], + ), + ), + ), + 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'], + trustedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + ), + ], + ), + ), + ), + throwsA(isA()), + ); + + await server.waitForConnection(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(server.receivedBytes, isEmpty); + }); + + test('allows matching certificate-byte trust 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(_localhostCertificatePem)], + ), + ], + ), + ), + ); + + 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 _localhostCertificatePem = ''' +-----BEGIN CERTIFICATE----- +MIIDPTCCAiWgAwIBAgIUAsxf3tE9w4P9nBBZp+I4U7mFWhowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPTGl2ZUtpdCBUZXN0IENBMB4XDTI2MDUwNTAwNDA1MloX +DTM2MDUwMjAwNDA1MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoLdtYxvAcqnaFXMYu/g57Zn2LhTBJBYjJ5UB +aVKcbtk5z0IjC+OJe75x6DcQS+HbH4cHF7FY52CLC2oxUsAIdHmXtN1UHrjIDFBC +nSTwAIpsO9NKdwmRB1cGC8vfwA2gWKaedHDwO9fLk7RC5kxVw23OuOPbdn6cKnkv +U4NZkUULyYk/bk5AFscLFeQkDf/0rAbibG+EKeoJ4VAQB8CYs3OeQm2Sxig7Oy09 +n5KA5+UjxjeVTJzAC0JqqeBs9ISNJ7+vlsfLng/S/xpnnzRkMYuG8sFFseN3pA9Y +Ur/WlgD7fSWKbEOxCsWiFKP0yUq8VpEeBRA48ERp1AHv/Q99pQIDAQABo4GAMH4w +GgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMB +MAsGA1UdDwQEAwIFoDAdBgNVHQ4EFgQULlshilL3OsKKeYGZiv0knrBXr1YwHwYD +VR0jBBgwFoAU+YOp4KUxVvCyeDTLAq2oMvOAdQowDQYJKoZIhvcNAQELBQADggEB +AA06Tu7DQrhoMlpH1GEqnHbaxZXjlp7D6SnJxZ7Sg1iNtolRRKZ0AAhVJ5LaRhiN +M7lmbOpxbI87GxIzI4DkerU4i23tqtrI3/xx2l08FIyl46pFWtHKb8zwAgtigVwO +rIhDsCFSwDP8srWTaVwcazlMDzr8KKB2uHV09aDL+ZI1czSTboPcdsJtQPbElGqe +hEIgiyr6t/CGVUjpERKJCv9CpJ+gjEZMYztseyWbhMLaooURFBhDTyNRCRq85pJ2 +xytNnc8A/nSkIDn2lYHFmeGlhwYrGDcT7itYaVQkgBrSFfmPHH4+/SGduS92qIxg +8lE1W7hFxs9bHcK7ys+1Ggc= +-----END CERTIFICATE----- +'''; + +const _localhostPrivateKeyPem = ''' +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCgt21jG8ByqdoV +cxi7+DntmfYuFMEkFiMnlQFpUpxu2TnPQiML44l7vnHoNxBL4dsfhwcXsVjnYIsL +ajFSwAh0eZe03VQeuMgMUEKdJPAAimw700p3CZEHVwYLy9/ADaBYpp50cPA718uT +tELmTFXDbc6449t2fpwqeS9Tg1mRRQvJiT9uTkAWxwsV5CQN//SsBuJsb4Qp6gnh +UBAHwJizc55CbZLGKDs7LT2fkoDn5SPGN5VMnMALQmqp4Gz0hI0nv6+Wx8ueD9L/ +GmefNGQxi4bywUWx43ekD1hSv9aWAPt9JYpsQ7EKxaIUo/TJSrxWkR4FEDjwRGnU +Ae/9D32lAgMBAAECggEAJjQD/hOaNwd2DjQ6VHBIgNjgwopvcN8MQzvxxnH7OoRL +cB92CjzvsOkP1ZXFO2x4NHHZ90FSc0mpM7DuAZAhUmKW88jK1rSw5PBtLUKbBF3j +JYNvx4UQIvEGQGaZjOMQUxJkRySTjn4Y58bpQioyFs7y3VNYlz24bIY7ADyQXW3t +2WDtKEe4+2FwRciuTSNe7EVtCL+0jsADOTpEwc1SPK5z8wpkCPUUB6LYIiCO480x +3qqY7b9RHRrZveblAP+v/S8KMP37ZMMvvgjC7FsH8MTbtfkxrr4R/fXnPI7sinOT +REZ/+01wPUwxFzde61vMfUKEHA2zd+sILX6RVJNkjwKBgQDgLdnSa6ajJuCLmvvI +NCRuS3fJz9pqomlMsBIadKFJsR13LUgdX/PAgCpruXfQyKbIa251NqDvxBEwmjI8 +ITTOI+BCqUyo9ekXsA842mJ9kBr3QjJmq4jGJPidOqZxw+VEoJ880mZJiiMMJ9Jh +vLtfGUYUZfOth/GySQX3vZ0kJwKBgQC3h34vHMj3r6YnBKQLuiW8IpWyeFXxatWj +22nA0uv4umX7zoD/MQ8ixzCbFELZhuz0IjUrjOV1erffUInzEoR83di29zCjgNN3 +UGIF6A+gUUiF2WEFVLoTBFpoEUj9d9DVjWVTh0GDS3vniEp+Y54yiZ2bWwh4riWC +KxxUOG8zUwKBgCZYcWvGsigyHDKE/hBOqvSawBCrFwcqZKyTaWVREc2TGCEsg6tS +oFULFzZ58P6rc6vQhIJUJ88bUH1pwrH6VBf2lwOQBebYuVgt60ykPjiQD6y/i/N3 +39tUs5nhUFshUPQeLV6v9oMZt8j6fsftCnfH0O7oSXgjSrpeN0EbE+f9AoGAI4gH +1fcssUdAU62CVQLk61eGw9aoTOTyF5cTElHDfZQYyndgYgeNdp45usxhZNvKZDl7 +McNFaUko8AMXsgeTvtj0a/fPYtg+GItnbt1OqSsTb1Z2giG1JJljJ2KxTuEzfSSy +yUkWVeT3SAwK4A1JQ1+BM+Kb8UFF4b2W7nc+kCECgYAfOOOasQEqszqL9w07gpML +Ohuh2z+d6RQWn5zQRBcHWPm6aSF0YvAN4rRdJtS7eS+Bq6D7cxMQwyNr8KdCLA1z +JEZReewjE+u0rN6aFFl5/IGhejlV2LMJ7tRW2jE+RZ2FKX1xGIeemlhfLmmJ/b2c +j9XLpu0FVZrAqZ0LIROzkQ== +-----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..df55c8d62 --- /dev/null +++ b/test/support/certificate_pinning_test.dart @@ -0,0 +1,149 @@ +// 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('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('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, + ); + }); +} + +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 _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]; +} From abaf4c9953f1fdcd702aea173ba4a0ae35982585 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 08:56:50 +0800 Subject: [PATCH 2/8] Use single-label certificate pin wildcards --- lib/src/options.dart | 7 +++--- lib/src/support/certificate_pinning.dart | 6 ++++- test/support/certificate_pinning_test.dart | 27 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/src/options.dart b/lib/src/options.dart index 3cca26423..a8f41cbdd 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -80,9 +80,10 @@ class CertificatePinningOptions { class CertificatePinningRule { /// Host patterns this rule applies to. /// - /// Use exact hosts like `example.livekit.cloud`, wildcard hosts like - /// `*.livekit.cloud`, or leave this empty to apply the rule to all - /// SDK-owned TLS connections. + /// 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/`. diff --git a/lib/src/support/certificate_pinning.dart b/lib/src/support/certificate_pinning.dart index 4272605c8..5437f24af 100644 --- a/lib/src/support/certificate_pinning.dart +++ b/lib/src/support/certificate_pinning.dart @@ -152,7 +152,11 @@ bool _hostMatches(String host, String pattern) { } if (normalizedPattern.startsWith('*.')) { final suffix = normalizedPattern.substring(2); - return host != suffix && host.endsWith('.$suffix'); + if (host == suffix || !host.endsWith('.$suffix')) { + return false; + } + final prefix = host.substring(0, host.length - suffix.length - 1); + return !prefix.contains('.'); } return host == normalizedPattern; } diff --git a/test/support/certificate_pinning_test.dart b/test/support/certificate_pinning_test.dart index df55c8d62..70748fcaa 100644 --- a/test/support/certificate_pinning_test.dart +++ b/test/support/certificate_pinning_test.dart @@ -102,6 +102,33 @@ void main() { 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) { From 104b196c1b42515f0903b1f45ea5cdaab47f6af4 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 08:57:36 +0800 Subject: [PATCH 3/8] Test SPKI parsing with real certificate --- test/support/certificate_pinning_test.dart | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/support/certificate_pinning_test.dart b/test/support/certificate_pinning_test.dart index 70748fcaa..30029e6c3 100644 --- a/test/support/certificate_pinning_test.dart +++ b/test/support/certificate_pinning_test.dart @@ -30,6 +30,13 @@ void main() { expect(certificateSpkiSha256Pin(certificate), expectedPin); }); + test('computes SHA-256 SPKI certificate pins for a real X.509 certificate', () { + expect( + certificateSpkiSha256Pin(_realCertificateDer()), + 'sha256/sWFyCMoHOXAfVi8WO1EdoENbDzfweoR9p3XCplWJlA4=', + ); + }); + 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])); @@ -149,6 +156,28 @@ List _certificate(List subjectPublicKeyInfo) { ]); } +List _realCertificateDer() => base64Decode(''' +MIIDPTCCAiWgAwIBAgIUAsxf3tE9w4P9nBBZp+I4U7mFWhowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPTGl2ZUtpdCBUZXN0IENBMB4XDTI2MDUwNTAwNDA1MloX +DTM2MDUwMjAwNDA1MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoLdtYxvAcqnaFXMYu/g57Zn2LhTBJBYjJ5UB +aVKcbtk5z0IjC+OJe75x6DcQS+HbH4cHF7FY52CLC2oxUsAIdHmXtN1UHrjIDFBC +nSTwAIpsO9NKdwmRB1cGC8vfwA2gWKaedHDwO9fLk7RC5kxVw23OuOPbdn6cKnkv +U4NZkUULyYk/bk5AFscLFeQkDf/0rAbibG+EKeoJ4VAQB8CYs3OeQm2Sxig7Oy09 +n5KA5+UjxjeVTJzAC0JqqeBs9ISNJ7+vlsfLng/S/xpnnzRkMYuG8sFFseN3pA9Y +Ur/WlgD7fSWKbEOxCsWiFKP0yUq8VpEeBRA48ERp1AHv/Q99pQIDAQABo4GAMH4w +GgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMB +MAsGA1UdDwQEAwIFoDAdBgNVHQ4EFgQULlshilL3OsKKeYGZiv0knrBXr1YwHwYD +VR0jBBgwFoAU+YOp4KUxVvCyeDTLAq2oMvOAdQowDQYJKoZIhvcNAQELBQADggEB +AA06Tu7DQrhoMlpH1GEqnHbaxZXjlp7D6SnJxZ7Sg1iNtolRRKZ0AAhVJ5LaRhiN +M7lmbOpxbI87GxIzI4DkerU4i23tqtrI3/xx2l08FIyl46pFWtHKb8zwAgtigVwO +rIhDsCFSwDP8srWTaVwcazlMDzr8KKB2uHV09aDL+ZI1czSTboPcdsJtQPbElGqe +hEIgiyr6t/CGVUjpERKJCv9CpJ+gjEZMYztseyWbhMLaooURFBhDTyNRCRq85pJ2 +xytNnc8A/nSkIDn2lYHFmeGlhwYrGDcT7itYaVQkgBrSFfmPHH4+/SGduS92qIxg +8lE1W7hFxs9bHcK7ys+1Ggc= +''' + .replaceAll(RegExp(r'\s'), '')); + List _subjectPublicKeyInfo(List publicKeyBytes) => _sequence([ ..._sequence(const []), ..._bitString(publicKeyBytes), From bfee960df425867a0f32dd32e3788458dc79ba2f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 08:58:29 +0800 Subject: [PATCH 4/8] Document certificate pinning usage --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index 1bbbce27f..12f09f495 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,61 @@ 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. + +Use SPKI SHA-256 pins when possible. `primaryPins` and `backupPins` are both accepted, which allows key rotation without breaking existing app versions. + +```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`. + +You can also pin an exact leaf certificate with PEM or DER bytes. This is stricter operationally: renewing the leaf certificate requires shipping updated certificate bytes unless the same certificate remains in use. + +```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'], + trustedCertificateBytes: [certificate.buffer.asUint8List()], + ), + ], + ), + ), +); +``` + ### Screen sharing Screen sharing is supported across all platforms. You can enable it with: From 5800cc894b4a83d92b640e40313158fb07e44ce0 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 10:40:59 +0800 Subject: [PATCH 5/8] Support multiple certificate pinning modes --- README.md | 23 +- lib/src/options.dart | 21 +- lib/src/support/certificate_pinning.dart | 37 ++- lib/src/support/http_client/io.dart | 36 ++- test/support/certificate_pinning_io_test.dart | 225 ++++++++++++++---- test/support/certificate_pinning_test.dart | 50 ++-- 6 files changed, 300 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 12f09f495..a3c4b52c1 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,9 @@ openssl s_client -connect your-host:443 -servername your-host /dev/ Prefix the output with `sha256/` before passing it to `primaryPins` or `backupPins`. -You can also pin an exact leaf certificate with PEM or DER bytes. This is stricter operationally: renewing the leaf certificate requires shipping updated certificate bytes unless the same certificate remains in use. +Certificate rules can also enforce exact leaf certificates or a custom TLS trust store. When multiple checks are configured for a matching host, all of them must pass. + +Use `pinnedCertificateBytes` to require an exact peer leaf certificate. This is stricter operationally: renewing the leaf certificate requires shipping updated certificate bytes unless the same certificate remains in use. ```dart final certificate = await rootBundle.load('assets/livekit_leaf_cert.pem'); @@ -256,6 +258,25 @@ final roomOptions = RoomOptions( rules: [ CertificatePinningRule( hosts: ['my-project.livekit.cloud'], + pinnedCertificateBytes: [certificate.buffer.asUint8List()], + ), + ], + ), + ), +); +``` + +Use `trustedCertificateBytes` to validate TLS against a custom trust store, similar to `SecurityContext.setTrustedCertificatesBytes`. These bytes can be 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()], ), ], diff --git a/lib/src/options.dart b/lib/src/options.dart index a8f41cbdd..acfc4df2a 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -77,6 +77,10 @@ class CertificatePinningOptions { } /// A set of accepted pins for one or more host patterns. +/// +/// SPKI pins, exact leaf certificate pins, and trusted certificate bytes are +/// composable. When more than one mode is configured for a matching host, every +/// configured mode must pass. class CertificatePinningRule { /// Host patterns this rule applies to. /// @@ -95,16 +99,23 @@ class CertificatePinningRule { /// for certificate rotation. final List backupPins; - /// PEM or DER encoded leaf certificates to pin for matching hosts. + /// PEM or DER encoded leaf certificates that must exactly match the peer + /// leaf certificate for matching hosts. + final List> pinnedCertificateBytes; + + /// PEM or DER encoded certificates to use as the TLS trust store for + /// matching hosts. /// - /// These are matched against the peer leaf certificate during the TLS - /// handshake before HTTP or WSS request bytes are sent. + /// 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`. final List> trustedCertificateBytes; const CertificatePinningRule({ this.hosts = const [], this.primaryPins = const [], this.backupPins = const [], + this.pinnedCertificateBytes = const [], this.trustedCertificateBytes = const [], }); @@ -115,9 +126,11 @@ class CertificatePinningRule { bool get hasSpkiPins => allPins.isNotEmpty; + bool get hasPinnedCertificates => pinnedCertificateBytes.isNotEmpty; + bool get hasTrustedCertificates => trustedCertificateBytes.isNotEmpty; - bool get isEnabled => hasSpkiPins || hasTrustedCertificates; + bool get isEnabled => hasSpkiPins || hasPinnedCertificates || hasTrustedCertificates; } /// Options used when connecting to the server. diff --git a/lib/src/support/certificate_pinning.dart b/lib/src/support/certificate_pinning.dart index 5437f24af..46dd489db 100644 --- a/lib/src/support/certificate_pinning.dart +++ b/lib/src/support/certificate_pinning.dart @@ -72,7 +72,7 @@ class CertificatePinValidator { } } - void validateTrustedCertificate({ + void validatePinnedCertificate({ required Uri uri, required List? certificateDer, }) { @@ -81,12 +81,12 @@ class CertificatePinValidator { } final host = uri.host.toLowerCase(); - final trustedCertificates = rulesForHost(host) - .where((rule) => rule.hasTrustedCertificates) - .expand((rule) => rule.trustedCertificateBytes) + final pinnedCertificates = rulesForHost(host) + .where((rule) => rule.hasPinnedCertificates) + .expand((rule) => rule.pinnedCertificateBytes) .expand(certificateDerCertificates) .toList(); - if (trustedCertificates.isEmpty) { + if (pinnedCertificates.isEmpty) { return; } @@ -97,7 +97,7 @@ class CertificatePinValidator { ); } - if (!trustedCertificates.any((trustedCertificate) => _bytesEqual(trustedCertificate, certificateDer))) { + if (!pinnedCertificates.any((pinnedCertificate) => _bytesEqual(pinnedCertificate, certificateDer))) { throw CertificatePinningException( 'Certificate mismatch for $host', host: host, @@ -119,10 +119,7 @@ String certificateSpkiSha256Pin(List certificateDer) { Iterable> certificateDerCertificates(List certificateBytes) sync* { final text = utf8.decode(certificateBytes, allowMalformed: true); - final pemMatches = RegExp( - r'-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----', - dotAll: true, - ).allMatches(text); + final pemMatches = _certificatePemPattern.allMatches(text); var foundPem = false; for (final match in pemMatches) { foundPem = true; @@ -133,6 +130,26 @@ Iterable> certificateDerCertificates(List certificateBytes) sync* } } +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(); diff --git a/lib/src/support/http_client/io.dart b/lib/src/support/http_client/io.dart index 777e76fa4..0e5397aec 100644 --- a/lib/src/support/http_client/io.dart +++ b/lib/src/support/http_client/io.dart @@ -54,22 +54,23 @@ class _CertificatePinningConnectionFactory { } final rules = _validator.rulesForHost(url.host); - final validateTrustedCertificate = rules.any((rule) => rule.hasTrustedCertificates); - CertificatePinningException? trustedCertificateFailure; + final validatePinnedCertificate = rules.any((rule) => rule.hasPinnedCertificates); + final context = _securityContextFor(rules, allowPinnedCertificateBypass: validatePinnedCertificate); + CertificatePinningException? pinnedCertificateFailure; final task = await io.SecureSocket.startConnect( url.host, _portFor(url), - context: validateTrustedCertificate ? io.SecurityContext(withTrustedRoots: false) : null, - onBadCertificate: validateTrustedCertificate + context: context, + onBadCertificate: validatePinnedCertificate && !rules.any((rule) => rule.hasTrustedCertificates) ? (certificate) { try { - _validator.validateTrustedCertificate( + _validator.validatePinnedCertificate( uri: url, certificateDer: certificate.der, ); return true; } on CertificatePinningException catch (error) { - trustedCertificateFailure = error; + pinnedCertificateFailure = error; return false; } } @@ -77,14 +78,14 @@ class _CertificatePinningConnectionFactory { ); final socket = task.socket.catchError((Object error) { - final failure = trustedCertificateFailure; + final failure = pinnedCertificateFailure; if (failure != null) { throw failure; } throw error; }).then((socket) { - if (validateTrustedCertificate) { - _validator.validateTrustedCertificate( + if (validatePinnedCertificate) { + _validator.validatePinnedCertificate( uri: url, certificateDer: socket.peerCertificate?.der, ); @@ -98,6 +99,23 @@ class _CertificatePinningConnectionFactory { 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'; diff --git a/test/support/certificate_pinning_io_test.dart b/test/support/certificate_pinning_io_test.dart index 9e71a47cb..d8d809e35 100644 --- a/test/support/certificate_pinning_io_test.dart +++ b/test/support/certificate_pinning_io_test.dart @@ -25,7 +25,30 @@ import 'package:livekit_client/src/support/http_client.dart'; import 'package:livekit_client/src/support/websocket.dart'; void main() { - test('allows certificate-byte trust without SPKI pins', () async { + 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'], + pinnedCertificateBytes: [_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); @@ -48,6 +71,29 @@ void main() { 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); @@ -62,7 +108,36 @@ void main() { CertificatePinningRule( hosts: const ['localhost'], primaryPins: const ['sha256/not-the-presented-pin'], - trustedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + pinnedCertificateBytes: [_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)], ), ], ), @@ -91,7 +166,7 @@ void main() { CertificatePinningRule( hosts: const ['localhost'], primaryPins: const ['sha256/not-the-presented-pin'], - trustedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + pinnedCertificateBytes: [_pemBytes(_localhostCertificatePem)], ), ], ), @@ -106,7 +181,7 @@ void main() { expect(server.receivedBytes, isEmpty); }); - test('allows matching certificate-byte trust and SPKI pins together', () async { + test('allows matching pinned leaf certificates and SPKI pins together', () async { final server = await _TlsTestServer.start(); addTearDown(server.close); @@ -119,7 +194,31 @@ void main() { CertificatePinningRule( hosts: const ['localhost'], primaryPins: [certificateSpkiSha256Pin(_certificateDerFromPem(_localhostCertificatePem))], - trustedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + pinnedCertificateBytes: [_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)], ), ], ), @@ -198,56 +297,84 @@ List _certificateDerFromPem(String pem) { 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----- -MIIDPTCCAiWgAwIBAgIUAsxf3tE9w4P9nBBZp+I4U7mFWhowDQYJKoZIhvcNAQEL -BQAwGjEYMBYGA1UEAwwPTGl2ZUtpdCBUZXN0IENBMB4XDTI2MDUwNTAwNDA1MloX -DTM2MDUwMjAwNDA1MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoLdtYxvAcqnaFXMYu/g57Zn2LhTBJBYjJ5UB -aVKcbtk5z0IjC+OJe75x6DcQS+HbH4cHF7FY52CLC2oxUsAIdHmXtN1UHrjIDFBC -nSTwAIpsO9NKdwmRB1cGC8vfwA2gWKaedHDwO9fLk7RC5kxVw23OuOPbdn6cKnkv -U4NZkUULyYk/bk5AFscLFeQkDf/0rAbibG+EKeoJ4VAQB8CYs3OeQm2Sxig7Oy09 -n5KA5+UjxjeVTJzAC0JqqeBs9ISNJ7+vlsfLng/S/xpnnzRkMYuG8sFFseN3pA9Y -Ur/WlgD7fSWKbEOxCsWiFKP0yUq8VpEeBRA48ERp1AHv/Q99pQIDAQABo4GAMH4w -GgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMB -MAsGA1UdDwQEAwIFoDAdBgNVHQ4EFgQULlshilL3OsKKeYGZiv0knrBXr1YwHwYD -VR0jBBgwFoAU+YOp4KUxVvCyeDTLAq2oMvOAdQowDQYJKoZIhvcNAQELBQADggEB -AA06Tu7DQrhoMlpH1GEqnHbaxZXjlp7D6SnJxZ7Sg1iNtolRRKZ0AAhVJ5LaRhiN -M7lmbOpxbI87GxIzI4DkerU4i23tqtrI3/xx2l08FIyl46pFWtHKb8zwAgtigVwO -rIhDsCFSwDP8srWTaVwcazlMDzr8KKB2uHV09aDL+ZI1czSTboPcdsJtQPbElGqe -hEIgiyr6t/CGVUjpERKJCv9CpJ+gjEZMYztseyWbhMLaooURFBhDTyNRCRq85pJ2 -xytNnc8A/nSkIDn2lYHFmeGlhwYrGDcT7itYaVQkgBrSFfmPHH4+/SGduS92qIxg -8lE1W7hFxs9bHcK7ys+1Ggc= +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----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCgt21jG8ByqdoV -cxi7+DntmfYuFMEkFiMnlQFpUpxu2TnPQiML44l7vnHoNxBL4dsfhwcXsVjnYIsL -ajFSwAh0eZe03VQeuMgMUEKdJPAAimw700p3CZEHVwYLy9/ADaBYpp50cPA718uT -tELmTFXDbc6449t2fpwqeS9Tg1mRRQvJiT9uTkAWxwsV5CQN//SsBuJsb4Qp6gnh -UBAHwJizc55CbZLGKDs7LT2fkoDn5SPGN5VMnMALQmqp4Gz0hI0nv6+Wx8ueD9L/ -GmefNGQxi4bywUWx43ekD1hSv9aWAPt9JYpsQ7EKxaIUo/TJSrxWkR4FEDjwRGnU -Ae/9D32lAgMBAAECggEAJjQD/hOaNwd2DjQ6VHBIgNjgwopvcN8MQzvxxnH7OoRL -cB92CjzvsOkP1ZXFO2x4NHHZ90FSc0mpM7DuAZAhUmKW88jK1rSw5PBtLUKbBF3j -JYNvx4UQIvEGQGaZjOMQUxJkRySTjn4Y58bpQioyFs7y3VNYlz24bIY7ADyQXW3t -2WDtKEe4+2FwRciuTSNe7EVtCL+0jsADOTpEwc1SPK5z8wpkCPUUB6LYIiCO480x -3qqY7b9RHRrZveblAP+v/S8KMP37ZMMvvgjC7FsH8MTbtfkxrr4R/fXnPI7sinOT -REZ/+01wPUwxFzde61vMfUKEHA2zd+sILX6RVJNkjwKBgQDgLdnSa6ajJuCLmvvI -NCRuS3fJz9pqomlMsBIadKFJsR13LUgdX/PAgCpruXfQyKbIa251NqDvxBEwmjI8 -ITTOI+BCqUyo9ekXsA842mJ9kBr3QjJmq4jGJPidOqZxw+VEoJ880mZJiiMMJ9Jh -vLtfGUYUZfOth/GySQX3vZ0kJwKBgQC3h34vHMj3r6YnBKQLuiW8IpWyeFXxatWj -22nA0uv4umX7zoD/MQ8ixzCbFELZhuz0IjUrjOV1erffUInzEoR83di29zCjgNN3 -UGIF6A+gUUiF2WEFVLoTBFpoEUj9d9DVjWVTh0GDS3vniEp+Y54yiZ2bWwh4riWC -KxxUOG8zUwKBgCZYcWvGsigyHDKE/hBOqvSawBCrFwcqZKyTaWVREc2TGCEsg6tS -oFULFzZ58P6rc6vQhIJUJ88bUH1pwrH6VBf2lwOQBebYuVgt60ykPjiQD6y/i/N3 -39tUs5nhUFshUPQeLV6v9oMZt8j6fsftCnfH0O7oSXgjSrpeN0EbE+f9AoGAI4gH -1fcssUdAU62CVQLk61eGw9aoTOTyF5cTElHDfZQYyndgYgeNdp45usxhZNvKZDl7 -McNFaUko8AMXsgeTvtj0a/fPYtg+GItnbt1OqSsTb1Z2giG1JJljJ2KxTuEzfSSy -yUkWVeT3SAwK4A1JQ1+BM+Kb8UFF4b2W7nc+kCECgYAfOOOasQEqszqL9w07gpML -Ohuh2z+d6RQWn5zQRBcHWPm6aSF0YvAN4rRdJtS7eS+Bq6D7cxMQwyNr8KdCLA1z -JEZReewjE+u0rN6aFFl5/IGhejlV2LMJ7tRW2jE+RZ2FKX1xGIeemlhfLmmJ/b2c -j9XLpu0FVZrAqZ0LIROzkQ== +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 index 30029e6c3..fc2de9968 100644 --- a/test/support/certificate_pinning_test.dart +++ b/test/support/certificate_pinning_test.dart @@ -33,10 +33,19 @@ void main() { test('computes SHA-256 SPKI certificate pins for a real X.509 certificate', () { expect( certificateSpkiSha256Pin(_realCertificateDer()), - 'sha256/sWFyCMoHOXAfVi8WO1EdoENbDzfweoR9p3XCplWJlA4=', + '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])); @@ -157,24 +166,27 @@ List _certificate(List subjectPublicKeyInfo) { } List _realCertificateDer() => base64Decode(''' -MIIDPTCCAiWgAwIBAgIUAsxf3tE9w4P9nBBZp+I4U7mFWhowDQYJKoZIhvcNAQEL -BQAwGjEYMBYGA1UEAwwPTGl2ZUtpdCBUZXN0IENBMB4XDTI2MDUwNTAwNDA1MloX -DTM2MDUwMjAwNDA1MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoLdtYxvAcqnaFXMYu/g57Zn2LhTBJBYjJ5UB -aVKcbtk5z0IjC+OJe75x6DcQS+HbH4cHF7FY52CLC2oxUsAIdHmXtN1UHrjIDFBC -nSTwAIpsO9NKdwmRB1cGC8vfwA2gWKaedHDwO9fLk7RC5kxVw23OuOPbdn6cKnkv -U4NZkUULyYk/bk5AFscLFeQkDf/0rAbibG+EKeoJ4VAQB8CYs3OeQm2Sxig7Oy09 -n5KA5+UjxjeVTJzAC0JqqeBs9ISNJ7+vlsfLng/S/xpnnzRkMYuG8sFFseN3pA9Y -Ur/WlgD7fSWKbEOxCsWiFKP0yUq8VpEeBRA48ERp1AHv/Q99pQIDAQABo4GAMH4w -GgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMB -MAsGA1UdDwQEAwIFoDAdBgNVHQ4EFgQULlshilL3OsKKeYGZiv0knrBXr1YwHwYD -VR0jBBgwFoAU+YOp4KUxVvCyeDTLAq2oMvOAdQowDQYJKoZIhvcNAQELBQADggEB -AA06Tu7DQrhoMlpH1GEqnHbaxZXjlp7D6SnJxZ7Sg1iNtolRRKZ0AAhVJ5LaRhiN -M7lmbOpxbI87GxIzI4DkerU4i23tqtrI3/xx2l08FIyl46pFWtHKb8zwAgtigVwO -rIhDsCFSwDP8srWTaVwcazlMDzr8KKB2uHV09aDL+ZI1czSTboPcdsJtQPbElGqe -hEIgiyr6t/CGVUjpERKJCv9CpJ+gjEZMYztseyWbhMLaooURFBhDTyNRCRq85pJ2 -xytNnc8A/nSkIDn2lYHFmeGlhwYrGDcT7itYaVQkgBrSFfmPHH4+/SGduS92qIxg -8lE1W7hFxs9bHcK7ys+1Ggc= +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'), '')); From ad01fb3a3ff60f5ea1ded4b30345215cf27906fb Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 14:50:43 +0800 Subject: [PATCH 6/8] Use asn1lib for certificate SPKI parsing --- lib/src/support/certificate_pinning.dart | 128 ++++------------------- pubspec.lock | 8 ++ pubspec.yaml | 1 + 3 files changed, 31 insertions(+), 106 deletions(-) diff --git a/lib/src/support/certificate_pinning.dart b/lib/src/support/certificate_pinning.dart index 46dd489db..357664300 100644 --- a/lib/src/support/certificate_pinning.dart +++ b/lib/src/support/certificate_pinning.dart @@ -15,6 +15,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:asn1lib/asn1lib.dart'; import 'package:crypto/crypto.dart'; import '../exceptions.dart'; @@ -191,122 +192,37 @@ bool _bytesEqual(List a, List b) { } Uint8List _extractSubjectPublicKeyInfo(Uint8List certificateDer) { - final certificateReader = _DerReader(certificateDer); - final certificate = certificateReader.readElement(); - certificate.expectTag(0x30, 'certificate'); - - final certificateContent = _DerReader( - certificateDer, - start: certificate.valueStart, - end: certificate.valueEnd, + final certificate = _asn1Sequence( + ASN1Parser(certificateDer).nextObject(), + 'certificate', ); - final tbsCertificate = certificateContent.readElement(); - tbsCertificate.expectTag(0x30, 'TBSCertificate'); - - final tbsContent = _DerReader( - certificateDer, - start: tbsCertificate.valueStart, - end: tbsCertificate.valueEnd, + final tbsCertificate = _asn1Sequence( + _asn1Element(certificate, 0, 'TBSCertificate'), + 'TBSCertificate', ); - final first = tbsContent.readElement(); - if (first.tag != 0xa0) { - tbsContent.offset = first.start; - } - - for (var i = 0; i < 5; i++) { - tbsContent.readElement(); + var fieldIndex = 0; + if (_asn1Element(tbsCertificate, fieldIndex, 'TBSCertificate first field').tag == 0xa0) { + fieldIndex++; } - final subjectPublicKeyInfo = tbsContent.readElement(); - subjectPublicKeyInfo.expectTag(0x30, 'SubjectPublicKeyInfo'); - return Uint8List.sublistView( - certificateDer, - subjectPublicKeyInfo.start, - subjectPublicKeyInfo.end, + final subjectPublicKeyInfo = _asn1Sequence( + _asn1Element(tbsCertificate, fieldIndex + 5, 'SubjectPublicKeyInfo'), + 'SubjectPublicKeyInfo', ); + return subjectPublicKeyInfo.encodedBytes; } -class _DerReader { - final Uint8List bytes; - final int end; - int offset; - - _DerReader( - this.bytes, { - int start = 0, - int? end, - }) : offset = start, - end = end ?? bytes.length; - - _DerElement readElement() { - final start = offset; - if (start >= end) { - throw const FormatException('Unexpected end of DER data'); - } - - final tag = bytes[offset++]; - if (offset >= end) { - throw const FormatException('Missing DER length'); - } - - final firstLengthByte = bytes[offset++]; - final length = _readLength(firstLengthByte); - final valueStart = offset; - final valueEnd = valueStart + length; - if (valueEnd > end) { - throw const FormatException('DER length exceeds container length'); - } - - offset = valueEnd; - return _DerElement( - tag: tag, - start: start, - valueStart: valueStart, - valueEnd: valueEnd, - ); - } - - int _readLength(int firstLengthByte) { - if ((firstLengthByte & 0x80) == 0) { - return firstLengthByte; - } - - final byteCount = firstLengthByte & 0x7f; - if (byteCount == 0) { - throw const FormatException('Indefinite DER lengths are not supported'); - } - if (byteCount > 4 || offset + byteCount > end) { - throw const FormatException('Invalid DER length'); - } - - var length = 0; - for (var i = 0; i < byteCount; i++) { - length = (length << 8) | bytes[offset++]; - } - return length; +ASN1Object _asn1Element(ASN1Sequence sequence, int index, String name) { + if (index >= sequence.elements.length) { + throw FormatException('Missing $name'); } + return sequence.elements[index]; } -class _DerElement { - final int tag; - final int start; - final int valueStart; - final int valueEnd; - - const _DerElement({ - required this.tag, - required this.start, - required this.valueStart, - required this.valueEnd, - }); - - int get end => valueEnd; - - void expectTag(int expectedTag, String name) { - if (tag != expectedTag) { - throw FormatException( - 'Expected $name DER tag 0x${expectedTag.toRadixString(16)}, got 0x${tag.toRadixString(16)}'); - } +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/pubspec.lock b/pubspec.lock index 06df4a7f2..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: diff --git a/pubspec.yaml b/pubspec.yaml index 630b61b6d..9e4bfb17e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: sdk: flutter flutter: sdk: flutter + asn1lib: ^1.6.5 async: ^2.13.0 collection: ^1.19.1 connectivity_plus: ^7.0.0 From b7befcd0a4f84a4c8b90c0f3a888b994557fa177 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 15:06:19 +0800 Subject: [PATCH 7/8] Clarify certificate pinning rule semantics --- README.md | 18 +++-- lib/src/options.dart | 44 +++++++++---- lib/src/support/certificate_pinning.dart | 6 +- lib/src/support/http_client/io.dart | 18 ++--- test/support/certificate_pinning_io_test.dart | 8 +-- test/support/certificate_pinning_test.dart | 65 +++++++++++++++++++ 6 files changed, 126 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index a3c4b52c1..e8c3b4c9f 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,13 @@ await room.localParticipant.setMicrophoneEnabled(true); 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. -Use SPKI SHA-256 pins when possible. `primaryPins` and `backupPins` are both accepted, which allows key rotation without breaking existing app versions. +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( @@ -245,9 +251,11 @@ openssl s_client -connect your-host:443 -servername your-host /dev/ 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. When multiple checks are configured for a matching host, all of them must pass. +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. -Use `pinnedCertificateBytes` to require an exact peer leaf certificate. This is stricter operationally: renewing the leaf certificate requires shipping updated certificate bytes unless the same certificate remains in use. +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'); @@ -258,7 +266,7 @@ final roomOptions = RoomOptions( rules: [ CertificatePinningRule( hosts: ['my-project.livekit.cloud'], - pinnedCertificateBytes: [certificate.buffer.asUint8List()], + pinnedLeafCertificateBytes: [certificate.buffer.asUint8List()], ), ], ), @@ -266,7 +274,7 @@ final roomOptions = RoomOptions( ); ``` -Use `trustedCertificateBytes` to validate TLS against a custom trust store, similar to `SecurityContext.setTrustedCertificatesBytes`. These bytes can be a leaf, intermediate, or root certificate. +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'); diff --git a/lib/src/options.dart b/lib/src/options.dart index acfc4df2a..ea9c2c44f 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -64,9 +64,17 @@ class NetworkOptions { } /// 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. Every rule whose [CertificatePinningRule.hosts] match the - /// connection host is applied. + /// 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({ @@ -76,11 +84,11 @@ class CertificatePinningOptions { bool get isEnabled => rules.any((rule) => rule.isEnabled); } -/// A set of accepted pins for one or more host patterns. +/// A set of accepted certificate checks for one or more host patterns. /// -/// SPKI pins, exact leaf certificate pins, and trusted certificate bytes are -/// composable. When more than one mode is configured for a matching host, every -/// configured mode must pass. +/// 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. /// @@ -99,9 +107,19 @@ class CertificatePinningRule { /// for certificate rotation. final List backupPins; - /// PEM or DER encoded leaf certificates that must exactly match the peer - /// leaf certificate for matching hosts. - final List> pinnedCertificateBytes; + /// 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. @@ -109,13 +127,15 @@ class CertificatePinningRule { /// 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.pinnedCertificateBytes = const [], + this.pinnedLeafCertificateBytes = const [], this.trustedCertificateBytes = const [], }); @@ -126,11 +146,11 @@ class CertificatePinningRule { bool get hasSpkiPins => allPins.isNotEmpty; - bool get hasPinnedCertificates => pinnedCertificateBytes.isNotEmpty; + bool get hasPinnedLeafCertificates => pinnedLeafCertificateBytes.isNotEmpty; bool get hasTrustedCertificates => trustedCertificateBytes.isNotEmpty; - bool get isEnabled => hasSpkiPins || hasPinnedCertificates || hasTrustedCertificates; + bool get isEnabled => hasSpkiPins || hasPinnedLeafCertificates || hasTrustedCertificates; } /// Options used when connecting to the server. diff --git a/lib/src/support/certificate_pinning.dart b/lib/src/support/certificate_pinning.dart index 357664300..c1257e915 100644 --- a/lib/src/support/certificate_pinning.dart +++ b/lib/src/support/certificate_pinning.dart @@ -73,7 +73,7 @@ class CertificatePinValidator { } } - void validatePinnedCertificate({ + void validatePinnedLeafCertificate({ required Uri uri, required List? certificateDer, }) { @@ -83,8 +83,8 @@ class CertificatePinValidator { final host = uri.host.toLowerCase(); final pinnedCertificates = rulesForHost(host) - .where((rule) => rule.hasPinnedCertificates) - .expand((rule) => rule.pinnedCertificateBytes) + .where((rule) => rule.hasPinnedLeafCertificates) + .expand((rule) => rule.pinnedLeafCertificateBytes) .expand(certificateDerCertificates) .toList(); if (pinnedCertificates.isEmpty) { diff --git a/lib/src/support/http_client/io.dart b/lib/src/support/http_client/io.dart index 0e5397aec..824be1b43 100644 --- a/lib/src/support/http_client/io.dart +++ b/lib/src/support/http_client/io.dart @@ -54,23 +54,23 @@ class _CertificatePinningConnectionFactory { } final rules = _validator.rulesForHost(url.host); - final validatePinnedCertificate = rules.any((rule) => rule.hasPinnedCertificates); - final context = _securityContextFor(rules, allowPinnedCertificateBypass: validatePinnedCertificate); - CertificatePinningException? pinnedCertificateFailure; + 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: validatePinnedCertificate && !rules.any((rule) => rule.hasTrustedCertificates) + onBadCertificate: validatePinnedLeafCertificate && !rules.any((rule) => rule.hasTrustedCertificates) ? (certificate) { try { - _validator.validatePinnedCertificate( + _validator.validatePinnedLeafCertificate( uri: url, certificateDer: certificate.der, ); return true; } on CertificatePinningException catch (error) { - pinnedCertificateFailure = error; + pinnedLeafCertificateFailure = error; return false; } } @@ -78,14 +78,14 @@ class _CertificatePinningConnectionFactory { ); final socket = task.socket.catchError((Object error) { - final failure = pinnedCertificateFailure; + final failure = pinnedLeafCertificateFailure; if (failure != null) { throw failure; } throw error; }).then((socket) { - if (validatePinnedCertificate) { - _validator.validatePinnedCertificate( + if (validatePinnedLeafCertificate) { + _validator.validatePinnedLeafCertificate( uri: url, certificateDer: socket.peerCertificate?.der, ); diff --git a/test/support/certificate_pinning_io_test.dart b/test/support/certificate_pinning_io_test.dart index d8d809e35..8855e1472 100644 --- a/test/support/certificate_pinning_io_test.dart +++ b/test/support/certificate_pinning_io_test.dart @@ -36,7 +36,7 @@ void main() { rules: [ CertificatePinningRule( hosts: const ['localhost'], - pinnedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], ), ], ), @@ -108,7 +108,7 @@ void main() { CertificatePinningRule( hosts: const ['localhost'], primaryPins: const ['sha256/not-the-presented-pin'], - pinnedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], ), ], ), @@ -166,7 +166,7 @@ void main() { CertificatePinningRule( hosts: const ['localhost'], primaryPins: const ['sha256/not-the-presented-pin'], - pinnedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], ), ], ), @@ -194,7 +194,7 @@ void main() { CertificatePinningRule( hosts: const ['localhost'], primaryPins: [certificateSpkiSha256Pin(_certificateDerFromPem(_localhostCertificatePem))], - pinnedCertificateBytes: [_pemBytes(_localhostCertificatePem)], + pinnedLeafCertificateBytes: [_pemBytes(_localhostCertificatePem)], ), ], ), diff --git a/test/support/certificate_pinning_test.dart b/test/support/certificate_pinning_test.dart index fc2de9968..4451f60f2 100644 --- a/test/support/certificate_pinning_test.dart +++ b/test/support/certificate_pinning_test.dart @@ -78,6 +78,71 @@ void main() { ); }); + 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])); From 652143a184bbcb17e6e4d20bc85eb302b5d14edd Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 5 May 2026 15:10:28 +0800 Subject: [PATCH 8/8] changes --- .changes/add-certificate-pinning | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/add-certificate-pinning 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"