Skip to content

Commit 67fd2c8

Browse files
authored
fix(e2ee) Wait for Olm sessions to be established before updating keys (#2955)
* fix(e2ee) Wait for Olm sessions to be established before updating keys * squash: address review feedback * fix(e2ee) Clean up session state when it is disabled
1 parent ef10c01 commit 67fd2c8

File tree

4 files changed

+107
-30
lines changed

4 files changed

+107
-30
lines changed

modules/e2ee/E2EEErrors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export enum E2EEErrors {
2+
E2EE_OLM_NO_ONE_TIME_KEYS = 'e2ee.olm.no-one-time-keys',
3+
E2EE_OLM_SESSION_ALREADY_EXISTS = 'e2ee.olm.session-already-exists',
4+
E2EE_OLM_SESSION_INIT_PENDING = 'e2ee.olm.session-init-pending',
25
E2EE_SAS_CHANNEL_VERIFICATION_FAILED = 'e2ee.sas.channel-verification-failed',
36
E2EE_SAS_COMMITMENT_MISMATCHED = 'e2ee.sas.commitment-mismatched',
47
E2EE_SAS_INVALID_SAS_VERIFICATION = 'e2ee.sas.invalid-sas-verification',

modules/e2ee/KeyHandler.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ export class KeyHandler extends Listenable {
7474

7575
this.enabled = enabled;
7676

77-
this._setEnabled && await this._setEnabled(enabled);
78-
77+
// Advertise E2EE support BEFORE establishing sessions so other participants
78+
// know to establish sessions with us when they see our presence update.
7979
this.conference.setLocalParticipantProperty('e2ee.enabled', enabled);
8080

81+
this._setEnabled && await this._setEnabled(enabled);
82+
8183
// Only restart media sessions if E2EE is enabled. If it's later disabled
8284
// we'll continue to use the existing media sessions with an empty transform.
8385
if (!this._firstEnable && enabled) {

modules/e2ee/ManagedKeyHandler.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,19 @@ export class ManagedKeyHandler extends KeyHandler {
8282
*/
8383
async _setEnabled(enabled) {
8484
if (enabled) {
85+
// Initialize Olm sessions with all participants.
8586
await this._olmAdapter.initSessions();
86-
} else {
87-
this._olmAdapter.clearAllParticipantsSessions();
87+
logger.debug('All Olm sessions established, proceeding with key distribution');
8888
}
89-
90-
// Generate a random key in case we are enabling.
9189
this._key = enabled ? this._generateKey() : false;
92-
93-
// Send it to others using the E2EE olm channel.
9490
const index = await this._olmAdapter.updateKey(this._key);
9591

96-
// Set our key so we begin encrypting.
9792
this.e2eeCtx.setKey(this.conference.myUserId(), this._key, index);
93+
94+
// Clear sessions after key distribution when disabling.
95+
if (!enabled) {
96+
this._olmAdapter.clearAllParticipantsSessions();
97+
}
9898
}
9999

100100
/**

modules/e2ee/OlmAdapter.js

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export class OlmAdapter extends Listenable {
7777
this._mediaKeyIndex = -1;
7878
this._reqs = new Map();
7979
this._sessionInitialization = undefined;
80+
this._sessionReadyCallbacks = new Map(); // participantId -> callback
8081

8182
if (OlmAdapter.isSupported()) {
8283
this._bootstrapOlm();
@@ -101,32 +102,64 @@ export class OlmAdapter extends Listenable {
101102
}
102103

103104
/**
104-
* Starts new olm sessions with every other participant that has the participantId "smaller" the localParticipantId.
105+
* Starts new olm sessions with every other participant and waits for all sessions to be established.
106+
* This includes both outgoing sessions (initiated by us to participants with higher IDs) and
107+
* incoming sessions (initiated by participants with lower IDs).
105108
*/
106109
async initSessions() {
107110
if (this._sessionInitialization) {
108111
throw new Error('OlmAdapter initSessions called multiple times');
109-
} else {
110-
this._sessionInitialization = new Deferred();
112+
}
111113

112-
await this._init;
114+
this._sessionInitialization = new Deferred();
113115

114-
const promises = [];
115-
const localParticipantId = this._conf.myUserId();
116+
await this._init;
116117

117-
for (const participant of this._conf.getParticipants()) {
118-
if (participant.hasFeature(FEATURE_E2EE) && localParticipantId < participant.getId()) {
119-
promises.push(this._sendSessionInit(participant));
120-
}
118+
const localParticipantId = this._conf.myUserId();
119+
const e2eeParticipants = this._conf.getParticipants().filter(p => p.hasFeature(FEATURE_E2EE));
120+
const outgoingParticipants = e2eeParticipants.filter(p => localParticipantId < p.getId());
121+
122+
// Send session-init to participants with higher IDs (outgoing sessions)
123+
const outgoingPromises = outgoingParticipants.map(participant =>
124+
this._sendSessionInit(participant)
125+
);
126+
127+
await Promise.allSettled(outgoingPromises);
128+
129+
// TODO: retry failed ones.
130+
131+
const waitPromises = [];
132+
133+
for (const participant of e2eeParticipants) {
134+
const pId = participant.getId();
135+
const olmData = this._getParticipantOlmData(participant);
136+
137+
if (olmData.session) {
138+
// Session already established
139+
continue;
121140
}
122141

123-
await Promise.allSettled(promises);
142+
logger.debug(`Waiting for session with participant ${pId}`);
143+
const sessionPromise = new Promise(resolve => {
144+
this._sessionReadyCallbacks.set(pId, resolve);
145+
});
146+
147+
waitPromises.push(sessionPromise);
148+
}
124149

125-
// TODO: retry failed ones.
150+
if (waitPromises.length > 0) {
151+
logger.debug(`Waiting for ${waitPromises.length} sessions to be established`);
126152

127-
this._sessionInitialization.resolve();
128-
this._sessionInitialization = undefined;
153+
await Promise.race([
154+
Promise.allSettled(waitPromises),
155+
new Promise(resolve => setTimeout(resolve, 10000)) // 10s timeout
156+
]);
157+
this._sessionReadyCallbacks.clear();
129158
}
159+
160+
logger.debug('All Olm sessions established (outgoing and incoming)');
161+
this._sessionInitialization.resolve();
162+
this._sessionInitialization = undefined;
130163
}
131164

132165
/**
@@ -207,7 +240,7 @@ export class OlmAdapter extends Listenable {
207240
}
208241

209242
/**
210-
* Frees the olmData session for the given participant.
243+
* Frees the olmData session for the given participant and clears all session-related state.
211244
*
212245
*/
213246
clearParticipantSession(participant) {
@@ -217,6 +250,16 @@ export class OlmAdapter extends Listenable {
217250
olmData.session.free();
218251
olmData.session = undefined;
219252
}
253+
254+
// Clear all session-related state to allow clean re-initialization
255+
olmData.pendingSessionUuid = undefined;
256+
olmData.lastKey = undefined;
257+
258+
// Clean up SAS verification if active
259+
if (olmData.sasVerification?.sas) {
260+
olmData.sasVerification.sas.free();
261+
}
262+
olmData.sasVerification = undefined;
220263
}
221264

222265
/**
@@ -227,6 +270,8 @@ export class OlmAdapter extends Listenable {
227270
for (const participant of this._conf.getParticipants()) {
228271
this.clearParticipantSession(participant);
229272
}
273+
this._reqs.clear();
274+
this._sessionInitialization = undefined;
230275
}
231276

232277
/**
@@ -387,6 +432,14 @@ export class OlmAdapter extends Listenable {
387432
*/
388433
_onParticipantE2EEChannelReady(id) {
389434
logger.debug(`E2EE channel with participant ${id} is ready`);
435+
436+
// Notify any waiting promises that this session is ready
437+
const callback = this._sessionReadyCallbacks.get(id);
438+
439+
if (callback) {
440+
callback();
441+
this._sessionReadyCallbacks.delete(id);
442+
}
390443
}
391444

392445
/**
@@ -900,10 +953,29 @@ export class OlmAdapter extends Listenable {
900953
const participantFeatures = await participant.getFeatures();
901954

902955
if (participantFeatures.has(FEATURE_E2EE) && localParticipantId < participantId) {
903-
if (this._sessionInitialization) {
904-
await this._sessionInitialization;
956+
let sessionEstablished = false;
957+
958+
try {
959+
// eslint-disable-next-line max-depth
960+
if (this._sessionInitialization) {
961+
await this._sessionInitialization;
962+
}
963+
await this._sendSessionInit(participant);
964+
sessionEstablished = true;
965+
} catch (error) {
966+
// Handle specific error cases
967+
// eslint-disable-next-line max-depth
968+
if (error.message === E2EEErrors.E2EE_OLM_SESSION_ALREADY_EXISTS) {
969+
sessionEstablished = true;
970+
} else if (error.message !== E2EEErrors.E2EE_OLM_SESSION_INIT_PENDING) {
971+
logger.error(`Failed to establish Olm session with ${participantId}:`, error);
972+
}
973+
}
974+
975+
// Only proceed with KEY_INFO if session is ready
976+
if (!sessionEstablished) {
977+
return;
905978
}
906-
await this._sendSessionInit(participant);
907979

908980
const uuid = uuidv4();
909981

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

989-
return Promise.reject();
1061+
return Promise.reject(new Error(E2EEErrors.E2EE_OLM_SESSION_ALREADY_EXISTS));
9901062
}
9911063

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

995-
return Promise.reject();
1067+
return Promise.reject(new Error(E2EEErrors.E2EE_OLM_SESSION_INIT_PENDING));
9961068
}
9971069

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

10041076
if (!otKey) {
1005-
return Promise.reject(new Error('No one-time-keys generated'));
1077+
return Promise.reject(new Error(E2EEErrors.E2EE_OLM_NO_ONE_TIME_KEYS));
10061078
}
10071079

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

0 commit comments

Comments
 (0)