diff --git a/src/appservice/Appservice.ts b/src/appservice/Appservice.ts index ceee434f..fa162818 100644 --- a/src/appservice/Appservice.ts +++ b/src/appservice/Appservice.ts @@ -238,6 +238,11 @@ export class Appservice extends EventEmitter { private eventProcessors: { [eventType: string]: IPreprocessor[] } = {}; private pendingTransactions = new Map>(); + /** + * A cache of intents for the purposes of decrypting rooms + */ + private cryptoClientForRoomId: LRU.LRUCache; + /** * Creates a new application service. * @param {IAppserviceOptions} options The options for the application service. @@ -256,6 +261,11 @@ export class Appservice extends EventEmitter { ttl: options.intentOptions.maxAgeMs, }); + this.cryptoClientForRoomId = new LRU.LRUCache({ + max: options.intentOptions.maxCached, + ttl: options.intentOptions.maxAgeMs, + }); + this.registration = options.registration; // If protocol is not defined, define an empty array. @@ -658,6 +668,75 @@ export class Appservice extends EventEmitter { return providedToken === this.registration.hs_token; } + private async decryptAppserviceEvent(roomId: string, encrypted: EncryptedRoomEvent): ReturnType { + const existingClient = this.cryptoClientForRoomId.get(roomId); + const decryptFn = async (client: MatrixClient) => { + // Also fetches state in order to decrypt room. We should throw if the client is confused. + if (!await client.crypto.isRoomEncrypted(roomId)) { + throw new Error("Client detected that the room is not encrypted."); + } + let event = (await client.crypto.decryptRoomEvent(encrypted, roomId)).raw; + event = await this.processEvent(event); + this.cryptoClientForRoomId.set(roomId, client); + // For logging purposes: show that the event was decrypted + LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); + return event; + }; + // 1. Try cached client + if (existingClient) { + try { + return await decryptFn(existingClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via cached client ${await existingClient.getUserId()}`, error); + LogService.warn("Appservice", `Cached client was not able to decrypt ${roomId} ${encrypted.eventId} - trying other intents`); + } + } + this.cryptoClientForRoomId.delete(roomId); + // 2. Try the bot client + if (this.botClient.crypto?.isReady) { + try { + return await decryptFn(this.botClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via bot client`, error); + LogService.warn("Appservice", `Bot client was not able to decrypt ${roomId} ${encrypted.eventId} - trying other intents`); + } + } + + const userIdsInRoom = (await this.botClient.getJoinedRoomMembers(roomId)).filter(u => this.isNamespacedUser(u)); + // 3. Try existing clients with crypto enabled. + for (const intentCacheEntry of this.intentsCache.entries()) { + const [userId, intent] = intentCacheEntry as [string, Intent]; + if (!userIdsInRoom.includes(userId)) { + // Not in this room. + continue; + } + // Is this client crypto enabled? + if (!intent.underlyingClient.crypto?.isReady) { + continue; + } + try { + return await decryptFn(intent.underlyingClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via ${userId}`, error); + LogService.warn("Appservice", `Existing encrypted client was not able to decrypt ${roomId} ${encrypted.eventId} - trying other intents`); + } + } + + // 4. Try to enable crypto on any client to decrypt it. + // We deliberately do not enable crypto on every client for performance reasons. + const userInRoom = this.intentsCache.find((intent, userId) => !intent.underlyingClient.crypto?.isReady && userIdsInRoom.includes(userId)); + if (!userInRoom) { + throw Error('No users in room, cannot decrypt'); + } + try { + await userInRoom.enableEncryption(); + return await decryptFn(userInRoom.underlyingClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via random user ${userInRoom.userId}`, error); + throw new Error("Unable to decrypt event", { cause: error }); + } + } + private async handleTransaction(txnId: string, body: Record) { // Process all the crypto stuff first to ensure that future transactions (if not this one) // will decrypt successfully. We start with EDUs because we need structures to put counts @@ -804,39 +883,11 @@ export class Appservice extends EventEmitter { try { const encrypted = new EncryptedRoomEvent(event); const roomId = event['room_id']; - try { - event = (await this.botClient.crypto.decryptRoomEvent(encrypted, roomId)).raw; - event = await this.processEvent(event); - this.emit("room.decrypted_event", roomId, event); - - // For logging purposes: show that the event was decrypted - LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); - } catch (e1) { - LogService.warn("Appservice", `Bot client was not able to decrypt ${roomId} ${event['event_id']} - trying other intents`); - - let tryUserId: string; - try { - // TODO: This could be more efficient - const userIdsInRoom = await this.botClient.getJoinedRoomMembers(roomId); - tryUserId = userIdsInRoom.find(u => this.isNamespacedUser(u)); - } catch (e) { - LogService.error("Appservice", "Failed to get members of room - cannot decrypt message"); - } - - if (tryUserId) { - const intent = this.getIntentForUserId(tryUserId); - - event = (await intent.underlyingClient.crypto.decryptRoomEvent(encrypted, roomId)).raw; - event = await this.processEvent(event); - this.emit("room.decrypted_event", roomId, event); - - // For logging purposes: show that the event was decrypted - LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); - } else { - // noinspection ExceptionCaughtLocallyJS - throw e1; - } - } + event = await this.decryptAppserviceEvent(roomId, encrypted); + this.emit("room.decrypted_event", roomId, event); + + // For logging purposes: show that the event was decrypted + LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); } catch (e) { LogService.error("Appservice", `Decryption error on ${event['room_id']} ${event['event_id']}`, e); this.emit("room.failed_decryption", event['room_id'], event, e); diff --git a/tsconfig-examples.json b/tsconfig-examples.json index f8f7df60..ffc89cbf 100644 --- a/tsconfig-examples.json +++ b/tsconfig-examples.json @@ -4,7 +4,7 @@ "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2015", + "target": "es2022", "noImplicitAny": false, "sourceMap": false, "outDir": "./lib", diff --git a/tsconfig-release.json b/tsconfig-release.json index 9c46a11a..2eef97bf 100644 --- a/tsconfig-release.json +++ b/tsconfig-release.json @@ -4,7 +4,7 @@ "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2020", + "target": "es2022", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", diff --git a/tsconfig.json b/tsconfig.json index b3e34a85..72a7c3c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2020", + "target": "ES2022", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib",