From 48eec08509e75368d2fc81fdac48de5c09035f31 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Thu, 6 Nov 2025 17:52:14 +0100 Subject: [PATCH] ov-components: Implement E2EE support for chat messages and participant names and add E2EE service --- .../audio-wave/audio-wave.component.ts | 12 +- .../panel/chat-panel/chat-panel.component.ts | 2 +- .../participant-panel-item.component.html | 2 +- .../components/session/session.component.ts | 39 +- .../components/toolbar/toolbar.component.ts | 2 +- .../videoconference.component.ts | 8 +- .../api/videoconference.directive.ts | 6 +- .../src/lib/models/participant.model.ts | 13 +- .../lib/openvidu-components-angular.module.ts | 2 + .../src/lib/services/chat/chat.service.ts | 39 +- .../src/lib/services/e2ee/e2ee.service.ts | 337 ++++++++++++++++++ .../lib/services/openvidu/openvidu.service.ts | 39 +- .../participant/participant.service.ts | 44 ++- .../src/public-api.ts | 1 + 14 files changed, 498 insertions(+), 48 deletions(-) create mode 100644 openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/e2ee/e2ee.service.ts diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/audio-wave/audio-wave.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/audio-wave/audio-wave.component.ts index e4e795650..9e47bd9ea 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/audio-wave/audio-wave.component.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/audio-wave/audio-wave.component.ts @@ -5,11 +5,13 @@ import { Component } from '@angular/core'; */ @Component({ selector: 'ov-audio-wave', - template: `
-
-
-
-
`, + template: ` +
+
+
+
+
+ `, styleUrls: ['./audio-wave.component.scss'], standalone: false }) diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/chat-panel/chat-panel.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/chat-panel/chat-panel.component.ts index ad80a8710..494d944c7 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/chat-panel/chat-panel.component.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/chat-panel/chat-panel.component.ts @@ -110,7 +110,7 @@ export class ChatPanelComponent implements OnInit, AfterViewInit { } private subscribeToMessages() { - this.chatService.messagesObs.pipe(takeUntil(this.destroy$)).subscribe((messages: ChatMessage[]) => { + this.chatService.chatMessages$.pipe(takeUntil(this.destroy$)).subscribe((messages: ChatMessage[]) => { this.messageList = messages; if (this.panelService.isChatPanelOpened()) { this.scrollToBottom(); diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/participants-panel/participant-panel-item/participant-panel-item.component.html b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/participants-panel/participant-panel-item/participant-panel-item.component.html index 90e53d638..abe70f06a 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/participants-panel/participant-panel-item/participant-panel-item.component.html +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/panel/participants-panel/participant-panel-item/participant-panel-item.component.html @@ -8,7 +8,7 @@ [style.background-color]="_participant?.colorProfile" [attr.aria-label]="'Avatar for ' + participantDisplayName" > - {{_participant.hasEncryptionError ? 'lock_person' : 'person'}} + {{ _participant.hasEncryptionError ? 'lock_person' : 'person' }} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts index ffad39287..533630495 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts @@ -50,6 +50,7 @@ import { ParticipantLeftEvent, ParticipantLeftReason, ParticipantModel } from '. import { RecordingStatus } from '../../models/recording.model'; import { TemplateManagerService, SessionTemplateConfiguration } from '../../services/template/template-manager.service'; import { ViewportService } from '../../services/viewport/viewport.service'; +import { E2eeService } from '../../services/e2ee/e2ee.service'; /** * @internal @@ -138,7 +139,8 @@ export class SessionComponent implements OnInit, OnDestroy { private backgroundService: VirtualBackgroundService, private cd: ChangeDetectorRef, private templateManagerService: TemplateManagerService, - protected viewportService: ViewportService + protected viewportService: ViewportService, + private e2eeService: E2eeService ) { this.log = this.loggerSrv.get('SessionComponent'); this.setupTemplates(); @@ -461,13 +463,38 @@ export class SessionComponent implements OnInit, OnDestroy { private subscribeToDataMessage() { this.room.on( RoomEvent.DataReceived, - (payload: Uint8Array, participant?: RemoteParticipant, _?: DataPacket_Kind, topic?: string) => { - const event = JSON.parse(new TextDecoder().decode(payload)); - this.log.d(`Data event received: ${topic}`); + async (payload: Uint8Array, participant?: RemoteParticipant, _?: DataPacket_Kind, topic?: string) => { + const storedParticipant = this.participantService.getRemoteParticipantBySid(participant?.sid || ''); + if (!storedParticipant) { + this.log.w('DataReceived from unknown participant', participant); + return; + } + + const { identity: participantIdentity, name: participantName } = storedParticipant; + // Decrypt payload if it's a CHAT message and E2EE is enabled + let decryptedPayload: Uint8Array = payload; + if (topic === DataTopic.CHAT && this.e2eeService.isEnabled) { + decryptedPayload = await this.e2eeService.decryptOrMask( + payload, + participantIdentity, + JSON.stringify({ message: '******' }) // The fallback text must be a valid JSON + ); + } + + // Decode and parse the JSON event + let event: any; + try { + event = JSON.parse(new TextDecoder().decode(decryptedPayload)); + this.log.d(`Data event received: ${topic}`); + } catch (parseError) { + this.log.e('Error parsing data message:', parseError); + return; // Can't process malformed data + } + + // Handle the event based on topic switch (topic) { case DataTopic.CHAT: - const participantName = participant?.name || 'Unknown'; - this.chatService.addRemoteMessage(event.message, participantName); + this.chatService.addRemoteMessage(event.message, participantName || participantIdentity || 'Unknown'); break; case DataTopic.RECORDING_STARTING: this.log.d('Recording is starting', event); diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/toolbar/toolbar.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/toolbar/toolbar.component.ts index 8f16c5d02..d9c0382e7 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/toolbar/toolbar.component.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/toolbar/toolbar.component.ts @@ -766,7 +766,7 @@ export class ToolbarComponent implements OnInit, OnDestroy, AfterViewInit { } private subscribeToChatMessages() { - this.chatService.messagesObs.pipe(skip(1), takeUntil(this.destroy$)).subscribe((messages) => { + this.chatService.chatMessages$.pipe(skip(1), takeUntil(this.destroy$)).subscribe((messages) => { if (!this.panelService.isChatPanelOpened()) { this.unreadMessages++; } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts index af8e41b8d..9302578d2 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts @@ -65,6 +65,7 @@ import { LeaveButtonDirective } from '../../directives/template/internals.directive'; import { OpenViduThemeService } from '../../services/theme/theme.service'; +import { E2eeService } from '../../services/e2ee/e2ee.service'; /** * The **VideoconferenceComponent** is the parent of all OpenVidu components. @@ -712,7 +713,8 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit { private actionService: ActionService, private libService: OpenViduComponentsConfigService, private templateManagerService: TemplateManagerService, - private themeService: OpenViduThemeService + private themeService: OpenViduThemeService, + private e2eeService: E2eeService ) { this.log = this.loggerSrv.get('VideoconferenceComponent'); @@ -1042,9 +1044,9 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit { } }); - this.libService.participantName$.pipe(takeUntil(this.destroy$)).subscribe((name: string) => { + this.libService.participantName$.pipe(takeUntil(this.destroy$)).subscribe(async (name: string) => { if (name) { - this.latestParticipantName = name; + this.latestParticipantName = await this.e2eeService.decrypt(name); this.storageSrv.setParticipantName(name); // If we're waiting for a participant name to proceed with joining, do it now diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/api/videoconference.directive.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/api/videoconference.directive.ts index 4a8036b4c..74b39ab6d 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/api/videoconference.directive.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/api/videoconference.directive.ts @@ -535,8 +535,10 @@ export class ParticipantNameDirective implements AfterViewInit, OnDestroy { /** * @ignore */ - update(value: string) { - if (value) this.libService.updateGeneralConfig({ participantName: value }); + update(participantName: string) { + if (participantName) { + this.libService.updateGeneralConfig({ participantName }); + } } } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/participant.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/participant.model.ts index 025ef7815..b9a95666b 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/participant.model.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/participant.model.ts @@ -129,6 +129,7 @@ export class ParticipantModel { private speaking: boolean = false; private customVideoTrack: Partial; private _hasEncryptionError: boolean = false; + private _decryptedName: string | undefined; constructor(props: ParticipantProperties) { this.participant = props.participant; @@ -171,8 +172,7 @@ export class ParticipantModel { * @returns string */ get name(): string | undefined { - return this.participant.name; - // return this.identity; + return this._decryptedName ?? this.participant.name; } /** @@ -569,4 +569,13 @@ export class ParticipantModel { setEncryptionError(hasError: boolean) { this._hasEncryptionError = hasError; } + + /** + * Sets the decrypted name for this participant. + * @param decryptedName - The decrypted participant name + * @internal + */ + setDecryptedName(decryptedName: string | undefined) { + this._decryptedName = decryptedName; + } } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts index fd6ee2346..f43f57c4e 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts @@ -23,6 +23,7 @@ import { GlobalConfigService } from './services/config/global-config.service'; import { OpenViduComponentsConfigService } from './services/config/directive-config.service'; import { OpenViduComponentsUiModule } from './openvidu-components-angular-ui.module'; import { ViewportService } from './services/viewport/viewport.service'; +import { E2eeService } from './services/e2ee/e2ee.service'; @NgModule({ imports: [OpenViduComponentsUiModule], @@ -52,6 +53,7 @@ export class OpenViduComponentsModule { StorageService, VirtualBackgroundService, ViewportService, + E2eeService, provideHttpClient(withInterceptorsFromDi()) ]; diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/chat/chat.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/chat/chat.service.ts index db86ced6c..8d99527bb 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/chat/chat.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/chat/chat.service.ts @@ -13,6 +13,7 @@ import { PanelService } from '../panel/panel.service'; import { ParticipantService } from '../participant/participant.service'; import { PanelType } from '../../models/panel.model'; import { TranslateService } from '../translate/translate.service'; +import { E2eeService } from '../e2ee/e2ee.service'; /** * @internal @@ -21,7 +22,7 @@ import { TranslateService } from '../translate/translate.service'; providedIn: 'root' }) export class ChatService { - messagesObs: Observable; + chatMessages$: Observable; private messageSound: HTMLAudioElement; private _messageList = >new BehaviorSubject([]); private messageList: ChatMessage[] = []; @@ -31,10 +32,11 @@ export class ChatService { private participantService: ParticipantService, private panelService: PanelService, private actionService: ActionService, - private translateService: TranslateService + private translateService: TranslateService, + private e2eeService: E2eeService ) { this.log = this.loggerSrv.get('ChatService'); - this.messagesObs = this._messageList.asObservable(); + this.chatMessages$ = this._messageList.asObservable(); this.messageSound = new Audio( 'data:audio/wav;base64,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' ); @@ -60,13 +62,32 @@ export class ChatService { } } + /** + * Sends a chat message through the data channel. + * If E2EE is enabled, the message will be encrypted before sending. + * + * @param message The message text to send + */ async sendMessage(message: string) { - message = message.replace(/ +(?= )/g, ''); - if (message !== '' && message !== ' ') { - const strData = JSON.stringify({ message }); - const data: Uint8Array = new TextEncoder().encode(strData); - await this.participantService.publishData(data, { topic: DataTopic.CHAT, reliable: true }); - this.addMessage(message, true, this.participantService.getMyName()!); + const plainTextMessage = message.replace(/ +(?= )/g, ''); + if (plainTextMessage !== '' && plainTextMessage !== ' ') { + try { + // Create message payload + const payload = JSON.stringify({ message: plainTextMessage }); + const plainData: Uint8Array = new TextEncoder().encode(payload); + + // Encrypt data if E2EE is enabled (Uint8Array → Uint8Array) + const dataToSend: Uint8Array = await this.e2eeService.encrypt(plainData); + + // Send through data channel + await this.participantService.publishData(dataToSend, { topic: DataTopic.CHAT, reliable: true }); + + // Add to local message list + this.addMessage(plainTextMessage, true, this.participantService.getMyName()!); + } catch (error) { + this.log.e('Error sending chat message:', error); + throw error; + } } } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/e2ee/e2ee.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/e2ee/e2ee.service.ts new file mode 100644 index 000000000..3f14173b0 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/e2ee/e2ee.service.ts @@ -0,0 +1,337 @@ +import { Injectable } from '@angular/core'; +import { OpenViduComponentsConfigService } from '../config/directive-config.service'; +import { Subject, takeUntil } from 'rxjs'; +import { createKeyMaterialFromString, deriveKeys } from 'livekit-client'; + +/** + * Independent E2EE Service for encrypting and decrypting text-based content + * (chat messages, participant names, metadata, etc.) + * + * This service uses LiveKit's key derivation utilities combined with Web Crypto API: + * - Uses createKeyMaterialFromString from livekit-client for key material generation (PBKDF2) + * - Uses deriveKeys from livekit-client for key derivation (HKDF) + * - Uses Web Crypto API (AES-GCM) for actual encryption/decryption + * - Generates random IV for each encryption operation + */ +@Injectable({ + providedIn: 'root', +}) +export class E2eeService { + private static readonly ENCRYPTION_ALGORITHM = 'AES-GCM'; + private static readonly IV_LENGTH = 12; + private static readonly SALT = 'livekit-e2ee-data'; // Salt for HKDF key derivation + + private decryptionCache = new Map(); + private destroy$ = new Subject(); + private isE2EEEnabled = false; + + private encryptionKey: CryptoKey | undefined; + + constructor(protected configService: OpenViduComponentsConfigService) { + // Monitor E2EE key changes + this.configService.e2eeKey$.pipe(takeUntil(this.destroy$)).subscribe(async (key: any) => { + await this.setE2EEKey(key); + }); + } + + async setE2EEKey(key: string | null): Promise { + if (key) { + this.isE2EEEnabled = true; + this.decryptionCache.clear(); + + await this.deriveEncryptionKey(key); + } else { + this.isE2EEEnabled = false; + this.encryptionKey = undefined; + } + } + + /** + * Derives encryption key from passphrase using LiveKit's key derivation utilities + * @param passphrase The E2EE passphrase + * @private + */ + private async deriveEncryptionKey(passphrase: string): Promise { + try { + // Use LiveKit's createKeyMaterialFromString (PBKDF2) + const keyMaterial = await createKeyMaterialFromString(passphrase); + + // Use LiveKit's deriveKeys to get encryption key (HKDF) + const derivedKeys = await deriveKeys(keyMaterial, E2eeService.SALT); + + // Store the encryption key for use in encrypt/decrypt operations + this.encryptionKey = derivedKeys.encryptionKey; + } catch (error) { + console.error('Failed to derive encryption key:', error); + this.encryptionKey = undefined; + } + } + + /** + * Checks if E2EE is currently enabled and encryption key is ready + */ + get isEnabled(): boolean { + return this.isE2EEEnabled && !!this.encryptionKey; + } + + /** + * Generates a random initialization vector for encryption + * @private + */ + private static generateIV(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(E2eeService.IV_LENGTH)); + } + + /** + * Encrypts text content using Web Crypto API with LiveKit-derived keys. + * Returns base64-encoded string suitable for metadata/names. + * + * @param text Plain text to encrypt + * @returns Encrypted text in base64 format, or original text if E2EE is disabled + */ + async encrypt(text: string): Promise; + + /** + * Encrypts binary data using Web Crypto API with LiveKit-derived keys. + * Returns Uint8Array suitable for data channels. + * + * @param data Plain data to encrypt + * @returns Encrypted data as Uint8Array, or original data if E2EE is disabled + */ + async encrypt(data: Uint8Array): Promise; + + /** + * Implementation of encrypt overloads + */ + async encrypt(input: string | Uint8Array): Promise { + if (!this.isEnabled) { + return input; + } + + const isString = typeof input === 'string'; + if (isString && !input) { + return input; + } + + if (!this.encryptionKey) { + console.warn('E2EE encryption not available: CryptoKey not initialized. Returning unencrypted data.'); + return input; + } + + try { + // Convert string to Uint8Array if needed + const data = isString ? new TextEncoder().encode(input as string) : (input as Uint8Array); + + // Generate a random IV for this encryption + const iv = E2eeService.generateIV(); + + // Encrypt the data using Web Crypto API with AES-GCM + const encryptedBuffer = await crypto.subtle.encrypt( + { + name: E2eeService.ENCRYPTION_ALGORITHM, + iv: iv as BufferSource + }, + this.encryptionKey, + data as BufferSource + ); + + const encryptedData = new Uint8Array(encryptedBuffer); + + // Combine IV + encrypted payload for transport + // Format: [iv(12 bytes)][payload(variable)] + const combined = new Uint8Array(iv.length + encryptedData.length); + combined.set(iv, 0); + combined.set(encryptedData, iv.length); + + // Return as base64 for strings, Uint8Array for binary data + return isString ? btoa(String.fromCharCode(...combined)) : combined; + } catch (error) { + console.error('E2EE encryption failed:', error); + // Return original input if encryption fails + return input; + } + } + + /** + * Decrypts text content from base64 format using Web Crypto API. + * Suitable for decrypting participant names, metadata, etc. + * + * @param encryptedText Encrypted text in base64 format + * @param participantIdentity Identity of the participant who encrypted the content (optional, used for caching) + * @returns Decrypted plain text, or throws error if decryption fails + */ + async decrypt(encryptedText: string, participantIdentity?: string): Promise; + + /** + * Decrypts binary data from Uint8Array using Web Crypto API. + * Suitable for decrypting data channel messages. + * + * If E2EE is not enabled, returns the original encryptedData. + * + * @param encryptedData Encrypted data as Uint8Array (format: [iv][payload]) + * @param participantIdentity Identity of the participant who encrypted the content (optional) + * @returns Decrypted data as Uint8Array + */ + async decrypt(encryptedData: Uint8Array, participantIdentity?: string): Promise; + + /** + * Implementation of decrypt overloads + */ + async decrypt(input: string | Uint8Array, participantIdentity?: string): Promise { + if (!this.isEnabled) { + return input; + } + + const isString = typeof input === 'string'; + if (isString && !input) { + return input; + } + + // Check cache for strings (caching binary data would be too memory intensive) + if (isString) { + const cacheKey = `${participantIdentity || 'unknown'}:${input}`; + if (this.decryptionCache.has(cacheKey)) { + return this.decryptionCache.get(cacheKey)!; + } + } + + if (!this.encryptionKey) { + throw new Error('E2EE decryption not available: CryptoKey not initialized'); + } + + try { + // Convert to Uint8Array if string (base64) + const combined = isString ? Uint8Array.from(atob(input as string), (c) => c.charCodeAt(0)) : (input as Uint8Array); + + // Extract components: iv(12) + payload(variable) + const iv = combined.slice(0, E2eeService.IV_LENGTH); + const payload = combined.slice(E2eeService.IV_LENGTH); + + // Decrypt the data using Web Crypto API with AES-GCM + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: E2eeService.ENCRYPTION_ALGORITHM, + iv: iv as BufferSource + }, + this.encryptionKey, + payload as BufferSource + ); + + const decryptedData = new Uint8Array(decryptedBuffer); + + // Return as string or Uint8Array depending on input type + if (isString) { + const decoder = new TextDecoder(); + const result = decoder.decode(decryptedData); + + // Cache successful string decryption + const cacheKey = `${participantIdentity || 'unknown'}:${input}`; + this.decryptionCache.set(cacheKey, result); + + // Limit cache size to prevent memory issues + if (this.decryptionCache.size > 1000) { + const firstKey = this.decryptionCache.keys().next().value; + if (firstKey) { + this.decryptionCache.delete(firstKey); + } + } + + return result; + } else { + return decryptedData; + } + } catch (error) { + console.warn('E2EE decryption failed (wrong key or corrupted data):', error); + throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Attempts to decrypt text content. If decryption fails or E2EE is not enabled, + * returns a masked string to indicate unavailable content. + * + * @param encryptedText Encrypted text in base64 format + * @param participantIdentity Identity of the participant (optional, used for caching) + * @param maskText Custom mask text to show on failure (default: '******') + * @returns Decrypted text or masked value if decryption fails + */ + async decryptOrMask(encryptedText: string, participantIdentity?: string, maskText?: string): Promise; + + /** + * Attempts to decrypt binary data. If decryption fails or E2EE is not enabled, + * returns the maskText encoded as Uint8Array to indicate unavailable content. + * + * @param encryptedData Encrypted data as Uint8Array + * @param participantIdentity Identity of the participant (optional) + * @param maskText Custom mask text to show on failure (default: '******') + * @returns Decrypted data or encoded maskText as Uint8Array if decryption fails + */ + async decryptOrMask(encryptedData: Uint8Array, participantIdentity?: string, maskText?: string): Promise; + + /** + * Implementation of decryptOrMask overloads + */ + async decryptOrMask( + input: string | Uint8Array, + participantIdentity?: string, + maskText: string = '******' + ): Promise { + const isString = typeof input === 'string'; + + // If E2EE is not enabled, return original input + if (!this.isEnabled) { + return input; + } + + // If encryption key is not available, return masked value + if (!this.encryptionKey) { + return isString ? maskText : new TextEncoder().encode(maskText); + } + + // If input is empty, return as-is + if ((isString && !input) || (!isString && input.length === 0)) { + return input; + } + + try { + // For strings, check if it's valid base64 before attempting decryption + if (isString) { + try { + atob(input as string); + } catch { + // Not base64, likely not encrypted - return original + return input; + } + } + + // Attempt decryption + return await this.decrypt(input as any, participantIdentity); + } catch (error) { + // Decryption failed - return masked value + if (isString) { + console.warn('E2EE: Failed to decrypt content, returning masked value:', error); + return maskText; + } else { + console.warn('E2EE: Failed to decrypt binary data, returning encoded mask text:', error); + return new TextEncoder().encode(maskText); + } + } + } + + /** + * Clears the decryption cache. + * Should be called when E2EE key changes or when leaving a room. + */ + clearCache(): void { + this.decryptionCache.clear(); + } + + /** + * Cleanup on service destroy + */ + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.clearCache(); + } +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/openvidu/openvidu.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/openvidu/openvidu.service.ts index 01d275a1a..b25bb9137 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/openvidu/openvidu.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/openvidu/openvidu.service.ts @@ -6,6 +6,7 @@ import { AudioCaptureOptions, ConnectionState, CreateLocalTracksOptions, + E2EEOptions, ExternalE2EEKeyProvider, LocalAudioTrack, LocalTrack, @@ -109,20 +110,29 @@ export class OpenViduService { disconnectOnPageLeave: true }; - // Configure E2EE if key is provided - if (e2eeKey && e2eeKey.trim() !== '') { - this.log.d('Configuring E2EE with provided key'); - this.keyProvider = new ExternalE2EEKeyProvider(); + // Configure E2EE if key is provided and keyProvider exists + if (needsE2EEConfig) { // Create worker using the copied livekit-client e2ee worker from assets - roomOptions.e2ee = { - keyProvider: this.keyProvider, - worker: new Worker('./assets/livekit/livekit-client.e2ee.worker.mjs', { type: 'module' }) - }; + roomOptions.e2ee = this.buildE2EEOptions(); + // !This config enables the data channel encryption + // (roomOptions as any).encryption = this.buildE2EEOptions(); } this.room = new Room(roomOptions); this.log.d('Room initialized successfully'); - } /** + } + + private buildE2EEOptions(): E2EEOptions { + this.log.d('Configuring E2EE with provided key'); + this.keyProvider = new ExternalE2EEKeyProvider(); + // Create worker using the copied livekit-client e2ee worker from assets + return { + keyProvider: this.keyProvider, + worker: new Worker('./assets/livekit/livekit-client.e2ee.worker.mjs', { type: 'module' }) + }; + } + + /** * Connects local participant to the room */ async connectRoom(): Promise { @@ -139,6 +149,7 @@ export class OpenViduService { } await this.room.connect(this.livekitUrl, this.livekitToken); this.log.d(`Successfully connected to room ${this.room.name}`); + const participantName = this.storageService.getParticipantName(); if (participantName) { this.room.localParticipant.setName(participantName); @@ -469,11 +480,10 @@ export class OpenViduService { } } catch (error) { this.log.e('Failed to create new video track:', error); - throw new Error(`Failed to switch camera: ${error.message}`); + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to switch camera: ${message}`); } - } - - /** + } /** * Switches the microphone device when the room is not connected (prejoin page) * @param deviceId new audio device to use * @internal @@ -520,7 +530,8 @@ export class OpenViduService { } } catch (error) { this.log.e('Failed to create new audio track:', error); - throw new Error(`Failed to switch microphone: ${error.message}`); + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to switch microphone: ${message}`); } } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/participant/participant.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/participant/participant.service.ts index 214f6ba8a..2d3194980 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/participant/participant.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/participant/participant.service.ts @@ -20,6 +20,7 @@ import { VideoPresets } from 'livekit-client'; import { StorageService } from '../storage/storage.service'; +import { E2eeService } from '../e2ee/e2ee.service'; @Injectable({ providedIn: 'root' @@ -50,7 +51,8 @@ export class ParticipantService { private directiveService: OpenViduComponentsConfigService, private openviduService: OpenViduService, private storageSrv: StorageService, - private loggerSrv: LoggerService + private loggerSrv: LoggerService, + private e2eeService: E2eeService ) { this.log = this.loggerSrv.get('ParticipantService'); this.localParticipant$ = this.localParticipantBS.asObservable(); @@ -553,11 +555,45 @@ export class ParticipantService { } } - private newParticipant(props: ParticipantProperties) { + private newParticipant(props: ParticipantProperties): ParticipantModel { + let participant: ParticipantModel; if (this.globalService.hasParticipantFactory()) { - return this.globalService.getParticipantFactory().apply(this, [props]); + participant = this.globalService.getParticipantFactory().apply(this, [props]); + } else { + participant = new ParticipantModel(props); + } + + // Decrypt participant name asynchronously if E2EE is enabled + this.decryptParticipantName(participant); + + return participant; + } + + /** + * Decrypts the participant name if E2EE is enabled. + * Updates the participant model asynchronously. + * @param participant - The participant model to decrypt the name for + * @private + */ + private async decryptParticipantName(participant: ParticipantModel): Promise { + const originalName = participant.name; + if (!originalName) { + return; + } + + try { + const decryptedName = await this.e2eeService.decryptOrMask(originalName, participant.identity); + participant.setDecryptedName(decryptedName); + + // Update observables to reflect the decrypted name + if (participant.isLocal) { + this.updateLocalParticipant(); + } else { + this.updateRemoteParticipants(); + } + } catch (error) { + this.log.w('Failed to decrypt participant name:', error); } - return new ParticipantModel(props); } private getScreenCaptureOptions(): ScreenShareCaptureOptions { diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts index 616b52184..742e5096e 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts @@ -66,6 +66,7 @@ export * from './lib/services/storage/storage.service'; export * from './lib/services/translate/translate.service'; export * from './lib/services/theme/theme.service'; export * from './lib/services/viewport/viewport.service'; +export * from './lib/services/e2ee/e2ee.service'; //Modules export * from './lib/openvidu-components-angular.module'; export * from './lib/config/custom-cdk-overlay';