diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts new file mode 100644 index 0000000000..13da99ccad --- /dev/null +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -0,0 +1,114 @@ +/* +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 { Locator, type Page } from "@playwright/test"; + +import { test as base, expect } from "../../element-web-test"; +import { viewRoomSummaryByName } from "../right-panel/utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + startHomeserverOpts: async ({}, use) => { + await use("dehydration"); + }, + config: async ({ homeserver, context }, use) => { + const wellKnown = { + "m.homeserver": { + base_url: homeserver.config.baseUrl, + }, + "org.matrix.msc3814": true, + }; + + await context.route("https://localhost/.well-known/matrix/client", async (route) => { + await route.fulfill({ json: wellKnown }); + }); + + await use({ + default_server_config: wellKnown, + }); + }, +}); + +const ROOM_NAME = "Test room"; +const NAME = "Alice"; + +function getMemberTileByName(page: Page, name: string): Locator { + return page.locator(`.mx_EntityTile, [title="${name}"]`); +} + +test.describe("Dehydration", () => { + test.skip(isDendrite, "does not yet support dehydration v2"); + + test.use({ + displayName: NAME, + }); + + test("Create dehydrated device", async ({ page, user, app }, workerInfo) => { + test.skip(workerInfo.project.name === "Legacy Crypto", "This test only works with Rust crypto."); + + // Create a backup (which will create SSSS, and dehydrated device) + + const securityTab = await app.settings.openUserSettings("Security & Privacy"); + + await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); + await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible(); + await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); + + const currentDialogLocator = page.locator(".mx_Dialog"); + + // It's the first time and secure storage is not set up, so it will create one + await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); + await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); + await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); + await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); + await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); + + // Open the settings again + await app.settings.openUserSettings("Security & Privacy"); + + // The Security tab should indicate that there is a dehydrated device present + await expect(securityTab.getByText("Offline device enabled")).toBeVisible(); + + await app.settings.closeDialog(); + + // the dehydrated device gets created with the name "Dehydrated + // device". We want to make sure that it is not visible as a normal + // device. + const sessionsTab = await app.settings.openUserSettings("Sessions"); + await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible(); + + await app.settings.closeDialog(); + + // now check that the user info right-panel shows the dehydrated device + // as a feature rather than as a normal device + await app.client.createRoom({ name: ROOM_NAME }); + + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("menuitem", { name: "People" }).click(); + await expect(page.locator(".mx_MemberList")).toBeVisible(); + + await getMemberTileByName(page, NAME).click(); + await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click(); + + await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible(); + await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible(); + }); +}); diff --git a/playwright/plugins/homeserver/synapse/templates/dehydration/README.md b/playwright/plugins/homeserver/synapse/templates/dehydration/README.md new file mode 100644 index 0000000000..18f7923e6d --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/dehydration/README.md @@ -0,0 +1 @@ +A synapse configured with device dehydration v2 enabled diff --git a/playwright/plugins/homeserver/synapse/templates/dehydration/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/dehydration/homeserver.yaml new file mode 100644 index 0000000000..c3ac5d6536 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/dehydration/homeserver.yaml @@ -0,0 +1,102 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +public_baseurl: "{{PUBLIC_BASEURL}}" +listeners: + - port: 8008 + tls: false + bind_addresses: ["::"] + type: http + x_forwarded: true + + resources: + - names: [client] + compress: false + +database: + name: "sqlite3" + args: + database: ":memory:" + +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 +rc_joins: + local: + per_second: 9999 + burst_count: 9999 + remote: + per_second: 9999 + burst_count: 9999 +rc_joins_per_room: + per_second: 9999 + burst_count: 9999 +rc_3pid_validation: + per_second: 1000 + burst_count: 1000 + +rc_invites: + per_room: + per_second: 1000 + burst_count: 1000 + per_user: + per_second: 1000 + burst_count: 1000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +signing_key_path: "/data/localhost.signing.key" + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true + +ui_auth: + session_timeout: "300s" + +oidc_providers: + - idp_id: test + idp_name: "OAuth test" + issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth" + authorization_endpoint: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth/auth.html" + # the token endpoint receives requests from synapse, rather than the webapp, so needs to escape the docker container. + token_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/token" + userinfo_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/userinfo" + client_id: "synapse" + discover: false + scopes: ["profile"] + skip_verification: true + client_auth_method: none + user_mapping_provider: + config: + display_name_template: "{{ user.name }}" + +# Inhibit background updates as this Synapse isn't long-lived +background_updates: + min_batch_size: 100000 + sleep_duration_ms: 100000 + +experimental_features: + msc2697_enabled: false + msc3814_enabled: true diff --git a/playwright/plugins/homeserver/synapse/templates/dehydration/log.config b/playwright/plugins/homeserver/synapse/templates/dehydration/log.config new file mode 100644 index 0000000000..b9123d0f5b --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/dehydration/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: DEBUG + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: DEBUG + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index be49b43851..d21164ec8e 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -48,6 +48,7 @@ import InteractiveAuthDialog from "../../../../components/views/dialogs/Interact import { IValidationResult } from "../../../../components/views/elements/Validation"; import { Icon as CheckmarkIcon } from "../../../../../res/img/element-icons/check.svg"; import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField"; +import { initialiseDehydration } from "../../../../utils/device/dehydration"; // I made a mistake while converting this and it has to be fixed! enum Phase { @@ -397,6 +398,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent device.deviceId !== dehydratedDeviceId); + dehydratedDeviceInExpandSection = true; + } expandSectionDevices = devices; expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length }); expandHideCaption = _t("user_info|hide_sessions"); @@ -347,6 +371,9 @@ function DevicesSection({ ); }), ); + if (dehydratedDeviceInExpandSection) { + deviceList.push(
{_t("user_info|dehydrated_device_enabled")}
); + } } return ( diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index c735b2cbce..f0cec2777a 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -77,6 +77,7 @@ export enum OwnDevicesError { } export type DevicesState = { devices: DevicesDictionary; + dehydratedDeviceId?: string; pushers: IPusher[]; localNotificationSettings: Map; currentDeviceId: string; @@ -97,6 +98,7 @@ export const useOwnDevices = (): DevicesState => { const userId = matrixClient.getSafeUserId(); const [devices, setDevices] = useState({}); + const [dehydratedDeviceId, setDehydratedDeviceId] = useState(undefined); const [pushers, setPushers] = useState([]); const [localNotificationSettings, setLocalNotificationSettings] = useState< DevicesState["localNotificationSettings"] @@ -131,6 +133,21 @@ export const useOwnDevices = (): DevicesState => { }); setLocalNotificationSettings(notificationSettings); + const ownUserId = matrixClient.getUserId()!; + const userDevices = (await matrixClient.getCrypto()?.getUserDeviceInfo([ownUserId]))?.get(ownUserId); + const dehydratedDeviceIds: string[] = []; + for (const device of userDevices?.values() ?? []) { + if (device.dehydrated) { + dehydratedDeviceIds.push(device.deviceId); + } + } + // If the user has exactly one device marked as dehydrated, we consider + // that as the dehydrated device, and hide it as a normal device (but + // indicate that the user is using a dehydrated device). If the user has + // more than one, that is anomalous, and we show all the devices so that + // nothing is hidden. + setDehydratedDeviceId(dehydratedDeviceIds.length == 1 ? dehydratedDeviceIds[0] : undefined); + setIsLoadingDeviceList(false); } catch (error) { if ((error as MatrixError).httpStatus == 404) { @@ -228,6 +245,7 @@ export const useOwnDevices = (): DevicesState => { return { devices, + dehydratedDeviceId, pushers, localNotificationSettings, currentDeviceId, diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 81ff32e38e..80c02bb546 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -42,6 +42,7 @@ import type { IServerVersions } from "matrix-js-sdk/src/matrix"; import SettingsTab from "../SettingsTab"; import { SettingsSection } from "../../shared/SettingsSection"; import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection"; +import { useOwnDevices } from "../../devices/useOwnDevices"; interface IIgnoredUserProps { userId: string; @@ -49,6 +50,23 @@ interface IIgnoredUserProps { inProgress: boolean; } +const DehydratedDeviceStatus: React.FC = () => { + const { dehydratedDeviceId } = useOwnDevices(); + + if (dehydratedDeviceId) { + return ( +
+
{_t("settings|security|dehydrated_device_enabled")}
+
+ {_t("settings|security|dehydrated_device_description")} +
+
+ ); + } else { + return null; + } +}; + export class IgnoredUser extends React.Component { private onUnignoreClicked = (): void => { this.props.onUnignored(this.props.userId); @@ -279,6 +297,7 @@ export default class SecurityUserSettingsTab extends React.Component + ); diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index fc215f069a..61c8e85f8d 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -150,6 +150,7 @@ const useSignOut = ( const SessionManagerTab: React.FC = () => { const { devices, + dehydratedDeviceId, pushers, localNotificationSettings, currentDeviceId, @@ -208,6 +209,9 @@ const SessionManagerTab: React.FC = () => { }; const { [currentDeviceId]: currentDevice, ...otherDevices } = devices; + if (dehydratedDeviceId && otherDevices[dehydratedDeviceId]?.isVerified) { + delete otherDevices[dehydratedDeviceId]; + } const otherSessionsCount = Object.keys(otherDevices).length; const shouldShowOtherSessions = otherSessionsCount > 0; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 48e6e2f0ae..4f07aedd94 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2684,6 +2684,8 @@ "cross_signing_self_signing_private_key": "Self signing private key:", "cross_signing_user_signing_private_key": "User signing private key:", "cryptography_section": "Cryptography", + "dehydrated_device_description": "The offline device feature allows you to receive encrypted messages even when you are not logged in to any devices", + "dehydrated_device_enabled": "Offline device enabled", "delete_backup": "Delete Backup", "delete_backup_confirm_description": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", "e2ee_default_disabled_warning": "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.", @@ -3705,6 +3707,7 @@ "deactivate_confirm_action": "Deactivate user", "deactivate_confirm_description": "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?", "deactivate_confirm_title": "Deactivate user?", + "dehydrated_device_enabled": "Offline device enabled", "demote_button": "Demote", "demote_self_confirm_description_space": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.", "demote_self_confirm_room": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.", diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 79621322be..e3069d4333 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +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. @@ -28,6 +28,7 @@ import InteractiveAuthDialog from "../components/views/dialogs/InteractiveAuthDi import { _t } from "../languageHandler"; import { SdkContextClass } from "../contexts/SDKContext"; import { asyncSome } from "../utils/arrays"; +import { initialiseDehydration } from "../utils/device/dehydration"; export enum Phase { Loading = 0, @@ -111,8 +112,12 @@ export class SetupEncryptionStore extends EventEmitter { const userDevices: Iterable = (await crypto.getUserDeviceInfo([ownUserId])).get(ownUserId)?.values() ?? []; this.hasDevicesToVerifyAgainst = await asyncSome(userDevices, async (device) => { - // ignore the dehydrated device + // Ignore dehydrated devices. `dehydratedDevice` is set by the + // implementation of MSC2697, whereas MSC3814 proposes that devices + // should set a `dehydrated` flag in the device key. We ignore + // both types of dehydrated devices. if (dehydratedDevice && device.deviceId == dehydratedDevice?.device_id) return false; + if (device.dehydrated) return false; // ignore devices without an identity key if (!device.getIdentityKey()) return false; @@ -144,11 +149,17 @@ export class SetupEncryptionStore extends EventEmitter { await new Promise((resolve: (value?: unknown) => void, reject: (reason?: any) => void) => { accessSecretStorage(async (): Promise => { await cli.checkOwnCrossSigningTrust(); + + // The remaining tasks (device dehydration and restoring + // key backup) may take some time due to processing many + // to-device messages in the case of device dehydration, or + // having many keys to restore in the case of key backups, + // so we allow the dialog to advance before this. resolve(); + + await initialiseDehydration(); + if (backupInfo) { - // A complete restore can take many minutes for large - // accounts / slow servers, so we allow the dialog - // to advance before this. await cli.restoreKeyBackupWithSecretStorage(backupInfo); } }).catch(reject); @@ -255,6 +266,9 @@ export class SetupEncryptionStore extends EventEmitter { }, setupNewCrossSigning: true, }); + + await initialiseDehydration(true); + this.phase = Phase.Finished; }, true); } catch (e) { diff --git a/src/utils/device/dehydration.ts b/src/utils/device/dehydration.ts new file mode 100644 index 0000000000..83297f42a4 --- /dev/null +++ b/src/utils/device/dehydration.ts @@ -0,0 +1,57 @@ +/* +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 { logger } from "matrix-js-sdk/src/logger"; +import { CryptoApi } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; + +/** + * Check if device dehydration is enabled. + * + * Note that this doesn't necessarily mean that device dehydration has been initialised + * (yet) on this client; rather, it means that the server supports it, the crypto backend + * supports it, and the application configuration suggests that it *should* be + * initialised on this device. + * + * Dehydration can currently only be enabled by setting a flag in the .well-known file. + */ +async function deviceDehydrationEnabled(crypto: CryptoApi | undefined): Promise { + if (!crypto) { + return false; + } + if (!(await crypto.isDehydrationSupported())) { + return false; + } + const wellknown = await MatrixClientPeg.safeGet().waitForClientWellKnown(); + return !!wellknown?.["org.matrix.msc3814"]; +} + +/** + * If dehydration is enabled (i.e., it is supported by the server and enabled in + * the configuration), rehydrate a device (if available) and create + * a new dehydrated device. + * + * @param createNewKey: force a new dehydration key to be created, even if one + * already exists. This is used when we reset secret storage. + */ +export async function initialiseDehydration(createNewKey: boolean = false): Promise { + const crypto = MatrixClientPeg.safeGet().getCrypto(); + if (await deviceDehydrationEnabled(crypto)) { + logger.log("Device dehydration enabled"); + await crypto!.startDehydration(createNewKey); + } +} diff --git a/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx index fac786b3c2..1412074ed9 100644 --- a/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx +++ b/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx @@ -50,6 +50,7 @@ describe("CreateSecretStorageDialog", () => { mockCrypto = mocked(mockClient.getCrypto()!); Object.assign(mockCrypto, { isKeyBackupTrusted: jest.fn(), + isDehydrationSupported: jest.fn(() => false), bootstrapCrossSigning: jest.fn(), bootstrapSecretStorage: jest.fn(), }); diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index b40146e609..7c12efb9d7 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -412,6 +412,183 @@ describe("", () => { await waitFor(() => expect(screen.getByRole("button", { name: "Verify" })).toBeInTheDocument()); expect(container).toMatchSnapshot(); }); + + describe("device dehydration", () => { + it("hides a verified dehydrated device (unverified user)", async () => { + const device1 = new Device({ + deviceId: "d1", + userId: defaultUserId, + displayName: "my device", + algorithms: [], + keys: new Map(), + }); + const device2 = new Device({ + deviceId: "d2", + userId: defaultUserId, + displayName: "dehydrated device", + algorithms: [], + keys: new Map(), + dehydrated: true, + }); + const devicesMap = new Map([ + [device1.deviceId, device1], + [device2.deviceId, device2], + ]); + const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + + renderComponent({ room: mockRoom }); + await act(flushPromises); + + // check the button exists with the expected text (the dehydrated device shouldn't be counted) + const devicesButton = screen.getByRole("button", { name: "1 session" }); + + // click it + await act(() => { + return userEvent.click(devicesButton); + }); + + // there should now be a button with the non-dehydrated device ID + expect(screen.getByRole("button", { description: "d1" })).toBeInTheDocument(); + + // but not for the dehydrated device ID + expect(screen.queryByRole("button", { description: "d2" })).not.toBeInTheDocument(); + + // there should be a line saying that the user has "Offline device" enabled + expect(screen.getByText("Offline device enabled")).toBeInTheDocument(); + }); + + it("hides a verified dehydrated device (verified user)", async () => { + const device1 = new Device({ + deviceId: "d1", + userId: defaultUserId, + displayName: "my device", + algorithms: [], + keys: new Map(), + }); + const device2 = new Device({ + deviceId: "d2", + userId: defaultUserId, + displayName: "dehydrated device", + algorithms: [], + keys: new Map(), + dehydrated: true, + }); + const devicesMap = new Map([ + [device1.deviceId, device1], + [device2.deviceId, device2], + ]); + const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true)); + mockCrypto.getDeviceVerificationStatus.mockResolvedValue({ + isVerified: () => true, + } as DeviceVerificationStatus); + + renderComponent({ room: mockRoom }); + await act(flushPromises); + + // check the button exists with the expected text (the dehydrated device shouldn't be counted) + const devicesButton = screen.getByRole("button", { name: "1 verified session" }); + + // click it + await act(() => { + return userEvent.click(devicesButton); + }); + + // there should now be a button with the non-dehydrated device ID + expect(screen.getByTitle("d1")).toBeInTheDocument(); + + // but not for the dehydrated device ID + expect(screen.queryByTitle("d2")).not.toBeInTheDocument(); + + // there should be a line saying that the user has "Offline device" enabled + expect(screen.getByText("Offline device enabled")).toBeInTheDocument(); + }); + + it("shows an unverified dehydrated device", async () => { + const device1 = new Device({ + deviceId: "d1", + userId: defaultUserId, + displayName: "my device", + algorithms: [], + keys: new Map(), + }); + const device2 = new Device({ + deviceId: "d2", + userId: defaultUserId, + displayName: "dehydrated device", + algorithms: [], + keys: new Map(), + dehydrated: true, + }); + const devicesMap = new Map([ + [device1.deviceId, device1], + [device2.deviceId, device2], + ]); + const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true)); + + renderComponent({ room: mockRoom }); + await act(flushPromises); + + // the dehydrated device should be shown as an unverified device, which means + // there should now be a button with the device id ... + const deviceButton = screen.getByRole("button", { description: "d2" }); + + // ... which should contain the device name + expect(within(deviceButton).getByText("dehydrated device")).toBeInTheDocument(); + }); + + it("shows dehydrated devices if there is more than one", async () => { + const device1 = new Device({ + deviceId: "d1", + userId: defaultUserId, + displayName: "dehydrated device 1", + algorithms: [], + keys: new Map(), + dehydrated: true, + }); + const device2 = new Device({ + deviceId: "d2", + userId: defaultUserId, + displayName: "dehydrated device 2", + algorithms: [], + keys: new Map(), + dehydrated: true, + }); + const devicesMap = new Map([ + [device1.deviceId, device1], + [device2.deviceId, device2], + ]); + const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + + renderComponent({ room: mockRoom }); + await act(flushPromises); + + // check the button exists with the expected text (the dehydrated device shouldn't be counted) + const devicesButton = screen.getByRole("button", { name: "2 sessions" }); + + // click it + await act(() => { + return userEvent.click(devicesButton); + }); + + // the dehydrated devices should be shown as an unverified device, which means + // there should now be a button with the first dehydrated device id ... + const device1Button = screen.getByRole("button", { description: "d1" }); + + // ... which should contain the device name + expect(within(device1Button).getByText("dehydrated device 1")).toBeInTheDocument(); + // and a button with the second dehydrated device id ... + const device2Button = screen.getByRole("button", { description: "d2" }); + + // ... which should contain the device name + expect(within(device2Button).getByText("dehydrated device 2")).toBeInTheDocument(); + }); + }); }); describe("with an encrypted room", () => { diff --git a/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx index c1848774ee..e0e1394c5b 100644 --- a/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx @@ -26,6 +26,7 @@ import { mockClientMethodsDevice, mockPlatformPeg, } from "../../../../../test-utils"; +import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext"; describe("", () => { const defaultProps = { @@ -44,9 +45,14 @@ describe("", () => { getKeyBackupVersion: jest.fn(), }); + const sdkContext = new SdkContextClass(); + sdkContext.client = mockClient; + const getComponent = () => ( - + + + ); diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 60b6bade4d..13992b8e15 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -22,6 +22,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { defer, sleep } from "matrix-js-sdk/src/utils"; import { ClientEvent, + Device, IMyDevice, LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixEvent, @@ -61,6 +62,18 @@ mockPlatformPeg(); // to restore later const realWindowLocation = window.location; +function deviceToDeviceObj(userId: string, device: IMyDevice, opts: Partial = {}): Device { + const deviceOpts: Pick & Partial = { + deviceId: device.device_id, + userId, + algorithms: [], + displayName: device.display_name, + keys: new Map(), + ...opts, + }; + return new Device(deviceOpts); +} + describe("", () => { const aliceId = "@alice:server.org"; const deviceId = "alices_device"; @@ -69,10 +82,12 @@ describe("", () => { device_id: deviceId, display_name: "Alices device", }; + const alicesDeviceObj = deviceToDeviceObj(aliceId, alicesDevice); const alicesMobileDevice = { device_id: "alices_mobile_device", last_seen_ts: Date.now(), }; + const alicesMobileDeviceObj = deviceToDeviceObj(aliceId, alicesMobileDevice); const alicesOlderMobileDevice = { device_id: "alices_older_mobile_device", @@ -84,6 +99,20 @@ describe("", () => { last_seen_ts: Date.now() - (INACTIVE_DEVICE_AGE_MS + 1000), }; + const alicesDehydratedDevice = { + device_id: "alices_dehydrated_device", + last_seen_ts: Date.now(), + }; + const alicesDehydratedDeviceObj = deviceToDeviceObj(aliceId, alicesDehydratedDevice, { dehydrated: true }); + + const alicesOtherDehydratedDevice = { + device_id: "alices_other_dehydrated_device", + last_seen_ts: Date.now(), + }; + const alicesOtherDehydratedDeviceObj = deviceToDeviceObj(aliceId, alicesOtherDehydratedDevice, { + dehydrated: true, + }); + const mockVerificationRequest = { cancel: jest.fn(), on: jest.fn(), @@ -91,6 +120,7 @@ describe("", () => { const mockCrypto = mocked({ getDeviceVerificationStatus: jest.fn(), + getUserDeviceInfo: jest.fn(), requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest), } as unknown as CryptoApi); @@ -627,6 +657,137 @@ describe("", () => { }); }); + describe("device dehydration", () => { + it("Hides a verified dehydrated device", async () => { + mockClient.getDevices.mockResolvedValue({ + devices: [alicesDevice, alicesMobileDevice, alicesDehydratedDevice], + }); + mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); + + const devicesMap = new Map([ + [alicesDeviceObj.deviceId, alicesDeviceObj], + [alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj], + [alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj], + ]); + const userDeviceMap = new Map>([[aliceId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { + // alices device is trusted + if (deviceId === alicesDevice.device_id) { + return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); + } + // the dehydrated device is trusted + if (deviceId === alicesDehydratedDevice.device_id) { + return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); + } + // alices mobile device is not + if (deviceId === alicesMobileDevice.device_id) { + return new DeviceVerificationStatus({}); + } + return null; + }); + + const { queryByTestId } = render(getComponent()); + + await act(async () => { + await flushPromises(); + }); + + expect(queryByTestId(`device-tile-${alicesDevice.device_id}`)).toBeTruthy(); + expect(queryByTestId(`device-tile-${alicesMobileDevice.device_id}`)).toBeTruthy(); + // the dehydrated device should be hidden + expect(queryByTestId(`device-tile-${alicesDehydratedDevice.device_id}`)).toBeFalsy(); + }); + + it("Shows an unverified dehydrated device", async () => { + mockClient.getDevices.mockResolvedValue({ + devices: [alicesDevice, alicesMobileDevice, alicesDehydratedDevice], + }); + mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); + + const devicesMap = new Map([ + [alicesDeviceObj.deviceId, alicesDeviceObj], + [alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj], + [alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj], + ]); + const userDeviceMap = new Map>([[aliceId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { + // alices device is trusted + if (deviceId === alicesDevice.device_id) { + return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); + } + // the dehydrated device is not + if (deviceId === alicesDehydratedDevice.device_id) { + return new DeviceVerificationStatus({ crossSigningVerified: false, localVerified: false }); + } + // alices mobile device is not + if (deviceId === alicesMobileDevice.device_id) { + return new DeviceVerificationStatus({}); + } + return null; + }); + + const { queryByTestId } = render(getComponent()); + + await act(async () => { + await flushPromises(); + }); + + expect(queryByTestId(`device-tile-${alicesDevice.device_id}`)).toBeTruthy(); + expect(queryByTestId(`device-tile-${alicesMobileDevice.device_id}`)).toBeTruthy(); + // the dehydrated device should be shown since it is unverified + expect(queryByTestId(`device-tile-${alicesDehydratedDevice.device_id}`)).toBeTruthy(); + }); + + it("Shows the dehydrated devices if there are multiple", async () => { + mockClient.getDevices.mockResolvedValue({ + devices: [alicesDevice, alicesMobileDevice, alicesDehydratedDevice, alicesOtherDehydratedDevice], + }); + mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); + + const devicesMap = new Map([ + [alicesDeviceObj.deviceId, alicesDeviceObj], + [alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj], + [alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj], + [alicesOtherDehydratedDeviceObj.deviceId, alicesOtherDehydratedDeviceObj], + ]); + const userDeviceMap = new Map>([[aliceId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { + // alices device is trusted + if (deviceId === alicesDevice.device_id) { + return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); + } + // one dehydrated device is trusted + if (deviceId === alicesDehydratedDevice.device_id) { + return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); + } + // the other is not + if (deviceId === alicesOtherDehydratedDevice.device_id) { + return new DeviceVerificationStatus({ crossSigningVerified: false, localVerified: false }); + } + // alices mobile device is not + if (deviceId === alicesMobileDevice.device_id) { + return new DeviceVerificationStatus({}); + } + return null; + }); + + const { queryByTestId } = render(getComponent()); + + await act(async () => { + await flushPromises(); + }); + + expect(queryByTestId(`device-tile-${alicesDevice.device_id}`)).toBeTruthy(); + expect(queryByTestId(`device-tile-${alicesMobileDevice.device_id}`)).toBeTruthy(); + // both the dehydrated devices should be shown, since there are multiple + expect(queryByTestId(`device-tile-${alicesDehydratedDevice.device_id}`)).toBeTruthy(); + expect(queryByTestId(`device-tile-${alicesOtherDehydratedDevice.device_id}`)).toBeTruthy(); + }); + }); + describe("Sign out", () => { it("Signs out of current device", async () => { const modalSpy = jest.spyOn(Modal, "createDialog"); diff --git a/test/stores/SetupEncryptionStore-test.ts b/test/stores/SetupEncryptionStore-test.ts index 26d12f677d..d220d7db9f 100644 --- a/test/stores/SetupEncryptionStore-test.ts +++ b/test/stores/SetupEncryptionStore-test.ts @@ -40,9 +40,12 @@ describe("SetupEncryptionStore", () => { client = mocked(stubClient()); mockCrypto = { bootstrapCrossSigning: jest.fn(), + getCrossSigningKeyId: jest.fn(), getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), getUserDeviceInfo: jest.fn(), getDeviceVerificationStatus: jest.fn(), + isDehydrationSupported: jest.fn().mockResolvedValue(false), + startDehydration: jest.fn(), } as unknown as Mocked; client.getCrypto.mockReturnValue(mockCrypto); @@ -101,7 +104,7 @@ describe("SetupEncryptionStore", () => { expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(true); }); - it("should ignore the dehydrated device", async () => { + it("should ignore the MSC2697 dehydrated device", async () => { mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); client.getDehydratedDevice.mockResolvedValue({ device_id: "dehydrated" } as IDehydratedDevice); @@ -123,6 +126,27 @@ describe("SetupEncryptionStore", () => { expect(mockCrypto.getDeviceVerificationStatus).not.toHaveBeenCalled(); }); + it("should ignore the MSC3812 dehydrated device", async () => { + mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); + + const fakeDevice = new Device({ + deviceId: "dehydrated", + userId: "", + algorithms: [], + keys: new Map([["curve25519:dehydrated", "identityKey"]]), + dehydrated: true, + }); + mockCrypto.getUserDeviceInfo.mockResolvedValue( + new Map([[client.getSafeUserId(), new Map([[fakeDevice.deviceId, fakeDevice]])]]), + ); + + setupEncryptionStore.start(); + await emitPromise(setupEncryptionStore, "update"); + + expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(false); + expect(mockCrypto.getDeviceVerificationStatus).not.toHaveBeenCalled(); + }); + it("should correctly handle getUserDeviceInfo() returning an empty map", async () => { mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); mockCrypto.getUserDeviceInfo.mockResolvedValue(new Map()); @@ -133,6 +157,39 @@ describe("SetupEncryptionStore", () => { }); }); + describe("usePassPhrase", () => { + it("should use dehydration when enabled", async () => { + // mocks for cross-signing and secret storage + mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); + mockCrypto.getUserDeviceInfo.mockResolvedValue(new Map()); + mockCrypto.getDeviceVerificationStatus.mockResolvedValue( + new DeviceVerificationStatus({ signedByOwner: true }), + ); + mocked(accessSecretStorage).mockImplementation(async (func?: () => Promise) => { + await func!(); + }); + + // mocks for dehydration + mockCrypto.isDehydrationSupported.mockResolvedValue(true); + const dehydrationPromise = new Promise((resolve) => { + // Dehydration gets processed in the background, after + // `usePassPhrase` returns, so we need to use a promise to make + // sure that it is called. + mockCrypto.startDehydration.mockImplementation(async () => { + resolve(); + }); + }); + client.waitForClientWellKnown.mockResolvedValue({ "org.matrix.msc3814": true }); + + setupEncryptionStore.start(); + await emitPromise(setupEncryptionStore, "update"); + + await setupEncryptionStore.usePassPhrase(); + + await dehydrationPromise; + }); + }); + it("resetConfirm should work with a cached account password", async () => { const makeRequest = jest.fn(); mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: IBootstrapCrossSigningOpts) => { diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 7a88ea3e03..b124af4ad1 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -115,6 +115,8 @@ export function createTestClient(): MatrixClient { credentials: { userId: "@userId:matrix.org" }, bootstrapCrossSigning: jest.fn(), hasSecretStorageKey: jest.fn(), + getKeyBackupVersion: jest.fn(), + checkOwnCrossSigningTrust: jest.fn(), secretStorage: { get: jest.fn(),