diff options
author | RaindropsSys <contact@minteck.org> | 2023-04-24 14:03:36 +0200 |
---|---|---|
committer | RaindropsSys <contact@minteck.org> | 2023-04-24 14:03:36 +0200 |
commit | 633c92eae865e957121e08de634aeee11a8b3992 (patch) | |
tree | 09d881bee1dae0b6eee49db1dfaf0f500240606c /includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification | |
parent | c4657e4509733699c0f26a3c900bab47e915d5a0 (diff) | |
download | pluralconnect-633c92eae865e957121e08de634aeee11a8b3992.tar.gz pluralconnect-633c92eae865e957121e08de634aeee11a8b3992.tar.bz2 pluralconnect-633c92eae865e957121e08de634aeee11a8b3992.zip |
Updated 18 files, added 1692 files and deleted includes/system/compare.inc (automated)
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification')
10 files changed, 3005 insertions, 0 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts new file mode 100644 index 0000000..89c700c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts @@ -0,0 +1,369 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Base class for verification methods. + */ + +import { MatrixEvent } from "../../models/event"; +import { EventType } from "../../@types/event"; +import { logger } from "../../logger"; +import { DeviceInfo } from "../deviceinfo"; +import { newTimeoutError } from "./Error"; +import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossSigning"; +import { IVerificationChannel } from "./request/Channel"; +import { MatrixClient } from "../../client"; +import { VerificationRequest } from "./request/VerificationRequest"; +import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter"; + +const timeoutException = new Error("Verification timed out"); + +export class SwitchStartEventError extends Error { + public constructor(public readonly startEvent: MatrixEvent | null) { + super(); + } +} + +export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void; + +export enum VerificationEvent { + Cancel = "cancel", +} + +export type VerificationEventHandlerMap = { + [VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void; +}; + +export class VerificationBase< + Events extends string, + Arguments extends ListenerMap<Events | VerificationEvent>, +> extends TypedEventEmitter<Events | VerificationEvent, Arguments, VerificationEventHandlerMap> { + private cancelled = false; + private _done = false; + private promise: Promise<void> | null = null; + private transactionTimeoutTimer: ReturnType<typeof setTimeout> | null = null; + protected expectedEvent?: string; + private resolve?: () => void; + private reject?: (e: Error | MatrixEvent) => void; + private resolveEvent?: (e: MatrixEvent) => void; + private rejectEvent?: (e: Error) => void; + private started?: boolean; + + /** + * Base class for verification methods. + * + * <p>Once a verifier object is created, the verification can be started by + * calling the verify() method, which will return a promise that will + * resolve when the verification is completed, or reject if it could not + * complete.</p> + * + * <p>Subclasses must have a NAME class property.</p> + * + * @param channel - the verification channel to send verification messages over. + * TODO: Channel types + * + * @param baseApis - base matrix api interface + * + * @param userId - the user ID that is being verified + * + * @param deviceId - the device ID that is being verified + * + * @param startEvent - the m.key.verification.start event that + * initiated this verification, if any + * + * @param request - the key verification request object related to + * this verification, if any + */ + public constructor( + public readonly channel: IVerificationChannel, + public readonly baseApis: MatrixClient, + public readonly userId: string, + public readonly deviceId: string, + public startEvent: MatrixEvent | null, + public readonly request: VerificationRequest, + ) { + super(); + } + + public get initiatedByMe(): boolean { + // if there is no start event yet, + // we probably want to send it, + // which happens if we initiate + if (!this.startEvent) { + return true; + } + const sender = this.startEvent.getSender(); + const content = this.startEvent.getContent(); + return sender === this.baseApis.getUserId() && content.from_device === this.baseApis.getDeviceId(); + } + + public get hasBeenCancelled(): boolean { + return this.cancelled; + } + + private resetTimer(): void { + logger.info("Refreshing/starting the verification transaction timeout timer"); + if (this.transactionTimeoutTimer !== null) { + clearTimeout(this.transactionTimeoutTimer); + } + this.transactionTimeoutTimer = setTimeout(() => { + if (!this._done && !this.cancelled) { + logger.info("Triggering verification timeout"); + this.cancel(timeoutException); + } + }, 10 * 60 * 1000); // 10 minutes + } + + private endTimer(): void { + if (this.transactionTimeoutTimer !== null) { + clearTimeout(this.transactionTimeoutTimer); + this.transactionTimeoutTimer = null; + } + } + + protected send(type: string, uncompletedContent: Record<string, any>): Promise<void> { + return this.channel.send(type, uncompletedContent); + } + + protected waitForEvent(type: string): Promise<MatrixEvent> { + if (this._done) { + return Promise.reject(new Error("Verification is already done")); + } + const existingEvent = this.request.getEventFromOtherParty(type); + if (existingEvent) { + return Promise.resolve(existingEvent); + } + + this.expectedEvent = type; + return new Promise((resolve, reject) => { + this.resolveEvent = resolve; + this.rejectEvent = reject; + }); + } + + public canSwitchStartEvent(event: MatrixEvent): boolean { + return false; + } + + public switchStartEvent(event: MatrixEvent): void { + if (this.canSwitchStartEvent(event)) { + logger.log("Verification Base: switching verification start event", { restartingFlow: !!this.rejectEvent }); + if (this.rejectEvent) { + const reject = this.rejectEvent; + this.rejectEvent = undefined; + reject(new SwitchStartEventError(event)); + } else { + this.startEvent = event; + } + } + } + + public handleEvent(e: MatrixEvent): void { + if (this._done) { + return; + } 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 !== EventType.KeyVerificationDone) { + this.expectedEvent = undefined; + this.rejectEvent = undefined; + this.resetTimer(); + this.resolveEvent?.(e); + } + } else if (e.getType() === EventType.KeyVerificationCancel) { + const reject = this.reject; + this.reject = undefined; + // there is only promise to reject if verify has been called + if (reject) { + const content = e.getContent(); + const { reason, code } = content; + reject(new Error(`Other side cancelled verification ` + `because ${reason} (${code})`)); + } + } else if (this.expectedEvent) { + // only cancel if there is an event expected. + // if there is no event expected, it means verify() wasn't called + // and we're just replaying the timeline events when syncing + // after a refresh when the events haven't been stored in the cache yet. + const exception = new Error( + "Unexpected message: expecting " + this.expectedEvent + " but got " + e.getType(), + ); + this.expectedEvent = undefined; + if (this.rejectEvent) { + const reject = this.rejectEvent; + this.rejectEvent = undefined; + reject(exception); + } + this.cancel(exception); + } + } + + public async done(): Promise<KeysDuringVerification | void> { + this.endTimer(); // always kill the activity timer + if (!this._done) { + this.request.onVerifierFinished(); + this.resolve?.(); + return requestKeysDuringVerification(this.baseApis, this.userId, this.deviceId); + } + } + + public cancel(e: Error | MatrixEvent): void { + this.endTimer(); // always kill the activity timer + if (!this._done) { + this.cancelled = true; + this.request.onVerifierCancelled(); + if (this.userId && this.deviceId) { + // send a cancellation to the other user (if it wasn't + // cancelled by the other user) + if (e === timeoutException) { + const timeoutEvent = newTimeoutError(); + this.send(timeoutEvent.getType(), timeoutEvent.getContent()); + } else if (e instanceof MatrixEvent) { + const sender = e.getSender(); + if (sender !== this.userId) { + const content = e.getContent(); + if (e.getType() === EventType.KeyVerificationCancel) { + content.code = content.code || "m.unknown"; + content.reason = content.reason || content.body || "Unknown reason"; + this.send(EventType.KeyVerificationCancel, content); + } else { + this.send(EventType.KeyVerificationCancel, { + code: "m.unknown", + reason: content.body || "Unknown reason", + }); + } + } + } else { + this.send(EventType.KeyVerificationCancel, { + code: "m.unknown", + reason: e.toString(), + }); + } + } + if (this.promise !== null) { + // when we cancel without a promise, we end up with a promise + // but no reject function. If cancel is called again, we'd error. + if (this.reject) this.reject(e); + } else { + // FIXME: this causes an "Uncaught promise" console message + // if nothing ends up chaining this promise. + this.promise = Promise.reject(e); + } + // Also emit a 'cancel' event that the app can listen for to detect cancellation + // before calling verify() + this.emit(VerificationEvent.Cancel, e); + } + } + + /** + * Begin the key verification + * + * @returns Promise which resolves when the verification has + * completed. + */ + public verify(): Promise<void> { + if (this.promise) return this.promise; + + this.promise = new Promise((resolve, reject) => { + this.resolve = (...args): void => { + this._done = true; + this.endTimer(); + resolve(...args); + }; + this.reject = (e: Error | MatrixEvent): void => { + this._done = true; + this.endTimer(); + reject(e); + }; + }); + if (this.doVerification && !this.started) { + this.started = true; + this.resetTimer(); // restart the timeout + new Promise<void>((resolve, reject) => { + const crossSignId = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(this.userId)?.getId(); + if (crossSignId === this.deviceId) { + reject(new Error("Device ID is the same as the cross-signing ID")); + } + resolve(); + }) + .then(() => this.doVerification!()) + .then(this.done.bind(this), this.cancel.bind(this)); + } + return this.promise; + } + + protected doVerification?: () => Promise<void>; + + protected async verifyKeys(userId: string, keys: Record<string, string>, verifier: KeyVerifier): Promise<void> { + // we try to verify all the keys that we're told about, but we might + // not know about all of them, so keep track of the keys that we know + // about, and ignore the rest + const verifiedDevices: [string, string, string][] = []; + + for (const [keyId, keyInfo] of Object.entries(keys)) { + const deviceId = keyId.split(":", 2)[1]; + const device = this.baseApis.getStoredDevice(userId, deviceId); + if (device) { + verifier(keyId, device, keyInfo); + verifiedDevices.push([deviceId, keyId, device.keys[keyId]]); + } else { + const crossSigningInfo = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigningInfo && crossSigningInfo.getId() === deviceId) { + verifier( + keyId, + DeviceInfo.fromStorage( + { + keys: { + [keyId]: deviceId, + }, + }, + deviceId, + ), + keyInfo, + ); + verifiedDevices.push([deviceId, keyId, deviceId]); + } else { + logger.warn(`verification: Could not find device ${deviceId} to verify`); + } + } + } + + // if none of the keys could be verified, then error because the app + // should be informed about that + if (!verifiedDevices.length) { + throw new Error("No devices could be verified"); + } + + logger.info("Verification completed! Marking devices verified: ", verifiedDevices); + // TODO: There should probably be a batch version of this, otherwise it's going + // to upload each signature in a separate API call which is silly because the + // API supports as many signatures as you like. + for (const [deviceId, keyId, key] of verifiedDevices) { + await this.baseApis.crypto!.setDeviceVerification(userId, deviceId, true, null, null, { [keyId]: key }); + } + + // if one of the user's own devices is being marked as verified / unverified, + // check the key backup status, since whether or not we use this depends on + // whether it has a signature from a verified device + if (userId == this.baseApis.credentials.userId) { + await this.baseApis.checkKeyBackup(); + } + } + + public get events(): string[] | undefined { + return undefined; + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts new file mode 100644 index 0000000..da73ebb --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts @@ -0,0 +1,76 @@ +/* +Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Error messages. + */ + +import { MatrixEvent } from "../../models/event"; +import { EventType } from "../../@types/event"; + +export function newVerificationError(code: string, reason: string, extraData?: Record<string, any>): MatrixEvent { + const content = Object.assign({}, { code, reason }, extraData); + return new MatrixEvent({ + type: EventType.KeyVerificationCancel, + content, + }); +} + +export function errorFactory(code: string, reason: string): (extraData?: Record<string, any>) => MatrixEvent { + return function (extraData?: Record<string, any>) { + return newVerificationError(code, reason, extraData); + }; +} + +/** + * The verification was cancelled by the user. + */ +export const newUserCancelledError = errorFactory("m.user", "Cancelled by user"); + +/** + * The verification timed out. + */ +export const newTimeoutError = errorFactory("m.timeout", "Timed out"); + +/** + * An unknown method was selected. + */ +export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method"); + +/** + * An unexpected message was sent. + */ +export const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message"); + +/** + * The key does not match. + */ +export const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch"); + +/** + * An invalid message was sent. + */ +export const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message"); + +export function errorFromEvent(event: MatrixEvent): { code: string; reason: string } { + const content = event.getContent(); + if (content) { + const { code, reason } = content; + return { code, reason }; + } else { + return { code: "Unknown error", reason: "m.unknown" }; + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts new file mode 100644 index 0000000..c437e0c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts @@ -0,0 +1,50 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Verification method that is illegal to have (cannot possibly + * do verification with this method). + */ + +import { VerificationBase as Base, VerificationEvent, VerificationEventHandlerMap } from "./Base"; +import { IVerificationChannel } from "./request/Channel"; +import { MatrixClient } from "../../client"; +import { MatrixEvent } from "../../models/event"; +import { VerificationRequest } from "./request/VerificationRequest"; + +export class IllegalMethod extends Base<VerificationEvent, VerificationEventHandlerMap> { + public static factory( + channel: IVerificationChannel, + baseApis: MatrixClient, + userId: string, + deviceId: string, + startEvent: MatrixEvent, + request: VerificationRequest, + ): IllegalMethod { + return new IllegalMethod(channel, baseApis, userId, deviceId, startEvent, request); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public static get NAME(): string { + // Typically the name will be something else, but to complete + // the contract we offer a default one here. + return "org.matrix.illegal_method"; + } + + protected doVerification = async (): Promise<void> => { + throw new Error("Verification is not possible with this method"); + }; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts new file mode 100644 index 0000000..bfb532e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts @@ -0,0 +1,311 @@ +/* +Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * QR code key verification. + */ + +import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base"; +import { newKeyMismatchError, newUserCancelledError } from "./Error"; +import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib"; +import { logger } from "../../logger"; +import { VerificationRequest } from "./request/VerificationRequest"; +import { MatrixClient } from "../../client"; +import { IVerificationChannel } from "./request/Channel"; +import { MatrixEvent } from "../../models/event"; + +export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1"; +export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1"; + +interface IReciprocateQr { + confirm(): void; + cancel(): void; +} + +export enum QrCodeEvent { + ShowReciprocateQr = "show_reciprocate_qr", +} + +type EventHandlerMap = { + [QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void; +} & VerificationEventHandlerMap; + +export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> { + public reciprocateQREvent?: IReciprocateQr; + + public static factory( + channel: IVerificationChannel, + baseApis: MatrixClient, + userId: string, + deviceId: string, + startEvent: MatrixEvent, + request: VerificationRequest, + ): ReciprocateQRCode { + return new ReciprocateQRCode(channel, baseApis, userId, deviceId, startEvent, request); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public static get NAME(): string { + return "m.reciprocate.v1"; + } + + protected doVerification = async (): Promise<void> => { + if (!this.startEvent) { + // TODO: Support scanning QR codes + throw new Error("It is not currently possible to start verification" + "with this method yet."); + } + + const { qrCodeData } = this.request; + // 1. check the secret + if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) { + throw newKeyMismatchError(); + } + + // 2. ask if other user shows shield as well + await new Promise<void>((resolve, reject) => { + this.reciprocateQREvent = { + confirm: resolve, + cancel: () => reject(newUserCancelledError()), + }; + this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent); + }); + + // 3. determine key to sign / mark as trusted + const keys: Record<string, string> = {}; + + switch (qrCodeData?.mode) { + case Mode.VerifyOtherUser: { + // add master key to keys to be signed, only if we're not doing self-verification + const masterKey = qrCodeData.otherUserMasterKey; + keys[`ed25519:${masterKey}`] = masterKey!; + break; + } + case Mode.VerifySelfTrusted: { + const deviceId = this.request.targetDevice.deviceId; + keys[`ed25519:${deviceId}`] = qrCodeData.otherDeviceKey!; + break; + } + case Mode.VerifySelfUntrusted: { + const masterKey = qrCodeData.myMasterKey; + keys[`ed25519:${masterKey}`] = masterKey!; + break; + } + } + + // 4. sign the key (or mark own MSK as verified in case of MODE_VERIFY_SELF_TRUSTED) + await this.verifyKeys(this.userId, keys, (keyId, device, keyInfo) => { + // make sure the device has the expected keys + const targetKey = keys[keyId]; + if (!targetKey) throw newKeyMismatchError(); + + if (keyInfo !== targetKey) { + logger.error("key ID from key info does not match"); + throw newKeyMismatchError(); + } + for (const deviceKeyId in device.keys) { + if (!deviceKeyId.startsWith("ed25519")) continue; + const deviceTargetKey = keys[deviceKeyId]; + if (!deviceTargetKey) throw newKeyMismatchError(); + if (device.keys[deviceKeyId] !== deviceTargetKey) { + logger.error("master key does not match"); + throw newKeyMismatchError(); + } + } + }); + }; +} + +const CODE_VERSION = 0x02; // the version of binary QR codes we support +const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format + +enum Mode { + VerifyOtherUser = 0x00, // Verifying someone who isn't us + VerifySelfTrusted = 0x01, // We trust the master key + VerifySelfUntrusted = 0x02, // We do not trust the master key +} + +interface IQrData { + prefix: string; + version: number; + mode: Mode; + transactionId?: string; + firstKeyB64: string; + secondKeyB64: string; + secretB64: string; +} + +export class QRCodeData { + public constructor( + public readonly mode: Mode, + private readonly sharedSecret: string, + // only set when mode is MODE_VERIFY_OTHER_USER, master key of other party at time of generating QR code + public readonly otherUserMasterKey: string | null, + // only set when mode is MODE_VERIFY_SELF_TRUSTED, device key of other party at time of generating QR code + public readonly otherDeviceKey: string | null, + // only set when mode is MODE_VERIFY_SELF_UNTRUSTED, own master key at time of generating QR code + public readonly myMasterKey: string | null, + private readonly buffer: Buffer, + ) {} + + public static async create(request: VerificationRequest, client: MatrixClient): Promise<QRCodeData> { + const sharedSecret = QRCodeData.generateSharedSecret(); + const mode = QRCodeData.determineMode(request, client); + let otherUserMasterKey: string | null = null; + let otherDeviceKey: string | null = null; + let myMasterKey: string | null = null; + if (mode === Mode.VerifyOtherUser) { + const otherUserCrossSigningInfo = client.getStoredCrossSigningForUser(request.otherUserId); + otherUserMasterKey = otherUserCrossSigningInfo!.getId("master"); + } else if (mode === Mode.VerifySelfTrusted) { + otherDeviceKey = await QRCodeData.getOtherDeviceKey(request, client); + } else if (mode === Mode.VerifySelfUntrusted) { + const myUserId = client.getUserId()!; + const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); + myMasterKey = myCrossSigningInfo!.getId("master"); + } + const qrData = QRCodeData.generateQrData( + request, + client, + mode, + sharedSecret, + otherUserMasterKey!, + otherDeviceKey!, + myMasterKey!, + ); + const buffer = QRCodeData.generateBuffer(qrData); + return new QRCodeData(mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, buffer); + } + + /** + * The unpadded base64 encoded shared secret. + */ + public get encodedSharedSecret(): string { + return this.sharedSecret; + } + + public getBuffer(): Buffer { + return this.buffer; + } + + private static generateSharedSecret(): string { + const secretBytes = new Uint8Array(11); + global.crypto.getRandomValues(secretBytes); + return encodeUnpaddedBase64(secretBytes); + } + + private static async getOtherDeviceKey(request: VerificationRequest, client: MatrixClient): Promise<string> { + const myUserId = client.getUserId()!; + const otherDevice = request.targetDevice; + const device = otherDevice.deviceId ? client.getStoredDevice(myUserId, otherDevice.deviceId) : undefined; + if (!device) { + throw new Error("could not find device " + otherDevice?.deviceId); + } + return device.getFingerprint(); + } + + private static determineMode(request: VerificationRequest, client: MatrixClient): Mode { + const myUserId = client.getUserId(); + const otherUserId = request.otherUserId; + + let mode = Mode.VerifyOtherUser; + if (myUserId === otherUserId) { + // Mode changes depending on whether or not we trust the master cross signing key + const myTrust = client.checkUserTrust(myUserId); + if (myTrust.isCrossSigningVerified()) { + mode = Mode.VerifySelfTrusted; + } else { + mode = Mode.VerifySelfUntrusted; + } + } + return mode; + } + + private static generateQrData( + request: VerificationRequest, + client: MatrixClient, + mode: Mode, + encodedSharedSecret: string, + otherUserMasterKey?: string, + otherDeviceKey?: string, + myMasterKey?: string, + ): IQrData { + const myUserId = client.getUserId()!; + const transactionId = request.channel.transactionId; + const qrData: IQrData = { + prefix: BINARY_PREFIX, + version: CODE_VERSION, + mode, + transactionId, + firstKeyB64: "", // worked out shortly + secondKeyB64: "", // worked out shortly + secretB64: encodedSharedSecret, + }; + + const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); + + if (mode === Mode.VerifyOtherUser) { + // First key is our master cross signing key + qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!; + // Second key is the other user's master cross signing key + qrData.secondKeyB64 = otherUserMasterKey!; + } else if (mode === Mode.VerifySelfTrusted) { + // First key is our master cross signing key + qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!; + qrData.secondKeyB64 = otherDeviceKey!; + } else if (mode === Mode.VerifySelfUntrusted) { + // First key is our device's key + qrData.firstKeyB64 = client.getDeviceEd25519Key()!; + // Second key is what we think our master cross signing key is + qrData.secondKeyB64 = myMasterKey!; + } + return qrData; + } + + private static generateBuffer(qrData: IQrData): Buffer { + let buf = Buffer.alloc(0); // we'll concat our way through life + + const appendByte = (b: number): void => { + const tmpBuf = Buffer.from([b]); + buf = Buffer.concat([buf, tmpBuf]); + }; + const appendInt = (i: number): void => { + const tmpBuf = Buffer.alloc(2); + tmpBuf.writeInt16BE(i, 0); + buf = Buffer.concat([buf, tmpBuf]); + }; + const appendStr = (s: string, enc: BufferEncoding, withLengthPrefix = true): void => { + const tmpBuf = Buffer.from(s, enc); + if (withLengthPrefix) appendInt(tmpBuf.byteLength); + buf = Buffer.concat([buf, tmpBuf]); + }; + const appendEncBase64 = (b64: string): void => { + const b = decodeBase64(b64); + const tmpBuf = Buffer.from(b); + buf = Buffer.concat([buf, tmpBuf]); + }; + + // Actually build the buffer for the QR code + appendStr(qrData.prefix, "ascii", false); + appendByte(qrData.version); + appendByte(qrData.mode); + appendStr(qrData.transactionId!, "utf-8"); + appendEncBase64(qrData.firstKeyB64); + appendEncBase64(qrData.secondKeyB64); + appendEncBase64(qrData.secretB64); + + return buf; + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts new file mode 100644 index 0000000..a8d237d --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts @@ -0,0 +1,492 @@ +/* +Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Short Authentication String (SAS) verification. + */ + +import anotherjson from "another-json"; +import { Utility, SAS as OlmSAS } from "@matrix-org/olm"; + +import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base"; +import { + errorFactory, + newInvalidMessageError, + newKeyMismatchError, + newUnknownMethodError, + newUserCancelledError, +} from "./Error"; +import { logger } from "../../logger"; +import { IContent, MatrixEvent } from "../../models/event"; +import { generateDecimalSas } from "./SASDecimal"; +import { EventType } from "../../@types/event"; + +const START_TYPE = EventType.KeyVerificationStart; + +const EVENTS = [EventType.KeyVerificationAccept, EventType.KeyVerificationKey, EventType.KeyVerificationMac]; + +let olmutil: Utility; + +const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short authentication string"); + +const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment"); + +type EmojiMapping = [emoji: string, name: string]; + +const emojiMapping: EmojiMapping[] = [ + ["🐶", "dog"], // 0 + ["🐱", "cat"], // 1 + ["🦁", "lion"], // 2 + ["🐎", "horse"], // 3 + ["🦄", "unicorn"], // 4 + ["🐷", "pig"], // 5 + ["🐘", "elephant"], // 6 + ["🐰", "rabbit"], // 7 + ["🐼", "panda"], // 8 + ["🐓", "rooster"], // 9 + ["🐧", "penguin"], // 10 + ["🐢", "turtle"], // 11 + ["🐟", "fish"], // 12 + ["🐙", "octopus"], // 13 + ["🦋", "butterfly"], // 14 + ["🌷", "flower"], // 15 + ["🌳", "tree"], // 16 + ["🌵", "cactus"], // 17 + ["🍄", "mushroom"], // 18 + ["🌏", "globe"], // 19 + ["🌙", "moon"], // 20 + ["☁️", "cloud"], // 21 + ["🔥", "fire"], // 22 + ["🍌", "banana"], // 23 + ["🍎", "apple"], // 24 + ["🍓", "strawberry"], // 25 + ["🌽", "corn"], // 26 + ["🍕", "pizza"], // 27 + ["🎂", "cake"], // 28 + ["❤️", "heart"], // 29 + ["🙂", "smiley"], // 30 + ["🤖", "robot"], // 31 + ["🎩", "hat"], // 32 + ["👓", "glasses"], // 33 + ["🔧", "spanner"], // 34 + ["🎅", "santa"], // 35 + ["👍", "thumbs up"], // 36 + ["☂️", "umbrella"], // 37 + ["⌛", "hourglass"], // 38 + ["⏰", "clock"], // 39 + ["🎁", "gift"], // 40 + ["💡", "light bulb"], // 41 + ["📕", "book"], // 42 + ["✏️", "pencil"], // 43 + ["📎", "paperclip"], // 44 + ["✂️", "scissors"], // 45 + ["🔒", "lock"], // 46 + ["🔑", "key"], // 47 + ["🔨", "hammer"], // 48 + ["☎️", "telephone"], // 49 + ["🏁", "flag"], // 50 + ["🚂", "train"], // 51 + ["🚲", "bicycle"], // 52 + ["✈️", "aeroplane"], // 53 + ["🚀", "rocket"], // 54 + ["🏆", "trophy"], // 55 + ["⚽", "ball"], // 56 + ["🎸", "guitar"], // 57 + ["🎺", "trumpet"], // 58 + ["🔔", "bell"], // 59 + ["⚓️", "anchor"], // 60 + ["🎧", "headphones"], // 61 + ["📁", "folder"], // 62 + ["📌", "pin"], // 63 +]; + +function generateEmojiSas(sasBytes: number[]): EmojiMapping[] { + const emojis = [ + // just like base64 encoding + sasBytes[0] >> 2, + ((sasBytes[0] & 0x3) << 4) | (sasBytes[1] >> 4), + ((sasBytes[1] & 0xf) << 2) | (sasBytes[2] >> 6), + sasBytes[2] & 0x3f, + sasBytes[3] >> 2, + ((sasBytes[3] & 0x3) << 4) | (sasBytes[4] >> 4), + ((sasBytes[4] & 0xf) << 2) | (sasBytes[5] >> 6), + ]; + + return emojis.map((num) => emojiMapping[num]); +} + +const sasGenerators = { + decimal: generateDecimalSas, + emoji: generateEmojiSas, +} as const; + +export interface IGeneratedSas { + decimal?: [number, number, number]; + emoji?: EmojiMapping[]; +} + +export interface ISasEvent { + sas: IGeneratedSas; + confirm(): Promise<void>; + cancel(): void; + mismatch(): void; +} + +function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas { + const sas: IGeneratedSas = {}; + for (const method of methods) { + if (method in sasGenerators) { + // @ts-ignore - ts doesn't like us mixing types like this + sas[method] = sasGenerators[method](Array.from(sasBytes)); + } + } + return sas; +} + +const macMethods = { + "hkdf-hmac-sha256": "calculate_mac", + "org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64", + "hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64", + "hmac-sha256": "calculate_mac_long_kdf", +} as const; + +type MacMethod = keyof typeof macMethods; + +function calculateMAC(olmSAS: OlmSAS, method: MacMethod) { + return function (input: string, info: string): string { + const mac = olmSAS[macMethods[method]](input, info); + logger.log("SAS calculateMAC:", method, [input, info], mac); + return mac; + }; +} + +const calculateKeyAgreement = { + // eslint-disable-next-line @typescript-eslint/naming-convention + "curve25519-hkdf-sha256": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { + const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` + `${sas.ourSASPubKey}|`; + const theirInfo = `${sas.userId}|${sas.deviceId}|${sas.theirSASPubKey}|`; + const sasInfo = + "MATRIX_KEY_VERIFICATION_SAS|" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + + sas.channel.transactionId; + return olmSAS.generate_bytes(sasInfo, bytes); + }, + "curve25519": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { + const ourInfo = `${sas.baseApis.getUserId()}${sas.baseApis.deviceId}`; + const theirInfo = `${sas.userId}${sas.deviceId}`; + const sasInfo = + "MATRIX_KEY_VERIFICATION_SAS" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + + sas.channel.transactionId; + return olmSAS.generate_bytes(sasInfo, bytes); + }, +} as const; + +type KeyAgreement = keyof typeof calculateKeyAgreement; + +/* lists of algorithms/methods that are supported. The key agreement, hashes, + * and MAC lists should be sorted in order of preference (most preferred + * first). + */ +const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"]; +const HASHES_LIST = ["sha256"]; +const MAC_LIST: MacMethod[] = [ + "hkdf-hmac-sha256.v2", + "org.matrix.msc3783.hkdf-hmac-sha256", + "hkdf-hmac-sha256", + "hmac-sha256", +]; +const SAS_LIST = Object.keys(sasGenerators); + +const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST); +const HASHES_SET = new Set(HASHES_LIST); +const MAC_SET = new Set(MAC_LIST); +const SAS_SET = new Set(SAS_LIST); + +function intersection<T>(anArray: T[], aSet: Set<T>): T[] { + return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : []; +} + +export enum SasEvent { + ShowSas = "show_sas", +} + +type EventHandlerMap = { + [SasEvent.ShowSas]: (sas: ISasEvent) => void; +} & VerificationEventHandlerMap; + +export class SAS extends Base<SasEvent, EventHandlerMap> { + private waitingForAccept?: boolean; + public ourSASPubKey?: string; + public theirSASPubKey?: string; + public sasEvent?: ISasEvent; + + // eslint-disable-next-line @typescript-eslint/naming-convention + public static get NAME(): string { + return "m.sas.v1"; + } + + public get events(): string[] { + return EVENTS; + } + + protected doVerification = async (): Promise<void> => { + await global.Olm.init(); + olmutil = olmutil || new global.Olm.Utility(); + + // make sure user's keys are downloaded + await this.baseApis.downloadKeys([this.userId]); + + let retry = false; + do { + try { + if (this.initiatedByMe) { + return await this.doSendVerification(); + } else { + return await this.doRespondVerification(); + } + } catch (err) { + if (err instanceof SwitchStartEventError) { + // this changes what initiatedByMe returns + this.startEvent = err.startEvent; + retry = true; + } else { + throw err; + } + } + } while (retry); + }; + + public canSwitchStartEvent(event: MatrixEvent): boolean { + if (event.getType() !== START_TYPE) { + return false; + } + const content = event.getContent(); + return content?.method === SAS.NAME && !!this.waitingForAccept; + } + + private async sendStart(): Promise<Record<string, any>> { + const startContent = this.channel.completeContent(START_TYPE, { + method: SAS.NAME, + from_device: this.baseApis.deviceId, + key_agreement_protocols: KEY_AGREEMENT_LIST, + hashes: HASHES_LIST, + message_authentication_codes: MAC_LIST, + // FIXME: allow app to specify what SAS methods can be used + short_authentication_string: SAS_LIST, + }); + await this.channel.sendCompleted(START_TYPE, startContent); + return startContent; + } + + private async verifyAndCheckMAC( + keyAgreement: KeyAgreement, + sasMethods: string[], + olmSAS: OlmSAS, + macMethod: MacMethod, + ): Promise<void> { + const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); + const verifySAS = new Promise<void>((resolve, reject) => { + this.sasEvent = { + sas: generateSas(sasBytes, sasMethods), + confirm: async (): Promise<void> => { + 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<void> { + this.waitingForAccept = true; + let startContent; + if (this.startEvent) { + startContent = this.channel.completedContentFromEvent(this.startEvent); + } else { + startContent = await this.sendStart(); + } + + // we might have switched to a different start event, + // but was we didn't call _waitForEvent there was no + // call that could throw yet. So check manually that + // we're still on the initiator side + if (!this.initiatedByMe) { + throw new SwitchStartEventError(this.startEvent); + } + + let e: MatrixEvent; + try { + e = await this.waitForEvent(EventType.KeyVerificationAccept); + } finally { + this.waitingForAccept = false; + } + let content = e.getContent(); + const sasMethods = intersection(content.short_authentication_string, SAS_SET); + if ( + !( + KEY_AGREEMENT_SET.has(content.key_agreement_protocol) && + HASHES_SET.has(content.hash) && + MAC_SET.has(content.message_authentication_code) && + sasMethods.length + ) + ) { + throw newUnknownMethodError(); + } + if (typeof content.commitment !== "string") { + throw newInvalidMessageError(); + } + const keyAgreement = content.key_agreement_protocol; + const macMethod = content.message_authentication_code; + const hashCommitment = content.commitment; + const olmSAS = new global.Olm.SAS(); + try { + this.ourSASPubKey = olmSAS.get_pubkey(); + await this.send(EventType.KeyVerificationKey, { + key: this.ourSASPubKey, + }); + + e = await this.waitForEvent(EventType.KeyVerificationKey); + // FIXME: make sure event is properly formed + content = e.getContent(); + const commitmentStr = content.key + anotherjson.stringify(startContent); + // TODO: use selected hash function (when we support multiple) + if (olmutil.sha256(commitmentStr) !== hashCommitment) { + throw newMismatchedCommitmentError(); + } + this.theirSASPubKey = content.key; + olmSAS.set_their_key(content.key); + + await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); + } finally { + olmSAS.free(); + } + } + + private async doRespondVerification(): Promise<void> { + // as m.related_to is not included in the encrypted content in e2e rooms, + // we need to make sure it is added + let content = this.channel.completedContentFromEvent(this.startEvent!); + + // Note: we intersect using our pre-made lists, rather than the sets, + // so that the result will be in our order of preference. Then + // fetching the first element from the array will give our preferred + // method out of the ones offered by the other party. + const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols))[0]; + const hashMethod = intersection(HASHES_LIST, new Set(content.hashes))[0]; + const macMethod = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0]; + // FIXME: allow app to specify what SAS methods can be used + const sasMethods = intersection(content.short_authentication_string, SAS_SET); + if (!(keyAgreement !== undefined && hashMethod !== undefined && macMethod !== undefined && sasMethods.length)) { + throw newUnknownMethodError(); + } + + const olmSAS = new global.Olm.SAS(); + try { + const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content); + await this.send(EventType.KeyVerificationAccept, { + key_agreement_protocol: keyAgreement, + hash: hashMethod, + message_authentication_code: macMethod, + short_authentication_string: sasMethods, + // TODO: use selected hash function (when we support multiple) + commitment: olmutil.sha256(commitmentStr), + }); + + 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(EventType.KeyVerificationKey, { + key: this.ourSASPubKey, + }); + + await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); + } finally { + olmSAS.free(); + } + } + + private sendMAC(olmSAS: OlmSAS, method: MacMethod): Promise<void> { + const mac: Record<string, string> = {}; + const keyList: string[] = []; + const baseInfo = + "MATRIX_KEY_VERIFICATION_MAC" + + this.baseApis.getUserId() + + this.baseApis.deviceId + + this.userId + + this.deviceId + + this.channel.transactionId; + + const deviceKeyId = `ed25519:${this.baseApis.deviceId}`; + mac[deviceKeyId] = calculateMAC(olmSAS, method)(this.baseApis.getDeviceEd25519Key()!, baseInfo + deviceKeyId); + keyList.push(deviceKeyId); + + const crossSigningId = this.baseApis.getCrossSigningId(); + if (crossSigningId) { + const crossSigningKeyId = `ed25519:${crossSigningId}`; + mac[crossSigningKeyId] = calculateMAC(olmSAS, method)(crossSigningId, baseInfo + crossSigningKeyId); + keyList.push(crossSigningKeyId); + } + + const keys = calculateMAC(olmSAS, method)(keyList.sort().join(","), baseInfo + "KEY_IDS"); + return this.send(EventType.KeyVerificationMac, { mac, keys }); + } + + private async checkMAC(olmSAS: OlmSAS, content: IContent, method: MacMethod): Promise<void> { + const baseInfo = + "MATRIX_KEY_VERIFICATION_MAC" + + this.userId + + this.deviceId + + this.baseApis.getUserId() + + this.baseApis.deviceId + + this.channel.transactionId; + + if ( + content.keys !== + calculateMAC(olmSAS, method)(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS") + ) { + throw newKeyMismatchError(); + } + + await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { + if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) { + throw newKeyMismatchError(); + } + }); + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts new file mode 100644 index 0000000..0cb4630 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts @@ -0,0 +1,37 @@ +/* +Copyright 2018 - 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Implementation of decimal encoding of SAS as per: + * https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal + * @param sasBytes - the five bytes generated by HKDF + * @returns the derived three numbers between 1000 and 9191 inclusive + */ +export function generateDecimalSas(sasBytes: number[]): [number, number, number] { + /* + * +--------+--------+--------+--------+--------+ + * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | + * +--------+--------+--------+--------+--------+ + * bits: 87654321 87654321 87654321 87654321 87654321 + * \____________/\_____________/\____________/ + * 1st number 2nd number 3rd number + */ + return [ + ((sasBytes[0] << 5) | (sasBytes[1] >> 3)) + 1000, + (((sasBytes[1] & 0x7) << 10) | (sasBytes[2] << 2) | (sasBytes[3] >> 6)) + 1000, + (((sasBytes[3] & 0x3f) << 7) | (sasBytes[4] >> 1)) + 1000, + ]; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts new file mode 100644 index 0000000..48415f9 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts @@ -0,0 +1,34 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "../../../models/event"; +import { VerificationRequest } from "./VerificationRequest"; + +export interface IVerificationChannel { + request?: VerificationRequest; + readonly userId?: string; + readonly roomId?: string; + readonly deviceId?: string; + readonly transactionId?: string; + readonly receiveStartFromOtherDevices?: boolean; + getTimestamp(event: MatrixEvent): number; + send(type: string, uncompletedContent: Record<string, any>): Promise<void>; + completeContent(type: string, content: Record<string, any>): Record<string, any>; + sendCompleted(type: string, content: Record<string, any>): Promise<void>; + completedContentFromEvent(event: MatrixEvent): Record<string, any>; + canCreateRequest(type: string): boolean; + handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent: boolean): Promise<void>; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts new file mode 100644 index 0000000..ff11bf1 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts @@ -0,0 +1,356 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest"; +import { logger } from "../../../logger"; +import { IVerificationChannel } from "./Channel"; +import { EventType } from "../../../@types/event"; +import { MatrixClient } from "../../../client"; +import { MatrixEvent } from "../../../models/event"; +import { IRequestsMap } from "../.."; + +const MESSAGE_TYPE = EventType.RoomMessage; +const M_REFERENCE = "m.reference"; +const M_RELATES_TO = "m.relates_to"; + +/** + * A key verification channel that sends verification events in the timeline of a room. + * Uses the event id of the initial m.key.verification.request event as a transaction id. + */ +export class InRoomChannel implements IVerificationChannel { + private requestEventId?: string; + + /** + * @param client - the matrix client, to send messages with and get current user & device from. + * @param roomId - id of the room where verification events should be posted in, should be a DM with the given user. + * @param userId - id of user that the verification request is directed at, should be present in the room. + */ + public constructor(private readonly client: MatrixClient, public readonly roomId: string, public userId?: string) {} + + public get receiveStartFromOtherDevices(): boolean { + return true; + } + + /** The transaction id generated/used by this verification channel */ + public get transactionId(): string | undefined { + return this.requestEventId; + } + + public static getOtherPartyUserId(event: MatrixEvent, client: MatrixClient): string | undefined { + const type = InRoomChannel.getEventType(event); + if (type !== REQUEST_TYPE) { + return; + } + const ownUserId = client.getUserId(); + const sender = event.getSender(); + const content = event.getContent(); + const receiver = content.to; + + if (sender === ownUserId) { + return receiver; + } else if (receiver === ownUserId) { + return sender; + } + } + + /** + * @param event - the event to get the timestamp of + * @returns the timestamp when the event was sent + */ + public getTimestamp(event: MatrixEvent): number { + return event.getTs(); + } + + /** + * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel + * @param type - the event type to check + * @returns boolean flag + */ + public static canCreateRequest(type: string): boolean { + return type === REQUEST_TYPE; + } + + public canCreateRequest(type: string): boolean { + return InRoomChannel.canCreateRequest(type); + } + + /** + * Extract the transaction id used by a given key verification event, if any + * @param event - the event + * @returns the transaction id + */ + public static getTransactionId(event: MatrixEvent): string | undefined { + if (InRoomChannel.getEventType(event) === REQUEST_TYPE) { + return event.getId(); + } else { + const relation = event.getRelation(); + if (relation?.rel_type === M_REFERENCE) { + return relation.event_id; + } + } + } + + /** + * Checks whether this event is a well-formed key verification event. + * This only does checks that don't rely on the current state of a potentially already channel + * so we can prevent channels being created by invalid events. + * `handleEvent` can do more checks and choose to ignore invalid events. + * @param event - the event to validate + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent + */ + public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { + const txnId = InRoomChannel.getTransactionId(event); + if (typeof txnId !== "string" || txnId.length === 0) { + return false; + } + const type = InRoomChannel.getEventType(event); + const content = event.getContent(); + + // from here on we're fairly sure that this is supposed to be + // part of a verification request, so be noisy when rejecting something + if (type === REQUEST_TYPE) { + if (!content || typeof content.to !== "string" || !content.to.length) { + logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to)); + return false; + } + + // ignore requests that are not direct to or sent by the syncing user + if (!InRoomChannel.getOtherPartyUserId(event, client)) { + logger.log( + "InRoomChannel: validateEvent: " + + `not directed to or sent by me: ${event.getSender()}` + + `, ${content && content.to}`, + ); + return false; + } + } + + return VerificationRequest.validateEvent(type, event, client); + } + + /** + * As m.key.verification.request events are as m.room.message events with the InRoomChannel + * to have a fallback message in non-supporting clients, we map the real event type + * to the symbolic one to keep things in unison with ToDeviceChannel + * @param event - the event to get the type of + * @returns the "symbolic" event type + */ + public static getEventType(event: MatrixEvent): string { + const type = event.getType(); + if (type === MESSAGE_TYPE) { + const content = event.getContent(); + if (content) { + const { msgtype } = content; + if (msgtype === REQUEST_TYPE) { + return REQUEST_TYPE; + } + } + } + if (type && type !== REQUEST_TYPE) { + return type; + } else { + return ""; + } + } + + /** + * Changes the state of the channel, request, and verifier in response to a key verification event. + * @param event - to handle + * @param request - the request to forward handling to + * @param isLiveEvent - whether this is an even received through sync or not + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. + */ + public async handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise<void> { + // prevent processing the same event multiple times, as under + // some circumstances Room.timeline can get emitted twice for the same event + if (request.hasEventId(event.getId()!)) { + return; + } + const type = InRoomChannel.getEventType(event); + // do validations that need state (roomId, userId), + // ignore if invalid + + if (event.getRoomId() !== this.roomId) { + return; + } + // set userId if not set already + if (!this.userId) { + const userId = InRoomChannel.getOtherPartyUserId(event, this.client); + if (userId) { + this.userId = userId; + } + } + // ignore events not sent by us or the other party + const ownUserId = this.client.getUserId(); + const sender = event.getSender(); + if (this.userId) { + if (sender !== ownUserId && sender !== this.userId) { + logger.log(`InRoomChannel: ignoring verification event from non-participating sender ${sender}`); + return; + } + } + if (!this.requestEventId) { + this.requestEventId = InRoomChannel.getTransactionId(event); + } + + const isRemoteEcho = !!event.getUnsigned().transaction_id; + const isSentByUs = event.getSender() === this.client.getUserId(); + + return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs); + } + + /** + * Adds the transaction id (relation) back to a received event + * so it has the same format as returned by `completeContent` before sending. + * The relation can not appear on the event content because of encryption, + * relations are excluded from encryption. + * @param event - the received event + * @returns the content object with the relation added again + */ + public completedContentFromEvent(event: MatrixEvent): Record<string, any> { + // ensure m.related_to is included in e2ee rooms + // as the field is excluded from encryption + const content = Object.assign({}, event.getContent()); + content[M_RELATES_TO] = event.getRelation()!; + return content; + } + + /** + * Add all the fields to content needed for sending it over this channel. + * This is public so verification methods (SAS uses this) can get the exact + * content that will be sent independent of the used channel, + * as they need to calculate the hash of it. + * @param type - the event type + * @param content - the (incomplete) content + * @returns the complete content, as it will be sent. + */ + public completeContent(type: string, content: Record<string, any>): Record<string, any> { + content = Object.assign({}, content); + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { + content.from_device = this.client.getDeviceId(); + } + if (type === REQUEST_TYPE) { + // type is mapped to m.room.message in the send method + content = { + body: + this.client.getUserId() + + " is requesting to verify " + + "your key, but your client does not support in-chat key " + + "verification. You will need to use legacy key " + + "verification to verify keys.", + msgtype: REQUEST_TYPE, + to: this.userId, + from_device: content.from_device, + methods: content.methods, + }; + } else { + content[M_RELATES_TO] = { + rel_type: M_REFERENCE, + event_id: this.transactionId, + }; + } + return content; + } + + /** + * Send an event over the channel with the content not having gone through `completeContent`. + * @param type - the event type + * @param uncompletedContent - the (incomplete) content + * @returns the promise of the request + */ + public send(type: string, uncompletedContent: Record<string, any>): Promise<void> { + const content = this.completeContent(type, uncompletedContent); + return this.sendCompleted(type, content); + } + + /** + * Send an event over the channel with the content having gone through `completeContent` already. + * @param type - the event type + * @returns the promise of the request + */ + public async sendCompleted(type: string, content: Record<string, any>): Promise<void> { + let sendType = type; + if (type === REQUEST_TYPE) { + sendType = MESSAGE_TYPE; + } + const response = await this.client.sendEvent(this.roomId, sendType, content); + if (type === REQUEST_TYPE) { + this.requestEventId = response.event_id; + } + } +} + +export class InRoomRequests implements IRequestsMap { + private requestsByRoomId = new Map<string, Map<string, VerificationRequest>>(); + + public getRequest(event: MatrixEvent): VerificationRequest | undefined { + const roomId = event.getRoomId()!; + const txnId = InRoomChannel.getTransactionId(event)!; + return this.getRequestByTxnId(roomId, txnId); + } + + public getRequestByChannel(channel: InRoomChannel): VerificationRequest | undefined { + return this.getRequestByTxnId(channel.roomId, channel.transactionId!); + } + + private getRequestByTxnId(roomId: string, txnId: string): VerificationRequest | undefined { + const requestsByTxnId = this.requestsByRoomId.get(roomId); + if (requestsByTxnId) { + return requestsByTxnId.get(txnId); + } + } + + public setRequest(event: MatrixEvent, request: VerificationRequest): void { + this.doSetRequest(event.getRoomId()!, InRoomChannel.getTransactionId(event)!, request); + } + + public setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void { + this.doSetRequest(channel.roomId!, channel.transactionId!, request); + } + + private doSetRequest(roomId: string, txnId: string, request: VerificationRequest): void { + let requestsByTxnId = this.requestsByRoomId.get(roomId); + if (!requestsByTxnId) { + requestsByTxnId = new Map(); + this.requestsByRoomId.set(roomId, requestsByTxnId); + } + requestsByTxnId.set(txnId, request); + } + + public removeRequest(event: MatrixEvent): void { + const roomId = event.getRoomId()!; + const requestsByTxnId = this.requestsByRoomId.get(roomId); + if (requestsByTxnId) { + requestsByTxnId.delete(InRoomChannel.getTransactionId(event)!); + if (requestsByTxnId.size === 0) { + this.requestsByRoomId.delete(roomId); + } + } + } + + public findRequestInProgress(roomId: string): VerificationRequest | undefined { + const requestsByTxnId = this.requestsByRoomId.get(roomId); + if (requestsByTxnId) { + for (const request of requestsByTxnId.values()) { + if (request.pending) { + return request; + } + } + } + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts new file mode 100644 index 0000000..d51b85a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts @@ -0,0 +1,354 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { randomString } from "../../../randomstring"; +import { logger } from "../../../logger"; +import { + CANCEL_TYPE, + PHASE_STARTED, + PHASE_READY, + REQUEST_TYPE, + READY_TYPE, + START_TYPE, + VerificationRequest, +} from "./VerificationRequest"; +import { errorFromEvent, newUnexpectedMessageError } from "../Error"; +import { MatrixEvent } from "../../../models/event"; +import { IVerificationChannel } from "./Channel"; +import { MatrixClient } from "../../../client"; +import { IRequestsMap } from "../.."; + +export type Request = VerificationRequest<ToDeviceChannel>; + +/** + * A key verification channel that sends verification events over to_device messages. + * Generates its own transaction ids. + */ +export class ToDeviceChannel implements IVerificationChannel { + public request?: VerificationRequest; + + // userId and devices of user we're about to verify + public constructor( + private readonly client: MatrixClient, + public readonly userId: string, + private readonly devices: string[], + public transactionId?: string, + public deviceId?: string, + ) {} + + public isToDevices(devices: string[]): boolean { + if (devices.length === this.devices.length) { + for (const device of devices) { + if (!this.devices.includes(device)) { + return false; + } + } + return true; + } else { + return false; + } + } + + public static getEventType(event: MatrixEvent): string { + return event.getType(); + } + + /** + * Extract the transaction id used by a given key verification event, if any + * @param event - the event + * @returns the transaction id + */ + public static getTransactionId(event: MatrixEvent): string { + const content = event.getContent(); + return content && content.transaction_id; + } + + /** + * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel + * @param type - the event type to check + * @returns boolean flag + */ + public static canCreateRequest(type: string): boolean { + return type === REQUEST_TYPE || type === START_TYPE; + } + + public canCreateRequest(type: string): boolean { + return ToDeviceChannel.canCreateRequest(type); + } + + /** + * Checks whether this event is a well-formed key verification event. + * This only does checks that don't rely on the current state of a potentially already channel + * so we can prevent channels being created by invalid events. + * `handleEvent` can do more checks and choose to ignore invalid events. + * @param event - the event to validate + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent + */ + public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { + if (event.isCancelled()) { + logger.warn("Ignoring flagged verification request from " + event.getSender()); + return false; + } + const content = event.getContent(); + if (!content) { + logger.warn("ToDeviceChannel.validateEvent: invalid: no content"); + return false; + } + + if (!content.transaction_id) { + logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id"); + return false; + } + + const type = event.getType(); + + if (type === REQUEST_TYPE) { + if (!Number.isFinite(content.timestamp)) { + logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp"); + return false; + } + if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId()) { + // ignore requests from ourselves, because it doesn't make sense for a + // device to verify itself + logger.warn("ToDeviceChannel.validateEvent: invalid: from own device"); + return false; + } + } + + return VerificationRequest.validateEvent(type, event, client); + } + + /** + * @param event - the event to get the timestamp of + * @returns the timestamp when the event was sent + */ + public getTimestamp(event: MatrixEvent): number { + const content = event.getContent(); + return content && content.timestamp; + } + + /** + * Changes the state of the channel, request, and verifier in response to a key verification event. + * @param event - to handle + * @param request - the request to forward handling to + * @param isLiveEvent - whether this is an even received through sync or not + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. + */ + public async handleEvent(event: MatrixEvent, request: Request, isLiveEvent = false): Promise<void> { + const type = event.getType(); + const content = event.getContent(); + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { + if (!this.transactionId) { + this.transactionId = content.transaction_id; + } + const deviceId = content.from_device; + // adopt deviceId if not set before and valid + if (!this.deviceId && this.devices.includes(deviceId)) { + this.deviceId = deviceId; + } + // if no device id or different from adopted one, cancel with sender + if (!this.deviceId || this.deviceId !== deviceId) { + // also check that message came from the device we sent the request to earlier on + // and do send a cancel message to that device + // (but don't cancel the request for the device we should be talking to) + const cancelContent = this.completeContent(CANCEL_TYPE, errorFromEvent(newUnexpectedMessageError())); + return this.sendToDevices(CANCEL_TYPE, cancelContent, [deviceId]); + } + } + const wasStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; + + await request.handleEvent(event.getType(), event, isLiveEvent, false, false); + + const isStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; + + const isAcceptingEvent = type === START_TYPE || type === READY_TYPE; + // the request has picked a ready or start event, tell the other devices about it + if (isAcceptingEvent && !wasStarted && isStarted && this.deviceId) { + const nonChosenDevices = this.devices.filter((d) => d !== this.deviceId && d !== this.client.getDeviceId()); + if (nonChosenDevices.length) { + const message = this.completeContent(CANCEL_TYPE, { + code: "m.accepted", + reason: "Verification request accepted by another device", + }); + await this.sendToDevices(CANCEL_TYPE, message, nonChosenDevices); + } + } + } + + /** + * See {@link InRoomChannel#completedContentFromEvent} for why this is needed. + * @param event - the received event + * @returns the content object + */ + public completedContentFromEvent(event: MatrixEvent): Record<string, any> { + return event.getContent(); + } + + /** + * Add all the fields to content needed for sending it over this channel. + * This is public so verification methods (SAS uses this) can get the exact + * content that will be sent independent of the used channel, + * as they need to calculate the hash of it. + * @param type - the event type + * @param content - the (incomplete) content + * @returns the complete content, as it will be sent. + */ + public completeContent(type: string, content: Record<string, any>): Record<string, any> { + // make a copy + content = Object.assign({}, content); + if (this.transactionId) { + content.transaction_id = this.transactionId; + } + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { + content.from_device = this.client.getDeviceId(); + } + if (type === REQUEST_TYPE) { + content.timestamp = Date.now(); + } + return content; + } + + /** + * Send an event over the channel with the content not having gone through `completeContent`. + * @param type - the event type + * @param uncompletedContent - the (incomplete) content + * @returns the promise of the request + */ + public send(type: string, uncompletedContent: Record<string, any> = {}): Promise<void> { + // create transaction id when sending request + if ((type === REQUEST_TYPE || type === START_TYPE) && !this.transactionId) { + this.transactionId = ToDeviceChannel.makeTransactionId(); + } + const content = this.completeContent(type, uncompletedContent); + return this.sendCompleted(type, content); + } + + /** + * Send an event over the channel with the content having gone through `completeContent` already. + * @param type - the event type + * @returns the promise of the request + */ + public async sendCompleted(type: string, content: Record<string, any>): Promise<void> { + let result; + if (type === REQUEST_TYPE || (type === CANCEL_TYPE && !this.deviceId)) { + result = await this.sendToDevices(type, content, this.devices); + } else { + result = await this.sendToDevices(type, content, [this.deviceId!]); + } + // the VerificationRequest state machine requires remote echos of the event + // the client sends itself, so we fake this for to_device messages + const remoteEchoEvent = new MatrixEvent({ + sender: this.client.getUserId()!, + content, + type, + }); + await this.request!.handleEvent( + type, + remoteEchoEvent, + /*isLiveEvent=*/ true, + /*isRemoteEcho=*/ true, + /*isSentByUs=*/ true, + ); + return result; + } + + private async sendToDevices(type: string, content: Record<string, any>, devices: string[]): Promise<void> { + if (devices.length) { + const deviceMessages: Map<string, Record<string, any>> = new Map(); + for (const deviceId of devices) { + deviceMessages.set(deviceId, content); + } + + await this.client.sendToDevice(type, new Map([[this.userId, deviceMessages]])); + } + } + + /** + * Allow Crypto module to create and know the transaction id before the .start event gets sent. + * @returns the transaction id + */ + public static makeTransactionId(): string { + return randomString(32); + } +} + +export class ToDeviceRequests implements IRequestsMap { + private requestsByUserId = new Map<string, Map<string, Request>>(); + + public getRequest(event: MatrixEvent): Request | undefined { + return this.getRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event)); + } + + public getRequestByChannel(channel: ToDeviceChannel): Request | undefined { + return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId!); + } + + public getRequestBySenderAndTxnId(sender: string, txnId: string): Request | undefined { + const requestsByTxnId = this.requestsByUserId.get(sender); + if (requestsByTxnId) { + return requestsByTxnId.get(txnId); + } + } + + public setRequest(event: MatrixEvent, request: Request): void { + this.setRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event), request); + } + + public setRequestByChannel(channel: ToDeviceChannel, request: Request): void { + this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId!, request); + } + + public setRequestBySenderAndTxnId(sender: string, txnId: string, request: Request): void { + let requestsByTxnId = this.requestsByUserId.get(sender); + if (!requestsByTxnId) { + requestsByTxnId = new Map(); + this.requestsByUserId.set(sender, requestsByTxnId); + } + requestsByTxnId.set(txnId, request); + } + + public removeRequest(event: MatrixEvent): void { + const userId = event.getSender()!; + const requestsByTxnId = this.requestsByUserId.get(userId); + if (requestsByTxnId) { + requestsByTxnId.delete(ToDeviceChannel.getTransactionId(event)); + if (requestsByTxnId.size === 0) { + this.requestsByUserId.delete(userId); + } + } + } + + public findRequestInProgress(userId: string, devices: string[]): Request | undefined { + const requestsByTxnId = this.requestsByUserId.get(userId); + if (requestsByTxnId) { + for (const request of requestsByTxnId.values()) { + if (request.pending && request.channel.isToDevices(devices)) { + return request; + } + } + } + } + + public getRequestsInProgress(userId: string): Request[] { + const requestsByTxnId = this.requestsByUserId.get(userId); + if (requestsByTxnId) { + return Array.from(requestsByTxnId.values()).filter((r) => r.pending); + } + return []; + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts new file mode 100644 index 0000000..617432e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts @@ -0,0 +1,926 @@ +/* +Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { logger } from "../../../logger"; +import { errorFactory, errorFromEvent, newUnexpectedMessageError, newUnknownMethodError } from "../Error"; +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"; + +// How long after the event's timestamp that the request times out +const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes + +// How long after we receive the event that the request times out +const TIMEOUT_FROM_EVENT_RECEIPT = 2 * 60 * 1000; // 2 minutes + +// to avoid almost expired verification notifications +// from showing a notification and almost immediately +// disappearing, also ignore verification requests that +// are this amount of time away from expiring. +const VERIFICATION_REQUEST_MARGIN = 3 * 1000; // 3 seconds + +export const EVENT_PREFIX = "m.key.verification."; +export const REQUEST_TYPE = EVENT_PREFIX + "request"; +export const START_TYPE = EVENT_PREFIX + "start"; +export const CANCEL_TYPE = EVENT_PREFIX + "cancel"; +export const DONE_TYPE = EVENT_PREFIX + "done"; +export const READY_TYPE = EVENT_PREFIX + "ready"; + +export enum Phase { + Unsent = 1, + Requested, + Ready, + Started, + Cancelled, + Done, +} + +// Legacy export fields +export const PHASE_UNSENT = Phase.Unsent; +export const PHASE_REQUESTED = Phase.Requested; +export const PHASE_READY = Phase.Ready; +export const PHASE_STARTED = Phase.Started; +export const PHASE_CANCELLED = Phase.Cancelled; +export const PHASE_DONE = Phase.Done; + +interface ITargetDevice { + userId?: string; + deviceId?: string; +} + +interface ITransition { + phase: Phase; + event?: MatrixEvent; +} + +export enum VerificationRequestEvent { + Change = "change", +} + +type EventHandlerMap = { + /** + * Fires whenever the state of the request object has changed. + */ + [VerificationRequestEvent.Change]: () => void; +}; + +/** + * State machine for verification requests. + * Things that differ based on what channel is used to + * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`. + */ +export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel> extends TypedEventEmitter< + VerificationRequestEvent, + EventHandlerMap +> { + private eventsByUs = new Map<string, MatrixEvent>(); + private eventsByThem = new Map<string, MatrixEvent>(); + private _observeOnly = false; + private timeoutTimer: ReturnType<typeof setTimeout> | null = null; + private _accepting = false; + private _declining = false; + private verifierHasFinished = false; + private _cancelled = false; + private _chosenMethod: VerificationMethod | null = null; + // we keep a copy of the QR Code data (including other user master key) around + // for QR reciprocate verification, to protect against + // cross-signing identity reset between the .ready and .start event + // and signing the wrong key after .start + private _qrCodeData: QRCodeData | null = null; + + // The timestamp when we received the request event from the other side + private requestReceivedAt: number | null = null; + + private commonMethods: VerificationMethod[] = []; + private _phase!: Phase; + public _cancellingUserId?: string; // Used in tests only + private _verifier?: VerificationBase<any, any>; + + public constructor( + public readonly channel: C, + private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>, + private readonly client: MatrixClient, + ) { + super(); + this.channel.request = this; + this.setPhase(PHASE_UNSENT, false); + } + + /** + * Stateless validation logic not specific to the channel. + * Invoked by the same static method in either channel. + * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. + * @param event - the event to validate. Don't call getType() on it but use the `type` parameter instead. + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent + */ + public static validateEvent(type: string, event: MatrixEvent, client: MatrixClient): boolean { + const content = event.getContent(); + + if (!type || !type.startsWith(EVENT_PREFIX)) { + return false; + } + + // from here on we're fairly sure that this is supposed to be + // part of a verification request, so be noisy when rejecting something + if (!content) { + logger.log("VerificationRequest: validateEvent: no content"); + return false; + } + + if (type === REQUEST_TYPE || type === READY_TYPE) { + if (!Array.isArray(content.methods)) { + logger.log("VerificationRequest: validateEvent: " + "fail because methods"); + return false; + } + } + + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { + if (typeof content.from_device !== "string" || content.from_device.length === 0) { + logger.log("VerificationRequest: validateEvent: " + "fail because from_device"); + return false; + } + } + + return true; + } + + public get invalid(): boolean { + return this.phase === PHASE_UNSENT; + } + + /** returns whether the phase is PHASE_REQUESTED */ + public get requested(): boolean { + return this.phase === PHASE_REQUESTED; + } + + /** returns whether the phase is PHASE_CANCELLED */ + public get cancelled(): boolean { + return this.phase === PHASE_CANCELLED; + } + + /** returns whether the phase is PHASE_READY */ + public get ready(): boolean { + return this.phase === PHASE_READY; + } + + /** returns whether the phase is PHASE_STARTED */ + public get started(): boolean { + return this.phase === PHASE_STARTED; + } + + /** returns whether the phase is PHASE_DONE */ + public get done(): boolean { + return this.phase === PHASE_DONE; + } + + /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */ + public get methods(): VerificationMethod[] { + return this.commonMethods; + } + + /** the method picked in the .start event */ + public get chosenMethod(): VerificationMethod | null { + return this._chosenMethod; + } + + public calculateEventTimeout(event: MatrixEvent): number { + let effectiveExpiresAt = this.channel.getTimestamp(event) + TIMEOUT_FROM_EVENT_TS; + + if (this.requestReceivedAt && !this.initiatedByMe && this.phase <= PHASE_REQUESTED) { + const expiresAtByReceipt = this.requestReceivedAt + TIMEOUT_FROM_EVENT_RECEIPT; + effectiveExpiresAt = Math.min(effectiveExpiresAt, expiresAtByReceipt); + } + + return Math.max(0, effectiveExpiresAt - Date.now()); + } + + /** The current remaining amount of ms before the request should be automatically cancelled */ + public get timeout(): number { + const requestEvent = this.getEventByEither(REQUEST_TYPE); + if (requestEvent) { + return this.calculateEventTimeout(requestEvent); + } + return 0; + } + + /** + * The key verification request event. + * @returns The request event, or falsey if not found. + */ + public get requestEvent(): MatrixEvent | undefined { + return this.getEventByEither(REQUEST_TYPE); + } + + /** current phase of the request. Some properties might only be defined in a current phase. */ + public get phase(): Phase { + return this._phase; + } + + /** The verifier to do the actual verification, once the method has been established. Only defined when the `phase` is PHASE_STARTED. */ + public get verifier(): VerificationBase<any, any> | undefined { + return this._verifier; + } + + public get canAccept(): boolean { + return this.phase < PHASE_READY && !this._accepting && !this._declining; + } + + public get accepting(): boolean { + return this._accepting; + } + + public get declining(): boolean { + return this._declining; + } + + /** whether this request has sent it's initial event and needs more events to complete */ + public get pending(): boolean { + return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED; + } + + /** Only set after a .ready if the other party can scan a QR code */ + public get qrCodeData(): QRCodeData | null { + return this._qrCodeData; + } + + /** Checks whether the other party supports a given verification method. + * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: + * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. + * For methods that need to be supported by both ends, use the `methods` property. + * @param method - the method to check + * @param force - to check even if the phase is not ready or started yet, internal usage + * @returns whether or not the other party said the supported the method */ + public otherPartySupportsMethod(method: string, force = false): boolean { + if (!force && !this.ready && !this.started) { + return false; + } + const theirMethodEvent = this.eventsByThem.get(REQUEST_TYPE) || this.eventsByThem.get(READY_TYPE); + if (!theirMethodEvent) { + // if we started straight away with .start event, + // we are assuming that the other side will support the + // chosen method, so return true for that. + if (this.started && this.initiatedByMe) { + const myStartEvent = this.eventsByUs.get(START_TYPE); + const content = myStartEvent && myStartEvent.getContent(); + const myStartMethod = content && content.method; + return method == myStartMethod; + } + return false; + } + const content = theirMethodEvent.getContent(); + if (!content) { + return false; + } + const { methods } = content; + if (!Array.isArray(methods)) { + return false; + } + + return methods.includes(method); + } + + /** Whether this request was initiated by the syncing user. + * For InRoomChannel, this is who sent the .request event. + * For ToDeviceChannel, this is who sent the .start event + */ + public get initiatedByMe(): boolean { + // event created by us but no remote echo has been received yet + const noEventsYet = this.eventsByUs.size + this.eventsByThem.size === 0; + if (this._phase === PHASE_UNSENT && noEventsYet) { + return true; + } + const hasMyRequest = this.eventsByUs.has(REQUEST_TYPE); + const hasTheirRequest = this.eventsByThem.has(REQUEST_TYPE); + if (hasMyRequest && !hasTheirRequest) { + return true; + } + if (!hasMyRequest && hasTheirRequest) { + return false; + } + const hasMyStart = this.eventsByUs.has(START_TYPE); + const hasTheirStart = this.eventsByThem.has(START_TYPE); + if (hasMyStart && !hasTheirStart) { + return true; + } + return false; + } + + /** The id of the user that initiated the request */ + public get requestingUserId(): string { + if (this.initiatedByMe) { + return this.client.getUserId()!; + } else { + return this.otherUserId; + } + } + + /** The id of the user that (will) receive(d) the request */ + public get receivingUserId(): string { + if (this.initiatedByMe) { + return this.otherUserId; + } else { + return this.client.getUserId()!; + } + } + + /** The user id of the other party in this request */ + public get otherUserId(): string { + return this.channel.userId!; + } + + public get isSelfVerification(): boolean { + return this.client.getUserId() === this.otherUserId; + } + + /** + * The id of the user that cancelled the request, + * only defined when phase is PHASE_CANCELLED + */ + public get cancellingUserId(): string | undefined { + const myCancel = this.eventsByUs.get(CANCEL_TYPE); + const theirCancel = this.eventsByThem.get(CANCEL_TYPE); + + if (myCancel && (!theirCancel || myCancel.getId()! < theirCancel.getId()!)) { + return myCancel.getSender(); + } + if (theirCancel) { + return theirCancel.getSender(); + } + return undefined; + } + + /** + * The cancellation code e.g m.user which is responsible for cancelling this verification + */ + public get cancellationCode(): string { + const ev = this.getEventByEither(CANCEL_TYPE); + return ev ? ev.getContent().code : null; + } + + public get observeOnly(): boolean { + return this._observeOnly; + } + + /** + * Gets which device the verification should be started with + * given the events sent so far in the verification. This is the + * same algorithm used to determine which device to send the + * verification to when no specific device is specified. + * @returns The device information + */ + public get targetDevice(): ITargetDevice { + const theirFirstEvent = + this.eventsByThem.get(REQUEST_TYPE) || + this.eventsByThem.get(READY_TYPE) || + this.eventsByThem.get(START_TYPE); + const theirFirstContent = theirFirstEvent?.getContent(); + const fromDevice = theirFirstContent?.from_device; + return { + userId: this.otherUserId, + deviceId: fromDevice, + }; + } + + /* Start the key verification, creating a verifier and sending a .start event. + * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. + * @param method - the name of the verification method to use. + * @param targetDevice.userId the id of the user to direct this request to + * @param targetDevice.deviceId the id of the device to direct this request to + * @returns the verifier of the given method + */ + public beginKeyVerification( + method: VerificationMethod, + targetDevice: ITargetDevice | null = null, + ): VerificationBase<any, any> { + // need to allow also when unsent in case of to_device + if (!this.observeOnly && !this._verifier) { + const validStartPhase = + this.phase === PHASE_REQUESTED || + this.phase === PHASE_READY || + (this.phase === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE)); + if (validStartPhase) { + // when called on a request that was initiated with .request event + // check the method is supported by both sides + if (this.commonMethods.length && !this.commonMethods.includes(method)) { + throw newUnknownMethodError(); + } + this._verifier = this.createVerifier(method, null, targetDevice); + if (!this._verifier) { + throw newUnknownMethodError(); + } + this._chosenMethod = method; + } + } + return this._verifier!; + } + + /** + * sends the initial .request event. + * @returns resolves when the event has been sent. + */ + public async sendRequest(): Promise<void> { + if (!this.observeOnly && this._phase === PHASE_UNSENT) { + const methods = [...this.verificationMethods.keys()]; + await this.channel.send(REQUEST_TYPE, { methods }); + } + } + + /** + * Cancels the request, sending a cancellation to the other party + * @param reason - the error reason to send the cancellation with + * @param code - the error code to send the cancellation with + * @returns resolves when the event has been sent. + */ + public async cancel({ reason = "User declined", code = "m.user" } = {}): Promise<void> { + if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { + this._declining = true; + this.emit(VerificationRequestEvent.Change); + if (this._verifier) { + return this._verifier.cancel(errorFactory(code, reason)()); + } else { + this._cancellingUserId = this.client.getUserId()!; + await this.channel.send(CANCEL_TYPE, { code, reason }); + } + } + } + + /** + * Accepts the request, sending a .ready event to the other party + * @returns resolves when the event has been sent. + */ + public async accept(): Promise<void> { + if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) { + const methods = [...this.verificationMethods.keys()]; + this._accepting = true; + this.emit(VerificationRequestEvent.Change); + await this.channel.send(READY_TYPE, { methods }); + } + } + + /** + * Can be used to listen for state changes until the callback returns true. + * @param fn - callback to evaluate whether the request is in the desired state. + * Takes the request as an argument. + * @returns that resolves once the callback returns true + * @throws Error when the request is cancelled + */ + public waitFor(fn: (request: VerificationRequest) => boolean): Promise<VerificationRequest> { + return new Promise((resolve, reject) => { + const check = (): boolean => { + let handled = false; + if (fn(this)) { + resolve(this); + handled = true; + } else if (this.cancelled) { + reject(new Error("cancelled")); + handled = true; + } + if (handled) { + this.off(VerificationRequestEvent.Change, check); + } + return handled; + }; + if (!check()) { + this.on(VerificationRequestEvent.Change, check); + } + }); + } + + private setPhase(phase: Phase, notify = true): void { + this._phase = phase; + if (notify) { + this.emit(VerificationRequestEvent.Change); + } + } + + private getEventByEither(type: string): MatrixEvent | undefined { + return this.eventsByThem.get(type) || this.eventsByUs.get(type); + } + + private getEventBy(type: string, byThem = false): MatrixEvent | undefined { + if (byThem) { + return this.eventsByThem.get(type); + } else { + return this.eventsByUs.get(type); + } + } + + private calculatePhaseTransitions(): ITransition[] { + const transitions: ITransition[] = [{ phase: PHASE_UNSENT }]; + const phase = (): Phase => transitions[transitions.length - 1].phase; + + // always pass by .request first to be sure channel.userId has been set + const hasRequestByThem = this.eventsByThem.has(REQUEST_TYPE); + const requestEvent = this.getEventBy(REQUEST_TYPE, hasRequestByThem); + if (requestEvent) { + transitions.push({ phase: PHASE_REQUESTED, event: requestEvent }); + } + + const readyEvent = requestEvent && this.getEventBy(READY_TYPE, !hasRequestByThem); + if (readyEvent && phase() === PHASE_REQUESTED) { + transitions.push({ phase: PHASE_READY, event: readyEvent }); + } + + let startEvent: MatrixEvent | undefined; + if (readyEvent || !requestEvent) { + const theirStartEvent = this.eventsByThem.get(START_TYPE); + const ourStartEvent = this.eventsByUs.get(START_TYPE); + // any party can send .start after a .ready or unsent + if (theirStartEvent && ourStartEvent) { + startEvent = + theirStartEvent.getSender()! < ourStartEvent.getSender()! ? theirStartEvent : ourStartEvent; + } else { + startEvent = theirStartEvent ? theirStartEvent : ourStartEvent; + } + } else { + startEvent = this.getEventBy(START_TYPE, !hasRequestByThem); + } + if (startEvent) { + const fromRequestPhase = + phase() === PHASE_REQUESTED && requestEvent?.getSender() !== startEvent.getSender(); + const fromUnsentPhase = phase() === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE); + if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) { + transitions.push({ phase: PHASE_STARTED, event: startEvent }); + } + } + + const ourDoneEvent = this.eventsByUs.get(DONE_TYPE); + if (this.verifierHasFinished || (ourDoneEvent && phase() === PHASE_STARTED)) { + transitions.push({ phase: PHASE_DONE }); + } + + const cancelEvent = this.getEventByEither(CANCEL_TYPE); + if ((this._cancelled || cancelEvent) && phase() !== PHASE_DONE) { + transitions.push({ phase: PHASE_CANCELLED, event: cancelEvent }); + return transitions; + } + + return transitions; + } + + private transitionToPhase(transition: ITransition): void { + const { phase, event } = transition; + // get common methods + if (phase === PHASE_REQUESTED || phase === PHASE_READY) { + if (!this.wasSentByOwnDevice(event)) { + const content = event!.getContent<{ + methods: string[]; + }>(); + this.commonMethods = content.methods.filter((m) => this.verificationMethods.has(m)); + } + } + // detect if we're not a party in the request, and we should just observe + if (!this.observeOnly) { + // if requested or accepted by one of my other devices + if (phase === PHASE_REQUESTED || phase === PHASE_STARTED || phase === PHASE_READY) { + if ( + this.channel.receiveStartFromOtherDevices && + this.wasSentByOwnUser(event) && + !this.wasSentByOwnDevice(event) + ) { + this._observeOnly = true; + } + } + } + // create verifier + if (phase === PHASE_STARTED) { + const { method } = event!.getContent(); + if (!this._verifier && !this.observeOnly) { + this._verifier = this.createVerifier(method, event); + if (!this._verifier) { + this.cancel({ + code: "m.unknown_method", + reason: `Unknown method: ${method}`, + }); + } else { + this._chosenMethod = method; + } + } + } + } + + private applyPhaseTransitions(): ITransition[] { + const transitions = this.calculatePhaseTransitions(); + const existingIdx = transitions.findIndex((t) => t.phase === this.phase); + // trim off phases we already went through, if any + const newTransitions = transitions.slice(existingIdx + 1); + // transition to all new phases + for (const transition of newTransitions) { + this.transitionToPhase(transition); + } + return newTransitions; + } + + private isWinningStartRace(newEvent: MatrixEvent): boolean { + if (newEvent.getType() !== START_TYPE) { + return false; + } + const oldEvent = this._verifier!.startEvent; + + let oldRaceIdentifier; + if (this.isSelfVerification) { + // if the verifier does not have a startEvent, + // it is because it's still sending and we are on the initator side + // we know we are sending a .start event because we already + // have a verifier (checked in calling method) + if (oldEvent) { + const oldContent = oldEvent.getContent(); + oldRaceIdentifier = oldContent && oldContent.from_device; + } else { + oldRaceIdentifier = this.client.getDeviceId(); + } + } else { + if (oldEvent) { + oldRaceIdentifier = oldEvent.getSender(); + } else { + oldRaceIdentifier = this.client.getUserId(); + } + } + + let newRaceIdentifier; + if (this.isSelfVerification) { + const newContent = newEvent.getContent(); + newRaceIdentifier = newContent && newContent.from_device; + } else { + newRaceIdentifier = newEvent.getSender(); + } + return newRaceIdentifier < oldRaceIdentifier; + } + + public hasEventId(eventId: string): boolean { + for (const event of this.eventsByUs.values()) { + if (event.getId() === eventId) { + return true; + } + } + for (const event of this.eventsByThem.values()) { + if (event.getId() === eventId) { + return true; + } + } + return false; + } + + /** + * Changes the state of the request and verifier in response to a key verification event. + * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. + * @param event - the event to handle. Don't call getType() on it but use the `type` parameter instead. + * @param isLiveEvent - whether this is an even received through sync or not + * @param isRemoteEcho - whether this is the remote echo of an event sent by the same device + * @param isSentByUs - whether this event is sent by a party that can accept and/or observe the request like one of our peers. + * For InRoomChannel this means any device for the syncing user. For ToDeviceChannel, just the syncing device. + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. + */ + public async handleEvent( + type: string, + event: MatrixEvent, + isLiveEvent: boolean, + isRemoteEcho: boolean, + isSentByUs: boolean, + ): Promise<void> { + // if reached phase cancelled or done, ignore anything else that comes + if (this.done || this.cancelled) { + return; + } + const wasObserveOnly = this._observeOnly; + + this.adjustObserveOnly(event, isLiveEvent); + + if (!this.observeOnly && !isRemoteEcho) { + if (await this.cancelOnError(type, event)) { + return; + } + } + + // This assumes verification won't need to send an event with + // the same type for the same party twice. + // This is true for QR and SAS verification, and was + // added here to prevent verification getting cancelled + // when the server duplicates an event (https://github.com/matrix-org/synapse/issues/3365) + const isDuplicateEvent = isSentByUs ? this.eventsByUs.has(type) : this.eventsByThem.has(type); + if (isDuplicateEvent) { + return; + } + + const oldPhase = this.phase; + this.addEvent(type, event, isSentByUs); + + // this will create if needed the verifier so needs to happen before calling it + const newTransitions = this.applyPhaseTransitions(); + try { + // only pass events from the other side to the verifier, + // no remote echos of our own events + if (this._verifier && !this.observeOnly) { + const newEventWinsRace = this.isWinningStartRace(event); + if (this._verifier.canSwitchStartEvent(event) && newEventWinsRace) { + this._verifier.switchStartEvent(event); + } else if (!isRemoteEcho) { + if (type === CANCEL_TYPE || this._verifier.events?.includes(type)) { + this._verifier.handleEvent(event); + } + } + } + + if (newTransitions.length) { + // create QRCodeData if the other side can scan + // important this happens before emitting a phase change, + // so listeners can rely on it being there already + // We only do this for live events because it is important that + // we sign the keys that were in the QR code, and not the keys + // we happen to have at some later point in time. + if (isLiveEvent && newTransitions.some((t) => t.phase === PHASE_READY)) { + const shouldGenerateQrCode = this.otherPartySupportsMethod(SCAN_QR_CODE_METHOD, true); + if (shouldGenerateQrCode) { + this._qrCodeData = await QRCodeData.create(this, this.client); + } + } + + const lastTransition = newTransitions[newTransitions.length - 1]; + const { phase } = lastTransition; + + this.setupTimeout(phase); + // set phase as last thing as this emits the "change" event + this.setPhase(phase); + } else if (this._observeOnly !== wasObserveOnly) { + this.emit(VerificationRequestEvent.Change); + } + } finally { + // log events we processed so we can see from rageshakes what events were added to a request + logger.log( + `Verification request ${this.channel.transactionId}: ` + + `${type} event with id:${event.getId()}, ` + + `content:${JSON.stringify(event.getContent())} ` + + `deviceId:${this.channel.deviceId}, ` + + `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` + + `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` + + `phase:${oldPhase}=>${this.phase}, ` + + `observeOnly:${wasObserveOnly}=>${this._observeOnly}`, + ); + } + } + + private setupTimeout(phase: Phase): void { + const shouldTimeout = !this.timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED; + + if (shouldTimeout) { + this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout); + } + if (this.timeoutTimer) { + const shouldClear = + phase === PHASE_STARTED || phase === PHASE_READY || phase === PHASE_DONE || phase === PHASE_CANCELLED; + if (shouldClear) { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + } + } + + private cancelOnTimeout = async (): Promise<void> => { + try { + if (this.initiatedByMe) { + await this.cancel({ + reason: "Other party didn't accept in time", + code: "m.timeout", + }); + } else { + await this.cancel({ + reason: "User didn't accept in time", + code: "m.timeout", + }); + } + } catch (err) { + logger.error("Error while cancelling verification request", err); + } + }; + + private async cancelOnError(type: string, event: MatrixEvent): Promise<boolean> { + if (type === START_TYPE) { + const method = event.getContent().method; + if (!this.verificationMethods.has(method)) { + await this.cancel(errorFromEvent(newUnknownMethodError())); + return true; + } + } + + const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; + const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && this.phase !== PHASE_STARTED; + // only if phase has passed from PHASE_UNSENT should we cancel, because events + // are allowed to come in in any order (at least with InRoomChannel). So we only know + // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED. + // Before that, we could be looking at somebody else's verification request and we just + // happen to be in the room + if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) { + logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`); + const reason = `Unexpected ${type} event in phase ${this.phase}`; + await this.cancel(errorFromEvent(newUnexpectedMessageError({ reason }))); + return true; + } + return false; + } + + private adjustObserveOnly(event: MatrixEvent, isLiveEvent = false): void { + // don't send out events for historical requests + if (!isLiveEvent) { + this._observeOnly = true; + } + if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) { + this._observeOnly = true; + } + } + + private addEvent(type: string, event: MatrixEvent, isSentByUs = false): void { + if (isSentByUs) { + this.eventsByUs.set(type, event); + } else { + this.eventsByThem.set(type, event); + } + + // once we know the userId of the other party (from the .request event) + // see if any event by anyone else crept into this.eventsByThem + if (type === REQUEST_TYPE) { + for (const [type, event] of this.eventsByThem.entries()) { + if (event.getSender() !== this.otherUserId) { + this.eventsByThem.delete(type); + } + } + // also remember when we received the request event + this.requestReceivedAt = Date.now(); + } + } + + private createVerifier( + method: VerificationMethod, + startEvent: MatrixEvent | null = null, + targetDevice: ITargetDevice | null = null, + ): VerificationBase<any, any> | undefined { + if (!targetDevice) { + targetDevice = this.targetDevice; + } + const { userId, deviceId } = targetDevice; + + const VerifierCtor = this.verificationMethods.get(method); + if (!VerifierCtor) { + logger.warn("could not find verifier constructor for method", method); + return; + } + return new VerifierCtor(this.channel, this.client, userId!, deviceId!, startEvent, this); + } + + private wasSentByOwnUser(event?: MatrixEvent): boolean { + return event?.getSender() === this.client.getUserId(); + } + + // only for .request, .ready or .start + private wasSentByOwnDevice(event?: MatrixEvent): boolean { + if (!this.wasSentByOwnUser(event)) { + return false; + } + const content = event!.getContent(); + if (!content || content.from_device !== this.client.getDeviceId()) { + return false; + } + return true; + } + + public onVerifierCancelled(): void { + this._cancelled = true; + // move to cancelled phase + const newTransitions = this.applyPhaseTransitions(); + if (newTransitions.length) { + this.setPhase(newTransitions[newTransitions.length - 1].phase); + } + } + + public onVerifierFinished(): void { + this.channel.send(EventType.KeyVerificationDone, {}); + this.verifierHasFinished = true; + // move to .done phase + const newTransitions = this.applyPhaseTransitions(); + if (newTransitions.length) { + this.setPhase(newTransitions[newTransitions.length - 1].phase); + } + } + + public getEventFromOtherParty(type: string): MatrixEvent | undefined { + return this.eventsByThem.get(type); + } +} |