Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit d25d529

Browse files
turt2liverichvdh
andauthored
Extract functions for service worker usage, and add initial MSC3916 playwright test (when supported) (#12414)
* Send user credentials to service worker for MSC3916 authentication * appease linter * Add initial test The test fails, seemingly because the service worker isn't being installed or because the network mock can't reach that far. * Remove unsafe access token code * Split out base IDB operations to avoid importing `document` in serviceworkers * Use safe crypto access for service workers * Fix tests/unsafe access * Remove backwards compatibility layer & appease linter * Add docs * Fix tests * Appease the linter * Iterate tests * Factor out pickle key handling for service workers * Enable everything we can about service workers * Appease the linter * Add docs * Rename win32 image to linux in hopes of it just working * Use actual image * Apply suggestions from code review Co-authored-by: Richard van der Hoff <[email protected]> * Improve documentation * Document `??` not working * Try to appease the tests * Add some notes --------- Co-authored-by: Richard van der Hoff <[email protected]>
1 parent 374cee9 commit d25d529

File tree

12 files changed

+435
-176
lines changed

12 files changed

+435
-176
lines changed

playwright/e2e/timeline/timeline.spec.ts

+102
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ const sendEvent = async (client: Client, roomId: string, html = false): Promise<
7070
return client.sendEvent(roomId, null, "m.room.message" as EventType, content);
7171
};
7272

73+
const sendImage = async (
74+
client: Client,
75+
roomId: string,
76+
pngBytes: Buffer,
77+
additionalContent?: any,
78+
): Promise<ISendEventResponse> => {
79+
const upload = await client.uploadContent(pngBytes, { name: "image.png", type: "image/png" });
80+
return client.sendEvent(roomId, null, "m.room.message" as EventType, {
81+
...(additionalContent ?? {}),
82+
83+
msgtype: "m.image" as MsgType,
84+
body: "image.png",
85+
url: upload.content_uri,
86+
});
87+
};
88+
7389
test.describe("Timeline", () => {
7490
test.use({
7591
displayName: OLD_NAME,
@@ -1136,5 +1152,91 @@ test.describe("Timeline", () => {
11361152
screenshotOptions,
11371153
);
11381154
});
1155+
1156+
async function testImageRendering(page: Page, app: ElementAppPage, room: { roomId: string }) {
1157+
await app.viewRoomById(room.roomId);
1158+
1159+
// Reinstall the service workers to clear their implicit caches (global-level stuff)
1160+
await page.evaluate(async () => {
1161+
const registrations = await window.navigator.serviceWorker.getRegistrations();
1162+
registrations.forEach((r) => r.update());
1163+
});
1164+
1165+
await sendImage(app.client, room.roomId, NEW_AVATAR);
1166+
await expect(page.locator(".mx_MImageBody").first()).toBeVisible();
1167+
1168+
// Exclude timestamp and read marker from snapshot
1169+
const screenshotOptions = {
1170+
mask: [page.locator(".mx_MessageTimestamp")],
1171+
css: `
1172+
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
1173+
display: none !important;
1174+
}
1175+
`,
1176+
};
1177+
1178+
await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot(
1179+
"image-in-timeline-default-layout.png",
1180+
screenshotOptions,
1181+
);
1182+
}
1183+
1184+
test("should render images in the timeline", async ({ page, app, room, context }) => {
1185+
await testImageRendering(page, app, room);
1186+
});
1187+
1188+
// XXX: This test doesn't actually work because the service worker relies on IndexedDB, which Playwright forces
1189+
// to be a localstorage implementation, which service workers cannot access.
1190+
// See https://github.com/microsoft/playwright/issues/11164
1191+
// See https://github.com/microsoft/playwright/issues/15684#issuecomment-2070862042
1192+
//
1193+
// In practice, this means this test will *always* succeed because it ends up relying on fallback behaviour tested
1194+
// above (unless of course the above tests are also broken).
1195+
test.describe("MSC3916 - Authenticated Media", () => {
1196+
test("should render authenticated images in the timeline", async ({ page, app, room, context }) => {
1197+
// Note: we have to use `context` instead of `page` for routing, otherwise we'll miss Service Worker events.
1198+
// See https://playwright.dev/docs/service-workers-experimental#network-events-and-routing
1199+
1200+
// Install our mocks and preventative measures
1201+
await context.route("**/_matrix/client/versions", async (route) => {
1202+
// Force enable MSC3916, which may require the service worker's internal cache to be cleared later.
1203+
const json = await (await route.fetch()).json();
1204+
if (!json["unstable_features"]) json["unstable_features"] = {};
1205+
json["unstable_features"]["org.matrix.msc3916"] = true;
1206+
await route.fulfill({ json });
1207+
});
1208+
await context.route("**/_matrix/media/*/download/**", async (route) => {
1209+
// should not be called. We don't use `abort` so that it's clearer in the logs what happened.
1210+
await route.fulfill({
1211+
status: 500,
1212+
json: { errcode: "M_UNKNOWN", error: "Unexpected route called." },
1213+
});
1214+
});
1215+
await context.route("**/_matrix/media/*/thumbnail/**", async (route) => {
1216+
// should not be called. We don't use `abort` so that it's clearer in the logs what happened.
1217+
await route.fulfill({
1218+
status: 500,
1219+
json: { errcode: "M_UNKNOWN", error: "Unexpected route called." },
1220+
});
1221+
});
1222+
await context.route("**/_matrix/client/unstable/org.matrix.msc3916/download/**", async (route) => {
1223+
expect(route.request().headers()["Authorization"]).toBeDefined();
1224+
// we can't use route.continue() because no configured homeserver supports MSC3916 yet
1225+
await route.fulfill({
1226+
body: NEW_AVATAR,
1227+
});
1228+
});
1229+
await context.route("**/_matrix/client/unstable/org.matrix.msc3916/thumbnail/**", async (route) => {
1230+
expect(route.request().headers()["Authorization"]).toBeDefined();
1231+
// we can't use route.continue() because no configured homeserver supports MSC3916 yet
1232+
await route.fulfill({
1233+
body: NEW_AVATAR,
1234+
});
1235+
});
1236+
1237+
// We check the same screenshot because there should be no user-visible impact to using authentication.
1238+
await testImageRendering(page, app, room);
1239+
});
1240+
});
11391241
});
11401242
});

