Created at:
Modified at:
Matrix Javascript SDK
Matrix documentation
Matrix end-to-end encryption implementation guide
latest version of the client-server API
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: