Skip to content

Commit 666da93

Browse files
committed
Merge remote-tracking branch 'origin/develop' into rav/element-r/crypto_backend
2 parents 07ec4f8 + 4c5f416 commit 666da93

File tree

6 files changed

+208
-30
lines changed

6 files changed

+208
-30
lines changed

.prettierignore

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ lib-cov
1111
out
1212
/dist
1313
/lib
14+
/examples/browser/lib
15+
/examples/crypto-browser/lib
16+
/examples/voip/lib
1417

1518
# version file and tarball created by `npm pack` / `yarn pack`
1619
/git-revision.txt

spec/integ/megolm-integ.spec.ts

+119-1
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,117 @@ function getSyncResponse(roomMembers: string[]): ISyncResponse {
204204
};
205205
}
206206

207+
/**
208+
* Establish an Olm Session with the test user
209+
*
210+
* Waits for the test user to upload their keys, then sends a /sync response with a to-device message which will
211+
* establish an Olm session.
212+
*
213+
* @param testClient: a TestClient for the user under test, which we expect to upload account keys, and to make a
214+
* /sync request which we will respond to.
215+
* @param peerOlmAccount: an OlmAccount which will be used to initiate the Olm session.
216+
*/
217+
async function establishOlmSession(testClient: TestClient, peerOlmAccount: Olm.Account): Promise<Olm.Session> {
218+
const peerE2EKeys = JSON.parse(peerOlmAccount.identity_keys());
219+
const p2pSession = await createOlmSession(peerOlmAccount, testClient);
220+
const olmEvent = encryptOlmEvent({
221+
senderKey: peerE2EKeys.curve25519,
222+
recipient: testClient,
223+
p2pSession: p2pSession,
224+
});
225+
testClient.httpBackend.when("GET", "/sync").respond(200, {
226+
next_batch: 1,
227+
to_device: { events: [olmEvent] },
228+
});
229+
await testClient.flushSync();
230+
return p2pSession;
231+
}
232+
233+
/**
234+
* Expect that the client shares keys with the given recipient
235+
*
236+
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
237+
* to establish an Olm InboundGroupSession.
238+
*
239+
* @param senderMockHttpBackend - MockHttpBackend for the sender
240+
*
241+
* @param recipientUserID - the user id of the expected recipient
242+
*
243+
* @param recipientOlmAccount - Olm.Account for the recipient
244+
*
245+
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
246+
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
247+
*
248+
* @returns the established inbound group session
249+
*/
250+
async function expectSendRoomKey(
251+
senderMockHttpBackend: MockHttpBackend,
252+
recipientUserID: string,
253+
recipientOlmAccount: Olm.Account,
254+
recipientOlmSession: Olm.Session | null = null,
255+
): Promise<Olm.InboundGroupSession> {
256+
const Olm = global.Olm;
257+
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
258+
259+
let inboundGroupSession: Olm.InboundGroupSession;
260+
261+
senderMockHttpBackend.when("PUT", "/sendToDevice/m.room.encrypted/").respond(200, (_path, content: any) => {
262+
const m = content.messages[recipientUserID].DEVICE_ID;
263+
const ct = m.ciphertext[testRecipientKey];
264+
265+
if (!recipientOlmSession) {
266+
expect(ct.type).toEqual(0); // pre-key message
267+
recipientOlmSession = new Olm.Session();
268+
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
269+
} else {
270+
expect(ct.type).toEqual(1); // regular message
271+
}
272+
273+
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
274+
expect(decrypted.type).toEqual("m.room_key");
275+
inboundGroupSession = new Olm.InboundGroupSession();
276+
inboundGroupSession.create(decrypted.content.session_key);
277+
return {};
278+
});
279+
280+
expect(await senderMockHttpBackend.flush("/sendToDevice/m.room.encrypted/", 1, 1000)).toEqual(1);
281+
return inboundGroupSession!;
282+
}
283+
284+
/**
285+
* Expect that the client sends an encrypted event
286+
*
287+
* Waits for an HTTP request to send an encrypted message in the test room.
288+
*
289+
* @param senderMockHttpBackend - MockHttpBackend for the sender
290+
*
291+
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
292+
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
293+
*
294+
* @returns The content of the successfully-decrypted event
295+
*/
296+
async function expectSendMegolmMessage(
297+
senderMockHttpBackend: MockHttpBackend,
298+
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
299+
): Promise<Partial<IEvent>> {
300+
let encryptedMessageContent: IContent | null = null;
301+
senderMockHttpBackend.when("PUT", "/send/m.room.encrypted/").respond(200, function (_path, content: IContent) {
302+
encryptedMessageContent = content;
303+
return {
304+
event_id: "$event_id",
305+
};
306+
});
307+
308+
expect(await senderMockHttpBackend.flush("/send/m.room.encrypted/", 1, 1000)).toEqual(1);
309+
310+
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
311+
const inboundGroupSession = await inboundGroupSessionPromise;
312+
313+
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
314+
logger.log("Decrypted received megolm message", r);
315+
return JSON.parse(r.plaintext);
316+
}
317+
207318
describe("megolm", () => {
208319
if (!global.Olm) {
209320
logger.warn("not running megolm tests: Olm not present");
@@ -491,9 +602,16 @@ describe("megolm", () => {
491602
const room = aliceTestClient.client.getRoom(ROOM_ID)!;
492603
const pendingMsg = room.getPendingEvents()[0];
493604

605+
const inboundGroupSessionPromise = expectSendRoomKey(
606+
aliceTestClient.httpBackend,
607+
"@bob:xyz",
608+
testOlmAccount,
609+
p2pSession,
610+
);
611+
494612
await Promise.all([
495613
aliceTestClient.client.resendEvent(pendingMsg, room),
496-
expectSendKeyAndMessage(aliceTestClient.httpBackend, "@bob:xyz", testOlmAccount, p2pSession),
614+
expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise),
497615
]);
498616
});
499617

spec/unit/crypto/algorithms/megolm.spec.ts

+20
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,26 @@ describe("MegolmDecryption", function () {
490490

491491
expect(mockBaseApis.queueToDevice).not.toHaveBeenCalled();
492492
});
493+
494+
it("shouldn't wedge the setup promise if sharing a room key fails", async () => {
495+
// @ts-ignore - private field access
496+
const initialSetupPromise = await megolmEncryption.setupPromise;
497+
expect(initialSetupPromise).toBe(null);
498+
499+
// @ts-ignore - private field access
500+
megolmEncryption.prepareSession = () => {
501+
throw new Error("Can't prepare session");
502+
};
503+
504+
await expect(() =>
505+
// @ts-ignore - private field access
506+
megolmEncryption.ensureOutboundSession(mockRoom, {}, {}, true),
507+
).rejects.toThrow();
508+
509+
// @ts-ignore - private field access
510+
const finalSetupPromise = await megolmEncryption.setupPromise;
511+
expect(finalSetupPromise).toBe(null);
512+
});
493513
});
494514
});
495515

