diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 58a389728..fadfacb89 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -90,7 +90,7 @@ jobs: homerunnersha: ${{ steps.gitsha.outputs.sha }} steps: - name: Checkout matrix-org/complement - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: matrix-org/complement - name: Get complement git sha @@ -98,7 +98,7 @@ jobs: run: echo sha=`git rev-parse --short HEAD` >> "$GITHUB_OUTPUT" - name: Cache homerunner id: cached - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: homerunner key: ${{ runner.os }}-homerunner-${{ steps.gitsha.outputs.sha }} @@ -123,23 +123,28 @@ jobs: needs: - test - build-homerunner + services: + redis: + image: redis + ports: + - 6379:6379 steps: - name: Install Complement Dependencies run: | sudo apt-get update && sudo apt-get install -y libolm3 - name: Load cached homerunner bin - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: homerunner key: ${{ runner.os }}-homerunner-${{ needs.build-synapse.outputs.homerunnersha }} fail-on-cache-miss: true # Shouldn't happen, we build this in the needs step. - name: Checkout matrix-hookshot - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: matrix-hookshot # Setup node & run tests - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: matrix-hookshot/.node-version - uses: Swatinem/rust-cache@v2 @@ -150,8 +155,9 @@ jobs: timeout-minutes: 10 env: HOMERUNNER_SPAWN_HS_TIMEOUT_SECS: 100 - HOMERUNNER_IMAGE: ghcr.io/element-hq/synapse/complement-synapse:latest + HOMERUNNER_IMAGE: ghcr.io/element-hq/synapse/complement-synapse:nightly NODE_OPTIONS: --dns-result-order ipv4first + REDIS_DATABASE_URI: "redis://localhost:6379" run: | docker pull $HOMERUNNER_IMAGE cd matrix-hookshot diff --git a/changelog.d/989.feature b/changelog.d/989.feature new file mode 100644 index 000000000..c396d2c08 --- /dev/null +++ b/changelog.d/989.feature @@ -0,0 +1,2 @@ +Support for E2E Encrypted rooms is now considered stable and can be enabled in production. Please see the [documentation](https://matrix-org.github.io/matrix-hookshot/latest/advanced/encryption.html) +on the requirements for enabling support. \ No newline at end of file diff --git a/config.sample.yml b/config.sample.yml index b351530ab..9a54339b1 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -1,5 +1,11 @@ # This is an example configuration file +logging: + # Logging settings. You can have a severity debug,info,warn,error + level: info + colorize: true + json: false + timestampFormat: HH:mm:ss:SSS bridge: # Basic homeserver configuration domain: example.com @@ -11,12 +17,6 @@ passFile: # A passkey used to encrypt tokens stored inside the bridge. # Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate ./passkey.pem -logging: - # Logging settings. You can have a severity debug,info,warn,error - level: info - colorize: true - json: false - timestampFormat: HH:mm:ss:SSS listeners: # HTTP Listener configuration. # Bind resource endpoints to ports and addresses. @@ -143,10 +143,12 @@ listeners: # # For encryption to work, this must be configured. # redisUri: redis://localhost:6379 -#queue: -# # (Optional) Message queue configuration options for large scale deployments. -# # For encryption to work, this must not be configured. -# redisUri: redis://localhost:6379 +#encryption: +# # (Optional) Configuration for encryption support in the bridge. +# # If omitted, encryption support will be disabled. +# storagePath: +# # Path to the directory used to store encryption files. These files must be persist between restarts of the service. +# ./cryptostore #widgets: # # (Optional) EXPERIMENTAL support for complimentary widgets diff --git a/docs/advanced/encryption.md b/docs/advanced/encryption.md index 4133f6be9..6d74c989e 100644 --- a/docs/advanced/encryption.md +++ b/docs/advanced/encryption.md @@ -1,27 +1,32 @@ Encryption ========== -
-Encryption support is HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE. It should not be enabled for production workloads. -For more details, see issue 594. +
+Support for encryption is considered stable, but the underlying specification changes are not yet. + +Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202), and [MSC4203](https://github.com/matrix-org/matrix-spec-proposals/pull/4203). Hookshot needs to be configured against a a homeserver that supports these features, such as [Synapse](#running-with-synapse). + +Please check with your homeserver implementation before reporting bugs against matrix-hookshot.
-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 `experimentalEncryption.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). Hookshot uses this directory for its crypto store (i.e. long-lived state relating to its encryption keys). +- 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). Hookshot uses this directory for its crypto store (i.e. long-lived state relating to its encryption keys). - Once a crypto store has been initialized, its files must not be modified, and Hookshot cannot be configured to use another crypto store of the same type as one it has used before. If a crypto store's files get lost or corrupted, Hookshot may fail to start up, or may be unable to decrypt command messages. To fix such issues, stop Hookshot, then reset its crypto store by running `yarn start:resetcrypto`. - [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue` MUST **NOT be configured**. -If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `experimentalEncryption.storagePath` directory and all worker state stored in your redis instance. Otherwise, Hookshot may fail on start up with registration errors. +If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `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`): +[Synapse](https://github.com/matrix-org/synapse/) has functional support for MSC3202 and MSC4203 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`): + +You may notice that MSC2409 is not listed above. Due to the changes being split out from MSC2409, `msc2409_to_device_messages_enabled` refers to MSC4203. ```yaml experimental_features: @@ -29,3 +34,4 @@ experimental_features: msc3202_transaction_extensions: true msc2409_to_device_messages_enabled: true ``` + diff --git a/spec/basic.spec.ts b/spec/basic.spec.ts index e375a18ab..2400487bb 100644 --- a/spec/basic.spec.ts +++ b/spec/basic.spec.ts @@ -26,37 +26,4 @@ describe('Basic test setup', () => { // Expect help text. expect((await msg).data.content.body).to.include('!hookshot help` - This help text\n'); }); - - // TODO: Move test to it's own generic connections file. - it('should be able to setup a webhook', async () => { - const user = testEnv.getUser('user'); - const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] }); - await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); - await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); - await user.sendText(testRoomId, "!hookshot webhook test-webhook"); - const inviteResponse = await user.waitForRoomInvite({sender: testEnv.botMxid}); - await user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, - body: 'Room configured to bridge webhooks. See admin room for secret url.' - }); - const webhookUrlMessage = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: inviteResponse.roomId - }); - await user.joinRoom(inviteResponse.roomId); - const msgData = (await webhookUrlMessage).data.content.body; - const webhookUrl = msgData.split('\n')[2]; - const webhookNotice = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!' - }); - - // Send a webhook - await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({text: 'Hello world!'}) - }); - - // And await the notice. - await webhookNotice; - }); }); diff --git a/spec/e2ee.spec.ts b/spec/e2ee.spec.ts new file mode 100644 index 000000000..a5c1d8219 --- /dev/null +++ b/spec/e2ee.spec.ts @@ -0,0 +1,66 @@ +import { MessageEventContent } from "matrix-bot-sdk"; +import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; +import { describe, it, beforeEach, afterEach } from "@jest/globals"; + +const CryptoRoomState = [{ + content: { + "algorithm": "m.megolm.v1.aes-sha2" + }, + state_key: "", + type: "m.room.encryption" +}]; + +describe('End-2-End Encryption support', () => { + let testEnv: E2ETestEnv; + + beforeEach(async () => { + testEnv = await E2ETestEnv.createTestEnv({ matrixLocalparts: ['user'], enableE2EE: true }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); + + afterEach(() => { + return testEnv?.tearDown(); + }); + + it('should be able to send the help command', async () => { + const user = testEnv.getUser('user'); + const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid], initial_state: CryptoRoomState}); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); + await user.sendText(testRoomId, "!hookshot help"); + await user.waitForRoomEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, + }); + }); + it('should send notices in an encrypted format', async () => { + const user = testEnv.getUser('user'); + const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid], initial_state: CryptoRoomState}); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); + await user.sendText(testRoomId, "!hookshot webhook test-webhook"); + const inviteResponse = await user.waitForRoomInvite({sender: testEnv.botMxid}); + await user.waitForEncryptedEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, + body: 'Room configured to bridge webhooks. See admin room for secret url.' + }); + const webhookUrlMessage = user.waitForEncryptedEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: inviteResponse.roomId + }); + await user.joinRoom(inviteResponse.roomId); + const msgData = (await webhookUrlMessage).data.content.body; + const webhookUrl = msgData.split('\n')[2]; + const webhookNotice = user.waitForEncryptedEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!' + }); + + // Send a webhook + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({text: 'Hello world!'}) + }); + + // And await the notice. + await webhookNotice; + }); +}); diff --git a/spec/util/e2e-test.ts b/spec/util/e2e-test.ts index 28631077a..7d842c7b3 100644 --- a/spec/util/e2e-test.ts +++ b/spec/util/e2e-test.ts @@ -5,13 +5,24 @@ import { BridgeConfig, BridgeConfigRoot } from "../../src/config/Config"; import { start } from "../../src/App/BridgeApp"; import { RSAKeyPairOptions, generateKeyPair } from "node:crypto"; import path from "node:path"; +import Redis from "ioredis"; -const WAIT_EVENT_TIMEOUT = 10000; +const WAIT_EVENT_TIMEOUT = 20000; export const E2ESetupTestTimeout = 60000; +const REDIS_DATABASE_URI = process.env.HOOKSHOT_E2E_REDIS_DB_URI ?? "redis://localhost:6379"; interface Opts { matrixLocalparts?: string[]; config?: Partial, + enableE2EE?: boolean, + useRedis?: boolean, +} + +interface WaitForEventResponse> { + roomId: string, + data: { + sender: string, type: string, state_key?: string, content: T, event_id: string, + } } export class E2ETestMatrixClient extends MatrixClient { @@ -55,13 +66,10 @@ export class E2ETestMatrixClient extends MatrixClient { }, `Timed out waiting for powerlevel from in ${roomId}`) } - public async waitForRoomEvent>( - opts: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string} - ): Promise<{roomId: string, data: { - sender: string, type: string, state_key?: string, content: T, event_id: string, - }}> { - const {eventType, sender, roomId, stateKey} = opts; - return this.waitForEvent('room.event', (eventRoomId: string, eventData: { + private async innerWaitForRoomEvent>( + {eventType, sender, roomId, stateKey, eventId, body}: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string, eventId?: string}, expectEncrypted: boolean, + ): Promise> { + return this.waitForEvent(expectEncrypted ? 'room.decrypted_event' : 'room.event', (eventRoomId: string, eventData: { sender: string, type: string, state_key?: string, content: T, event_id: string, }) => { if (eventData.sender !== sender) { @@ -73,21 +81,36 @@ export class E2ETestMatrixClient extends MatrixClient { if (roomId && eventRoomId !== roomId) { return undefined; } + if (eventId && eventData.event_id !== eventId) { + return undefined; + } if (stateKey !== undefined && eventData.state_key !== stateKey) { return undefined; } - const body = 'body' in eventData.content && eventData.content.body; - if (opts.body && body !== opts.body) { + const evtBody = 'body' in eventData.content && eventData.content.body; + if (body && body !== evtBody) { return undefined; } console.info( // eslint-disable-next-line max-len - `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? body ?? ''}` + `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? evtBody ?? ''}` ); return {roomId: eventRoomId, data: eventData}; }, `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`) } + public async waitForRoomEvent>( + opts: Parameters[0] + ): Promise> { + return this.innerWaitForRoomEvent(opts, false); + } + + public async waitForEncryptedEvent>( + opts: Parameters[0] + ): Promise> { + return this.innerWaitForRoomEvent(opts, true); + } + public async waitForRoomJoin( opts: {sender: string, roomId?: string} ): Promise<{roomId: string, data: unknown}> { @@ -173,10 +196,11 @@ export class E2ETestEnv { if (err) { reject(err) } else { resolve(privateKey) } })); + const dir = await mkdtemp('hookshot-int-test'); + // Configure homeserver and bots - const [homeserver, dir, privateKey] = await Promise.all([ - createHS([...matrixLocalparts || []], workerID), - mkdtemp('hookshot-int-test'), + const [homeserver, privateKey] = await Promise.all([ + createHS([...matrixLocalparts || []], workerID, opts.enableE2EE ? path.join(dir, 'client-crypto') : undefined), keyPromise, ]); const keyPath = path.join(dir, 'key.pem'); @@ -193,6 +217,15 @@ export class E2ETestEnv { providedConfig.github.auth.privateKeyFile = keyPath; } + opts.useRedis = opts.enableE2EE || opts.useRedis; + + let cacheConfig: BridgeConfigRoot["cache"]|undefined; + if (opts.useRedis) { + cacheConfig = { + redisUri: `${REDIS_DATABASE_URI}/${workerID}`, + } + } + const config = new BridgeConfig({ bridge: { domain: homeserver.domain, @@ -201,7 +234,7 @@ export class E2ETestEnv { bindAddress: '0.0.0.0', }, logging: { - level: 'info', + level: 'debug', }, // Always enable webhooks so that hookshot starts. generic: { @@ -214,6 +247,12 @@ export class E2ETestEnv { resources: ['webhooks'], }], passFile: keyPath, + ...(opts.enableE2EE ? { + encryption: { + storagePath: path.join(dir, 'crypto-store'), + } + } : undefined), + cache: cacheConfig, ...providedConfig, }); const registration: IAppserviceRegistration = { @@ -227,7 +266,8 @@ export class E2ETestEnv { }], rooms: [], aliases: [], - } + }, + "de.sorunome.msc2409.push_ephemeral": true }; const app = await start(config, registration); app.listener.finaliseListeners(); @@ -255,6 +295,12 @@ export class E2ETestEnv { await this.app.bridgeApp.stop(); await this.app.listener.stop(); await this.app.storage.disconnect?.(); + + // Clear the redis DB. + if (this.config.cache?.redisUri) { + await new Redis(this.config.cache.redisUri).flushdb(); + } + this.homeserver.users.forEach(u => u.client.stop()); await destroyHS(this.homeserver.id); await rm(this.dir, { recursive: true }); diff --git a/spec/util/homerunner.ts b/spec/util/homerunner.ts index a33bbc96a..16e4a1750 100644 --- a/spec/util/homerunner.ts +++ b/spec/util/homerunner.ts @@ -1,9 +1,10 @@ -import { MatrixClient } from "matrix-bot-sdk"; +import { MatrixClient, MemoryStorageProvider, RustSdkCryptoStorageProvider, RustSdkCryptoStoreType } from "matrix-bot-sdk"; import { createHash, createHmac, randomUUID } from "crypto"; import { Homerunner } from "homerunner-client"; import { E2ETestMatrixClient } from "./e2e-test"; +import path from "node:path"; -const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:latest'; +const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:nightly'; export const DEFAULT_REGISTRATION_SHARED_SECRET = ( process.env.REGISTRATION_SHARED_SECRET || 'complement' ); @@ -41,7 +42,7 @@ async function waitForHomerunner() { } } -export async function createHS(localparts: string[] = [], workerId: number): Promise { +export async function createHS(localparts: string[] = [], workerId: number, cryptoRootPath?: string): Promise { await waitForHomerunner(); const appPort = 49600 + workerId; @@ -61,25 +62,35 @@ export async function createHS(localparts: string[] = [], workerId: number): Pro SenderLocalpart: 'hookshot', RateLimited: false, ...{ASToken: asToken, - HSToken: hsToken}, + HSToken: hsToken, + SendEphemeral: true, + EnableEncryption: true}, }] }], } }); const [homeserverName, homeserver] = Object.entries(blueprintResponse.homeservers)[0]; // Skip AS user. - const users = Object.entries(homeserver.AccessTokens) + const users = await Promise.all(Object.entries(homeserver.AccessTokens) .filter(([_uId, accessToken]) => accessToken !== asToken) - .map(([userId, accessToken]) => ({ - userId: userId, - accessToken, - deviceId: homeserver.DeviceIDs[userId], - client: new E2ETestMatrixClient(homeserver.BaseURL, accessToken), - }) - ); + .map(async ([userId, accessToken]) => { + const cryptoStore = cryptoRootPath ? new RustSdkCryptoStorageProvider(path.join(cryptoRootPath, userId), RustSdkCryptoStoreType.Sqlite) : undefined; + const client = new E2ETestMatrixClient(homeserver.BaseURL, accessToken, new MemoryStorageProvider(), cryptoStore); + if (cryptoStore) { + await client.crypto.prepare(); + } + // Start syncing proactively. + await client.start(); + return { + userId: userId, + accessToken, + deviceId: homeserver.DeviceIDs[userId], + client, + } + } + )); + - // Start syncing proactively. - await Promise.all(users.map(u => u.client.start())); return { users, id: blueprint, @@ -119,7 +130,7 @@ export async function registerUser( .update(password).update("\x00") .update(user.admin ? 'admin' : 'notadmin') .digest('hex'); - return await fetch(registerUrl, { method: "POST", body: JSON.stringify( + const req = await fetch(registerUrl, { method: "POST", body: JSON.stringify( { nonce, username: user.username, @@ -127,8 +138,10 @@ export async function registerUser( admin: user.admin, mac: hmac, } - )}).then(res => res.json()).then(res => ({ - mxid: (res as {user_id: string}).user_id, - client: new MatrixClient(homeserverUrl, (res as {access_token: string}).access_token), - })).catch(err => { console.log(err.response.body); throw new Error(`Failed to register user: ${err}`); }); + )}); + const res = await req.json() as {user_id: string, access_token: string}; + return { + mxid: res.user_id, + client: new MatrixClient(homeserverUrl, res.access_token), + }; } diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 64294d793..5927cd117 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -171,13 +171,10 @@ export default class BotUsersManager { // Determine if an avatar update is needed if (profile.avatar_url) { try { - const res = await axios.get( - await botUser.intent.underlyingClient.mxcToHttp(profile.avatar_url), - { responseType: "arraybuffer" }, - ); + const res = await botUser.intent.underlyingClient.downloadContent(profile.avatar_url); const currentAvatarImage = { - image: Buffer.from(res.data), - contentType: res.headers["content-type"], + image: res.data, + contentType: res.contentType, }; if ( currentAvatarImage.image.equals(avatarImage.image) diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index b4ed8ac22..54be0a1d6 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -104,7 +104,7 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme } public async addRegisteredUser(userId: string) { - this.redis.sadd(REGISTERED_USERS_KEY, [userId]); + await this.redis.sadd(REGISTERED_USERS_KEY, [userId]); } public async isUserRegistered(userId: string): Promise { @@ -112,7 +112,7 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme } public async setTransactionCompleted(transactionId: string) { - this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]); + await this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]); } public async isTransactionCompleted(transactionId: string): Promise { diff --git a/src/appservice.ts b/src/appservice.ts index 9fbd5f346..7d380b1a2 100644 --- a/src/appservice.ts +++ b/src/appservice.ts @@ -45,7 +45,7 @@ export function getAppservice(config: BridgeConfig, registration: IAppserviceReg }, storage: storage, intentOptions: { - encryption: !!config.encryption, + encryption: !!cryptoStorage, }, cryptoStorage: cryptoStorage, }); diff --git a/src/config/Config.ts b/src/config/Config.ts index d07aa19ff..3aece92b1 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -15,6 +15,7 @@ import { Logger } from "matrix-appservice-bridge"; import { BridgeConfigCache } from "./sections/cache"; import { BridgeConfigGenericWebhooks, BridgeConfigQueue, BridgeGenericWebhooksConfigYAML } from "./sections"; import { GenericHookServiceConfig } from "../Connections"; +import { BridgeConfigEncryption } from "./sections/encryption"; const log = new Logger("Config"); @@ -358,8 +359,6 @@ interface BridgeConfigBridge { mediaUrl?: string; port: number; bindAddress: string; - // Removed - pantalaimon?: never; } interface BridgeConfigWebhook { @@ -378,10 +377,7 @@ interface BridgeConfigBot { displayname?: string; avatar?: string; } -interface BridgeConfigEncryption { - storagePath: string; - useLegacySledStore: boolean; -} + export interface BridgeConfigServiceBot { localpart: string; @@ -417,7 +413,11 @@ export interface BridgeConfigRoot { bot?: BridgeConfigBot; bridge: BridgeConfigBridge; cache?: BridgeConfigCache; - experimentalEncryption?: BridgeConfigEncryption; + /** + * @deprecated Old, unsupported encryption propety. + */ + experimentalEncryption?: never; + encryption?: BridgeConfigEncryption; feeds?: BridgeConfigFeedsYAML; figma?: BridgeConfigFigma; generic?: BridgeGenericWebhooksConfigYAML; @@ -445,9 +445,7 @@ export class BridgeConfig { For encryption to work, this must be configured.`, true) public readonly cache?: BridgeConfigCache; @configKey(`Configuration for encryption support in the bridge. - If omitted, encryption support will be disabled. - This feature is HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE. - For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.`, true) + If omitted, encryption support will be disabled.`, true) public readonly encryption?: BridgeConfigEncryption; @configKey(`Message queue configuration options for large scale deployments. For encryption to work, this must not be configured.`, true) @@ -502,6 +500,9 @@ export class BridgeConfig { constructor(configData: BridgeConfigRoot, env?: {[key: string]: string|undefined}) { + this.logging = configData.logging || { + level: "info", + } this.bridge = configData.bridge; assert.ok(this.bridge); this.github = configData.github && new BridgeConfigGitHub(configData.github); @@ -550,13 +551,11 @@ export class BridgeConfig { } } - this.encryption = configData.experimentalEncryption; - - - this.logging = configData.logging || { - level: "info", + if (configData.experimentalEncryption) { + throw new ConfigError("experimentalEncryption", `This key is now called 'encryption'. Please adjust your config file.`) } + this.encryption = configData.encryption && new BridgeConfigEncryption(configData.encryption, this.cache, this.queue); this.widgets = configData.widgets && new BridgeWidgetConfig(configData.widgets); this.sentry = configData.sentry; @@ -642,37 +641,6 @@ export class BridgeConfig { 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 `experimentalEncryption` config option"); - } - - if (this.encryption) { - log.warn(` -You have enabled encryption support in the bridge. This feature is HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE. -For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. - `); - - if (!this.encryption.storagePath) { - throw new ConfigError("experimentalEncryption.storagePath", "The crypto storage path must not be empty."); - } - - if (this.encryption.useLegacySledStore) { - throw new ConfigError( - "experimentalEncryption.useLegacySledStore", ` -The Sled crypto store format is no longer supported. -Please back up your crypto store at ${this.encryption.storagePath}, -remove "useLegacySledStore" from your configuration file, and restart Hookshot. - `); - } - if (!this.cache) { - throw new ConfigError("cache", "Encryption requires the Redis cache to be enabled."); - } - - if (this.queue) { - throw new ConfigError("queue", "Encryption does not support message queues."); - } - } - if (this.figma?.overrideUserId) { log.warn("The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead."); } diff --git a/src/config/Defaults.ts b/src/config/Defaults.ts index 6c07046cb..2e1ea29c4 100644 --- a/src/config/Defaults.ts +++ b/src/config/Defaults.ts @@ -17,9 +17,6 @@ export const DefaultConfigRoot: BridgeConfigRoot = { port: 9993, bindAddress: "127.0.0.1", }, - queue: { - redisUri: "redis://localhost:6379", - }, cache: { redisUri: "redis://localhost:6379", }, @@ -156,6 +153,9 @@ export const DefaultConfigRoot: BridgeConfigRoot = { sentry: { dsn: "https://examplePublicKey@o0.ingest.sentry.io/0", environment: "production" + }, + encryption: { + storagePath: "./cryptostore" } }; diff --git a/src/config/sections/encryption.ts b/src/config/sections/encryption.ts new file mode 100644 index 000000000..1dc96d167 --- /dev/null +++ b/src/config/sections/encryption.ts @@ -0,0 +1,27 @@ +import { ConfigError } from "../../errors"; +import { configKey } from "../Decorators"; +import { BridgeConfigCache } from "./cache"; +import { BridgeConfigQueue } from "./queue"; + +interface BridgeConfigEncryptionYAML { + storagePath: string; +} + +export class BridgeConfigEncryption { + @configKey("Path to the directory used to store encryption files. These files must be persist between restarts of the service.") + public readonly storagePath: string; + + constructor(config: BridgeConfigEncryptionYAML, cache: unknown|undefined, queue: unknown|undefined) { + if (typeof config.storagePath !== "string" || !config.storagePath) { + throw new ConfigError("encryption.storagePath", "The crypto storage path must not be empty."); + } + this.storagePath = config.storagePath; + + if (!cache) { + throw new ConfigError("cache", "Encryption requires the Redis cache to be enabled."); + } + if (queue) { + throw new ConfigError("queue", "Encryption does not support message queues."); + } + } +} diff --git a/src/feeds/parser.rs b/src/feeds/parser.rs index 631caee81..6b7ffadc3 100644 --- a/src/feeds/parser.rs +++ b/src/feeds/parser.rs @@ -60,7 +60,7 @@ fn parse_channel_to_js_result(channel: &Channel) -> JsRssChannel { .and_then(|i| i.permalink.then(|| i.value.to_string())) }), id: item.guid().map(|f| f.value().to_string()), - id_is_permalink: item.guid().map_or(false, |f| f.is_permalink()), + id_is_permalink: item.guid().is_some_and(|f| f.is_permalink()), pubdate: item.pub_date().map(String::from), summary: item.description().map(String::from), author: item.author().map(String::from), @@ -106,7 +106,7 @@ fn parse_feed_to_js_result(feed: &Feed) -> JsRssChannel { link: item .links() .iter() - .find(|l| l.mime_type.as_ref().map_or(false, |t| t == "text/html")) + .find(|l| l.mime_type.as_ref().is_some_and(|t| t == "text/html")) .or_else(|| item.links().first()) .map(|f| f.href.clone()), id: Some(item.id.clone()), diff --git a/tests/IntentUtilsTest.ts b/tests/IntentUtilsTest.ts index 03f359e57..9d010d36d 100644 --- a/tests/IntentUtilsTest.ts +++ b/tests/IntentUtilsTest.ts @@ -25,7 +25,11 @@ describe("IntentUtils", () => { return; } expect(roomId).to.equal(ROOM_ID); +<<<<<<< HEAD throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, {}) +======= + throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, { }) +>>>>>>> origin/main }; // This should invite the puppet user. @@ -46,7 +50,7 @@ describe("IntentUtils", () => { // This should fail the first time, then pass once we've tried to invite the user targetIntent.ensureJoined = () => { - throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, {}) + throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, { }) }; try { ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); diff --git a/tests/config/config.ts b/tests/config/config.ts index d9b4f503f..f942edc9f 100644 --- a/tests/config/config.ts +++ b/tests/config/config.ts @@ -42,9 +42,13 @@ describe("Config/BridgeConfig", () => { }); it("with monolithic disabled", () => { - const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { - monolithic: false - }}); + const config = new BridgeConfig({ + ...DefaultConfigRoot, + encryption: undefined, + queue: { + monolithic: false + } + }); expect(config.queue).to.deep.equal({ monolithic: false, }); @@ -54,9 +58,13 @@ describe("Config/BridgeConfig", () => { describe("will handle the queue option", () => { it("with redisUri", () => { - const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { - redisUri: "redis://localhost:6379" - }, cache: undefined}); + const config = new BridgeConfig({ ...DefaultConfigRoot, + encryption: undefined, + queue: { + redisUri: "redis://localhost:6379" + }, + cache: undefined + }); expect(config.queue).to.deep.equal({ redisUri: "redis://localhost:6379" }); diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index f196f0780..d1a6023b7 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -321,7 +321,11 @@ describe("GenericHookConnection", () => { return roomId; } expect(roomId).to.equal(ROOM_ID); +<<<<<<< HEAD throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, {}) +======= + throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, { }) +>>>>>>> origin/main }; // This should invite the puppet user. @@ -344,7 +348,11 @@ describe("GenericHookConnection", () => { // This should fail the first time, then pass once we've tried to invite the user intent.ensureJoined = () => { +<<<<<<< HEAD throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, {}) +======= + throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, { }) +>>>>>>> origin/main }; try { // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 diff --git a/tests/utils/IntentMock.ts b/tests/utils/IntentMock.ts index 259479a80..078091b17 100644 --- a/tests/utils/IntentMock.ts +++ b/tests/utils/IntentMock.ts @@ -39,7 +39,7 @@ export class MatrixClientMock { throw new MatrixError({ errcode: 'M_NOT_FOUND', error: 'Test error: No account data', - }, 404, {}); + }, 404, { }); } async setRoomAccountData(key: string, roomId: string, value: string): Promise {