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

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