diff --git a/spec/unit/crypto/algorithms/megolm.spec.ts b/spec/unit/crypto/algorithms/megolm.spec.ts index f1bb62284..0cc9ccb90 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.ts +++ b/spec/unit/crypto/algorithms/megolm.spec.ts @@ -493,9 +493,9 @@ describe("MegolmDecryption", function() { bobClient1.initCrypto(), bobClient2.initCrypto(), ]); - const aliceDevice = aliceClient.crypto.olmDevice; - const bobDevice1 = bobClient1.crypto.olmDevice; - const bobDevice2 = bobClient2.crypto.olmDevice; + const aliceDevice = aliceClient.crypto!.olmDevice; + const bobDevice1 = bobClient1.crypto!.olmDevice; + const bobDevice2 = bobClient2.crypto!.olmDevice; const encryptionCfg = { "algorithm": "m.megolm.v1.aes-sha2", @@ -532,10 +532,10 @@ describe("MegolmDecryption", function() { }, }; - aliceClient.crypto.deviceList.storeDevicesForUser( + aliceClient.crypto!.deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); - aliceClient.crypto.deviceList.downloadKeys = async function(userIds) { + aliceClient.crypto!.deviceList.downloadKeys = async function(userIds) { return this.getDevicesFromStore(userIds); }; @@ -551,7 +551,7 @@ describe("MegolmDecryption", function() { body: "secret", }, }); - await aliceClient.crypto.encryptEvent(event, room); + await aliceClient.crypto!.encryptEvent(event, room); expect(aliceClient.sendToDevice).toHaveBeenCalled(); const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0]; @@ -583,6 +583,100 @@ describe("MegolmDecryption", function() { bobClient2.stopClient(); }); + it("does not block unverified devices when sending verification events", async function() { + const aliceClient = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + const bobClient = (new TestClient( + "@bob:example.com", "bobdevice", + )).client; + await Promise.all([ + aliceClient.initCrypto(), + bobClient.initCrypto(), + ]); + const bobDevice = bobClient.crypto!.olmDevice; + + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const room = new Room(roomId, aliceClient, "@alice:example.com", {}); + + const bobMember = new RoomMember(roomId, "@bob:example.com"); + room.getEncryptionTargetMembers = async function() { + return [bobMember]; + }; + room.setBlacklistUnverifiedDevices(true); + aliceClient.store.storeRoom(room); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + + const BOB_DEVICES: Record = { + bobdevice: { + algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], + keys: { + "ed25519:bobdevice": bobDevice.deviceEd25519Key, + "curve25519:bobdevice": bobDevice.deviceCurve25519Key, + }, + verified: 0, + known: true, + }, + }; + + aliceClient.crypto!.deviceList.storeDevicesForUser( + "@bob:example.com", BOB_DEVICES, + ); + aliceClient.crypto!.deviceList.downloadKeys = async function(userIds) { + // @ts-ignore private + return this.getDevicesFromStore(userIds); + }; + + await bobDevice.generateOneTimeKeys(1); + const oneTimeKeys = await bobDevice.getOneTimeKeys(); + const signedOneTimeKeys: Record = {}; + for (const keyId in oneTimeKeys.curve25519) { + if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { + const k = { + key: oneTimeKeys.curve25519[keyId], + signatures: {}, + }; + signedOneTimeKeys["signed_curve25519:" + keyId] = k; + await bobClient.crypto!.signObject(k); + break; + } + } + + aliceClient.claimOneTimeKeys = jest.fn().mockResolvedValue({ + one_time_keys: { + '@bob:example.com': { + bobdevice: signedOneTimeKeys, + }, + }, + failures: {}, + }); + + aliceClient.sendToDevice = jest.fn().mockResolvedValue({}); + + const event = new MatrixEvent({ + type: "m.key.verification.start", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$event", + content: { + from_device: "alicedevice", + method: "m.sas.v1", + transaction_id: "transactionid", + }, + }); + await aliceClient.crypto!.encryptEvent(event, room); + + expect(aliceClient.sendToDevice).toHaveBeenCalled(); + const [msgtype] = mocked(aliceClient.sendToDevice).mock.calls[0]; + expect(msgtype).toEqual("m.room.encrypted"); + + aliceClient.stopClient(); + bobClient.stopClient(); + }); + it("notifies devices when unable to create olm session", async function() { const aliceClient = (new TestClient( "@alice:example.com", "alicedevice", @@ -594,8 +688,8 @@ describe("MegolmDecryption", function() { aliceClient.initCrypto(), bobClient.initCrypto(), ]); - const aliceDevice = aliceClient.crypto.olmDevice; - const bobDevice = bobClient.crypto.olmDevice; + const aliceDevice = aliceClient.crypto!.olmDevice; + const bobDevice = bobClient.crypto!.olmDevice; const encryptionCfg = { "algorithm": "m.megolm.v1.aes-sha2", @@ -632,10 +726,11 @@ describe("MegolmDecryption", function() { }, }; - aliceClient.crypto.deviceList.storeDevicesForUser( + aliceClient.crypto!.deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); - aliceClient.crypto.deviceList.downloadKeys = async function(userIds) { + aliceClient.crypto!.deviceList.downloadKeys = async function(userIds) { + // @ts-ignore private return this.getDevicesFromStore(userIds); }; @@ -654,7 +749,7 @@ describe("MegolmDecryption", function() { event_id: "$event", content: {}, }); - await aliceClient.crypto.encryptEvent(event, aliceRoom); + await aliceClient.crypto!.encryptEvent(event, aliceRoom); expect(aliceClient.sendToDevice).toHaveBeenCalled(); const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0]; @@ -685,10 +780,10 @@ describe("MegolmDecryption", function() { aliceClient.initCrypto(), bobClient.initCrypto(), ]); - const bobDevice = bobClient.crypto.olmDevice; + const bobDevice = bobClient.crypto!.olmDevice; const aliceEventEmitter = new TypedEventEmitter(); - aliceClient.crypto.registerEventHandlers(aliceEventEmitter); + aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); const roomId = "!someroom"; @@ -705,7 +800,7 @@ describe("MegolmDecryption", function() { }, })); - await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({ + await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", @@ -732,7 +827,7 @@ describe("MegolmDecryption", function() { }, })); - await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({ + await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", @@ -762,10 +857,10 @@ describe("MegolmDecryption", function() { ]); const aliceEventEmitter = new TypedEventEmitter(); - aliceClient.crypto.registerEventHandlers(aliceEventEmitter); + aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); - aliceClient.crypto.downloadKeys = jest.fn(); - const bobDevice = bobClient.crypto.olmDevice; + aliceClient.crypto!.downloadKeys = jest.fn(); + const bobDevice = bobClient.crypto!.olmDevice; const roomId = "!someroom"; @@ -788,7 +883,7 @@ describe("MegolmDecryption", function() { setTimeout(resolve, 100); }); - await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({ + await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", @@ -820,7 +915,7 @@ describe("MegolmDecryption", function() { setTimeout(resolve, 100); }); - await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({ + await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", @@ -850,10 +945,10 @@ describe("MegolmDecryption", function() { bobClient.initCrypto(), ]); const aliceEventEmitter = new TypedEventEmitter(); - aliceClient.crypto.registerEventHandlers(aliceEventEmitter); + aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); - const bobDevice = bobClient.crypto.olmDevice; - aliceClient.crypto.downloadKeys = jest.fn(); + const bobDevice = bobClient.crypto!.olmDevice; + aliceClient.crypto!.downloadKeys = jest.fn(); const roomId = "!someroom"; @@ -875,7 +970,7 @@ describe("MegolmDecryption", function() { setTimeout(resolve, 100); }); - await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({ + await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", diff --git a/src/@types/event.ts b/src/@types/event.ts index 6ecde1a12..3bb720138 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -59,6 +59,10 @@ export enum EventType { KeyVerificationCancel = "m.key.verification.cancel", KeyVerificationMac = "m.key.verification.mac", KeyVerificationDone = "m.key.verification.done", + KeyVerificationKey = "m.key.verification.key", + KeyVerificationAccept = "m.key.verification.accept", + // XXX this event is not yet supported by js-sdk + KeyVerificationReady = "m.key.verification.ready", // use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback RoomMessageFeedback = "m.room.message.feedback", Reaction = "m.reaction", diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index a831c5650..5c358950a 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -36,6 +36,7 @@ import { DeviceInfo } from "../deviceinfo"; import { IOlmSessionResult } from "../olmlib"; import { DeviceInfoMap } from "../DeviceList"; import { MatrixEvent } from "../../models/event"; +import { EventType, MsgType } from '../../@types/event'; import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; import { RoomKeyRequestState } from '../OutgoingRoomKeyRequestManager'; import { OlmGroupSessionExtraData } from "../../@types/crypto"; @@ -1019,7 +1020,12 @@ class MegolmEncryption extends EncryptionAlgorithm { } } - const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + /** + * When using in-room messages and the room has encryption enabled, + * clients should ensure that encryption does not hinder the verification. + */ + const forceDistributeToUnverified = this.isVerificationEvent(eventType, content); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room, forceDistributeToUnverified); // check if any of these devices are not yet known to the user. // if so, warn the user so they can verify or ignore. @@ -1053,6 +1059,26 @@ class MegolmEncryption extends EncryptionAlgorithm { return encryptedContent; } + private isVerificationEvent(eventType: string, content: object): boolean { + switch (eventType) { + case EventType.KeyVerificationCancel: + case EventType.KeyVerificationDone: + case EventType.KeyVerificationMac: + case EventType.KeyVerificationStart: + case EventType.KeyVerificationKey: + case EventType.KeyVerificationReady: + case EventType.KeyVerificationAccept: { + return true; + } + case EventType.RoomMessage: { + return content['msgtype'] === MsgType.KeyVerificationRequest; + } + default: { + return false; + } + } + } + /** * Forces the current outbound group session to be discarded such * that another one will be created next time an event is sent. @@ -1119,6 +1145,8 @@ class MegolmEncryption extends EncryptionAlgorithm { * Get the list of unblocked devices for all users in the room * * @param {module:models/room} room + * @param forceDistributeToUnverified if set to true will include the unverified devices + * even if setting is set to block them (useful for verification) * * @return {Promise} Promise which resolves to an array whose * first element is a map from userId to deviceId to deviceInfo indicating @@ -1126,7 +1154,10 @@ class MegolmEncryption extends EncryptionAlgorithm { * element is a map from userId to deviceId to data indicating the devices * that are in the room but that have been blocked */ - private async getDevicesInRoom(room: Room): Promise<[DeviceInfoMap, IBlockedMap]> { + private async getDevicesInRoom( + room: Room, + forceDistributeToUnverified = false, + ): Promise<[DeviceInfoMap, IBlockedMap]> { const members = await room.getEncryptionTargetMembers(); const roomMembers = members.map(function(u) { return u.userId; @@ -1161,7 +1192,7 @@ class MegolmEncryption extends EncryptionAlgorithm { const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); if (userDevices[deviceId].isBlocked() || - (!deviceTrust.isVerified() && isBlacklisting) + (!deviceTrust.isVerified() && isBlacklisting && !forceDistributeToUnverified) ) { if (!blocked[userId]) { blocked[userId] = {}; diff --git a/src/crypto/verification/Base.ts b/src/crypto/verification/Base.ts index ddf38f8ce..72cd71d9e 100644 --- a/src/crypto/verification/Base.ts +++ b/src/crypto/verification/Base.ts @@ -21,6 +21,7 @@ limitations under the License. */ import { MatrixEvent } from '../../models/event'; +import { EventType } from '../../@types/event'; import { logger } from '../../logger'; import { DeviceInfo } from '../deviceinfo'; import { newTimeoutError } from "./Error"; @@ -182,13 +183,13 @@ export class VerificationBase< } else if (e.getType() === this.expectedEvent) { // if we receive an expected m.key.verification.done, then just // ignore it, since we don't need to do anything about it - if (this.expectedEvent !== "m.key.verification.done") { + if (this.expectedEvent !== EventType.KeyVerificationDone) { this.expectedEvent = undefined; this.rejectEvent = undefined; this.resetTimer(); this.resolveEvent(e); } - } else if (e.getType() === "m.key.verification.cancel") { + } else if (e.getType() === EventType.KeyVerificationCancel) { const reject = this.reject; this.reject = undefined; // there is only promise to reject if verify has been called @@ -241,20 +242,20 @@ export class VerificationBase< const sender = e.getSender(); if (sender !== this.userId) { const content = e.getContent(); - if (e.getType() === "m.key.verification.cancel") { + if (e.getType() === EventType.KeyVerificationCancel) { content.code = content.code || "m.unknown"; content.reason = content.reason || content.body || "Unknown reason"; - this.send("m.key.verification.cancel", content); + this.send(EventType.KeyVerificationCancel, content); } else { - this.send("m.key.verification.cancel", { + this.send(EventType.KeyVerificationCancel, { code: "m.unknown", reason: content.body || "Unknown reason", }); } } } else { - this.send("m.key.verification.cancel", { + this.send(EventType.KeyVerificationCancel, { code: "m.unknown", reason: e.toString(), }); diff --git a/src/crypto/verification/Error.ts b/src/crypto/verification/Error.ts index 35ea7003d..0caae788a 100644 --- a/src/crypto/verification/Error.ts +++ b/src/crypto/verification/Error.ts @@ -21,11 +21,12 @@ limitations under the License. */ import { MatrixEvent } from "../../models/event"; +import { EventType } from '../../@types/event'; export function newVerificationError(code: string, reason: string, extraData: Record): MatrixEvent { const content = Object.assign({}, { code, reason }, extraData); return new MatrixEvent({ - type: "m.key.verification.cancel", + type: EventType.KeyVerificationCancel, content, }); } diff --git a/src/crypto/verification/SAS.ts b/src/crypto/verification/SAS.ts index 9e68e70ca..53c0331cf 100644 --- a/src/crypto/verification/SAS.ts +++ b/src/crypto/verification/SAS.ts @@ -32,13 +32,14 @@ import { } from './Error'; import { logger } from '../../logger'; import { IContent, MatrixEvent } from "../../models/event"; +import { EventType } from '../../@types/event'; -const START_TYPE = "m.key.verification.start"; +const START_TYPE = EventType.KeyVerificationStart; const EVENTS = [ - "m.key.verification.accept", - "m.key.verification.key", - "m.key.verification.mac", + EventType.KeyVerificationAccept, + EventType.KeyVerificationKey, + EventType.KeyVerificationMac, ]; let olmutil: Utility; @@ -310,6 +311,45 @@ export class SAS extends Base { return startContent; } + private async verifyAndCheckMAC( + keyAgreement: string, + sasMethods: string[], + olmSAS: OlmSAS, + macMethod: string, + ): Promise { + const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); + const verifySAS = new Promise((resolve, reject) => { + this.sasEvent = { + sas: generateSas(sasBytes, sasMethods), + confirm: async () => { + try { + await this.sendMAC(olmSAS, macMethod); + resolve(); + } catch (err) { + reject(err); + } + }, + cancel: () => reject(newUserCancelledError()), + mismatch: () => reject(newMismatchedSASError()), + }; + this.emit(SasEvent.ShowSas, this.sasEvent); + }); + + const [e] = await Promise.all([ + this.waitForEvent(EventType.KeyVerificationMac) + .then((e) => { + // we don't expect any more messages from the other + // party, and they may send a m.key.verification.done + // when they're done on their end + this.expectedEvent = EventType.KeyVerificationDone; + return e; + }), + verifySAS, + ]); + const content = e.getContent(); + await this.checkMAC(olmSAS, content, macMethod); + } + private async doSendVerification(): Promise { this.waitingForAccept = true; let startContent; @@ -329,7 +369,7 @@ export class SAS extends Base { let e; try { - e = await this.waitForEvent("m.key.verification.accept"); + e = await this.waitForEvent(EventType.KeyVerificationAccept); } finally { this.waitingForAccept = false; } @@ -351,11 +391,11 @@ export class SAS extends Base { const olmSAS = new global.Olm.SAS(); try { this.ourSASPubKey = olmSAS.get_pubkey(); - await this.send("m.key.verification.key", { + await this.send(EventType.KeyVerificationKey, { key: this.ourSASPubKey, }); - e = await this.waitForEvent("m.key.verification.key"); + e = await this.waitForEvent(EventType.KeyVerificationKey); // FIXME: make sure event is properly formed content = e.getContent(); const commitmentStr = content.key + anotherjson.stringify(startContent); @@ -366,37 +406,7 @@ export class SAS extends Base { this.theirSASPubKey = content.key; olmSAS.set_their_key(content.key); - const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); - const verifySAS = new Promise((resolve, reject) => { - this.sasEvent = { - sas: generateSas(sasBytes, sasMethods), - confirm: async () => { - try { - await this.sendMAC(olmSAS, macMethod); - resolve(); - } catch (err) { - reject(err); - } - }, - cancel: () => reject(newUserCancelledError()), - mismatch: () => reject(newMismatchedSASError()), - }; - this.emit(SasEvent.ShowSas, this.sasEvent); - }); - - [e] = await Promise.all([ - this.waitForEvent("m.key.verification.mac") - .then((e) => { - // we don't expect any more messages from the other - // party, and they may send a m.key.verification.done - // when they're done on their end - this.expectedEvent = "m.key.verification.done"; - return e; - }), - verifySAS, - ]); - content = e.getContent(); - await this.checkMAC(olmSAS, content, macMethod); + await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); } finally { olmSAS.free(); } @@ -423,7 +433,7 @@ export class SAS extends Base { const olmSAS = new global.Olm.SAS(); try { const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content); - await this.send("m.key.verification.accept", { + await this.send(EventType.KeyVerificationAccept, { key_agreement_protocol: keyAgreement, hash: hashMethod, message_authentication_code: macMethod, @@ -432,47 +442,17 @@ export class SAS extends Base { commitment: olmutil.sha256(commitmentStr), }); - let e = await this.waitForEvent("m.key.verification.key"); + const e = await this.waitForEvent(EventType.KeyVerificationKey); // FIXME: make sure event is properly formed content = e.getContent(); this.theirSASPubKey = content.key; olmSAS.set_their_key(content.key); this.ourSASPubKey = olmSAS.get_pubkey(); - await this.send("m.key.verification.key", { + await this.send(EventType.KeyVerificationKey, { key: this.ourSASPubKey, }); - const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); - const verifySAS = new Promise((resolve, reject) => { - this.sasEvent = { - sas: generateSas(sasBytes, sasMethods), - confirm: async () => { - try { - await this.sendMAC(olmSAS, macMethod); - resolve(); - } catch (err) { - reject(err); - } - }, - cancel: () => reject(newUserCancelledError()), - mismatch: () => reject(newMismatchedSASError()), - }; - this.emit(SasEvent.ShowSas, this.sasEvent); - }); - - [e] = await Promise.all([ - this.waitForEvent("m.key.verification.mac") - .then((e) => { - // we don't expect any more messages from the other - // party, and they may send a m.key.verification.done - // when they're done on their end - this.expectedEvent = "m.key.verification.done"; - return e; - }), - verifySAS, - ]); - content = e.getContent(); - await this.checkMAC(olmSAS, content, macMethod); + await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); } finally { olmSAS.free(); } @@ -480,7 +460,7 @@ export class SAS extends Base { private sendMAC(olmSAS: OlmSAS, method: string): Promise { const mac = {}; - const keyList = []; + const keyList: string[] = []; const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + this.baseApis.getUserId() + this.baseApis.deviceId + this.userId + this.deviceId @@ -507,7 +487,7 @@ export class SAS extends Base { keyList.sort().join(","), baseInfo + "KEY_IDS", ); - return this.send("m.key.verification.mac", { mac, keys }); + return this.send(EventType.KeyVerificationMac, { mac, keys }); } private async checkMAC(olmSAS: OlmSAS, content: IContent, method: string): Promise { diff --git a/src/crypto/verification/request/VerificationRequest.ts b/src/crypto/verification/request/VerificationRequest.ts index d084a642d..6ecba447e 100644 --- a/src/crypto/verification/request/VerificationRequest.ts +++ b/src/crypto/verification/request/VerificationRequest.ts @@ -25,6 +25,7 @@ import { QRCodeData, SCAN_QR_CODE_METHOD } from "../QRCode"; import { IVerificationChannel } from "./Channel"; import { MatrixClient } from "../../../client"; import { MatrixEvent } from "../../../models/event"; +import { EventType } from '../../../@types/event'; import { VerificationBase } from "../Base"; import { VerificationMethod } from "../../index"; import { TypedEventEmitter } from "../../../models/typed-event-emitter"; @@ -931,7 +932,7 @@ export class VerificationRequest< } public onVerifierFinished(): void { - this.channel.send("m.key.verification.done", {}); + this.channel.send(EventType.KeyVerificationDone, {}); this.verifierHasFinished = true; // move to .done phase const newTransitions = this.applyPhaseTransitions();