Skip to content

Commit b539eda

Browse files
authored
Prompt the user when key storage is unexpectedly off (#29912)
* Assert that we set backup_disabled when turning off key storage * Prompt the user when key storage is unexpectedly off * Playwright tests for the Turn on key storage toast
1 parent 22c7bf3 commit b539eda

File tree

16 files changed

+655
-20
lines changed

16 files changed

+655
-20
lines changed

playwright/e2e/crypto/event-shields.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,9 @@ test.describe("Cryptography", function () {
6767
// Bob has a second, not cross-signed, device
6868
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
6969

70-
// Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list
71-
await page.getByRole("button", { name: "Not now" }).click();
70+
// Dismiss the toasts nagging us, otherwise they get in the way of clicking the room list
71+
await page.getByRole("button", { name: "Dismiss" }).click();
72+
await page.getByRole("button", { name: "Yes, dismiss" }).click();
7273

7374
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
7475
algorithm: "m.megolm.v1.aes-sha2",

playwright/e2e/crypto/toasts.spec.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
99

1010
import { test, expect } from "../../element-web-test";
11-
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils";
11+
import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement } from "./utils";
12+
import { type Bot } from "../../pages/bot";
1213

1314
test.describe("Key storage out of sync toast", () => {
1415
let recoveryKey: GeneratedSecretStorageKey;
@@ -53,3 +54,114 @@ test.describe("Key storage out of sync toast", () => {
5354
).toBeVisible();
5455
});
5556
});
57+
58+
test.describe("'Turn on key storage' toast", () => {
59+
let botClient: Bot | undefined;
60+
61+
test.beforeEach(async ({ page, homeserver, credentials, toasts }) => {
62+
// Set up all crypto stuff. Key storage defaults to on.
63+
64+
const res = await createBot(page, homeserver, credentials);
65+
const recoveryKey = res.recoveryKey;
66+
botClient = res.botClient;
67+
68+
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
69+
70+
// We won't be prompted for crypto setup unless we have an e2e room, so make one
71+
await page.getByRole("button", { name: "Add room" }).click();
72+
await page.getByRole("menuitem", { name: "New room" }).click();
73+
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
74+
await page.getByRole("button", { name: "Create room" }).click();
75+
76+
await toasts.rejectToast("Notifications");
77+
});
78+
79+
test("should not show toast if key storage is on", async ({ page, toasts }) => {
80+
// Given the default situation after signing in
81+
// Then no toast is shown (because key storage is on)
82+
await toasts.assertNoToasts();
83+
84+
// When we reload
85+
await page.reload();
86+
87+
// Give the toasts time to appear
88+
await new Promise((resolve) => setTimeout(resolve, 2000));
89+
90+
// Then still no toast is shown
91+
await toasts.assertNoToasts();
92+
});
93+
94+
test("should not show toast if key storage is off because we turned it off", async ({ app, page, toasts }) => {
95+
// Given the backup is disabled because we disabled it
96+
await disableKeyBackup(app);
97+
98+
// Then no toast is shown
99+
await toasts.assertNoToasts();
100+
101+
// When we reload
102+
await page.reload();
103+
104+
// Give the toasts time to appear
105+
await new Promise((resolve) => setTimeout(resolve, 2000));
106+
107+
// Then still no toast is shown
108+
await toasts.assertNoToasts();
109+
});
110+
111+
test("should show toast if key storage is off but account data is missing", async ({ app, page, toasts }) => {
112+
// Given the backup is disabled but we didn't set account data saying that is expected
113+
await disableKeyBackup(app);
114+
await botClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: false });
115+
116+
// Wait for the account data setting to stick
117+
await new Promise((resolve) => setTimeout(resolve, 2000));
118+
119+
// When we enter the app
120+
await page.reload();
121+
122+
// Then the toast is displayed
123+
let toast = await toasts.getToast("Turn on key storage");
124+
125+
// And when we click "Continue"
126+
await toast.getByRole("button", { name: "Continue" }).click();
127+
128+
// Then we see the Encryption settings dialog with an option to turn on key storage
129+
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
130+
131+
// And when we close that
132+
await page.getByRole("button", { name: "Close dialog" }).click();
133+
134+
// Then we see the toast again
135+
toast = await toasts.getToast("Turn on key storage");
136+
137+
// And when we click "Dismiss"
138+
await toast.getByRole("button", { name: "Dismiss" }).click();
139+
140+
// Then we see the "are you sure?" dialog
141+
await expect(
142+
page.getByRole("heading", { name: "Are you sure you want to keep key storage turned off?" }),
143+
).toBeVisible();
144+
145+
// And when we close it by clicking away
146+
await page.getByTestId("dialog-background").click({ force: true, position: { x: 10, y: 10 } });
147+
148+
// Then we see the toast again
149+
toast = await toasts.getToast("Turn on key storage");
150+
151+
// And when we click Dismiss and then "Go to Settings"
152+
await toast.getByRole("button", { name: "Dismiss" }).click();
153+
await page.getByRole("button", { name: "Go to Settings" }).click();
154+
155+
// Then we see Encryption settings again
156+
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
157+
158+
// And when we close that, see the toast, click Dismiss, and Yes, Dismiss
159+
await page.getByRole("button", { name: "Close dialog" }).click();
160+
toast = await toasts.getToast("Turn on key storage");
161+
await toast.getByRole("button", { name: "Dismiss" }).click();
162+
await page.getByRole("button", { name: "Yes, dismiss" }).click();
163+
164+
// Then the toast is gone
165+
await toasts.assertNoToasts();
166+
});
167+
});

