Skip to content

Commit bc63a4e

Browse files
authored
Merge pull request #69 from flutter-webrtc/feat/data-packet-cryptor
feat: Data packet cryptor
2 parents 569fd75 + 312cbf7 commit bc63a4e

File tree

9 files changed

+559
-6
lines changed

9 files changed

+559
-6
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Changelog
22

33
--------------------------------------------
4+
[1.6.0] - 2025-09-13
5+
6+
* feat: data packet cryptor.
7+
48
[1.5.3+hotfix.5] - 2025-08-11
59

610
* fixed E2EE bug for Chrome rejoin.

lib/dart_webrtc.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ library dart_webrtc;
33
export 'package:webrtc_interface/webrtc_interface.dart'
44
hide MediaDevices, MediaRecorder, Navigator;
55

6+
export 'src/data_packet_cryptor_impl.dart';
67
export 'src/factory_impl.dart';
78
export 'src/media_devices.dart';
89
export 'src/media_recorder.dart';
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import 'dart:js_interop';
2+
import 'dart:typed_data';
3+
4+
import 'package:web/web.dart' as web;
5+
import 'package:webrtc_interface/webrtc_interface.dart';
6+
7+
import 'e2ee.worker/e2ee.logger.dart';
8+
import 'event.dart';
9+
import 'frame_cryptor_impl.dart' show KeyProviderImpl, WorkerResponse;
10+
import 'utils.dart';
11+
12+
class DataPacketCryptorImpl implements DataPacketCryptor {
13+
DataPacketCryptorImpl({
14+
required this.keyProvider,
15+
required this.algorithm,
16+
});
17+
18+
final KeyProviderImpl keyProvider;
19+
final Algorithm algorithm;
20+
web.Worker get worker => keyProvider.worker;
21+
final String _dataCryptorId = randomString(24);
22+
EventsEmitter<WorkerResponse> get events => keyProvider.events;
23+
24+
@override
25+
Future<EncryptedPacket> encrypt({
26+
required String participantId,
27+
required int keyIndex,
28+
required Uint8List data,
29+
}) async {
30+
var msgId = randomString(12);
31+
worker.postMessage(
32+
{
33+
'msgType': 'dataCryptorEncrypt',
34+
'msgId': msgId,
35+
'keyProviderId': keyProvider.id,
36+
'dataCryptorId': _dataCryptorId,
37+
'participantId': participantId,
38+
'keyIndex': keyIndex,
39+
'data': data,
40+
'algorithm': algorithm.name,
41+
}.jsify(),
42+
);
43+
44+
var res = await events.waitFor<WorkerResponse>(
45+
filter: (event) {
46+
logger.fine('waiting for encrypt on msg: $msgId');
47+
return event.msgId == msgId;
48+
},
49+
duration: Duration(seconds: 5),
50+
onTimeout: () => throw Exception('waiting for encrypt on msg timed out'),
51+
);
52+
53+
return EncryptedPacket(
54+
data: res.data['data'] as Uint8List,
55+
keyIndex: res.data['keyIndex'] as int,
56+
iv: res.data['iv'] as Uint8List,
57+
);
58+
}
59+
60+
@override
61+
Future<Uint8List> decrypt({
62+
required String participantId,
63+
required EncryptedPacket encryptedPacket,
64+
}) async {
65+
var msgId = randomString(12);
66+
worker.postMessage(
67+
{
68+
'msgType': 'dataCryptorDecrypt',
69+
'msgId': msgId,
70+
'keyProviderId': keyProvider.id,
71+
'dataCryptorId': _dataCryptorId,
72+
'participantId': participantId,
73+
'keyIndex': encryptedPacket.keyIndex,
74+
'data': encryptedPacket.data,
75+
'iv': encryptedPacket.iv,
76+
'algorithm': algorithm.name,
77+
}.jsify(),
78+
);
79+
80+
var res = await events.waitFor<WorkerResponse>(
81+
filter: (event) {
82+
logger.fine('waiting for decrypt on msg: $msgId');
83+
return event.msgId == msgId;
84+
},
85+
duration: Duration(seconds: 5),
86+
onTimeout: () => throw Exception('waiting for decrypt on msg timed out'),
87+
);
88+
89+
return res.data['data'] as Uint8List;
90+
}
91+
92+
@override
93+
Future<void> dispose() async {
94+
var msgId = randomString(12);
95+
worker.postMessage(
96+
{
97+
'msgType': 'dataCryptorDispose',
98+
'msgId': msgId,
99+
'dataCryptorId': _dataCryptorId
100+
}.jsify(),
101+
);
102+
103+
await events.waitFor<WorkerResponse>(
104+
filter: (event) {
105+
logger.fine('waiting for dispose on msg: $msgId');
106+
return event.msgId == msgId;
107+
},
108+
duration: Duration(seconds: 5),
109+
onTimeout: () => throw Exception('waiting for dispose on msg timed out'),
110+
);
111+
}
112+
}
113+
114+
class DataPacketCryptorFactoryImpl implements DataPacketCryptorFactory {
115+
DataPacketCryptorFactoryImpl._internal();
116+
117+
static final DataPacketCryptorFactoryImpl instance =
118+
DataPacketCryptorFactoryImpl._internal();
119+
@override
120+
Future<DataPacketCryptor> createDataPacketCryptor(
121+
{required Algorithm algorithm, required KeyProvider keyProvider}) async {
122+
return Future.value(DataPacketCryptorImpl(
123+
algorithm: algorithm, keyProvider: keyProvider as KeyProviderImpl));
124+
}
125+
}
126+
127+
DataPacketCryptorFactory get dataPacketCryptorFactory =>
128+
DataPacketCryptorFactoryImpl.instance;
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import 'dart:async';
2+
import 'dart:js_interop';
3+
import 'dart:math';
4+
import 'dart:typed_data';
5+
6+
import 'package:web/web.dart' as web;
7+
8+
import 'e2ee.keyhandler.dart';
9+
import 'e2ee.logger.dart';
10+
11+
class EncryptedPacket {
12+
EncryptedPacket({
13+
required this.data,
14+
required this.keyIndex,
15+
required this.iv,
16+
});
17+
18+
Uint8List data;
19+
int keyIndex;
20+
Uint8List iv;
21+
}
22+
23+
class E2EEDataPacketCryptor {
24+
E2EEDataPacketCryptor({
25+
required this.worker,
26+
required this.participantIdentity,
27+
required this.dataCryptorId,
28+
required this.keyHandler,
29+
});
30+
int sendCount_ = -1;
31+
String? participantIdentity;
32+
String? dataCryptorId;
33+
ParticipantKeyHandler keyHandler;
34+
KeyOptions get keyOptions => keyHandler.keyOptions;
35+
int currentKeyIndex = 0;
36+
final web.DedicatedWorkerGlobalScope worker;
37+
38+
void setParticipant(String identity, ParticipantKeyHandler keys) {
39+
participantIdentity = identity;
40+
keyHandler = keys;
41+
}
42+
43+
void unsetParticipant() {
44+
participantIdentity = null;
45+
}
46+
47+
void setKeyIndex(int keyIndex) {
48+
logger.config('setKeyIndex for $participantIdentity, newIndex: $keyIndex');
49+
currentKeyIndex = keyIndex;
50+
}
51+
52+
Uint8List makeIv({required int timestamp}) {
53+
var iv = ByteData(IV_LENGTH);
54+
55+
// having to keep our own send count (similar to a picture id) is not ideal.
56+
if (sendCount_ == -1) {
57+
// Initialize with a random offset, similar to the RTP sequence number.
58+
sendCount_ = Random.secure().nextInt(0xffff);
59+
}
60+
61+
var sendCount = sendCount_;
62+
final randomBytes =
63+
Random.secure().nextInt(max(0, 0xffffffff)).toUnsigned(32);
64+
65+
iv.setUint32(0, randomBytes);
66+
iv.setUint32(4, timestamp);
67+
iv.setUint32(8, timestamp - (sendCount % 0xffff));
68+
69+
sendCount_ = sendCount + 1;
70+
71+
return iv.buffer.asUint8List();
72+
}
73+
74+
void postMessage(Object message) {
75+
worker.postMessage(message.jsify());
76+
}
77+
78+
Future<EncryptedPacket?> encrypt(
79+
ParticipantKeyHandler keys,
80+
Uint8List data,
81+
) async {
82+
logger.fine('encodeFunction: buffer ${data.length}');
83+
84+
var secretKey = keyHandler.getKeySet(currentKeyIndex)?.encryptionKey;
85+
var keyIndex = currentKeyIndex;
86+
87+
if (secretKey == null) {
88+
logger.warning(
89+
'encodeFunction: no secretKey for index $keyIndex, cannot encrypt');
90+
return null;
91+
}
92+
93+
var iv = makeIv(timestamp: DateTime.timestamp().millisecondsSinceEpoch);
94+
95+
var frameTrailer = ByteData(2);
96+
frameTrailer.setInt8(0, IV_LENGTH);
97+
frameTrailer.setInt8(1, keyIndex);
98+
99+
try {
100+
var cipherText = await worker.crypto.subtle
101+
.encrypt(
102+
{
103+
'name': 'AES-GCM',
104+
'iv': iv,
105+
}.jsify() as web.AlgorithmIdentifier,
106+
secretKey,
107+
data.toJS,
108+
)
109+
.toDart as JSArrayBuffer;
110+
111+
logger.finer(
112+
'encodeFunction: encrypted buffer: ${data.length}, cipherText: ${cipherText.toDart.asUint8List().length}');
113+
114+
return EncryptedPacket(
115+
data: cipherText.toDart.asUint8List(),
116+
keyIndex: keyIndex,
117+
iv: iv,
118+
);
119+
} catch (e) {
120+
logger.warning('encodeFunction encrypt: e ${e.toString()}');
121+
rethrow;
122+
}
123+
}
124+
125+
Future<Uint8List?> decrypt(
126+
ParticipantKeyHandler keys,
127+
EncryptedPacket encryptedPacket,
128+
) async {
129+
var ratchetCount = 0;
130+
131+
logger.fine(
132+
'decodeFunction: data packet lenght ${encryptedPacket.data.length}');
133+
134+
ByteBuffer? decrypted;
135+
KeySet? initialKeySet;
136+
var initialKeyIndex = currentKeyIndex;
137+
138+
try {
139+
var ivLength = encryptedPacket.iv.length;
140+
var keyIndex = encryptedPacket.keyIndex;
141+
var iv = encryptedPacket.iv;
142+
var payload = encryptedPacket.data;
143+
initialKeySet = keyHandler.getKeySet(initialKeyIndex);
144+
145+
logger.finer(
146+
'decodeFunction: start decrypting data packet length ${payload.length}, ivLength $ivLength, keyIndex $keyIndex, iv $iv');
147+
148+
/// missingKey flow:
149+
/// tries to decrypt once, fails, tries to ratchet once and decrypt again,
150+
/// fails (does not save ratcheted key), bumps _decryptionFailureCount,
151+
/// if higher than failuretolerance hasValidKey is set to false, on next
152+
/// frame it fires a missingkey
153+
/// to throw missingkeys faster lower your failureTolerance
154+
if (initialKeySet == null || !keyHandler.hasValidKey) {
155+
return null;
156+
}
157+
var currentkeySet = initialKeySet;
158+
159+
Future<void> decryptFrameInternal() async {
160+
decrypted = ((await worker.crypto.subtle
161+
.decrypt(
162+
{
163+
'name': 'AES-GCM',
164+
'iv': iv,
165+
}.jsify() as web.AlgorithmIdentifier,
166+
currentkeySet.encryptionKey,
167+
payload.toJS,
168+
)
169+
.toDart) as JSArrayBuffer)
170+
.toDart;
171+
logger.finer(
172+
'decodeFunction::decryptFrameInternal: decrypted: ${decrypted!.asUint8List().length}');
173+
174+
if (decrypted == null) {
175+
throw Exception('[decryptFrameInternal] could not decrypt');
176+
}
177+
logger.finer(
178+
'decodeFunction::decryptFrameInternal: decrypted: ${decrypted!.asUint8List().length}');
179+
if (currentkeySet != initialKeySet) {
180+
logger.fine(
181+
'decodeFunction::decryptFrameInternal: ratchetKey: decryption ok, newState: kKeyRatcheted');
182+
await keyHandler.setKeySetFromMaterial(
183+
currentkeySet, initialKeyIndex);
184+
}
185+
}
186+
187+
Future<void> ratchedKeyInternal() async {
188+
if (ratchetCount >= keyOptions.ratchetWindowSize ||
189+
keyOptions.ratchetWindowSize <= 0) {
190+
throw Exception('[ratchedKeyInternal] cannot ratchet anymore');
191+
}
192+
193+
var newKeyBuffer = await keyHandler.ratchet(
194+
currentkeySet.material, keyOptions.ratchetSalt);
195+
var newMaterial = await keyHandler.ratchetMaterial(
196+
currentkeySet.material, newKeyBuffer.buffer);
197+
currentkeySet =
198+
await keyHandler.deriveKeys(newMaterial, keyOptions.ratchetSalt);
199+
ratchetCount++;
200+
await decryptFrameInternal();
201+
}
202+
203+
try {
204+
/// gets frame -> tries to decrypt -> tries to ratchet (does this failureTolerance
205+
/// times, then says missing key)
206+
/// we only save the new key after ratcheting if we were able to decrypt something
207+
await decryptFrameInternal();
208+
} catch (e) {
209+
logger.finer('decodeFunction: kInternalError catch $e');
210+
await ratchedKeyInternal();
211+
}
212+
213+
if (decrypted == null) {
214+
throw Exception(
215+
'[decodeFunction] decryption failed even after ratchting');
216+
}
217+
218+
// we can now be sure that decryption was a success
219+
keyHandler.decryptionSuccess();
220+
221+
logger.finer(
222+
'decodeFunction: decryption success, buffer length ${payload.length}, decrypted: ${decrypted!.asUint8List().length}');
223+
224+
return decrypted!.asUint8List();
225+
} catch (e) {
226+
keyHandler.decryptionFailure();
227+
rethrow;
228+
}
229+
}
230+
}

lib/src/e2ee.worker/e2ee.cryptor.dart renamed to lib/src/e2ee.worker/e2ee.frame_cryptor.dart

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import 'e2ee.keyhandler.dart';
1111
import 'e2ee.logger.dart';
1212
import 'e2ee.sfi_guard.dart';
1313

14-
const IV_LENGTH = 12;
15-
1614
const kNaluTypeMask = 0x1f;
1715

1816
/// Coded slice of a non-IDR picture

lib/src/e2ee.worker/e2ee.keyhandler.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'e2ee.logger.dart';
99
import 'e2ee.utils.dart';
1010

1111
const KEYRING_SIZE = 16;
12+
const IV_LENGTH = 12;
1213

1314
class KeyOptions {
1415
KeyOptions({

0 commit comments

Comments
 (0)