diff --git a/spec/unit/crypto.spec.ts b/spec/unit/crypto.spec.ts index b96c06686..4ba00af26 100644 --- a/spec/unit/crypto.spec.ts +++ b/spec/unit/crypto.spec.ts @@ -15,11 +15,16 @@ import { sleep } from "../../src/utils"; import { CRYPTO_ENABLED } from "../../src/client"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { logger } from "../../src/logger"; -import { MemoryStore } from "../../src"; +import { DeviceVerification, MemoryStore } from "../../src"; import { RoomKeyRequestState } from "../../src/crypto/OutgoingRoomKeyRequestManager"; import { RoomMember } from "../../src/models/room-member"; import { IStore } from "../../src/store"; import { IRoomEncryption, RoomList } from "../../src/crypto/RoomList"; +import { EventShieldColour, EventShieldReason } from "../../src/crypto-api"; +import { UserTrustLevel } from "../../src/crypto/CrossSigning"; +import { CryptoBackend } from "../../src/common-crypto/CryptoBackend"; +import { EventDecryptionResult } from "../../src/common-crypto/CryptoBackend"; +import * as testData from "../test-utils/test-data"; const Olm = global.Olm; @@ -111,13 +116,14 @@ describe("Crypto", function () { }); describe("encrypted events", function () { - it("provides encryption information", async function () { + it("provides encryption information for events from unverified senders", async function () { const client = new TestClient("@alice:example.com", "deviceid").client; await client.initCrypto(); // unencrypted event const event = { getId: () => "$event_id", + getSender: () => "@bob:example.com", getSenderKey: () => null, getWireContent: () => { return {}; @@ -127,6 +133,8 @@ describe("Crypto", function () { let encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeFalsy(); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toBe(null); + // unknown sender (e.g. deleted device), forwarded megolm key (untrusted) event.getSenderKey = () => "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"; event.getWireContent = () => { @@ -141,6 +149,11 @@ describe("Crypto", function () { expect(encryptionInfo.authenticated).toBeFalsy(); expect(encryptionInfo.sender).toBeFalsy(); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + }); + // known sender, megolm key from backup event.getForwardingCurve25519KeyChain = () => []; event.isKeySourceUntrusted = () => true; @@ -155,6 +168,11 @@ describe("Crypto", function () { expect(encryptionInfo.sender).toBeTruthy(); expect(encryptionInfo.mismatchedSender).toBeFalsy(); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + }); + // known sender, trusted megolm key, but bad ed25519key event.isKeySourceUntrusted = () => false; device.keys["ed25519:FLIBBLE"] = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; @@ -165,9 +183,115 @@ describe("Crypto", function () { expect(encryptionInfo.sender).toBeTruthy(); expect(encryptionInfo.mismatchedSender).toBeTruthy(); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY, + }); + client.stopClient(); }); + describe("provides encryption information for events from verified senders", function () { + const testDeviceId = testData.BOB_TEST_DEVICE_ID; + const testDevice = testData.BOB_SIGNED_TEST_DEVICE_DATA; + + let client: MatrixClient; + beforeEach(async () => { + client = new TestClient("@alice:example.com", "deviceid").client; + await client.initCrypto(); + + // mock out the verification check + client.crypto!.checkUserTrust = (userId) => new UserTrustLevel(true, false, false); + }); + + afterEach(() => { + client.stopClient(); + }); + + async function buildEncryptedEvent( + decryptionResult: Partial = {}, + ): Promise { + const mockCryptoBackend = { + decryptEvent: async (event: MatrixEvent): Promise => { + return { + claimedEd25519Key: testDevice.keys["ed25519:" + testDeviceId], + clearEvent: { + room_id: "!room_id", + type: "m.room.message", + content: { body: "test" }, + }, + forwardingCurve25519KeyChain: [], + senderCurve25519Key: testDevice.keys["curve25519:" + testDeviceId], + ...decryptionResult, + }; + }, + } as unknown as CryptoBackend; + + const event = new MatrixEvent({ + event_id: "$event_id", + sender: testData.BOB_TEST_USER_ID, + type: "m.room.encrypted", + content: { algorithm: "m.megolm.v1.aes-sha2" }, + }); + await event.attemptDecryption(mockCryptoBackend); + return event; + } + + it("unknown device", async () => { + const event = await buildEncryptedEvent(); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.UNKNOWN_DEVICE, + }); + }); + + it("known but unsigned device", async () => { + client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, { + [testDeviceId]: { + keys: testDevice.keys, + algorithms: testDevice.algorithms, + verified: DeviceVerification.Unverified, + known: true, + }, + }); + + const event = await buildEncryptedEvent(); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNVERIFIED_IDENTITY, + }); + }); + + describe("known and verified device", () => { + beforeEach(() => { + client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, { + [testDeviceId]: { + keys: testDevice.keys, + algorithms: testDevice.algorithms, + verified: DeviceVerification.Verified, + known: true, + }, + }); + }); + + it("regular key", async () => { + const event = await buildEncryptedEvent(); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ + shieldColour: EventShieldColour.NONE, + shieldReason: null, + }); + }); + + it("unauthenticated key", async () => { + const event = await buildEncryptedEvent({ untrusted: true }); + expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + }); + }); + }); + }); + it("doesn't throw an error when attempting to decrypt a redacted event", async () => { const client = new TestClient("@alice:example.com", "deviceid").client; await client.initCrypto(); diff --git a/src/client.ts b/src/client.ts index c6da2e54f..45a74afba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2848,6 +2848,7 @@ export class MatrixClient extends TypedEventEmitter; + /** + * Get information about the encryption of the given event. + * + * @param event - the event to get information for + * + * @returns `null` if the event is not encrypted, or has not (yet) been successfully decrypted. Otherwise, an + * object with information about the encryption of the event. + */ + getEncryptionInfoForEvent(event: MatrixEvent): Promise; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // Device/User verification @@ -665,5 +676,57 @@ export interface GeneratedSecretStorageKey { encodedPrivateKey?: string; } +/** + * Result type of {@link CryptoApi#getEncryptionInfoForEvent}. + */ +export interface EventEncryptionInfo { + /** "Shield" to be shown next to this event representing its verification status */ + shieldColour: EventShieldColour; + + /** + * `null` if `shieldColour` is `EventShieldColour.NONE`; otherwise a reason code for the shield in `shieldColour`. + */ + shieldReason: EventShieldReason | null; +} + +/** + * Types of shield to be shown for {@link EventEncryptionInfo#shieldColour}. + */ +export enum EventShieldColour { + NONE, + GREY, + RED, +} + +/** + * Reason codes for {@link EventEncryptionInfo#shieldReason}. + */ +export enum EventShieldReason { + /** An unknown reason from the crypto library (if you see this, it is a bug in matrix-js-sdk). */ + UNKNOWN, + + /** "Encrypted by an unverified user." */ + UNVERIFIED_IDENTITY, + + /** "Encrypted by a device not verified by its owner." */ + UNSIGNED_DEVICE, + + /** "Encrypted by an unknown or deleted device." */ + UNKNOWN_DEVICE, + + /** + * "The authenticity of this encrypted message can't be guaranteed on this device." + * + * ie: the key has been forwarded, or retrieved from an insecure backup. + */ + AUTHENTICITY_NOT_GUARANTEED, + + /** + * The (deprecated) sender_key field in the event does not match the Ed25519 key of the device that sent us the + * decryption keys. + */ + MISMATCHED_SENDER_KEY, +} + export * from "./crypto-api/verification"; export * from "./crypto-api/keybackup"; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 7a5c3c895..b435912fd 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -91,6 +91,9 @@ import { BootstrapCrossSigningOpts, CrossSigningStatus, DeviceVerificationStatus, + EventEncryptionInfo, + EventShieldColour, + EventShieldReason, ImportRoomKeysOpts, KeyBackupCheck, KeyBackupInfo, @@ -2701,6 +2704,68 @@ export class Crypto extends TypedEventEmitter { + const encryptionInfo = this.getEventEncryptionInfo(event); + if (!encryptionInfo.encrypted) { + return null; + } + + const senderId = event.getSender(); + if (!senderId || encryptionInfo.mismatchedSender) { + // something definitely wrong is going on here + return { + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY, + }; + } + + const userTrust = this.checkUserTrust(senderId); + 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 + if (!encryptionInfo.authenticated) { + return { + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + }; + } else { + return { shieldColour: EventShieldColour.NONE, shieldReason: null }; + } + } + + const eventSenderTrust = + senderId && + encryptionInfo.sender && + (await this.getDeviceVerificationStatus(senderId, encryptionInfo.sender.deviceId)); + + if (!eventSenderTrust) { + return { + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.UNKNOWN_DEVICE, + }; + } + + if (!eventSenderTrust.isVerified()) { + return { + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNVERIFIED_IDENTITY, + }; + } + + if (!encryptionInfo.authenticated) { + return { + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + }; + } + + return { shieldColour: EventShieldColour.NONE, shieldReason: null }; + } + /** * Forces the current outbound group session to be discarded such * that another one will be created next time an event is sent. diff --git a/src/models/event.ts b/src/models/event.ts index 2767f3c54..dcd11b53e 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -1026,7 +1026,7 @@ export class MatrixEvent extends TypedEventEmitter { + return { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + }; + } + /** * Returns to-device verification requests that are already in progress for the given user id. *