diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 7911cf794a..8dae6cf5ab 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -39,7 +39,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5 + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5 with: images: | vectorim/element-web @@ -51,7 +51,7 @@ jobs: - name: Build and push id: build-and-push - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 with: context: . push: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 14fd5ffd64..0c531f89b4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -104,7 +104,7 @@ jobs: - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@1f26a0237cd1a57626fbb5a0eb2494c9b8797d07 + uses: guibranco/github-status-action-v2@66088c44e212a906c32a047529a213d81809ec1c with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/package.json b/package.json index a48284bb97..2ef4a6f170 100644 --- a/package.json +++ b/package.json @@ -114,10 +114,10 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-element": "4.1.3", - "linkify-react": "4.1.3", - "linkify-string": "4.1.3", - "linkifyjs": "4.1.3", + "linkify-element": "4.1.4", + "linkify-react": "4.1.4", + "linkify-string": "4.1.4", + "linkifyjs": "4.1.4", "lodash": "^4.17.21", "maplibre-gl": "^4.0.0", "matrix-encrypt-attachment": "^1.0.3", diff --git a/playwright/Dockerfile b/playwright/Dockerfile index 9d478ff231..2b30c416f7 100644 --- a/playwright/Dockerfile +++ b/playwright/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.48.2-jammy +FROM mcr.microsoft.com/playwright:v1.49.0-jammy WORKDIR /work diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts index ce7ca34d8e..b2a1209a70 100644 --- a/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -67,6 +67,9 @@ test.describe("Cryptography", function () { await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); await app.viewRoomByName("Test room"); + // In this case, the call to cryptoApi.isEncryptionEnabledInRoom is taking a long time to resolve + await page.waitForTimeout(1000); + // There should be two historical events in the timeline const tiles = await page.locator(".mx_EventTile").all(); expect(tiles.length).toBeGreaterThanOrEqual(2); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index b5d3790aaa..c6382f1d72 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -16,6 +16,7 @@ import { logOutOfElement, verify, } from "./utils"; +import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; test.describe("Cryptography", function () { test.use({ @@ -307,5 +308,30 @@ test.describe("Cryptography", function () { const penultimate = page.locator(".mx_EventTile").filter({ hasText: "test encrypted from verified" }); await expect(penultimate.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); }); + + test("should show correct shields on events sent by users with changed identity", async ({ + page, + app, + bot: bob, + homeserver, + }) => { + // Verify Bob + await verify(app, bob); + + // Bob logs in a new device and resets cross-signing + const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true); + + /* should show an error for a message from a previously verified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified"); + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("test encrypted from user that was previously verified"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( + "Sender's verified identity has changed", + ); + }); }); }); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index cbf875382c..872bcd0225 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -9,7 +9,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, ComponentProps, createRef, ReactElement, ReactNode, RefObject } from "react"; +import React, { + ChangeEvent, + ComponentProps, + createRef, + ReactElement, + ReactNode, + RefObject, + JSX, +} from "react"; import classNames from "classnames"; import { IRecommendedVersion, @@ -29,6 +37,7 @@ import { MatrixError, ISearchResults, THREAD_RELATION_TYPE, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -234,6 +243,11 @@ export interface IRoomState { liveTimeline?: EventTimeline; narrow: boolean; msc3946ProcessDynamicPredecessor: boolean; + /** + * Whether the room is encrypted or not. + * If null, we are still determining the encryption status. + */ + isRoomEncrypted: boolean | null; canAskToJoin: boolean; promptAskToJoin: boolean; @@ -420,6 +434,7 @@ export class RoomView extends React.Component { canAskToJoin: this.askToJoinEnabled, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, + isRoomEncrypted: null, }; } @@ -850,7 +865,7 @@ export class RoomView extends React.Component { return isManuallyShown && widgets.length > 0; } - public componentDidMount(): void { + public async componentDidMount(): Promise { this.unmounted = false; this.dispatcherRef = defaultDispatcher.register(this.onAction); @@ -1345,13 +1360,12 @@ export class RoomView extends React.Component { this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.calculatePeekRules(room); - this.updatePreviewUrlVisibility(room); this.loadMembersIfJoined(room); this.calculateRecommendedVersion(room); - this.updateE2EStatus(room); this.updatePermissions(room); this.checkWidgets(room); this.loadVirtualRoom(room); + this.updateRoomEncrypted(room); if ( this.getMainSplitContentType(room) !== MainSplitContentType.Timeline && @@ -1380,6 +1394,13 @@ export class RoomView extends React.Component { return room?.currentState.getStateEvents(EventType.RoomTombstone, "") ?? undefined; } + private async getIsRoomEncrypted(roomId = this.state.roomId): Promise { + const crypto = this.context.client?.getCrypto(); + if (!crypto || !roomId) return false; + + return await crypto.isEncryptionEnabledInRoom(roomId); + } + private async calculateRecommendedVersion(room: Room): Promise { const upgradeRecommendation = await room.getRecommendedVersion(); if (this.unmounted) return; @@ -1412,12 +1433,15 @@ export class RoomView extends React.Component { }); } - private updatePreviewUrlVisibility({ roomId }: Room): void { - // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit - const key = this.context.client?.isRoomEncrypted(roomId) ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; - this.setState({ - showUrlPreview: SettingsStore.getValue(key, roomId), - }); + private updatePreviewUrlVisibility(room: Room): void { + this.setState(({ isRoomEncrypted }) => ({ + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + })); + } + + private getPreviewUrlVisibility({ roomId }: Room, isRoomEncrypted: boolean | null): boolean { + const key = isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; + return SettingsStore.getValue(key, roomId); } private onRoom = (room: Room): void => { @@ -1459,7 +1483,7 @@ export class RoomView extends React.Component { }; private async updateE2EStatus(room: Room): Promise { - if (!this.context.client?.isRoomEncrypted(room.roomId)) return; + if (!this.context.client || !this.state.isRoomEncrypted) return; // If crypto is not currently enabled, we aren't tracking devices at all, // so we don't know what the answer is. Let's error on the safe side and show @@ -1470,33 +1494,54 @@ export class RoomView extends React.Component { if (this.context.client.getCrypto()) { /* At this point, the user has encryption on and cross-signing on */ - e2eStatus = await shieldStatusForRoom(this.context.client, room); - RoomView.e2eStatusCache.set(room.roomId, e2eStatus); + e2eStatus = await this.cacheAndGetE2EStatus(room, this.context.client); if (this.unmounted) return; this.setState({ e2eStatus }); } } + private async cacheAndGetE2EStatus(room: Room, client: MatrixClient): Promise { + const e2eStatus = await shieldStatusForRoom(client, room); + RoomView.e2eStatusCache.set(room.roomId, e2eStatus); + return e2eStatus; + } + private onUrlPreviewsEnabledChange = (): void => { if (this.state.room) { this.updatePreviewUrlVisibility(this.state.room); } }; - private onRoomStateEvents = (ev: MatrixEvent, state: RoomState): void => { + private onRoomStateEvents = async (ev: MatrixEvent, state: RoomState): Promise => { // ignore if we don't have a room yet - if (!this.state.room || this.state.room.roomId !== state.roomId) return; + if (!this.state.room || this.state.room.roomId !== state.roomId || !this.context.client) return; switch (ev.getType()) { case EventType.RoomTombstone: this.setState({ tombstone: this.getRoomTombstone() }); break; - + case EventType.RoomEncryption: { + await this.updateRoomEncrypted(); + break; + } default: this.updatePermissions(this.state.room); } }; + private async updateRoomEncrypted(room = this.state.room): Promise { + if (!room || !this.context.client) return; + + const isRoomEncrypted = await this.getIsRoomEncrypted(room.roomId); + const newE2EStatus = isRoomEncrypted ? await this.cacheAndGetE2EStatus(room, this.context.client) : null; + + this.setState({ + isRoomEncrypted, + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + ...(newE2EStatus && { e2eStatus: newE2EStatus }), + }); + } + private onRoomStateUpdate = (state: RoomState): void => { // ignore members in other rooms if (state.roomId !== this.state.room?.roomId) { @@ -2036,6 +2081,8 @@ export class RoomView extends React.Component { public render(): ReactNode { if (!this.context.client) return null; + const { isRoomEncrypted } = this.state; + const isRoomEncryptionLoading = isRoomEncrypted === null; if (this.state.room instanceof LocalRoom) { if (this.state.room.state === LocalRoomState.CREATING) { @@ -2251,14 +2298,16 @@ export class RoomView extends React.Component { let aux: JSX.Element | undefined; let previewBar; if (this.state.timelineRenderingType === TimelineRenderingType.Search) { - aux = ( - - ); + if (!isRoomEncryptionLoading) { + aux = ( + + ); + } } else if (showRoomUpgradeBar) { aux = ; } else if (myMembership !== KnownMembership.Join) { @@ -2334,8 +2383,10 @@ export class RoomView extends React.Component { let messageComposer; const showComposer = + !isRoomEncryptionLoading && // joined and not showing search results - myMembership === KnownMembership.Join && !this.state.search; + myMembership === KnownMembership.Join && + !this.state.search; if (showComposer) { messageComposer = ( { highlightedEventId = this.state.initialEventId; } - const messagePanel = ( -