Skip to content

Commit

Permalink
Use a more structured algorithm for determining decryption client for…
Browse files Browse the repository at this point in the history
… appservice events (#41)

This PR changes the way we pick a client for decrypting an event, and
caches the result so that it can be reused. This is intended to ensure
that when the sender user isn't used for decryption, Appservice doesn't
make repeated calls to get_joined_rooms, and when it picks a client it
prefers one with encryption already enabled to save time.

We also update to ES2022 so I can use Error.cause, one of my favourite
new ES features.

## Checklist

* [ ] Tests written for all new code
* [ ] Linter has been satisfied
* [ ] Sign-off given on the changes (see CONTRIBUTING.md)
  • Loading branch information
Half-Shot authored Dec 4, 2023
2 parents a806668 + 9344147 commit 545eb16
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 36 deletions.
117 changes: 84 additions & 33 deletions src/appservice/Appservice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export class Appservice extends EventEmitter {
private eventProcessors: { [eventType: string]: IPreprocessor[] } = {};
private pendingTransactions = new Map<string, Promise<void>>();

/**
* A cache of intents for the purposes of decrypting rooms
*/
private cryptoClientForRoomId: LRU.LRUCache<string, MatrixClient>;

/**
* Creates a new application service.
* @param {IAppserviceOptions} options The options for the application service.
Expand All @@ -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.
Expand Down Expand Up @@ -658,6 +668,75 @@ export class Appservice extends EventEmitter {
return providedToken === this.registration.hs_token;
}

private async decryptAppserviceEvent(roomId: string, encrypted: EncryptedRoomEvent): ReturnType<Appservice["processEvent"]> {
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<string, unknown>) {
// 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
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tsconfig-examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"emitDecoratorMetadata": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2015",
"target": "es2022",
"noImplicitAny": false,
"sourceMap": false,
"outDir": "./lib",
Expand Down
2 changes: 1 addition & 1 deletion tsconfig-release.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"emitDecoratorMetadata": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2020",
"target": "es2022",
"noImplicitAny": false,
"sourceMap": true,
"outDir": "./lib",
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"emitDecoratorMetadata": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2020",
"target": "ES2022",
"noImplicitAny": false,
"sourceMap": true,
"outDir": "./lib",
Expand Down

0 comments on commit 545eb16

Please sign in to comment.