src/crypto/algorithms/megolm.ts

+27-15
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,23 @@ export class MegolmEncryption extends EncryptionAlgorithm {
243243
* @param singleOlmCreationPhase - Only perform one round of olm
244244
* session creation
245245
*
246+
* This method updates the setupPromise field of the class by chaining a new
247+
* call on top of the existing promise, and then catching and discarding any
248+
* errors that might happen while setting up the outbound group session. This
249+
* is done to ensure that `setupPromise` always resolves to `null` or the
250+
* `OutboundSessionInfo`.
251+
*
252+
* Using `>>=` to represent the promise chaining operation, it does the
253+
* following:
254+
*
255+
* ```
256+
* setupPromise = previousSetupPromise >>= setup >>= discardErrors
257+
* ```
258+
*
259+
* The initial value for the `setupPromise` is a promise that resolves to
260+
* `null`. The forceDiscardSession() resets setupPromise to this initial
261+
* promise.
262+
*
246263
* @returns Promise which resolves to the
247264
* OutboundSessionInfo when setup is complete.
248265
*/
@@ -255,36 +272,31 @@ export class MegolmEncryption extends EncryptionAlgorithm {
255272
// takes the previous OutboundSessionInfo, and considers whether to create
256273
// a new one. Also shares the key with any (new) devices in the room.
257274
//
258-
// Returns the successful session whether keyshare succeeds or not.
259-
//
260275
// returns a promise which resolves once the keyshare is successful.
261276
const setup = async (oldSession: OutboundSessionInfo | null): Promise<OutboundSessionInfo> => {
262277
const sharedHistory = isRoomSharedHistory(room);
263-
264278
const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession);
265279

266-
try {
267-
await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session);
268-
} catch (e) {
269-
logger.error(`Failed to ensure outbound session in ${this.roomId}`, e);
270-
}
280+
await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session);
271281

272282
return session;
273283
};
274284

275285
// first wait for the previous share to complete
276-
const prom = this.setupPromise.then(setup);
286+
const fallible = this.setupPromise.then(setup);
277287

278-
// Ensure any failures are logged for debugging
279-
prom.catch((e) => {
288+
// Ensure any failures are logged for debugging and make sure that the
289+
// promise chain remains unbroken
290+
//
291+
// setupPromise resolves to `null` or the `OutboundSessionInfo` whether
292+
// or not the share succeeds
293+
this.setupPromise = fallible.catch((e) => {
280294
logger.error(`Failed to setup outbound session in ${this.roomId}`, e);
295+
return null;
281296
});
282297

283-
// setupPromise resolves to `session` whether or not the share succeeds
284-
this.setupPromise = prom;
285-
286298
// but we return a promise which only resolves if the share was successful.
287-
return prom;
299+
return fallible;
288300
}
289301

290302
private async prepareSession(

src/models/room.ts

+5-11
Original file line numberDiff line numberDiff line change
@@ -2079,8 +2079,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
20792079
room: this,
20802080
client: this.client,
20812081
pendingEventOrdering: this.opts.pendingEventOrdering,
2082+
receipts: this.cachedThreadReadReceipts.get(threadId) ?? [],
20822083
});
20832084

2085+
// All read receipts should now come down from sync, we do not need to keep
2086+
// a reference to the cached receipts anymore.
2087+
this.cachedThreadReadReceipts.delete(threadId);
2088+
20842089
// This is necessary to be able to jump to events in threads:
20852090
// If we jump to an event in a thread where neither the event, nor the root,
20862091
// nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread,
@@ -2110,17 +2115,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
21102115
if (this.threadsReady) {
21112116
this.updateThreadRootEvents(thread, toStartOfTimeline, false);
21122117
}
2113-
2114-
// Pulling all the cached thread read receipts we've discovered when we
2115-
// did an initial sync, and applying them to the thread now that it exists
2116-
// on the client side
2117-
if (this.cachedThreadReadReceipts.has(threadId)) {
2118-
for (const { event, synthetic } of this.cachedThreadReadReceipts.get(threadId)!) {
2119-
this.addReceipt(event, synthetic);
2120-
}
2121-
this.cachedThreadReadReceipts.delete(threadId);
2122-
}
2123-
21242118
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
21252119

21262120
return thread;

src/models/thread.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ import { MatrixClient, PendingEventOrdering } from "../client";
2020
import { TypedReEmitter } from "../ReEmitter";
2121
import { RelationType } from "../@types/event";
2222
import { EventStatus, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event";
23-
import { EventTimeline } from "./event-timeline";
23+
import { Direction, EventTimeline } from "./event-timeline";
2424
import { EventTimelineSet, EventTimelineSetHandlerMap } from "./event-timeline-set";
2525
import { NotificationCountType, Room, RoomEvent } from "./room";
2626
import { RoomState } from "./room-state";
2727
import { ServerControlledNamespacedValue } from "../NamespacedValue";
2828
import { logger } from "../logger";
2929
import { ReadReceipt } from "./read-receipt";
30-
import { ReceiptType } from "../@types/read_receipts";
30+
import { Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts";
3131

3232
export enum ThreadEvent {
3333
New = "Thread.new",
@@ -50,6 +50,7 @@ interface IThreadOpts {
5050
room: Room;
5151
client: MatrixClient;
5252
pendingEventOrdering?: PendingEventOrdering;
53+
receipts?: { event: MatrixEvent; synthetic: boolean }[];
5354
}
5455

5556
export enum FeatureSupport {
@@ -127,6 +128,8 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
127128
this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho);
128129
this.timelineSet.on(RoomEvent.Timeline, this.onTimelineEvent);
129130

131+
this.processReceipts(opts.receipts);
132+
130133
// even if this thread is thought to be originating from this client, we initialise it as we may be in a
131134
// gappy sync and a thread around this event may already exist.
132135
this.updateThreadMetadata();
@@ -284,6 +287,26 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
284287
this.timeline = this.events;
285288
}
286289

290+
/**
291+
* Processes the receipts that were caught during initial sync
292+
* When clients become aware of a thread, they try to retrieve those read receipts
293+
* and apply them to the current thread
294+
* @param receipts - A collection of the receipts cached from initial sync
295+
*/
296+
private processReceipts(receipts: { event: MatrixEvent; synthetic: boolean }[] = []): void {
297+
for (const { event, synthetic } of receipts) {
298+
const content = event.getContent<ReceiptContent>();
299+
Object.keys(content).forEach((eventId: string) => {
300+
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
301+
Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
302+
const receipt = content[eventId][receiptType][userId] as Receipt;
303+
this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
304+
});
305+
});
306+
});
307+
}
308+
}
309+
287310
private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship | undefined {
288311
return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
289312
}
@@ -335,7 +358,15 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
335358
try {
336359
// if the thread has regular events, this will just load the last reply.
337360
// if the thread is newly created, this will load the root event.
338-
await this.client.paginateEventTimeline(this.liveTimeline, { backwards: true, limit: 1 });
361+
if (this.replyCount === 0 && this.rootEvent) {
362+
this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null);
363+
this.liveTimeline.setPaginationToken(null, Direction.Backward);
364+
} else {
365+
await this.client.paginateEventTimeline(this.liveTimeline, {
366+
backwards: true,
367+
limit: Math.max(1, this.length),
368+
});
369+
}
339370
// just to make sure that, if we've created a timeline window for this thread before the thread itself
340371
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
341372
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);

0 commit comments

Comments
 (0)