e2ee. (#226)
* chore: e2ee. * update. * update. * update. * update. * chore: Use feat/frame-encryption branch of flutter-webrtc. * chore: Add E2EEKEY defines for dart environment, and e2ee switch. * Add encodedInsertableStreams to RTCConfiguration. * update. * feat: Add e2ee indicator for Participant. * feat: add e2ee worker js for flutter web. * dart format. * remove unused file. * fix flutter analyze . * update. * update. * add: indicate for decryption failure, and string key. * remove .lock files. * update. * update. * update e2ee.worker for web. * feat: support setCodecPreferences. * state TrackE2EEStateEvent. * fix wrong import interface from dart_webrtc. * update. * update. * update. * update. * update pubspec.lock. * chore: update protocol and add EncryptionType for Participant. * Update lib/src/e2ee/options.dart Co-authored-by: Théo Monnom <[email protected]> * fix typo. * revert changes for internal import. * Add _cleanUp() for previous room. * Add e2ee supports detection method for native/web. * Remove redundant overriding methods. * dart format. * Add e2ee.worker code and deployment docs. * chore: remove duplicate words. * chore: using Pbkdf2 derive the key. * Update pubspec.yaml * fix e2ee for safari. * fix key length. * update e2ee.worker.dart.js. * fix. * update proto. * update. * chore: add simulate for rachetKey. * update. * chore: key ratchet for flutter web. * update. * update. * update. * chore: key ratchet export for web. * bump version for xframeworks. * update. * chore: some changes for key safety ratcheting. * update. * fix typo. * update. * rename. * magic bytes for web. * bump version for flutter-webrtc. * fix analyzer. --------- Co-authored-by: Théo Monnom <[email protected]>
This commit is contained in:
co-authored by
Théo Monnom
parent
3407221fc4
commit
26947e96d6
@@ -0,0 +1,3 @@
|
||||
# Upgrade Notes
|
||||
|
||||
When the flutter-webrtc version is upgraded ([web/e2ee*.dart](https://github.com/flutter-webrtc/flutter-webrtc/tree/main/web) changes), please directly overwrite these files from the corresponding flutter-webrtc version
|
||||
@@ -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<T> {
|
||||
external factory Promise._();
|
||||
}
|
||||
|
||||
@JS('Algorithm')
|
||||
class Algorithm {
|
||||
external String get name;
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.encrypt')
|
||||
external Promise<ByteBuffer> encrypt(
|
||||
dynamic algorithm,
|
||||
html.CryptoKey key,
|
||||
ByteBuffer data,
|
||||
);
|
||||
|
||||
@JS('crypto.subtle.decrypt')
|
||||
external Promise<ByteBuffer> 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<int> 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<html.CryptoKey> importKey(
|
||||
String format,
|
||||
ByteBuffer keyData,
|
||||
dynamic algorithm,
|
||||
bool extractable,
|
||||
List<String> keyUsages,
|
||||
);
|
||||
|
||||
@JS('crypto.subtle.exportKey')
|
||||
external Promise<ByteBuffer> exportKey(
|
||||
String format,
|
||||
html.CryptoKey key,
|
||||
);
|
||||
|
||||
@JS('crypto.subtle.deriveKey')
|
||||
external Promise<html.CryptoKey> deriveKey(
|
||||
dynamic algorithm,
|
||||
html.CryptoKey baseKey,
|
||||
dynamic derivedKeyAlgorithm,
|
||||
bool extractable,
|
||||
List<String> keyUsages);
|
||||
|
||||
@JS('crypto.subtle.deriveBits')
|
||||
external Promise<ByteBuffer> deriveBits(
|
||||
dynamic algorithm,
|
||||
html.CryptoKey baseKey,
|
||||
int length,
|
||||
);
|
||||
|
||||
Future<html.CryptoKey> impportKeyFromRawData(List<int> secretKeyData,
|
||||
{required String webCryptoAlgorithm,
|
||||
required List<String> keyUsages}) async {
|
||||
return jsutil.promiseToFuture<html.CryptoKey>(importKey(
|
||||
'raw',
|
||||
jsArrayBufferFrom(secretKeyData),
|
||||
jsutil.jsify({'name': webCryptoAlgorithm}),
|
||||
false,
|
||||
keyUsages,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
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<int> findNALUIndices(Uint8List stream) {
|
||||
var result = <int>[];
|
||||
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<int, int> 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<KeySet?> cryptoKeyRing = List.filled(KEYRING_SIZE, null);
|
||||
|
||||
Future<void> ratchetKey(int? keyIndex) async {
|
||||
if (_ratchetCompleter == null) {
|
||||
_ratchetCompleter = Completer<void>();
|
||||
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<CryptoKey> 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<void> 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<void> 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<KeySet> 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<CryptoKey>(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<Uint8List> 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<ByteBuffer>(
|
||||
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<void> 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<void> 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<ByteBuffer>(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<void> 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;
|
||||
var currentkeySet = initialKeySet;
|
||||
while (!endDecLoop) {
|
||||
try {
|
||||
decrypted = await jsutil.promiseToFuture<ByteBuffer>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CryptoKey> importKey(
|
||||
Uint8List keyBytes, String algorithm, String usage) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
|
||||
return promiseToFuture<CryptoKey>(crypto.importKey(
|
||||
'raw',
|
||||
crypto.jsArrayBufferFrom(keyBytes),
|
||||
js.JsObject.jsify({'name': algorithm}),
|
||||
false,
|
||||
usage == 'derive' ? ['deriveBits', 'deriveKey'] : ['encrypt', 'decrypt'],
|
||||
));
|
||||
}
|
||||
|
||||
Future<CryptoKey> createKeyMaterialFromString(
|
||||
Uint8List keyBytes, String algorithm, String usage) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
|
||||
return promiseToFuture<CryptoKey>(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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:html' as html;
|
||||
import 'dart:js_util' as js_util;
|
||||
import 'dart:typed_data';
|
||||
import 'package:js/js.dart';
|
||||
|
||||
import 'e2ee.cryptor.dart';
|
||||
|
||||
import 'package:flutter_webrtc/src/web/rtc_transform_stream.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'crypto.dart' as crypto;
|
||||
|
||||
@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<Function>(this, 'onrtctransform', callback);
|
||||
}
|
||||
|
||||
var participantCryptors = <FrameCryptor>[];
|
||||
var publisherKeys = <String, html.CryptoKey>{};
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user