Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modules/e2ee/E2EEErrors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export enum E2EEErrors {
E2EE_OLM_NO_ONE_TIME_KEYS = 'e2ee.olm.no-one-time-keys',
E2EE_OLM_SESSION_ALREADY_EXISTS = 'e2ee.olm.session-already-exists',
E2EE_OLM_SESSION_INIT_PENDING = 'e2ee.olm.session-init-pending',
E2EE_SAS_CHANNEL_VERIFICATION_FAILED = 'e2ee.sas.channel-verification-failed',
E2EE_SAS_COMMITMENT_MISMATCHED = 'e2ee.sas.commitment-mismatched',
E2EE_SAS_INVALID_SAS_VERIFICATION = 'e2ee.sas.invalid-sas-verification',
Expand Down
6 changes: 4 additions & 2 deletions modules/e2ee/KeyHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ export class KeyHandler extends Listenable {

this.enabled = enabled;

this._setEnabled && await this._setEnabled(enabled);

// Advertise E2EE support BEFORE establishing sessions so other participants
// know to establish sessions with us when they see our presence update.
this.conference.setLocalParticipantProperty('e2ee.enabled', enabled);

this._setEnabled && await this._setEnabled(enabled);

// Only restart media sessions if E2EE is enabled. If it's later disabled
// we'll continue to use the existing media sessions with an empty transform.
if (!this._firstEnable && enabled) {
Expand Down
10 changes: 10 additions & 0 deletions modules/e2ee/ManagedKeyHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,16 @@ export class ManagedKeyHandler extends KeyHandler {
*/
async _setEnabled(enabled) {
if (enabled) {
// Step 1: Initiate outgoing sessions (to participants with higher IDs)
await this._olmAdapter.initSessions();

// Step 2: Wait for incoming sessions (from participants with lower IDs)
// This ensures all bidirectional sessions are established before sending keys.
// Since we advertised e2ee.enabled before this method was called,
// participants with lower IDs should be establishing sessions with us.
await this._olmAdapter.waitForAllSessions();

logger.debug('All Olm sessions established, proceeding with key distribution');
} else {
this._olmAdapter.clearAllParticipantsSessions();
}
Expand All @@ -91,6 +100,7 @@ export class ManagedKeyHandler extends KeyHandler {
this._key = enabled ? this._generateKey() : false;

// Send it to others using the E2EE olm channel.
// At this point, all sessions should be ready.
const index = await this._olmAdapter.updateKey(this._key);

// Set our key so we begin encrypting.
Expand Down
96 changes: 90 additions & 6 deletions modules/e2ee/OlmAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export class OlmAdapter extends Listenable {
this._mediaKeyIndex = -1;
this._reqs = new Map();
this._sessionInitialization = undefined;
this._sessionReadyCallbacks = new Map(); // participantId -> callback

if (OlmAdapter.isSupported()) {
this._bootstrapOlm();
Expand Down Expand Up @@ -129,6 +130,62 @@ export class OlmAdapter extends Listenable {
}
}

/**
* Waits for Olm sessions to be established with all E2EE-enabled participants.
* This includes both outgoing sessions (that we initiated) and incoming sessions
* (initiated by participants with lower IDs).
*
* @returns {Promise<void>}
*/
async waitForAllSessions() {
await this._init;

const promises = [];
const participantsToWaitFor = [];

for (const participant of this._conf.getParticipants()) {
const pId = participant.getId();

if (!participant.hasFeature(FEATURE_E2EE)) {
continue;
}

const olmData = this._getParticipantOlmData(participant);

if (olmData.session) {
// Session already exists
continue;
}

// Need to wait for session to be established
participantsToWaitFor.push(pId);
const sessionPromise = new Promise(resolve => {
this._sessionReadyCallbacks.set(pId, resolve);
});

promises.push(sessionPromise);
}

if (promises.length === 0) {
logger.debug('All sessions already established');

return;
}

logger.debug(`Waiting for ${promises.length} sessions to be established`);

// Wait for all pending sessions with a timeout
await Promise.race([
Promise.allSettled(promises),
new Promise(resolve => setTimeout(resolve, 10000)) // 10s timeout
]);

// Clean up any remaining callbacks (e.g., if timeout fired before sessions completed)
for (const pId of participantsToWaitFor) {
this._sessionReadyCallbacks.delete(pId);
}
}

/**
* Indicates if olm is supported on the current platform.
*
Expand Down Expand Up @@ -387,6 +444,14 @@ export class OlmAdapter extends Listenable {
*/
_onParticipantE2EEChannelReady(id) {
logger.debug(`E2EE channel with participant ${id} is ready`);

// Notify any waiting promises that this session is ready
const callback = this._sessionReadyCallbacks.get(id);

if (callback) {
callback();
this._sessionReadyCallbacks.delete(id);
}
}

/**
Expand Down Expand Up @@ -900,10 +965,29 @@ export class OlmAdapter extends Listenable {
const participantFeatures = await participant.getFeatures();

if (participantFeatures.has(FEATURE_E2EE) && localParticipantId < participantId) {
if (this._sessionInitialization) {
await this._sessionInitialization;
let sessionEstablished = false;

try {
// eslint-disable-next-line max-depth
if (this._sessionInitialization) {
await this._sessionInitialization;
}
await this._sendSessionInit(participant);
sessionEstablished = true;
} catch (error) {
// Handle specific error cases
// eslint-disable-next-line max-depth
if (error.message === E2EEErrors.E2EE_OLM_SESSION_ALREADY_EXISTS) {
sessionEstablished = true;
} else if (error.message !== E2EEErrors.E2EE_OLM_SESSION_INIT_PENDING) {
logger.error(`Failed to establish Olm session with ${participantId}:`, error);
}
}

// Only proceed with KEY_INFO if session is ready
if (!sessionEstablished) {
return;
}
await this._sendSessionInit(participant);

const uuid = uuidv4();

Expand Down Expand Up @@ -986,13 +1070,13 @@ export class OlmAdapter extends Listenable {
if (olmData.session) {
logger.warn(`Tried to send session-init to ${pId} but we already have a session`);

return Promise.reject();
return Promise.reject(new Error(E2EEErrors.E2EE_OLM_SESSION_ALREADY_EXISTS));
}

if (olmData.pendingSessionUuid !== undefined) {
logger.warn(`Tried to send session-init to ${pId} but we already have a pending session`);

return Promise.reject();
return Promise.reject(new Error(E2EEErrors.E2EE_OLM_SESSION_INIT_PENDING));
}

// Generate a One Time Key.
Expand All @@ -1002,7 +1086,7 @@ export class OlmAdapter extends Listenable {
const otKey = Object.values(otKeys.curve25519)[0];

if (!otKey) {
return Promise.reject(new Error('No one-time-keys generated'));
return Promise.reject(new Error(E2EEErrors.E2EE_OLM_NO_ONE_TIME_KEYS));
}

// Mark the OT keys (one really) as published so they are not reused.
Expand Down