playwright/e2e/crypto/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,25 @@ export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
316316
return recoveryKey;
317317
}
318318

319+
/**
320+
* Open the encryption settings and disable key storage (and recovery)
321+
* Assumes that the current device has been verified
322+
*/
323+
export async function disableKeyBackup(app: ElementAppPage): Promise<void> {
324+
const encryptionTab = await app.settings.openUserSettings("Encryption");
325+
326+
const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" });
327+
if (await keyStorageToggle.isChecked()) {
328+
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click();
329+
await encryptionTab.getByRole("button", { name: "Delete key storage" }).click();
330+
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).isVisible();
331+
332+
// Wait for the update to account data to stick
333+
await new Promise((resolve) => setTimeout(resolve, 2000));
334+
}
335+
await app.settings.closeDialog();
336+
}
337+
319338
/**
320339
* Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`).
321340
*

res/css/_common.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ legend {
593593
.mx_Dialog
594594
button:not(
595595
.mx_EncryptionUserSettingsTab button,
596+
.mx_EncryptionCard button,
596597
.mx_UserProfileSettings button,
597598
.mx_ShareDialog button,
598599
.mx_UnpinAllDialog button,

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
@import "./views/dialogs/_BugReportDialog.pcss";
132132
@import "./views/dialogs/_ChangelogDialog.pcss";
133133
@import "./views/dialogs/_CompoundDialog.pcss";
134+
@import "./views/dialogs/_ConfirmKeyStorageOffDialog.pcss";
134135
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
135136
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
136137
@import "./views/dialogs/_CreateRoomDialog.pcss";

res/css/structures/_ToastContainer.pcss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ Please see LICENSE files in the repository root for full details.
7979
background-color: $primary-content;
8080
}
8181

82+
&.mx_Toast_icon_key_storage::after {
83+
mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg");
84+
background-color: $primary-content;
85+
}
86+
8287
&.mx_Toast_icon_labs::after {
8388
mask-image: url("$(res)/img/element-icons/flask.svg");
8489
background-color: $secondary-content;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
.mx_ConfirmKeyStorageOffDialog {
9+
.mx_Dialog_border {
10+
width: 600px;
11+
}
12+
13+
.mx_EncryptionCard {
14+
text-align: center;
15+
}
16+
}

src/DeviceListener.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export default class DeviceListener {
9797
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
9898
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
9999
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
100+
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
100101
this.client.on(ClientEvent.AccountData, this.onAccountData);
101102
this.client.on(ClientEvent.Sync, this.onSync);
102103
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
@@ -132,7 +133,7 @@ export default class DeviceListener {
132133
this.dismissedThisDeviceToast = false;
133134
this.keyBackupInfo = null;
134135
this.keyBackupFetchedAt = null;
135-
this.cachedKeyBackupStatus = undefined;
136+
this.cachedKeyBackupUploadActive = undefined;
136137
this.ourDeviceIdsAtStart = null;
137138
this.displayingToastsForDeviceIds = new Set();
138139
this.client = undefined;
@@ -157,6 +158,13 @@ export default class DeviceListener {
157158
this.recheck();
158159
}
159160

161+
/**
162+
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
163+
*/
164+
public async recordKeyBackupDisabled(): Promise<void> {
165+
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
166+
}
167+
160168
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
161169
if (this.ourDeviceIdsAtStart === null) {
162170
this.ourDeviceIdsAtStart = await this.getDeviceIds();
@@ -192,6 +200,11 @@ export default class DeviceListener {
192200
this.recheck();
193201
};
194202

203+
private onKeyBackupStatusChanged = (): void => {
204+
this.cachedKeyBackupUploadActive = undefined;
205+
this.recheck();
206+
};
207+
195208
private onCrossSingingKeysChanged = (): void => {
196209
this.recheck();
197210
};
@@ -201,11 +214,13 @@ export default class DeviceListener {
201214
// * migrated SSSS to symmetric
202215
// * uploaded keys to secret storage
203216
// * completed secret storage creation
217+
// * disabled key backup
204218
// which result in account data changes affecting checks below.
205219
if (
206220
ev.getType().startsWith("m.secret_storage.") ||
207221
ev.getType().startsWith("m.cross_signing.") ||
208-
ev.getType() === "m.megolm_backup.v1"
222+
ev.getType() === "m.megolm_backup.v1" ||
223+
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
209224
) {
210225
this.recheck();
211226
}
@@ -324,7 +339,16 @@ export default class DeviceListener {
324339
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
325340
);
326341

327-
const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached;
342+
const keyBackupUploadActive = await this.isKeyBackupUploadActive();
343+
const backupDisabled = await this.recheckBackupDisabled(cli);
344+
345+
// We warn if key backup upload is turned off and we have not explicitly
346+
// said we are OK with that.
347+
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
348+
349+
const allSystemsReady =
350+
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
351+
328352
await this.reportCryptoSessionStateToAnalytics(cli);
329353

330354
if (this.dismissedThisDeviceToast || allSystemsReady) {
@@ -353,14 +377,19 @@ export default class DeviceListener {
353377
crossSigningStatus.privateKeysCachedLocally,
354378
);
355379
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
380+
} else if (!keyBackupIsOk) {
381+
logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast");
382+
showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE);
356383
} else if (defaultKeyId === null) {
357-
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage)
358-
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
359-
if (!disabledEvent?.getContent().disabled) {
384+
// The user just hasn't set up 4S yet: if they have key
385+
// backup, prompt them to turn on recovery too. (If not, they
386+
// have explicitly opted out, so don't hassle them.)
387+
if (keyBackupUploadActive) {
360388
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
361389
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
362390
} else {
363391
logSpan.info("No default 4S key but backup disabled: no toast needed");
392+
hideSetupEncryptionToast();
364393
}
365394
} else {
366395
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
@@ -443,6 +472,16 @@ export default class DeviceListener {
443472
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
444473
}
445474

475+
/**
476+
* Fetch the account data for `backup_disabled`. If this is the first time,
477+
* fetch it from the server (in case the initial sync has not finished).
478+
* Otherwise, fetch it from the store as normal.
479+
*/
480+
private async recheckBackupDisabled(cli: MatrixClient): Promise<boolean> {
481+
const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
482+
return !!backupDisabled?.disabled;
483+
}
484+
446485
/**
447486
* Reports current recovery state to analytics.
448487
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
@@ -512,36 +551,42 @@ export default class DeviceListener {
512551
* trigger an auto-rageshake).
513552
*/
514553
private checkKeyBackupStatus = async (): Promise<void> => {
515-
if (!(await this.getKeyBackupStatus())) {
554+
if (!(await this.isKeyBackupUploadActive())) {
516555
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
517556
}
518557
};
519558

520559
/**
521560
* Is key backup enabled? Use a cached answer if we have one.
522561
*/
523-
private getKeyBackupStatus = async (): Promise<boolean> => {
562+
private isKeyBackupUploadActive = async (): Promise<boolean> => {
524563
if (!this.client) {
525564
// To preserve existing behaviour, if there is no client, we
526-
// pretend key storage is on.
565+
// pretend key backup upload is on.
527566
//
528567
// Someone looking to improve this code could try throwing an error
529568
// here since we don't expect client to be undefined.
530569
return true;
531570
}
532571

572+
const crypto = this.client.getCrypto();
573+
if (!crypto) {
574+
// If there is no crypto, there is no key backup
575+
return false;
576+
}
577+
533578
// If we've already cached the answer, return it.
534-
if (this.cachedKeyBackupStatus !== undefined) {
535-
return this.cachedKeyBackupStatus;
579+
if (this.cachedKeyBackupUploadActive !== undefined) {
580+
return this.cachedKeyBackupUploadActive;
536581
}
537582

538583
// Fetch the answer and cache it
539-
const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion();
540-
this.cachedKeyBackupStatus = !!activeKeyBackupVersion;
584+
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
585+
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
541586

542-
return this.cachedKeyBackupStatus;
587+
return this.cachedKeyBackupUploadActive;
543588
};
544-
private cachedKeyBackupStatus: boolean | undefined = undefined;
589+
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
545590

546591
private onRecordClientInformationSettingChange: CallbackFn = (
547592
_originalSettingName,

0 commit comments

Comments
 (0)