Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog

--------------------------------------------
[1.6.0] - 2025-09-13

* feat: data packet cryptor.

[1.5.3+hotfix.5] - 2025-08-11

* fixed E2EE bug for Chrome rejoin.
Expand Down
1 change: 1 addition & 0 deletions lib/dart_webrtc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ library dart_webrtc;
export 'package:webrtc_interface/webrtc_interface.dart'
hide MediaDevices, MediaRecorder, Navigator;

export 'src/data_packet_cryptor_impl.dart';
export 'src/factory_impl.dart';
export 'src/media_devices.dart';
export 'src/media_recorder.dart';
Expand Down
128 changes: 128 additions & 0 deletions lib/src/data_packet_cryptor_impl.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import 'dart:js_interop';
import 'dart:typed_data';

import 'package:web/web.dart' as web;
import 'package:webrtc_interface/webrtc_interface.dart';

import 'e2ee.worker/e2ee.logger.dart';
import 'event.dart';
import 'frame_cryptor_impl.dart' show KeyProviderImpl, WorkerResponse;
import 'utils.dart';

class DataPacketCryptorImpl implements DataPacketCryptor {
DataPacketCryptorImpl({
required this.keyProvider,
required this.algorithm,
});

final KeyProviderImpl keyProvider;
final Algorithm algorithm;
web.Worker get worker => keyProvider.worker;
final String _dataCryptorId = randomString(24);
EventsEmitter<WorkerResponse> get events => keyProvider.events;

@override
Future<EncryptedPacket> encrypt({
required String participantId,
required int keyIndex,
required Uint8List data,
}) async {
var msgId = randomString(12);
worker.postMessage(
{
'msgType': 'dataCryptorEncrypt',
'msgId': msgId,
'keyProviderId': keyProvider.id,
'dataCryptorId': _dataCryptorId,
'participantId': participantId,
'keyIndex': keyIndex,
'data': data,
'algorithm': algorithm.name,
}.jsify(),
);

var res = await events.waitFor<WorkerResponse>(
filter: (event) {
logger.fine('waiting for encrypt on msg: $msgId');
return event.msgId == msgId;
},
duration: Duration(seconds: 5),
onTimeout: () => throw Exception('waiting for encrypt on msg timed out'),
);

return EncryptedPacket(
data: res.data['data'] as Uint8List,
keyIndex: res.data['keyIndex'] as int,
iv: res.data['iv'] as Uint8List,
);
}

@override
Future<Uint8List> decrypt({
required String participantId,
required EncryptedPacket encryptedPacket,
}) async {
var msgId = randomString(12);
worker.postMessage(
{
'msgType': 'dataCryptorDecrypt',
'msgId': msgId,
'keyProviderId': keyProvider.id,
'dataCryptorId': _dataCryptorId,
'participantId': participantId,
'keyIndex': encryptedPacket.keyIndex,
'data': encryptedPacket.data,
'iv': encryptedPacket.iv,
'algorithm': algorithm.name,
}.jsify(),
);

var res = await events.waitFor<WorkerResponse>(
filter: (event) {
logger.fine('waiting for decrypt on msg: $msgId');
return event.msgId == msgId;
},
duration: Duration(seconds: 5),
onTimeout: () => throw Exception('waiting for decrypt on msg timed out'),
);

return res.data['data'] as Uint8List;
}

@override
Future<void> dispose() async {
var msgId = randomString(12);
worker.postMessage(
{
'msgType': 'dataCryptorDispose',
'msgId': msgId,
'dataCryptorId': _dataCryptorId
}.jsify(),
);

await events.waitFor<WorkerResponse>(
filter: (event) {
logger.fine('waiting for dispose on msg: $msgId');
return event.msgId == msgId;
},
duration: Duration(seconds: 5),
onTimeout: () => throw Exception('waiting for dispose on msg timed out'),
);
}
}

