diff --git a/changelog.d/299.feature b/changelog.d/299.feature new file mode 100644 index 000000000..7c50d02d2 --- /dev/null +++ b/changelog.d/299.feature @@ -0,0 +1 @@ +Add support for end-to-bridge encryption via MSC3202. diff --git a/changelog.d/299.removal b/changelog.d/299.removal new file mode 100644 index 000000000..2b8b2023c --- /dev/null +++ b/changelog.d/299.removal @@ -0,0 +1 @@ +Remove support for Pantalaimon-based encryption. diff --git a/config.sample.yml b/config.sample.yml index b4d9f3653..383c64763 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -107,11 +107,17 @@ metrics: # enabled: true queue: - # (Optional) Message queue / cache configuration options for large scale deployments + # (Optional) Message queue / cache configuration options for large scale deployments. + # For encryption to work, must be set to monolithic mode and have a host & port specified. # monolithic: true port: 6379 host: localhost +encryption: + # (Optional) Configuration for encryption support in the bridge. + # If omitted, encryption support will be disabled. + # + storagePath: ./data/encryption logging: # (Optional) Logging settings. You can have a severity debug,info,warn,error # diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 94cbcde82..e2837e201 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -25,4 +25,5 @@ - [Provisioning](./advanced/provisioning.md) - [Workers](./advanced/workers.md) +- [🔒 Encryption](./advanced/encryption.md) - [🪀 Widgets](./advanced/widgets.md) diff --git a/docs/advanced/encryption.md b/docs/advanced/encryption.md new file mode 100644 index 000000000..e466525ec --- /dev/null +++ b/docs/advanced/encryption.md @@ -0,0 +1,25 @@ +Encryption +======= + +Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202). As such, encryption requires hookshot to be connected to a homeserver that supports that MSC, such as [Synapse](#running-with-synapse). + +## Enabling encryption in Hookshot + +In order for hookshot to use encryption, it must be configured as follows: +- The `encryption.storagePath` setting must point to a directory that hookshot has permissions to write files into. If running with Docker, this path should be within a volume (for persistency). +- [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue.monolithic` must be set to `true`. + +If you ever reset your homeserver's state, ensure you also reset hookshot's encryption state. This includes clearing the `encryption.storagePath` directory and all worker state stored in your redis instance. Otherwise, hookshot may fail on start up with registration errors. + +Also ensure that hookshot's appservice registration file contains every line from `registration.sample.yml` that appears after the `If enabling encryption` comment. Note that changing the registration file may require restarting the homeserver that hookshot is connected to. + +## Running with Synapse + +[Synapse](https://github.com/matrix-org/synapse/) has functional support for MSC3202 as of [v1.63.0](https://github.com/matrix-org/synapse/releases/tag/v1.63.0). To enable it, add the following section to Synapse's configuration file (typically named `homeserver.yaml`): + +```yaml +experimental_features: + msc3202_device_masquerading: true + msc3202_transaction_extensions: true + msc2409_to_device_messages_enabled: true +``` diff --git a/docs/advanced/workers.md b/docs/advanced/workers.md index d32b113c6..d16261714 100644 --- a/docs/advanced/workers.md +++ b/docs/advanced/workers.md @@ -23,6 +23,8 @@ queue: host: github-bridge-redis ``` +Note that if [encryption](./encryption.md) is enabled, `queue.monolithic` must be set to `true`, as worker mode is not yet supported with encryption. + Once that is done, you can simply start the processes by name using yarn: ``` yarn start:webhooks diff --git a/docs/metrics.md b/docs/metrics.md index 4364a7189..88d1f7eaa 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -37,6 +37,7 @@ Below is the generated list of Prometheus metrics for Hookshot. | matrix_api_calls | The number of Matrix client API calls made | method | | matrix_api_calls_failed | The number of Matrix client API calls which failed | method | | matrix_appservice_events | The number of events sent over the AS API | | +| matrix_appservice_decryption_failed | The number of events sent over the AS API that failed to decrypt | | ## feed | Metric | Help | Labels | |--------|------|--------| diff --git a/package.json b/package.json index a1eb11468..8694d7903 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "jira-client": "^8.0.0", "markdown-it": "^12.3.2", "matrix-appservice-bridge": "^6.0.0", - "matrix-bot-sdk": "^0.6.2", + "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.6.3-element.0", "matrix-widget-api": "^1.0.0", "micromatch": "^4.0.4", "mime": "^3.0.0", diff --git a/registration.sample.yml b/registration.sample.yml index 644e27e3f..d7a89d629 100644 --- a/registration.sample.yml +++ b/registration.sample.yml @@ -19,3 +19,8 @@ namespaces: sender_localpart: hookshot url: "http://localhost:9993" # This should match the bridge.port in your config file rate_limited: false + +# If enabling encryption +de.sorunome.msc2409.push_ephemeral: true +push_ephemeral: true +org.matrix.msc3202: true \ No newline at end of file diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 909bb151c..703fcde1f 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -7,6 +7,7 @@ import { UserNotificationWatcher } from "../Notifications/UserNotificationWatche import { ListenerService } from "../ListenerService"; import { Logger } from "matrix-appservice-bridge"; import { LogService } from "matrix-bot-sdk"; +import { getAppservice } from "../appservice"; Logger.configure({console: "info"}); const log = new Logger("App"); @@ -25,14 +26,16 @@ async function start() { }); LogService.setLogger(Logger.botSdkLogger); + const {appservice, storage} = getAppservice(config, registration); + if (config.queue.monolithic) { - const matrixSender = new MatrixSender(config, registration); + const matrixSender = new MatrixSender(config, appservice); matrixSender.listen(); const userNotificationWatcher = new UserNotificationWatcher(config); userNotificationWatcher.start(); } - const bridgeApp = new Bridge(config, registration, listener); + const bridgeApp = new Bridge(config, listener, appservice, storage); process.once("SIGTERM", () => { log.error("Got SIGTERM"); diff --git a/src/App/MatrixSenderApp.ts b/src/App/MatrixSenderApp.ts index 1198c98b2..e0881f98e 100644 --- a/src/App/MatrixSenderApp.ts +++ b/src/App/MatrixSenderApp.ts @@ -4,6 +4,7 @@ import { Logger } from "matrix-appservice-bridge"; import Metrics from "../Metrics"; import { ListenerService } from "../ListenerService"; import { LogService } from "matrix-bot-sdk"; +import { getAppservice } from "../appservice"; const log = new Logger("App"); @@ -21,7 +22,7 @@ async function start() { }); LogService.setLogger(Logger.botSdkLogger); const listener = new ListenerService(config.listeners); - const sender = new MatrixSender(config, registration); + const sender = new MatrixSender(config, getAppservice(config, registration).appservice); if (config.metrics) { if (!config.metrics.port) { log.warn(`Not running metrics for service, no port specified`); diff --git a/src/Bridge.ts b/src/Bridge.ts index 22318d6cf..f2286bfea 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1,6 +1,6 @@ import { AdminAccountData } from "./AdminRoomCommandHandler"; import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom"; -import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient, EventKind, PowerLevelsEvent } from "matrix-bot-sdk"; +import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent } from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; import { CommentProcessor } from "./CommentProcessor"; @@ -14,14 +14,12 @@ import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNot import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./Jira/WebhookTypes"; import { JiraOAuthResult } from "./Jira/Types"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; -import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { MessageSenderClient } from "./MatrixSender"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { NotificationProcessor } from "./NotificationsProcessor"; import { NotificationsEnableEvent, NotificationsDisableEvent } from "./Webhooks"; import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./Github/Types"; -import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; import { retry } from "./PromiseUtil"; import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"; import { UserTokenStore } from "./UserTokenStore"; @@ -36,7 +34,6 @@ import Metrics from "./Metrics"; import { FigmaEvent, ensureFigmaWebhooks } from "./figma"; import { ListenerService } from "./ListenerService"; import { SetupConnection } from "./Connections/SetupConnection"; -import { getAppservice } from "./appservice"; import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./Jira/OAuth"; import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types"; import { SetupWidget } from "./Widgets/SetupWidget"; @@ -44,8 +41,6 @@ import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReade const log = new Logger("Bridge"); export class Bridge { - private readonly as: Appservice; - private readonly storage: IBridgeStorageProvider; private readonly messageClient: MessageSenderClient; private readonly queue: MessageQueue; private readonly commentProcessor: CommentProcessor; @@ -53,24 +48,18 @@ export class Bridge { private readonly tokenStore: UserTokenStore; private connectionManager?: ConnectionManager; private github?: GithubInstance; - private encryptedMatrixClient?: MatrixClient; private adminRooms: Map = new Map(); - private widgetApi?: BridgeWidgetApi; private provisioningApi?: Provisioner; private replyProcessor = new RichRepliesPreprocessor(true); private ready = false; - constructor(private config: BridgeConfig, private registration: IAppserviceRegistration, private readonly listener: ListenerService) { - if (this.config.queue.host && this.config.queue.port) { - log.info(`Initialising Redis storage (on ${this.config.queue.host}:${this.config.queue.port})`); - this.storage = new RedisStorageProvider(this.config.queue.host, this.config.queue.port); - } else { - log.info('Initialising memory storage'); - this.storage = new MemoryStorageProvider(); - } - this.as = getAppservice(this.config, this.registration, this.storage); - Metrics.registerMatrixSdkMetrics(this.as); + constructor( + private config: BridgeConfig, + private readonly listener: ListenerService, + private readonly as: Appservice, + private readonly storage: IBridgeStorageProvider, + ) { this.queue = createMessageQueue(this.config.queue); this.messageClient = new MessageSenderClient(this.queue); this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url); @@ -97,7 +86,7 @@ export class Bridge { while(joinedRooms === undefined) { try { log.info("Connecting to homeserver and fetching joined rooms.."); - joinedRooms = await this.as.botClient.getJoinedRooms(); + joinedRooms = await this.as.botIntent.getJoinedRooms(); log.debug(`Bridge bot is joined to ${joinedRooms.length} rooms`); } catch (ex) { // This is our first interaction with the homeserver, so wait if it's not ready yet. @@ -122,24 +111,6 @@ export class Bridge { await ensureFigmaWebhooks(this.config.figma, this.as.botClient); } - if (this.config.bridge.pantalaimon) { - log.info(`Loading pantalaimon client`); - const pan = new PantalaimonClient( - this.config.bridge.pantalaimon.url, - this.storage, - ); - this.encryptedMatrixClient = await pan.createClientWithCredentials( - this.config.bridge.pantalaimon.username, - this.config.bridge.pantalaimon.password - ); - this.encryptedMatrixClient.on("room.message", async (roomId, event) => { - return this.onRoomMessage(roomId, event); - }); - // TODO: Filter - await this.encryptedMatrixClient.start(); - log.info(`Pan client is syncing`); - } - const connManager = this.connectionManager = new ConnectionManager(this.as, this.config, this.tokenStore, this.commentProcessor, this.messageClient, this.storage, this.github); @@ -206,6 +177,11 @@ export class Bridge { return this.onRoomJoin(roomId, event); }); + this.as.on("room.failed_decryption", (roomId, event, err) => { + log.warn(`Failed to decrypt event ${event.event_id} from ${roomId}: ${err.message}`); + Metrics.matrixAppserviceDecryptionFailed.inc(); + }); + this.queue.subscribe("response.matrix.message"); this.queue.subscribe("notifications.user.events"); this.queue.subscribe("github.*"); @@ -434,7 +410,7 @@ export class Bridge { } as GitHubOAuthToken)); // Some users won't have an admin room and would have gone through provisioning. - const adminRoom = [...this.adminRooms.values()].find(r => r.userId === userId); + const adminRoom = this.getAdminRoomForUser(userId); if (adminRoom) { await adminRoom.sendNotice("Logged into GitHub"); } @@ -559,7 +535,7 @@ export class Bridge { }); // Some users won't have an admin room and would have gone through provisioning. - const adminRoom = [...this.adminRooms.values()].find(r => r.userId === userId); + const adminRoom = this.getAdminRoomForUser(userId); if (adminRoom) { await adminRoom.sendNotice("Logged into Jira"); } @@ -741,7 +717,7 @@ export class Bridge { if (apps.length > 1) { throw Error('You may only bind `widgets` to one listener.'); } - this.widgetApi = new BridgeWidgetApi( + new BridgeWidgetApi( this.adminRooms, this.config, this.storage, @@ -795,30 +771,9 @@ export class Bridge { await retry(() => this.as.botIntent.joinRoom(roomId), 5); if (event.content.is_direct) { - const room = await this.setUpAdminRoom(roomId, {admin_user: event.sender}, NotifFilter.getDefaultContent()); await this.as.botClient.setRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, room.accountData, + BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender}, ); - return; - } - - if (this.connectionManager?.isRoomConnected(roomId)) { - // Room has connections, don't set up a wizard. - return; - } - - try { - // Otherwise it's a new room - if (this.config.widgets?.roomSetupWidget?.addOnInvite) { - if (await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, roomId, "im.vector.modular.widgets", true) === false) { - await this.as.botIntent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin."); - } else { - // Set up the widget - await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); - } - } - } catch (ex) { - log.error(`Failed to set up new widget for room`, ex); } } @@ -892,7 +847,7 @@ export class Bridge { github: this.github, getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this.connectionManager), }, - this.getOrCreateAdminRoom.bind(this), + this.getOrCreateAdminRoomForUser.bind(this), this.connectionManager.push.bind(this.connectionManager), ) ).onMessageEvent(event, checkPermission); @@ -926,7 +881,7 @@ export class Bridge { log.info("Missing parts!:", splitParts, issueNumber); } } catch (ex) { - await adminRoom.sendNotice("Failed to handle repy. You may not be authenticated to do that."); + await adminRoom.sendNotice("Failed to handle reply. You may not be authenticated to do that."); log.error("Reply event could not be handled:", ex); } return; @@ -944,14 +899,46 @@ export class Bridge { // Only act on bot joins return; } + + if (this.config.encryption) { + // Ensure crypto is aware of all members of this room before posting any messages, + // so that the bot can share room keys to all recipients first. + await this.as.botClient.crypto.onRoomJoin(roomId); + } + + const adminAccountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( + BRIDGE_ROOM_TYPE, roomId, + ); + if (adminAccountData) { + const room = await this.setUpAdminRoom(roomId, adminAccountData, NotifFilter.getDefaultContent()); + await this.as.botClient.setRoomAccountData( + BRIDGE_ROOM_TYPE, roomId, room.accountData, + ); + } + if (!this.connectionManager) { // Not ready yet. return; } // Only fetch rooms we have no connections in yet. - if (!this.connectionManager.isRoomConnected(roomId)) { + const roomHasConnection = + this.connectionManager.isRoomConnected(roomId) || await this.connectionManager.createConnectionsForRoomId(roomId, true); + + // If room has connections or is an admin room, don't setup a wizard. + // Otherwise it's a new room + if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) { + try { + if (await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, roomId, "im.vector.modular.widgets", true) === false) { + await this.as.botIntent.sendText(roomId, "Hello! To setup new integrations in this room, please promote me to a Moderator/Admin"); + } else { + // Setup the widget + await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); + } + } catch (ex) { + log.error(`Failed to setup new widget for room`, ex); + } } } @@ -1180,17 +1167,12 @@ export class Bridge { } - private async getOrCreateAdminRoom(userId: string): Promise { - const existingRoom = [...this.adminRooms.values()].find(r => r.userId === userId); + private async getOrCreateAdminRoomForUser(userId: string): Promise { + const existingRoom = this.getAdminRoomForUser(userId); if (existingRoom) { return existingRoom; } - // Otherwise, we need to create a room. - const roomId = await this.as.botClient.createRoom({ - invite: [userId], - is_direct: true, - preset: "trusted_private_chat", - }); + const roomId = await this.as.botClient.dms.getOrCreateDm(userId); const room = await this.setUpAdminRoom(roomId, {admin_user: userId}, NotifFilter.getDefaultContent()); await this.as.botClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, @@ -1198,6 +1180,15 @@ export class Bridge { return room; } + private getAdminRoomForUser(userId: string): AdminRoom|null { + for (const adminRoom of this.adminRooms.values()) { + if (adminRoom.userId === userId) { + return adminRoom; + } + } + return null; + } + private async setUpAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) { if (!this.connectionManager) { throw Error('setUpAdminRoom() called before connectionManager was ready'); @@ -1258,7 +1249,8 @@ export class Bridge { } else { return; } - for (const adminRoom of [...this.adminRooms.values()].filter(r => r.userId === userId)) { + for (const adminRoom of this.adminRooms.values()) { + if (adminRoom.userId !== userId) continue; if (adminRoom?.notificationsEnabled(type, instanceName)) { log.debug(`Token was updated for ${userId} (${type}), notifying notification watcher`); this.queue.push({ diff --git a/src/Config/Config.ts b/src/Config/Config.ts index 2accef04f..ae251d1cf 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -381,11 +381,8 @@ interface BridgeConfigBridge { mediaUrl?: string; port: number; bindAddress: string; - pantalaimon?: { - url: string; - username: string; - password: string; - } + // Removed + pantalaimon?: never; } interface BridgeConfigWebhook { @@ -410,6 +407,9 @@ interface BridgeConfigBot { displayname?: string; avatar?: string; } +interface BridgeConfigEncryption { + storagePath: string; +} export interface BridgeConfigProvisioning { bindAddress?: string; @@ -426,6 +426,7 @@ export interface BridgeConfigMetrics { export interface BridgeConfigRoot { bot?: BridgeConfigBot; bridge: BridgeConfigBridge; + encryption?: BridgeConfigEncryption; figma?: BridgeConfigFigma; feeds?: BridgeConfigFeedsYAML; generic?: BridgeGenericWebhooksConfigYAML; @@ -446,7 +447,11 @@ export interface BridgeConfigRoot { export class BridgeConfig { @configKey("Basic homeserver configuration") public readonly bridge: BridgeConfigBridge; - @configKey("Message queue / cache configuration options for large scale deployments", true) + @configKey(`Configuration for encryption support in the bridge. + If omitted, encryption support will be disabled.`, true) + public readonly encryption?: BridgeConfigEncryption; + @configKey(`Message queue / cache configuration options for large scale deployments. + For encryption to work, must be set to monolithic mode and have a host & port specified.`, true) public readonly queue: BridgeConfigQueue; @configKey("Logging settings. You can have a severity debug,info,warn,error", true) public readonly logging: BridgeConfigLogging; @@ -510,6 +515,7 @@ export class BridgeConfig { this.queue = configData.queue || { monolithic: true, }; + this.encryption = configData.encryption; this.logging = configData.logging || { level: "info", @@ -601,6 +607,18 @@ export class BridgeConfig { if (this.widgets && this.widgets.openIdOverrides) { log.warn("The `widgets.openIdOverrides` config value SHOULD NOT be used in a production environment.") } + + if (this.bridge.pantalaimon) { + throw new ConfigError("bridge.pantalaimon", "Pantalaimon support has been removed. Encrypted bridges should now use the `encryption` config option"); + } + + if (this.encryption && !this.queue.monolithic) { + throw new ConfigError("queue.monolithic", "Encryption is not supported in worker mode yet."); + } + + if (this.encryption && !this.queue.port) { + throw new ConfigError("queue.port", "You must enable redis support for encryption to work."); + } } public async prefillMembershipCache(client: MatrixClient) { diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index 5b8d8d771..d7d292b75 100644 --- a/src/Config/Defaults.ts +++ b/src/Config/Defaults.ts @@ -134,7 +134,10 @@ export const DefaultConfig = new BridgeConfig({ bindAddress: '0.0.0.0', resources: ['widgets'], } - ] + ], + encryption: { + storagePath: "./data/encryption" + } }, {}); function renderSection(doc: YAML.Document, obj: Record, parentNode?: YAMLSeq) { diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index a2570fe6f..bdf7b183a 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -171,6 +171,7 @@ export class ConnectionManager extends EventEmitter { } public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) { + let connectionCreated = false; const state = await this.as.botClient.getRoomState(roomId); for (const event of state) { try { @@ -178,11 +179,13 @@ export class ConnectionManager extends EventEmitter { if (conn) { log.debug(`Room ${roomId} is connected to: ${conn}`); this.push(conn); + connectionCreated = true; } } catch (ex) { log.error(`Failed to create connection for ${roomId}:`, ex); } } + return connectionCreated; } public getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitHubRepoConnection)[] { diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 0fdf6eaf6..211dcf876 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -232,11 +232,10 @@ export class GenericHookConnection extends BaseConnection implements IConnection return `@${this.config.userIdPrefix}${name || 'bot'}:${domain}`; } - public async ensureDisplayname() { + public async ensureDisplayname(sender: string) { if (!this.state.name) { return; } - const sender = this.getUserId(); if (sender === this.as.botUserId) { // Don't set the global displayname for the bot. return; @@ -377,7 +376,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection } const sender = this.getUserId(); - await this.ensureDisplayname(); + await this.ensureDisplayname(sender); // Matrix cannot handle float data, so make sure we parse out any floats. const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts index f1f37523e..89db2a496 100644 --- a/src/MatrixSender.ts +++ b/src/MatrixSender.ts @@ -1,10 +1,8 @@ import { BridgeConfig } from "./Config/Config"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; -import { Appservice, IAppserviceRegistration, MemoryStorageProvider } from "matrix-bot-sdk"; +import { Appservice, Intent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { v4 as uuid } from "uuid"; -import { getAppservice } from "./appservice"; -import Metrics from "./Metrics"; export interface IMatrixSendMessage { sender: string|null; @@ -26,11 +24,8 @@ const log = new Logger("MatrixSender"); export class MatrixSender { private mq: MessageQueue; - private as: Appservice; - constructor(private config: BridgeConfig, registration: IAppserviceRegistration) { + constructor(private config: BridgeConfig, private readonly as: Appservice) { this.mq = createMessageQueue(this.config.queue); - this.as = getAppservice(config, registration, new MemoryStorageProvider()); - Metrics.registerMatrixSdkMetrics(this.as); } public listen() { @@ -52,7 +47,15 @@ export class MatrixSender { public async sendMatrixMessage(messageId: string, msg: IMatrixSendMessage) { const intent = msg.sender ? this.as.getIntentForUserId(msg.sender) : this.as.botIntent; - await intent.ensureRegisteredAndJoined(msg.roomId); + if (this.config.encryption) { + // Ensure crypto is aware of all members of this room before posting any messages, + // so that the bot can share room keys to all recipients first. + await intent.enableEncryption(); + await intent.joinRoom(msg.roomId); + await intent.underlyingClient.crypto.onRoomJoin(msg.roomId); + } else { + await intent.ensureRegisteredAndJoined(msg.roomId); + } try { const eventId = await intent.underlyingClient.sendEvent(msg.roomId, msg.type, msg.content); log.info(`Sent event to room ${msg.roomId} (${msg.sender}) > ${eventId}`); diff --git a/src/Metrics.ts b/src/Metrics.ts index ff4ec7c8f..1216eac23 100644 --- a/src/Metrics.ts +++ b/src/Metrics.ts @@ -22,6 +22,7 @@ export class Metrics { private readonly matrixApiCallsFailed = new Counter({ name: "matrix_api_calls_failed", help: "The number of Matrix client API calls which failed", labelNames: ["method"], registers: [this.registry]}); public readonly matrixAppserviceEvents = new Counter({ name: "matrix_appservice_events", help: "The number of events sent over the AS API", labelNames: [], registers: [this.registry]}); + public readonly matrixAppserviceDecryptionFailed = new Counter({ name: "matrix_appservice_decryption_failed", help: "The number of events sent over the AS API that failed to decrypt", registers: [this.registry]}); public readonly feedsCount = new Gauge({ name: "feed_count", help: "The number of RSS feeds that hookshot is subscribed to", labelNames: [], registers: [this.registry]}); public readonly feedFetchMs = new Gauge({ name: "feed_fetch_ms", help: "The time taken for hookshot to fetch all feeds", labelNames: [], registers: [this.registry]}); diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index ff47a3d8a..f6821efcc 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -3,7 +3,7 @@ import { Redis, default as redis } from "ioredis"; import { Logger } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "./StorageProvider"; -import { IFilterInfo } from "matrix-bot-sdk"; +import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk"; import { ProvisionSession } from "matrix-appservice-bridge"; const BOT_SYNC_TOKEN_KEY = "bot.sync_token."; @@ -25,27 +25,8 @@ const WIDGET_USER_TOKENS = "widgets.user-tokens."; const log = new Logger("RedisASProvider"); -export class RedisStorageProvider implements IBridgeStorageProvider { - private redis: Redis; - - constructor(host: string, port: number, private contextSuffix = '') { - this.redis = new redis(port, host); - } - - public async connect(): Promise { - try { - await this.redis.ping(); - } catch (ex) { - log.error('Could not ping the redis instance, is it reachable?'); - throw ex; - } - log.info("Successfully connected"); - try { - await this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER); - } catch (ex) { - log.warn("Failed to set expiry time on as.completed_transactions", ex); - } - } +export class RedisStorageContextualProvider implements IStorageProvider { + constructor(protected readonly redis: Redis, protected readonly contextSuffix = '') { } public setSyncToken(token: string|null){ if (token === null) { @@ -76,6 +57,31 @@ export class RedisStorageProvider implements IBridgeStorageProvider { return this.redis.get(`${BOT_VALUE_KEY}${this.contextSuffix}.${key}`); } +} + +export class RedisStorageProvider extends RedisStorageContextualProvider implements IBridgeStorageProvider { + constructor(host: string, port: number, contextSuffix = '') { + super(new redis(port, host), contextSuffix); + this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => { + log.warn("Failed to set expiry time on as.completed_transactions", ex); + }); + } + + public async connect(): Promise { + try { + await this.redis.ping(); + } catch (ex) { + log.error('Could not ping the redis instance, is it reachable?'); + throw ex; + } + log.info("Successfully connected"); + try { + await this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER); + } catch (ex) { + log.warn("Failed to set expiry time on as.completed_transactions", ex); + } + } + public async addRegisteredUser(userId: string) { this.redis.sadd(REGISTERED_USERS_KEY, [userId]); } @@ -167,4 +173,12 @@ export class RedisStorageProvider implements IBridgeStorageProvider { token = await this.redis.spop(`${WIDGET_USER_TOKENS}${userId}`); } } + + storageForUser(userId: string) { + const newContext = [userId]; + if (this.contextSuffix) { + newContext.push(this.contextSuffix); + } + return new RedisStorageContextualProvider(this.redis, newContext.join(".")); + } } diff --git a/src/appservice.ts b/src/appservice.ts index 01cd79a21..ff7873928 100644 --- a/src/appservice.ts +++ b/src/appservice.ts @@ -1,8 +1,25 @@ -import { Appservice, IAppserviceRegistration, IAppserviceStorageProvider } from "matrix-bot-sdk"; +import { Logger } from "matrix-appservice-bridge"; +import { Appservice, IAppserviceRegistration, RustSdkAppserviceCryptoStorageProvider } from "matrix-bot-sdk"; import { BridgeConfig } from "./Config/Config"; +import Metrics from "./Metrics"; +import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; +import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; +import { IBridgeStorageProvider } from "./Stores/StorageProvider"; +const log = new Logger("Appservice"); -export function getAppservice(config: BridgeConfig, registration: IAppserviceRegistration, storage: IAppserviceStorageProvider) { - return new Appservice({ +export function getAppservice(config: BridgeConfig, registration: IAppserviceRegistration) { + let storage: IBridgeStorageProvider; + if (config.queue.host && config.queue.port) { + log.info(`Initialising Redis storage (on ${config.queue.host}:${config.queue.port})`); + storage = new RedisStorageProvider(config.queue.host, config.queue.port); + } else { + log.info('Initialising memory storage'); + storage = new MemoryStorageProvider(); + } + + const cryptoStorage = config.encryption?.storagePath ? new RustSdkAppserviceCryptoStorageProvider(config.encryption.storagePath) : undefined; + + const appservice = new Appservice({ homeserverName: config.bridge.domain, homeserverUrl: config.bridge.url, port: config.bridge.port, @@ -20,5 +37,13 @@ export function getAppservice(config: BridgeConfig, registration: IAppserviceReg } }, storage: storage, + intentOptions: { + encryption: !!config.encryption, + }, + cryptoStorage: cryptoStorage, }); + + Metrics.registerMatrixSdkMetrics(appservice); + + return {appservice, storage}; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 6fb52cc38..e01a22585 100644 --- a/yarn.lock +++ b/yarn.lock @@ -773,6 +773,13 @@ dependencies: node-downloader-helper "^2.1.1" +"@matrix-org/matrix-sdk-crypto-nodejs@^0.1.0-beta.3": + version "0.1.0-beta.3" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.3.tgz#a07225dd180d9d227c24ba62bba439939446d113" + integrity sha512-jHFn6xBeNqfsY5gX60akbss7iFBHZwXycJWMw58Mjz08OwOi7AbTxeS9I2Pa4jX9/M2iinskmGZbzpqOT2fM3A== + dependencies: + node-downloader-helper "^2.1.1" + "@mdn/browser-compat-data@^3.3.14": version "3.3.14" resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-3.3.14.tgz#b72a37c654e598f9ae6f8335faaee182bebc6b28" @@ -4268,6 +4275,29 @@ matrix-bot-sdk@^0.6.2: request-promise "^4.2.6" sanitize-html "^2.7.0" +"matrix-bot-sdk@npm:@vector-im/matrix-bot-sdk@^0.6.3-element.0": + version "0.6.3-element.0" + resolved "https://registry.yarnpkg.com/@vector-im/matrix-bot-sdk/-/matrix-bot-sdk-0.6.3-element.0.tgz#dfd36d1073145ce95b609b3f78f66a50a9c520e4" + integrity sha512-PuH4vSP2aE0TkJUqLtPsfqU0oEAlW9zmYOd8b5hY8RSkpfitup4lCwTYipWovJIKeoolNv5uMPA2mzqM3mcP6w== + dependencies: + "@matrix-org/matrix-sdk-crypto-nodejs" "^0.1.0-beta.3" + "@types/express" "^4.17.13" + another-json "^0.2.0" + async-lock "^1.3.2" + chalk "^4" + express "^4.18.1" + glob-to-regexp "^0.4.1" + hash.js "^1.1.7" + html-to-text "^8.2.0" + htmlencode "^0.0.4" + lowdb "^1" + lru-cache "^7.10.1" + mkdirp "^1.0.4" + morgan "^1.10.0" + request "^2.88.2" + request-promise "^4.2.6" + sanitize-html "^2.7.0" + matrix-widget-api@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.0.0.tgz#0cde6839cca66ad817ab12aca3490ccc8bac97d1"