Created at:

Modified at:

Matrix Javascript SDK

Matrix documentation

Matrix end-to-end encryption implementation guide

latest version of the client-server API

Not an official documentation, but a useful rather old blog post about how encryption works in Matrix

My matrix notes

Matrix Javascript SDK

The Matrix js SDK source code repository

Based on version 12.2.0

Encryption

Starting a megolm session

The purpose of this section is to describe how matrix-js-sdk shares its keys with new devices.

Matrix end-to-end encryption implementation guide says that we "should build a unique m.room_key event, and send it encrypted using Olm to each device in the room which has not been blocked".

Matrix end-to-end encryption implementation guide

matrix-js-sdk prepares the m.room_key event within function ensureOutboundSession(). Let's take a look at a piece of this function where we declare a payload object:

File src/crypto/algorithms/megolm.ts, starting at line 318:

    const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId);
    const payload: IPayload = {
        type: "m.room_key",
        content: {
            "algorithm": olmlib.MEGOLM_ALGORITHM,
            "room_id": this.roomId,
            "session_id": session.sessionId,
            "session_key": key.key,
            "chain_index": key.chain_index,
            "org.matrix.msc3061.shared_history": sharedHistory,
        },
    };
    const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions(
        this.olmDevice, this.baseApis, shareMap,
    );

We are mostly interested about devicesWithoutSession for now. Let's take a look at getExistingOlmSessions():

File src/crypto/olmlib.ts, starting at line 147:

 * @return {Promise} resolves to an array.  The first element of the array is a
 *    a map of user IDs to arrays of deviceInfo, representing the devices that
 *    don't have established olm sessions.  The second element of the array is
 *    a map from userId to deviceId to {@link module:crypto~OlmSessionResult}
 */
export async function getExistingOlmSessions(
    olmDevice: OlmDevice,
    baseApis: MatrixClient,
    devicesByUser: Record<string, DeviceInfo[]>,
) {
    const devicesWithoutSession = {};
    const sessions = {};

    const promises = [];

    for (const [userId, devices] of Object.entries(devicesByUser)) {
        for (const deviceInfo of devices) {
            const deviceId = deviceInfo.deviceId;
            const key = deviceInfo.getIdentityKey();
            promises.push((async () => {
                const sessionId = await olmDevice.getSessionIdForDevice(
                    key, true,
                );
                if (sessionId === null) {
                    devicesWithoutSession[userId] = devicesWithoutSession[userId] || [];
                    devicesWithoutSession[userId].push(deviceInfo);
                } else {
                    sessions[userId] = sessions[userId] || {};
                    sessions[userId][deviceId] = {
                        device: deviceInfo,
                        sessionId: sessionId,
                    };
                }
            })());
        }
    }

    await Promise.all(promises);

    return [devicesWithoutSession, sessions];
}

So, according to the commentary at the top of the function and looking at the code, devicesWithoutSession is a structure in the form:

devicesWithoutSession: {
    userA: {
        deviceInfo,
        deviceInfo,
        ...
    },
    userB: {
    	deviceInfo,
    },
    ...
}

deviceInfo is an object of type DeviceInfo that you can find in src/crypto/deviceinfo.ts.

Back in src/crypt/algorithms/megolm.ts, after it have received devicesWithoutSession, it pass it to shareKeyWithDevices():

File src/crypto/algorithms/megolm.ts, starting at line 352:

                await this.shareKeyWithDevices(
                    session, key, payload, devicesWithoutSession, errorDevices,
                    singleOlmCreationPhase ? 10000 : 2000, failedServers,
                );

Then, in function shareKeyWithDevices():

File src/crypto/algorithms/megolm.ts, starting at line 811:

    private async shareKeyWithDevices(
        session: OutboundSessionInfo,
        key: IOutboundGroupSessionKey,
        payload: IPayload,
        devicesByUser: Record<string, DeviceInfo[]>,
        errorDevices: IOlmDevice[],
        otkTimeout: number,
        failedServers?: string[],
    ) {
        logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`);
        const devicemap = await olmlib.ensureOlmSessionsForDevices(
            this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers,
            logger.withPrefix(`[${this.roomId}]`),
        );
        logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`);

        this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices);

        logger.debug(`Sharing keys with Olm sessions in ${this.roomId}`);
        await this.shareKeyWithOlmSessions(session, key, payload, devicemap);
        logger.debug(`Shared keys with Olm sessions in ${this.roomId}`);
    }

this._getDevicesWithoutSessions() is not particularly interesting for us, since it populates errorDevices array, that is used somewhere else, so we are going to skip it for now.

But olmlib.ensureOlmSessionsForDevices() is important and a complex function. It traverses all users and devices and claims its keys using the /keys/claim endpoint.

What the spec says about /keys/claim endpoint

File src/crypto/olmlib.ts, starting at line 327:

        res = await baseApis.claimOneTimeKeys(
            devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout,
        );

Later, it verifies and start a session (if it is not started yet):

File src/crypto/olmlib.ts, starting at line 384:

                _verifyKeyAndStartSession(
                    olmDevice, oneTimeKey, userId, deviceInfo,

The _verifyKeyAndStartSession() function is important. After verifying the signature for the one-time key for this device (that we are not show here) it creates an OutboundSession:

File src/crypto/olmlib.ts, starting at line 428:

    let sid;
    try {
        sid = await olmDevice.createOutboundSession(
            deviceInfo.getIdentityKey(), oneTimeKey.key,
        );

So, in summary, olmlib.ensureOlmSessionsForDevices() does a bunch of things but, mostly important, creates OutboundSession for devices. We can now go back to shareKeyWithDevices() that finally calls shareKeyWithOlmSessions(). Let's take a look at it:

File src/crypto/algorithms/megolm.ts, starting at line 834:

private async shareKeyWithOlmSessions(
    session: OutboundSessionInfo,
    key: IOutboundGroupSessionKey,
    payload: IPayload,
    devicemap: Record<string, Record<string, IOlmSessionResult>>,
): Promise<void> {
    const userDeviceMaps = this.splitDevices(devicemap);

    for (let i = 0; i < userDeviceMaps.length; i++) {
        const taskDetail =
            `megolm keys for ${session.sessionId} ` +
            `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`;
        try {
            logger.debug(`Sharing ${taskDetail}`);
            await this.encryptAndSendKeysToDevices(
                session, key.chain_index, userDeviceMaps[i], payload,
            );
            logger.debug(`Shared ${taskDetail}`);
        } catch (e) {
            logger.error(`Failed to share ${taskDetail}`);
            throw e;
        }
    }
}

All right, for every item in the userDeviceMaps (i.e., the user structure) it calls encryptAndSendKeysToDevices(), passing payload as well.

So, let's take a look at the beginning of encryptAndSendKeysToDevices():

File src/crypto/algorithms/megolm.ts, starting at line 568:

    private encryptAndSendKeysToDevices(
        session: OutboundSessionInfo,
        chainIndex: number,
        userDeviceMap: IOlmDevice[],
        payload: IPayload,
    ): Promise<void> {
        const contentMap = {};

        const promises = [];
        for (let i = 0; i < userDeviceMap.length; i++) {
            const encryptedContent = {
                algorithm: olmlib.OLM_ALGORITHM,
                sender_key: this.olmDevice.deviceCurve25519Key,
                ciphertext: {},
            };
            const val = userDeviceMap[i];
            const userId = val.userId;
            const deviceInfo = val.deviceInfo;
            const deviceId = deviceInfo.deviceId;

            if (!contentMap[userId]) {
                contentMap[userId] = {};
            }
            contentMap[userId][deviceId] = encryptedContent;

            promises.push(
                olmlib.encryptMessageForDevice(
                    encryptedContent.ciphertext,
                    this.userId,
                    this.deviceId,
                    this.olmDevice,
                    userId,
                    deviceInfo,
                    payload,
                ),
            );
        }

As a side note, it is important to see that matrix-js-sdk declares algorithms contants here:

File src/crypto/olmlib.ts, starting at line 34:

enum Algorithm {
    Olm = "m.olm.v1.curve25519-aes-sha2",
    Megolm = "m.megolm.v1.aes-sha2",
    MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2",
}

/**
 * matrix algorithm tag for olm
 */
export const OLM_ALGORITHM = Algorithm.Olm;

/**
 * matrix algorithm tag for megolm
 */
export const MEGOLM_ALGORITHM = Algorithm.Megolm;

So, for every device in userDeviceMap it builds an encryptedContent message. Notice that ciphertext property is empty. Where it gets populated? Somewhere below we have a call to olmlib.encryptMessageForDevice() that passed many parameters, encryptedContent.ciphertext (passed as reference) and payload included. Remember that payload is built up there in ensureOutboundSession() and passed down to here.

Before continue, let's jump into encryptMessageForDevice(), starting from its signature:

File src/crypto/olmlib.ts, starting at line 76:

export async function encryptMessageForDevice(
    resultsObject: Record<string, string>,
    ourUserId: string,
    ourDeviceId: string,
    olmDevice: OlmDevice,
    recipientUserId: string,
    recipientDevice: DeviceInfo,
    payloadFields: Record<string, any>,
) {

So, the result is stored in resultsObject (that is a reference to encryptedContent.ciphertext in encryptAndSendKeysToDevices() context). Also, our payload is now called payloadFields.

We are going to skip a small part of this function and see the declaration of a new payload object:

File src/crypto/olmlib.ts, starting at line 98:

    const payload = {
        sender: ourUserId,
        // TODO this appears to no longer be used whatsoever
        sender_device: ourDeviceId,

        // Include the Ed25519 key so that the recipient knows what
        // device this message came from.
        // We don't need to include the curve25519 key since the
        // recipient will already know this from the olm headers.
        // When combined with the device keys retrieved from the
        // homeserver signed by the ed25519 key this proves that
        // the curve25519 key and the ed25519 key are owned by
        // the same device.
        keys: {
            "ed25519": olmDevice.deviceEd25519Key,
        },

        // include the recipient device details in the payload,
        // to avoid unknown key attacks, per
        // https://github.com/vector-im/vector-web/issues/2483
        recipient: recipientUserId,
        recipient_keys: {
            "ed25519": recipientDevice.getFingerprint(),
        },
    };

    // TODO: technically, a bunch of that stuff only needs to be included for
    // pre-key messages: after that, both sides know exactly which devices are
    // involved in the session. If we're looking to reduce data transfer in the
    // future, we could elide them for subsequent messages.

    utils.extend(payload, payloadFields);

    resultsObject[deviceKey] = await olmDevice.encryptMessage(
        deviceKey, sessionId, JSON.stringify(payload),
    );

Interesting. We declare a new payload object and extend it with the fields of payloadFields (our previous payload). There a TODO note just before extending: if we see the spec about m.room.encrypted event these extra fields are only needed in pre-key messages, but matrix-js-sdk implementers decided to send it anyway, regardless of the type of the message (probably to make implementation easier).

spec about m.room.encrypted event

Finally, we transform the new and extended payload object into canonical JSON and call encryptMessage(). Let's now take a look at olmDevice.encryptMessage(). We are not going to dive into the details of this function. The important part is:

File src/crypto/OlmDevice.js, starting at line 765:

                res = sessionInfo.session.encrypt(payloadString);

I couldn't discover where this encrypt() function is implemented, so at August 12 2021 I asked at #e2e-dev:matrix.org:

Hello. I'm studying how matrix-js-sdk works with olm and I found this: file src/crypto/OlmDevice.js, line 765: res = sessionInfo.session.encrypt(payloadString); Can anybody tell me where this "encrypt" function is defined? (using matrix-js-sdk 12.2.0)

uhoreg (@hubert:uhoreg.ca) kindly answered:

It's defined in libolm at https://gitlab.matrix.org/matrix-org/olm/-/blob/master/javascript/olm_post.js#L383, which will call https://gitlab.matrix.org/matrix-org/olm/-/blob/master/src/olm.cpp#L705 (written in C)

We are not going to dissect this code, but in summary, the Javascript code is just a wrapper for Olm's olm_encrypt(), containing necessary boilerplate code (olm_encrypt_random_length(), olm_encrypt_message_length(), etc.) in order to encrypt the message.

For some tips about how to implement Matrix E2E, see the end-to-end encryption notes in:

Matrix protocol notes