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 <1389908+richvdh@users.noreply.github.com> * Improve documentation * Document `??` not working * Try to appease the tests * Add some notes --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
parent
374cee9080
commit
d25d529e86
12 changed files with 435 additions and 176 deletions
132
src/utils/StorageAccess.ts
Normal file
132
src/utils/StorageAccess.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
Copyright 2019-2021, 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieves the IndexedDB factory object.
|
||||
*
|
||||
* @returns {IDBFactory | undefined} The IndexedDB factory object if available, or undefined if it is not supported.
|
||||
*/
|
||||
export function getIDBFactory(): IDBFactory | undefined {
|
||||
// IndexedDB loading is lazy for easier testing.
|
||||
|
||||
// just *accessing* _indexedDB throws an exception in firefox with
|
||||
// indexeddb disabled.
|
||||
try {
|
||||
// `self` is preferred for service workers, which access this file's functions.
|
||||
// We check `self` first because `window` returns something which doesn't work for service workers.
|
||||
// Note: `self?.indexedDB ?? window.indexedDB` breaks in service workers for unknown reasons.
|
||||
return self?.indexedDB ? self.indexedDB : window.indexedDB;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
let idb: IDBDatabase | null = null;
|
||||
|
||||
async function idbInit(): Promise<void> {
|
||||
if (!getIDBFactory()) {
|
||||
throw new Error("IndexedDB not available");
|
||||
}
|
||||
idb = await new Promise((resolve, reject) => {
|
||||
const request = getIDBFactory()!.open("matrix-react-sdk", 1);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (): void => {
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onupgradeneeded = (): void => {
|
||||
const db = request.result;
|
||||
db.createObjectStore("pickleKey");
|
||||
db.createObjectStore("account");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an item from an IndexedDB table within the underlying `matrix-react-sdk` database.
|
||||
*
|
||||
* If IndexedDB access is not supported in the environment, an error is thrown.
|
||||
*
|
||||
* @param {string} table The name of the object store in IndexedDB.
|
||||
* @param {string | string[]} key The key where the data is stored.
|
||||
* @returns {Promise<any>} A promise that resolves with the retrieved item from the table.
|
||||
*/
|
||||
export async function idbLoad(table: string, key: string | string[]): Promise<any> {
|
||||
if (!idb) {
|
||||
await idbInit();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const txn = idb!.transaction([table], "readonly");
|
||||
txn.onerror = reject;
|
||||
|
||||
const objectStore = txn.objectStore(table);
|
||||
const request = objectStore.get(key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (event): void => {
|
||||
resolve(request.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves data to an IndexedDB table within the underlying `matrix-react-sdk` database.
|
||||
*
|
||||
* If IndexedDB access is not supported in the environment, an error is thrown.
|
||||
*
|
||||
* @param {string} table The name of the object store in the IndexedDB.
|
||||
* @param {string|string[]} key The key to use for storing the data.
|
||||
* @param {*} data The data to be saved.
|
||||
* @returns {Promise<void>} A promise that resolves when the data is saved successfully.
|
||||
*/
|
||||
export async function idbSave(table: string, key: string | string[], data: any): Promise<void> {
|
||||
if (!idb) {
|
||||
await idbInit();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const txn = idb!.transaction([table], "readwrite");
|
||||
txn.onerror = reject;
|
||||
|
||||
const objectStore = txn.objectStore(table);
|
||||
const request = objectStore.put(data, key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (event): void => {
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record from an IndexedDB table within the underlying `matrix-react-sdk` database.
|
||||
*
|
||||
* If IndexedDB access is not supported in the environment, an error is thrown.
|
||||
*
|
||||
* @param {string} table The name of the object store where the record is stored.
|
||||
* @param {string|string[]} key The key of the record to be deleted.
|
||||
* @returns {Promise<void>} A Promise that resolves when the record(s) have been successfully deleted.
|
||||
*/
|
||||
export async function idbDelete(table: string, key: string | string[]): Promise<void> {
|
||||
if (!idb) {
|
||||
await idbInit();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const txn = idb!.transaction([table], "readwrite");
|
||||
txn.onerror = reject;
|
||||
|
||||
const objectStore = txn.objectStore(table);
|
||||
const request = objectStore.delete(key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (): void => {
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
|
@ -19,18 +19,10 @@ import { logger } from "matrix-js-sdk/src/logger";
|
|||
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { Features } from "../settings/Settings";
|
||||
import { getIDBFactory } from "./StorageAccess";
|
||||
|
||||
const localStorage = window.localStorage;
|
||||
|
||||
// make this lazy in order to make testing easier
|
||||
function getIndexedDb(): IDBFactory | undefined {
|
||||
// just *accessing* _indexedDB throws an exception in firefox with
|
||||
// indexeddb disabled.
|
||||
try {
|
||||
return window.indexedDB;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name.
|
||||
const SYNC_STORE_NAME = "riot-web-sync";
|
||||
const LEGACY_CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
|
||||
|
@ -68,7 +60,7 @@ export async function checkConsistency(): Promise<{
|
|||
}> {
|
||||
log("Checking storage consistency");
|
||||
log(`Local storage supported? ${!!localStorage}`);
|
||||
log(`IndexedDB supported? ${!!getIndexedDb()}`);
|
||||
log(`IndexedDB supported? ${!!getIDBFactory()}`);
|
||||
|
||||
let dataInLocalStorage = false;
|
||||
let dataInCryptoStore = false;
|
||||
|
@ -86,7 +78,7 @@ export async function checkConsistency(): Promise<{
|
|||
error("Local storage cannot be used on this browser");
|
||||
}
|
||||
|
||||
if (getIndexedDb() && localStorage) {
|
||||
if (getIDBFactory() && localStorage) {
|
||||
const results = await checkSyncStore();
|
||||
if (!results.healthy) {
|
||||
healthy = false;
|
||||
|
@ -96,7 +88,7 @@ export async function checkConsistency(): Promise<{
|
|||
error("Sync store cannot be used on this browser");
|
||||
}
|
||||
|
||||
if (getIndexedDb()) {
|
||||
if (getIDBFactory()) {
|
||||
const results = await checkCryptoStore();
|
||||
dataInCryptoStore = results.exists;
|
||||
if (!results.healthy) {
|
||||
|
@ -138,7 +130,7 @@ interface StoreCheck {
|
|||
async function checkSyncStore(): Promise<StoreCheck> {
|
||||
let exists = false;
|
||||
try {
|
||||
exists = await IndexedDBStore.exists(getIndexedDb()!, SYNC_STORE_NAME);
|
||||
exists = await IndexedDBStore.exists(getIDBFactory()!, SYNC_STORE_NAME);
|
||||
log(`Sync store using IndexedDB contains data? ${exists}`);
|
||||
return { exists, healthy: true };
|
||||
} catch (e) {
|
||||
|
@ -152,7 +144,7 @@ async function checkCryptoStore(): Promise<StoreCheck> {
|
|||
if (await SettingsStore.getValue(Features.RustCrypto)) {
|
||||
// check first if there is a rust crypto store
|
||||
try {
|
||||
const rustDbExists = await IndexedDBCryptoStore.exists(getIndexedDb()!, RUST_CRYPTO_STORE_NAME);
|
||||
const rustDbExists = await IndexedDBCryptoStore.exists(getIDBFactory()!, RUST_CRYPTO_STORE_NAME);
|
||||
log(`Rust Crypto store using IndexedDB contains data? ${rustDbExists}`);
|
||||
|
||||
if (rustDbExists) {
|
||||
|
@ -162,7 +154,7 @@ async function checkCryptoStore(): Promise<StoreCheck> {
|
|||
// No rust store, so let's check if there is a legacy store not yet migrated.
|
||||
try {
|
||||
const legacyIdbExists = await IndexedDBCryptoStore.existsAndIsNotMigrated(
|
||||
getIndexedDb()!,
|
||||
getIDBFactory()!,
|
||||
LEGACY_CRYPTO_STORE_NAME,
|
||||
);
|
||||
log(`Legacy Crypto store using IndexedDB contains non migrated data? ${legacyIdbExists}`);
|
||||
|
@ -183,7 +175,7 @@ async function checkCryptoStore(): Promise<StoreCheck> {
|
|||
let exists = false;
|
||||
// legacy checks
|
||||
try {
|
||||
exists = await IndexedDBCryptoStore.exists(getIndexedDb()!, LEGACY_CRYPTO_STORE_NAME);
|
||||
exists = await IndexedDBCryptoStore.exists(getIDBFactory()!, LEGACY_CRYPTO_STORE_NAME);
|
||||
log(`Crypto store using IndexedDB contains data? ${exists}`);
|
||||
return { exists, healthy: true };
|
||||
} catch (e) {
|
||||
|
@ -214,77 +206,3 @@ async function checkCryptoStore(): Promise<StoreCheck> {
|
|||
export function setCryptoInitialised(cryptoInited: boolean): void {
|
||||
localStorage.setItem("mx_crypto_initialised", String(cryptoInited));
|
||||
}
|
||||
|
||||
/* Simple wrapper functions around IndexedDB.
|
||||
*/
|
||||
|
||||
let idb: IDBDatabase | null = null;
|
||||
|
||||
async function idbInit(): Promise<void> {
|
||||
if (!getIndexedDb()) {
|
||||
throw new Error("IndexedDB not available");
|
||||
}
|
||||
idb = await new Promise((resolve, reject) => {
|
||||
const request = getIndexedDb()!.open("matrix-react-sdk", 1);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (): void => {
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onupgradeneeded = (): void => {
|
||||
const db = request.result;
|
||||
db.createObjectStore("pickleKey");
|
||||
db.createObjectStore("account");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function idbLoad(table: string, key: string | string[]): Promise<any> {
|
||||
if (!idb) {
|
||||
await idbInit();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const txn = idb!.transaction([table], "readonly");
|
||||
txn.onerror = reject;
|
||||
|
||||
const objectStore = txn.objectStore(table);
|
||||
const request = objectStore.get(key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (event): void => {
|
||||
resolve(request.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function idbSave(table: string, key: string | string[], data: any): Promise<void> {
|
||||
if (!idb) {
|
||||
await idbInit();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const txn = idb!.transaction([table], "readwrite");
|
||||
txn.onerror = reject;
|
||||
|
||||
const objectStore = txn.objectStore(table);
|
||||
const request = objectStore.put(data, key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (event): void => {
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function idbDelete(table: string, key: string | string[]): Promise<void> {
|
||||
if (!idb) {
|
||||
await idbInit();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const txn = idb!.transaction([table], "readwrite");
|
||||
txn.onerror = reject;
|
||||
|
||||
const objectStore = txn.objectStore(table);
|
||||
const request = objectStore.delete(key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = (): void => {
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
88
src/utils/tokens/pickling.ts
Normal file
88
src/utils/tokens/pickling.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2020, 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 { encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
/**
|
||||
* Calculates the `additionalData` for the AES-GCM key used by the pickling processes. This
|
||||
* additional data is *not* encrypted, but *is* authenticated. The additional data is constructed
|
||||
* from the user ID and device ID provided.
|
||||
*
|
||||
* The later-constructed pickle key is used to decrypt values, such as access tokens, from IndexedDB.
|
||||
*
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams for more information on
|
||||
* `additionalData`.
|
||||
*
|
||||
* @param {string} userId The user ID who owns the pickle key.
|
||||
* @param {string} deviceId The device ID which owns the pickle key.
|
||||
* @return {Uint8Array} The additional data as a Uint8Array.
|
||||
*/
|
||||
export function getPickleAdditionalData(userId: string, deviceId: string): Uint8Array {
|
||||
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
additionalData[i] = userId.charCodeAt(i);
|
||||
}
|
||||
additionalData[userId.length] = 124; // "|"
|
||||
for (let i = 0; i < deviceId.length; i++) {
|
||||
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
|
||||
}
|
||||
return additionalData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the provided data into a pickle key and base64-encodes it ready for use elsewhere.
|
||||
*
|
||||
* If `data` is undefined in part or in full, returns undefined.
|
||||
*
|
||||
* If crypto functions are not available, returns undefined regardless of input.
|
||||
*
|
||||
* @param data An object containing the encrypted pickle key data: encrypted payload, initialization vector (IV), and crypto key. Typically loaded from indexedDB.
|
||||
* @param userId The user ID the pickle key belongs to.
|
||||
* @param deviceId The device ID the pickle key belongs to.
|
||||
* @returns A promise that resolves to the encoded pickle key, or undefined if the key cannot be built and encoded.
|
||||
*/
|
||||
export async function buildAndEncodePickleKey(
|
||||
data: { encrypted?: BufferSource; iv?: BufferSource; cryptoKey?: CryptoKey } | undefined,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!crypto?.subtle) {
|
||||
return undefined;
|
||||
}
|
||||
if (!data || !data.encrypted || !data.iv || !data.cryptoKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const additionalData = getPickleAdditionalData(userId, deviceId);
|
||||
const pickleKeyBuf = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: data.iv, additionalData },
|
||||
data.cryptoKey,
|
||||
data.encrypted,
|
||||
);
|
||||
if (pickleKeyBuf) {
|
||||
return encodeUnpaddedBase64(pickleKeyBuf);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Error decrypting pickle key");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import * as StorageManager from "../StorageManager";
|
||||
import * as StorageAccess from "../StorageAccess";
|
||||
|
||||
/**
|
||||
* Utility functions related to the storage and retrieval of access tokens
|
||||
|
@ -50,10 +50,10 @@ async function pickleKeyToAesKey(pickleKey: string): Promise<Uint8Array> {
|
|||
for (let i = 0; i < pickleKey.length; i++) {
|
||||
pickleKeyBuffer[i] = pickleKey.charCodeAt(i);
|
||||
}
|
||||
const hkdfKey = await window.crypto.subtle.importKey("raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"]);
|
||||
const hkdfKey = await crypto.subtle.importKey("raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"]);
|
||||
pickleKeyBuffer.fill(0);
|
||||
return new Uint8Array(
|
||||
await window.crypto.subtle.deriveBits(
|
||||
await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
|
@ -142,7 +142,7 @@ export async function persistTokenInStorage(
|
|||
// Save either the encrypted access token, or the plain access
|
||||
// token if there is no token or we were unable to encrypt (e.g. if the browser doesn't
|
||||
// have WebCrypto).
|
||||
await StorageManager.idbSave("account", storageKey, encryptedToken || token);
|
||||
await StorageAccess.idbSave("account", storageKey, encryptedToken || token);
|
||||
} catch (e) {
|
||||
// if we couldn't save to indexedDB, fall back to localStorage. We
|
||||
// store the access token unencrypted since localStorage only saves
|
||||
|
@ -155,7 +155,7 @@ export async function persistTokenInStorage(
|
|||
}
|
||||
} else {
|
||||
try {
|
||||
await StorageManager.idbSave("account", storageKey, token);
|
||||
await StorageAccess.idbSave("account", storageKey, token);
|
||||
} catch (e) {
|
||||
if (!!token) {
|
||||
localStorage.setItem(storageKey, token);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue