mirror of
https://github.com/element-hq/element-web
synced 2024-11-25 10:45:51 +03:00
Merge branch 'develop' into florianduros/tooltip-update
This commit is contained in:
commit
e53b175a6b
18 changed files with 823 additions and 8 deletions
114
playwright/e2e/crypto/dehydration.spec.ts
Normal file
114
playwright/e2e/crypto/dehydration.spec.ts
Normal file
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
A synapse configured with device dehydration v2 enabled
|
|
@ -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
|
|
@ -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
|
|
@ -48,6 +48,7 @@ import InteractiveAuthDialog from "../../../../components/views/dialogs/Interact
|
||||||
import { IValidationResult } from "../../../../components/views/elements/Validation";
|
import { IValidationResult } from "../../../../components/views/elements/Validation";
|
||||||
import { Icon as CheckmarkIcon } from "../../../../../res/img/element-icons/check.svg";
|
import { Icon as CheckmarkIcon } from "../../../../../res/img/element-icons/check.svg";
|
||||||
import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField";
|
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!
|
// I made a mistake while converting this and it has to be fixed!
|
||||||
enum Phase {
|
enum Phase {
|
||||||
|
@ -397,6 +398,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await initialiseDehydration(true);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
phase: Phase.Stored,
|
phase: Phase.Stored,
|
||||||
|
|
|
@ -290,6 +290,20 @@ function DevicesSection({
|
||||||
let expandHideCaption;
|
let expandHideCaption;
|
||||||
let expandIconClasses = "mx_E2EIcon";
|
let expandIconClasses = "mx_E2EIcon";
|
||||||
|
|
||||||
|
const dehydratedDeviceIds: string[] = [];
|
||||||
|
for (const device of devices) {
|
||||||
|
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.
|
||||||
|
const dehydratedDeviceId: string | undefined = dehydratedDeviceIds.length == 1 ? dehydratedDeviceIds[0] : undefined;
|
||||||
|
let dehydratedDeviceInExpandSection = false;
|
||||||
|
|
||||||
if (isUserVerified) {
|
if (isUserVerified) {
|
||||||
for (let i = 0; i < devices.length; ++i) {
|
for (let i = 0; i < devices.length; ++i) {
|
||||||
const device = devices[i];
|
const device = devices[i];
|
||||||
|
@ -302,7 +316,13 @@ function DevicesSection({
|
||||||
const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified());
|
const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified());
|
||||||
|
|
||||||
if (isVerified) {
|
if (isVerified) {
|
||||||
expandSectionDevices.push(device);
|
// don't show dehydrated device as a normal device, if it's
|
||||||
|
// verified
|
||||||
|
if (device.deviceId === dehydratedDeviceId) {
|
||||||
|
dehydratedDeviceInExpandSection = true;
|
||||||
|
} else {
|
||||||
|
expandSectionDevices.push(device);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
unverifiedDevices.push(device);
|
unverifiedDevices.push(device);
|
||||||
}
|
}
|
||||||
|
@ -311,6 +331,10 @@ function DevicesSection({
|
||||||
expandHideCaption = _t("user_info|hide_verified_sessions");
|
expandHideCaption = _t("user_info|hide_verified_sessions");
|
||||||
expandIconClasses += " mx_E2EIcon_verified";
|
expandIconClasses += " mx_E2EIcon_verified";
|
||||||
} else {
|
} else {
|
||||||
|
if (dehydratedDeviceId) {
|
||||||
|
devices = devices.filter((device) => device.deviceId !== dehydratedDeviceId);
|
||||||
|
dehydratedDeviceInExpandSection = true;
|
||||||
|
}
|
||||||
expandSectionDevices = devices;
|
expandSectionDevices = devices;
|
||||||
expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length });
|
expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length });
|
||||||
expandHideCaption = _t("user_info|hide_sessions");
|
expandHideCaption = _t("user_info|hide_sessions");
|
||||||
|
@ -347,6 +371,9 @@ function DevicesSection({
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (dehydratedDeviceInExpandSection) {
|
||||||
|
deviceList.push(<div>{_t("user_info|dehydrated_device_enabled")}</div>);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -77,6 +77,7 @@ export enum OwnDevicesError {
|
||||||
}
|
}
|
||||||
export type DevicesState = {
|
export type DevicesState = {
|
||||||
devices: DevicesDictionary;
|
devices: DevicesDictionary;
|
||||||
|
dehydratedDeviceId?: string;
|
||||||
pushers: IPusher[];
|
pushers: IPusher[];
|
||||||
localNotificationSettings: Map<string, LocalNotificationSettings>;
|
localNotificationSettings: Map<string, LocalNotificationSettings>;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
|
@ -97,6 +98,7 @@ export const useOwnDevices = (): DevicesState => {
|
||||||
const userId = matrixClient.getSafeUserId();
|
const userId = matrixClient.getSafeUserId();
|
||||||
|
|
||||||
const [devices, setDevices] = useState<DevicesState["devices"]>({});
|
const [devices, setDevices] = useState<DevicesState["devices"]>({});
|
||||||
|
const [dehydratedDeviceId, setDehydratedDeviceId] = useState<DevicesState["dehydratedDeviceId"]>(undefined);
|
||||||
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
|
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
|
||||||
const [localNotificationSettings, setLocalNotificationSettings] = useState<
|
const [localNotificationSettings, setLocalNotificationSettings] = useState<
|
||||||
DevicesState["localNotificationSettings"]
|
DevicesState["localNotificationSettings"]
|
||||||
|
@ -131,6 +133,21 @@ export const useOwnDevices = (): DevicesState => {
|
||||||
});
|
});
|
||||||
setLocalNotificationSettings(notificationSettings);
|
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);
|
setIsLoadingDeviceList(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as MatrixError).httpStatus == 404) {
|
if ((error as MatrixError).httpStatus == 404) {
|
||||||
|
@ -228,6 +245,7 @@ export const useOwnDevices = (): DevicesState => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
devices,
|
devices,
|
||||||
|
dehydratedDeviceId,
|
||||||
pushers,
|
pushers,
|
||||||
localNotificationSettings,
|
localNotificationSettings,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
|
|
|
@ -42,6 +42,7 @@ import type { IServerVersions } from "matrix-js-sdk/src/matrix";
|
||||||
import SettingsTab from "../SettingsTab";
|
import SettingsTab from "../SettingsTab";
|
||||||
import { SettingsSection } from "../../shared/SettingsSection";
|
import { SettingsSection } from "../../shared/SettingsSection";
|
||||||
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
|
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
|
||||||
|
import { useOwnDevices } from "../../devices/useOwnDevices";
|
||||||
|
|
||||||
interface IIgnoredUserProps {
|
interface IIgnoredUserProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
@ -49,6 +50,23 @@ interface IIgnoredUserProps {
|
||||||
inProgress: boolean;
|
inProgress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DehydratedDeviceStatus: React.FC = () => {
|
||||||
|
const { dehydratedDeviceId } = useOwnDevices();
|
||||||
|
|
||||||
|
if (dehydratedDeviceId) {
|
||||||
|
return (
|
||||||
|
<div className="mx_SettingsSubsection_content">
|
||||||
|
<div className="mx_SettingsFlag_label">{_t("settings|security|dehydrated_device_enabled")}</div>
|
||||||
|
<div className="mx_SettingsSubsection_text">
|
||||||
|
{_t("settings|security|dehydrated_device_description")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export class IgnoredUser extends React.Component<IIgnoredUserProps> {
|
export class IgnoredUser extends React.Component<IIgnoredUserProps> {
|
||||||
private onUnignoreClicked = (): void => {
|
private onUnignoreClicked = (): void => {
|
||||||
this.props.onUnignored(this.props.userId);
|
this.props.onUnignored(this.props.userId);
|
||||||
|
@ -279,6 +297,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||||
const secureBackup = (
|
const secureBackup = (
|
||||||
<SettingsSubsection heading={_t("common|secure_backup")}>
|
<SettingsSubsection heading={_t("common|secure_backup")}>
|
||||||
<SecureBackupPanel />
|
<SecureBackupPanel />
|
||||||
|
<DehydratedDeviceStatus />
|
||||||
</SettingsSubsection>
|
</SettingsSubsection>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -150,6 +150,7 @@ const useSignOut = (
|
||||||
const SessionManagerTab: React.FC = () => {
|
const SessionManagerTab: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
devices,
|
devices,
|
||||||
|
dehydratedDeviceId,
|
||||||
pushers,
|
pushers,
|
||||||
localNotificationSettings,
|
localNotificationSettings,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
|
@ -208,6 +209,9 @@ const SessionManagerTab: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
|
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
|
||||||
|
if (dehydratedDeviceId && otherDevices[dehydratedDeviceId]?.isVerified) {
|
||||||
|
delete otherDevices[dehydratedDeviceId];
|
||||||
|
}
|
||||||
const otherSessionsCount = Object.keys(otherDevices).length;
|
const otherSessionsCount = Object.keys(otherDevices).length;
|
||||||
const shouldShowOtherSessions = otherSessionsCount > 0;
|
const shouldShowOtherSessions = otherSessionsCount > 0;
|
||||||
|
|
||||||
|
|
|
@ -2684,6 +2684,8 @@
|
||||||
"cross_signing_self_signing_private_key": "Self signing private key:",
|
"cross_signing_self_signing_private_key": "Self signing private key:",
|
||||||
"cross_signing_user_signing_private_key": "User signing private key:",
|
"cross_signing_user_signing_private_key": "User signing private key:",
|
||||||
"cryptography_section": "Cryptography",
|
"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": "Delete Backup",
|
||||||
"delete_backup_confirm_description": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
|
"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.",
|
"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_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_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?",
|
"deactivate_confirm_title": "Deactivate user?",
|
||||||
|
"dehydrated_device_enabled": "Offline device enabled",
|
||||||
"demote_button": "Demote",
|
"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_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.",
|
"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.",
|
||||||
|
|
|
@ -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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 { _t } from "../languageHandler";
|
||||||
import { SdkContextClass } from "../contexts/SDKContext";
|
import { SdkContextClass } from "../contexts/SDKContext";
|
||||||
import { asyncSome } from "../utils/arrays";
|
import { asyncSome } from "../utils/arrays";
|
||||||
|
import { initialiseDehydration } from "../utils/device/dehydration";
|
||||||
|
|
||||||
export enum Phase {
|
export enum Phase {
|
||||||
Loading = 0,
|
Loading = 0,
|
||||||
|
@ -111,8 +112,12 @@ export class SetupEncryptionStore extends EventEmitter {
|
||||||
const userDevices: Iterable<Device> =
|
const userDevices: Iterable<Device> =
|
||||||
(await crypto.getUserDeviceInfo([ownUserId])).get(ownUserId)?.values() ?? [];
|
(await crypto.getUserDeviceInfo([ownUserId])).get(ownUserId)?.values() ?? [];
|
||||||
this.hasDevicesToVerifyAgainst = await asyncSome(userDevices, async (device) => {
|
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 (dehydratedDevice && device.deviceId == dehydratedDevice?.device_id) return false;
|
||||||
|
if (device.dehydrated) return false;
|
||||||
|
|
||||||
// ignore devices without an identity key
|
// ignore devices without an identity key
|
||||||
if (!device.getIdentityKey()) return false;
|
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) => {
|
await new Promise((resolve: (value?: unknown) => void, reject: (reason?: any) => void) => {
|
||||||
accessSecretStorage(async (): Promise<void> => {
|
accessSecretStorage(async (): Promise<void> => {
|
||||||
await cli.checkOwnCrossSigningTrust();
|
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();
|
resolve();
|
||||||
|
|
||||||
|
await initialiseDehydration();
|
||||||
|
|
||||||
if (backupInfo) {
|
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);
|
await cli.restoreKeyBackupWithSecretStorage(backupInfo);
|
||||||
}
|
}
|
||||||
}).catch(reject);
|
}).catch(reject);
|
||||||
|
@ -255,6 +266,9 @@ export class SetupEncryptionStore extends EventEmitter {
|
||||||
},
|
},
|
||||||
setupNewCrossSigning: true,
|
setupNewCrossSigning: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await initialiseDehydration(true);
|
||||||
|
|
||||||
this.phase = Phase.Finished;
|
this.phase = Phase.Finished;
|
||||||
}, true);
|
}, true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
57
src/utils/device/dehydration.ts
Normal file
57
src/utils/device/dehydration.ts
Normal file
|
@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
const crypto = MatrixClientPeg.safeGet().getCrypto();
|
||||||
|
if (await deviceDehydrationEnabled(crypto)) {
|
||||||
|
logger.log("Device dehydration enabled");
|
||||||
|
await crypto!.startDehydration(createNewKey);
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,6 +50,7 @@ describe("CreateSecretStorageDialog", () => {
|
||||||
mockCrypto = mocked(mockClient.getCrypto()!);
|
mockCrypto = mocked(mockClient.getCrypto()!);
|
||||||
Object.assign(mockCrypto, {
|
Object.assign(mockCrypto, {
|
||||||
isKeyBackupTrusted: jest.fn(),
|
isKeyBackupTrusted: jest.fn(),
|
||||||
|
isDehydrationSupported: jest.fn(() => false),
|
||||||
bootstrapCrossSigning: jest.fn(),
|
bootstrapCrossSigning: jest.fn(),
|
||||||
bootstrapSecretStorage: jest.fn(),
|
bootstrapSecretStorage: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
|
@ -407,6 +407,183 @@ describe("<UserInfo />", () => {
|
||||||
await waitFor(() => expect(screen.getByRole("button", { name: "Verify" })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole("button", { name: "Verify" })).toBeInTheDocument());
|
||||||
expect(container).toMatchSnapshot();
|
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<string, Device>([
|
||||||
|
[device1.deviceId, device1],
|
||||||
|
[device2.deviceId, device2],
|
||||||
|
]);
|
||||||
|
const userDeviceMap = new Map<string, Map<string, Device>>([[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<string, Device>([
|
||||||
|
[device1.deviceId, device1],
|
||||||
|
[device2.deviceId, device2],
|
||||||
|
]);
|
||||||
|
const userDeviceMap = new Map<string, Map<string, Device>>([[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<string, Device>([
|
||||||
|
[device1.deviceId, device1],
|
||||||
|
[device2.deviceId, device2],
|
||||||
|
]);
|
||||||
|
const userDeviceMap = new Map<string, Map<string, Device>>([[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<string, Device>([
|
||||||
|
[device1.deviceId, device1],
|
||||||
|
[device2.deviceId, device2],
|
||||||
|
]);
|
||||||
|
const userDeviceMap = new Map<string, Map<string, Device>>([[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", () => {
|
describe("with an encrypted room", () => {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import {
|
||||||
mockClientMethodsDevice,
|
mockClientMethodsDevice,
|
||||||
mockPlatformPeg,
|
mockPlatformPeg,
|
||||||
} from "../../../../../test-utils";
|
} from "../../../../../test-utils";
|
||||||
|
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
||||||
|
|
||||||
describe("<SecurityUserSettingsTab />", () => {
|
describe("<SecurityUserSettingsTab />", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
@ -44,9 +45,14 @@ describe("<SecurityUserSettingsTab />", () => {
|
||||||
getKeyBackupVersion: jest.fn(),
|
getKeyBackupVersion: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sdkContext = new SdkContextClass();
|
||||||
|
sdkContext.client = mockClient;
|
||||||
|
|
||||||
const getComponent = () => (
|
const getComponent = () => (
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
<SecurityUserSettingsTab {...defaultProps} />
|
<SDKContext.Provider value={sdkContext}>
|
||||||
|
<SecurityUserSettingsTab {...defaultProps} />
|
||||||
|
</SDKContext.Provider>
|
||||||
</MatrixClientContext.Provider>
|
</MatrixClientContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
|
||||||
import { defer, sleep } from "matrix-js-sdk/src/utils";
|
import { defer, sleep } from "matrix-js-sdk/src/utils";
|
||||||
import {
|
import {
|
||||||
ClientEvent,
|
ClientEvent,
|
||||||
|
Device,
|
||||||
IMyDevice,
|
IMyDevice,
|
||||||
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
|
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
|
@ -60,6 +61,18 @@ mockPlatformPeg();
|
||||||
// to restore later
|
// to restore later
|
||||||
const realWindowLocation = window.location;
|
const realWindowLocation = window.location;
|
||||||
|
|
||||||
|
function deviceToDeviceObj(userId: string, device: IMyDevice, opts: Partial<Device> = {}): Device {
|
||||||
|
const deviceOpts: Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device> = {
|
||||||
|
deviceId: device.device_id,
|
||||||
|
userId,
|
||||||
|
algorithms: [],
|
||||||
|
displayName: device.display_name,
|
||||||
|
keys: new Map(),
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
return new Device(deviceOpts);
|
||||||
|
}
|
||||||
|
|
||||||
describe("<SessionManagerTab />", () => {
|
describe("<SessionManagerTab />", () => {
|
||||||
const aliceId = "@alice:server.org";
|
const aliceId = "@alice:server.org";
|
||||||
const deviceId = "alices_device";
|
const deviceId = "alices_device";
|
||||||
|
@ -68,10 +81,12 @@ describe("<SessionManagerTab />", () => {
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
display_name: "Alices device",
|
display_name: "Alices device",
|
||||||
};
|
};
|
||||||
|
const alicesDeviceObj = deviceToDeviceObj(aliceId, alicesDevice);
|
||||||
const alicesMobileDevice = {
|
const alicesMobileDevice = {
|
||||||
device_id: "alices_mobile_device",
|
device_id: "alices_mobile_device",
|
||||||
last_seen_ts: Date.now(),
|
last_seen_ts: Date.now(),
|
||||||
};
|
};
|
||||||
|
const alicesMobileDeviceObj = deviceToDeviceObj(aliceId, alicesMobileDevice);
|
||||||
|
|
||||||
const alicesOlderMobileDevice = {
|
const alicesOlderMobileDevice = {
|
||||||
device_id: "alices_older_mobile_device",
|
device_id: "alices_older_mobile_device",
|
||||||
|
@ -83,6 +98,20 @@ describe("<SessionManagerTab />", () => {
|
||||||
last_seen_ts: Date.now() - (INACTIVE_DEVICE_AGE_MS + 1000),
|
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 = {
|
const mockVerificationRequest = {
|
||||||
cancel: jest.fn(),
|
cancel: jest.fn(),
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
|
@ -90,6 +119,7 @@ describe("<SessionManagerTab />", () => {
|
||||||
|
|
||||||
const mockCrypto = mocked({
|
const mockCrypto = mocked({
|
||||||
getDeviceVerificationStatus: jest.fn(),
|
getDeviceVerificationStatus: jest.fn(),
|
||||||
|
getUserDeviceInfo: jest.fn(),
|
||||||
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
|
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
|
||||||
} as unknown as CryptoApi);
|
} as unknown as CryptoApi);
|
||||||
|
|
||||||
|
@ -624,6 +654,137 @@ describe("<SessionManagerTab />", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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<string, Device>([
|
||||||
|
[alicesDeviceObj.deviceId, alicesDeviceObj],
|
||||||
|
[alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj],
|
||||||
|
[alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj],
|
||||||
|
]);
|
||||||
|
const userDeviceMap = new Map<string, Map<string, Device>>([[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<string, Device>([
|
||||||
|
[alicesDeviceObj.deviceId, alicesDeviceObj],
|
||||||
|
[alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj],
|
||||||
|
[alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj],
|
||||||
|
]);
|
||||||
|
const userDeviceMap = new Map<string, Map<string, Device>>([[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<string, Device>([
|
||||||
|
[alicesDeviceObj.deviceId, alicesDeviceObj],
|
||||||
|
[alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj],
|
||||||
|
[alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj],
|
||||||
|
[alicesOtherDehydratedDeviceObj.deviceId, alicesOtherDehydratedDeviceObj],
|
||||||
|
]);
|
||||||
|
const userDeviceMap = new Map<string, Map<string, Device>>([[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", () => {
|
describe("Sign out", () => {
|
||||||
it("Signs out of current device", async () => {
|
it("Signs out of current device", async () => {
|
||||||
const modalSpy = jest.spyOn(Modal, "createDialog");
|
const modalSpy = jest.spyOn(Modal, "createDialog");
|
||||||
|
|
|
@ -40,9 +40,12 @@ describe("SetupEncryptionStore", () => {
|
||||||
client = mocked(stubClient());
|
client = mocked(stubClient());
|
||||||
mockCrypto = {
|
mockCrypto = {
|
||||||
bootstrapCrossSigning: jest.fn(),
|
bootstrapCrossSigning: jest.fn(),
|
||||||
|
getCrossSigningKeyId: jest.fn(),
|
||||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||||
getUserDeviceInfo: jest.fn(),
|
getUserDeviceInfo: jest.fn(),
|
||||||
getDeviceVerificationStatus: jest.fn(),
|
getDeviceVerificationStatus: jest.fn(),
|
||||||
|
isDehydrationSupported: jest.fn().mockResolvedValue(false),
|
||||||
|
startDehydration: jest.fn(),
|
||||||
} as unknown as Mocked<CryptoApi>;
|
} as unknown as Mocked<CryptoApi>;
|
||||||
client.getCrypto.mockReturnValue(mockCrypto);
|
client.getCrypto.mockReturnValue(mockCrypto);
|
||||||
|
|
||||||
|
@ -101,7 +104,7 @@ describe("SetupEncryptionStore", () => {
|
||||||
expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(true);
|
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 });
|
mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 });
|
||||||
|
|
||||||
client.getDehydratedDevice.mockResolvedValue({ device_id: "dehydrated" } as IDehydratedDevice);
|
client.getDehydratedDevice.mockResolvedValue({ device_id: "dehydrated" } as IDehydratedDevice);
|
||||||
|
@ -123,6 +126,27 @@ describe("SetupEncryptionStore", () => {
|
||||||
expect(mockCrypto.getDeviceVerificationStatus).not.toHaveBeenCalled();
|
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 () => {
|
it("should correctly handle getUserDeviceInfo() returning an empty map", async () => {
|
||||||
mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 });
|
mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 });
|
||||||
mockCrypto.getUserDeviceInfo.mockResolvedValue(new Map());
|
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<void>) => {
|
||||||
|
await func!();
|
||||||
|
});
|
||||||
|
|
||||||
|
// mocks for dehydration
|
||||||
|
mockCrypto.isDehydrationSupported.mockResolvedValue(true);
|
||||||
|
const dehydrationPromise = new Promise<void>((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 () => {
|
it("resetConfirm should work with a cached account password", async () => {
|
||||||
const makeRequest = jest.fn();
|
const makeRequest = jest.fn();
|
||||||
mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: IBootstrapCrossSigningOpts) => {
|
mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: IBootstrapCrossSigningOpts) => {
|
||||||
|
|
|
@ -115,6 +115,8 @@ export function createTestClient(): MatrixClient {
|
||||||
credentials: { userId: "@userId:matrix.org" },
|
credentials: { userId: "@userId:matrix.org" },
|
||||||
bootstrapCrossSigning: jest.fn(),
|
bootstrapCrossSigning: jest.fn(),
|
||||||
hasSecretStorageKey: jest.fn(),
|
hasSecretStorageKey: jest.fn(),
|
||||||
|
getKeyBackupVersion: jest.fn(),
|
||||||
|
checkOwnCrossSigningTrust: jest.fn(),
|
||||||
|
|
||||||
secretStorage: {
|
secretStorage: {
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
|
|
Loading…
Reference in a new issue