diff --git a/playwright/e2e/crypto/staged-rollout.spec.ts b/playwright/e2e/crypto/staged-rollout.spec.ts new file mode 100644 index 00000000000..acdd20bc899 --- /dev/null +++ b/playwright/e2e/crypto/staged-rollout.spec.ts @@ -0,0 +1,290 @@ +/* +Copyright 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 { test, expect } from "../../element-web-test"; +import { createRoom, enableKeyBackup, logIntoElement, logOutOfElement, sendMessageInCurrentRoom } from "./utils"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +test.describe("Adoption of rust stack", () => { + test("Test migration of existing logins when rollout is 100%", async ({ + page, + context, + app, + credentials, + homeserver, + }, workerInfo) => { + test.skip( + workerInfo.project.name === "Rust Crypto", + "No need to test this on Rust Crypto as we override the config manually", + ); + await page.goto("/#/login"); + test.slow(); + + let featureRustCrypto = false; + let stagedRolloutPercent = 0; + + await context.route(`http://localhost:8080/config.json*`, async (route) => { + const json = { + default_server_config: { + "m.homeserver": { + base_url: "https://server.invalid", + }, + }, + }; + json["features"] = { + feature_rust_crypto: featureRustCrypto, + }; + json["setting_defaults"] = { + "language": "en-GB", + "RustCrypto.staged_rollout_percent": stagedRolloutPercent, + }; + await route.fulfill({ json }); + }); + + // reload to ensure we read the config + await page.reload(); + + await logIntoElement(page, homeserver, credentials); + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Olm")).toBeVisible(); + + featureRustCrypto = true; + + await page.reload(); + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Olm")).toBeVisible(); + + stagedRolloutPercent = 100; + + await page.reload(); + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible(); + }); + + test("Test new logins by default on rust stack", async ({ + page, + context, + app, + credentials, + homeserver, + }, workerInfo) => { + test.skip( + workerInfo.project.name === "Rust Crypto", + "No need to test this on Rust Crypto as we override the config manually", + ); + test.slow(); + await page.goto("/#/login"); + + await context.route(`http://localhost:8080/config.json*`, async (route) => { + const json = { + default_server_config: { + "m.homeserver": { + base_url: "https://server.invalid", + }, + }, + }; + // we only want to test the default + json["features"] = {}; + json["setting_defaults"] = { + language: "en-GB", + }; + await route.fulfill({ json }); + }); + + // reload to get the new config + await page.reload(); + await logIntoElement(page, homeserver, credentials); + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible(); + }); + + test("Test default is to not rollout existing logins", async ({ + page, + context, + app, + credentials, + homeserver, + }, workerInfo) => { + test.skip( + workerInfo.project.name === "Rust Crypto", + "No need to test this on Rust Crypto as we override the config manually", + ); + test.slow(); + + await page.goto("/#/login"); + + // In the project.name = "Legacy crypto" it will be olm crypto + await logIntoElement(page, homeserver, credentials); + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Olm")).toBeVisible(); + + // Now simulate a refresh with `feature_rust_crypto` enabled but ensure we use the default rollout + await context.route(`http://localhost:8080/config.json*`, async (route) => { + const json = {}; + json["features"] = { + feature_rust_crypto: true, + }; + json["setting_defaults"] = { + // We want to test the default so we don't set this + // "RustCrypto.staged_rollout_percent": 0, + }; + await route.fulfill({ json }); + }); + + await page.reload(); + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Olm")).toBeVisible(); + }); + + test("Migrate using labflag should work", async ({ page, context, app, credentials, homeserver }, workerInfo) => { + test.skip( + workerInfo.project.name === "Rust Crypto", + "No need to test this on Rust Crypto as we override the config manually", + ); + test.slow(); + + await page.goto("/#/login"); + + // In the project.name = "Legacy crypto" it will be olm crypto + await logIntoElement(page, homeserver, credentials); + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Olm")).toBeVisible(); + + // We need to enable devtools for this test + await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true); + + // Now simulate a refresh with `feature_rust_crypto` enabled but ensure no automatic migration + await context.route(`http://localhost:8080/config.json*`, async (route) => { + const json = {}; + json["features"] = { + feature_rust_crypto: true, + }; + json["setting_defaults"] = { + "RustCrypto.staged_rollout_percent": 0, + }; + await route.fulfill({ json }); + }); + + await page.reload(); + + // Go to the labs flag and enable the migration + await app.settings.openUserSettings("Labs"); + await page.getByRole("switch", { name: "Rust cryptography implementation" }).click(); + + // Fixes a bug where a missing session data was shown + // https://github.com/element-hq/element-web/issues/26970 + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible(); + }); + + test("Test migration of room shields", async ({ page, context, app, credentials, homeserver }, workerInfo) => { + test.skip( + workerInfo.project.name === "Rust Crypto", + "No need to test this on Rust Crypto as we override the config manually", + ); + test.slow(); + + await page.goto("/#/login"); + + // In the project.name = "Legacy crypto" it will be olm crypto + await logIntoElement(page, homeserver, credentials); + + // create a room and send a message + await createRoom(page, "Room1", true); + await sendMessageInCurrentRoom(page, "Hello"); + + // enable backup to save this room key + const securityKey = await enableKeyBackup(app); + + // wait a bit for upload to complete, there is a random timout on key upload + await page.waitForTimeout(6000); + + // logout + await logOutOfElement(page); + + // We logout and log back in in order to get the historical key from backup and have a gray shield + await page.reload(); + await page.goto("/#/login"); + // login again and verify + await logIntoElement(page, homeserver, credentials, securityKey); + + await app.viewRoomByName("Room1"); + + { + const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "Hello" }); + // there should be a shield + await expect(messageDiv.locator(".mx_EventTile_e2eIcon")).toBeVisible(); + } + + // Now type a new message + await sendMessageInCurrentRoom(page, "World"); + + // wait a bit for the message to be sent + await expect( + page + .locator(".mx_EventTile_line") + .filter({ hasText: "World" }) + .locator("..") + .locator(".mx_EventTile_receiptSent"), + ).toBeVisible(); + { + const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "World" }); + // there should not be a shield + expect(await messageDiv.locator(".mx_EventTile_e2eIcon").count()).toEqual(0); + } + + // trigger a migration + await context.route(`http://localhost:8080/config.json*`, async (route) => { + const json = {}; + json["features"] = { + feature_rust_crypto: true, + }; + json["setting_defaults"] = { + "RustCrypto.staged_rollout_percent": 100, + }; + await route.fulfill({ json }); + }); + + await page.reload(); + + await app.viewRoomByName("Room1"); + + // The shields should be migrated properly + { + const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "Hello" }); + await expect(messageDiv).toBeVisible(); + // there should be a shield + await expect(messageDiv.locator(".mx_EventTile_e2eIcon")).toBeVisible(); + } + { + const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "World" }); + await expect(messageDiv).toBeVisible(); + // there should not be a shield + expect(await messageDiv.locator(".mx_EventTile_e2eIcon").count()).toEqual(0); + } + + await app.settings.openUserSettings("Help & About"); + await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible(); + }); +}); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 646f9eec629..72340cb35fc 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -40,9 +40,10 @@ import Modal from "./Modal"; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from "./utils/StorageManager"; import IdentityAuthClient from "./IdentityAuthClient"; -import { crossSigningCallbacks } from "./SecurityManager"; +import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from "./SecurityManager"; import { ModuleRunner } from "./modules/ModuleRunner"; import { SlidingSyncManager } from "./SlidingSyncManager"; +import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog"; import { _t, UserFriendlyError } from "./languageHandler"; import { SettingLevel } from "./settings/SettingLevel"; import MatrixClientBackedController from "./settings/controllers/MatrixClientBackedController"; @@ -51,6 +52,7 @@ import PlatformPeg from "./PlatformPeg"; import { formatList } from "./utils/FormattingUtils"; import SdkConfig from "./SdkConfig"; import { Features } from "./settings/Settings"; +import { PhasedRolloutFeature } from "./utils/PhasedRolloutFeature"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -324,7 +326,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { /** * Attempt to initialize the crypto layer on a newly-created MatrixClient * - * @param rustCryptoStoreKey - A key with which to encrypt the rust crypto indexeddb. + * @param rustCryptoStoreKey - If we are using Rust crypto, a key with which to encrypt the indexeddb. * If provided, it must be exactly 32 bytes of data. If both this and `rustCryptoStorePassword` are * undefined, the store will be unencrypted. * @@ -337,23 +339,70 @@ class MatrixClientPegClass implements IMatrixClientPeg { throw new Error("createClient must be called first"); } - if (!rustCryptoStoreKey && !rustCryptoStorePassword) { - logger.error("Warning! Not using an encryption key for rust crypto store."); + let useRustCrypto = SettingsStore.getValue(Features.RustCrypto); + + // We want the value that is set in the config.json for that web instance + const defaultUseRustCrypto = SettingsStore.getValueAt(SettingLevel.CONFIG, Features.RustCrypto); + const migrationPercent = SettingsStore.getValueAt(SettingLevel.CONFIG, "RustCrypto.staged_rollout_percent"); + + // If the default config is to use rust crypto, and the user is on legacy crypto, + // we want to check if we should migrate the current user. + if (!useRustCrypto && defaultUseRustCrypto && Number.isInteger(migrationPercent)) { + // The user is not on rust crypto, but the default stack is now rust; Let's check if we should migrate + // the current user to rust crypto. + try { + const stagedRollout = new PhasedRolloutFeature("RustCrypto.staged_rollout_percent", migrationPercent); + // Device id should not be null at that point, or init crypto will fail anyhow + const deviceId = this.matrixClient.getDeviceId()!; + // we use deviceId rather than userId because we don't particularly want all devices + // of a user to be migrated at the same time. + useRustCrypto = stagedRollout.isFeatureEnabled(deviceId); + } catch (e) { + logger.warn("Failed to create staged rollout feature for rust crypto migration", e); + } } - // Record the fact that we used the Rust crypto stack with this client. This just guards against people - // rolling back to versions of EW that did not default to Rust crypto (which would lead to an error, since - // we cannot migrate from Rust to Legacy crypto). - await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, true); + // we want to make sure that the same crypto implementation is used throughout the lifetime of a device, + // so persist the setting at the device layer + // (At some point, we'll allow the user to *enable* the setting via labs, which will migrate their existing + // device to the rust-sdk implementation, but that won't change anything here). + await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, useRustCrypto); - await this.matrixClient.initRustCrypto({ - storageKey: rustCryptoStoreKey, - storagePassword: rustCryptoStorePassword, - }); + // Now we can initialise the right crypto impl. + if (useRustCrypto) { + if (!rustCryptoStoreKey && !rustCryptoStorePassword) { + logger.error("Warning! Not using an encryption key for rust crypto store."); + } + await this.matrixClient.initRustCrypto({ + storageKey: rustCryptoStoreKey, + storagePassword: rustCryptoStorePassword, + }); - StorageManager.setCryptoInitialised(true); - // TODO: device dehydration and whathaveyou - return; + StorageManager.setCryptoInitialised(true); + // TODO: device dehydration and whathaveyou + return; + } + + // fall back to the libolm layer. + try { + // check that we have a version of the js-sdk which includes initCrypto + if (this.matrixClient.initCrypto) { + await this.matrixClient.initCrypto(); + this.matrixClient.setCryptoTrustCrossSignedDevices( + !SettingsStore.getValue("e2ee.manuallyVerifyAllSessions"), + ); + await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient); + StorageManager.setCryptoInitialised(true); + } + } catch (e) { + if (e instanceof Error && e.name === "InvalidCryptoStoreError") { + // The js-sdk found a crypto DB too new for it to use + Modal.createDialog(CryptoStoreTooNewDialog); + } + // this can happen for a number of reasons, the most likely being + // that the olm library was missing. It's not fatal. + logger.warn("Unable to initialise e2e", e); + } } /** diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index c1c4e7a72b3..02edff2199f 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Crypto, ICryptoCallbacks, encodeBase64, SecretStorage } from "matrix-js-sdk/src/matrix"; +import { Crypto, ICryptoCallbacks, MatrixClient, encodeBase64, SecretStorage } from "matrix-js-sdk/src/matrix"; import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase"; import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey"; import { logger } from "matrix-js-sdk/src/logger"; @@ -39,6 +39,8 @@ let secretStorageKeys: Record = {}; let secretStorageKeyInfo: Record = {}; let secretStorageBeingAccessed = false; +let nonInteractive = false; + let dehydrationCache: { key?: Uint8Array; keyInfo?: SecretStorage.SecretStorageKeyDescription; @@ -135,6 +137,10 @@ async function getSecretStorageKey({ } logger.debug("getSecretStorageKey: prompting user for key"); + if (nonInteractive) { + throw new Error("Could not unlock non-interactively"); + } + const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createDialog( AccessSecretStorageDialog, @@ -417,3 +423,52 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool throw e; } } + +// FIXME: this function name is a bit of a mouthful +export async function tryToUnlockSecretStorageWithDehydrationKey(client: MatrixClient): Promise { + const key = dehydrationCache.key; + let restoringBackup = false; + if (key && (await client.isSecretStorageReady())) { + logger.log("Trying to set up cross-signing using dehydration key"); + secretStorageBeingAccessed = true; + nonInteractive = true; + try { + await client.checkOwnCrossSigningTrust(); + + // we also need to set a new dehydrated device to replace the + // device we rehydrated + let dehydrationKeyInfo = {}; + if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) { + dehydrationKeyInfo = { passphrase: dehydrationCache.keyInfo.passphrase }; + } + await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device"); + + // and restore from backup + const backupInfo = await client.getKeyBackupVersion(); + if (backupInfo) { + restoringBackup = true; + // don't await, because this can take a long time + client.restoreKeyBackupWithSecretStorage(backupInfo).finally(() => { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + }); + } + } finally { + dehydrationCache = {}; + // the secret storage cache is needed for restoring from backup, so + // don't clear it yet if we're restoring from backup + if (!restoringBackup) { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } + } + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 37e69b7a77c..4d06f7e07ba 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1469,6 +1469,11 @@ "render_reaction_images_description": "Sometimes referred to as \"custom emojis\".", "report_to_moderators": "Report to moderators", "report_to_moderators_description": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.", + "rust_crypto": "Rust cryptography implementation", + "rust_crypto_in_config": "Rust cryptography cannot be disabled on this deployment of %(brand)s", + "rust_crypto_in_config_description": "Switching to the Rust cryptography requires a migration process that may take several minutes. It cannot be disabled; use with caution!", + "rust_crypto_optin_warning": "Switching to the Rust cryptography requires a migration process that may take several minutes. To disable you will need to log out and back in; use with caution!", + "rust_crypto_requires_logout": "Once enabled, Rust cryptography can only be disabled by logging out and in again", "sliding_sync": "Sliding Sync mode", "sliding_sync_description": "Under active development, cannot be disabled.", "sliding_sync_disabled_notice": "Log out and back in to disable", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index ab7cb664b50..cf31bca2462 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -39,9 +39,11 @@ import { MetaSpace } from "../stores/spaces"; import SdkConfig from "../SdkConfig"; import SlidingSyncController from "./controllers/SlidingSyncController"; import { FontWatcher } from "./watchers/FontWatcher"; +import RustCryptoSdkController from "./controllers/RustCryptoSdkController"; import ServerSupportUnstableFeatureController from "./controllers/ServerSupportUnstableFeatureController"; import { WatchManager } from "./WatchManager"; import { CustomTheme } from "../theme"; +import SettingsStore from "./SettingsStore"; import AnalyticsController from "./controllers/AnalyticsController"; export const defaultWatchManager = new WatchManager(); @@ -94,14 +96,9 @@ export enum Features { VoiceBroadcastForceSmallChunks = "feature_voice_broadcast_force_small_chunks", NotificationSettings2 = "feature_notification_settings2", OidcNativeFlow = "feature_oidc_native_flow", - ReleaseAnnouncement = "feature_release_announcement", - - /** If true, use the Rust crypto implementation. - * - * This is no longer read, but we continue to populate it on all devices, to guard against people rolling back to - * old versions of EW that do not use rust crypto by default. - */ + // If true, every new login will use the new rust crypto implementation RustCrypto = "feature_rust_crypto", + ReleaseAnnouncement = "feature_release_announcement", } export const labGroupNames: Record = { @@ -480,8 +477,29 @@ export const SETTINGS: { [setting: string]: ISetting } = { default: false, }, [Features.RustCrypto]: { - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + // use the rust matrix-sdk-crypto-wasm for crypto. + isFeature: true, + labsGroup: LabGroup.Developer, + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + displayName: _td("labs|rust_crypto"), + description: () => { + if (SettingsStore.getValueAt(SettingLevel.CONFIG, Features.RustCrypto)) { + // It's enabled in the config, so you can't get rid of it even by logging out. + return _t("labs|rust_crypto_in_config_description"); + } else { + return _t("labs|rust_crypto_optin_warning"); + } + }, + shouldWarn: true, default: true, + controller: new RustCryptoSdkController(), + }, + // Must be set under `setting_defaults` in config.json. + // If set to 100 in conjunction with `feature_rust_crypto`, all existing users will migrate to the new crypto. + // Default is 0, meaning no existing users on legacy crypto will migrate. + "RustCrypto.staged_rollout_percent": { + supportedLevels: [SettingLevel.CONFIG], + default: 0, }, /** * @deprecated in favor of {@link fontSizeDelta} diff --git a/src/settings/controllers/RustCryptoSdkController.ts b/src/settings/controllers/RustCryptoSdkController.ts new file mode 100644 index 00000000000..3bf8526febb --- /dev/null +++ b/src/settings/controllers/RustCryptoSdkController.ts @@ -0,0 +1,49 @@ +/* +Copyright 2022 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 { _t } from "../../languageHandler"; +import SettingsStore from "../SettingsStore"; +import { SettingLevel } from "../SettingLevel"; +import PlatformPeg from "../../PlatformPeg"; +import SettingController from "./SettingController"; +import { Features } from "../Settings"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import SdkConfig from "../../SdkConfig"; + +export default class RustCryptoSdkController extends SettingController { + public onChange(level: SettingLevel, roomId: string | null, newValue: any): void { + // If the crypto stack has already been initialized, we'll need to reload the app to make it take effect. + if (MatrixClientPeg.get()?.getCrypto()) { + PlatformPeg.get()?.reload(); + } + } + + public get settingDisabled(): boolean | string { + if (!SettingsStore.getValueAt(SettingLevel.DEVICE, Features.RustCrypto)) { + // If rust crypto has not yet been enabled for this device, you can turn it on, IF YOU DARE + return false; + } + + if (SettingsStore.getValueAt(SettingLevel.CONFIG, Features.RustCrypto)) { + // It's enabled in the config, so you can't get rid of it even by logging out. + return _t("labs|rust_crypto_in_config", { brand: SdkConfig.get().brand }); + } + + // The setting is enabled at the device level, but not mandated at the config level. + // You can only turn it off by logging out and in again. + return _t("labs|rust_crypto_requires_logout"); + } +} diff --git a/src/utils/StorageManager.ts b/src/utils/StorageManager.ts index be9c9c41781..0cee3d9ef5d 100644 --- a/src/utils/StorageManager.ts +++ b/src/utils/StorageManager.ts @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IndexedDBStore, IndexedDBCryptoStore } from "matrix-js-sdk/src/matrix"; +import { LocalStorageCryptoStore, IndexedDBStore, IndexedDBCryptoStore } from "matrix-js-sdk/src/matrix"; 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; @@ -139,34 +141,55 @@ async function checkSyncStore(): Promise { } async function checkCryptoStore(): Promise { - // check first if there is a rust crypto store - try { - const rustDbExists = await IndexedDBCryptoStore.exists(getIDBFactory()!, RUST_CRYPTO_STORE_NAME); - log(`Rust Crypto store using IndexedDB contains data? ${rustDbExists}`); - - if (rustDbExists) { - // There was an existing rust database, so consider it healthy. - return { exists: true, healthy: true }; - } else { - // No rust store, so let's check if there is a legacy store not yet migrated. - try { - const legacyIdbExists = await IndexedDBCryptoStore.existsAndIsNotMigrated( - getIDBFactory()!, - LEGACY_CRYPTO_STORE_NAME, - ); - log(`Legacy Crypto store using IndexedDB contains non migrated data? ${legacyIdbExists}`); - return { exists: legacyIdbExists, healthy: true }; - } catch (e) { - error("Legacy crypto store using IndexedDB inaccessible", e); + if (await SettingsStore.getValue(Features.RustCrypto)) { + // check first if there is a rust crypto store + try { + const rustDbExists = await IndexedDBCryptoStore.exists(getIDBFactory()!, RUST_CRYPTO_STORE_NAME); + log(`Rust Crypto store using IndexedDB contains data? ${rustDbExists}`); + + if (rustDbExists) { + // There was an existing rust database, so consider it healthy. + return { exists: true, healthy: true }; + } else { + // No rust store, so let's check if there is a legacy store not yet migrated. + try { + const legacyIdbExists = await IndexedDBCryptoStore.existsAndIsNotMigrated( + getIDBFactory()!, + LEGACY_CRYPTO_STORE_NAME, + ); + log(`Legacy Crypto store using IndexedDB contains non migrated data? ${legacyIdbExists}`); + return { exists: legacyIdbExists, healthy: true }; + } catch (e) { + error("Legacy crypto store using IndexedDB inaccessible", e); + } + + // No need to check local storage or memory as rust stack doesn't support them. + // Given that rust stack requires indexeddb, set healthy to false. + return { exists: false, healthy: false }; } - - // No need to check local storage or memory as rust stack doesn't support them. - // Given that rust stack requires indexeddb, set healthy to false. + } catch (e) { + error("Rust crypto store using IndexedDB inaccessible", e); return { exists: false, healthy: false }; } - } catch (e) { - error("Rust crypto store using IndexedDB inaccessible", e); - return { exists: false, healthy: false }; + } else { + let exists = false; + // legacy checks + try { + exists = await IndexedDBCryptoStore.exists(getIDBFactory()!, LEGACY_CRYPTO_STORE_NAME); + log(`Crypto store using IndexedDB contains data? ${exists}`); + return { exists, healthy: true }; + } catch (e) { + error("Crypto store using IndexedDB inaccessible", e); + } + try { + exists = LocalStorageCryptoStore.exists(localStorage); + log(`Crypto store using local storage contains data? ${exists}`); + return { exists, healthy: true }; + } catch (e) { + error("Crypto store using local storage inaccessible", e); + } + log("Crypto store using memory only"); + return { exists, healthy: false }; } } diff --git a/test/MatrixClientPeg-test.ts b/test/MatrixClientPeg-test.ts index 121da2a154e..e5585f8cc3c 100644 --- a/test/MatrixClientPeg-test.ts +++ b/test/MatrixClientPeg-test.ts @@ -16,6 +16,7 @@ limitations under the License. import { logger } from "matrix-js-sdk/src/logger"; import fetchMockJest from "fetch-mock-jest"; +import EventEmitter from "events"; import { ProvideCryptoSetupExtensions, SecretStorageKeyDescription, @@ -24,7 +25,10 @@ import { import { advanceDateAndTime, stubClient } from "./test-utils"; import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg"; import SettingsStore from "../src/settings/SettingsStore"; +import Modal from "../src/Modal"; +import PlatformPeg from "../src/PlatformPeg"; import { SettingLevel } from "../src/settings/SettingLevel"; +import { Features } from "../src/settings/Settings"; import { ModuleRunner } from "../src/modules/ModuleRunner"; jest.useFakeTimers(); @@ -165,7 +169,75 @@ describe("MatrixClientPeg", () => { }); }); + describe("legacy crypto", () => { + beforeEach(() => { + const originalGetValue = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName: string, roomId: string | null = null, excludeDefault = false) => { + if (settingName === "feature_rust_crypto") { + return false; + } + return originalGetValue(settingName, roomId, excludeDefault); + }, + ); + }); + + it("should initialise client crypto", async () => { + const mockInitCrypto = jest.spyOn(testPeg.safeGet(), "initCrypto").mockResolvedValue(undefined); + const mockSetTrustCrossSignedDevices = jest + .spyOn(testPeg.safeGet(), "setCryptoTrustCrossSignedDevices") + .mockImplementation(() => {}); + const mockStartClient = jest.spyOn(testPeg.safeGet(), "startClient").mockResolvedValue(undefined); + + await testPeg.start(); + expect(mockInitCrypto).toHaveBeenCalledTimes(1); + expect(mockSetTrustCrossSignedDevices).toHaveBeenCalledTimes(1); + expect(mockStartClient).toHaveBeenCalledTimes(1); + }); + + it("should carry on regardless if there is an error initialising crypto", async () => { + const e2eError = new Error("nope nope nope"); + const mockInitCrypto = jest.spyOn(testPeg.safeGet(), "initCrypto").mockRejectedValue(e2eError); + const mockSetTrustCrossSignedDevices = jest + .spyOn(testPeg.safeGet(), "setCryptoTrustCrossSignedDevices") + .mockImplementation(() => {}); + const mockStartClient = jest.spyOn(testPeg.safeGet(), "startClient").mockResolvedValue(undefined); + const mockWarning = jest.spyOn(logger, "warn").mockReturnValue(undefined); + + await testPeg.start(); + expect(mockInitCrypto).toHaveBeenCalledTimes(1); + expect(mockSetTrustCrossSignedDevices).not.toHaveBeenCalled(); + expect(mockStartClient).toHaveBeenCalledTimes(1); + expect(mockWarning).toHaveBeenCalledWith(expect.stringMatching("Unable to initialise e2e"), e2eError); + }); + + it("should reload when store database closes for a guest user", async () => { + testPeg.safeGet().isGuest = () => true; + const emitter = new EventEmitter(); + testPeg.safeGet().store.on = emitter.on.bind(emitter); + const platform: any = { reload: jest.fn() }; + PlatformPeg.set(platform); + await testPeg.assign({}); + emitter.emit("closed" as any); + expect(platform.reload).toHaveBeenCalled(); + }); + + it("should show error modal when store database closes", async () => { + testPeg.safeGet().isGuest = () => false; + const emitter = new EventEmitter(); + const platform: any = { getHumanReadableName: jest.fn() }; + PlatformPeg.set(platform); + testPeg.safeGet().store.on = emitter.on.bind(emitter); + const spy = jest.spyOn(Modal, "createDialog"); + await testPeg.assign({}); + emitter.emit("closed" as any); + expect(spy).toHaveBeenCalled(); + }); + }); + it("should initialise the rust crypto library by default", async () => { + await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, null); + const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); const mockInitCrypto = jest.spyOn(testPeg.safeGet(), "initCrypto").mockResolvedValue(undefined); @@ -180,15 +252,143 @@ describe("MatrixClientPeg", () => { expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); }); - it("Should migrate existing login", async () => { + it("should initialise the legacy crypto library if set", async () => { + await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, null); + + const originalGetValue = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName: string, roomId: string | null = null, excludeDefault = false) => { + if (settingName === "feature_rust_crypto") { + return false; + } + return originalGetValue(settingName, roomId, excludeDefault); + }, + ); + const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + + const mockInitCrypto = jest.spyOn(testPeg.safeGet(), "initCrypto").mockResolvedValue(undefined); const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); await testPeg.start(); - expect(mockInitRustCrypto).toHaveBeenCalledTimes(1); + expect(mockInitCrypto).toHaveBeenCalled(); + expect(mockInitRustCrypto).not.toHaveBeenCalled(); // we should have stashed the setting in the settings store - expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); + expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false); + }); + + describe("Rust staged rollout", () => { + function mockSettingStore( + userIsUsingRust: boolean, + newLoginShouldUseRust: boolean, + rolloutPercent: number | null, + ) { + const originalGetValue = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName: string, roomId: string | null = null, excludeDefault = false) => { + if (settingName === "feature_rust_crypto") { + return userIsUsingRust; + } + return originalGetValue(settingName, roomId, excludeDefault); + }, + ); + const originalGetValueAt = SettingsStore.getValueAt; + jest.spyOn(SettingsStore, "getValueAt").mockImplementation( + (level: SettingLevel, settingName: string) => { + if (settingName === "feature_rust_crypto") { + return newLoginShouldUseRust; + } + // if null we let the original implementation handle it to get the default + if (settingName === "RustCrypto.staged_rollout_percent" && rolloutPercent !== null) { + return rolloutPercent; + } + return originalGetValueAt(level, settingName); + }, + ); + } + + let mockSetValue: jest.SpyInstance; + let mockInitCrypto: jest.SpyInstance; + let mockInitRustCrypto: jest.SpyInstance; + + beforeEach(async () => { + mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + mockInitCrypto = jest.spyOn(testPeg.safeGet(), "initCrypto").mockResolvedValue(undefined); + mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); + + await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, null); + }); + + it("Should not migrate existing login if rollout is 0", async () => { + mockSettingStore(false, true, 0); + + await testPeg.start(); + expect(mockInitCrypto).toHaveBeenCalled(); + expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1); + + // we should have stashed the setting in the settings store + expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false); + }); + + it("Should migrate existing login if rollout is 100", async () => { + mockSettingStore(false, true, 100); + await testPeg.start(); + expect(mockInitCrypto).not.toHaveBeenCalled(); + expect(mockInitRustCrypto).toHaveBeenCalledTimes(1); + + // we should have stashed the setting in the settings store + expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); + }); + + it("Should migrate existing login if user is in rollout bucket", async () => { + mockSettingStore(false, true, 30); + + // Use a device id that is known to be in the 30% bucket (hash modulo 100 < 30) + const spy = jest.spyOn(testPeg.get()!, "getDeviceId").mockReturnValue("AAA"); + + await testPeg.start(); + expect(mockInitCrypto).not.toHaveBeenCalled(); + expect(mockInitRustCrypto).toHaveBeenCalledTimes(1); + + // we should have stashed the setting in the settings store + expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); + + spy.mockReset(); + }); + + it("Should not migrate existing login if rollout is malformed", async () => { + mockSettingStore(false, true, 100.1); + + await testPeg.start(); + expect(mockInitCrypto).toHaveBeenCalled(); + expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1); + + // we should have stashed the setting in the settings store + expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false); + }); + + it("Default is to not migrate", async () => { + mockSettingStore(false, true, null); + + await testPeg.start(); + expect(mockInitCrypto).toHaveBeenCalled(); + expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1); + + // we should have stashed the setting in the settings store + expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false); + }); + + it("Should not migrate if feature_rust_crypto is false", async () => { + mockSettingStore(false, false, 100); + + await testPeg.start(); + expect(mockInitCrypto).toHaveBeenCalled(); + expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1); + + // we should have stashed the setting in the settings store + expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false); + }); }); }); }); diff --git a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx index e8943cf1477..18622d87a1b 100644 --- a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx @@ -15,11 +15,13 @@ limitations under the License. */ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import LabsUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/LabsUserSettingsTab"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; import SdkConfig from "../../../../../../src/SdkConfig"; +import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; describe("", () => { const defaultProps = { @@ -61,4 +63,105 @@ describe("", () => { const labsSections = container.getElementsByClassName("mx_SettingsSubsection"); expect(labsSections).toHaveLength(10); }); + + describe("Rust crypto setting", () => { + const SETTING_NAME = "Rust cryptography implementation"; + + beforeEach(() => { + SdkConfig.add({ show_labs_settings: true }); + }); + + describe("Not enabled in config", () => { + // these tests only works if the feature is not enabled in the config by default? + const copyOfGetValueAt = SettingsStore.getValueAt; + + beforeEach(() => { + SettingsStore.getValueAt = ( + level: SettingLevel, + name: string, + roomId?: string, + isExplicit?: boolean, + ) => { + if (level == SettingLevel.CONFIG && name === "feature_rust_crypto") return false; + return copyOfGetValueAt(level, name, roomId, isExplicit); + }; + }); + + afterEach(() => { + SettingsStore.getValueAt = copyOfGetValueAt; + }); + + it("can be turned on if not already", async () => { + // By the time the settings panel is shown, `MatrixClientPeg.initClientCrypto` has saved the current + // value to the settings store. + await SettingsStore.setValue("feature_rust_crypto", null, SettingLevel.DEVICE, false); + + const rendered = render(getComponent()); + const toggle = rendered.getByRole("switch", { name: SETTING_NAME }); + expect(toggle.getAttribute("aria-disabled")).toEqual("false"); + expect(toggle.getAttribute("aria-checked")).toEqual("false"); + + const description = toggle.closest(".mx_SettingsFlag")?.querySelector(".mx_SettingsFlag_microcopy"); + expect(description).toHaveTextContent(/To disable you will need to log out and back in/); + }); + + it("cannot be turned off once enabled", async () => { + await SettingsStore.setValue("feature_rust_crypto", null, SettingLevel.DEVICE, true); + + const rendered = render(getComponent()); + const toggle = rendered.getByRole("switch", { name: SETTING_NAME }); + expect(toggle.getAttribute("aria-disabled")).toEqual("true"); + expect(toggle.getAttribute("aria-checked")).toEqual("true"); + + // Hover over the toggle to make it show the tooltip + await userEvent.hover(toggle); + + await waitFor(() => { + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent( + "Once enabled, Rust cryptography can only be disabled by logging out and in again", + ); + }); + }); + }); + + describe("Enabled in config", () => { + beforeEach(() => { + SdkConfig.add({ features: { feature_rust_crypto: true } }); + }); + + it("can be turned on if not already", async () => { + // By the time the settings panel is shown, `MatrixClientPeg.initClientCrypto` has saved the current + // value to the settings store. + await SettingsStore.setValue("feature_rust_crypto", null, SettingLevel.DEVICE, false); + + const rendered = render(getComponent()); + const toggle = rendered.getByRole("switch", { name: SETTING_NAME }); + expect(toggle.getAttribute("aria-disabled")).toEqual("false"); + expect(toggle.getAttribute("aria-checked")).toEqual("false"); + + const description = toggle.closest(".mx_SettingsFlag")?.querySelector(".mx_SettingsFlag_microcopy"); + expect(description).toHaveTextContent(/It cannot be disabled/); + }); + + it("cannot be turned off once enabled", async () => { + await SettingsStore.setValue("feature_rust_crypto", null, SettingLevel.DEVICE, true); + + const rendered = render(getComponent()); + const toggle = rendered.getByRole("switch", { name: SETTING_NAME }); + expect(toggle.getAttribute("aria-disabled")).toEqual("true"); + expect(toggle.getAttribute("aria-checked")).toEqual("true"); + + // Hover over the toggle to make it show the tooltip + await userEvent.hover(toggle); + + await waitFor(() => { + const tooltip = rendered.getByRole("tooltip"); + expect(tooltip).toHaveTextContent( + "Rust cryptography cannot be disabled on this deployment of BrandedClient", + ); + }); + }); + }); + }); }); diff --git a/test/utils/StorageManager-test.ts b/test/utils/StorageManager-test.ts index e2eb172581f..786e20caea7 100644 --- a/test/utils/StorageManager-test.ts +++ b/test/utils/StorageManager-test.ts @@ -20,6 +20,7 @@ import { IDBFactory } from "fake-indexeddb"; import { IndexedDBCryptoStore } from "matrix-js-sdk/src/matrix"; import * as StorageManager from "../../src/utils/StorageManager"; +import SettingsStore from "../../src/settings/SettingsStore"; const LEGACY_CRYPTO_STORE_NAME = "matrix-js-sdk:crypto"; const RUST_CRYPTO_STORE_NAME = "matrix-js-sdk::matrix-sdk-crypto"; @@ -76,54 +77,98 @@ describe("StorageManager", () => { indexedDB = new IDBFactory(); }); - it("should not be ok if sync store but no crypto store", async () => { - const result = await StorageManager.checkConsistency(); - expect(result.healthy).toBe(true); - expect(result.dataInCryptoStore).toBe(false); - }); - - it("should be ok if sync store and a rust crypto store", async () => { - await createDB(RUST_CRYPTO_STORE_NAME); + describe("with `feature_rust_crypto` enabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation(async (key) => { + if (key === "feature_rust_crypto") { + return true; + } + throw new Error(`Unknown key ${key}`); + }); + }); - const result = await StorageManager.checkConsistency(); - expect(result.healthy).toBe(true); - expect(result.dataInCryptoStore).toBe(true); - }); + it("should not be ok if sync store but no crypto store", async () => { + const result = await StorageManager.checkConsistency(); + expect(result.healthy).toBe(true); + expect(result.dataInCryptoStore).toBe(false); + }); - describe("without rust store", () => { - it("should be ok if there is non migrated legacy crypto store", async () => { - await populateLegacyStore(undefined); + it("should be ok if sync store and a rust crypto store", async () => { + await createDB(RUST_CRYPTO_STORE_NAME); const result = await StorageManager.checkConsistency(); expect(result.healthy).toBe(true); expect(result.dataInCryptoStore).toBe(true); }); - it("should be ok if legacy store in MigrationState `NOT_STARTED`", async () => { - await populateLegacyStore(0 /* MigrationState.NOT_STARTED*/); + describe("without rust store", () => { + it("should be ok if there is non migrated legacy crypto store", async () => { + await populateLegacyStore(undefined); + + const result = await StorageManager.checkConsistency(); + expect(result.healthy).toBe(true); + expect(result.dataInCryptoStore).toBe(true); + }); + + it("should be ok if legacy store in MigrationState `NOT_STARTED`", async () => { + await populateLegacyStore(0 /* MigrationState.NOT_STARTED*/); + + const result = await StorageManager.checkConsistency(); + expect(result.healthy).toBe(true); + expect(result.dataInCryptoStore).toBe(true); + }); + + it("should not be ok if MigrationState greater than `NOT_STARTED`", async () => { + await populateLegacyStore(1 /*INITIAL_DATA_MIGRATED*/); + + const result = await StorageManager.checkConsistency(); + expect(result.healthy).toBe(true); + expect(result.dataInCryptoStore).toBe(false); + }); + it("should not be healthy if no indexeddb", async () => { + // eslint-disable-next-line no-global-assign + indexedDB = {} as IDBFactory; + + const result = await StorageManager.checkConsistency(); + expect(result.healthy).toBe(false); + + // eslint-disable-next-line no-global-assign + indexedDB = new IDBFactory(); + }); + }); + }); + + describe("with `feature_rust_crypto` disabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation(async (key) => { + if (key === "feature_rust_crypto") { + return false; + } + throw new Error(`Unknown key ${key}`); + }); + }); + + it("should not be ok if sync store but no crypto store", async () => { const result = await StorageManager.checkConsistency(); expect(result.healthy).toBe(true); - expect(result.dataInCryptoStore).toBe(true); + expect(result.dataInCryptoStore).toBe(false); }); - it("should not be ok if MigrationState greater than `NOT_STARTED`", async () => { - await populateLegacyStore(1 /*INITIAL_DATA_MIGRATED*/); + it("should not be ok if sync store but no crypto store and a rust store", async () => { + await createDB(RUST_CRYPTO_STORE_NAME); const result = await StorageManager.checkConsistency(); expect(result.healthy).toBe(true); expect(result.dataInCryptoStore).toBe(false); }); - it("should not be healthy if no indexeddb", async () => { - // eslint-disable-next-line no-global-assign - indexedDB = {} as IDBFactory; + it("should be healthy if sync store and a legacy crypto store", async () => { + await createDB(LEGACY_CRYPTO_STORE_NAME); const result = await StorageManager.checkConsistency(); - expect(result.healthy).toBe(false); - - // eslint-disable-next-line no-global-assign - indexedDB = new IDBFactory(); + expect(result.healthy).toBe(true); + expect(result.dataInCryptoStore).toBe(true); }); }); });