Add support for device dehydration v2 (#12316)

* rehydrate/dehydrate device if configured in well-known

* add handling for dehydrated devices

* some fixes

* schedule dehydration

* improve display of own dehydrated device

* created dehydrated device when creating or resetting SSSS

* some UI tweaks

* reorder strings

* lint

* remove statement for testing

* add playwright test

* lint and fix broken test

* update to new dehydration API

* some fixes from review

* try to fix test error

* remove unneeded debug line

* apply changes from review

* add Jest tests

* fix typo

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* don't need Object.assign

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Hubert Chathi 2024-04-15 11:47:15 -04:00 committed by GitHub
parent 6392759bec
commit 31373399f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 823 additions and 8 deletions

View 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();
});
});

View file

@ -0,0 +1 @@
A synapse configured with device dehydration v2 enabled

View file

@ -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

View file

@ -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

View file

@ -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<IProp
},
});
}
await initialiseDehydration(true);
this.setState({
phase: Phase.Stored,

View file

@ -290,6 +290,20 @@ function DevicesSection({
let expandHideCaption;
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) {
for (let i = 0; i < devices.length; ++i) {
const device = devices[i];
@ -302,7 +316,13 @@ function DevicesSection({
const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.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 {
unverifiedDevices.push(device);
}
@ -311,6 +331,10 @@ function DevicesSection({
expandHideCaption = _t("user_info|hide_verified_sessions");
expandIconClasses += " mx_E2EIcon_verified";
} else {
if (dehydratedDeviceId) {
devices = devices.filter((device) => 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(<div>{_t("user_info|dehydrated_device_enabled")}</div>);
}
}
return (

View file

@ -77,6 +77,7 @@ export enum OwnDevicesError {
}
export type DevicesState = {
devices: DevicesDictionary;
dehydratedDeviceId?: string;
pushers: IPusher[];
localNotificationSettings: Map<string, LocalNotificationSettings>;
currentDeviceId: string;
@ -97,6 +98,7 @@ export const useOwnDevices = (): DevicesState => {
const userId = matrixClient.getSafeUserId();
const [devices, setDevices] = useState<DevicesState["devices"]>({});
const [dehydratedDeviceId, setDehydratedDeviceId] = useState<DevicesState["dehydratedDeviceId"]>(undefined);
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
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,

View file

@ -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 (
<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> {
private onUnignoreClicked = (): void => {
this.props.onUnignored(this.props.userId);
@ -279,6 +297,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
const secureBackup = (
<SettingsSubsection heading={_t("common|secure_backup")}>
<SecureBackupPanel />
<DehydratedDeviceStatus />
</SettingsSubsection>
);

View file

@ -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;

View file

@ -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.",

View file

@ -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<Device> =
(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<void> => {
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) {

View 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);
}
}

View file

@ -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(),
});

View file

@ -412,6 +412,183 @@ describe("<UserInfo />", () => {
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<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", () => {

View file

@ -26,6 +26,7 @@ import {
mockClientMethodsDevice,
mockPlatformPeg,
} from "../../../../../test-utils";
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
describe("<SecurityUserSettingsTab />", () => {
const defaultProps = {
@ -44,9 +45,14 @@ describe("<SecurityUserSettingsTab />", () => {
getKeyBackupVersion: jest.fn(),
});
const sdkContext = new SdkContextClass();
sdkContext.client = mockClient;
const getComponent = () => (
<MatrixClientContext.Provider value={mockClient}>
<SecurityUserSettingsTab {...defaultProps} />
<SDKContext.Provider value={sdkContext}>
<SecurityUserSettingsTab {...defaultProps} />
</SDKContext.Provider>
</MatrixClientContext.Provider>
);

View file

@ -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> = {}): 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 />", () => {
const aliceId = "@alice:server.org";
const deviceId = "alices_device";
@ -69,10 +82,12 @@ describe("<SessionManagerTab />", () => {
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("<SessionManagerTab />", () => {
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("<SessionManagerTab />", () => {
const mockCrypto = mocked({
getDeviceVerificationStatus: jest.fn(),
getUserDeviceInfo: jest.fn(),
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
} as unknown as CryptoApi);
@ -627,6 +657,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", () => {
it("Signs out of current device", async () => {
const modalSpy = jest.spyOn(Modal, "createDialog");

View file

@ -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<CryptoApi>;
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<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 () => {
const makeRequest = jest.fn();
mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: IBootstrapCrossSigningOpts) => {

View file

@ -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(),