playwright/element-web-test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import { Bot, CreateBotOpts } from "./pages/bot";
3333
import { ProxyInstance, SlidingSyncProxy } from "./plugins/sliding-sync-proxy";
3434
import { Webserver } from "./plugins/webserver";
3535

36+
// Enable experimental service worker support
37+
// See https://playwright.dev/docs/service-workers-experimental#how-to-enable
38+
process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1";
39+
3640
const CONFIG_JSON: Partial<IConfigOptions> = {
3741
// This is deliberately quite a minimal config.json, so that we can test that the default settings
3842
// actually work.
Loading

src/BasePlatform.ts

+7-40
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload";
3434
import { Action } from "./dispatcher/actions";
3535
import { hideToast as hideUpdateToast } from "./toasts/UpdateToast";
3636
import { MatrixClientPeg } from "./MatrixClientPeg";
37-
import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
37+
import { idbLoad, idbSave, idbDelete } from "./utils/StorageAccess";
3838
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
3939
import { IConfigOptions } from "./IConfigOptions";
4040
import SdkConfig from "./SdkConfig";
41+
import { buildAndEncodePickleKey, getPickleAdditionalData } from "./utils/tokens/pickling";
4142

4243
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
4344
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
@@ -352,55 +353,21 @@ export default abstract class BasePlatform {
352353

353354
/**
354355
* Get a previously stored pickle key. The pickle key is used for
355-
* encrypting libolm objects.
356+
* encrypting libolm objects and react-sdk-crypto data.
356357
* @param {string} userId the user ID for the user that the pickle key is for.
357-
* @param {string} userId the device ID that the pickle key is for.
358+
* @param {string} deviceId the device ID that the pickle key is for.
358359
* @returns {string|null} the previously stored pickle key, or null if no
359360
* pickle key has been stored.
360361
*/
361362
public async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
362-
if (!window.crypto || !window.crypto.subtle) {
363-
return null;
364-
}
365-
let data;
363+
let data: { encrypted?: BufferSource; iv?: BufferSource; cryptoKey?: CryptoKey } | undefined;
366364
try {
367365
data = await idbLoad("pickleKey", [userId, deviceId]);
368366
} catch (e) {
369367
logger.error("idbLoad for pickleKey failed", e);
370368
}
371-
if (!data) {
372-
return null;
373-
}
374-
if (!data.encrypted || !data.iv || !data.cryptoKey) {
375-
logger.error("Badly formatted pickle key");
376-
return null;
377-
}
378-
379-
const additionalData = this.getPickleAdditionalData(userId, deviceId);
380369

381-
try {
382-
const key = await crypto.subtle.decrypt(
383-
{ name: "AES-GCM", iv: data.iv, additionalData },
384-
data.cryptoKey,
385-
data.encrypted,
386-
);
387-
return encodeUnpaddedBase64(key);
388-
} catch (e) {
389-
logger.error("Error decrypting pickle key");
390-
return null;
391-
}
392-
}
393-
394-
private getPickleAdditionalData(userId: string, deviceId: string): Uint8Array {
395-
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
396-
for (let i = 0; i < userId.length; i++) {
397-
additionalData[i] = userId.charCodeAt(i);
398-
}
399-
additionalData[userId.length] = 124; // "|"
400-
for (let i = 0; i < deviceId.length; i++) {
401-
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
402-
}
403-
return additionalData;
370+
return (await buildAndEncodePickleKey(data, userId, deviceId)) ?? null;
404371
}
405372

406373
/**
@@ -424,7 +391,7 @@ export default abstract class BasePlatform {
424391
const iv = new Uint8Array(32);
425392
crypto.getRandomValues(iv);
426393

427-
const additionalData = this.getPickleAdditionalData(userId, deviceId);
394+
const additionalData = getPickleAdditionalData(userId, deviceId);
428395
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray);
429396

430397
try {

src/Lifecycle.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import ActiveWidgetStore from "./stores/ActiveWidgetStore";
3737
import PlatformPeg from "./PlatformPeg";
3838
import { sendLoginRequest } from "./Login";
3939
import * as StorageManager from "./utils/StorageManager";
40+
import * as StorageAccess from "./utils/StorageAccess";
4041
import SettingsStore from "./settings/SettingsStore";
4142
import { SettingLevel } from "./settings/SettingLevel";
4243
import ToastStore from "./stores/ToastStore";
@@ -493,7 +494,7 @@ export interface IStoredSession {
493494
async function getStoredToken(storageKey: string): Promise<string | undefined> {
494495
let token: string | undefined;
495496
try {
496-
token = await StorageManager.idbLoad("account", storageKey);
497+
token = await StorageAccess.idbLoad("account", storageKey);
497498
} catch (e) {
498499
logger.error(`StorageManager.idbLoad failed for account:${storageKey}`, e);
499500
}
@@ -502,7 +503,7 @@ async function getStoredToken(storageKey: string): Promise<string | undefined> {
502503
if (token) {
503504
try {
504505
// try to migrate access token to IndexedDB if we can
505-
await StorageManager.idbSave("account", storageKey, token);
506+
await StorageAccess.idbSave("account", storageKey, token);
506507
localStorage.removeItem(storageKey);
507508
} catch (e) {
508509
logger.error(`migration of token ${storageKey} to IndexedDB failed`, e);
@@ -1064,7 +1065,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
10641065
AbstractLocalStorageSettingsHandler.clear();
10651066

10661067
try {
1067-
await StorageManager.idbDelete("account", ACCESS_TOKEN_STORAGE_KEY);
1068+
await StorageAccess.idbDelete("account", ACCESS_TOKEN_STORAGE_KEY);
10681069
} catch (e) {
10691070
logger.error("idbDelete failed for account:mx_access_token", e);
10701071
}

src/utils/StorageAccess.ts

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
Copyright 2019-2021, 2024 The Matrix.org Foundation C.I.C.
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+
/**
18+
* Retrieves the IndexedDB factory object.
19+
*
20+
* @returns {IDBFactory | undefined} The IndexedDB factory object if available, or undefined if it is not supported.
21+
*/
22+
export function getIDBFactory(): IDBFactory | undefined {
23+
// IndexedDB loading is lazy for easier testing.
24+
25+
// just *accessing* _indexedDB throws an exception in firefox with
26+
// indexeddb disabled.
27+
try {
28+
// `self` is preferred for service workers, which access this file's functions.
29+
// We check `self` first because `window` returns something which doesn't work for service workers.
30+
// Note: `self?.indexedDB ?? window.indexedDB` breaks in service workers for unknown reasons.
31+
return self?.indexedDB ? self.indexedDB : window.indexedDB;
32+
} catch (e) {}
33+
}
34+
35+
let idb: IDBDatabase | null = null;
36+
37+
async function idbInit(): Promise<void> {
38+
if (!getIDBFactory()) {
39+
throw new Error("IndexedDB not available");
40+
}
41+
idb = await new Promise((resolve, reject) => {
42+
const request = getIDBFactory()!.open("matrix-react-sdk", 1);
43+
request.onerror = reject;
44+
request.onsuccess = (): void => {
45+
resolve(request.result);
46+
};
47+
request.onupgradeneeded = (): void => {
48+
const db = request.result;
49+
db.createObjectStore("pickleKey");
50+
db.createObjectStore("account");
51+
};
52+
});
53+
}
54+
55+
/**
56+
* Loads an item from an IndexedDB table within the underlying `matrix-react-sdk` database.
57+
*
58+
* If IndexedDB access is not supported in the environment, an error is thrown.
59+
*
60+
* @param {string} table The name of the object store in IndexedDB.
61+
* @param {string | string[]} key The key where the data is stored.
62+
* @returns {Promise<any>} A promise that resolves with the retrieved item from the table.
63+
*/
64+
export async function idbLoad(table: string, key: string | string[]): Promise<any> {
65+
if (!idb) {
66+
await idbInit();
67+
}
68+
return new Promise((resolve, reject) => {
69+
const txn = idb!.transaction([table], "readonly");
70+
txn.onerror = reject;
71+
72+
const objectStore = txn.objectStore(table);
73+
const request = objectStore.get(key);
74+
request.onerror = reject;
75+
request.onsuccess = (event): void => {
76+
resolve(request.result);
77+
};
78+
});
79+
}
80+
81+
/**
82+
* Saves data to an IndexedDB table within the underlying `matrix-react-sdk` database.
83+
*
84+
* If IndexedDB access is not supported in the environment, an error is thrown.
85+
*
86+
* @param {string} table The name of the object store in the IndexedDB.
87+
* @param {string|string[]} key The key to use for storing the data.
88+
* @param {*} data The data to be saved.
89+
* @returns {Promise<void>} A promise that resolves when the data is saved successfully.
90+
*/
91+
export async function idbSave(table: string, key: string | string[], data: any): Promise<void> {
92+
if (!idb) {
93+
await idbInit();
94+
}
95+
return new Promise((resolve, reject) => {
96+
const txn = idb!.transaction([table], "readwrite");
97+
txn.onerror = reject;
98+
99+
const objectStore = txn.objectStore(table);
100+
const request = objectStore.put(data, key);
101+
request.onerror = reject;
102+
request.onsuccess = (event): void => {
103+
resolve();
104+
};
105+
});
106+
}
107+
108+
/**
109+
* Deletes a record from an IndexedDB table within the underlying `matrix-react-sdk` database.
110+
*
111+
* If IndexedDB access is not supported in the environment, an error is thrown.
112+
*
113+
* @param {string} table The name of the object store where the record is stored.
114+
* @param {string|string[]} key The key of the record to be deleted.
115+
* @returns {Promise<void>} A Promise that resolves when the record(s) have been successfully deleted.
116+
*/
117+
export async function idbDelete(table: string, key: string | string[]): Promise<void> {
118+
if (!idb) {
119+
await idbInit();
120+
}
121+
return new Promise((resolve, reject) => {
122+
const txn = idb!.transaction([table], "readwrite");
123+
txn.onerror = reject;
124+
125+
const objectStore = txn.objectStore(table);
126+
const request = objectStore.delete(key);
127+
request.onerror = reject;
128+
request.onsuccess = (): void => {
129+
resolve();
130+
};
131+
});
132+
}

0 commit comments

Comments
 (0)