diff --git a/playwright/e2e/crypto/invisible-crypto.spec.ts b/playwright/e2e/crypto/invisible-crypto.spec.ts new file mode 100644 index 0000000000..3ec4db931a --- /dev/null +++ b/playwright/e2e/crypto/invisible-crypto.spec.ts @@ -0,0 +1,60 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, test } from "../../element-web-test"; +import { autoJoin, createSharedRoomWithUser, verify } from "./utils"; +import { Bot } from "../../pages/bot"; + +test.describe("Invisible cryptography", () => { + test.use({ + displayName: "Alice", + botCreateOpts: { displayName: "Bob", autoAcceptInvites: true }, + labsFlags: ["feature_invisible_crypto"], + }); + + test("Messages fail to decrypt when sender is previously verified", async ({ + page, + bot: bob, + user: aliceCredentials, + app, + homeserver, + }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + await autoJoin(bob); + + // create an encrypted room + const testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { + name: "TestRoom", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + + // Verify Bob + await verify(app, bob); + + // Bob logs in a new device and resets cross-signing + const bobSecondDevice = new Bot(page, homeserver, { + bootstrapSecretStorage: true, + bootstrapCrossSigning: true, + setupNewCrossSigning: true, + }); + bobSecondDevice.setCredentials(await homeserver.loginUser(bob.credentials.userId, bob.credentials.password)); + await bobSecondDevice.prepareClient(); + + /* should show an error for a message from a previously verified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from previously verified"); + const lastTile = page.locator(".mx_EventTile_last"); + await expect(lastTile).toContainText("Verified identity has changed"); + }); +}); diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index b7542338b6..ae606779ad 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -37,6 +37,10 @@ export interface CreateBotOpts { * Whether to generate cross-signing keys */ bootstrapCrossSigning?: boolean; + /** + * Whether to reset the cross-signing keys even if keys already exist + */ + setupNewCrossSigning?: boolean; /** * Whether to bootstrap the secret storage */ @@ -186,6 +190,7 @@ export class Bot extends Client { await cli.getCrypto()!.getUserDeviceInfo([credentials.userId]); await cli.getCrypto()!.bootstrapCrossSigning({ + setupNewCrossSigning: opts.setupNewCrossSigning, authUploadDeviceSigningKeys: async (func) => { await func({ type: "m.login.password", diff --git a/res/css/views/messages/_DecryptionFailureBody.pcss b/res/css/views/messages/_DecryptionFailureBody.pcss index 5dfdd7b7ae..c03cca2035 100644 --- a/res/css/views/messages/_DecryptionFailureBody.pcss +++ b/res/css/views/messages/_DecryptionFailureBody.pcss @@ -10,3 +10,19 @@ Please see LICENSE files in the repository root for full details. color: $secondary-content; font-style: italic; } + +.mx_DecryptionFailureVerifiedIdentityChanged > span { + color: $e2e-warning-color; + border-radius: $font-16px; + border-width: 1px; + border-color: $e2e-warning-color; + border-style: solid; + padding: $font-1px 0.4em $font-1px 0.4em; + display: inline-flex; + align-items: center; + + .mx_Icon { + margin-inline-start: -0.3em; + margin-inline-end: 0.2em; + } +} diff --git a/src/components/views/messages/DecryptionFailureBody.tsx b/src/components/views/messages/DecryptionFailureBody.tsx index d6e46267af..cb021ebcc1 100644 --- a/src/components/views/messages/DecryptionFailureBody.tsx +++ b/src/components/views/messages/DecryptionFailureBody.tsx @@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ +import classNames from "classnames"; import React, { forwardRef, ForwardRefExoticComponent, useContext } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; @@ -13,8 +14,9 @@ import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; import { _t } from "../../../languageHandler"; import { IBodyProps } from "./IBodyProps"; import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; +import { Icon as WarningBadgeIcon } from "../../../../res/img/compound/error-16px.svg"; -function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string { +function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string | React.JSX.Element { switch (mxEvent.decryptionFailureReason) { case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: return _t("timeline|decryption_failure|blocked"); @@ -33,15 +35,39 @@ function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED: return _t("timeline|decryption_failure|historical_event_user_not_joined"); + + case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + return ( + + + {_t("timeline|decryption_failure|sender_identity_previously_verified")} + + ); + + case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: + // TODO: event should be hidden instead of showing this error (only + // happens when invisible crypto is enabled) + return _t("encryption|event_shield_reason_unsigned_device"); } return _t("timeline|decryption_failure|unable_to_decrypt"); } +function getErrorExtraClass(mxEvent: MatrixEvent): Record { + switch (mxEvent.decryptionFailureReason) { + case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + return { mx_DecryptionFailureVerifiedIdentityChanged: true }; + + default: + return {}; + } +} + // A placeholder element for messages that could not be decrypted export const DecryptionFailureBody = forwardRef(({ mxEvent }, ref): React.JSX.Element => { const verificationState = useContext(LocalDeviceVerificationStateContext); + const classes = classNames("mx_DecryptionFailureBody", "mx_EventTile_content", getErrorExtraClass(mxEvent)); return ( -
+
{getErrorMessage(mxEvent, verificationState)}
); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8b09ca922b..0611b539be 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3293,6 +3293,7 @@ "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", "historical_event_user_not_joined": "You don't have access to this message", + "sender_identity_previously_verified": "Verified identity has changed", "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 8ba4503446..26183067a9 100644 --- a/test/components/views/messages/DecryptionFailureBody-test.tsx +++ b/test/components/views/messages/DecryptionFailureBody-test.tsx @@ -103,4 +103,32 @@ describe("DecryptionFailureBody", () => { // Then expect(container).toHaveTextContent("You don't have access to this message"); }); + + it("should handle messages from users who change identities after verification", async () => { + // When + const event = await mkDecryptionFailureMatrixEvent({ + code: DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, + msg: "User previously verified", + roomId: "fakeroom", + sender: "fakesender", + }); + const { container } = customRender(event); + + // Then + expect(container).toHaveTextContent("Verified identity has changed"); + }); + + it("should handle messages from unverified devices", async () => { + // When + const event = await mkDecryptionFailureMatrixEvent({ + code: DecryptionFailureCode.UNSIGNED_SENDER_DEVICE, + msg: "Unsigned device", + roomId: "fakeroom", + sender: "fakesender", + }); + const { container } = customRender(event); + + // Then + expect(container).toHaveTextContent("Encrypted by a device not verified by its owner"); + }); });