import 'dart:html'; import 'dart:js'; import 'dart:js_util' as jsutil; import 'dart:math'; import 'dart:typed_data'; import 'dart:collection'; import 'dart:async'; import 'package:flutter_webrtc/src/web/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; bool hasValidKey = false; 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); hasValidKey = true; } 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, buffer.length - 1); //print('magicBytesBuffer $magicBytesBuffer, magicBytes $magicBytes, '); if (magicBytesBuffer.toString() == magicBytes.toString()) { var frameType = buffer.sublist(buffer.length - 1)[0]; print('skip uncrypted frame, type $frameType'); 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 || !hasValidKey) { 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; var 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 (decrypted != null && 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. if (initialKeySet != null) { await setKeySetFromMaterial(initialKeySet, initialKeyIndex); } hasValidKey = false; } } }