From b37576aa249d6e80e2004c6ce9c7b93529c72446 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Tue, 20 Jun 2023 20:33:17 +0800 Subject: [PATCH 1/2] feat: add frame cryptor. --- lib/src/e2ee.worker/crypto.dart | 94 ++++ lib/src/e2ee.worker/e2ee.cryptor.dart | 639 ++++++++++++++++++++++++++ lib/src/e2ee.worker/e2ee.utils.dart | 66 +++ lib/src/e2ee.worker/e2ee.worker.dart | 279 +++++++++++ lib/src/factory_impl.dart | 9 + lib/src/frame_cryptor_impl.dart | 417 +++++++++++++++++ lib/src/rtc_transform_stream.dart | 129 ++++++ 7 files changed, 1633 insertions(+) create mode 100644 lib/src/e2ee.worker/crypto.dart create mode 100644 lib/src/e2ee.worker/e2ee.cryptor.dart create mode 100644 lib/src/e2ee.worker/e2ee.utils.dart create mode 100644 lib/src/e2ee.worker/e2ee.worker.dart create mode 100644 lib/src/frame_cryptor_impl.dart create mode 100644 lib/src/rtc_transform_stream.dart diff --git a/lib/src/e2ee.worker/crypto.dart b/lib/src/e2ee.worker/crypto.dart new file mode 100644 index 0000000..b9769ee --- /dev/null +++ b/lib/src/e2ee.worker/crypto.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:js_util' as jsutil; +import 'dart:html' as html; + +import 'package:js/js.dart'; + +@JS('Promise') +class Promise { + external factory Promise._(); +} + +@JS('Algorithm') +class Algorithm { + external String get name; +} + +@JS('crypto.subtle.encrypt') +external Promise encrypt( + dynamic algorithm, + html.CryptoKey key, + ByteBuffer data, +); + +@JS('crypto.subtle.decrypt') +external Promise decrypt( + dynamic algorithm, + html.CryptoKey key, + ByteBuffer data, +); + +@JS() +@anonymous +class AesGcmParams { + external factory AesGcmParams({ + required String name, + required ByteBuffer iv, + ByteBuffer? additionalData, + int tagLength = 128, + }); +} + +ByteBuffer jsArrayBufferFrom(List data) { + // Avoid copying if possible + if (data is Uint8List && + data.offsetInBytes == 0 && + data.lengthInBytes == data.buffer.lengthInBytes) { + return data.buffer; + } + // Copy + return Uint8List.fromList(data).buffer; +} + +@JS('crypto.subtle.importKey') +external Promise importKey( + String format, + ByteBuffer keyData, + dynamic algorithm, + bool extractable, + List keyUsages, +); + +@JS('crypto.subtle.exportKey') +external Promise exportKey( + String format, + html.CryptoKey key, +); + +@JS('crypto.subtle.deriveKey') +external Promise deriveKey( + dynamic algorithm, + html.CryptoKey baseKey, + dynamic derivedKeyAlgorithm, + bool extractable, + List keyUsages); + +@JS('crypto.subtle.deriveBits') +external Promise deriveBits( + dynamic algorithm, + html.CryptoKey baseKey, + int length, +); + +Future impportKeyFromRawData(List secretKeyData, + {required String webCryptoAlgorithm, + required List keyUsages}) async { + return jsutil.promiseToFuture(importKey( + 'raw', + jsArrayBufferFrom(secretKeyData), + jsutil.jsify({'name': webCryptoAlgorithm}), + false, + keyUsages, + )); +} diff --git a/lib/src/e2ee.worker/e2ee.cryptor.dart b/lib/src/e2ee.worker/e2ee.cryptor.dart new file mode 100644 index 0000000..d0e9d83 --- /dev/null +++ b/lib/src/e2ee.worker/e2ee.cryptor.dart @@ -0,0 +1,639 @@ +import 'dart:html'; +import 'dart:js'; +import 'dart:js_util' as jsutil; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:async'; + +import '../rtc_transform_stream.dart'; +import 'crypto.dart' as crypto; +import 'e2ee.utils.dart'; + +class KeyOptions { + KeyOptions({ + required this.sharedKey, + required this.ratchetSalt, + required this.ratchetWindowSize, + this.uncryptedMagicBytes, + }); + bool sharedKey; + Uint8List ratchetSalt; + int ratchetWindowSize; + Uint8List? uncryptedMagicBytes; + + @override + String toString() { + return 'KeyOptions{sharedKey: $sharedKey, ratchetWindowSize: $ratchetWindowSize}'; + } +} + +const IV_LENGTH = 12; + +const kNaluTypeMask = 0x1f; + +/// Coded slice of a non-IDR picture +const SLICE_NON_IDR = 1; + +/// Coded slice data partition A +const SLICE_PARTITION_A = 2; + +/// Coded slice data partition B +const SLICE_PARTITION_B = 3; + +/// Coded slice data partition C +const SLICE_PARTITION_C = 4; + +/// Coded slice of an IDR picture +const SLICE_IDR = 5; + +/// Supplemental enhancement information +const SEI = 6; + +/// Sequence parameter set +const SPS = 7; + +/// Picture parameter set +const PPS = 8; + +/// Access unit delimiter +const AUD = 9; + +/// End of sequence +const END_SEQ = 10; + +/// End of stream +const END_STREAM = 11; + +/// Filler data +const FILLER_DATA = 12; + +/// Sequence parameter set extension +const SPS_EXT = 13; + +/// Prefix NAL unit +const PREFIX_NALU = 14; + +/// Subset sequence parameter set +const SUBSET_SPS = 15; + +/// Depth parameter set +const DPS = 16; + +// 17, 18 reserved + +/// Coded slice of an auxiliary coded picture without partitioning +const SLICE_AUX = 19; + +/// Coded slice extension +const SLICE_EXT = 20; + +/// Coded slice extension for a depth view component or a 3D-AVC texture view component +const SLICE_LAYER_EXT = 21; + +// 22, 23 reserved + +List findNALUIndices(Uint8List stream) { + var result = []; + var start = 0, pos = 0, searchLength = stream.length - 2; + while (pos < searchLength) { + // skip until end of current NALU + while (pos < searchLength && + !(stream[pos] == 0 && stream[pos + 1] == 0 && stream[pos + 2] == 1)) { + pos++; + } + if (pos >= searchLength) pos = stream.length; + // remove trailing zeros from current NALU + var end = pos; + while (end > start && stream[end - 1] == 0) { + end--; + } + // save current NALU + if (start == 0) { + if (end != start) throw Exception('byte stream contains leading data'); + } else { + result.add(start); + } + // begin new NALU + start = pos = pos + 3; + } + return result; +} + +int parseNALUType(int startByte) { + return startByte & kNaluTypeMask; +} + +enum CryptorError { + kNew, + kOk, + kDecryptError, + kEncryptError, + kUnsupportedCodec, + kMissingKey, + kKeyRatcheted, + kInternalError, + kDisposed, +} + +const KEYRING_SIZE = 16; + +class KeySet { + KeySet(this.material, this.encryptionKey); + CryptoKey material; + CryptoKey encryptionKey; +} + +class FrameCryptor { + FrameCryptor( + {required this.worker, + required this.participantId, + required this.trackId, + required this.keyOptions}); + Map sendCounts = {}; + String? participantId; + String? trackId; + String? codec; + final KeyOptions keyOptions; + late String kind; + bool enabled = false; + CryptorError lastError = CryptorError.kNew; + final DedicatedWorkerGlobalScope worker; + int currentKeyIndex = 0; + + Completer? _ratchetCompleter; + + List cryptoKeyRing = List.filled(KEYRING_SIZE, null); + + Future ratchetKey(int? keyIndex) async { + if (_ratchetCompleter == null) { + _ratchetCompleter = Completer(); + var currentMaterial = getKeySet(keyIndex)?.material; + if (currentMaterial == null) { + _ratchetCompleter!.complete(); + _ratchetCompleter = null; + return; + } + ratchetMaterial(currentMaterial).then((newMaterial) { + deriveKeys(newMaterial, keyOptions.ratchetSalt).then((newKeySet) { + setKeySetFromMaterial(newKeySet, keyIndex ?? currentKeyIndex) + .then((_) { + _ratchetCompleter!.complete(); + _ratchetCompleter = null; + }); + }); + }); + } + + return _ratchetCompleter!.future; + } + + Future ratchetMaterial(CryptoKey currentMaterial) async { + var newMaterial = await jsutil.promiseToFuture(crypto.importKey( + 'raw', + crypto.jsArrayBufferFrom( + await ratchet(currentMaterial, keyOptions.ratchetSalt)), + (currentMaterial.algorithm as crypto.Algorithm).name, + false, + ['deriveBits', 'deriveKey'], + )); + return newMaterial; + } + + KeySet? getKeySet(int? keyIndex) { + return cryptoKeyRing[keyIndex ?? currentKeyIndex]; + } + + void setParticipantId(String participantId) { + if (lastError != CryptorError.kOk) { + print( + 'setParticipantId: lastError != CryptorError.kOk, reset state to kNew'); + lastError = CryptorError.kNew; + } + this.participantId = participantId; + } + + void setKeyIndex(int keyIndex) { + if (lastError != CryptorError.kOk) { + print('setKeyIndex: lastError != CryptorError.kOk, reset state to kNew'); + lastError = CryptorError.kNew; + } + currentKeyIndex = keyIndex; + } + + void setEnabled(bool enabled) { + if (lastError != CryptorError.kOk) { + print( + 'setEnabled[$enabled]: lastError != CryptorError.kOk, reset state to kNew'); + lastError = CryptorError.kNew; + } + this.enabled = enabled; + } + + Future setKey(int keyIndex, Uint8List key) async { + if (lastError != CryptorError.kOk) { + print('setKey: lastError != CryptorError.kOk, reset state to kNew'); + lastError = CryptorError.kNew; + } + var keyMaterial = await crypto.impportKeyFromRawData(key, + webCryptoAlgorithm: 'PBKDF2', keyUsages: ['deriveBits', 'deriveKey']); + var keySet = await deriveKeys( + keyMaterial, + keyOptions.ratchetSalt, + ); + await setKeySetFromMaterial(keySet, keyIndex); + } + + Future setKeySetFromMaterial(KeySet keySet, int keyIndex) async { + print('setting new key'); + if (keyIndex >= 0) { + currentKeyIndex = keyIndex % cryptoKeyRing.length; + } + cryptoKeyRing[currentKeyIndex] = keySet; + } + + /// Derives a set of keys from the master key. + /// See https://tools.ietf.org/html/draft-omara-sframe-00#section-4.3.1 + Future deriveKeys(CryptoKey material, Uint8List salt) async { + var algorithmOptions = + getAlgoOptions((material.algorithm as crypto.Algorithm).name, salt); + + // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#HKDF + // https://developer.mozilla.org/en-US/docs/Web/API/HkdfParams + var encryptionKey = + await jsutil.promiseToFuture(crypto.deriveKey( + jsutil.jsify(algorithmOptions), + material, + jsutil.jsify({'name': 'AES-GCM', 'length': 128}), + false, + ['encrypt', 'decrypt'], + )); + + return KeySet(material, encryptionKey); + } + + /// Ratchets a key. See + /// https://tools.ietf.org/html/draft-omara-sframe-00#section-4.3.5.1 + + Future ratchet(CryptoKey material, Uint8List salt) async { + var algorithmOptions = getAlgoOptions('PBKDF2', salt); + + // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveBits + var newKey = await jsutil.promiseToFuture( + crypto.deriveBits(jsutil.jsify(algorithmOptions), material, 256)); + return newKey.asUint8List(); + } + + void updateCodec(String codec) { + if (lastError != CryptorError.kOk) { + print( + 'updateCodec[$codec]: lastError != CryptorError.kOk, reset state to kNew'); + lastError = CryptorError.kNew; + } + this.codec = codec; + } + + Uint8List makeIv( + {required int synchronizationSource, required int timestamp}) { + var iv = ByteData(IV_LENGTH); + + // having to keep our own send count (similar to a picture id) is not ideal. + if (sendCounts[synchronizationSource] == null) { + // Initialize with a random offset, similar to the RTP sequence number. + sendCounts[synchronizationSource] = Random.secure().nextInt(0xffff); + } + + var sendCount = sendCounts[synchronizationSource] ?? 0; + + iv.setUint32(0, synchronizationSource); + iv.setUint32(4, timestamp); + iv.setUint32(8, timestamp - (sendCount % 0xffff)); + + sendCounts[synchronizationSource] = sendCount + 1; + + return iv.buffer.asUint8List(); + } + + void postMessage(Object message) { + worker.postMessage(message); + } + + Future setupTransform({ + required String operation, + required ReadableStream readable, + required WritableStream writable, + required String trackId, + required String kind, + String? codec, + }) async { + print('setupTransform $operation'); + this.kind = kind; + if (codec != null) { + print('setting codec on cryptor to $codec'); + this.codec = codec; + } + var transformer = TransformStream(jsutil.jsify({ + 'transform': + allowInterop(operation == 'encode' ? encodeFunction : decodeFunction) + })); + try { + readable.pipeThrough(transformer).pipeTo(writable); + } catch (e) { + print('e ${e.toString()}'); + if (lastError != CryptorError.kInternalError) { + lastError = CryptorError.kInternalError; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'state': 'internalError', + 'error': 'Internal error: ${e.toString()}' + }); + } + } + this.trackId = trackId; + } + + int getUnencryptedBytes(RTCEncodedFrame frame, String? codec) { + if (codec != null && codec.toLowerCase() == 'h264') { + var data = frame.data.asUint8List(); + var naluIndices = findNALUIndices(data); + for (var index in naluIndices) { + var type = parseNALUType(data[index]); + switch (type) { + case SLICE_IDR: + case SLICE_NON_IDR: + // skipping + //print('unEncryptedBytes NALU of type $type, offset ${index + 2}'); + return index + 2; + default: + //print('skipping NALU of type $type'); + break; + } + } + throw Exception('Could not find NALU'); + } + switch (frame.type) { + case 'key': + return 10; + case 'delta': + return 3; + case 'audio': + return 1; // frame.type is not set on audio, so this is set manually + default: + return 0; + } + } + + Future encodeFunction( + RTCEncodedFrame frame, + TransformStreamDefaultController controller, + ) async { + var buffer = frame.data.asUint8List(); + + if (!enabled || + // skip for encryption for empty dtx frames + buffer.isEmpty) { + controller.enqueue(frame); + return; + } + + var secretKey = getKeySet(currentKeyIndex)?.encryptionKey; + var keyIndex = currentKeyIndex; + + if (secretKey == null) { + if (lastError != CryptorError.kMissingKey) { + lastError = CryptorError.kMissingKey; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'trackId': trackId, + 'kind': kind, + 'state': 'missingKey', + 'error': 'Missing key for track $trackId', + }); + } + return; + } + + try { + var headerLength = + kind == 'video' ? getUnencryptedBytes(frame, codec) : 1; + var metaData = frame.getMetadata(); + var iv = makeIv( + synchronizationSource: metaData.synchronizationSource, + timestamp: frame.timestamp); + + var frameTrailer = ByteData(2); + frameTrailer.setInt8(0, IV_LENGTH); + frameTrailer.setInt8(1, keyIndex); + + var cipherText = await jsutil.promiseToFuture(crypto.encrypt( + crypto.AesGcmParams( + name: 'AES-GCM', + iv: crypto.jsArrayBufferFrom(iv), + additionalData: + crypto.jsArrayBufferFrom(buffer.sublist(0, headerLength)), + ), + secretKey, + crypto.jsArrayBufferFrom(buffer.sublist(headerLength, buffer.length)), + )); + + //print( + // 'buffer: ${buffer.length}, cipherText: ${cipherText.asUint8List().length}'); + var finalBuffer = BytesBuilder(); + + finalBuffer.add(Uint8List.fromList(buffer.sublist(0, headerLength))); + finalBuffer.add(cipherText.asUint8List()); + finalBuffer.add(iv); + finalBuffer.add(frameTrailer.buffer.asUint8List()); + frame.data = crypto.jsArrayBufferFrom(finalBuffer.toBytes()); + + controller.enqueue(frame); + + if (lastError != CryptorError.kOk) { + lastError = CryptorError.kOk; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'trackId': trackId, + 'kind': kind, + 'state': 'ok', + 'error': 'encryption ok' + }); + } + + //print( + // 'encrypto kind $kind,codec $codec headerLength: $headerLength, timestamp: ${frame.timestamp}, ssrc: ${metaData.synchronizationSource}, data length: ${buffer.length}, encrypted length: ${finalBuffer.toBytes().length}, key ${secretKey.toString()} , iv $iv'); + } catch (e) { + //print('encrypt: e ${e.toString()}'); + if (lastError != CryptorError.kEncryptError) { + lastError = CryptorError.kEncryptError; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'trackId': trackId, + 'kind': kind, + 'state': 'encryptError', + 'error': e.toString() + }); + } + } + } + + Future decodeFunction( + RTCEncodedFrame frame, + TransformStreamDefaultController controller, + ) async { + var ratchetCount = 0; + var buffer = frame.data.asUint8List(); + ByteBuffer? decrypted; + KeySet? initialKeySet; + int initialKeyIndex = currentKeyIndex; + + if (!enabled || + // skip for encryption for empty dtx frames + buffer.isEmpty) { + controller.enqueue(frame); + return; + } + + if (keyOptions.uncryptedMagicBytes != null) { + var magicBytes = keyOptions.uncryptedMagicBytes!; + if (buffer.length >= magicBytes.length + 1) { + var magicBytesBuffer = buffer.sublist( + buffer.length - (magicBytes.length + 1), magicBytes.length); + if (magicBytesBuffer.toString() == magicBytes.toString()) { + var finalBuffer = BytesBuilder(); + finalBuffer.add(Uint8List.fromList( + buffer.sublist(0, buffer.length - (magicBytes.length + 1)))); + frame.data = crypto.jsArrayBufferFrom(finalBuffer.toBytes()); + controller.enqueue(frame); + return; + } + } + } + + try { + var headerLength = + kind == 'video' ? getUnencryptedBytes(frame, codec) : 1; + var metaData = frame.getMetadata(); + + var frameTrailer = buffer.sublist(buffer.length - 2); + var ivLength = frameTrailer[0]; + var keyIndex = frameTrailer[1]; + var iv = buffer.sublist(buffer.length - ivLength - 2, buffer.length - 2); + + var initialKeySet = getKeySet(keyIndex); + initialKeyIndex = keyIndex; + + if (initialKeySet == null) { + if (lastError != CryptorError.kMissingKey) { + lastError = CryptorError.kMissingKey; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'trackId': trackId, + 'kind': kind, + 'state': 'missingKey', + 'error': 'Missing key for track $trackId' + }); + } + controller.enqueue(frame); + return; + } + bool endDecLoop = false; + KeySet currentkeySet = initialKeySet; + while (!endDecLoop) { + try { + decrypted = await jsutil.promiseToFuture(crypto.decrypt( + crypto.AesGcmParams( + name: 'AES-GCM', + iv: crypto.jsArrayBufferFrom(iv), + additionalData: + crypto.jsArrayBufferFrom(buffer.sublist(0, headerLength)), + ), + currentkeySet.encryptionKey, + crypto.jsArrayBufferFrom( + buffer.sublist(headerLength, buffer.length - ivLength - 2)), + )); + + if (currentkeySet != initialKeySet) { + await setKeySetFromMaterial(currentkeySet, initialKeyIndex); + } + + endDecLoop = true; + + if (lastError != CryptorError.kOk && + lastError != CryptorError.kKeyRatcheted && + ratchetCount > 0) { + print( + 'KeyRatcheted: ssrc ${metaData.synchronizationSource} timestamp ${frame.timestamp} ratchetCount $ratchetCount participantId: $participantId'); + print( + 'ratchetKey: lastError != CryptorError.kKeyRatcheted, reset state to kKeyRatcheted'); + + lastError = CryptorError.kKeyRatcheted; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'trackId': trackId, + 'kind': kind, + 'state': 'keyRatcheted', + 'error': 'Key ratcheted ok' + }); + } + } catch (e) { + lastError = CryptorError.kInternalError; + endDecLoop = ratchetCount >= keyOptions.ratchetWindowSize || + keyOptions.ratchetWindowSize <= 0; + if (endDecLoop) { + rethrow; + } + var newMaterial = await ratchetMaterial(currentkeySet.material); + currentkeySet = await deriveKeys(newMaterial, keyOptions.ratchetSalt); + ratchetCount++; + } + } + + //print( + // 'buffer: ${buffer.length}, decrypted: ${decrypted.asUint8List().length}'); + var finalBuffer = BytesBuilder(); + + finalBuffer.add(Uint8List.fromList(buffer.sublist(0, headerLength))); + finalBuffer.add(decrypted!.asUint8List()); + frame.data = crypto.jsArrayBufferFrom(finalBuffer.toBytes()); + controller.enqueue(frame); + + if (lastError != CryptorError.kOk) { + lastError = CryptorError.kOk; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'trackId': trackId, + 'kind': kind, + 'state': 'ok', + 'error': 'decryption ok' + }); + } + + //print( + // 'decrypto kind $kind,codec $codec headerLength: $headerLength, timestamp: ${frame.timestamp}, ssrc: ${metaData.synchronizationSource}, data length: ${buffer.length}, decrypted length: ${finalBuffer.toBytes().length}, key ${secretKey.toString()}, keyindex $keyIndex iv $iv'); + } catch (e) { + if (lastError != CryptorError.kDecryptError) { + lastError = CryptorError.kDecryptError; + postMessage({ + 'type': 'cryptorState', + 'participantId': participantId, + 'trackId': trackId, + 'kind': kind, + 'state': 'decryptError', + 'error': e.toString() + }); + } + + /// Since the key it is first send and only afterwards actually used for encrypting, there were + /// situations when the decrypting failed due to the fact that the received frame was not encrypted + /// yet and ratcheting, of course, did not solve the problem. So if we fail RATCHET_WINDOW_SIZE times, + /// we come back to the initial key. + await setKeySetFromMaterial(initialKeySet!, initialKeyIndex); + } + } +} diff --git a/lib/src/e2ee.worker/e2ee.utils.dart b/lib/src/e2ee.worker/e2ee.utils.dart new file mode 100644 index 0000000..a2b6bfe --- /dev/null +++ b/lib/src/e2ee.worker/e2ee.utils.dart @@ -0,0 +1,66 @@ +import 'dart:html'; +import 'dart:js' as js; +import 'dart:js_util'; +import 'dart:typed_data'; + +import 'crypto.dart' as crypto; + +bool isE2EESupported() { + return isInsertableStreamSupported() || isScriptTransformSupported(); +} + +bool isScriptTransformSupported() { + return js.context['RTCRtpScriptTransform'] != null; +} + +bool isInsertableStreamSupported() { + return js.context['RTCRtpSender'] != null && + js.context['RTCRtpSender']['prototype']['createEncodedStreams'] != null; +} + +Future importKey( + Uint8List keyBytes, String algorithm, String usage) { + // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey + return promiseToFuture(crypto.importKey( + 'raw', + crypto.jsArrayBufferFrom(keyBytes), + js.JsObject.jsify({'name': algorithm}), + false, + usage == 'derive' ? ['deriveBits', 'deriveKey'] : ['encrypt', 'decrypt'], + )); +} + +Future createKeyMaterialFromString( + Uint8List keyBytes, String algorithm, String usage) { + // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey + return promiseToFuture(crypto.importKey( + 'raw', + crypto.jsArrayBufferFrom(keyBytes), + js.JsObject.jsify({'name': 'PBKDF2'}), + false, + ['deriveBits', 'deriveKey'], + )); +} + +dynamic getAlgoOptions(String algorithmName, Uint8List salt) { + switch (algorithmName) { + case 'HKDF': + return { + 'name': 'HKDF', + 'salt': crypto.jsArrayBufferFrom(salt), + 'hash': 'SHA-256', + 'info': crypto.jsArrayBufferFrom(Uint8List(128)), + }; + case 'PBKDF2': + { + return { + 'name': 'PBKDF2', + 'salt': crypto.jsArrayBufferFrom(salt), + 'hash': 'SHA-256', + 'iterations': 100000, + }; + } + default: + throw Exception('algorithm $algorithmName is currently unsupported'); + } +} diff --git a/lib/src/e2ee.worker/e2ee.worker.dart b/lib/src/e2ee.worker/e2ee.worker.dart new file mode 100644 index 0000000..16d7730 --- /dev/null +++ b/lib/src/e2ee.worker/e2ee.worker.dart @@ -0,0 +1,279 @@ +import 'dart:convert'; +import 'dart:html' as html; +import 'dart:js_util' as js_util; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:js/js.dart'; + +import '../rtc_transform_stream.dart'; +import 'e2ee.cryptor.dart'; + +@JS() +abstract class TransformMessage { + external String get msgType; + external String get kind; +} + +@anonymous +@JS() +class EnableTransformMessage { + external factory EnableTransformMessage({ + ReadableStream readable, + WritableStream writable, + String msgType, + String kind, + String participantId, + String trackId, + String codec, + }); + external ReadableStream get readable; + external WritableStream get writable; + external String get msgType; // 'encode' or 'decode' + external String get participantId; + external String get trackId; + external String get kind; + external String get codec; +} + +@anonymous +@JS() +class RemoveTransformMessage { + external factory RemoveTransformMessage( + {String msgType, String participantId, String trackId}); + external String get msgType; // 'removeTransform' + external String get participantId; + external String get trackId; +} + +@JS('self') +external html.DedicatedWorkerGlobalScope get self; + +extension PropsRTCTransformEventHandler on html.DedicatedWorkerGlobalScope { + set onrtctransform(Function(dynamic) callback) => + js_util.setProperty(this, 'onrtctransform', callback); +} + +var participantCryptors = []; +var publisherKeys = {}; +bool isEncryptionEnabled = false; + +KeyOptions keyProviderOptions = KeyOptions( + sharedKey: true, + ratchetSalt: Uint8List.fromList('ratchetSalt'.codeUnits), + ratchetWindowSize: 16); + +void main() async { + print('E2EE Worker created'); + + if (js_util.getProperty(self, 'RTCTransformEvent') != null) { + print('setup transform event handler'); + self.onrtctransform = allowInterop((event) { + print('got transform event'); + var transformer = (event as RTCTransformEvent).transformer; + transformer.handled = true; + var options = transformer.options; + var kind = options.kind; + var participantId = options.participantId; + var trackId = options.trackId; + var codec = options.codec; + var msgType = options.msgType; + + var cryptor = + participantCryptors.firstWhereOrNull((c) => c.trackId == trackId); + + if (cryptor == null) { + cryptor = FrameCryptor( + worker: self, + participantId: participantId, + trackId: trackId, + keyOptions: keyProviderOptions, + ); + participantCryptors.add(cryptor); + } + + cryptor.setupTransform( + operation: msgType, + readable: transformer.readable, + writable: transformer.writable, + trackId: trackId, + kind: kind, + codec: codec); + }); + } + + self.onMessage.listen((e) { + var msg = e.data; + var msgType = msg['msgType']; + switch (msgType) { + case 'init': + var options = msg['keyOptions']; + keyProviderOptions = KeyOptions( + sharedKey: options['sharedKey'], + ratchetSalt: Uint8List.fromList( + base64Decode(options['ratchetSalt'] as String)), + ratchetWindowSize: options['ratchetWindowSize'], + uncryptedMagicBytes: options['ratchetSalt'] != null + ? Uint8List.fromList( + base64Decode(options['uncryptedMagicBytes'] as String)) + : null); + print('worker: init with keyOptions ${keyProviderOptions.toString()}'); + break; + case 'enable': + { + var enabled = msg['enabled'] as bool; + var participantId = msg['participantId'] as String; + print('worker: set enable $enabled for participantId $participantId'); + var cryptors = participantCryptors + .where((c) => c.participantId == participantId) + .toList(); + for (var cryptor in cryptors) { + cryptor.setEnabled(enabled); + } + self.postMessage({ + 'type': 'cryptorEnabled', + 'participantId': participantId, + 'enable': enabled, + }); + } + break; + case 'decode': + case 'encode': + { + var kind = msg['kind']; + var exist = msg['exist'] as bool; + var participantId = msg['participantId'] as String; + var trackId = msg['trackId']; + var readable = msg['readableStream'] as ReadableStream; + var writable = msg['writableStream'] as WritableStream; + + print( + 'worker: got $msgType, kind $kind, trackId $trackId, participantId $participantId, ${readable.runtimeType} ${writable.runtimeType}}'); + var cryptor = + participantCryptors.firstWhereOrNull((c) => c.trackId == trackId); + + if (cryptor == null) { + cryptor = FrameCryptor( + worker: self, + participantId: participantId, + trackId: trackId, + keyOptions: keyProviderOptions); + participantCryptors.add(cryptor); + } + + if (!exist) { + cryptor.setupTransform( + operation: msgType, + readable: readable, + writable: writable, + trackId: trackId, + kind: kind); + } + cryptor.setParticipantId(participantId); + self.postMessage({ + 'type': 'cryptorSetup', + 'participantId': participantId, + 'trackId': trackId, + 'exist': exist, + 'operation': msgType, + }); + cryptor.lastError = CryptorError.kNew; + } + break; + case 'removeTransform': + { + var trackId = msg['trackId'] as String; + print('worker: removing trackId $trackId'); + participantCryptors.removeWhere((c) => c.trackId == trackId); + } + break; + case 'setKey': + { + var key = Uint8List.fromList(base64Decode(msg['key'] as String)); + var keyIndex = msg['keyIndex']; + //print('worker: got setKey ${msg['key']}, key $key'); + var participantId = msg['participantId'] as String; + print('worker: setup key for participant $participantId'); + + if (keyProviderOptions.sharedKey) { + for (var c in participantCryptors) { + c.setKey(keyIndex, key); + } + return; + } + var cryptors = participantCryptors + .where((c) => c.participantId == participantId) + .toList(); + for (var c in cryptors) { + c.setKey(keyIndex, key); + } + } + break; + case 'ratchetKey': + { + var keyIndex = msg['keyIndex']; + var participantId = msg['participantId'] as String; + print( + 'worker: ratchetKey for participant $participantId, keyIndex $keyIndex'); + var cryptors = participantCryptors + .where((c) => c.participantId == participantId) + .toList(); + for (var c in cryptors) { + var keySet = c.getKeySet(keyIndex); + c.ratchetKey(keyIndex).then((_) async { + var newKey = await c.ratchet( + keySet!.material, keyProviderOptions.ratchetSalt); + self.postMessage({ + 'type': 'ratchetKey', + 'participantId': participantId, + 'trackId': c.trackId, + 'key': base64Encode(newKey), + }); + }); + } + } + break; + case 'setKeyIndex': + { + var keyIndex = msg['index']; + var participantId = msg['participantId'] as String; + print('worker: setup key index for participant $participantId'); + var cryptors = participantCryptors + .where((c) => c.participantId == participantId) + .toList(); + for (var c in cryptors) { + c.setKeyIndex(keyIndex); + } + } + break; + case 'updateCodec': + { + var codec = msg['codec'] as String; + var trackId = msg['trackId'] as String; + print('worker: update codec for trackId $trackId, codec $codec'); + var cryptor = + participantCryptors.firstWhereOrNull((c) => c.trackId == trackId); + cryptor?.updateCodec(codec); + } + break; + case 'dispose': + { + var trackId = msg['trackId'] as String; + print('worker: dispose trackId $trackId'); + var cryptor = + participantCryptors.firstWhereOrNull((c) => c.trackId == trackId); + if (cryptor != null) { + cryptor.lastError = CryptorError.kDisposed; + self.postMessage({ + 'type': 'cryptorDispose', + 'participantId': cryptor.participantId, + 'trackId': trackId, + }); + } + } + break; + default: + print('worker: unknown message kind $msg'); + } + }); +} diff --git a/lib/src/factory_impl.dart b/lib/src/factory_impl.dart index 78e9e7c..52ff7d8 100644 --- a/lib/src/factory_impl.dart +++ b/lib/src/factory_impl.dart @@ -4,6 +4,7 @@ import 'dart:html' as html; import 'package:js/js.dart'; import 'package:webrtc_interface/webrtc_interface.dart'; +import 'frame_cryptor_impl.dart'; import 'media_recorder_impl.dart'; import 'media_stream_impl.dart'; import 'navigator_impl.dart'; @@ -62,6 +63,10 @@ class RTCFactoryWeb extends RTCFactory { @override Navigator get navigator => NavigatorWeb(); + @override + FrameCryptorFactory get frameCryptorFactory => + FrameCryptorFactoryImpl.instance; + @override Future getRtpReceiverCapabilities(String kind) async { var caps = RTCRtpReceiverJs.getCapabilities(kind); @@ -103,3 +108,7 @@ VideoRenderer videoRenderer() { } Navigator get navigator => RTCFactoryWeb.instance.navigator; + +FrameCryptorFactory get frameCryptorFactory => FrameCryptorFactoryImpl.instance; + +MediaDevices get mediaDevices => navigator.mediaDevices; diff --git a/lib/src/frame_cryptor_impl.dart b/lib/src/frame_cryptor_impl.dart new file mode 100644 index 0000000..6e6d184 --- /dev/null +++ b/lib/src/frame_cryptor_impl.dart @@ -0,0 +1,417 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:html' as html; +import 'dart:js' as js; +import 'dart:js_util' as jsutil; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:dart_webrtc/src/rtc_rtp_receiver_impl.dart'; +import 'package:dart_webrtc/src/rtc_rtp_sender_impl.dart'; +import 'package:webrtc_interface/webrtc_interface.dart'; + +import 'rtc_transform_stream.dart'; + +// ignore: implementation_imports +// ignore: implementation_imports + +extension RtcRtpReceiverExt on html.RtcRtpReceiver { + static Map readableStreams_ = {}; + static Map writableStreams_ = {}; + + ReadableStream? get readable { + if (readableStreams_.containsKey(hashCode)) { + return readableStreams_[hashCode]!; + } + return null; + } + + WritableStream? get writable { + if (writableStreams_.containsKey(hashCode)) { + return writableStreams_[hashCode]!; + } + return null; + } + + set readableStream(ReadableStream stream) { + readableStreams_[hashCode] = stream; + } + + set writableStream(WritableStream stream) { + writableStreams_[hashCode] = stream; + } + + void closeStreams() { + readableStreams_.remove(hashCode); + writableStreams_.remove(hashCode); + } +} + +extension RtcRtpSenderExt on html.RtcRtpSender { + static Map readableStreams_ = {}; + static Map writableStreams_ = {}; + + ReadableStream? get readable { + if (readableStreams_.containsKey(hashCode)) { + return readableStreams_[hashCode]!; + } + return null; + } + + WritableStream? get writable { + if (writableStreams_.containsKey(hashCode)) { + return writableStreams_[hashCode]!; + } + return null; + } + + set readableStream(ReadableStream stream) { + readableStreams_[hashCode] = stream; + } + + set writableStream(WritableStream stream) { + writableStreams_[hashCode] = stream; + } + + void closeStreams() { + readableStreams_.remove(hashCode); + writableStreams_.remove(hashCode); + } +} + +class FrameCryptorImpl extends FrameCryptor { + FrameCryptorImpl( + this._factory, this.worker, this._participantId, this._trackId, + {this.jsSender, this.jsReceiver, required this.keyProvider}); + html.Worker worker; + bool _enabled = false; + int _keyIndex = 0; + final String _participantId; + final String _trackId; + final html.RtcRtpSender? jsSender; + final html.RtcRtpReceiver? jsReceiver; + final FrameCryptorFactoryImpl _factory; + final KeyProviderImpl keyProvider; + + @override + Future dispose() async { + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'dispose', + 'trackId': _trackId, + }) + ]); + _factory.removeFrameCryptor(_trackId); + return; + } + + @override + Future get enabled => Future(() => _enabled); + + @override + Future get keyIndex => Future(() => _keyIndex); + + @override + String get participantId => _participantId; + + String get trackId => _trackId; + + @override + Future setEnabled(bool enabled) async { + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'enable', + 'participantId': participantId, + 'enabled': enabled + }) + ]); + _enabled = enabled; + return true; + } + + @override + Future setKeyIndex(int index) async { + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'setKeyIndex', + 'participantId': participantId, + 'index': index, + }) + ]); + _keyIndex = index; + return true; + } + + @override + Future updateCodec(String codec) async { + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'updateCodec', + 'trackId': _trackId, + 'codec': codec, + }) + ]); + } +} + +class KeyProviderImpl implements KeyProvider { + KeyProviderImpl(this._id, this.worker, this.options); + final String _id; + final html.Worker worker; + final KeyProviderOptions options; + final Map> _keys = {}; + + @override + String get id => _id; + + Future init() async { + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'init', + 'id': id, + 'keyOptions': { + 'sharedKey': options.sharedKey, + 'ratchetSalt': base64Encode(options.ratchetSalt), + 'ratchetWindowSize': options.ratchetWindowSize, + if (options.uncryptedMagicBytes != null) + 'uncryptedMagicBytes': base64Encode(options.uncryptedMagicBytes!), + }, + }) + ]); + } + + @override + Future dispose() { + return Future.value(); + } + + @override + Future setKey( + {required String participantId, + required int index, + required Uint8List key}) async { + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'setKey', + 'participantId': participantId, + 'keyIndex': index, + 'key': base64Encode(key), + }) + ]); + _keys[participantId] ??= []; + if (_keys[participantId]!.length <= index) { + _keys[participantId]!.add(key); + } else { + _keys[participantId]![index] = key; + } + return true; + } + + Completer? _ratchetKeyCompleter; + + void onRatchetKey(Uint8List key) { + if (_ratchetKeyCompleter != null) { + _ratchetKeyCompleter!.complete(key); + _ratchetKeyCompleter = null; + } + } + + @override + Future ratchetKey( + {required String participantId, required int index}) async { + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'ratchetKey', + 'participantId': participantId, + 'keyIndex': index, + }) + ]); + + _ratchetKeyCompleter ??= Completer(); + + return _ratchetKeyCompleter!.future; + } +} + +class FrameCryptorFactoryImpl implements FrameCryptorFactory { + FrameCryptorFactoryImpl._internal() { + worker = html.Worker('e2ee.worker.dart.js'); + worker.onMessage.listen((msg) { + print('master got ${msg.data}'); + var type = msg.data['type']; + if (type == 'cryptorState') { + var trackId = msg.data['trackId']; + var participantId = msg.data['participantId']; + var frameCryptor = _frameCryptors.values.firstWhereOrNull( + (element) => (element as FrameCryptorImpl).trackId == trackId); + var state = msg.data['state']; + var frameCryptorState = FrameCryptorState.FrameCryptorStateNew; + switch (state) { + case 'ok': + frameCryptorState = FrameCryptorState.FrameCryptorStateOk; + break; + case 'decryptError': + frameCryptorState = + FrameCryptorState.FrameCryptorStateDecryptionFailed; + break; + case 'encryptError': + frameCryptorState = + FrameCryptorState.FrameCryptorStateEncryptionFailed; + break; + case 'missingKey': + frameCryptorState = FrameCryptorState.FrameCryptorStateMissingKey; + break; + case 'internalError': + frameCryptorState = + FrameCryptorState.FrameCryptorStateInternalError; + break; + case 'keyRatcheted': + frameCryptorState = FrameCryptorState.FrameCryptorStateKeyRatcheted; + break; + } + frameCryptor?.onFrameCryptorStateChanged + ?.call(participantId, frameCryptorState); + } else if (type == 'ratchetKey') { + var trackId = msg.data['trackId']; + var frameCryptor = _frameCryptors.values.firstWhereOrNull( + (element) => (element as FrameCryptorImpl).trackId == trackId); + if (frameCryptor != null) { + (frameCryptor as FrameCryptorImpl) + .keyProvider + .onRatchetKey(base64Decode(msg.data['key'])); + } + } + }); + worker.onError.listen((err) { + print('worker error: $err'); + }); + } + + static final FrameCryptorFactoryImpl instance = + FrameCryptorFactoryImpl._internal(); + + late html.Worker worker; + final Map _frameCryptors = {}; + + @override + Future createDefaultKeyProvider( + KeyProviderOptions options) async { + var keyProvider = KeyProviderImpl('default', worker, options); + await keyProvider.init(); + return keyProvider; + } + + @override + Future createFrameCryptorForRtpReceiver( + {required String participantId, + required RTCRtpReceiver receiver, + required Algorithm algorithm, + required KeyProvider keyProvider}) { + var jsReceiver = (receiver as RTCRtpReceiverWeb).jsRtpReceiver; + + var trackId = jsReceiver.hashCode.toString(); + var kind = jsReceiver.track!.kind!; + + if (js.context['RTCRtpScriptTransform'] != null) { + print('support RTCRtpScriptTransform'); + var options = { + 'msgType': 'decode', + 'kind': kind, + 'participantId': participantId, + 'trackId': trackId, + }; + jsutil.setProperty(jsReceiver, 'transform', + RTCRtpScriptTransform(worker, jsutil.jsify(options))); + } else { + var writable = jsReceiver.writable; + var readable = jsReceiver.readable; + var exist = true; + if (writable == null || readable == null) { + EncodedStreams streams = + jsutil.callMethod(jsReceiver, 'createEncodedStreams', []); + readable = streams.readable; + jsReceiver.readableStream = readable; + writable = streams.writable; + jsReceiver.writableStream = writable; + exist = false; + } + + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'decode', + 'kind': kind, + 'exist': exist, + 'participantId': participantId, + 'trackId': trackId, + 'readableStream': readable, + 'writableStream': writable + }), + jsutil.jsify([readable, writable]), + ]); + } + FrameCryptor cryptor = FrameCryptorImpl( + this, worker, participantId, trackId, + jsReceiver: jsReceiver, keyProvider: keyProvider as KeyProviderImpl); + _frameCryptors[trackId] = cryptor; + return Future.value(cryptor); + } + + @override + Future createFrameCryptorForRtpSender( + {required String participantId, + required RTCRtpSender sender, + required Algorithm algorithm, + required KeyProvider keyProvider}) { + var jsSender = (sender as RTCRtpSenderWeb).jsRtpSender; + var trackId = jsSender.hashCode.toString(); + var kind = jsSender.track!.kind!; + + if (js.context['RTCRtpScriptTransform'] != null) { + print('support RTCRtpScriptTransform'); + var options = { + 'msgType': 'encode', + 'kind': kind, + 'participantId': participantId, + 'trackId': trackId, + 'options': (keyProvider as KeyProviderImpl).options.toJson(), + }; + jsutil.setProperty(jsSender, 'transform', + RTCRtpScriptTransform(worker, jsutil.jsify(options))); + } else { + var writable = jsSender.writable; + var readable = jsSender.readable; + var exist = true; + if (writable == null || readable == null) { + EncodedStreams streams = + jsutil.callMethod(jsSender, 'createEncodedStreams', []); + readable = streams.readable; + jsSender.readableStream = readable; + writable = streams.writable; + jsSender.writableStream = writable; + exist = false; + } + jsutil.callMethod(worker, 'postMessage', [ + jsutil.jsify({ + 'msgType': 'encode', + 'kind': kind, + 'exist': exist, + 'participantId': participantId, + 'trackId': trackId, + 'options': (keyProvider as KeyProviderImpl).options.toJson(), + 'readableStream': readable, + 'writableStream': writable + }), + jsutil.jsify([readable, writable]), + ]); + } + FrameCryptor cryptor = FrameCryptorImpl( + this, worker, participantId, trackId, + jsSender: jsSender, keyProvider: keyProvider); + _frameCryptors[trackId] = cryptor; + return Future.value(cryptor); + } + + void removeFrameCryptor(String trackId) { + _frameCryptors.remove(trackId); + } +} diff --git a/lib/src/rtc_transform_stream.dart b/lib/src/rtc_transform_stream.dart new file mode 100644 index 0000000..bdb7a00 --- /dev/null +++ b/lib/src/rtc_transform_stream.dart @@ -0,0 +1,129 @@ +import 'dart:html'; +import 'dart:js_util' as js_util; +import 'dart:typed_data'; + +import 'package:js/js.dart'; + +@JS('WritableStream') +abstract class WritableStream { + external void abort(); + external void close(); + external bool locked(); + external WritableStream clone(); +} + +@JS('ReadableStream') +abstract class ReadableStream { + external void cancel(); + external bool locked(); + external ReadableStream pipeThrough(dynamic transformStream); + external void pipeTo(WritableStream writableStream); + external ReadableStream clone(); +} + +@JS('TransformStream') +class TransformStream { + external TransformStream(dynamic); + external ReadableStream get readable; + external WritableStream get writable; +} + +@anonymous +@JS() +abstract class TransformStreamDefaultController { + external void enqueue(dynamic chunk); + external void error(dynamic error); + external void terminate(); +} + +@anonymous +@JS() +class EncodedStreams { + external ReadableStream get readable; + external WritableStream get writable; +} + +@JS() +class RTCEncodedFrame { + external int get timestamp; + external ByteBuffer get data; + external set data(ByteBuffer data); + external RTCEncodedFrameMetadata getMetadata(); + external String? get type; +} + +@JS() +class RTCEncodedAudioFrame { + external int get timestamp; + external ByteBuffer get data; + external set data(ByteBuffer data); + external int? get size; + external RTCEncodedAudioFrameMetadata getMetadata(); +} + +@JS() +class RTCEncodedVideoFrame { + external int get timestamp; + external ByteBuffer get data; + external set data(ByteBuffer data); + external String get type; + external RTCEncodedVideoFrameMetadata getMetadata(); +} + +@JS() +class RTCEncodedFrameMetadata { + external int get payloadType; + external int get synchronizationSource; +} + +@JS() +class RTCEncodedAudioFrameMetadata { + external int get payloadType; + external int get synchronizationSource; +} + +@JS() +class RTCEncodedVideoFrameMetadata { + external int get frameId; + external int get width; + external int get height; + external int get payloadType; + external int get synchronizationSource; +} + +@JS('RTCTransformEvent') +class RTCTransformEvent { + external factory RTCTransformEvent(); +} + +extension PropsRTCTransformEvent on RTCTransformEvent { + RTCRtpScriptTransformer get transformer => + js_util.getProperty(this, 'transformer'); +} + +@JS() +@staticInterop +class RTCRtpScriptTransformer { + external factory RTCRtpScriptTransformer(); +} + +extension PropsRTCRtpScriptTransformer on RTCRtpScriptTransformer { + ReadableStream get readable => js_util.getProperty(this, 'readable'); + WritableStream get writable => js_util.getProperty(this, 'writable'); + dynamic get options => js_util.getProperty(this, 'options'); + Future generateKeyFrame([String? rid]) => js_util + .promiseToFuture(js_util.callMethod(this, 'generateKeyFrame', [rid])); + + Future sendKeyFrameRequest() => js_util + .promiseToFuture(js_util.callMethod(this, 'sendKeyFrameRequest', [])); + + set handled(bool value) { + js_util.setProperty(this, 'handled', value); + } +} + +@JS('RTCRtpScriptTransform') +class RTCRtpScriptTransform { + external factory RTCRtpScriptTransform(Worker worker, + [dynamic options, Iterable? transfer]); +} From e2cbcf7a3440eae5c6938c7ca2187e7800533eb8 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Tue, 20 Jun 2023 20:37:23 +0800 Subject: [PATCH 2/2] update. --- lib/src/frame_cryptor_impl.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/src/frame_cryptor_impl.dart b/lib/src/frame_cryptor_impl.dart index 6e6d184..c65e336 100644 --- a/lib/src/frame_cryptor_impl.dart +++ b/lib/src/frame_cryptor_impl.dart @@ -6,15 +6,12 @@ import 'dart:js_util' as jsutil; import 'dart:typed_data'; import 'package:collection/collection.dart'; -import 'package:dart_webrtc/src/rtc_rtp_receiver_impl.dart'; -import 'package:dart_webrtc/src/rtc_rtp_sender_impl.dart'; import 'package:webrtc_interface/webrtc_interface.dart'; +import 'rtc_rtp_receiver_impl.dart'; +import 'rtc_rtp_sender_impl.dart'; import 'rtc_transform_stream.dart'; -// ignore: implementation_imports -// ignore: implementation_imports - extension RtcRtpReceiverExt on html.RtcRtpReceiver { static Map readableStreams_ = {}; static Map writableStreams_ = {};