/* global __filename, Olm */ import { getLogger } from '@jitsi/logger'; import base64js from 'base64-js'; import isEqual from 'lodash.isequal'; import { v4 as uuidv4 } from 'uuid'; import * as JitsiConferenceEvents from '../../JitsiConferenceEvents'; import Deferred from '../util/Deferred'; import Listenable from '../util/Listenable'; import { FEATURE_E2EE, JITSI_MEET_MUC_TYPE } from '../xmpp/xmpp'; const logger = getLogger(__filename); const REQ_TIMEOUT = 5 * 1000; const OLM_MESSAGE_TYPE = 'olm'; const OLM_MESSAGE_TYPES = { ERROR: 'error', KEY_INFO: 'key-info', KEY_INFO_ACK: 'key-info-ack', SESSION_ACK: 'session-ack', SESSION_INIT: 'session-init' }; const kOlmData = Symbol('OlmData'); const OlmAdapterEvents = { OLM_ID_KEY_READY: 'olm.id_key_ready', PARTICIPANT_E2EE_CHANNEL_READY: 'olm.participant_e2ee_channel_ready', PARTICIPANT_KEY_UPDATED: 'olm.partitipant_key_updated' }; /** * This class implements an End-to-End Encrypted communication channel between every two peers * in the conference. This channel uses libolm to achieve E2EE. * * The created channel is then used to exchange the secret key that each participant will use * to encrypt the actual media (see {@link E2EEContext}). * * A simple JSON message based protocol is implemented, which follows a request - response model: * - session-init: Initiates an olm session establishment procedure. This message will be sent * by the participant who just joined, to everyone else. * - session-ack: Completes the olm session etablishment. This messsage may contain ancilliary * encrypted data, more specifically the sender's current key. * - key-info: Includes the sender's most up to date key information. * - key-info-ack: Acknowledges the reception of a key-info request. In addition, it may contain * the sender's key information, if available. * - error: Indicates a request processing error has occurred. * * These requessts and responses are transport independent. Currently they are sent using XMPP * MUC private messages. */ export class OlmAdapter extends Listenable { /** * Creates an adapter instance for the given conference. */ constructor(conference) { super(); this._conf = conference; this._init = new Deferred(); this._key = undefined; this._keyIndex = -1; this._reqs = new Map(); this._sessionInitialization = undefined; if (OlmAdapter.isSupported()) { this._bootstrapOlm(); this._conf.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, this._onEndpointMessageReceived.bind(this)); this._conf.on(JitsiConferenceEvents.CONFERENCE_LEFT, this._onConferenceLeft.bind(this)); this._conf.on(JitsiConferenceEvents.USER_LEFT, this._onParticipantLeft.bind(this)); this._conf.on(JitsiConferenceEvents.PARTICIPANT_PROPERTY_CHANGED, this._onParticipantPropertyChanged.bind(this)); } else { this._init.reject(new Error('Olm not supported')); } } /** * Starts new olm sessions with every other participant that has the participantId "smaller" the localParticipantId. */ async initSessions() { if (this._sessionInitialization) { throw new Error('OlmAdapter initSessions called multiple times'); } else { this._sessionInitialization = new Deferred(); await this._init; const promises = []; const localParticipantId = this._conf.myUserId(); for (const participant of this._conf.getParticipants()) { const participantFeatures = await participant.getFeatures(); if (participantFeatures.has(FEATURE_E2EE) && localParticipantId < participant.getId()) { promises.push(this._sendSessionInit(participant)); } } await Promise.allSettled(promises); // TODO: retry failed ones. this._sessionInitialization.resolve(); this._sessionInitialization = undefined; } } /** * Indicates if olm is supported on the current platform. * * @returns {boolean} */ static isSupported() { return typeof window.Olm !== 'undefined'; } /** * Updates the current participant key and distributes it to all participants in the conference * by sending a key-info message. * * @param {Uint8Array|boolean} key - The new key. * @retrns {Promise} */ async updateKey(key) { // Store it locally for new sessions. this._key = key; this._keyIndex++; // Broadcast it. const promises = []; for (const participant of this._conf.getParticipants()) { const pId = participant.getId(); const olmData = this._getParticipantOlmData(participant); // TODO: skip those who don't support E2EE. if (!olmData.session) { logger.warn(`Tried to send key to participant ${pId} but we have no session`); // eslint-disable-next-line no-continue continue; } const uuid = uuidv4(); const data = { [JITSI_MEET_MUC_TYPE]: OLM_MESSAGE_TYPE, olm: { type: OLM_MESSAGE_TYPES.KEY_INFO, data: { ciphertext: this._encryptKeyInfo(olmData.session), uuid } } }; const d = new Deferred(); d.setRejectTimeout(REQ_TIMEOUT); d.catch(() => { this._reqs.delete(uuid); }); this._reqs.set(uuid, d); promises.push(d); this._sendMessage(data, pId); } await Promise.allSettled(promises); // TODO: retry failed ones? return this._keyIndex; } /** * Updates the current participant key. * @param {Uint8Array|boolean} key - The new key. * @returns {number} */ updateCurrentKey(key) { this._key = key; return this._keyIndex; } /** * Frees the olmData session for the given participant. * */ clearParticipantSession(participant) { const olmData = this._getParticipantOlmData(participant); if (olmData.session) { olmData.session.free(); olmData.session = undefined; } } /** * Frees the olmData sessions for all participants. * */ clearAllParticipantsSessions() { for (const participant of this._conf.getParticipants()) { this.clearParticipantSession(participant); } } /** * Internal helper to bootstrap the olm library. * * @returns {Promise} * @private */ async _bootstrapOlm() { logger.debug('Initializing Olm...'); try { await Olm.init(); this._olmAccount = new Olm.Account(); this._olmAccount.create(); const idKeys = JSON.parse(this._olmAccount.identity_keys()); this._idKey = idKeys.curve25519; logger.debug(`Olm ${Olm.get_library_version().join('.')} initialized`); this._init.resolve(); this._onIdKeyReady(this._idKey); } catch (e) { logger.error('Failed to initialize Olm', e); this._init.reject(e); } } /** * Publishes our own Olmn id key in presence. * @private */ _onIdKeyReady(idKey) { logger.debug(`Olm id key ready: ${idKey}`); // Publish it in presence. this._conf.setLocalParticipantProperty('e2ee.idKey', idKey); } /** * Event posted when the E2EE signalling channel has been established with the given participant. * @private */ _onParticipantE2EEChannelReady(id) { logger.debug(`E2EE channel with participant ${id} is ready`); } /** * Internal helper for encrypting the current key information for a given participant. * * @param {Olm.Session} session - Participant's session. * @returns {string} - The encrypted text with the key information. * @private */ _encryptKeyInfo(session) { const keyInfo = {}; if (this._key !== undefined) { keyInfo.key = this._key ? base64js.fromByteArray(this._key) : false; keyInfo.keyIndex = this._keyIndex; } return session.encrypt(JSON.stringify(keyInfo)); } /** * Internal helper for getting the olm related data associated with a participant. * * @param {JitsiParticipant} participant - Participant whose data wants to be extracted. * @returns {Object} * @private */ _getParticipantOlmData(participant) { participant[kOlmData] = participant[kOlmData] || {}; return participant[kOlmData]; } /** * Handles leaving the conference, cleaning up olm sessions. * * @private */ async _onConferenceLeft() { logger.debug('Conference left'); await this._init; for (const participant of this._conf.getParticipants()) { this._onParticipantLeft(participant.getId(), participant); } if (this._olmAccount) { this._olmAccount.free(); this._olmAccount = undefined; } } /** * Main message handler. Handles 1-to-1 messages received from other participants * and send the appropriate replies. * * @private */ async _onEndpointMessageReceived(participant, payload) { if (payload[JITSI_MEET_MUC_TYPE] !== OLM_MESSAGE_TYPE) { return; } if (!payload.olm) { logger.warn('Incorrectly formatted message'); return; } await this._init; const msg = payload.olm; const pId = participant.getId(); const olmData = this._getParticipantOlmData(participant); switch (msg.type) { case OLM_MESSAGE_TYPES.SESSION_INIT: { if (olmData.session) { logger.warn(`Participant ${pId} already has a session`); this._sendError(participant, 'Session already established'); } else { // Create a session for communicating with this participant. const session = new Olm.Session(); session.create_outbound(this._olmAccount, msg.data.idKey, msg.data.otKey); olmData.session = session; // Send ACK const ack = { [JITSI_MEET_MUC_TYPE]: OLM_MESSAGE_TYPE, olm: { type: OLM_MESSAGE_TYPES.SESSION_ACK, data: { ciphertext: this._encryptKeyInfo(session), uuid: msg.data.uuid } } }; this._sendMessage(ack, pId); this._onParticipantE2EEChannelReady(pId); } break; } case OLM_MESSAGE_TYPES.SESSION_ACK: { if (olmData.session) { logger.warn(`Participant ${pId} already has a session`); this._sendError(participant, 'No session found'); } else if (msg.data.uuid === olmData.pendingSessionUuid) { const { ciphertext } = msg.data; const d = this._reqs.get(msg.data.uuid); const session = new Olm.Session(); session.create_inbound(this._olmAccount, ciphertext.body); // Remove OT keys that have been used to setup this session. this._olmAccount.remove_one_time_keys(session); // Decrypt first message. const data = session.decrypt(ciphertext.type, ciphertext.body); olmData.session = session; olmData.pendingSessionUuid = undefined; this._onParticipantE2EEChannelReady(pId); this._reqs.delete(msg.data.uuid); d.resolve(); const json = safeJsonParse(data); if (json.key) { const key = base64js.toByteArray(json.key); const keyIndex = json.keyIndex; olmData.lastKey = key; this.eventEmitter.emit(OlmAdapterEvents.PARTICIPANT_KEY_UPDATED, pId, key, keyIndex); } } else { logger.warn('Received ACK with the wrong UUID'); this._sendError(participant, 'Invalid UUID'); } break; } case OLM_MESSAGE_TYPES.ERROR: { logger.error(msg.data.error); break; } case OLM_MESSAGE_TYPES.KEY_INFO: { if (olmData.session) { const { ciphertext } = msg.data; const data = olmData.session.decrypt(ciphertext.type, ciphertext.body); const json = safeJsonParse(data); if (json.key !== undefined && json.keyIndex !== undefined) { const key = json.key ? base64js.toByteArray(json.key) : false; const keyIndex = json.keyIndex; if (!isEqual(olmData.lastKey, key)) { olmData.lastKey = key; this.eventEmitter.emit(OlmAdapterEvents.PARTICIPANT_KEY_UPDATED, pId, key, keyIndex); } // Send ACK. const ack = { [JITSI_MEET_MUC_TYPE]: OLM_MESSAGE_TYPE, olm: { type: OLM_MESSAGE_TYPES.KEY_INFO_ACK, data: { ciphertext: this._encryptKeyInfo(olmData.session), uuid: msg.data.uuid } } }; this._sendMessage(ack, pId); } } else { logger.debug(`Received key info message from ${pId} but we have no session for them!`); this._sendError(participant, 'No session found while processing key-info'); } break; } case OLM_MESSAGE_TYPES.KEY_INFO_ACK: { if (olmData.session) { const { ciphertext } = msg.data; const data = olmData.session.decrypt(ciphertext.type, ciphertext.body); const json = safeJsonParse(data); if (json.key !== undefined && json.keyIndex !== undefined) { const key = json.key ? base64js.toByteArray(json.key) : false; const keyIndex = json.keyIndex; if (!isEqual(olmData.lastKey, key)) { olmData.lastKey = key; this.eventEmitter.emit(OlmAdapterEvents.PARTICIPANT_KEY_UPDATED, pId, key, keyIndex); } } const d = this._reqs.get(msg.data.uuid); this._reqs.delete(msg.data.uuid); d.resolve(); } else { logger.debug(`Received key info ack message from ${pId} but we have no session for them!`); this._sendError(participant, 'No session found while processing key-info-ack'); } break; } } } /** * Handles a participant leaving. When a participant leaves their olm session is destroyed. * * @private */ _onParticipantLeft(id, participant) { logger.debug(`Participant ${id} left`); this.clearParticipantSession(participant); } /** * Handles an update in a participant's presence property. * * @param {JitsiParticipant} participant - The participant. * @param {string} name - The name of the property that changed. * @param {*} oldValue - The property's previous value. * @param {*} newValue - The property's new value. * @private */ async _onParticipantPropertyChanged(participant, name, oldValue, newValue) { switch (name) { case 'e2ee.enabled': if (newValue && this._conf.isE2EEEnabled()) { const localParticipantId = this._conf.myUserId(); const participantId = participant.getId(); const participantFeatures = await participant.getFeatures(); if (participantFeatures.has(FEATURE_E2EE) && localParticipantId < participantId) { if (this._sessionInitialization) { await this._sessionInitialization; } await this._sendSessionInit(participant); const olmData = this._getParticipantOlmData(participant); const uuid = uuidv4(); const data = { [JITSI_MEET_MUC_TYPE]: OLM_MESSAGE_TYPE, olm: { type: OLM_MESSAGE_TYPES.KEY_INFO, data: { ciphertext: this._encryptKeyInfo(olmData.session), uuid } } }; this._sendMessage(data, participantId); } } break; } } /** * Builds and sends an error message to the target participant. * * @param {JitsiParticipant} participant - The target participant. * @param {string} error - The error message. * @returns {void} */ _sendError(participant, error) { const pId = participant.getId(); const err = { [JITSI_MEET_MUC_TYPE]: OLM_MESSAGE_TYPE, olm: { type: OLM_MESSAGE_TYPES.ERROR, data: { error } } }; this._sendMessage(err, pId); } /** * Internal helper to send the given object to the given participant ID. * This function merely exists so the transport can be easily swapped. * Currently messages are transmitted via XMPP MUC private messages. * * @param {object} data - The data that will be sent to the target participant. * @param {string} participantId - ID of the target participant. */ _sendMessage(data, participantId) { this._conf.sendMessage(data, participantId); } /** * Builds and sends the session-init request to the target participant. * * @param {JitsiParticipant} participant - Participant to whom we'll send the request. * @returns {Promise} - The promise will be resolved when the session-ack is received. * @private */ _sendSessionInit(participant) { const pId = participant.getId(); const olmData = this._getParticipantOlmData(participant); if (olmData.session) { logger.warn(`Tried to send session-init to ${pId} but we already have a session`); return Promise.reject(); } if (olmData.pendingSessionUuid !== undefined) { logger.warn(`Tried to send session-init to ${pId} but we already have a pending session`); return Promise.reject(); } // Generate a One Time Key. this._olmAccount.generate_one_time_keys(1); const otKeys = JSON.parse(this._olmAccount.one_time_keys()); const otKey = Object.values(otKeys.curve25519)[0]; if (!otKey) { return Promise.reject(new Error('No one-time-keys generated')); } // Mark the OT keys (one really) as published so they are not reused. this._olmAccount.mark_keys_as_published(); const uuid = uuidv4(); const init = { [JITSI_MEET_MUC_TYPE]: OLM_MESSAGE_TYPE, olm: { type: OLM_MESSAGE_TYPES.SESSION_INIT, data: { idKey: this._idKey, otKey, uuid } } }; const d = new Deferred(); d.setRejectTimeout(REQ_TIMEOUT); d.catch(() => { this._reqs.delete(uuid); olmData.pendingSessionUuid = undefined; }); this._reqs.set(uuid, d); this._sendMessage(init, pId); // Store the UUID for matching with the ACK. olmData.pendingSessionUuid = uuid; return d; } } /** * Helper to ensure JSON parsing always returns an object. * * @param {string} data - The data that needs to be parsed. * @returns {object} - Parsed data or empty object in case of failure. */ function safeJsonParse(data) { try { return JSON.parse(data); } catch (e) { return {}; } } OlmAdapter.events = OlmAdapterEvents;