diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index 957be58711..323e1eb703 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -15,14 +15,17 @@ limitations under the License. */ import type { Page } from "@playwright/test"; -import { test, expect } from "../../element-web-test"; +import { expect, test } from "../../element-web-test"; import { + copyAndContinue, + createRoom, createSharedRoomWithUser, doTwoWaySasVerification, - copyAndContinue, enableKeyBackup, logIntoElement, logOutOfElement, + sendMessageInCurrentRoom, + verifySession, waitForVerificationRequest, } from "./utils"; import { Bot } from "../../pages/bot"; @@ -453,8 +456,8 @@ test.describe("Cryptography", function () { // no e2e icon await expect(lastTileE2eIcon).not.toBeVisible(); - // It can take up to 10 seconds for the key to be backed up. We don't really have much option other than - // to wait :/ + // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for + // the key to be backed up. await page.waitForTimeout(10000); /* log out, and back in */ @@ -532,4 +535,69 @@ test.describe("Cryptography", function () { ).not.toBeVisible(); }); }); + + test.describe("decryption failure messages", () => { + test("should handle device-relative historical messages", async ({ + homeserver, + page, + app, + credentials, + user, + cryptoBackend, + }) => { + test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto"); + test.setTimeout(60000); + + // Start with a logged-in session, without key backup, and send a message. + await createRoom(page, "Test room", true); + await sendMessageInCurrentRoom(page, "test test"); + + // Log out, discarding the key for the sent message. + await logOutOfElement(page, true); + + // Log in again, and see how the message looks. + await logIntoElement(page, homeserver, credentials); + await app.viewRoomByName("Test room"); + const lastTile = page.locator(".mx_EventTile").last(); + await expect(lastTile).toContainText("Historical messages are not available on this device"); + await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // Now, we set up key backup, and then send another message. + const secretStorageKey = await enableKeyBackup(app); + await app.viewRoomByName("Test room"); + await sendMessageInCurrentRoom(page, "test2 test2"); + + // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for + // the key to be backed up. + await page.waitForTimeout(10000); + + // Finally, log out again, and back in, skipping verification for now, and see what we see. + await logOutOfElement(page); + await logIntoElement(page, homeserver, credentials); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); + await app.viewRoomByName("Test room"); + + // There should be two historical events in the timeline + const tiles = await page.locator(".mx_EventTile").all(); + expect(tiles.length).toBeGreaterThanOrEqual(2); + // look at the last two tiles only + for (const tile of tiles.slice(-2)) { + await expect(tile).toContainText("You need to verify this device for access to historical messages"); + await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + } + + // Now verify our device (setting up key backup), and check what happens + await verifySession(app, secretStorageKey); + const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2); + + // The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though. + await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message"); + await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // The second message should now be decrypted, with a grey shield + await expect(tilesAfterVerify[1]).toContainText("test2 test2"); + await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible(); + }); + }); }); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index d43e4c7f94..5b0bf29b97 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type Page, expect, JSHandle } from "@playwright/test"; +import { expect, JSHandle, type Page } from "@playwright/test"; import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import type { + EmojiMapping, + ShowSasCallbacks, VerificationRequest, Verifier, - EmojiMapping, VerifierEvent, - ShowSasCallbacks, } from "matrix-js-sdk/src/crypto-api"; import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; import { Client } from "../../pages/client"; @@ -148,7 +148,7 @@ export async function logIntoElement( // select homeserver await page.getByRole("button", { name: "Edit" }).click(); await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); // wait for the dialog to go away await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible(); @@ -167,15 +167,40 @@ export async function logIntoElement( } } -export async function logOutOfElement(page: Page) { +/** + * Click the "sign out" option in Element, and wait for the login page to load + * + * @param page - Playwright `Page` object. + * @param discardKeys - if true, expect a "You'll lose access to your encrypted messages" dialog, and dismiss it. + */ +export async function logOutOfElement(page: Page, discardKeys: boolean = false) { await page.getByRole("button", { name: "User menu" }).click(); await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); - await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click(); + if (discardKeys) { + await page.getByRole("button", { name: "I don't want my encrypted messages" }).click(); + } else { + await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click(); + } // Wait for the login page to load await page.getByRole("heading", { name: "Sign in" }).click(); } +/** + * Open the security settings, and verify the current session using the security key. + * + * @param app - `ElementAppPage` wrapper for the playwright `Page`. + * @param securityKey - The security key (i.e., 4S key), set up during a previous session. + */ +export async function verifySession(app: ElementAppPage, securityKey: string) { + const settings = await app.settings.openUserSettings("Security & Privacy"); + await settings.getByRole("button", { name: "Verify this session" }).click(); + await app.page.getByRole("button", { name: "Verify with Security Key" }).click(); + await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); + await app.page.getByRole("button", { name: "Continue", disabled: false }).click(); + await app.page.getByRole("button", { name: "Done" }).click(); +} + /** * Given a SAS verifier for a bot client: * - wait for the bot to receive the emojis @@ -289,4 +314,9 @@ export async function createRoom(page: Page, roomName: string, isEncrypted: bool } await dialog.getByRole("button", { name: "Create room" }).click(); + + // Wait for the client to process the encryption event before carrying on (and potentially sending events). + if (isEncrypted) { + await expect(page.getByText("Encryption enabled")).toBeVisible(); + } } diff --git a/src/components/views/messages/DecryptionFailureBody.tsx b/src/components/views/messages/DecryptionFailureBody.tsx index d3895e815f..37caf69161 100644 --- a/src/components/views/messages/DecryptionFailureBody.tsx +++ b/src/components/views/messages/DecryptionFailureBody.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022-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. @@ -14,23 +14,38 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { forwardRef, ForwardRefExoticComponent } from "react"; +import React, { forwardRef, ForwardRefExoticComponent, useContext } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; import { _t } from "../../../languageHandler"; import { IBodyProps } from "./IBodyProps"; +import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; -function getErrorMessage(mxEvent?: MatrixEvent): string { - return mxEvent?.isEncryptedDisabledForUnverifiedDevices - ? _t("timeline|decryption_failure|blocked") - : _t("timeline|decryption_failure|unable_to_decrypt"); +function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string { + if (mxEvent.isEncryptedDisabledForUnverifiedDevices) return _t("timeline|decryption_failure|blocked"); + switch (mxEvent.decryptionFailureReason) { + case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP: + return _t("timeline|decryption_failure|historical_event_no_key_backup"); + + case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED: + if (isVerified === false) { + // The user seems to have a key backup, so prompt them to verify in the hope that doing so will + // mean we can restore from backup and we'll get the key for this message. + return _t("timeline|decryption_failure|historical_event_unverified_device"); + } + // otherwise, use the default. + break; + } + return _t("timeline|decryption_failure|unable_to_decrypt"); } // A placeholder element for messages that could not be decrypted -export const DecryptionFailureBody = forwardRef(({ mxEvent }, ref): JSX.Element => { +export const DecryptionFailureBody = forwardRef(({ mxEvent }, ref): React.JSX.Element => { + const verificationState = useContext(LocalDeviceVerificationStateContext); return (
- {getErrorMessage(mxEvent)} + {getErrorMessage(mxEvent, verificationState)}
); }) as ForwardRefExoticComponent; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0f353c820a..16cdbd6949 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3214,6 +3214,8 @@ "creation_summary_room": "%(creator)s created and configured the room.", "decryption_failure": { "blocked": "The sender has blocked you from receiving this message", + "historical_event_no_key_backup": "Historical messages are not available on this device", + "historical_event_unverified_device": "You need to verify this device for access to historical messages", "unable_to_decrypt": "Unable to decrypt message" }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", diff --git a/test/components/views/messages/DecryptionFailureBody-test.tsx b/test/components/views/messages/DecryptionFailureBody-test.tsx index e8d4fce56e..b01bbb7729 100644 --- a/test/components/views/messages/DecryptionFailureBody-test.tsx +++ b/test/components/views/messages/DecryptionFailureBody-test.tsx @@ -17,13 +17,20 @@ import React from "react"; import { render } from "@testing-library/react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing"; +import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; import { mkEvent } from "../../../test-utils"; import { DecryptionFailureBody } from "../../../../src/components/views/messages/DecryptionFailureBody"; +import { LocalDeviceVerificationStateContext } from "../../../../src/contexts/LocalDeviceVerificationStateContext"; describe("DecryptionFailureBody", () => { - function customRender(event: MatrixEvent) { - return render(); + function customRender(event: MatrixEvent, localDeviceVerified: boolean = false) { + return render( + + + , + ); } it(`Should display "Unable to decrypt message"`, () => { @@ -60,4 +67,37 @@ describe("DecryptionFailureBody", () => { // Then expect(container).toMatchSnapshot(); }); + + it("should handle historical messages with no key backup", async () => { + // When + const event = await mkDecryptionFailureMatrixEvent({ + code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP, + msg: "No backup", + roomId: "fakeroom", + sender: "fakesender", + }); + const { container } = customRender(event); + + // Then + expect(container).toHaveTextContent("Historical messages are not available on this device"); + }); + + it.each([true, false])( + "should handle historical messages when there is a backup and device verification is %s", + async (verified) => { + // When + const event = await mkDecryptionFailureMatrixEvent({ + code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + msg: "Failure", + roomId: "fakeroom", + sender: "fakesender", + }); + const { container } = customRender(event, verified); + + // Then + expect(container).toHaveTextContent( + verified ? "Unable to decrypt" : "You need to verify this device for access to historical messages", + ); + }, + ); });