diff --git a/res/sw.js b/res/sw.js deleted file mode 100644 index 1fdf7324e1..0000000000 --- a/res/sw.js +++ /dev/null @@ -1 +0,0 @@ -self.addEventListener("fetch", () => {}); diff --git a/src/serviceworker/index.ts b/src/serviceworker/index.ts new file mode 100644 index 0000000000..b06921892f --- /dev/null +++ b/src/serviceworker/index.ts @@ -0,0 +1,186 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { idbLoad } from "matrix-react-sdk/src/utils/StorageAccess"; +import { ACCESS_TOKEN_IV, tryDecryptToken } from "matrix-react-sdk/src/utils/tokens/tokens"; +import { buildAndEncodePickleKey } from "matrix-react-sdk/src/utils/tokens/pickling"; + +const serverSupportMap: { + [serverUrl: string]: { + supportsMSC3916: boolean; + cacheExpiryTimeMs: number; + }; +} = {}; + +self.addEventListener("install", (event) => { + // We skipWaiting() to update the service worker more frequently, particularly in development environments. + // @ts-expect-error - service worker types are not available. See 'fetch' event handler. + event.waitUntil(skipWaiting()); +}); + +self.addEventListener("activate", (event) => { + // We force all clients to be under our control, immediately. This could be old tabs. + // @ts-expect-error - service worker types are not available. See 'fetch' event handler. + event.waitUntil(clients.claim()); +}); + +// @ts-expect-error - the service worker types conflict with the DOM types available through TypeScript. Many hours +// have been spent trying to convince the type system that there's no actual conflict, but it has yet to work. Instead +// of trying to make it do the thing, we force-cast to something close enough where we can (and ignore errors otherwise). +self.addEventListener("fetch", (event: FetchEvent) => { + // This is the authenticated media (MSC3916) check, proxying what was unauthenticated to the authenticated variants. + + if (event.request.method !== "GET") { + return; // not important to us + } + + // Note: ideally we'd keep the request headers etc, but in practice we can't even see those details. + // See https://stackoverflow.com/a/59152482 + let url = event.request.url; + + // We only intercept v3 download and thumbnail requests as presumably everything else is deliberate. + // For example, `/_matrix/media/unstable` or `/_matrix/media/v3/preview_url` are something well within + // the control of the application, and appear to be choices made at a higher level than us. + if (!url.includes("/_matrix/media/v3/download") && !url.includes("/_matrix/media/v3/thumbnail")) { + return; // not a URL we care about + } + + // We need to call respondWith synchronously, otherwise we may never execute properly. This means + // later on we need to proxy the request through if it turns out the server doesn't support authentication. + event.respondWith( + (async (): Promise<Response> => { + let accessToken: string | undefined; + try { + // Figure out which homeserver we're communicating with + const csApi = url.substring(0, url.indexOf("/_matrix/media/v3")); + + // Add jitter to reduce request spam, particularly to `/versions` on initial page load + await new Promise<void>((resolve) => setTimeout(() => resolve(), Math.random() * 10)); + + // Locate our access token, and populate the fetchConfig with the authentication header. + // @ts-expect-error - service worker types are not available. See 'fetch' event handler. + const client = await self.clients.get(event.clientId); + accessToken = await getAccessToken(client); + + // Update or populate the server support map using a (usually) authenticated `/versions` call. + await tryUpdateServerSupportMap(csApi, accessToken); + + // If we have server support (and a means of authentication), rewrite the URL to use MSC3916 endpoints. + if (serverSupportMap[csApi].supportsMSC3916 && accessToken) { + // Currently unstable only. + // TODO: Support stable endpoints when available. + url = url.replace(/\/media\/v3\/(.*)\//, "/client/unstable/org.matrix.msc3916/media/$1/"); + } // else by default we make no changes + } catch (err) { + console.error("SW: Error in request rewrite.", err); + } + + // Add authentication and send the request. We add authentication even if MSC3916 endpoints aren't + // being used to ensure patches like this work: + // https://github.com/matrix-org/synapse/commit/2390b66bf0ec3ff5ffb0c7333f3c9b239eeb92bb + return fetch(url, fetchConfigForToken(accessToken)); + })(), + ); +}); + +async function tryUpdateServerSupportMap(clientApiUrl: string, accessToken?: string): Promise<void> { + // only update if we don't know about it, or if the data is stale + if (serverSupportMap[clientApiUrl]?.cacheExpiryTimeMs > new Date().getTime()) { + return; // up to date + } + + const config = fetchConfigForToken(accessToken); + const versions = await (await fetch(`${clientApiUrl}/_matrix/client/versions`, config)).json(); + + serverSupportMap[clientApiUrl] = { + supportsMSC3916: Boolean(versions?.unstable_features?.["org.matrix.msc3916"]), + cacheExpiryTimeMs: new Date().getTime() + 2 * 60 * 60 * 1000, // 2 hours from now + }; +} + +// Ideally we'd use the `Client` interface for `client`, but since it's not available (see 'fetch' listener), we use +// unknown for now and force-cast it to something close enough later. +async function getAccessToken(client: unknown): Promise<string | undefined> { + // Access tokens are encrypted at rest, so while we can grab the "access token", we'll need to do work to get the + // real thing. + const encryptedAccessToken = await idbLoad("account", "mx_access_token"); + + // We need to extract a user ID and device ID from localstorage, which means calling WebPlatform for the + // read operation. Service workers can't access localstorage. + const { userId, deviceId } = await askClientForUserIdParams(client); + + // ... and this is why we need the user ID and device ID: they're index keys for the pickle key table. + const pickleKeyData = await idbLoad("pickleKey", [userId, deviceId]); + if (pickleKeyData && (!pickleKeyData.encrypted || !pickleKeyData.iv || !pickleKeyData.cryptoKey)) { + console.error("SW: Invalid pickle key loaded - ignoring"); + return undefined; + } + + // Finally, try decrypting the thing and return that. This may fail, but that's okay. + try { + const pickleKey = await buildAndEncodePickleKey(pickleKeyData, userId, deviceId); + return tryDecryptToken(pickleKey, encryptedAccessToken, ACCESS_TOKEN_IV); + } catch (e) { + console.error("SW: Error decrypting access token.", e); + return undefined; + } +} + +// Ideally we'd use the `Client` interface for `client`, but since it's not available (see 'fetch' listener), we use +// unknown for now and force-cast it to something close enough inside the function. +async function askClientForUserIdParams(client: unknown): Promise<{ userId: string; deviceId: string }> { + return new Promise((resolve, reject) => { + // Dev note: this uses postMessage, which is a highly insecure channel. postMessage is typically visible to other + // tabs, windows, browser extensions, etc, making it far from ideal for sharing sensitive information. This is + // why our service worker calculates/decrypts the access token manually: we don't want the user's access token + // to be available to (potentially) malicious listeners. We do require some information for that decryption to + // work though, and request that in the least sensitive way possible. + // + // We could also potentially use some version of TLS to encrypt postMessage, though that feels way more involved + // than just reading IndexedDB ourselves. + + // Avoid stalling the tab in case something goes wrong. + const timeoutId = setTimeout(() => reject(new Error("timeout in postMessage")), 1000); + + // We don't need particularly good randomness here - we just use this to generate a request ID, so we know + // which postMessage reply is for our active request. + const responseKey = Math.random().toString(36); + + // Add the listener first, just in case the tab is *really* fast. + const listener = (event: MessageEvent): void => { + if (event.data?.responseKey !== responseKey) return; // not for us + clearTimeout(timeoutId); // do this as soon as possible, avoiding a race between resolve and reject. + resolve(event.data); // "unblock" the remainder of the thread, if that were such a thing in JavaScript. + self.removeEventListener("message", listener); // cleanup, since we're not going to do anything else. + }; + self.addEventListener("message", listener); + + // Ask the tab for the information we need. This is handled by WebPlatform. + (client as Window).postMessage({ responseKey, type: "userinfo" }); + }); +} + +function fetchConfigForToken(accessToken?: string): RequestInit | undefined { + if (!accessToken) { + return undefined; // no headers/config to specify + } + + return { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }; +} diff --git a/src/vector/platform/WebPlatform.ts b/src/vector/platform/WebPlatform.ts index 39446158fd..da6f61b323 100644 --- a/src/vector/platform/WebPlatform.ts +++ b/src/vector/platform/WebPlatform.ts @@ -1,7 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2016 OpenMarket Ltd -Copyright 2017-2020 New Vector Ltd +Copyright 2017-2020, 2024 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -44,9 +44,41 @@ export default class WebPlatform extends VectorBasePlatform { public constructor() { super(); - // Register service worker if available on this platform - if ("serviceWorker" in navigator) { - navigator.serviceWorker.register("sw.js"); + + // Register the service worker in the background + this.tryRegisterServiceWorker().catch((e) => console.error("Error registering/updating service worker:", e)); + } + + private async tryRegisterServiceWorker(): Promise<void> { + if (!("serviceWorker" in navigator)) { + return; // not available on this platform - don't try to register the service worker + } + + // sw.js is exported by webpack, sourced from `/src/serviceworker/index.ts` + const registration = await navigator.serviceWorker.register("sw.js"); + if (!registration) { + // Registration didn't work for some reason - assume failed and ignore. + // This typically happens in Jest. + return; + } + + await registration.update(); + navigator.serviceWorker.addEventListener("message", this.onServiceWorkerPostMessage.bind(this)); + } + + private onServiceWorkerPostMessage(event: MessageEvent): void { + try { + if (event.data?.["type"] === "userinfo" && event.data?.["responseKey"]) { + const userId = localStorage.getItem("mx_user_id"); + const deviceId = localStorage.getItem("mx_device_id"); + event.source!.postMessage({ + responseKey: event.data["responseKey"], + userId, + deviceId, + }); + } + } catch (e) { + console.error("Error responding to service worker: ", e); } } diff --git a/webpack.config.js b/webpack.config.js index 03506242ad..1b8f385f94 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -153,6 +153,10 @@ module.exports = (env, argv) => { mobileguide: "./src/vector/mobile_guide/index.ts", jitsi: "./src/vector/jitsi/index.ts", usercontent: "./node_modules/matrix-react-sdk/src/usercontent/index.ts", + serviceworker: { + import: "./src/serviceworker/index.ts", + filename: "sw.js", // update WebPlatform if this changes + }, ...(useHMR ? {} : cssThemes), }, @@ -666,7 +670,7 @@ module.exports = (env, argv) => { // HtmlWebpackPlugin will screw up our formatting like the names // of the themes and which chunks we actually care about. inject: false, - excludeChunks: ["mobileguide", "usercontent", "jitsi"], + excludeChunks: ["mobileguide", "usercontent", "jitsi", "serviceworker"], minify: false, templateParameters: { og_image_url: ogImageUrl, @@ -740,7 +744,6 @@ module.exports = (env, argv) => { "res/jitsi_external_api.min.js", "res/jitsi_external_api.min.js.LICENSE.txt", "res/manifest.json", - "res/sw.js", "res/welcome.html", { from: "welcome/**", context: path.resolve(__dirname, "res") }, { from: "themes/**", context: path.resolve(__dirname, "res") },