diff --git a/changelog.d/365.feature b/changelog.d/365.feature new file mode 100644 index 00000000..6fe87429 --- /dev/null +++ b/changelog.d/365.feature @@ -0,0 +1 @@ +Use MediaProxy to serve authenticated Matrix media. diff --git a/config.sample.yaml b/config.sample.yaml index aa67af82..e56516d9 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -5,10 +5,20 @@ bridge: homeserverUrl: "http://localhost:8008" # Prefix of all users of the bridge. userPrefix: "_bifrost_" - # If homeserverUrl is not reachable publically, the public address that media can be reached on. - # mediaserverUrl: "http://example.com:8008" # Set this to the port you want the bridge to listen on. appservicePort: 9555 + # Config for the media proxy + # required to serve publically accessible URLs to authenticated Matrix media + mediaProxy: + # To generate a .jwk file: + # $ node src/generate-signing-key.js > signingkey.jwk + signingKeyPath: "signingkey.jwk" + # How long should the generated URLs be valid for + ttlSeconds: 3600 + # The port for the media proxy to listen on + bindPort: 11111 + # The publically accessible URL to the media proxy + publicUrl: "https://bifrost.bridge/media" roomRules: [] # - room: "#badroom:example.com" diff --git a/config/config.schema.yaml b/config/config.schema.yaml index 0cb821a4..e3aa53a1 100644 --- a/config/config.schema.yaml +++ b/config/config.schema.yaml @@ -5,18 +5,28 @@ required: ["bridge", "datastore", "purple", "portals"] properties: bridge: type: object - required: ["domain", "homeserverUrl", "userPrefix"] + required: ["domain", "homeserverUrl", "userPrefix", "mediaProxy"] properties: domain: type: string homeserverUrl: type: string - mediaserverUrl: - type: string userPrefix: type: string appservicePort: type: number + mediaProxy: + type: "object" + properties: + signingKeyPath: + type: "string" + ttlSeconds: + type: "integer" + bindPort: + type: "integer" + publicUrl: + type: "string" + required: ["signingKeyPath", "ttlSeconds", "bindPort", "publicUrl"] datastore: required: ["engine"] type: "object" diff --git a/package.json b/package.json index 625531cd..2bd780e9 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "leven": "^3.0.0", "marked": "^11.1.1", "nedb": "^1.8.0", - "matrix-appservice-bridge": "^10.1.0", + "matrix-appservice-bridge": "^10.2.0", "pg": "8.11.3", "prom-client": "^15.1.0", "quick-lru": "^5.0.0" diff --git a/src/Config.ts b/src/Config.ts index 8a17dd1d..1f98f3e5 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -12,9 +12,14 @@ export class Config { public readonly bridge: IConfigBridge = { domain: "", homeserverUrl: "", - mediaserverUrl: undefined, userPrefix: "_bifrost_", appservicePort: 9555, + mediaProxy: { + signingKeyPath: "", + ttlSeconds: 0, + bindPort: 0, + publicUrl: "" + }, }; public readonly roomRules: IConfigRoomRule[] = []; @@ -102,9 +107,14 @@ export class Config { export interface IConfigBridge { domain: string; homeserverUrl: string; - mediaserverUrl?: string; userPrefix: string; appservicePort?: number; + mediaProxy: { + signingKeyPath: string; + ttlSeconds: number; + bindPort: number; + publicUrl: string; + }, } export interface IConfigPurple { @@ -183,4 +193,4 @@ export interface IConfigRoomRule { * Should the room be allowed, or denied. */ action: "allow"|"deny"; -} \ No newline at end of file +} diff --git a/src/MatrixEventHandler.ts b/src/MatrixEventHandler.ts index 07af3906..2369f907 100644 --- a/src/MatrixEventHandler.ts +++ b/src/MatrixEventHandler.ts @@ -1,4 +1,4 @@ -import { Bridge, MatrixUser, Request, WeakEvent, RoomBridgeStoreEntry, TypingEvent, PresenceEvent } from "matrix-appservice-bridge"; +import { Bridge, MatrixUser, Request, WeakEvent, RoomBridgeStoreEntry, TypingEvent, PresenceEvent, MediaProxy } from "matrix-appservice-bridge"; import { MatrixMembershipEvent, MatrixMessageEvent } from "./MatrixTypes"; import { MROOM_TYPE_UADMIN, MROOM_TYPE_IM, MROOM_TYPE_GROUP, IRemoteUserAdminData, @@ -37,6 +37,7 @@ export class MatrixEventHandler { private readonly config: Config, private readonly gatewayHandler: GatewayHandler, private readonly bridge: Bridge, + private readonly mediaProxy: MediaProxy, private readonly autoReg: AutoRegistration|null = null, ) { this.roomAliases = new RoomAliasSet(this.config.portals, this.purple); @@ -609,7 +610,7 @@ Say \`help\` for more commands. } const recipient: string = context.remote.get("recipient"); log.info(`Sending IM to ${recipient}`); - const msg = MessageFormatter.matrixEventToBody(event as MatrixMessageEvent, this.config.bridge); + const msg = await MessageFormatter.matrixEventToBody(event as MatrixMessageEvent, this.config.bridge, this.mediaProxy); acct.sendIM(recipient, msg); } @@ -625,7 +626,7 @@ Say \`help\` for more commands. const isGateway: boolean = context.remote.get("gateway"); const name: string = context.remote.get("room_name"); if (isGateway) { - const msg = MessageFormatter.matrixEventToBody(event as MatrixMessageEvent, this.config.bridge); + const msg = await MessageFormatter.matrixEventToBody(event as MatrixMessageEvent, this.config.bridge, this.mediaProxy); try { await this.gatewayHandler.sendMatrixMessage(name, event.sender, msg, context); } catch (ex) { @@ -645,7 +646,7 @@ Say \`help\` for more commands. await this.joinOrDefer(acct, name, props); } const roomName: string = context.remote.get("room_name"); - const msg = MessageFormatter.matrixEventToBody(event as MatrixMessageEvent, this.config.bridge); + const msg = await MessageFormatter.matrixEventToBody(event as MatrixMessageEvent, this.config.bridge, this.mediaProxy); let nick = ""; // XXX: Gnarly way of trying to determine who we are. try { diff --git a/src/MessageFormatter.ts b/src/MessageFormatter.ts index 4ac3d578..038812cf 100644 --- a/src/MessageFormatter.ts +++ b/src/MessageFormatter.ts @@ -1,7 +1,7 @@ import { BifrostProtocol } from "./bifrost/Protocol"; import { PRPL_S4B, PRPL_XMPP } from "./ProtoHacks"; import { Parser } from "htmlparser2"; -import { Intent, Logger } from "matrix-appservice-bridge"; +import { Intent, Logger, MediaProxy } from "matrix-appservice-bridge"; import { IConfigBridge } from "./Config"; import { IMatrixMsgContents, MatrixMessageEvent } from "./MatrixTypes"; @@ -33,7 +33,7 @@ const log = new Logger("MessageFormatter"); export class MessageFormatter { - public static matrixEventToBody(event: MatrixMessageEvent, config: IConfigBridge): IBasicProtocolMessage { + public static async matrixEventToBody(event: MatrixMessageEvent, config: IConfigBridge, mediaProxy: MediaProxy): Promise { let content = event.content; const originalMessage = event.content["m.relates_to"]?.event_id; const formatted: {type: string, body: string}[] = []; @@ -51,15 +51,13 @@ export class MessageFormatter { return {body: `/me ${content.body}`, formatted, id: event.event_id}; } if (["m.file", "m.image", "m.video"].includes(event.content.msgtype) && event.content.url) { - const [domain, mediaId] = event.content.url.substr("mxc://".length).split("/"); - const url = (config.mediaserverUrl ? config.mediaserverUrl : config.homeserverUrl).replace(/\/$/, ""); return { body: content.body, id: event.event_id, opts: { attachments: [ { - uri: `${url}/_matrix/media/v1/download/${domain}/${mediaId}`, + uri: (await mediaProxy.generateMediaUrl(event.content.url)).toString(), mimetype: event.content.info?.mimetype, size: event.content.info?.size, }, diff --git a/src/Program.ts b/src/Program.ts index 6616599b..21928a35 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -1,4 +1,4 @@ -import { Cli, Bridge, AppServiceRegistration, Logger, TypingEvent, Request, PresenceEvent } from "matrix-appservice-bridge"; +import { Cli, Bridge, AppServiceRegistration, Logger, TypingEvent, Request, PresenceEvent, MediaProxy } from "matrix-appservice-bridge"; import { EventEmitter } from "events"; import { MatrixEventHandler } from "./MatrixEventHandler"; import { MatrixRoomHandler } from "./MatrixRoomHandler"; @@ -16,6 +16,9 @@ import { AutoRegistration } from "./AutoRegistration"; import { GatewayHandler } from "./GatewayHandler"; import { IRemoteUserAdminData, MROOM_TYPE_UADMIN } from "./store/Types"; +import * as fs from "fs"; +import { webcrypto } from "node:crypto"; + Logger.configure({console: "debug"}); const log = new Logger("Program"); const bridgeLog = new Logger("bridge"); @@ -88,6 +91,21 @@ class Program { callback(reg); } + private async initialiseMediaProxy(): Promise { + const config = this.config.bridge.mediaProxy; + const jwk = JSON.parse(fs.readFileSync(config.signingKeyPath, "utf8").toString()); + const signingKey = await webcrypto.subtle.importKey('jwk', jwk, { + name: 'HMAC', + hash: 'SHA-512', + }, true, ['sign', 'verify']); + const publicUrl = new URL(config.publicUrl); + + const mediaProxy = new MediaProxy({ publicUrl, signingKey, ttl: config.ttlSeconds * 1000 }, this.bridge.getIntent().matrixClient); + mediaProxy.start(config.bindPort); + + return mediaProxy; + } + private async waitForHomeserver() { log.info("Checking if homeserver is up"); // Wait for the homeserver to start before progressing with the bridge. @@ -314,8 +332,10 @@ class Program { this.roomSync = new RoomSync( purple, this.store, this.deduplicator, this.gatewayHandler, this.bridge.getIntent(), ); + const mediaProxy = await this.initialiseMediaProxy(); + this.eventHandler = new MatrixEventHandler( - purple, this.store, this.deduplicator, this.config, this.gatewayHandler, this.bridge, autoReg, + purple, this.store, this.deduplicator, this.config, this.gatewayHandler, this.bridge, mediaProxy, autoReg ); await this.bridge.listen(port); @@ -351,4 +371,4 @@ new Program().start(); process.on('unhandledRejection', (reason, promise) => { log.warn(`Unhandled rejection`, reason, promise); -}); \ No newline at end of file +}); diff --git a/src/generate-signing-key.js b/src/generate-signing-key.js new file mode 100644 index 00000000..cb032b43 --- /dev/null +++ b/src/generate-signing-key.js @@ -0,0 +1,11 @@ +const webcrypto = require('node:crypto'); + +async function main() { + const key = await webcrypto.subtle.generateKey({ + name: 'HMAC', + hash: 'SHA-512', + }, true, ['sign', 'verify']); + console.log(JSON.stringify(await webcrypto.subtle.exportKey('jwk', key), undefined, 4)); +} + +main().then(() => process.exit(0)).catch(err => { throw err }); diff --git a/test/test_matrixeventhandler.ts b/test/test_matrixeventhandler.ts index 061650df..b478aa39 100644 --- a/test/test_matrixeventhandler.ts +++ b/test/test_matrixeventhandler.ts @@ -76,7 +76,8 @@ function createMEH() { new Deduplicator(), config, gatewayHandler as any, - bridge as any + bridge as any, + {} as any, ); return {meh, store}; } diff --git a/test/test_messageformatter.ts b/test/test_messageformatter.ts index 0c9e62dd..b69a0806 100644 --- a/test/test_messageformatter.ts +++ b/test/test_messageformatter.ts @@ -18,10 +18,14 @@ const intent = { }), } as any; +const mediaProxy = { + generateMediaUrl: () => Promise.resolve('http://mediaproxy/token'), +} as any; + describe("MessageFormatter", () => { describe("matrixEventToBody", () => { - it("should transform a plain text message to a basic body", () => { - const msg = MessageFormatter.matrixEventToBody({ + it("should transform a plain text message to a basic body", async () => { + const msg = await MessageFormatter.matrixEventToBody({ sender: "@foo:bar", event_id: "$event:bar", content: { @@ -35,15 +39,16 @@ describe("MessageFormatter", () => { domain: "bar", homeserverUrl: "http://bar", userPrefix: "_xmpp", - }); + mediaProxy: {} as any, + }, mediaProxy); expect(msg).to.deep.eq({ body: "This is some plaintext!", formatted: [], id: "$event:bar", }); }); - it("should transform a formatted message", () => { - const msg = MessageFormatter.matrixEventToBody({ + it("should transform a formatted message", async () => { + const msg = await MessageFormatter.matrixEventToBody({ sender: "@foo:bar", event_id: "$event:bar", content: { @@ -59,7 +64,8 @@ describe("MessageFormatter", () => { domain: "bar", homeserverUrl: "http://bar", userPrefix: "_xmpp", - }); + mediaProxy: {} as any, + }, mediaProxy); expect(msg).to.deep.eq({ body: "This is some plaintext!", formatted: [{ @@ -69,8 +75,8 @@ describe("MessageFormatter", () => { id: "$event:bar", }); }); - it("should transform an info-less media event", () => { - const msg = MessageFormatter.matrixEventToBody({ + it("should transform an info-less media event", async () => { + const msg = await MessageFormatter.matrixEventToBody({ sender: "@foo:bar", event_id: "$event:bar", content: { @@ -85,7 +91,8 @@ describe("MessageFormatter", () => { domain: "bar", homeserverUrl: "http://bar", userPrefix: "_xmpp", - }); + mediaProxy: {} as any, + }, mediaProxy); expect(msg).to.deep.eq({ body: "image.jpg", opts: { @@ -93,15 +100,15 @@ describe("MessageFormatter", () => { { mimetype: undefined, size: undefined, - uri: "http://bar/_matrix/media/v1/download/bar/foosdsd", + uri: "http://mediaproxy/token", }, ], }, id: "$event:bar", }); }); - it("should transform a media event", () => { - const msg = MessageFormatter.matrixEventToBody({ + it("should transform a media event", async () => { + const msg = await MessageFormatter.matrixEventToBody({ sender: "@foo:bar", event_id: "$event:bar", content: { @@ -120,7 +127,8 @@ describe("MessageFormatter", () => { domain: "bar", homeserverUrl: "http://bar", userPrefix: "_xmpp", - }); + mediaProxy: {} as any, + }, mediaProxy); expect(msg).to.deep.eq({ body: "image.jpg", opts: { @@ -128,15 +136,15 @@ describe("MessageFormatter", () => { { mimetype: "image/jpeg", size: 1000, - uri: "http://bar/_matrix/media/v1/download/bar/foosdsd", + uri: "http://mediaproxy/token", }, ], }, id: "$event:bar", }); }); - it("should transform a emote message to a basic body", () => { - const msg = MessageFormatter.matrixEventToBody({ + it("should transform a emote message to a basic body", async () => { + const msg = await MessageFormatter.matrixEventToBody({ sender: "@foo:bar", event_id: "$event:bar", content: { @@ -150,7 +158,8 @@ describe("MessageFormatter", () => { domain: "bar", homeserverUrl: "http://bar", userPrefix: "_xmpp", - }); + mediaProxy: {} as any, + }, mediaProxy); expect(msg).to.deep.eq({ body: "/me pets the dog", formatted: [], diff --git a/yarn.lock b/yarn.lock index c67fd85d..772a9b3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3300,10 +3300,10 @@ marked@^11.1.1: resolved "https://registry.yarnpkg.com/marked/-/marked-11.1.1.tgz#e1b2407241f744fb1935fac224680874d9aff7a3" integrity sha512-EgxRjgK9axsQuUa/oKMx5DEY8oXpKJfk61rT5iY3aRlgU6QJtUcxU5OAymdhCvWvhYcd9FKmO5eQoX8m9VGJXg== -matrix-appservice-bridge@^10.1.0: - version "10.1.0" - resolved "https://registry.npmjs.org/matrix-appservice-bridge/-/matrix-appservice-bridge-10.1.0.tgz" - integrity sha512-WiyovdQv3WfguffCTTycZ+tM+gwc4DvxSS6em0q5kjdCYiw+ogXezTTkUZZ2QBsIqmgVzF96UQC8PuFS7iWoBA== +matrix-appservice-bridge@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/matrix-appservice-bridge/-/matrix-appservice-bridge-10.2.0.tgz#79b6f53593ab21cf6211a3ba1051a0439b2f2824" + integrity sha512-GYpIBPgQnc0/p93KoqzrOYwh6JJEWZc+t9N0GxMO7EXb0IEkfKMji6lOOEqk9QdNs2+fpMBKIS2mjT3X5iuTUg== dependencies: "@alloc/quick-lru" "^5.2.0" "@types/nedb" "^1.8.16"