Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Add support for device dehydration v2 #12316

Merged
merged 22 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +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 { initializeDehydration } from "../../../../utils/device/dehydration";
import { initialiseDehydration } from "../../../../utils/device/dehydration";

// I made a mistake while converting this and it has to be fixed!
enum Phase {
Expand Down Expand Up @@ -398,7 +398,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
},
});
}
initializeDehydration(true);
await initialiseDehydration(true);

this.setState({
phase: Phase.Stored,
Expand Down
6 changes: 6 additions & 0 deletions src/components/views/right_panel/UserInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ function DevicesSection({
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;
richvdh marked this conversation as resolved.
Show resolved Hide resolved
let dehydratedDeviceInExpandSection = false;

Expand Down Expand Up @@ -328,6 +333,7 @@ function DevicesSection({
} else {
if (dehydratedDeviceId) {
devices = devices.filter((device) => device.deviceId !== dehydratedDeviceId);
dehydratedDeviceInExpandSection = true;
}
expandSectionDevices = devices;
expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length });
Expand Down
6 changes: 5 additions & 1 deletion src/components/views/settings/devices/useOwnDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,14 @@ export const useOwnDevices = (): DevicesState => {
const dehydratedDeviceIds: string[] = [];
for (const device of userDevices?.values() ?? []) {
if (device.dehydrated) {
logger.debug("Found dehydrated device", device.deviceId);
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, how should we handle >1 dehydrated devices?


setIsLoadingDeviceList(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ const SessionManagerTab: React.FC = () => {
};

const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
if (dehydratedDeviceId) {
if (dehydratedDeviceId && otherDevices[dehydratedDeviceId]?.isVerified) {
delete otherDevices[dehydratedDeviceId];
}
const otherSessionsCount = Object.keys(otherDevices).length;
Expand Down
27 changes: 16 additions & 11 deletions src/stores/SetupEncryptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import InteractiveAuthDialog from "../components/views/dialogs/InteractiveAuthDi
import { _t } from "../languageHandler";
import { SdkContextClass } from "../contexts/SDKContext";
import { asyncSome } from "../utils/arrays";
import { initializeDehydration } from "../utils/device/dehydration";
import { initialiseDehydration } from "../utils/device/dehydration";

export enum Phase {
Loading = 0,
Expand Down Expand Up @@ -112,11 +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;
}
if (device.dehydrated) return false;

// ignore devices without an identity key
if (!device.getIdentityKey()) return false;
Expand Down Expand Up @@ -148,15 +149,19 @@ 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);
}

await initializeDehydration();
}).catch(reject);
});

Expand Down Expand Up @@ -262,7 +267,7 @@ export class SetupEncryptionStore extends EventEmitter {
setupNewCrossSigning: true,
});

await initializeDehydration(true);
await initialiseDehydration(true);

this.phase = Phase.Finished;
}, true);
Expand Down
46 changes: 23 additions & 23 deletions src/utils/device/dehydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,43 @@ limitations under the License.
*/

import { logger } from "matrix-js-sdk/src/logger";
import { CryptoApi } from "matrix-js-sdk/src/matrix";

import { MatrixClientPeg } from "../../MatrixClientPeg";

// the interval between creating dehydrated devices
const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000;

// check if device dehydration is enabled
export async function deviceDehydrationEnabled(): Promise<boolean> {
const crypto = MatrixClientPeg.safeGet().getCrypto();
/**
* 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 enabled by setting a flag in the .well-known file.
uhoreg marked this conversation as resolved.
Show resolved Hide resolved
*/
async function deviceDehydrationEnabled(crypto: CryptoApi | undefined): Promise<boolean> {
if (!crypto) {
return false;
}
if (!(await crypto.isDehydrationSupported())) {
return false;
}
if (await crypto.isDehydrationKeyStored()) {
return true;
}
const wellknown = await MatrixClientPeg.safeGet().waitForClientWellKnown();
return !!wellknown?.["org.matrix.msc3814"];
}

// if dehydration is enabled, rehydrate a device (if available) and create
// a new dehydrated device
export async function initializeDehydration(reset?: boolean): Promise<void> {
/**
* 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 (crypto && (await deviceDehydrationEnabled())) {
if (await deviceDehydrationEnabled(crypto)) {
logger.log("Device dehydration enabled");
if (reset) {
await crypto.resetDehydrationKey();
} else {
try {
await crypto.rehydrateDeviceIfAvailable();
} catch (e) {
logger.error("Error rehydrating device:", e);
}
}
await crypto.scheduleDeviceDehydration(DEHYDRATION_INTERVAL);
await crypto!.startDehydration(createNewKey);
}
}
177 changes: 177 additions & 0 deletions test/components/views/right_panel/UserInfo-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
mockClientMethodsDevice,
mockPlatformPeg,
} from "../../../../../test-utils";
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";

describe("<SecurityUserSettingsTab />", () => {
const defaultProps = {
Expand All @@ -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>
);

Expand Down
Loading
Loading