|
| 1 | +/* |
| 2 | +Copyright 2024 New Vector Ltd |
| 3 | +
|
| 4 | +Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +you may not use this file except in compliance with the License. |
| 6 | +You may obtain a copy of the License at |
| 7 | +
|
| 8 | + http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +
|
| 10 | +Unless required by applicable law or agreed to in writing, software |
| 11 | +distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +See the License for the specific language governing permissions and |
| 14 | +limitations under the License. |
| 15 | +*/ |
| 16 | + |
| 17 | +import { idbLoad } from "matrix-react-sdk/src/utils/StorageAccess"; |
| 18 | +import { ACCESS_TOKEN_IV, tryDecryptToken } from "matrix-react-sdk/src/utils/tokens/tokens"; |
| 19 | +import { buildAndEncodePickleKey } from "matrix-react-sdk/src/utils/tokens/pickling"; |
| 20 | + |
| 21 | +const serverSupportMap: { |
| 22 | + [serverUrl: string]: { |
| 23 | + supportsMSC3916: boolean; |
| 24 | + cacheExpiryTimeMs: number; |
| 25 | + }; |
| 26 | +} = {}; |
| 27 | + |
| 28 | +self.addEventListener("install", (event) => { |
| 29 | + // We skipWaiting() to update the service worker more frequently, particularly in development environments. |
| 30 | + // @ts-expect-error - service worker types are not available. See 'fetch' event handler. |
| 31 | + event.waitUntil(skipWaiting()); |
| 32 | +}); |
| 33 | + |
| 34 | +self.addEventListener("activate", (event) => { |
| 35 | + // We force all clients to be under our control, immediately. This could be old tabs. |
| 36 | + // @ts-expect-error - service worker types are not available. See 'fetch' event handler. |
| 37 | + event.waitUntil(clients.claim()); |
| 38 | +}); |
| 39 | + |
| 40 | +// @ts-expect-error - the service worker types conflict with the DOM types available through TypeScript. Many hours |
| 41 | +// have been spent trying to convince the type system that there's no actual conflict, but it has yet to work. Instead |
| 42 | +// of trying to make it do the thing, we force-cast to something close enough where we can (and ignore errors otherwise). |
| 43 | +self.addEventListener("fetch", (event: FetchEvent) => { |
| 44 | + // This is the authenticated media (MSC3916) check, proxying what was unauthenticated to the authenticated variants. |
| 45 | + |
| 46 | + if (event.request.method !== "GET") { |
| 47 | + return; // not important to us |
| 48 | + } |
| 49 | + |
| 50 | + // Note: ideally we'd keep the request headers etc, but in practice we can't even see those details. |
| 51 | + // See https://stackoverflow.com/a/59152482 |
| 52 | + let url = event.request.url; |
| 53 | + |
| 54 | + // We only intercept v3 download and thumbnail requests as presumably everything else is deliberate. |
| 55 | + // For example, `/_matrix/media/unstable` or `/_matrix/media/v3/preview_url` are something well within |
| 56 | + // the control of the application, and appear to be choices made at a higher level than us. |
| 57 | + if (!url.includes("/_matrix/media/v3/download") && !url.includes("/_matrix/media/v3/thumbnail")) { |
| 58 | + return; // not a URL we care about |
| 59 | + } |
| 60 | + |
| 61 | + // We need to call respondWith synchronously, otherwise we may never execute properly. This means |
| 62 | + // later on we need to proxy the request through if it turns out the server doesn't support authentication. |
| 63 | + event.respondWith( |
| 64 | + (async (): Promise<Response> => { |
| 65 | + let accessToken: string | undefined; |
| 66 | + try { |
| 67 | + // Figure out which homeserver we're communicating with |
| 68 | + const csApi = url.substring(0, url.indexOf("/_matrix/media/v3")); |
| 69 | + |
| 70 | + // Add jitter to reduce request spam, particularly to `/versions` on initial page load |
| 71 | + await new Promise<void>((resolve) => setTimeout(() => resolve(), Math.random() * 10)); |
| 72 | + |
| 73 | + // Locate our access token, and populate the fetchConfig with the authentication header. |
| 74 | + // @ts-expect-error - service worker types are not available. See 'fetch' event handler. |
| 75 | + const client = await self.clients.get(event.clientId); |
| 76 | + accessToken = await getAccessToken(client); |
| 77 | + |
| 78 | + // Update or populate the server support map using a (usually) authenticated `/versions` call. |
| 79 | + await tryUpdateServerSupportMap(csApi, accessToken); |
| 80 | + |
| 81 | + // If we have server support (and a means of authentication), rewrite the URL to use MSC3916 endpoints. |
| 82 | + if (serverSupportMap[csApi].supportsMSC3916 && accessToken) { |
| 83 | + // Currently unstable only. |
| 84 | + // TODO: Support stable endpoints when available. |
| 85 | + url = url.replace(/\/media\/v3\/(.*)\//, "/client/unstable/org.matrix.msc3916/media/$1/"); |
| 86 | + } // else by default we make no changes |
| 87 | + } catch (err) { |
| 88 | + console.error("SW: Error in request rewrite.", err); |
| 89 | + } |
| 90 | + |
| 91 | + // Add authentication and send the request. We add authentication even if MSC3916 endpoints aren't |
| 92 | + // being used to ensure patches like this work: |
| 93 | + // https://github.com/matrix-org/synapse/commit/2390b66bf0ec3ff5ffb0c7333f3c9b239eeb92bb |
| 94 | + return fetch(url, fetchConfigForToken(accessToken)); |
| 95 | + })(), |
| 96 | + ); |
| 97 | +}); |
| 98 | + |
| 99 | +async function tryUpdateServerSupportMap(clientApiUrl: string, accessToken?: string): Promise<void> { |
| 100 | + // only update if we don't know about it, or if the data is stale |
| 101 | + if (serverSupportMap[clientApiUrl]?.cacheExpiryTimeMs > new Date().getTime()) { |
| 102 | + return; // up to date |
| 103 | + } |
| 104 | + |
| 105 | + const config = fetchConfigForToken(accessToken); |
| 106 | + const versions = await (await fetch(`${clientApiUrl}/_matrix/client/versions`, config)).json(); |
| 107 | + |
| 108 | + serverSupportMap[clientApiUrl] = { |
| 109 | + supportsMSC3916: Boolean(versions?.unstable_features?.["org.matrix.msc3916"]), |
| 110 | + cacheExpiryTimeMs: new Date().getTime() + 2 * 60 * 60 * 1000, // 2 hours from now |
| 111 | + }; |
| 112 | +} |
| 113 | + |
| 114 | +// Ideally we'd use the `Client` interface for `client`, but since it's not available (see 'fetch' listener), we use |
| 115 | +// unknown for now and force-cast it to something close enough later. |
| 116 | +async function getAccessToken(client: unknown): Promise<string | undefined> { |
| 117 | + // Access tokens are encrypted at rest, so while we can grab the "access token", we'll need to do work to get the |
| 118 | + // real thing. |
| 119 | + const encryptedAccessToken = await idbLoad("account", "mx_access_token"); |
| 120 | + |
| 121 | + // We need to extract a user ID and device ID from localstorage, which means calling WebPlatform for the |
| 122 | + // read operation. Service workers can't access localstorage. |
| 123 | + const { userId, deviceId } = await askClientForUserIdParams(client); |
| 124 | + |
| 125 | + // ... and this is why we need the user ID and device ID: they're index keys for the pickle key table. |
| 126 | + const pickleKeyData = await idbLoad("pickleKey", [userId, deviceId]); |
| 127 | + if (pickleKeyData && (!pickleKeyData.encrypted || !pickleKeyData.iv || !pickleKeyData.cryptoKey)) { |
| 128 | + console.error("SW: Invalid pickle key loaded - ignoring"); |
| 129 | + return undefined; |
| 130 | + } |
| 131 | + |
| 132 | + // Finally, try decrypting the thing and return that. This may fail, but that's okay. |
| 133 | + try { |
| 134 | + const pickleKey = await buildAndEncodePickleKey(pickleKeyData, userId, deviceId); |
| 135 | + return tryDecryptToken(pickleKey, encryptedAccessToken, ACCESS_TOKEN_IV); |
| 136 | + } catch (e) { |
| 137 | + console.error("SW: Error decrypting access token.", e); |
| 138 | + return undefined; |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +// Ideally we'd use the `Client` interface for `client`, but since it's not available (see 'fetch' listener), we use |
| 143 | +// unknown for now and force-cast it to something close enough inside the function. |
| 144 | +async function askClientForUserIdParams(client: unknown): Promise<{ userId: string; deviceId: string }> { |
| 145 | + return new Promise((resolve, reject) => { |
| 146 | + // Dev note: this uses postMessage, which is a highly insecure channel. postMessage is typically visible to other |
| 147 | + // tabs, windows, browser extensions, etc, making it far from ideal for sharing sensitive information. This is |
| 148 | + // why our service worker calculates/decrypts the access token manually: we don't want the user's access token |
| 149 | + // to be available to (potentially) malicious listeners. We do require some information for that decryption to |
| 150 | + // work though, and request that in the least sensitive way possible. |
| 151 | + // |
| 152 | + // We could also potentially use some version of TLS to encrypt postMessage, though that feels way more involved |
| 153 | + // than just reading IndexedDB ourselves. |
| 154 | + |
| 155 | + // Avoid stalling the tab in case something goes wrong. |
| 156 | + const timeoutId = setTimeout(() => reject(new Error("timeout in postMessage")), 1000); |
| 157 | + |
| 158 | + // We don't need particularly good randomness here - we just use this to generate a request ID, so we know |
| 159 | + // which postMessage reply is for our active request. |
| 160 | + const responseKey = Math.random().toString(36); |
| 161 | + |
| 162 | + // Add the listener first, just in case the tab is *really* fast. |
| 163 | + const listener = (event: MessageEvent): void => { |
| 164 | + if (event.data?.responseKey !== responseKey) return; // not for us |
| 165 | + clearTimeout(timeoutId); // do this as soon as possible, avoiding a race between resolve and reject. |
| 166 | + resolve(event.data); // "unblock" the remainder of the thread, if that were such a thing in JavaScript. |
| 167 | + self.removeEventListener("message", listener); // cleanup, since we're not going to do anything else. |
| 168 | + }; |
| 169 | + self.addEventListener("message", listener); |
| 170 | + |
| 171 | + // Ask the tab for the information we need. This is handled by WebPlatform. |
| 172 | + (client as Window).postMessage({ responseKey, type: "userinfo" }); |
| 173 | + }); |
| 174 | +} |
| 175 | + |
| 176 | +function fetchConfigForToken(accessToken?: string): RequestInit | undefined { |
| 177 | + if (!accessToken) { |
| 178 | + return undefined; // no headers/config to specify |
| 179 | + } |
| 180 | + |
| 181 | + return { |
| 182 | + headers: { |
| 183 | + Authorization: `Bearer ${accessToken}`, |
| 184 | + }, |
| 185 | + }; |
| 186 | +} |
0 commit comments