diff --git a/changelog.d/444.feature b/changelog.d/444.feature new file mode 100644 index 00000000..678d7334 --- /dev/null +++ b/changelog.d/444.feature @@ -0,0 +1 @@ +Add a MediaProxy component for MSC3910 compliance. diff --git a/spec/unit/media-proxy.spec.ts b/spec/unit/media-proxy.spec.ts new file mode 100644 index 00000000..f8add2ff --- /dev/null +++ b/spec/unit/media-proxy.spec.ts @@ -0,0 +1,39 @@ +import { MediaProxy } from "../../src"; +import { webcrypto } from 'node:crypto'; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +const signingKey = webcrypto.subtle.generateKey({ + name: 'HMAC', + hash: 'SHA-512', +}, true, ['sign', 'verify']); +const publicUrl = new URL("http://example-public.url/my-cs-path"); + +describe("MediaProxy", function() { + let mediaProxy: MediaProxy; + beforeEach(async function () { + mediaProxy = new MediaProxy({ + publicUrl, + ttl: 60 * 1000, + signingKey: await signingKey, + }, new MatrixClient('https://example.com', 'test_access_token')); + }) + + it('can generate a media url', async () => { + const url = await mediaProxy.generateMediaUrl('mxc://example.com/some_media'); + expect(url.origin).toEqual(publicUrl.origin); + expect(url.pathname.startsWith('/my-cs-path/v1/media/download')).toBeTrue(); + const base64Data = url.pathname.slice('/my-cs-path/v1/media/download'.length); + expect(() => Buffer.from(base64Data, 'base64url')).not.toThrow(); + }); + + it('can decode a media url', async () => { + const now = Date.now(); + const mxc = 'mxc://example.com/some_media'; + const url = await mediaProxy.generateMediaUrl(mxc); + const token = url.pathname.slice('/my-cs-path/v1/media/download/'.length); + const data = await mediaProxy.verifyMediaToken(token); + expect('mxc://' + data.mxc).toBe(mxc); + expect(data.endDt).toBeGreaterThanOrEqual(now + 60 * 1000); + expect(data.endDt).toBeLessThanOrEqual(now + 61 * 1000); + }); +}); diff --git a/src/components/media-proxy.ts b/src/components/media-proxy.ts new file mode 100644 index 00000000..defb64cf --- /dev/null +++ b/src/components/media-proxy.ts @@ -0,0 +1,191 @@ +import { webcrypto } from 'node:crypto'; +import { Request, Response, default as express, Application, NextFunction, Router } from 'express'; +import { ApiError, IApiError, Logger, ErrCode } from '..'; +import { Server, get } from 'http'; +import { MatrixClient } from '@vector-im/matrix-bot-sdk'; +const subtleCrypto = webcrypto.subtle; +const log = new Logger('MediaProxy'); + +interface MediaMetadata { + endDt?: number; + mxc: string; +} + +interface Opts { + publicUrl: URL; + ttl?: number; + signingKey: webcrypto.CryptoKey; +} + +const ALGORITHM: webcrypto.HmacKeyAlgorithm = { name: 'hmac', hash: { + name: 'SHA-512' + }, + length: 512, +}; + +/** + * A media proxy class intended for bridges which share media to the + * public internet. + */ + +export class MediaProxy { + private readonly internalRouter: Router; + + /** + * Only used if start() is called. + */ + private readonly app?: Application; + private server?: Server; + /** + * Get the express router used for handling calls. + */ + public get router() { + return this.internalRouter; + } + + constructor(private readonly opts: Opts, private readonly matrixClient: MatrixClient) { + // eslint-disable-next-line new-cap + this.internalRouter = Router(); + this.internalRouter.use((req, _res, next) => { + log.info(`${req.method} ${req.path} ${req.ip || ''} ${req.headers["user-agent"] || ''}`); + next(); + }); + this.internalRouter.get('/health', this.getHealth.bind(this)); + this.internalRouter.get('/v1/media/download/:mediaToken', + (req, res, next) => this.onMediaRequest(req, res).catch(ex => next(ex)) + ); + this.internalRouter.use(this.onError); + } + + public async start(port: number, hostname = "0.0.0.0", backlog = 10): Promise { + const app = express(); + app.use(this.internalRouter); + return new Promise((res) => { + if (this.app) { + this.server = this.app.listen(port, hostname, backlog, () => res()); + log.info(`Media proxy API listening on port ${port}`); + } + }); + } + + public close(): Promise { + return new Promise((res, rej) => this.server?.close(e => e ? rej(e) : res())); + } + + async getMediaToken(metadata: MediaMetadata) { + // V1 token format: + // - At offset zero: a single byte, numeric int, indicating a token version. + // Version 0 is reserved for future use, for the remote possibility we run out of versions in an int8 :) + // - At offset 1: the SHA-512 HMAC signature of the payload (64 bytes) + // - At offset 65: MediaMetadata.endDt, encoded as a Big-Endian double (matching JS' `number` type). + // An undefined endDt is encoded as a -1. 8 bytes. + // - At offset 73: the MXC of the media content, until the end of the buffer. + // The payload, for the purpose of generating the signature, + // is the byte-encoded endDt concatenated with the byte-encoded MXC. + const version = Buffer.allocUnsafe(1); + version.writeInt8(1); + + const dt = Buffer.allocUnsafe(8); + dt.writeDoubleBE(metadata.endDt ?? -1); + + const mxcBuf = Buffer.from(metadata.mxc); + + const payload = Buffer.concat([dt, mxcBuf]); + const sig = Buffer.from(await subtleCrypto.sign(ALGORITHM, this.opts.signingKey, payload)); + + const token = Buffer.concat([version, sig, dt, mxcBuf]); + return token.toString('base64url'); + } + + async verifyMediaToken(token: string): Promise { + const buf = Buffer.from(token, 'base64url'); + let cursor = 0; + const version = buf.readInt8(cursor++); + if (version !== 1) { + throw new ApiError(`Unrecognized version of media token (${version})`, ErrCode.BadValue); + } + + const sig = buf.subarray(cursor, cursor += 64); + const dtBuf = buf.subarray(cursor, cursor += 8); + const mxcBuf = buf.subarray(cursor); + + try { + if (!subtleCrypto.verify(ALGORITHM, this.opts.signingKey, Buffer.concat([dtBuf, mxcBuf]), sig)) { + throw new Error('Signature did not match'); + } + } + catch (ex) { + throw new ApiError('Media token signature is invalid', ErrCode.BadValue) + } + + const dt = dtBuf.readDoubleBE(); + return { + mxc: mxcBuf.toString(), + endDt: dt === -1 ? undefined : dt, + }; + } + + + public async generateMediaUrl(mxc: string): Promise { + const endDt = this.opts.ttl ? Date.now() + this.opts.ttl : undefined; + // Remove cruft + const token = await this.getMediaToken({ endDt, mxc: mxc.replace('mxc://', '') }); + const { pathname, origin } = this.opts.publicUrl; + const slash = pathname.endsWith('/') ? '' : '/'; + const path = new URL( + `${pathname}${slash}v1/media/download/${token}`, + origin + ); + return path; + } + + public async onMediaRequest(req: Request, res: Response) { + const { mediaToken } = req.params; + if (typeof mediaToken !== "string") { + throw new ApiError("Invalid mediaToken supplied", ErrCode.BadValue); + } + const metadata = await this.verifyMediaToken(mediaToken); + if (metadata.endDt && metadata.endDt < Date.now()) { + throw new ApiError('Access to the media you requested has now expired.', ErrCode.NotFound); + } + // Cache from this point onwards. + // Extract the media from the event. + const url = this.matrixClient.mxcToHttp('mxc://' + metadata.mxc); + get(url, { + headers: { + 'Authorization': `Bearer ${this.matrixClient.accessToken}`, + }, + }, (getRes) => { + const { statusCode } = res; + res.setHeader('content-disposition', getRes.headers['content-disposition'] as string); + res.setHeader('content-type', getRes.headers['content-type'] as string); + res.setHeader('content-length', getRes.headers['content-length'] as string); + res.status(statusCode); + getRes.pipe(res); + }); + } + + private getHealth(req: Request, res: Response) { + res.send({ok: true}); + } + + // Needed so that _next can be defined in order to preserve signature. + private onError( + err: IApiError|Error, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _req: Request, res: Response, _next: NextFunction) { + if (!err) { + return; + } + log.error(err); + if (res.headersSent) { + return; + } + if ("apply" in err && typeof err.apply === "function") { + err.apply(res); + } + else { + new ApiError("An internal error occured").apply(res); + } + } +} diff --git a/src/index.ts b/src/index.ts index 21f063e5..3bc663e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export * from "./components/room-upgrade-handler"; export * from "./components/app-service-bot"; export * from "./components/state-lookup"; export * from "./components/activity-tracker"; +export * from "./components/media-proxy"; // Config and CLI export * from "./components/cli"; diff --git a/src/provisioning/errors.ts b/src/provisioning/errors.ts index 1f0429c8..6016d6e7 100644 --- a/src/provisioning/errors.ts +++ b/src/provisioning/errors.ts @@ -27,10 +27,18 @@ export enum ErrCode { */ BadOpenID = "M_AS_BAD_OPENID", - Ratelimited = "M_AS_LIMIT_EXCEEDED" + /** + * The request was denied due to ratelimiting rules. + */ + Ratelimited = "M_AS_LIMIT_EXCEEDED", + /** + * The item that was requested could not be found. + */ + NotFound = "M_NOT_FOUND", } const ErrCodeToStatusCode: Record = { + M_NOT_FOUND: 404, M_AS_UNKNOWN: 500, M_AS_UNSUPPORTED_OPERATION: 400, M_AS_BAD_VALUE: 400,