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';