diff --git a/.changeset/chilled-oranges-try.md b/.changeset/chilled-oranges-try.md new file mode 100644 index 0000000000..04559ba280 --- /dev/null +++ b/.changeset/chilled-oranges-try.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +Add preliminary support for data message decryption diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 4af622f96f..ae95f7048d 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -50,7 +50,7 @@ const state = { decoder: new TextDecoder(), defaultDevices: new Map([['audioinput', 'default']]), bitrateInterval: undefined as any, - e2eeKeyProvider: new ExternalE2EEKeyProvider(), + e2eeKeyProvider: new ExternalE2EEKeyProvider({ ratchetWindowSize: 100 }), chatMessages: new Map(), }; let currentRoom: Room | undefined; @@ -128,7 +128,7 @@ const appActions = { videoCaptureDefaults: { resolution: VideoPresets.h720.resolution, }, - e2ee: e2eeEnabled + encryption: e2eeEnabled ? { keyProvider: state.e2eeKeyProvider, worker: new E2EEWorker() } : undefined, }; @@ -273,6 +273,7 @@ const appActions = { try { for await (const chunk of reader.withAbortSignal(streamReaderAbortController.signal)) { message += chunk; + console.log('received message', message, participant); handleChatMessage( { id: info.id, @@ -434,7 +435,7 @@ const appActions = { }, toggleE2EE: async () => { - if (!currentRoom || !currentRoom.options.e2ee) { + if (!currentRoom || !currentRoom.hasE2EESetup) { return; } // read and set current key from input @@ -488,7 +489,7 @@ const appActions = { }, ratchetE2EEKey: async () => { - if (!currentRoom || !currentRoom.options.e2ee) { + if (!currentRoom || !currentRoom.hasE2EESetup) { return; } await state.e2eeKeyProvider.ratchetKey(); @@ -1043,7 +1044,7 @@ function setButtonsForState(connected: boolean) { 'flip-video-button', 'send-button', ]; - if (currentRoom && currentRoom.options.e2ee) { + if (currentRoom && currentRoom.hasE2EESetup) { connectedSet.push('toggle-e2ee-button', 'e2ee-ratchet-button'); } const disconnectedSet = ['connect-button']; diff --git a/package.json b/package.json index 9275e297f1..d4c95d1cc7 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ }, "dependencies": { "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.39.3", + "@livekit/protocol": "2.0.0", "events": "^3.3.0", "loglevel": "^1.9.2", "sdp-transform": "^2.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3e1eb4c36..d29e1fb36f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.39.3 - version: 1.39.3 + specifier: 2.0.0 + version: 2.0.0 '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 @@ -1039,8 +1039,8 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.39.3': - resolution: {integrity: sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==} + '@livekit/protocol@2.0.0': + resolution: {integrity: sha512-XxP4mf4OppL8d7iKfSrTC0GaEMgBtPBoVPsXme2qYZkQ/T23bl85F3cGp3Q/i7jsgk1S/xPyjrHG3EpsIhkXiA==} '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -3447,8 +3447,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20250904: - resolution: {integrity: sha512-301ifUSj+fMMKRGYMLW8bIRhlpLxnqSwyBCCUlcjcNw6GBr5O3KI9mbnL3VW03cwGtV6+Zjs7hI7nFrTA0sLtQ==} + typescript@6.0.0-dev.20250915: + resolution: {integrity: sha512-CwQsPJqBWQRvwnbDlx9YanqAZPCf2syjyoGGuzFNF5yHjvHL1vJ9MV2UbQ32ViZaA63fHd11xl6+CWoaTorakQ==} engines: {node: '>=14.17'} hasBin: true @@ -4818,7 +4818,7 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.39.3': + '@livekit/protocol@2.0.0': dependencies: '@bufbuild/protobuf': 1.10.1 @@ -5681,7 +5681,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20250904 + typescript: 6.0.0-dev.20250915 dunder-proto@1.0.1: dependencies: @@ -7522,7 +7522,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20250904: {} + typescript@6.0.0-dev.20250915: {} uc.micro@2.1.0: {} diff --git a/src/e2ee/E2eeManager.ts b/src/e2ee/E2eeManager.ts index 600444c885..08c4140ce8 100644 --- a/src/e2ee/E2eeManager.ts +++ b/src/e2ee/E2eeManager.ts @@ -11,15 +11,19 @@ import type RemoteTrack from '../room/track/RemoteTrack'; import type { Track } from '../room/track/Track'; import type { VideoCodec } from '../room/track/options'; import { mimeTypeToVideoCodecString } from '../room/track/utils'; -import { isLocalTrack, isSafariBased, isVideoTrack } from '../room/utils'; +import { Future, isLocalTrack, isSafariBased, isVideoTrack } from '../room/utils'; import type { BaseKeyProvider } from './KeyProvider'; import { E2EE_FLAG } from './constants'; import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events'; import type { + DecryptDataRequestMessage, + DecryptDataResponseMessage, E2EEManagerOptions, E2EEWorkerMessage, EnableMessage, EncodeMessage, + EncryptDataRequestMessage, + EncryptDataResponseMessage, InitMessage, KeyInfo, RTPVideoMapMessage, @@ -35,8 +39,17 @@ import { isE2EESupported, isScriptTransformSupported } from './utils'; export interface BaseE2EEManager { setup(room: Room): void; setupEngine(engine: RTCEngine): void; + isEnabled: boolean; + isDataChannelEncryptionEnabled: boolean; setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string): void; setSifTrailer(trailer: Uint8Array): void; + encryptData(data: Uint8Array): Promise; + handleEncryptedData( + payload: Uint8Array, + iv: Uint8Array, + participantIdentity: string, + keyIndex: number, + ): Promise; on(event: E, listener: E2EEManagerCallbacks[E]): this; } @@ -55,11 +68,26 @@ export class E2EEManager private keyProvider: BaseKeyProvider; - constructor(options: E2EEManagerOptions) { + private decryptDataRequests: Map> = new Map(); + + private encryptDataRequests: Map> = new Map(); + + private dataChannelEncryptionEnabled: boolean; + + constructor(options: E2EEManagerOptions, dcEncryptionEnabled: boolean) { super(); this.keyProvider = options.keyProvider; this.worker = options.worker; this.encryptionEnabled = false; + this.dataChannelEncryptionEnabled = dcEncryptionEnabled; + } + + get isEnabled(): boolean { + return this.encryptionEnabled; + } + + get isDataChannelEncryptionEnabled(): boolean { + return this.isEnabled && this.dataChannelEncryptionEnabled; } /** @@ -160,6 +188,19 @@ export class E2EEManager data.keyIndex, ); break; + + case 'decryptDataResponse': + const decryptFuture = this.decryptDataRequests.get(data.uuid); + if (decryptFuture?.resolve) { + decryptFuture.resolve(data); + } + break; + case 'encryptDataResponse': + const encryptFuture = this.encryptDataRequests.get(data.uuid); + if (encryptFuture?.resolve) { + encryptFuture.resolve(data as EncryptDataResponseMessage['data']); + } + break; default: break; } @@ -250,6 +291,57 @@ export class E2EEManager ); } + async encryptData(data: Uint8Array): Promise { + if (!this.worker) { + throw Error('could not encrypt data, worker is missing'); + } + const uuid = crypto.randomUUID(); + const msg: EncryptDataRequestMessage = { + kind: 'encryptDataRequest', + data: { + uuid, + payload: data, + participantIdentity: this.room!.localParticipant.identity, + }, + }; + const future = new Future(); + future.onFinally = () => { + this.encryptDataRequests.delete(uuid); + }; + this.encryptDataRequests.set(uuid, future); + this.worker.postMessage(msg); + return future!.promise!; + } + + handleEncryptedData( + payload: Uint8Array, + iv: Uint8Array, + participantIdentity: string, + keyIndex: number, + ) { + if (!this.worker) { + throw Error('could not handle encrypted data, worker is missing'); + } + const uuid = crypto.randomUUID(); + const msg: DecryptDataRequestMessage = { + kind: 'decryptDataRequest', + data: { + uuid, + payload, + iv, + participantIdentity, + keyIndex, + }, + }; + const future = new Future(); + future.onFinally = () => { + this.decryptDataRequests.delete(uuid); + }; + this.decryptDataRequests.set(uuid, future); + this.worker.postMessage(msg); + return future.promise; + } + private postRatchetRequest(participantIdentity?: string, keyIndex?: number) { if (!this.worker) { throw Error('could not ratchet key, worker is missing'); diff --git a/src/e2ee/types.ts b/src/e2ee/types.ts index 72f308beca..915a8178ae 100644 --- a/src/e2ee/types.ts +++ b/src/e2ee/types.ts @@ -109,6 +109,44 @@ export interface InitAck extends BaseMessage { }; } +export interface DecryptDataRequestMessage extends BaseMessage { + kind: 'decryptDataRequest'; + data: { + uuid: string; + payload: Uint8Array; + iv: Uint8Array; + participantIdentity: string; + keyIndex: number; + }; +} + +export interface DecryptDataResponseMessage extends BaseMessage { + kind: 'decryptDataResponse'; + data: { + uuid: string; + payload: Uint8Array; + }; +} + +export interface EncryptDataRequestMessage extends BaseMessage { + kind: 'encryptDataRequest'; + data: { + uuid: string; + payload: Uint8Array; + participantIdentity: string; + }; +} + +export interface EncryptDataResponseMessage extends BaseMessage { + kind: 'encryptDataResponse'; + data: { + uuid: string; + payload: Uint8Array; + iv: Uint8Array; + keyIndex: number; + }; +} + export type E2EEWorkerMessage = | InitMessage | SetKeyMessage @@ -121,7 +159,11 @@ export type E2EEWorkerMessage = | RatchetRequestMessage | RatchetMessage | SifTrailerMessage - | InitAck; + | InitAck + | DecryptDataRequestMessage + | DecryptDataResponseMessage + | EncryptDataRequestMessage + | EncryptDataResponseMessage; export type KeySet = { material: CryptoKey; encryptionKey: CryptoKey }; @@ -150,6 +192,7 @@ export type E2EEManagerOptions = { keyProvider: BaseKeyProvider; worker: Worker; }; + export type E2EEOptions = | E2EEManagerOptions | { diff --git a/src/e2ee/utils.ts b/src/e2ee/utils.ts index 62b80555e6..a75f78a351 100644 --- a/src/e2ee/utils.ts +++ b/src/e2ee/utils.ts @@ -1,3 +1,4 @@ +import { type DataPacket, EncryptedPacketPayload } from '@livekit/protocol'; import { ENCRYPTION_ALGORITHM } from './constants'; export function isE2EESupported() { @@ -176,3 +177,18 @@ export function writeRbsp(data_in: Uint8Array): Uint8Array { } return new Uint8Array(dataOut); } + +export function asEncryptablePacket(packet: DataPacket): EncryptedPacketPayload | undefined { + if ( + packet.value?.case !== 'sipDtmf' && + packet.value?.case !== 'metrics' && + packet.value?.case !== 'speaker' && + packet.value?.case !== 'transcription' && + packet.value?.case !== 'encryptedPacket' + ) { + return new EncryptedPacketPayload({ + value: packet.value, + }); + } + return undefined; +} diff --git a/src/e2ee/worker/DataCryptor.test.ts b/src/e2ee/worker/DataCryptor.test.ts new file mode 100644 index 0000000000..dbd76cbbd1 --- /dev/null +++ b/src/e2ee/worker/DataCryptor.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vitest } from 'vitest'; +import { IV_LENGTH, KEY_PROVIDER_DEFAULTS } from '../constants'; +import type { KeyProviderOptions } from '../types'; +import { createKeyMaterialFromString } from '../utils'; +import { DataCryptor } from './DataCryptor'; +import { ParticipantKeyHandler } from './ParticipantKeyHandler'; + +function prepareParticipantTestKeys( + participantIdentity: string, + partialKeyProviderOptions: Partial, +): ParticipantKeyHandler { + const keyProviderOptions = { ...KEY_PROVIDER_DEFAULTS, ...partialKeyProviderOptions }; + return new ParticipantKeyHandler(participantIdentity, keyProviderOptions); +} + +describe('DataCryptor', () => { + const participantIdentity = 'testParticipant'; + + describe('encrypt', () => { + it('throws error when no key set', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + + await expect(DataCryptor.encrypt(data, keys)).rejects.toThrow('No key set found'); + }); + + it('encrypts data successfully with key', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('test-key'), 1); + + const plainData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const result = await DataCryptor.encrypt(plainData, keys); + + expect(result.payload).toBeInstanceOf(Uint8Array); + expect(result.iv).toBeInstanceOf(Uint8Array); + expect(result.iv.length).toBe(IV_LENGTH); + expect(result.keyIndex).toBe(1); + expect(result.payload).not.toEqual(plainData); + expect(result.payload.length).toBeGreaterThan(0); + }); + + it('generates different IV for each encryption', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('test-key'), 1); + + const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + + const result1 = await DataCryptor.encrypt(data, keys); + const result2 = await DataCryptor.encrypt(data, keys); + + expect(result1.iv).not.toEqual(result2.iv); + expect(result1.payload).not.toEqual(result2.payload); + }); + + it('uses correct key index from key handler', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('test-key'), 5); + + const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const result = await DataCryptor.encrypt(data, keys); + + expect(result.keyIndex).toBe(5); + }); + }); + + describe('decrypt', () => { + it('throws error when no key set for index', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const iv = new Uint8Array(IV_LENGTH); + + await expect(DataCryptor.decrypt(data, iv, keys, 1)).rejects.toThrow('No key set found'); + }); + + it('decrypts data successfully with correct key', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('test-key'), 1); + + const plainData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + + // First encrypt the data + const encrypted = await DataCryptor.encrypt(plainData, keys); + + // Then decrypt it + const decrypted = await DataCryptor.decrypt( + encrypted.payload, + encrypted.iv, + keys, + encrypted.keyIndex, + ); + + expect(decrypted.payload).toEqual(plainData); + }); + + it('fails to decrypt with incorrect key', async () => { + const keys1 = prepareParticipantTestKeys('participant1', {}); + const keys2 = prepareParticipantTestKeys('participant2', {}); + + await keys1.setKey(await createKeyMaterialFromString('correct-key'), 1); + await keys2.setKey(await createKeyMaterialFromString('wrong-key'), 1); + + const plainData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + + // Encrypt with first key + const encrypted = await DataCryptor.encrypt(plainData, keys1); + + // Try to decrypt with second (wrong) key + await expect( + DataCryptor.decrypt(encrypted.payload, encrypted.iv, keys2, encrypted.keyIndex), + ).rejects.toThrow(); + }); + + it('handles ratcheting when enabled', async () => { + const senderKeys = prepareParticipantTestKeys('sender', { + ratchetWindowSize: 2, + }); + const receiverKeys = prepareParticipantTestKeys('receiver', { + ratchetWindowSize: 2, + }); + + // Both start with the same initial key + const initialMaterial = await createKeyMaterialFromString('test-key'); + await senderKeys.setKey(initialMaterial, 1); + await receiverKeys.setKey(initialMaterial, 1); + + const plainData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + + // Sender ratchets their key forward + await senderKeys.ratchetKey(1, false); + + // Sender encrypts data with the ratcheted key + const encrypted = await DataCryptor.encrypt(plainData, senderKeys); + + // Receiver should be able to decrypt by automatically ratcheting their key + const decrypted = await DataCryptor.decrypt( + encrypted.payload, + encrypted.iv, + receiverKeys, + encrypted.keyIndex, + ); + + expect(decrypted.payload).toEqual(plainData); + }); + + it('respects ratchet window size limit', async () => { + // Create a scenario where we have valid encrypted data that requires ratcheting but it's disabled + const senderKeys = prepareParticipantTestKeys('sender', { + ratchetWindowSize: 10, // Large window for sender + }); + const receiverKeys = prepareParticipantTestKeys('receiver', { + ratchetWindowSize: 1, // No ratcheting allowed for receiver + }); + + // Both start with the same initial key + const initialMaterial = await createKeyMaterialFromString('test-key'); + await senderKeys.setKey(initialMaterial, 1); + await receiverKeys.setKey(initialMaterial, 1); + + const plainData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + + // Sender ratchets their key forward once + await senderKeys.ratchetKey(1); + await senderKeys.ratchetKey(1); + + // Sender encrypts data with the ratcheted key + const encrypted = await DataCryptor.encrypt(plainData, senderKeys); + + // Receiver should fail to decrypt with invalid key because ratcheting is limited (window size 1) + await expect( + DataCryptor.decrypt(encrypted.payload, encrypted.iv, receiverKeys, encrypted.keyIndex), + ).rejects.toThrow('valid key missing for participant'); + }); + + it('throws CryptorError when ratcheting disabled and decryption fails', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, { + ratchetWindowSize: 0, + }); + + await keys.setKey(await createKeyMaterialFromString('test-key'), 1); + + const invalidData = new Uint8Array([99, 98, 97, 96, 95, 94, 93, 92]); + const iv = new Uint8Array(IV_LENGTH); + crypto.getRandomValues(iv); + + await expect(DataCryptor.decrypt(invalidData, iv, keys, 1)).rejects.toThrow( + 'Decryption failed', + ); + }); + }); + + describe('round-trip encryption/decryption', () => { + it('encrypts and decrypts data correctly', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('round-trip-key'), 2); + + const originalData = new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31, 32, + ]); + + const encrypted = await DataCryptor.encrypt(originalData, keys); + const decrypted = await DataCryptor.decrypt( + encrypted.payload, + encrypted.iv, + keys, + encrypted.keyIndex, + ); + + expect(decrypted.payload).toEqual(originalData); + }); + + it('handles empty data', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('empty-data-key'), 1); + + const emptyData = new Uint8Array(0); + + const encrypted = await DataCryptor.encrypt(emptyData, keys); + const decrypted = await DataCryptor.decrypt( + encrypted.payload, + encrypted.iv, + keys, + encrypted.keyIndex, + ); + + expect(decrypted.payload).toEqual(emptyData); + }); + + it('handles large data', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('large-data-key'), 1); + + const largeData = new Uint8Array(1024); + for (let i = 0; i < largeData.length; i++) { + largeData[i] = i % 256; + } + + const encrypted = await DataCryptor.encrypt(largeData, keys); + const decrypted = await DataCryptor.decrypt( + encrypted.payload, + encrypted.iv, + keys, + encrypted.keyIndex, + ); + + expect(decrypted.payload).toEqual(largeData); + }); + }); + + describe('IV generation', () => { + it('generates unique IVs with performance.now() timestamp', async () => { + const keys = prepareParticipantTestKeys(participantIdentity, {}); + await keys.setKey(await createKeyMaterialFromString('iv-test-key'), 1); + + const data = new Uint8Array([1, 2, 3, 4]); + + vitest.useFakeTimers(); + const time1 = 1000; + vitest.setSystemTime(time1); + const result1 = await DataCryptor.encrypt(data, keys); + + vitest.setSystemTime(2000); + const result2 = await DataCryptor.encrypt(data, keys); + + vitest.useRealTimers(); + + // IVs should be different due to different timestamps and sendCount + expect(result1.iv).not.toEqual(result2.iv); + }); + }); +}); diff --git a/src/e2ee/worker/DataCryptor.ts b/src/e2ee/worker/DataCryptor.ts new file mode 100644 index 0000000000..8f9047a79f --- /dev/null +++ b/src/e2ee/worker/DataCryptor.ts @@ -0,0 +1,147 @@ +import { workerLogger } from '../../logger'; +import { ENCRYPTION_ALGORITHM } from '../constants'; +import { CryptorError, CryptorErrorReason } from '../errors'; +import type { DecodeRatchetOptions, KeySet, RatchetResult } from '../types'; +import { deriveKeys } from '../utils'; +import type { ParticipantKeyHandler } from './ParticipantKeyHandler'; + +export class DataCryptor { + private static sendCount = 0; + + private static makeIV(timestamp: number) { + const iv = new ArrayBuffer(12); + const ivView = new DataView(iv); + const randomBytes = crypto.getRandomValues(new Uint32Array(1)); + ivView.setUint32(0, randomBytes[0]); + ivView.setUint32(4, timestamp); + ivView.setUint32(8, timestamp - (DataCryptor.sendCount % 0xffff)); + DataCryptor.sendCount++; + + return iv; + } + + static async encrypt( + data: Uint8Array, + keys: ParticipantKeyHandler, + ): Promise<{ + payload: Uint8Array; + iv: Uint8Array; + keyIndex: number; + }> { + const iv = DataCryptor.makeIV(performance.now()); + const keySet = await keys.getKeySet(); + if (!keySet) { + throw new Error('No key set found'); + } + + const cipherText = await crypto.subtle.encrypt( + { + name: ENCRYPTION_ALGORITHM, + iv, + }, + keySet.encryptionKey, + new Uint8Array(data), + ); + + return { + payload: new Uint8Array(cipherText), + iv: new Uint8Array(iv), + keyIndex: keys.getCurrentKeyIndex(), + }; + } + + static async decrypt( + data: Uint8Array, + iv: Uint8Array, + keys: ParticipantKeyHandler, + keyIndex: number = 0, + initialMaterial?: KeySet, + ratchetOpts: DecodeRatchetOptions = { ratchetCount: 0 }, + ): Promise<{ + payload: Uint8Array; + }> { + const keySet = await keys.getKeySet(keyIndex); + if (!keySet) { + throw new Error('No key set found'); + } + + try { + const plainText = await crypto.subtle.decrypt( + { + name: ENCRYPTION_ALGORITHM, + iv, + }, + keySet.encryptionKey, + new Uint8Array(data), + ); + return { + payload: new Uint8Array(plainText), + }; + } catch (error: any) { + if (keys.keyProviderOptions.ratchetWindowSize > 0) { + if (ratchetOpts.ratchetCount < keys.keyProviderOptions.ratchetWindowSize) { + workerLogger.debug( + `DataCryptor: ratcheting key attempt ${ratchetOpts.ratchetCount} of ${ + keys.keyProviderOptions.ratchetWindowSize + }, for data packet`, + ); + + let ratchetedKeySet: KeySet | undefined; + let ratchetResult: RatchetResult | undefined; + if ((initialMaterial ?? keySet) === keys.getKeySet(keyIndex)) { + // only ratchet if the currently set key is still the same as the one used to decrypt this frame + // if not, it might be that a different frame has already ratcheted and we try with that one first + ratchetResult = await keys.ratchetKey(keyIndex, false); + + ratchetedKeySet = await deriveKeys( + ratchetResult.cryptoKey, + keys.keyProviderOptions.ratchetSalt, + ); + } + + const decryptedData = await DataCryptor.decrypt( + data, + iv, + keys, + keyIndex, + initialMaterial, + { + ratchetCount: ratchetOpts.ratchetCount + 1, + encryptionKey: ratchetedKeySet?.encryptionKey, + }, + ); + + if (decryptedData && ratchetedKeySet) { + // before updating the keys, make sure that the keySet used for this frame is still the same as the currently set key + // if it's not, a new key might have been set already, which we don't want to override + if ((initialMaterial ?? keySet) === keys.getKeySet(keyIndex)) { + keys.setKeySet(ratchetedKeySet, keyIndex, ratchetResult); + // decryption was successful, set the new key index to reflect the ratcheted key set + keys.setCurrentKeyIndex(keyIndex); + } + } + return decryptedData; + } else { + /** + * Because we only set a new key once decryption has been successful, + * we can be sure that we don't need to reset the key to the initial material at this point + * as the key has not been updated on the keyHandler instance + */ + + workerLogger.warn('DataCryptor: maximum ratchet attempts exceeded'); + throw new CryptorError( + `DataCryptor: valid key missing for participant ${keys.participantIdentity}`, + CryptorErrorReason.InvalidKey, + keys.participantIdentity, + ); + } + } else { + throw new CryptorError( + `DataCryptor: Decryption failed: ${error.message}`, + CryptorErrorReason.InvalidKey, + keys.participantIdentity, + ); + } + } + } +} diff --git a/src/e2ee/worker/ParticipantKeyHandler.ts b/src/e2ee/worker/ParticipantKeyHandler.ts index 4e3e36d5b2..2841aca6a5 100644 --- a/src/e2ee/worker/ParticipantKeyHandler.ts +++ b/src/e2ee/worker/ParticipantKeyHandler.ts @@ -23,11 +23,12 @@ export class ParticipantKeyHandler extends (EventEmitter as new () => TypedEvent private decryptionFailureCounts: Array; - private keyProviderOptions: KeyProviderOptions; - private ratchetPromiseMap: Map>; - private participantIdentity: string; + readonly participantIdentity: string; + + /** @internal */ + readonly keyProviderOptions: KeyProviderOptions; /** * true if the current key has not been marked as invalid diff --git a/src/e2ee/worker/e2ee.worker.ts b/src/e2ee/worker/e2ee.worker.ts index d4589eda2e..9be87107d8 100644 --- a/src/e2ee/worker/e2ee.worker.ts +++ b/src/e2ee/worker/e2ee.worker.ts @@ -5,7 +5,9 @@ import { KEY_PROVIDER_DEFAULTS } from '../constants'; import { CryptorErrorReason } from '../errors'; import { CryptorEvent, KeyHandlerEvent } from '../events'; import type { + DecryptDataResponseMessage, E2EEWorkerMessage, + EncryptDataResponseMessage, ErrorMessage, InitAck, KeyProviderOptions, @@ -14,6 +16,7 @@ import type { RatchetResult, ScriptTransformOptions, } from '../types'; +import { DataCryptor } from './DataCryptor'; import { FrameCryptor, encryptionEnabledMap } from './FrameCryptor'; import { ParticipantKeyHandler } from './ParticipantKeyHandler'; @@ -81,6 +84,50 @@ onmessage = (ev) => { data.codec, ); break; + + case 'encryptDataRequest': + const { + payload: encryptedPayload, + iv, + keyIndex, + } = await DataCryptor.encrypt( + data.payload, + getParticipantKeyHandler(data.participantIdentity), + ); + console.log('encrypted payload', { + original: data.payload, + encrypted: encryptedPayload, + iv, + }); + postMessage({ + kind: 'encryptDataResponse', + data: { + payload: encryptedPayload, + iv, + keyIndex, + uuid: data.uuid, + }, + } satisfies EncryptDataResponseMessage); + break; + + case 'decryptDataRequest': + const { payload: decryptedPayload } = await DataCryptor.decrypt( + data.payload, + data.iv, + getParticipantKeyHandler(data.participantIdentity), + data.keyIndex, + ); + console.log('decrypted payload', { + original: data.payload, + decrypted: decryptedPayload, + iv: data.iv, + }); + postMessage({ + kind: 'decryptDataResponse', + data: { payload: decryptedPayload, uuid: data.uuid }, + } satisfies DecryptDataResponseMessage); + break; + case 'setKey': if (useSharedKey) { await setSharedKey(data.key, data.keyIndex); diff --git a/src/index.ts b/src/index.ts index 8a059262a0..5327dceb71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ import { Mutex } from '@livekit/mutex'; -import { DataPacket_Kind, DisconnectReason, SubscriptionError, TrackType } from '@livekit/protocol'; +import { + DataPacket_Kind, + DisconnectReason, + Encryption_Type, + SubscriptionError, + TrackType, +} from '@livekit/protocol'; import { LogLevel, LoggerNames, getLogger, setLogExtension, setLogLevel } from './logger'; import DefaultReconnectPolicy from './room/DefaultReconnectPolicy'; import type { ReconnectContext, ReconnectPolicy } from './room/ReconnectPolicy'; @@ -79,6 +85,7 @@ export { ConnectionState, CriticalTimers, DataPacket_Kind, + Encryption_Type, DefaultReconnectPolicy, DisconnectReason, LocalAudioTrack, diff --git a/src/options.ts b/src/options.ts index a03b31bae6..73d5e3b9a1 100644 --- a/src/options.ts +++ b/src/options.ts @@ -88,10 +88,27 @@ export interface InternalRoomOptions { webAudioMix: boolean | WebAudioSettings; /** - * @experimental + * @deprecated Use `encryption` field instead. */ e2ee?: E2EEOptions; + /** + * @experimental + * Options for enabling end-to-end encryption. + */ + encryption?: E2EEOptions; + + /** + * @experimental + */ + + // TODO: add this back in for a subsequent release and deprecate `e2ee` above + // /** + // * @experimental + // * Options for enabling end-to-end encryption. + // */ + // encryption?: E2EEOptions; + loggerName?: string; } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index fe2eaf8b12..4db6f23a59 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -9,6 +9,9 @@ import { DataPacket, DataPacket_Kind, DisconnectReason, + EncryptedPacket, + EncryptedPacketPayload, + Encryption_Type, type JoinResponse, type LeaveRequest, LeaveRequest_Action, @@ -43,6 +46,8 @@ import { SignalConnectionState, toProtoSessionDescription, } from '../api/SignalClient'; +import type { BaseE2EEManager } from '../e2ee/E2eeManager'; +import { asEncryptablePacket } from '../e2ee/utils'; import log, { LoggerNames, getLogger } from '../logger'; import type { InternalRoomOptions } from '../options'; import { DataPacketBuffer } from '../utils/dataPacketBuffer'; @@ -116,6 +121,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit */ latestRemoteOfferId: number = 0; + /** @internal */ + e2eeManager: BaseE2EEManager | undefined; + get isClosed() { return this._isClosed; } @@ -710,12 +718,32 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (dp.value?.case === 'speaker') { // dispatch speaker updates this.emit(EngineEvent.ActiveSpeakersUpdate, dp.value.value.speakers); + } else if (dp.value?.case === 'encryptedPacket') { + if (!this.e2eeManager) { + this.log.error('Received encrypted packet but E2EE not set up', this.logContext); + return; + } + const decryptedData = await this.e2eeManager?.handleEncryptedData( + dp.value.value.encryptedValue, + dp.value.value.iv, + dp.participantIdentity, + dp.value.value.keyIndex, + ); + const decryptedPacket = EncryptedPacketPayload.fromBinary(decryptedData.payload); + const newDp = new DataPacket({ + value: decryptedPacket.value, + }); + if (newDp.value?.case === 'user') { + // compatibility + applyUserDataCompat(newDp, newDp.value.value); + } + this.emit(EngineEvent.DataPacketReceived, newDp, dp.value.value.encryptionType); } else { if (dp.value?.case === 'user') { // compatibility applyUserDataCompat(dp, dp.value.value); } - this.emit(EngineEvent.DataPacketReceived, dp); + this.emit(EngineEvent.DataPacketReceived, dp, Encryption_Type.NONE); } } finally { unlock(); @@ -1191,11 +1219,28 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // make sure we do have a data connection await this.ensurePublisherConnected(kind); + if (this.e2eeManager && this.e2eeManager.isDataChannelEncryptionEnabled) { + const encryptablePacket = asEncryptablePacket(packet); + if (encryptablePacket) { + const encryptedData = await this.e2eeManager.encryptData(encryptablePacket.toBinary()); + packet.value = { + case: 'encryptedPacket', + value: new EncryptedPacket({ + encryptedValue: encryptedData.payload, + iv: encryptedData.iv, + keyIndex: encryptedData.keyIndex, + }), + }; + } + } + if (kind === DataPacket_Kind.RELIABLE) { packet.sequence = this.reliableDataSequence; this.reliableDataSequence += 1; } + const msg = packet.toBinary(); + const dc = this.dataChannelForKind(kind); if (dc) { if (kind === DataPacket_Kind.RELIABLE) { @@ -1558,7 +1603,7 @@ export type EngineEventCallbacks = { receiver: RTCRtpReceiver, ) => void; activeSpeakersUpdate: (speakers: Array) => void; - dataPacketReceived: (packet: DataPacket) => void; + dataPacketReceived: (packet: DataPacket, encryptionType: Encryption_Type) => void; transcriptionReceived: (transcription: Transcription) => void; transportsCreated: (publisher: PCTransport, subscriber: PCTransport) => void; /** @internal */ diff --git a/src/room/Room.ts b/src/room/Room.ts index 0bc66959e0..ceea07f2d6 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -5,6 +5,7 @@ import { type DataPacket, DataPacket_Kind, DisconnectReason, + Encryption_Type, JoinResponse, LeaveRequest, LeaveRequest_Action, @@ -198,6 +199,10 @@ class Room extends (EventEmitter as new () => TypedEmitter) private rpcHandlers: Map Promise> = new Map(); + get hasE2EESetup(): boolean { + return this.e2eeManager !== undefined; + } + /** * Creates a new Room, the primary construct for a LiveKit session. * @param options @@ -241,6 +246,12 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.outgoingDataStreamManager, ); + if (this.options.e2ee || this.options.encryption) { + this.setupE2EE(); + } + + this.engine.e2eeManager = this.e2eeManager; + if (this.options.videoCaptureDefaults.deviceId) { this.localParticipant.activeDeviceMap.set( 'videoinput', @@ -260,10 +271,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) ).catch((e) => this.log.warn(`Could not set audio output: ${e.message}`, this.logContext)); } - if (this.options.e2ee) { - this.setupE2EE(); - } - if (isWeb()) { const abortController = new AbortController(); @@ -355,11 +362,16 @@ class Room extends (EventEmitter as new () => TypedEmitter) } private setupE2EE() { - if (this.options.e2ee) { - if ('e2eeManager' in this.options.e2ee) { - this.e2eeManager = this.options.e2ee.e2eeManager; + // when encryption is enabled via `options.encryption`, we enable data channel encryption + + const dcEncryptionEnabled = !!this.options.encryption; + const e2eeOptions = this.options.encryption || this.options.e2ee; + + if (e2eeOptions) { + if ('e2eeManager' in e2eeOptions) { + this.e2eeManager = e2eeOptions.e2eeManager; } else { - this.e2eeManager = new E2EEManager(this.options.e2ee); + this.e2eeManager = new E2EEManager(e2eeOptions, dcEncryptionEnabled); } this.e2eeManager.on( EncryptionEvent.ParticipantEncryptionStatusChanged, @@ -443,6 +455,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) } this.engine = new RTCEngine(this.options); + this.engine.e2eeManager = this.e2eeManager; this.engine .on(EngineEvent.ParticipantUpdate, this.handleParticipantUpdates) @@ -789,7 +802,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.localParticipant.identity = pi.identity; this.localParticipant.setEnabledPublishCodecs(joinResponse.enabledPublishCodecs); - if (this.options.e2ee && this.e2eeManager) { + if (this.e2eeManager) { try { this.e2eeManager.setSifTrailer(joinResponse.sifTrailer); } catch (e: any) { @@ -1711,11 +1724,11 @@ class Room extends (EventEmitter as new () => TypedEmitter) pub.setSubscriptionError(update.err); }; - private handleDataPacket = (packet: DataPacket) => { + private handleDataPacket = (packet: DataPacket, encryptionType: Encryption_Type) => { // find the participant const participant = this.remoteParticipants.get(packet.participantIdentity); if (packet.value.case === 'user') { - this.handleUserPacket(participant, packet.value.value, packet.kind); + this.handleUserPacket(participant, packet.value.value, packet.kind, encryptionType); } else if (packet.value.case === 'transcription') { this.handleTranscription(participant, packet.value.value); } else if (packet.value.case === 'sipDtmf') { @@ -1729,7 +1742,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) packet.value.case === 'streamChunk' || packet.value.case === 'streamTrailer' ) { - this.handleDataStream(packet); + this.handleDataStream(packet, encryptionType); } else if (packet.value.case === 'rpcRequest') { const rpc = packet.value.value; this.handleIncomingRpcRequest( @@ -1747,11 +1760,19 @@ class Room extends (EventEmitter as new () => TypedEmitter) participant: RemoteParticipant | undefined, userPacket: UserPacket, kind: DataPacket_Kind, + encryptionType: Encryption_Type, ) => { - this.emit(RoomEvent.DataReceived, userPacket.payload, participant, kind, userPacket.topic); + this.emit( + RoomEvent.DataReceived, + userPacket.payload, + participant, + kind, + userPacket.topic, + encryptionType, + ); // also emit on the participant - participant?.emit(ParticipantEvent.DataReceived, userPacket.payload, kind); + participant?.emit(ParticipantEvent.DataReceived, userPacket.payload, kind, encryptionType); }; private handleSipDtmf = (participant: RemoteParticipant | undefined, dtmf: SipDTMF) => { @@ -1791,8 +1812,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.emit(RoomEvent.MetricsReceived, metrics, participant); }; - private handleDataStream = (packet: DataPacket) => { - this.incomingDataStreamManager.handleDataStreamPacket(packet); + private handleDataStream = (packet: DataPacket, encryptionType: Encryption_Type) => { + this.incomingDataStreamManager.handleDataStreamPacket(packet, encryptionType); }; private async handleIncomingRpcRequest( @@ -2596,6 +2617,7 @@ export type RoomEventCallbacks = { participant?: RemoteParticipant, kind?: DataPacket_Kind, topic?: string, + encryptionType?: Encryption_Type, ) => void; sipDTMFReceived: (dtmf: SipDTMF, participant?: RemoteParticipant) => void; transcriptionReceived: ( diff --git a/src/room/data-stream/incoming/IncomingDataStreamManager.ts b/src/room/data-stream/incoming/IncomingDataStreamManager.ts index 996c62ba8c..413b28c645 100644 --- a/src/room/data-stream/incoming/IncomingDataStreamManager.ts +++ b/src/room/data-stream/incoming/IncomingDataStreamManager.ts @@ -3,6 +3,7 @@ import { DataStream_Chunk, DataStream_Header, DataStream_Trailer, + Encryption_Type, } from '@livekit/protocol'; import log from '../../../logger'; import { DataStreamError, DataStreamErrorReason } from '../../errors'; @@ -89,20 +90,28 @@ export default class IncomingDataStreamManager { } } - async handleDataStreamPacket(packet: DataPacket) { + async handleDataStreamPacket(packet: DataPacket, encryptionType: Encryption_Type) { switch (packet.value.case) { case 'streamHeader': - return this.handleStreamHeader(packet.value.value, packet.participantIdentity); + return this.handleStreamHeader( + packet.value.value, + packet.participantIdentity, + encryptionType, + ); case 'streamChunk': - return this.handleStreamChunk(packet.value.value); + return this.handleStreamChunk(packet.value.value, encryptionType); case 'streamTrailer': - return this.handleStreamTrailer(packet.value.value); + return this.handleStreamTrailer(packet.value.value, encryptionType); default: throw new Error(`DataPacket of value "${packet.value.case}" is not data stream related!`); } } - private async handleStreamHeader(streamHeader: DataStream_Header, participantIdentity: string) { + private async handleStreamHeader( + streamHeader: DataStream_Header, + participantIdentity: string, + encryptionType: Encryption_Type, + ) { if (streamHeader.contentHeader.case === 'byteHeader') { const streamHandlerCallback = this.byteStreamHandlers.get(streamHeader.topic); if (!streamHandlerCallback) { @@ -124,6 +133,7 @@ export default class IncomingDataStreamManager { topic: streamHeader.topic, timestamp: bigIntToNumber(streamHeader.timestamp), attributes: streamHeader.attributes, + encryptionType, }; const stream = new ReadableStream({ start: (controller) => { @@ -175,6 +185,7 @@ export default class IncomingDataStreamManager { topic: streamHeader.topic, timestamp: Number(streamHeader.timestamp), attributes: streamHeader.attributes, + encryptionType, }; const stream = new ReadableStream({ @@ -209,39 +220,68 @@ export default class IncomingDataStreamManager { } } - private handleStreamChunk(chunk: DataStream_Chunk) { + private handleStreamChunk(chunk: DataStream_Chunk, encryptionType: Encryption_Type) { const fileBuffer = this.byteStreamControllers.get(chunk.streamId); if (fileBuffer) { - if (chunk.content.length > 0) { + if (fileBuffer.info.encryptionType !== encryptionType) { + fileBuffer.controller.error( + new DataStreamError( + `Encryption type mismatch for stream ${chunk.streamId}. Expected ${encryptionType}, got ${fileBuffer.info.encryptionType}`, + DataStreamErrorReason.EncryptionTypeMismatch, + ), + ); + this.byteStreamControllers.delete(chunk.streamId); + } else if (chunk.content.length > 0) { fileBuffer.controller.enqueue(chunk); } } const textBuffer = this.textStreamControllers.get(chunk.streamId); if (textBuffer) { - if (chunk.content.length > 0) { + if (textBuffer.info.encryptionType !== encryptionType) { + textBuffer.controller.error( + new DataStreamError( + `Encryption type mismatch for stream ${chunk.streamId}. Expected ${encryptionType}, got ${textBuffer.info.encryptionType}`, + DataStreamErrorReason.EncryptionTypeMismatch, + ), + ); + this.textStreamControllers.delete(chunk.streamId); + } else if (chunk.content.length > 0) { textBuffer.controller.enqueue(chunk); } } } - private handleStreamTrailer(trailer: DataStream_Trailer) { + private handleStreamTrailer(trailer: DataStream_Trailer, encryptionType: Encryption_Type) { const textBuffer = this.textStreamControllers.get(trailer.streamId); if (textBuffer) { - textBuffer.info.attributes = { - ...textBuffer.info.attributes, - ...trailer.attributes, - }; - textBuffer.controller.close(); - this.textStreamControllers.delete(trailer.streamId); + if (textBuffer.info.encryptionType !== encryptionType) { + textBuffer.controller.error( + new DataStreamError( + `Encryption type mismatch for stream ${trailer.streamId}. Expected ${encryptionType}, got ${textBuffer.info.encryptionType}`, + DataStreamErrorReason.EncryptionTypeMismatch, + ), + ); + } else { + textBuffer.info.attributes = { ...textBuffer.info.attributes, ...trailer.attributes }; + textBuffer.controller.close(); + this.textStreamControllers.delete(trailer.streamId); + } } const fileBuffer = this.byteStreamControllers.get(trailer.streamId); if (fileBuffer) { - { + if (fileBuffer.info.encryptionType !== encryptionType) { + fileBuffer.controller.error( + new DataStreamError( + `Encryption type mismatch for stream ${trailer.streamId}. Expected ${encryptionType}, got ${fileBuffer.info.encryptionType}`, + DataStreamErrorReason.EncryptionTypeMismatch, + ), + ); + } else { fileBuffer.info.attributes = { ...fileBuffer.info.attributes, ...trailer.attributes }; fileBuffer.controller.close(); - this.byteStreamControllers.delete(trailer.streamId); } + this.byteStreamControllers.delete(trailer.streamId); } } } diff --git a/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts b/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts index aa1166ad03..8258a97484 100644 --- a/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +++ b/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts @@ -8,6 +8,7 @@ import { DataStream_OperationType, DataStream_TextHeader, DataStream_Trailer, + Encryption_Type, } from '@livekit/protocol'; import { type StructuredLogger } from '../../../logger'; import type RTCEngine from '../../RTCEngine'; @@ -104,6 +105,9 @@ export default class OutgoingDataStreamManager { topic: options?.topic ?? '', size: options?.totalSize, attributes: options?.attributes, + encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled + ? Encryption_Type.GCM + : Encryption_Type.NONE, }; const header = new DataStream_Header({ streamId, @@ -231,6 +235,9 @@ export default class OutgoingDataStreamManager { attributes: options?.attributes, size: options?.totalSize, name: options?.name ?? 'unknown', + encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled + ? Encryption_Type.GCM + : Encryption_Type.NONE, }; const header = new DataStream_Header({ diff --git a/src/room/errors.ts b/src/room/errors.ts index 28e4b8c238..5c4c842aab 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -130,6 +130,9 @@ export enum DataStreamErrorReason { // Unable to register a stream handler more than once. HandlerAlreadyRegistered = 7, + + // Encryption type mismatch. + EncryptionTypeMismatch = 8, } export class DataStreamError extends LivekitError { diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 5e77eddc61..bd4b937900 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1634,16 +1634,18 @@ export default class LocalParticipant extends Participant { const destinationIdentities = options.destinationIdentities; const topic = options.topic; + let userPacket = new UserPacket({ + participantIdentity: this.identity, + payload: data, + destinationIdentities, + topic, + }); + const packet = new DataPacket({ kind: kind, value: { case: 'user', - value: new UserPacket({ - participantIdentity: this.identity, - payload: data, - destinationIdentities, - topic, - }), + value: userPacket, }, }); diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index cb8a3a6742..5cc2f72202 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -1,5 +1,6 @@ import { DataPacket_Kind, + Encryption_Type, ParticipantInfo, ParticipantInfo_State, ParticipantInfo_Kind as ParticipantKind, @@ -408,7 +409,11 @@ export type ParticipantEventCallbacks = { localSenderCreated: (sender: RTCRtpSender, track: Track) => void; participantMetadataChanged: (prevMetadata: string | undefined, participant?: any) => void; participantNameChanged: (name: string) => void; - dataReceived: (payload: Uint8Array, kind: DataPacket_Kind) => void; + dataReceived: ( + payload: Uint8Array, + kind: DataPacket_Kind, + encryptionType?: Encryption_Type, + ) => void; sipDTMFReceived: (dtmf: SipDTMF) => void; transcriptionReceived: ( transcription: TranscriptionSegment[], diff --git a/src/room/types.ts b/src/room/types.ts index 55b2601cb3..228ed56025 100644 --- a/src/room/types.ts +++ b/src/room/types.ts @@ -136,6 +136,7 @@ export interface BaseStreamInfo { /** total size in bytes for finite streams and undefined for streams of unknown size */ size?: number; attributes?: Record; + encryptionType: Encryption_Type; } export interface ByteStreamInfo extends BaseStreamInfo { name: string;