class DataPacketCryptorFactoryImpl implements DataPacketCryptorFactory {
DataPacketCryptorFactoryImpl._internal();

static final DataPacketCryptorFactoryImpl instance =
DataPacketCryptorFactoryImpl._internal();
@override
Future<DataPacketCryptor> createDataPacketCryptor(
{required Algorithm algorithm, required KeyProvider keyProvider}) async {
return Future.value(DataPacketCryptorImpl(
algorithm: algorithm, keyProvider: keyProvider as KeyProviderImpl));
}
}

DataPacketCryptorFactory get dataPacketCryptorFactory =>
DataPacketCryptorFactoryImpl.instance;
230 changes: 230 additions & 0 deletions lib/src/e2ee.worker/e2ee.data_packet_cryptor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:math';
import 'dart:typed_data';

import 'package:web/web.dart' as web;

import 'e2ee.keyhandler.dart';
import 'e2ee.logger.dart';

class EncryptedPacket {
EncryptedPacket({
required this.data,
required this.keyIndex,
required this.iv,
});

Uint8List data;
int keyIndex;
Uint8List iv;
}

class E2EEDataPacketCryptor {
E2EEDataPacketCryptor({
required this.worker,
required this.participantIdentity,
required this.dataCryptorId,
required this.keyHandler,
});
int sendCount_ = -1;
String? participantIdentity;
String? dataCryptorId;
ParticipantKeyHandler keyHandler;
KeyOptions get keyOptions => keyHandler.keyOptions;
int currentKeyIndex = 0;
final web.DedicatedWorkerGlobalScope worker;

void setParticipant(String identity, ParticipantKeyHandler keys) {
participantIdentity = identity;
keyHandler = keys;
}

void unsetParticipant() {
participantIdentity = null;
}

void setKeyIndex(int keyIndex) {
logger.config('setKeyIndex for $participantIdentity, newIndex: $keyIndex');
currentKeyIndex = keyIndex;
}

Uint8List makeIv({required int timestamp}) {
var iv = ByteData(IV_LENGTH);

// having to keep our own send count (similar to a picture id) is not ideal.
if (sendCount_ == -1) {
// Initialize with a random offset, similar to the RTP sequence number.
sendCount_ = Random.secure().nextInt(0xffff);
}

var sendCount = sendCount_;
final randomBytes =
Random.secure().nextInt(max(0, 0xffffffff)).toUnsigned(32);

iv.setUint32(0, randomBytes);
iv.setUint32(4, timestamp);
iv.setUint32(8, timestamp - (sendCount % 0xffff));

sendCount_ = sendCount + 1;

return iv.buffer.asUint8List();
}

void postMessage(Object message) {
worker.postMessage(message.jsify());
}

Future<EncryptedPacket?> encrypt(
ParticipantKeyHandler keys,
Uint8List data,
) async {
logger.fine('encodeFunction: buffer ${data.length}');

var secretKey = keyHandler.getKeySet(currentKeyIndex)?.encryptionKey;
var keyIndex = currentKeyIndex;

if (secretKey == null) {
logger.warning(
'encodeFunction: no secretKey for index $keyIndex, cannot encrypt');
return null;
}

var iv = makeIv(timestamp: DateTime.timestamp().millisecondsSinceEpoch);

var frameTrailer = ByteData(2);
frameTrailer.setInt8(0, IV_LENGTH);
frameTrailer.setInt8(1, keyIndex);

try {
var cipherText = await worker.crypto.subtle
.encrypt(
{
'name': 'AES-GCM',
'iv': iv,
}.jsify() as web.AlgorithmIdentifier,
secretKey,
data.toJS,
)
.toDart as JSArrayBuffer;

logger.finer(
'encodeFunction: encrypted buffer: ${data.length}, cipherText: ${cipherText.toDart.asUint8List().length}');

return EncryptedPacket(
data: cipherText.toDart.asUint8List(),
keyIndex: keyIndex,
iv: iv,
);
} catch (e) {
logger.warning('encodeFunction encrypt: e ${e.toString()}');
rethrow;
}
}

Future<Uint8List?> decrypt(
ParticipantKeyHandler keys,
EncryptedPacket encryptedPacket,
) async {
var ratchetCount = 0;

logger.fine(
'decodeFunction: data packet lenght ${encryptedPacket.data.length}');

ByteBuffer? decrypted;
KeySet? initialKeySet;
var initialKeyIndex = currentKeyIndex;

try {
var ivLength = encryptedPacket.iv.length;
var keyIndex = encryptedPacket.keyIndex;
var iv = encryptedPacket.iv;
var payload = encryptedPacket.data;
initialKeySet = keyHandler.getKeySet(initialKeyIndex);

logger.finer(
'decodeFunction: start decrypting data packet length ${payload.length}, ivLength $ivLength, keyIndex $keyIndex, iv $iv');

/// missingKey flow:
/// tries to decrypt once, fails, tries to ratchet once and decrypt again,
/// fails (does not save ratcheted key), bumps _decryptionFailureCount,
/// if higher than failuretolerance hasValidKey is set to false, on next
/// frame it fires a missingkey
/// to throw missingkeys faster lower your failureTolerance
if (initialKeySet == null || !keyHandler.hasValidKey) {
return null;
}
var currentkeySet = initialKeySet;

Future<void> decryptFrameInternal() async {
decrypted = ((await worker.crypto.subtle
.decrypt(
{
'name': 'AES-GCM',
'iv': iv,
}.jsify() as web.AlgorithmIdentifier,
currentkeySet.encryptionKey,
payload.toJS,
)
.toDart) as JSArrayBuffer)
.toDart;
logger.finer(
'decodeFunction::decryptFrameInternal: decrypted: ${decrypted!.asUint8List().length}');

if (decrypted == null) {
throw Exception('[decryptFrameInternal] could not decrypt');
}
logger.finer(
'decodeFunction::decryptFrameInternal: decrypted: ${decrypted!.asUint8List().length}');
if (currentkeySet != initialKeySet) {
logger.fine(
'decodeFunction::decryptFrameInternal: ratchetKey: decryption ok, newState: kKeyRatcheted');
await keyHandler.setKeySetFromMaterial(
currentkeySet, initialKeyIndex);
}
}

Future<void> ratchedKeyInternal() async {
if (ratchetCount >= keyOptions.ratchetWindowSize ||
keyOptions.ratchetWindowSize <= 0) {
throw Exception('[ratchedKeyInternal] cannot ratchet anymore');
}

var newKeyBuffer = await keyHandler.ratchet(
currentkeySet.material, keyOptions.ratchetSalt);
var newMaterial = await keyHandler.ratchetMaterial(
currentkeySet.material, newKeyBuffer.buffer);
currentkeySet =
await keyHandler.deriveKeys(newMaterial, keyOptions.ratchetSalt);
ratchetCount++;
await decryptFrameInternal();
}

try {
/// gets frame -> tries to decrypt -> tries to ratchet (does this failureTolerance
/// times, then says missing key)
/// we only save the new key after ratcheting if we were able to decrypt something
await decryptFrameInternal();
} catch (e) {
logger.finer('decodeFunction: kInternalError catch $e');
await ratchedKeyInternal();
}

if (decrypted == null) {
throw Exception(
'[decodeFunction] decryption failed even after ratchting');
}

// we can now be sure that decryption was a success
keyHandler.decryptionSuccess();

logger.finer(
'decodeFunction: decryption success, buffer length ${payload.length}, decrypted: ${decrypted!.asUint8List().length}');

return decrypted!.asUint8List();
} catch (e) {
keyHandler.decryptionFailure();
rethrow;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import 'e2ee.keyhandler.dart';
import 'e2ee.logger.dart';
import 'e2ee.sfi_guard.dart';

const IV_LENGTH = 12;

const kNaluTypeMask = 0x1f;

/// Coded slice of a non-IDR picture
Expand Down
1 change: 1 addition & 0 deletions lib/src/e2ee.worker/e2ee.keyhandler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'e2ee.logger.dart';
import 'e2ee.utils.dart';

const KEYRING_SIZE = 16;
const IV_LENGTH = 12;

class KeyOptions {
KeyOptions({
Expand Down
Loading