Skip to content

Commit bb79d52

Browse files
committed
Finish primary send path
1 parent e42a44b commit bb79d52

File tree

6 files changed

+391
-31
lines changed

6 files changed

+391
-31
lines changed

examples/encryption_bot.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,9 @@ const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto);
6262
})();
6363

6464
async function sendEncryptedNotice(roomId: string, text: string) {
65-
const payload = {
66-
room_id: roomId,
67-
type: "m.room.message",
68-
content: {
69-
msgtype: "m.notice",
70-
body: text,
71-
},
72-
};
73-
74-
65+
const encrypted = await client.crypto.encryptRoomEvent(roomId, "m.room.message", {
66+
msgtype: "m.notice",
67+
body: text,
68+
});
69+
await client.sendEvent(roomId, "m.room.encrypted", encrypted);
7570
}

src/MatrixClient.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ import { CryptoClient } from "./e2ee/CryptoClient";
2828
import {
2929
DeviceKeyAlgorithm,
3030
DeviceKeyLabel,
31-
EncryptionAlgorithm,
32-
MultiUserDeviceListResponse,
31+
EncryptionAlgorithm, IDeviceMessage,
32+
MultiUserDeviceListResponse, OTKAlgorithm, OTKClaimResponse,
3333
OTKCounts,
3434
OTKs,
3535
UserDevice
@@ -1672,7 +1672,7 @@ export class MatrixClient extends EventEmitter {
16721672
*
16731673
* See https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-keys-query for more
16741674
* information.
1675-
* @param {string[]} userIds The user IDs to
1675+
* @param {string[]} userIds The user IDs to query.
16761676
* @param {number} federationTimeoutMs The default timeout for requesting devices over federation. Defaults to
16771677
* 10 seconds.
16781678
* @returns {Promise<MultiUserDeviceListResponse>} Resolves to the device list/errors for the requested user IDs.
@@ -1683,7 +1683,44 @@ export class MatrixClient extends EventEmitter {
16831683
for (const userId of userIds) {
16841684
req[userId] = [];
16851685
}
1686-
return this.doRequest("POST", "/_matrix/client/r0/keys/query", { timeout: federationTimeoutMs }, req);
1686+
return this.doRequest("POST", "/_matrix/client/r0/keys/query", {}, {
1687+
timeout: federationTimeoutMs,
1688+
device_keys: req,
1689+
});
1690+
}
1691+
1692+
/**
1693+
* Claims One Time Keys for a set of user devices, returning those keys. The caller is expected to verify
1694+
* and validate the returned keys.
1695+
*
1696+
* Failures with federation are reported in the returned object.
1697+
* @param {Record<string, Record<string, OTKAlgorithm>>} userDeviceMap The map of user IDs to device IDs to
1698+
* OTKAlgorithm to request a claim for.
1699+
* @param {number} federationTimeoutMs The default timeout for claiming keys over federation. Defaults to
1700+
* 10 seconds.
1701+
*/
1702+
@timedMatrixClientFunctionCall()
1703+
@requiresCrypto()
1704+
public async claimOneTimeKeys(userDeviceMap: Record<string, Record<string, OTKAlgorithm>>, federationTimeoutMs = 10000): Promise<OTKClaimResponse> {
1705+
return this.doRequest("POST", "/_matrix/client/r0/keys/claim", {}, {
1706+
timeout: federationTimeoutMs,
1707+
one_time_keys: userDeviceMap,
1708+
});
1709+
}
1710+
1711+
/**
1712+
* Sends to-device messages to the respective users/devices.
1713+
* @param {string} type The message type being sent.
1714+
* @param {Record<string, Record<string, any>>} messages The messages to send, mapped as user ID to
1715+
* device ID (or "*" to denote all of the user's devices) to message payload (content).
1716+
* @returns {Promise<void>} Resolves when complete.
1717+
*/
1718+
@timedMatrixClientFunctionCall()
1719+
public async sendToDevices(type: string, messages: Record<string, Record<string, any>>): Promise<void> {
1720+
const txnId = (new Date().getTime()) + "_TDEV__inc" + (++this.requestId);
1721+
return this.doRequest("PUT", `/_matrix/client/r0/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, {
1722+
messages: messages,
1723+
});
16871724
}
16881725

16891726
/**

src/e2ee/CryptoClient.ts

Lines changed: 188 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ import * as anotherJson from "another-json";
66
import {
77
DeviceKeyAlgorithm,
88
EncryptionAlgorithm,
9+
IOlmEncrypted,
10+
IOlmPayload,
11+
IOlmSession,
912
OTKAlgorithm,
10-
OTKCounts, OTKs,
13+
OTKCounts,
14+
OTKs,
1115
Signatures,
16+
UserDevice,
1217
} from "../models/Crypto";
1318
import { requiresReady } from "./decorators";
1419
import { RoomTracker } from "./RoomTracker";
@@ -105,6 +110,11 @@ export class CryptoClient {
105110
this.pickledAccount = pickled;
106111

107112
this.maxOTKs = account.max_number_of_one_time_keys();
113+
114+
const keys = JSON.parse(account.identity_keys());
115+
this.deviceCurve25519 = keys['curve25519'];
116+
this.deviceEd25519 = keys['ed25519'];
117+
108118
this.ready = true;
109119

110120
const counts = await this.client.uploadDeviceKeys([
@@ -120,13 +130,14 @@ export class CryptoClient {
120130
this.pickleKey = pickleKey;
121131
this.pickledAccount = pickled;
122132
this.maxOTKs = account.max_number_of_one_time_keys();
133+
134+
const keys = JSON.parse(account.identity_keys());
135+
this.deviceCurve25519 = keys['curve25519'];
136+
this.deviceEd25519 = keys['ed25519'];
137+
123138
this.ready = true;
124139
await this.updateCounts(await this.client.checkOneTimeKeyCounts());
125140
}
126-
127-
const keys = JSON.parse(account.identity_keys());
128-
this.deviceCurve25519 = keys['curve25519'];
129-
this.deviceEd25519 = keys['ed25519'];
130141
} finally {
131142
account.free();
132143
}
@@ -211,7 +222,7 @@ export class CryptoClient {
211222
const util = new Olm.Utility();
212223
try {
213224
const message = anotherJson.stringify(obj);
214-
util.ed25519_verify(message, key, signature);
225+
util.ed25519_verify(key, message, signature);
215226
} catch (e) {
216227
// Assume it's a verification failure
217228
return false;
@@ -232,6 +243,142 @@ export class CryptoClient {
232243
this.deviceTracker.flagUsersOutdated(userIds, resync);
233244
}
234245

246+
/**
247+
* Gets or creates Olm sessions for the given users and devices. Where sessions cannot be created,
248+
* the user/device will be excluded from the returned map.
249+
* @param {Record<string, string[]>} userDeviceMap Map of user IDs to device IDs
250+
* @returns {Promise<Record<string, Record<string, IOlmSession>>>} Resolves to a map of user ID to device
251+
* ID to session. Users/devices which cannot have sessions made will not be included, thus the object
252+
* may be empty.
253+
*/
254+
public async getOrCreateOlmSessions(userDeviceMap: Record<string, string[]>): Promise<Record<string, Record<string, IOlmSession>>> {
255+
const otkClaimRequest: Record<string, Record<string, OTKAlgorithm>> = {};
256+
const userDeviceSessionIds: Record<string, Record<string, IOlmSession>> = {};
257+
258+
const myUserId = await this.client.getUserId();
259+
const myDeviceId = this.clientDeviceId;
260+
for (const userId of Object.keys(userDeviceMap)) {
261+
for (const deviceId of userDeviceMap[userId]) {
262+
if (userId === myUserId && deviceId === myDeviceId) {
263+
// Skip creating a session for our own device
264+
continue;
265+
}
266+
267+
const existingSession = await this.client.cryptoStore.getCurrentOlmSession(userId, deviceId);
268+
if (existingSession) {
269+
if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {};
270+
userDeviceSessionIds[userId][deviceId] = existingSession;
271+
} else {
272+
if (!otkClaimRequest[userId]) otkClaimRequest[userId] = {};
273+
otkClaimRequest[userId][deviceId] = OTKAlgorithm.Signed;
274+
}
275+
}
276+
}
277+
278+
const claimed = await this.client.claimOneTimeKeys(otkClaimRequest);
279+
for (const userId of Object.keys(claimed.one_time_keys)) {
280+
const storedDevices = await this.client.cryptoStore.getUserDevices(userId);
281+
for (const deviceId of Object.keys(claimed.one_time_keys[userId])) {
282+
try {
283+
const device = storedDevices.find(d => d.user_id === userId && d.device_id === deviceId);
284+
if (!device) {
285+
LogService.warn("CryptoClient", `Failed to handle claimed OTK: unable to locate stored device for user: ${userId} ${deviceId}`);
286+
continue;
287+
}
288+
289+
const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25119}:${deviceId}`;
290+
291+
const keyId = Object.keys(claimed.one_time_keys[userId][deviceId])[0];
292+
const signedKey = claimed.one_time_keys[userId][deviceId][keyId];
293+
const signature = signedKey?.signatures?.[userId]?.[deviceKeyLabel];
294+
if (!signature) {
295+
LogService.warn("CryptoClient", `Failed to find appropriate signature for claimed OTK ${userId} ${deviceId}`);
296+
continue;
297+
}
298+
299+
const verified = await this.verifySignature(signedKey, device.keys[deviceKeyLabel], signature);
300+
if (!verified) {
301+
LogService.warn("CryptoClient", `Invalid signature for claimed OTK ${userId} ${deviceId}`);
302+
continue;
303+
}
304+
305+
// TODO: Handle spec rate limiting
306+
// Clients should rate-limit the number of sessions it creates per device that it receives a message
307+
// from. Clients should not create a new session with another device if it has already created one
308+
// for that given device in the past 1 hour.
309+
310+
// Finally, we can create a session. We do this on each loop just in case something goes wrong given
311+
// we don't have app-level transaction support here. We want to persist as many outbound sessions as
312+
// we can before exploding.
313+
const account = await this.getOlmAccount();
314+
const session = new Olm.Session();
315+
try {
316+
const curveDeviceKey = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`];
317+
session.create_outbound(account, curveDeviceKey, signedKey.key);
318+
const storedSession: IOlmSession = {
319+
sessionId: session.session_id(),
320+
lastDecryptionTs: Date.now(),
321+
pickled: session.pickle(this.pickleKey),
322+
};
323+
await this.client.cryptoStore.storeOlmSession(userId, deviceId, storedSession);
324+
325+
if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {};
326+
userDeviceSessionIds[userId][deviceId] = storedSession;
327+
328+
// Send a dummy event so the device can prepare the session.
329+
// await this.encryptAndSendOlmMessage(device, storedSession, "m.dummy", {});
330+
} finally {
331+
session.free();
332+
await this.storeAndFreeOlmAccount(account);
333+
}
334+
} catch (e) {
335+
LogService.warn("CryptoClient", `Unable to verify signature of claimed OTK ${userId} ${deviceId}:`, e);
336+
}
337+
}
338+
}
339+
340+
return userDeviceSessionIds;
341+
}
342+
343+
private async encryptAndSendOlmMessage(device: UserDevice, session: IOlmSession, type: string, content: any): Promise<void> {
344+
const olmSession = new Olm.Session();
345+
try {
346+
olmSession.unpickle(this.pickleKey, session.pickled);
347+
const payload: IOlmPayload = {
348+
keys: {
349+
ed25519: this.deviceEd25519,
350+
},
351+
recipient_keys: {
352+
ed25519: device.keys[`${DeviceKeyAlgorithm.Ed25119}:${device.device_id}`],
353+
},
354+
recipient: device.user_id,
355+
sender: await this.client.getUserId(),
356+
content: content,
357+
type: type,
358+
};
359+
const encrypted = olmSession.encrypt(JSON.stringify(payload));
360+
await this.client.cryptoStore.storeOlmSession(device.user_id, device.device_id, {
361+
pickled: olmSession.pickle(this.pickleKey),
362+
lastDecryptionTs: session.lastDecryptionTs,
363+
sessionId: olmSession.session_id(),
364+
});
365+
const message: IOlmEncrypted = {
366+
algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2,
367+
ciphertext: {
368+
[device.keys[`${DeviceKeyAlgorithm.Curve25519}:${device.device_id}`]]: encrypted as any,
369+
},
370+
sender_key: this.deviceCurve25519,
371+
};
372+
await this.client.sendToDevices("m.room.encrypted", {
373+
[device.user_id]: {
374+
[device.device_id]: message,
375+
},
376+
});
377+
} finally {
378+
olmSession.free();
379+
}
380+
}
381+
235382
/**
236383
* Encrypts the details of a room event, returning an encrypted payload to be sent in an
237384
* `m.room.encrypted` event to the room. If needed, this function will send decryption keys
@@ -243,7 +390,7 @@ export class CryptoClient {
243390
* @param {any} content The event content being encrypted.
244391
* @returns {Promise<any>} Resolves to the encrypted content for an `m.room.encrypted` event.
245392
*/
246-
public async encryptEvent(roomId: string, eventType: string, content: any): Promise<any> {
393+
public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise<any> {
247394
if (!(await this.isRoomEncrypted(roomId))) {
248395
throw new Error("Room is not encrypted");
249396
}
@@ -290,14 +437,45 @@ export class CryptoClient {
290437
try {
291438
session.unpickle(this.pickleKey, currentSession.pickled);
292439

440+
const neededSessions: Record<string, string[]> = {};
293441
for (const userId of Object.keys(devices)) {
294-
for (const deviceId of Object.keys(devices[userId])) {
295-
const device = devices[userId][deviceId];
296-
// TODO: Olm session management
442+
neededSessions[userId] = devices[userId].map(d => d.device_id);
443+
}
444+
const olmSessions = await this.getOrCreateOlmSessions(neededSessions);
445+
446+
for (const userId of Object.keys(devices)) {
447+
for (const device of devices[userId]) {
448+
const olmSession = olmSessions[userId]?.[device.device_id];
449+
if (!olmSession) {
450+
LogService.warn("CryptoClient", `Unable to send Megolm session to ${userId} ${device.device_id}: No Olm session`);
451+
continue;
452+
}
453+
await this.encryptAndSendOlmMessage(device, olmSession, "m.room_key", {
454+
algorithm: EncryptionAlgorithm.MegolmV1AesSha2,
455+
room_id: roomId,
456+
session_id: session.session_id(),
457+
session_key: session.session_key(),
458+
});
297459
}
298460
}
461+
462+
const encrypted = session.encrypt(JSON.stringify({
463+
type: eventType,
464+
content: content,
465+
room_id: roomId,
466+
}));
467+
468+
return {
469+
algorithm: EncryptionAlgorithm.MegolmV1AesSha2,
470+
sender_key: this.deviceCurve25519,
471+
ciphertext: encrypted,
472+
session_id: session.session_id(),
473+
device_id: this.clientDeviceId,
474+
};
299475
} finally {
300476
session.free();
301477
}
478+
479+
302480
}
303481
}

0 commit comments

Comments
 (0)