Skip to content

Commit

Permalink
Merge pull request #1684 from matrix-org/gsouquet/cache-decrypt
Browse files Browse the repository at this point in the history
  • Loading branch information
germain-gg authored May 12, 2021
2 parents e3583dd + e484a2e commit 2246aed
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 27 deletions.
6 changes: 4 additions & 2 deletions spec/integ/megolm-integ.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,8 +484,9 @@ describe("megolm", function() {
return aliceTestClient.flushSync().then(() => {
return aliceTestClient.flushSync();
});
}).then(function() {
}).then(async function() {
const room = aliceTestClient.client.getRoom(ROOM_ID);
await room.decryptCriticalEvents();
const event = room.getLiveTimeline().getEvents()[0];
expect(event.getContent().body).toEqual('42');
});
Expand Down Expand Up @@ -933,8 +934,9 @@ describe("megolm", function() {

aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse);
return aliceTestClient.flushSync();
}).then(function() {
}).then(async function() {
const room = aliceTestClient.client.getRoom(ROOM_ID);
await room.decryptCriticalEvents();
const event = room.getLiveTimeline().getEvents()[0];
expect(event.getContent().body).toEqual('42');

Expand Down
23 changes: 13 additions & 10 deletions spec/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,18 +212,21 @@ MockStorageApi.prototype = {
* @returns {Promise} promise which resolves (to `event`) when the event has been decrypted
*/
export function awaitDecryption(event) {
if (!event.isBeingDecrypted()) {
return Promise.resolve(event);
}

logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`);
// An event is not always decrypted ahead of time
// getClearContent is a good signal to know whether an event has been decrypted
// already
if (event.getClearContent() !== null) {
return event;
} else {
logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`);

return new Promise((resolve, reject) => {
event.once('Event.decrypted', (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
return new Promise((resolve, reject) => {
event.once('Event.decrypted', (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
});
});
});
}
}


Expand Down
10 changes: 7 additions & 3 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -5554,8 +5554,9 @@ function _resolve(callback, resolve, res) {
resolve(res);
}

function _PojoToMatrixEventMapper(client, options) {
const preventReEmit = Boolean(options && options.preventReEmit);
function _PojoToMatrixEventMapper(client, options = {}) {
const preventReEmit = Boolean(options.preventReEmit);
const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject) {
const event = new MatrixEvent(plainOldJsObject);
if (event.isEncrypted()) {
Expand All @@ -5564,7 +5565,9 @@ function _PojoToMatrixEventMapper(client, options) {
"Event.decrypted",
]);
}
event.attemptDecryption(client._crypto);
if (decrypt) {
event.attemptDecryption(client._crypto);
}
}
if (!preventReEmit) {
client.reEmitter.reEmit(event, ["Event.replaced"]);
Expand All @@ -5577,6 +5580,7 @@ function _PojoToMatrixEventMapper(client, options) {
/**
* @param {object} [options]
* @param {bool} options.preventReEmit don't reemit events emitted on an event mapped by this mapper on the client
* @param {bool} options.decrypt decrypt event proactively
* @return {Function}
*/
MatrixClient.prototype.getEventMapper = function(options = undefined) {
Expand Down
2 changes: 1 addition & 1 deletion src/crypto/algorithms/megolm.js
Original file line number Diff line number Diff line change
Expand Up @@ -1692,7 +1692,7 @@ MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionI

await Promise.all([...pending].map(async (ev) => {
try {
await ev.attemptDecryption(this._crypto, true);
await ev.attemptDecryption(this._crypto, { isRetry: true });
} catch (e) {
// don't die if something goes wrong
}
Expand Down
32 changes: 25 additions & 7 deletions src/models/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,12 @@ utils.extend(MatrixEvent.prototype, {
this._clearEvent.content.msgtype === "m.bad.encrypted";
},

shouldAttemptDecryption: function() {
return this.isEncrypted()
&& !this.isBeingDecrypted()
&& this.getClearContent() === null;
},

/**
* Start the process of trying to decrypt this event.
*
Expand All @@ -407,12 +413,22 @@ utils.extend(MatrixEvent.prototype, {
* @internal
*
* @param {module:crypto} crypto crypto module
* @param {bool} isRetry True if this is a retry (enables more logging)
* @param {object} options
* @param {bool} options.isRetry True if this is a retry (enables more logging)
* @param {bool} options.emit Emits "event.decrypted" if set to true
*
* @returns {Promise} promise which resolves (to undefined) when the decryption
* attempt is completed.
*/
attemptDecryption: async function(crypto, isRetry) {
attemptDecryption: async function(crypto, options = {}) {
// For backwards compatibility purposes
// The function signature used to be attemptDecryption(crypto, isRetry)
if (typeof options === "boolean") {
options = {
isRetry: options,
};
}

// start with a couple of sanity checks.
if (!this.isEncrypted()) {
throw new Error("Attempt to decrypt event which isn't encrypted");
Expand Down Expand Up @@ -442,7 +458,7 @@ utils.extend(MatrixEvent.prototype, {
return this._decryptionPromise;
}

this._decryptionPromise = this._decryptionLoop(crypto, isRetry);
this._decryptionPromise = this._decryptionLoop(crypto, options);
return this._decryptionPromise;
},

Expand Down Expand Up @@ -487,7 +503,7 @@ utils.extend(MatrixEvent.prototype, {
return recipients;
},

_decryptionLoop: async function(crypto, isRetry) {
_decryptionLoop: async function(crypto, options = {}) {
// make sure that this method never runs completely synchronously.
// (doing so would mean that we would clear _decryptionPromise *before*
// it is set in attemptDecryption - and hence end up with a stuck
Expand All @@ -504,15 +520,15 @@ utils.extend(MatrixEvent.prototype, {
res = this._badEncryptedMessage("Encryption not enabled");
} else {
res = await crypto.decryptEvent(this);
if (isRetry) {
if (options.isRetry === true) {
logger.info(`Decrypted event on retry (id=${this.getId()})`);
}
}
} catch (e) {
if (e.name !== "DecryptionError") {
// not a decryption error: log the whole exception as an error
// (and don't bother with a retry)
const re = isRetry ? 're' : '';
const re = options.isRetry ? 're' : '';
logger.error(
`Error ${re}decrypting event ` +
`(id=${this.getId()}): ${e.stack || e}`,
Expand Down Expand Up @@ -578,7 +594,9 @@ utils.extend(MatrixEvent.prototype, {
// pick up the wrong contents.
this.setPushActions(null);

this.emit("Event.decrypted", this, err);
if (options.emit !== false) {
this.emit("Event.decrypted", this, err);
}

return;
}
Expand Down
45 changes: 45 additions & 0 deletions src/models/room.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,51 @@ function pendingEventsKey(roomId) {

utils.inherits(Room, EventEmitter);


/**
* Bulk decrypt critical events in a room
*
* Critical events represents the minimal set of events to decrypt
* for a typical UI to function properly
*
* - Last event of every room (to generate likely message preview)
* - All events up to the read receipt (to calculate an accurate notification count)
*
* @returns {Promise} Signals when all events have been decrypted
*/
Room.prototype.decryptCriticalEvents = function() {
const readReceiptEventId = this.getEventReadUpTo(this._client.getUserId(), true);
const events = this.getLiveTimeline().getEvents();
const readReceiptTimelineIndex = events.findIndex(matrixEvent => {
return matrixEvent.event.event_id === readReceiptEventId;
});

const decryptionPromises = events
.slice(readReceiptTimelineIndex)
.filter(event => event.shouldAttemptDecryption())
.reverse()
.map(event => event.attemptDecryption(this._client._crypto, { isRetry: true }));

return Promise.allSettled(decryptionPromises);
};

/**
* Bulk decrypt events in a room
*
* @returns {Promise} Signals when all events have been decrypted
*/
Room.prototype.decryptAllEvents = function() {
const decryptionPromises = this
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter(event => event.shouldAttemptDecryption())
.reverse()
.map(event => event.attemptDecryption(this._client._crypto, { isRetry: true }));

return Promise.allSettled(decryptionPromises);
};

/**
* Gets the version of the room
* @returns {string} The version of the room, or null if it could not be determined
Expand Down
18 changes: 14 additions & 4 deletions src/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -1158,10 +1158,15 @@ SyncApi.prototype._processSyncResponse = async function(
await utils.promiseMapSeries(joinRooms, async function(joinObj) {
const room = joinObj.room;
const stateEvents = self._mapSyncEventsFormat(joinObj.state, room);
const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room);
// Prevent events from being decrypted ahead of time
// this helps large account to speed up faster
// room::decryptCriticalEvent is in charge of decrypting all the events
// required for a client to function properly
const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room, false);
const ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral);
const accountDataEvents = self._mapSyncEventsFormat(joinObj.account_data);

const encrypted = client.isRoomEncrypted(room.roomId);
// we do this first so it's correct when any of the events fire
if (joinObj.unread_notifications) {
room.setUnreadNotificationCount(
Expand All @@ -1172,7 +1177,6 @@ SyncApi.prototype._processSyncResponse = async function(
// bother setting it here. We trust our calculations better than the
// server's for this case, and therefore will assume that our non-zero
// count is accurate.
const encrypted = client.isRoomEncrypted(room.roomId);
if (!encrypted
|| (encrypted && room.getUnreadNotificationCount('highlight') <= 0)) {
room.setUnreadNotificationCount(
Expand Down Expand Up @@ -1294,6 +1298,11 @@ SyncApi.prototype._processSyncResponse = async function(
});

room.updateMyMembership("join");

// Decrypt only the last message in all rooms to make sure we can generate a preview
// And decrypt all events after the recorded read receipt to ensure an accurate
// notification count
room.decryptCriticalEvents();
});

// Handle leaves (e.g. kicked rooms)
Expand Down Expand Up @@ -1516,13 +1525,14 @@ SyncApi.prototype._mapSyncResponseToRoomArray = function(obj) {
/**
* @param {Object} obj
* @param {Room} room
* @param {bool} decrypt
* @return {MatrixEvent[]}
*/
SyncApi.prototype._mapSyncEventsFormat = function(obj, room) {
SyncApi.prototype._mapSyncEventsFormat = function(obj, room, decrypt = true) {
if (!obj || !utils.isArray(obj.events)) {
return [];
}
const mapper = this.client.getEventMapper();
const mapper = this.client.getEventMapper({ decrypt });
return obj.events.map(function(e) {
if (room) {
e.room_id = room.roomId;
Expand Down

0 comments on commit 2246aed

Please sign in to comment.