mirror of https://github.com/OpenVidu/openvidu.git
ov-components: Implement E2EE support for chat messages and participant names and add E2EE service
parent
41bca24bfa
commit
48eec08509
|
|
@ -5,11 +5,13 @@ import { Component } from '@angular/core';
|
|||
*/
|
||||
@Component({
|
||||
selector: 'ov-audio-wave',
|
||||
template: `<div class="audio-container">
|
||||
<div class="stick normal play"></div>
|
||||
<div class="stick loud play"></div>
|
||||
<div class="stick normal play"></div>
|
||||
</div>`,
|
||||
template: `
|
||||
<div class="audio-container audio-wave-indicator">
|
||||
<div class="stick normal play"></div>
|
||||
<div class="stick loud play"></div>
|
||||
<div class="stick normal play"></div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./audio-wave.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
[style.background-color]="_participant?.colorProfile"
|
||||
[attr.aria-label]="'Avatar for ' + participantDisplayName"
|
||||
>
|
||||
<mat-icon>{{_participant.hasEncryptionError ? 'lock_person' : 'person'}}</mat-icon>
|
||||
<mat-icon>{{ _participant.hasEncryptionError ? 'lock_person' : 'person' }}</mat-icon>
|
||||
</div>
|
||||
|
||||
<!-- Content section with name and status -->
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export class ParticipantModel {
|
|||
private speaking: boolean = false;
|
||||
private customVideoTrack: Partial<ParticipantTrackPublication>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ChatMessage[]>;
|
||||
chatMessages$: Observable<ChatMessage[]>;
|
||||
private messageSound: HTMLAudioElement;
|
||||
private _messageList = <BehaviorSubject<ChatMessage[]>>new BehaviorSubject<ChatMessage[]>([]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
private destroy$ = new Subject<void>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>;
|
||||
|
||||
/**
|
||||
* 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<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Implementation of encrypt overloads
|
||||
*/
|
||||
async encrypt(input: string | Uint8Array): Promise<string | Uint8Array> {
|
||||
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<string>;
|
||||
|
||||
/**
|
||||
* 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<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Implementation of decrypt overloads
|
||||
*/
|
||||
async decrypt(input: string | Uint8Array, participantIdentity?: string): Promise<string | Uint8Array> {
|
||||
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<string>;
|
||||
|
||||
/**
|
||||
* 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<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Implementation of decryptOrMask overloads
|
||||
*/
|
||||
async decryptOrMask(
|
||||
input: string | Uint8Array,
|
||||
participantIdentity?: string,
|
||||
maskText: string = '******'
|
||||
): Promise<string | Uint8Array> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue