diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 28f6a91de0..08a7b49576 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -409,7 +409,7 @@ describe("Cryptography", function () { .should("contain", "test encrypted from unverified") .find(".mx_EventTile_e2eIcon", { timeout: 100000 }) .should("have.class", "mx_EventTile_e2eIcon_warning") - .should("have.attr", "aria-label", "Encrypted by an unverified session"); + .should("have.attr", "aria-label", "Encrypted by an unverified user."); /* Should show a grey padlock for a message from an unknown device */ @@ -422,7 +422,7 @@ describe("Cryptography", function () { .should("contain", "test encrypted from unverified") .find(".mx_EventTile_e2eIcon") .should("have.class", "mx_EventTile_e2eIcon_normal") - .should("have.attr", "aria-label", "Encrypted by a deleted session"); + .should("have.attr", "aria-label", "Encrypted by an unknown or deleted device."); }); it("Should show a grey padlock for a key restored from backup", () => { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index cbd35c914d..d65dfa4f95 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -846,17 +846,17 @@ $left-gutter: 64px; } &.mx_EventTile_e2eIcon_warning::after { - mask-image: url("$(res)/img/e2e/warning.svg"); - background-color: $e2e-warning-color; + mask-image: url("$(res)/img/e2e/warning.svg"); // (!) in a shield + background-color: $e2e-warning-color; // red } &.mx_EventTile_e2eIcon_normal::after { - mask-image: url("$(res)/img/e2e/normal.svg"); - background-color: $header-panel-text-primary-color; + mask-image: url("$(res)/img/e2e/normal.svg"); // regular shield + background-color: $header-panel-text-primary-color; // grey } &.mx_EventTile_e2eIcon_decryption_failure::after { - mask-image: url("$(res)/img/e2e/decryption-failure.svg"); + mask-image: url("$(res)/img/e2e/decryption-failure.svg"); // key in a circle background-color: $secondary-content; } } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 77fb270768..bc70e95fa9 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -18,17 +18,17 @@ limitations under the License. import React, { createRef, forwardRef, MouseEvent, ReactNode, useRef } from "react"; import classNames from "classnames"; import { - EventType, - MsgType, - RelationType, EventStatus, + EventType, MatrixEvent, MatrixEventEvent, - RoomMember, + MsgType, NotificationCountType, + Relations, + RelationType, Room, RoomEvent, - Relations, + RoomMember, Thread, ThreadEvent, } from "matrix-js-sdk/src/matrix"; @@ -36,6 +36,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -44,7 +45,6 @@ import { Layout } from "../../../settings/enums/Layout"; import { formatTime } from "../../../DateUtils"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { DecryptionFailureBody } from "../messages/DecryptionFailureBody"; -import { E2EState } from "./E2EIcon"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import { aboveRightOf } from "../../structures/ContextMenu"; @@ -236,8 +236,19 @@ export interface EventTileProps { interface IState { // Whether the action bar is focused. actionBarFocused: boolean; - // Whether the event's sender has been verified. - verified: string | null; + + /** + * E2EE shield we should show for decryption problems. + * + * Note this will be `EventShieldColour.NONE` for all unencrypted events, **including those in encrypted rooms**. + */ + shieldColour: EventShieldColour; + + /** + * Reason code for the E2EE shield. `null` if `shieldColour` is `EventShieldColour.NONE` + */ + shieldReason: EventShieldReason | null; + // The Relations model from the JS SDK for reactions to `mxEvent` reactions?: Relations | null | undefined; @@ -299,9 +310,10 @@ export class UnwrappedEventTile extends React.Component this.state = { // Whether the action bar is focused. actionBarFocused: false, - // Whether the event's sender has been verified. `null` if no attempt has yet been made to verify - // (including if the event is not encrypted). - verified: null, + + shieldColour: EventShieldColour.NONE, + shieldReason: null, + // The Relations model from the JS SDK for reactions to `mxEvent` reactions: this.getReactions(), @@ -437,8 +449,9 @@ export class UnwrappedEventTile extends React.Component } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { - // If the verification state changed, the height might have changed - if (prevState.verified !== this.state.verified && this.props.onHeightChanged) { + // If the shield state changed, the height might have changed. + // XXX: does the shield *actually* cause a change in height? Not sure. + if (prevState.shieldColour !== this.state.shieldColour && this.props.onHeightChanged) { this.props.onHeightChanged(); } // If we're not listening for receipts and expect to be, register a listener. @@ -582,59 +595,20 @@ export class UnwrappedEventTile extends React.Component const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { - this.setState({ verified: null }); + this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); return; } - const encryptionInfo = MatrixClientPeg.safeGet().getEventEncryptionInfo(mxEvent); - const senderId = mxEvent.getSender(); - if (!senderId) { - // something definitely wrong is going on here - this.setState({ verified: E2EState.Warning }); - return; - } - - const userTrust = MatrixClientPeg.safeGet().checkUserTrust(senderId); - - if (encryptionInfo.mismatchedSender) { - // something definitely wrong is going on here - this.setState({ verified: E2EState.Warning }); - return; - } - - if (!userTrust.isCrossSigningVerified()) { - // If the message is unauthenticated, then display a grey - // shield, otherwise if the user isn't cross-signed then - // nothing's needed - this.setState({ verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated }); - return; - } - - const eventSenderTrust = - senderId && - encryptionInfo.sender && - (await MatrixClientPeg.safeGet() - .getCrypto() - ?.getDeviceVerificationStatus(senderId, encryptionInfo.sender.deviceId)); - + const encryptionInfo = + (await MatrixClientPeg.safeGet().getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null; if (this.unmounted) return; - - if (!eventSenderTrust) { - this.setState({ verified: E2EState.Unknown }); + if (encryptionInfo === null) { + // likely a decryption error + this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); return; } - if (!eventSenderTrust.isVerified()) { - this.setState({ verified: E2EState.Warning }); - return; - } - - if (!encryptionInfo.authenticated) { - this.setState({ verified: E2EState.Unauthenticated }); - return; - } - - this.setState({ verified: E2EState.Verified }); + this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason }); } private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { @@ -751,18 +725,42 @@ export class UnwrappedEventTile extends React.Component return ; } - // event is encrypted and not redacted, display padlock corresponding to whether or not it is verified - if (ev.isEncrypted() && !ev.isRedacted()) { - if (this.state.verified === E2EState.Normal) { - return null; // no icon if we've not even cross-signed the user - } else if (this.state.verified === E2EState.Verified) { - return null; // no icon for verified - } else if (this.state.verified === E2EState.Unauthenticated) { - return ; - } else if (this.state.verified === E2EState.Unknown) { - return ; + if (this.state.shieldColour !== EventShieldColour.NONE) { + let shieldReasonMessage: string; + switch (this.state.shieldReason) { + case null: + case EventShieldReason.UNKNOWN: + shieldReasonMessage = _t("Unknown error"); + break; + + case EventShieldReason.UNVERIFIED_IDENTITY: + shieldReasonMessage = _t("Encrypted by an unverified user."); + break; + + case EventShieldReason.UNSIGNED_DEVICE: + shieldReasonMessage = _t("Encrypted by a device not verified by its owner."); + break; + + case EventShieldReason.UNKNOWN_DEVICE: + shieldReasonMessage = _t("Encrypted by an unknown or deleted device."); + break; + + case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED: + shieldReasonMessage = _t( + "The authenticity of this encrypted message can't be guaranteed on this device.", + ); + break; + + case EventShieldReason.MISMATCHED_SENDER_KEY: + shieldReasonMessage = _t("Encrypted by an unverified session"); + break; + } + + if (this.state.shieldColour === EventShieldColour.GREY) { + return ; } else { - return ; + // red, by elimination + return ; } } @@ -781,8 +779,10 @@ export class UnwrappedEventTile extends React.Component if (ev.isRedacted()) { return null; // we expect this to be unencrypted } - // if the event is not encrypted, but it's an e2e room, show the open padlock - return ; + if (!ev.isEncrypted()) { + // if the event is not encrypted, but it's an e2e room, show a warning + return ; + } } // no padlock needed @@ -1460,28 +1460,10 @@ const SafeEventTile = forwardRef((props, ref }); export default SafeEventTile; -function E2ePadlockUnverified(props: Omit): JSX.Element { - return ; -} - function E2ePadlockUnencrypted(props: Omit): JSX.Element { return ; } -function E2ePadlockUnknown(props: Omit): JSX.Element { - return ; -} - -function E2ePadlockUnauthenticated(props: Omit): JSX.Element { - return ( - - ); -} - function E2ePadlockDecryptionFailure(props: Omit): JSX.Element { return ( %(room)s": " in %(room)s", - "Encrypted by an unverified session": "Encrypted by an unverified session", "Unencrypted": "Unencrypted", - "Encrypted by a deleted session": "Encrypted by a deleted session", - "The authenticity of this encrypted message can't be guaranteed on this device.": "The authenticity of this encrypted message can't be guaranteed on this device.", "This message could not be decrypted": "This message could not be decrypted", "Sending your message…": "Sending your message…", "Encrypting your message…": "Encrypting your message…", diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index 6ce54db760..92a0aa27aa 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -15,31 +15,40 @@ limitations under the License. */ import * as React from "react"; -import { render, waitFor, screen, act, fireEvent } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { mocked } from "jest-mock"; import { - EventType, CryptoApi, - TweakName, - NotificationCountType, - Room, - MatrixEvent, + EventType, + IEventDecryptionResult, MatrixClient, + MatrixEvent, + NotificationCountType, PendingEventOrdering, + Room, + TweakName, } from "matrix-js-sdk/src/matrix"; -import { DeviceTrustLevel, UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; -import { IEncryptedEventInfo } from "matrix-js-sdk/src/crypto/api"; +import { EventEncryptionInfo, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api"; +import { CryptoBackend } from "matrix-js-sdk/src/common-crypto/CryptoBackend"; import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { flushPromises, getRoomContext, mkEncryptedEvent, mkEvent, mkMessage, stubClient } from "../../../test-utils"; +import { + filterConsole, + flushPromises, + getRoomContext, + mkEncryptedEvent, + mkEvent, + mkMessage, + stubClient, +} from "../../../test-utils"; import { mkThread } from "../../../test-utils/threads"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import dis from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; +import { IRoomState } from "../../../../src/components/structures/RoomView"; describe("EventTile", () => { const ROOM_ID = "!roomId:example.org"; @@ -49,12 +58,22 @@ describe("EventTile", () => { // let changeEvent: (event: MatrixEvent) => void; - function TestEventTile(props: Partial) { - // const [event] = useState(mxEvent); - // Give a way for a test to update the event prop. - // changeEvent = setEvent; - - return ; + /** wrap the EventTile up in context providers, and with basic properties, as it would be by MessagePanel normally. */ + function WrappedEventTile(props: { + roomContext: IRoomState; + eventTilePropertyOverrides?: Partial; + }) { + return ( + + + + + + ); } function getComponent( @@ -64,14 +83,7 @@ describe("EventTile", () => { const context = getRoomContext(room, { timelineRenderingType: renderingType, }); - return render( - - - - - , - , - ); + return render(); } beforeEach(() => { @@ -196,34 +208,15 @@ describe("EventTile", () => { }); }); describe("Event verification", () => { - // data for our stubbed getEventEncryptionInfo: a map from event id to result - const eventToEncryptionInfoMap = new Map(); - - const TRUSTED_DEVICE = DeviceInfo.fromStorage({}, "TRUSTED_DEVICE"); - const UNTRUSTED_DEVICE = DeviceInfo.fromStorage({}, "UNTRUSTED_DEVICE"); + // data for our stubbed getEncryptionInfoForEvent: a map from event id to result + const eventToEncryptionInfoMap = new Map(); beforeEach(() => { eventToEncryptionInfoMap.clear(); - // a mocked version of getEventEncryptionInfo which will pick its result from `eventToEncryptionInfoMap` - client.getEventEncryptionInfo = (event) => eventToEncryptionInfoMap.get(event.getId()!)!; - - // a mocked version of checkUserTrust which always says the user is trusted (we do our testing via - // unverified devices). - const trustedUserTrustLevel = new UserTrustLevel(true, true, true); - client.checkUserTrust = (_userId) => trustedUserTrustLevel; - - // a version of checkDeviceTrust which says that TRUSTED_DEVICE is trusted, and others are not. - const trustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, true, false); - const untrustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, false, false); const mockCrypto = { - getDeviceVerificationStatus: async (userId: string, deviceId: string) => { - if (deviceId === TRUSTED_DEVICE.deviceId) { - return trustedDeviceTrustLevel; - } else { - return untrustedDeviceTrustLevel; - } - }, + // a mocked version of getEncryptionInfoForEvent which will pick its result from `eventToEncryptionInfoMap` + getEncryptionInfoForEvent: async (event: MatrixEvent) => eventToEncryptionInfoMap.get(event.getId()!)!, } as unknown as CryptoApi; client.getCrypto = () => mockCrypto; }); @@ -236,9 +229,9 @@ describe("EventTile", () => { room: room.roomId, }); eventToEncryptionInfoMap.set(mxEvent.getId()!, { - authenticated: true, - sender: UNTRUSTED_DEVICE, - } as IEncryptedEventInfo); + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNSIGNED_DEVICE, + } as EventEncryptionInfo); const { container } = getComponent(); await act(flushPromises); @@ -261,9 +254,9 @@ describe("EventTile", () => { room: room.roomId, }); eventToEncryptionInfoMap.set(mxEvent.getId()!, { - authenticated: true, - sender: TRUSTED_DEVICE, - } as IEncryptedEventInfo); + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); const { container } = getComponent(); await act(flushPromises); @@ -275,6 +268,67 @@ describe("EventTile", () => { expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); }); + it.each([ + [EventShieldReason.UNKNOWN, "Unknown error"], + [EventShieldReason.UNVERIFIED_IDENTITY, "unverified user"], + [EventShieldReason.UNSIGNED_DEVICE, "device not verified by its owner"], + [EventShieldReason.UNKNOWN_DEVICE, "unknown or deleted device"], + [EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, "can't be guaranteed"], + [EventShieldReason.MISMATCHED_SENDER_KEY, "Encrypted by an unverified session"], + ])("shows the correct reason code for %i (%s)", async (reasonCode: EventShieldReason, expectedText: string) => { + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.GREY, + shieldReason: reasonCode, + } as EventEncryptionInfo); + + const { container } = getComponent(); + await act(flushPromises); + + const e2eIcons = container.getElementsByClassName("mx_EventTile_e2eIcon"); + expect(e2eIcons).toHaveLength(1); + expect(e2eIcons[0].classList).toContain("mx_EventTile_e2eIcon_normal"); + expect(e2eIcons[0].getAttribute("aria-label")).toContain(expectedText); + }); + + describe("undecryptable event", () => { + filterConsole("Error decrypting event"); + + it("shows an undecryptable warning", async () => { + mxEvent = mkEvent({ + type: "m.room.encrypted", + room: room.roomId, + user: "@alice:example.org", + event: true, + content: {}, + }); + + const mockCrypto = { + decryptEvent: async (_ev): Promise => { + throw new Error("can't decrypt"); + }, + } as CryptoBackend; + + await mxEvent.attemptDecryption(mockCrypto); + + const { container } = getComponent(); + await act(flushPromises); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain( + "mx_EventTile_e2eIcon_decryption_failure", + ); + }); + }); + it("should update the warning when the event is edited", async () => { // we start out with an event from the trusted device mxEvent = await mkEncryptedEvent({ @@ -284,11 +338,13 @@ describe("EventTile", () => { room: room.roomId, }); eventToEncryptionInfoMap.set(mxEvent.getId()!, { - authenticated: true, - sender: TRUSTED_DEVICE, - } as IEncryptedEventInfo); + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); + + const roomContext = getRoomContext(room, {}); + const { container, rerender } = render(); - const { container } = getComponent(); await act(flushPromises); const eventTiles = container.getElementsByClassName("mx_EventTile"); @@ -305,13 +361,14 @@ describe("EventTile", () => { room: room.roomId, }); eventToEncryptionInfoMap.set(replacementEvent.getId()!, { - authenticated: true, - sender: UNTRUSTED_DEVICE, - } as IEncryptedEventInfo); + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNSIGNED_DEVICE, + } as EventEncryptionInfo); await act(async () => { mxEvent.makeReplaced(replacementEvent); - flushPromises(); + rerender(); + await flushPromises; }); // check it was updated @@ -331,12 +388,14 @@ describe("EventTile", () => { user: "@alice:example.org", room: room.roomId, }); - eventToEncryptionInfoMap.set(mxEvent.getId()!, { - authenticated: true, - sender: TRUSTED_DEVICE, - } as IEncryptedEventInfo); - const { container } = getComponent(); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); + + const roomContext = getRoomContext(room, {}); + const { container, rerender } = render(); await act(flushPromises); const eventTiles = container.getElementsByClassName("mx_EventTile"); @@ -355,7 +414,8 @@ describe("EventTile", () => { await act(async () => { mxEvent.makeReplaced(replacementEvent); - await flushPromises(); + rerender(); + await flushPromises; }); // check it was updated