Skip to content

Commit 3cc1ccd

Browse files
authored
Dismiss "Key storage out of sync" toast when secrets received (#29348)
* DeviceListener: improve logging use a LogSpan to tie together logs from the same run, and add some more logs for various cases * Regression playwright test * Remove unused mocking of `getCrossSigningId` DeviceListener no longer reads this thing * Clean up unit tests Remove redundant describe block * Remove the "out of sync" toast when we are no longer out of sync Receiving the crypto secrets via secret sharing should make the toast go away.
1 parent 43efd91 commit 3cc1ccd

File tree

3 files changed

+158
-29
lines changed

3 files changed

+158
-29
lines changed

playwright/e2e/crypto/device-verification.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
waitForVerificationRequest,
2222
} from "./utils";
2323
import { type Bot } from "../../pages/bot";
24+
import { Toasts } from "../../pages/toasts.ts";
2425

2526
test.describe("Device verification", { tag: "@no-webkit" }, () => {
2627
let aliceBotClient: Bot;
@@ -72,6 +73,51 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
7273
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
7374
});
7475

76+
// Regression test for https://github.com/element-hq/element-web/issues/29110
77+
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {
78+
// Before we log in, the bot creates an encrypted room, so that we can test the toast behaviour that only happens
79+
// when we are in an encrypted room.
80+
await aliceBotClient.createRoom({
81+
initial_state: [
82+
{
83+
type: "m.room.encryption",
84+
state_key: "",
85+
content: { algorithm: "m.megolm.v1.aes-sha2" },
86+
},
87+
],
88+
});
89+
90+
// In order to simulate a real environment more accurately, we need to slow down the arrival of the
91+
// `m.secret.send` to-device messages. That's slightly tricky to do directly, so instead we delay the *outgoing*
92+
// `m.secret.request` messages.
93+
await page.route("**/_matrix/client/v3/sendToDevice/m.secret.request/**", async (route) => {
94+
await route.fulfill({ json: {} });
95+
await new Promise((f) => setTimeout(f, 1000));
96+
await route.fetch();
97+
});
98+
99+
await logIntoElement(page, credentials);
100+
101+
// Launch the verification request between alice and the bot
102+
const verificationRequest = await initiateAliceVerificationRequest(page);
103+
104+
// Handle emoji SAS verification
105+
const infoDialog = page.locator(".mx_InfoDialog");
106+
// the bot chooses to do an emoji verification
107+
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
108+
109+
// Handle emoji request and check that emojis are matching
110+
await doTwoWaySasVerification(page, verifier);
111+
112+
await infoDialog.getByRole("button", { name: "They match" }).click();
113+
await infoDialog.getByRole("button", { name: "Got it" }).click();
114+
115+
// There should be no toast (other than the notifications one)
116+
const toasts = new Toasts(page);
117+
await toasts.rejectToast("Notifications");
118+
await toasts.assertNoToasts();
119+
});
120+
75121
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
76122
// A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key"
77123
await logIntoElement(page, credentials);

src/DeviceListener.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import {
1515
type SyncState,
1616
ClientStoppedError,
1717
} from "matrix-js-sdk/src/matrix";
18-
import { logger as baseLogger } from "matrix-js-sdk/src/logger";
18+
import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger";
1919
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
2020
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
21+
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
2122

2223
import { PosthogAnalytics } from "./PosthogAnalytics";
2324
import dis from "./dispatcher/dispatcher";
@@ -96,6 +97,7 @@ export default class DeviceListener {
9697
this.client.on(ClientEvent.AccountData, this.onAccountData);
9798
this.client.on(ClientEvent.Sync, this.onSync);
9899
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
100+
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
99101
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
100102
// only configurable in config, so we don't need to watch the value
101103
this.enableBulkUnverifiedSessionsReminder = SettingsStore.getValue(UIFeature.BulkUnverifiedSessionsReminder);
@@ -118,6 +120,7 @@ export default class DeviceListener {
118120
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
119121
this.client.removeListener(ClientEvent.Sync, this.onSync);
120122
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
123+
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
121124
}
122125
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
123126
dis.unregister(this.dispatcherRef);
@@ -225,6 +228,11 @@ export default class DeviceListener {
225228
this.updateClientInformation();
226229
};
227230

231+
private onToDeviceEvent = (event: MatrixEvent): void => {
232+
// Receiving a 4S secret can mean we are in sync where we were not before.
233+
if (event.getType() === EventType.SecretSend) this.recheck();
234+
};
235+
228236
/**
229237
* Fetch the key backup information from the server.
230238
*
@@ -273,18 +281,29 @@ export default class DeviceListener {
273281

274282
private async doRecheck(): Promise<void> {
275283
if (!this.running || !this.client) return; // we have been stopped
284+
const logSpan = new LogSpan(logger, "check_" + secureRandomString(4));
285+
276286
const cli = this.client;
277287

278288
// cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1
279-
if (!(await cli.isVersionSupported("v1.1"))) return;
289+
if (!(await cli.isVersionSupported("v1.1"))) {
290+
logSpan.debug("cross-signing not supported");
291+
return;
292+
}
280293

281294
const crypto = cli.getCrypto();
282-
if (!crypto) return;
295+
if (!crypto) {
296+
logSpan.debug("crypto not enabled");
297+
return;
298+
}
283299

284300
// don't recheck until the initial sync is complete: lots of account data events will fire
285301
// while the initial sync is processing and we don't need to recheck on each one of them
286302
// (we add a listener on sync to do once check after the initial sync is done)
287-
if (!cli.isInitialSyncComplete()) return;
303+
if (!cli.isInitialSyncComplete()) {
304+
logSpan.debug("initial sync not yet complete");
305+
return;
306+
}
288307

289308
const crossSigningReady = await crypto.isCrossSigningReady();
290309
const secretStorageReady = await crypto.isSecretStorageReady();
@@ -306,6 +325,7 @@ export default class DeviceListener {
306325
await this.reportCryptoSessionStateToAnalytics(cli);
307326

308327
if (this.dismissedThisDeviceToast || allSystemsReady) {
328+
logSpan.info("No toast needed");
309329
hideSetupEncryptionToast();
310330

311331
this.checkKeyBackupStatus();
@@ -316,27 +336,33 @@ export default class DeviceListener {
316336
if (!crossSigningReady) {
317337
// This account is legacy and doesn't have cross-signing set up at all.
318338
// Prompt the user to set it up.
319-
logger.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
339+
logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
320340
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
321341
} else if (!isCurrentDeviceTrusted) {
322342
// cross signing is ready but the current device is not trusted: prompt the user to verify
323-
logger.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
343+
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
324344
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
325345
} else if (!allCrossSigningSecretsCached) {
326346
// cross signing ready & device trusted, but we are missing secrets from our local cache.
327347
// prompt the user to enter their recovery key.
328-
logger.info("Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast");
348+
logSpan.info(
349+
"Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast",
350+
crossSigningStatus.privateKeysCachedLocally,
351+
);
329352
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
330353
} else if (defaultKeyId === null) {
331354
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage)
332355
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
333356
if (!disabledEvent?.getContent().disabled) {
357+
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
334358
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
359+
} else {
360+
logSpan.info("No default 4S key but backup disabled: no toast needed");
335361
}
336362
} else {
337363
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
338364
// in 'other' situations. Possibly we should consider prompting for a full reset in this case?
339-
logger.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
365+
logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
340366
crossSigningReady,
341367
secretStorageReady,
342368
allCrossSigningSecretsCached,
@@ -345,6 +371,8 @@ export default class DeviceListener {
345371
});
346372
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
347373
}
374+
} else {
375+
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
348376
}
349377

350378
// This needs to be done after awaiting on getUserDeviceInfo() above, so
@@ -377,9 +405,9 @@ export default class DeviceListener {
377405
}
378406
}
379407

380-
logger.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(","));
381-
logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(","));
382-
logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(","));
408+
logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(","));
409+
logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(","));
410+
logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(","));
383411

384412
const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed();
385413

@@ -404,7 +432,7 @@ export default class DeviceListener {
404432
// ...and hide any we don't need any more
405433
for (const deviceId of this.displayingToastsForDeviceIds) {
406434
if (!newUnverifiedDeviceIds.has(deviceId)) {
407-
logger.debug("Hiding unverified session toast for " + deviceId);
435+
logSpan.debug("Hiding unverified session toast for " + deviceId);
408436
hideUnverifiedSessionsToast(deviceId);
409437
}
410438
}

test/unit-tests/DeviceListener-test.ts

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ Please see LICENSE files in the repository root for full details.
77
*/
88

99
import { type Mocked, mocked } from "jest-mock";
10-
import { MatrixEvent, type Room, type MatrixClient, Device, ClientStoppedError } from "matrix-js-sdk/src/matrix";
10+
import {
11+
MatrixEvent,
12+
type Room,
13+
type MatrixClient,
14+
Device,
15+
ClientStoppedError,
16+
ClientEvent,
17+
} from "matrix-js-sdk/src/matrix";
1118
import {
1219
CryptoEvent,
1320
type CrossSigningStatus,
@@ -81,7 +88,6 @@ describe("DeviceListener", () => {
8188
getDeviceVerificationStatus: jest.fn().mockResolvedValue({
8289
crossSigningVerified: false,
8390
}),
84-
getCrossSigningKeyId: jest.fn(),
8591
getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()),
8692
isCrossSigningReady: jest.fn().mockResolvedValue(true),
8793
isSecretStorageReady: jest.fn().mockResolvedValue(true),
@@ -328,26 +334,21 @@ describe("DeviceListener", () => {
328334
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled();
329335
});
330336

331-
describe("when user does not have a cross signing id on this device", () => {
332-
beforeEach(() => {
333-
mockCrypto!.getCrossSigningKeyId.mockResolvedValue(null);
334-
});
335-
336-
it("shows verify session toast when account has cross signing", async () => {
337-
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
338-
await createAndStart();
337+
it("shows verify session toast when account has cross signing", async () => {
338+
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
339+
await createAndStart();
339340

340-
expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled();
341-
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
342-
SetupEncryptionToast.Kind.VERIFY_THIS_SESSION,
343-
);
344-
});
341+
expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled();
342+
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
343+
SetupEncryptionToast.Kind.VERIFY_THIS_SESSION,
344+
);
345345
});
346346

347-
describe("when user does have a cross signing id on this device", () => {
347+
describe("when current device is verified", () => {
348348
beforeEach(() => {
349349
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
350-
mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc");
350+
351+
// current device is verified
351352
mockCrypto!.getDeviceVerificationStatus.mockResolvedValue(
352353
new DeviceVerificationStatus({
353354
trustCrossSignedDevices: true,
@@ -356,6 +357,60 @@ describe("DeviceListener", () => {
356357
);
357358
});
358359

360+
it("shows an out-of-sync toast when one of the secrets is missing", async () => {
361+
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
362+
publicKeysOnDevice: true,
363+
privateKeysInSecretStorage: true,
364+
privateKeysCachedLocally: {
365+
masterKey: false,
366+
selfSigningKey: true,
367+
userSigningKey: true,
368+
},
369+
});
370+
371+
await createAndStart();
372+
373+
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
374+
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
375+
);
376+
});
377+
378+
it("hides the out-of-sync toast when one of the secrets is missing", async () => {
379+
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
380+
381+
// First show the toast
382+
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
383+
publicKeysOnDevice: true,
384+
privateKeysInSecretStorage: true,
385+
privateKeysCachedLocally: {
386+
masterKey: false,
387+
selfSigningKey: true,
388+
userSigningKey: true,
389+
},
390+
});
391+
392+
await createAndStart();
393+
394+
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
395+
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
396+
);
397+
398+
// Then, when we receive the secret, it should be hidden.
399+
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
400+
publicKeysOnDevice: true,
401+
privateKeysInSecretStorage: true,
402+
privateKeysCachedLocally: {
403+
masterKey: true,
404+
selfSigningKey: true,
405+
userSigningKey: true,
406+
},
407+
});
408+
409+
mockClient.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({ type: "m.secret.send" }));
410+
await flushPromises();
411+
expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
412+
});
413+
359414
it("shows set up recovery toast when user has a key backup available", async () => {
360415
// non falsy response
361416
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);

0 commit comments

Comments
 (0)