Skip to content

Commit 980b922

Browse files
authored
Enable key backup by default (#28691)
* Factor out crypto setup process into a store To make components pure and avoid react 18 dev mode problems due to components making requests when mounted. * fix test * test for the store * Add comment * Enable key backup by default When we set up cross signing, so the key backup key will be stored locally along with the cross signing keys until the user sets up recovery (4s). This will mean that a user can restore their backup if they log in on a new device as long as they verify with the one they registered on. Replaces #28267 * Fix test * Prompt user to set up 4S on logout * Fix test * Add playwright test for key backup by default * Fix imports * This isn't unexpected anymore * Update doc * Fix docs and function name on renderSetupBackupDialog() * Use checkKeyBackupAndEnable * Docs for setup encryption toast * Also test the toast appears * Update mock for the method we use now * Okay fine I guess we need both * Swap here too * Fix comment & doc comments
1 parent aa44cad commit 980b922

File tree

12 files changed

+162
-30
lines changed

12 files changed

+162
-30
lines changed

playwright/e2e/crypto/backups.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Please see LICENSE files in the repository root for full details.
99
import { type Page } from "@playwright/test";
1010

1111
import { test, expect } from "../../element-web-test";
12+
import { test as masTest, registerAccountMas } from "../oidc";
13+
import { isDendrite } from "../../plugins/homeserver/dendrite";
1214

1315
async function expectBackupVersionToBe(page: Page, version: string) {
1416
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
@@ -18,6 +20,32 @@ async function expectBackupVersionToBe(page: Page, version: string) {
1820
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
1921
}
2022

23+
masTest.describe("Encryption state after registration", () => {
24+
masTest.skip(isDendrite, "does not yet support MAS");
25+
26+
masTest("Key backup is enabled by default", async ({ page, mailhog, app }) => {
27+
await page.goto("/#/login");
28+
await page.getByRole("button", { name: "Continue" }).click();
29+
await registerAccountMas(page, mailhog.api, "alice", "[email protected]", "Pa$sW0rD!");
30+
31+
await app.settings.openUserSettings("Security & Privacy");
32+
expect(page.getByText("This session is backing up your keys.")).toBeVisible();
33+
});
34+
35+
masTest("user is prompted to set up recovery", async ({ page, mailhog, app }) => {
36+
await page.goto("/#/login");
37+
await page.getByRole("button", { name: "Continue" }).click();
38+
await registerAccountMas(page, mailhog.api, "alice", "[email protected]", "Pa$sW0rD!");
39+
40+
await page.getByRole("button", { name: "Add room" }).click();
41+
await page.getByRole("menuitem", { name: "New room" }).click();
42+
await page.getByRole("textbox", { name: "Name" }).fill("test room");
43+
await page.getByRole("button", { name: "Create room" }).click();
44+
45+
await expect(page.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
46+
});
47+
});
48+
2149
test.describe("Backups", () => {
2250
test.use({
2351
displayName: "Hanako",

src/CreateCrossSigning.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,19 @@ import { _t } from "./languageHandler";
1616
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
1717

1818
/**
19-
* Determine if the homeserver allows uploading device keys with only password auth.
19+
* Determine if the homeserver allows uploading device keys with only password auth, or with no auth at
20+
* all (ie. if the homeserver supports MSC3967).
2021
* @param cli The Matrix Client to use
21-
* @returns True if the homeserver allows uploading device keys with only password auth, otherwise false
22+
* @returns True if the homeserver allows uploading device keys with only password auth or with no auth
23+
* at all, otherwise false
2224
*/
2325
async function canUploadKeysWithPasswordOnly(cli: MatrixClient): Promise<boolean> {
2426
try {
2527
await cli.uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys);
26-
// We should never get here: the server should always require
27-
// UI auth to upload device signing keys. If we do, we upload
28-
// no keys which would be a no-op.
29-
logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
30-
return false;
28+
// If we get here, it's because the server is allowing us to upload keys without
29+
// auth the first time due to MSC3967. Therefore, yes, we can upload keys
30+
// (with or without password, technically, but that's fine).
31+
return true;
3132
} catch (error) {
3233
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
3334
logger.log("uploadDeviceSigningKeys advertised no flows!");

src/DeviceListener.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -295,21 +295,29 @@ export default class DeviceListener {
295295
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
296296

297297
// cross signing isn't enabled - nag to enable it
298-
// There are 2 different toasts for:
298+
// There are 3 different toasts for:
299299
if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) {
300-
// Cross-signing on account but this device doesn't trust the master key (verify this session)
300+
// Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session)
301301
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
302302
this.checkKeyBackupStatus();
303303
} else {
304-
// No cross-signing or key backup on account (set up encryption)
305-
await cli.waitForClientWellKnown();
306-
if (isSecureBackupRequired(cli) && isLoggedIn()) {
307-
// If we're meant to set up, and Secure Backup is required,
308-
// trigger the flow directly without a toast once logged in.
309-
hideSetupEncryptionToast();
310-
accessSecretStorage();
304+
const backupInfo = await this.getKeyBackupInfo();
305+
if (backupInfo) {
306+
// Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery.
307+
// Since we now enable key backup at registration time, this will be the common case for
308+
// new users.
309+
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
311310
} else {
312-
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
311+
// Toast 3: No cross-signing or key backup on account (set up encryption)
312+
await cli.waitForClientWellKnown();
313+
if (isSecureBackupRequired(cli) && isLoggedIn()) {
314+
// If we're meant to set up, and Secure Backup is required,
315+
// trigger the flow directly without a toast once logged in.
316+
hideSetupEncryptionToast();
317+
accessSecretStorage();
318+
} else {
319+
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
320+
}
313321
}
314322
}
315323
}

src/components/views/dialogs/LogoutDialog.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ enum BackupStatus {
3838
/** there is a backup on the server but we are not backing up to it */
3939
SERVER_BACKUP_BUT_DISABLED,
4040

41+
/** Key backup is set up but recovery (4s) is not */
42+
BACKUP_NO_RECOVERY,
43+
4144
/** backup is not set up locally and there is no backup on the server */
4245
NO_BACKUP,
4346

@@ -104,7 +107,11 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
104107
}
105108

106109
if ((await crypto.getActiveSessionBackupVersion()) !== null) {
107-
this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE });
110+
if (await crypto.isSecretStorageReady()) {
111+
this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE });
112+
} else {
113+
this.setState({ backupStatus: BackupStatus.BACKUP_NO_RECOVERY });
114+
}
108115
return;
109116
}
110117

@@ -164,13 +171,17 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
164171
};
165172

166173
/**
167-
* Show a dialog prompting the user to set up key backup.
174+
* Show a dialog prompting the user to set up their recovery method.
175+
*
176+
* Either:
177+
* * There is no backup at all ({@link BackupStatus.NO_BACKUP})
178+
* * There is a backup set up but recovery (4s) is not ({@link BackupStatus.BACKUP_NO_RECOVERY})
179+
* * There is a backup on the server but we are not connected to it ({@link BackupStatus.SERVER_BACKUP_BUT_DISABLED})
180+
* * We were unable to pull the backup data ({@link BackupStatus.ERROR}).
168181
*
169-
* Either there is no backup at all ({@link BackupStatus.NO_BACKUP}), there is a backup on the server but
170-
* we are not connected to it ({@link BackupStatus.SERVER_BACKUP_BUT_DISABLED}), or we were unable to pull the
171-
* backup data ({@link BackupStatus.ERROR}). In all three cases, we should prompt the user to set up key backup.
182+
* In all four cases, we should prompt the user to set up a method of recovery.
172183
*/
173-
private renderSetupBackupDialog(): React.ReactNode {
184+
private renderSetupRecoveryMethod(): React.ReactNode {
174185
const description = (
175186
<div>
176187
<p>{_t("auth|logout_dialog|setup_secure_backup_description_1")}</p>
@@ -254,7 +265,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
254265
case BackupStatus.NO_BACKUP:
255266
case BackupStatus.SERVER_BACKUP_BUT_DISABLED:
256267
case BackupStatus.ERROR:
257-
return this.renderSetupBackupDialog();
268+
case BackupStatus.BACKUP_NO_RECOVERY:
269+
return this.renderSetupRecoveryMethod();
258270
}
259271
}
260272
}

src/i18n/strings/en_EN.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,9 @@
914914
"warning": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
915915
},
916916
"reset_all_button": "Forgotten or lost all recovery methods? <a>Reset all</a>",
917+
"set_up_recovery": "Set up recovery",
918+
"set_up_recovery_later": "Not now",
919+
"set_up_recovery_toast_description": "Generate a recovery key that can be used to restore your encrypted message history in case you lose access to your devices.",
917920
"set_up_toast_description": "Safeguard against losing access to encrypted messages & data",
918921
"set_up_toast_title": "Set up Secure Backup",
919922
"setup_secure_backup": {

src/stores/InitialCryptoSetupStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,15 @@ export class InitialCryptoSetupStore extends EventEmitter {
114114
this.emit("update");
115115

116116
try {
117+
// Create the user's cross-signing keys
117118
await createCrossSigning(this.client, this.isTokenLogin, this.stores.accountPasswordStore.getPassword());
118119

120+
// Check for any existing backup and enable key backup if there isn't one
121+
const currentKeyBackup = await cryptoApi.checkKeyBackupAndEnable();
122+
if (currentKeyBackup === null) {
123+
await cryptoApi.resetKeyBackup();
124+
}
125+
119126
this.reset();
120127

121128
this.status = "complete";

src/toasts/SetupEncryptionToast.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,19 @@ const getTitle = (kind: Kind): string => {
2323
switch (kind) {
2424
case Kind.SET_UP_ENCRYPTION:
2525
return _t("encryption|set_up_toast_title");
26+
case Kind.SET_UP_RECOVERY:
27+
return _t("encryption|set_up_recovery");
2628
case Kind.VERIFY_THIS_SESSION:
2729
return _t("encryption|verify_toast_title");
2830
}
2931
};
3032

31-
const getIcon = (kind: Kind): string => {
33+
const getIcon = (kind: Kind): string | undefined => {
3234
switch (kind) {
3335
case Kind.SET_UP_ENCRYPTION:
3436
return "secure_backup";
37+
case Kind.SET_UP_RECOVERY:
38+
return undefined;
3539
case Kind.VERIFY_THIS_SESSION:
3640
return "verification_warning";
3741
}
@@ -41,29 +45,61 @@ const getSetupCaption = (kind: Kind): string => {
4145
switch (kind) {
4246
case Kind.SET_UP_ENCRYPTION:
4347
return _t("action|continue");
48+
case Kind.SET_UP_RECOVERY:
49+
return _t("action|continue");
4450
case Kind.VERIFY_THIS_SESSION:
4551
return _t("action|verify");
4652
}
4753
};
4854

55+
const getSecondaryButtonLabel = (kind: Kind): string => {
56+
switch (kind) {
57+
case Kind.SET_UP_RECOVERY:
58+
return _t("encryption|set_up_recovery_later");
59+
case Kind.SET_UP_ENCRYPTION:
60+
case Kind.VERIFY_THIS_SESSION:
61+
return _t("encryption|verification|unverified_sessions_toast_reject");
62+
}
63+
};
64+
4965
const getDescription = (kind: Kind): string => {
5066
switch (kind) {
5167
case Kind.SET_UP_ENCRYPTION:
5268
return _t("encryption|set_up_toast_description");
69+
case Kind.SET_UP_RECOVERY:
70+
return _t("encryption|set_up_recovery_toast_description");
5371
case Kind.VERIFY_THIS_SESSION:
5472
return _t("encryption|verify_toast_description");
5573
}
5674
};
5775

76+
/**
77+
* The kind of toast to show.
78+
*/
5879
export enum Kind {
80+
/**
81+
* Prompt the user to set up encryption
82+
*/
5983
SET_UP_ENCRYPTION = "set_up_encryption",
84+
/**
85+
* Prompt the user to set up a recovery key
86+
*/
87+
SET_UP_RECOVERY = "set_up_recovery",
88+
/**
89+
* Prompt the user to verify this session
90+
*/
6091
VERIFY_THIS_SESSION = "verify_this_session",
6192
}
6293

6394
const onReject = (): void => {
6495
DeviceListener.sharedInstance().dismissEncryptionSetup();
6596
};
6697

98+
/**
99+
* Show a toast prompting the user for some action related to setting up their encryption.
100+
*
101+
* @param kind The kind of toast to show
102+
*/
67103
export const showToast = (kind: Kind): void => {
68104
if (
69105
ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({
@@ -101,15 +137,17 @@ export const showToast = (kind: Kind): void => {
101137
description: getDescription(kind),
102138
primaryLabel: getSetupCaption(kind),
103139
onPrimaryClick: onAccept,
104-
secondaryLabel: _t("encryption|verification|unverified_sessions_toast_reject"),
140+
secondaryLabel: getSecondaryButtonLabel(kind),
105141
onSecondaryClick: onReject,
106-
destructive: "secondary",
107142
},
108143
component: GenericToast,
109144
priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40,
110145
});
111146
};
112147

148+
/**
149+
* Hide the encryption setup toast if it is currently being shown.
150+
*/
113151
export const hideToast = (): void => {
114152
ToastStore.sharedInstance().dismissToast(TOAST_KEY);
115153
};

test/test-utils/test-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export function createTestClient(): MatrixClient {
134134
restoreKeyBackupWithPassphrase: jest.fn(),
135135
loadSessionBackupPrivateKeyFromSecretStorage: jest.fn(),
136136
storeSessionBackupPrivateKey: jest.fn(),
137+
checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null),
137138
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
138139
getEncryptionInfoForEvent: jest.fn().mockResolvedValue(null),
139140
}),

test/unit-tests/DeviceListener-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,13 +352,13 @@ describe("DeviceListener", () => {
352352
mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc");
353353
});
354354

355-
it("shows set up encryption toast when user has a key backup available", async () => {
355+
it("shows set up recovery toast when user has a key backup available", async () => {
356356
// non falsy response
357357
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);
358358
await createAndStart();
359359

360360
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
361-
SetupEncryptionToast.Kind.SET_UP_ENCRYPTION,
361+
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
362362
);
363363
});
364364
});

test/unit-tests/components/structures/MatrixChat-test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,7 +1003,9 @@ describe("<MatrixChat />", () => {
10031003
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
10041004
// This needs to not finish immediately because we need to test the screen appears
10051005
bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise),
1006+
resetKeyBackup: jest.fn(),
10061007
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false),
1008+
checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null),
10071009
};
10081010
loginClient.getCrypto.mockReturnValue(mockCrypto as any);
10091011
});

0 commit comments

Comments
 (0)