diff options
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/crypto')
37 files changed, 19850 insertions, 0 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts new file mode 100644 index 0000000..31ed2d4 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts @@ -0,0 +1,803 @@ +/* +Copyright 2019 - 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. +*/ + +/** + * Cross signing methods + */ + +import { PkSigning } from "@matrix-org/olm"; + +import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib"; +import { logger } from "../logger"; +import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store"; +import { decryptAES, encryptAES } from "./aes"; +import { DeviceInfo } from "./deviceinfo"; +import { SecretStorage } from "./SecretStorage"; +import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client"; +import { OlmDevice } from "./OlmDevice"; +import { ICryptoCallbacks } from "."; +import { ISignatures } from "../@types/signed"; +import { CryptoStore, SecretStorePrivateKeys } from "./store/base"; +import { SecretStorageKeyDescription } from "../secret-storage"; + +const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; + +function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string { + // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } + // We assume only a single key, and we want the bare form without type + // prefix, so we select the values. + return Object.values(keyInfo.keys)[0]; +} + +export interface ICacheCallbacks { + getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise<Uint8Array | null>; + storeCrossSigningKeyCache?(type: string, key?: Uint8Array): Promise<void>; +} + +export interface ICrossSigningInfo { + keys: Record<string, ICrossSigningKey>; + firstUse: boolean; + crossSigningVerifiedBefore: boolean; +} + +export class CrossSigningInfo { + public keys: Record<string, ICrossSigningKey> = {}; + public firstUse = true; + // This tracks whether we've ever verified this user with any identity. + // When you verify a user, any devices online at the time that receive + // the verifying signature via the homeserver will latch this to true + // and can use it in the future to detect cases where the user has + // become unverified later for any reason. + private crossSigningVerifiedBefore = false; + + /** + * Information about a user's cross-signing keys + * + * @param userId - the user that the information is about + * @param callbacks - Callbacks used to interact with the app + * Requires getCrossSigningKey and saveCrossSigningKeys + * @param cacheCallbacks - Callbacks used to interact with the cache + */ + public constructor( + public readonly userId: string, + private callbacks: ICryptoCallbacks = {}, + private cacheCallbacks: ICacheCallbacks = {}, + ) {} + + public static fromStorage(obj: ICrossSigningInfo, userId: string): CrossSigningInfo { + const res = new CrossSigningInfo(userId); + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + // @ts-ignore - ts doesn't like this and nor should we + res[prop] = obj[prop]; + } + } + return res; + } + + public toStorage(): ICrossSigningInfo { + return { + keys: this.keys, + firstUse: this.firstUse, + crossSigningVerifiedBefore: this.crossSigningVerifiedBefore, + }; + } + + /** + * Calls the app callback to ask for a private key + * + * @param type - The key type ("master", "self_signing", or "user_signing") + * @param expectedPubkey - The matching public key or undefined to use + * the stored public key for the given key type. + * @returns An array with [ public key, Olm.PkSigning ] + */ + public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> { + const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0; + + if (!this.callbacks.getCrossSigningKey) { + throw new Error("No getCrossSigningKey callback supplied"); + } + + if (expectedPubkey === undefined) { + expectedPubkey = this.getId(type)!; + } + + function validateKey(key: Uint8Array | null): [string, PkSigning] | undefined { + if (!key) return; + const signing = new global.Olm.PkSigning(); + const gotPubkey = signing.init_with_seed(key); + if (gotPubkey === expectedPubkey) { + return [gotPubkey, signing]; + } + signing.free(); + } + + let privkey: Uint8Array | null = null; + if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) { + privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey); + } + + const cacheresult = validateKey(privkey); + if (cacheresult) { + return cacheresult; + } + + privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey); + const result = validateKey(privkey); + if (result) { + if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { + await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey!); + } + return result; + } + + /* No keysource even returned a key */ + if (!privkey) { + throw new Error("getCrossSigningKey callback for " + type + " returned falsey"); + } + + /* We got some keys from the keysource, but none of them were valid */ + throw new Error("Key type " + type + " from getCrossSigningKey callback did not match"); + } + + /** + * Check whether the private keys exist in secret storage. + * XXX: This could be static, be we often seem to have an instance when we + * want to know this anyway... + * + * @param secretStorage - The secret store using account data + * @returns map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key + */ + public async isStoredInSecretStorage( + secretStorage: SecretStorage<MatrixClient | undefined>, + ): Promise<Record<string, object> | null> { + // check what SSSS keys have encrypted the master key (if any) + const stored = (await secretStorage.isStored("m.cross_signing.master")) || {}; + // then check which of those SSSS keys have also encrypted the SSK and USK + function intersect(s: Record<string, SecretStorageKeyDescription>): void { + for (const k of Object.keys(stored)) { + if (!s[k]) { + delete stored[k]; + } + } + } + for (const type of ["self_signing", "user_signing"]) { + intersect((await secretStorage.isStored(`m.cross_signing.${type}`)) || {}); + } + return Object.keys(stored).length ? stored : null; + } + + /** + * Store private keys in secret storage for use by other devices. This is + * typically called in conjunction with the creation of new cross-signing + * keys. + * + * @param keys - The keys to store + * @param secretStorage - The secret store using account data + */ + public static async storeInSecretStorage( + keys: Map<string, Uint8Array>, + secretStorage: SecretStorage<undefined>, + ): Promise<void> { + for (const [type, privateKey] of keys) { + const encodedKey = encodeBase64(privateKey); + await secretStorage.store(`m.cross_signing.${type}`, encodedKey); + } + } + + /** + * Get private keys from secret storage created by some other device. This + * also passes the private keys to the app-specific callback. + * + * @param type - The type of key to get. One of "master", + * "self_signing", or "user_signing". + * @param secretStorage - The secret store using account data + * @returns The private key + */ + public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise<Uint8Array | null> { + const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); + if (!encodedKey) { + return null; + } + return decodeBase64(encodedKey); + } + + /** + * Check whether the private keys exist in the local key cache. + * + * @param type - The type of key to get. One of "master", + * "self_signing", or "user_signing". Optional, will check all by default. + * @returns True if all keys are stored in the local cache. + */ + public async isStoredInKeyCache(type?: string): Promise<boolean> { + const cacheCallbacks = this.cacheCallbacks; + if (!cacheCallbacks) return false; + const types = type ? [type] : ["master", "self_signing", "user_signing"]; + for (const t of types) { + if (!(await cacheCallbacks.getCrossSigningKeyCache?.(t))) { + return false; + } + } + return true; + } + + /** + * Get cross-signing private keys from the local cache. + * + * @returns A map from key type (string) to private key (Uint8Array) + */ + public async getCrossSigningKeysFromCache(): Promise<Map<string, Uint8Array>> { + const keys = new Map(); + const cacheCallbacks = this.cacheCallbacks; + if (!cacheCallbacks) return keys; + for (const type of ["master", "self_signing", "user_signing"]) { + const privKey = await cacheCallbacks.getCrossSigningKeyCache?.(type); + if (!privKey) { + continue; + } + keys.set(type, privKey); + } + return keys; + } + + /** + * Get the ID used to identify the user. This can also be used to test for + * the existence of a given key type. + * + * @param type - The type of key to get the ID of. One of "master", + * "self_signing", or "user_signing". Defaults to "master". + * + * @returns the ID + */ + public getId(type = "master"): string | null { + if (!this.keys[type]) return null; + const keyInfo = this.keys[type]; + return publicKeyFromKeyInfo(keyInfo); + } + + /** + * Create new cross-signing keys for the given key types. The public keys + * will be held in this class, while the private keys are passed off to the + * `saveCrossSigningKeys` application callback. + * + * @param level - The key types to reset + */ + public async resetKeys(level?: CrossSigningLevel): Promise<void> { + if (!this.callbacks.saveCrossSigningKeys) { + throw new Error("No saveCrossSigningKeys callback supplied"); + } + + // If we're resetting the master key, we reset all keys + if (level === undefined || level & CrossSigningLevel.MASTER || !this.keys.master) { + level = CrossSigningLevel.MASTER | CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING; + } else if (level === (0 as CrossSigningLevel)) { + return; + } + + const privateKeys: Record<string, Uint8Array> = {}; + const keys: Record<string, ICrossSigningKey> = {}; + let masterSigning; + let masterPub; + + try { + if (level & CrossSigningLevel.MASTER) { + masterSigning = new global.Olm.PkSigning(); + privateKeys.master = masterSigning.generate_seed(); + masterPub = masterSigning.init_with_seed(privateKeys.master); + keys.master = { + user_id: this.userId, + usage: ["master"], + keys: { + ["ed25519:" + masterPub]: masterPub, + }, + }; + } else { + [masterPub, masterSigning] = await this.getCrossSigningKey("master"); + } + + if (level & CrossSigningLevel.SELF_SIGNING) { + const sskSigning = new global.Olm.PkSigning(); + try { + privateKeys.self_signing = sskSigning.generate_seed(); + const sskPub = sskSigning.init_with_seed(privateKeys.self_signing); + keys.self_signing = { + user_id: this.userId, + usage: ["self_signing"], + keys: { + ["ed25519:" + sskPub]: sskPub, + }, + }; + pkSign(keys.self_signing, masterSigning, this.userId, masterPub); + } finally { + sskSigning.free(); + } + } + + if (level & CrossSigningLevel.USER_SIGNING) { + const uskSigning = new global.Olm.PkSigning(); + try { + privateKeys.user_signing = uskSigning.generate_seed(); + const uskPub = uskSigning.init_with_seed(privateKeys.user_signing); + keys.user_signing = { + user_id: this.userId, + usage: ["user_signing"], + keys: { + ["ed25519:" + uskPub]: uskPub, + }, + }; + pkSign(keys.user_signing, masterSigning, this.userId, masterPub); + } finally { + uskSigning.free(); + } + } + + Object.assign(this.keys, keys); + this.callbacks.saveCrossSigningKeys(privateKeys); + } finally { + if (masterSigning) { + masterSigning.free(); + } + } + } + + /** + * unsets the keys, used when another session has reset the keys, to disable cross-signing + */ + public clearKeys(): void { + this.keys = {}; + } + + public setKeys(keys: Record<string, ICrossSigningKey>): void { + const signingKeys: Record<string, ICrossSigningKey> = {}; + if (keys.master) { + if (keys.master.user_id !== this.userId) { + const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId; + logger.error(error); + throw new Error(error); + } + if (!this.keys.master) { + // this is the first key we've seen, so first-use is true + this.firstUse = true; + } else if (publicKeyFromKeyInfo(keys.master) !== this.getId()) { + // this is a different key, so first-use is false + this.firstUse = false; + } // otherwise, same key, so no change + signingKeys.master = keys.master; + } else if (this.keys.master) { + signingKeys.master = this.keys.master; + } else { + throw new Error("Tried to set cross-signing keys without a master key"); + } + const masterKey = publicKeyFromKeyInfo(signingKeys.master); + + // verify signatures + if (keys.user_signing) { + if (keys.user_signing.user_id !== this.userId) { + const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId; + logger.error(error); + throw new Error(error); + } + try { + pkVerify(keys.user_signing, masterKey, this.userId); + } catch (e) { + logger.error("invalid signature on user-signing key"); + // FIXME: what do we want to do here? + throw e; + } + } + if (keys.self_signing) { + if (keys.self_signing.user_id !== this.userId) { + const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId; + logger.error(error); + throw new Error(error); + } + try { + pkVerify(keys.self_signing, masterKey, this.userId); + } catch (e) { + logger.error("invalid signature on self-signing key"); + // FIXME: what do we want to do here? + throw e; + } + } + + // if everything checks out, then save the keys + if (keys.master) { + this.keys.master = keys.master; + // if the master key is set, then the old self-signing and user-signing keys are obsolete + delete this.keys["self_signing"]; + delete this.keys["user_signing"]; + } + if (keys.self_signing) { + this.keys.self_signing = keys.self_signing; + } + if (keys.user_signing) { + this.keys.user_signing = keys.user_signing; + } + } + + public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void { + // It is critical that this value latches forward from false to true but + // never back to false to avoid a downgrade attack. + if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) { + this.crossSigningVerifiedBefore = true; + } + } + + public async signObject<T extends object>(data: T, type: string): Promise<T & { signatures: ISignatures }> { + if (!this.keys[type]) { + throw new Error("Attempted to sign with " + type + " key but no such key present"); + } + const [pubkey, signing] = await this.getCrossSigningKey(type); + try { + pkSign(data, signing, this.userId, pubkey); + return data as T & { signatures: ISignatures }; + } finally { + signing.free(); + } + } + + public async signUser(key: CrossSigningInfo): Promise<ICrossSigningKey | undefined> { + if (!this.keys.user_signing) { + logger.info("No user signing key: not signing user"); + return; + } + return this.signObject(key.keys.master, "user_signing"); + } + + public async signDevice(userId: string, device: DeviceInfo): Promise<ISignedKey | undefined> { + if (userId !== this.userId) { + throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`); + } + if (!this.keys.self_signing) { + logger.info("No self signing key: not signing device"); + return; + } + return this.signObject<Omit<ISignedKey, "signatures">>( + { + algorithms: device.algorithms, + keys: device.keys, + device_id: device.deviceId, + user_id: userId, + }, + "self_signing", + ); + } + + /** + * Check whether a given user is trusted. + * + * @param userCrossSigning - Cross signing info for user + * + * @returns + */ + public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel { + // if we're checking our own key, then it's trusted if the master key + // and self-signing key match + if ( + this.userId === userCrossSigning.userId && + this.getId() && + this.getId() === userCrossSigning.getId() && + this.getId("self_signing") && + this.getId("self_signing") === userCrossSigning.getId("self_signing") + ) { + return new UserTrustLevel(true, true, this.firstUse); + } + + if (!this.keys.user_signing) { + // If there's no user signing key, they can't possibly be verified. + // They may be TOFU trusted though. + return new UserTrustLevel(false, false, userCrossSigning.firstUse); + } + + let userTrusted: boolean; + const userMaster = userCrossSigning.keys.master; + const uskId = this.getId("user_signing")!; + try { + pkVerify(userMaster, uskId, this.userId); + userTrusted = true; + } catch (e) { + userTrusted = false; + } + return new UserTrustLevel(userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse); + } + + /** + * Check whether a given device is trusted. + * + * @param userCrossSigning - Cross signing info for user + * @param device - The device to check + * @param localTrust - Whether the device is trusted locally + * @param trustCrossSignedDevices - Whether we trust cross signed devices + * + * @returns + */ + public checkDeviceTrust( + userCrossSigning: CrossSigningInfo, + device: DeviceInfo, + localTrust: boolean, + trustCrossSignedDevices: boolean, + ): DeviceTrustLevel { + const userTrust = this.checkUserTrust(userCrossSigning); + + const userSSK = userCrossSigning.keys.self_signing; + if (!userSSK) { + // if the user has no self-signing key then we cannot make any + // trust assertions about this device from cross-signing + return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); + } + + const deviceObj = deviceToObject(device, userCrossSigning.userId); + try { + // if we can verify the user's SSK from their master key... + pkVerify(userSSK, userCrossSigning.getId()!, userCrossSigning.userId); + // ...and this device's key from their SSK... + pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId); + // ...then we trust this device as much as far as we trust the user + return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices); + } catch (e) { + return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); + } + } + + /** + * @returns Cache callbacks + */ + public getCacheCallbacks(): ICacheCallbacks { + return this.cacheCallbacks; + } +} + +interface DeviceObject extends IObject { + algorithms: string[]; + keys: Record<string, string>; + device_id: string; + user_id: string; +} + +function deviceToObject(device: DeviceInfo, userId: string): DeviceObject { + return { + algorithms: device.algorithms, + keys: device.keys, + device_id: device.deviceId, + user_id: userId, + signatures: device.signatures, + }; +} + +export enum CrossSigningLevel { + MASTER = 4, + USER_SIGNING = 2, + SELF_SIGNING = 1, +} + +/** + * Represents the ways in which we trust a user + */ +export class UserTrustLevel { + public constructor( + private readonly crossSigningVerified: boolean, + private readonly crossSigningVerifiedBefore: boolean, + private readonly tofu: boolean, + ) {} + + /** + * @returns true if this user is verified via any means + */ + public isVerified(): boolean { + return this.isCrossSigningVerified(); + } + + /** + * @returns true if this user is verified via cross signing + */ + public isCrossSigningVerified(): boolean { + return this.crossSigningVerified; + } + + /** + * @returns true if we ever verified this user before (at least for + * the history of verifications observed by this device). + */ + public wasCrossSigningVerified(): boolean { + return this.crossSigningVerifiedBefore; + } + + /** + * @returns true if this user's key is trusted on first use + */ + public isTofu(): boolean { + return this.tofu; + } +} + +/** + * Represents the ways in which we trust a device + */ +export class DeviceTrustLevel { + public constructor( + public readonly crossSigningVerified: boolean, + public readonly tofu: boolean, + private readonly localVerified: boolean, + private readonly trustCrossSignedDevices: boolean, + ) {} + + public static fromUserTrustLevel( + userTrustLevel: UserTrustLevel, + localVerified: boolean, + trustCrossSignedDevices: boolean, + ): DeviceTrustLevel { + return new DeviceTrustLevel( + userTrustLevel.isCrossSigningVerified(), + userTrustLevel.isTofu(), + localVerified, + trustCrossSignedDevices, + ); + } + + /** + * @returns true if this device is verified via any means + */ + public isVerified(): boolean { + return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified())); + } + + /** + * @returns true if this device is verified via cross signing + */ + public isCrossSigningVerified(): boolean { + return this.crossSigningVerified; + } + + /** + * @returns true if this device is verified locally + */ + public isLocallyVerified(): boolean { + return this.localVerified; + } + + /** + * @returns true if this device is trusted from a user's key + * that is trusted on first use + */ + public isTofu(): boolean { + return this.tofu; + } +} + +export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks { + return { + getCrossSigningKeyCache: async function ( + type: keyof SecretStorePrivateKeys, + _expectedPublicKey: string, + ): Promise<Uint8Array> { + const key = await new Promise<any>((resolve) => { + return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + store.getSecretStorePrivateKey(txn, resolve, type); + }); + }); + + if (key && key.ciphertext) { + const pickleKey = Buffer.from(olmDevice.pickleKey); + const decrypted = await decryptAES(key, pickleKey, type); + return decodeBase64(decrypted); + } else { + return key; + } + }, + storeCrossSigningKeyCache: async function ( + type: keyof SecretStorePrivateKeys, + key?: Uint8Array, + ): Promise<void> { + if (!(key instanceof Uint8Array)) { + throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`); + } + const pickleKey = Buffer.from(olmDevice.pickleKey); + const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type); + return store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + store.storeSecretStorePrivateKey(txn, type, encryptedKey); + }); + }, + }; +} + +export type KeysDuringVerification = [[string, PkSigning], [string, PkSigning], [string, PkSigning], void]; + +/** + * Request cross-signing keys from another device during verification. + * + * @param baseApis - base Matrix API interface + * @param userId - The user ID being verified + * @param deviceId - The device ID being verified + */ +export async function requestKeysDuringVerification( + baseApis: MatrixClient, + userId: string, + deviceId: string, +): Promise<KeysDuringVerification | void> { + // If this is a self-verification, ask the other party for keys + if (baseApis.getUserId() !== userId) { + return; + } + logger.log("Cross-signing: Self-verification done; requesting keys"); + // This happens asynchronously, and we're not concerned about waiting for + // it. We return here in order to test. + return new Promise<KeysDuringVerification | void>((resolve, reject) => { + const client = baseApis; + const original = client.crypto!.crossSigningInfo; + + // We already have all of the infrastructure we need to validate and + // cache cross-signing keys, so instead of replicating that, here we set + // up callbacks that request them from the other device and call + // CrossSigningInfo.getCrossSigningKey() to validate/cache + const crossSigning = new CrossSigningInfo( + original.userId, + { + getCrossSigningKey: async (type): Promise<Uint8Array> => { + logger.debug("Cross-signing: requesting secret", type, deviceId); + const { promise } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]); + const result = await promise; + const decoded = decodeBase64(result); + return Uint8Array.from(decoded); + }, + }, + original.getCacheCallbacks(), + ); + crossSigning.keys = original.keys; + + // XXX: get all keys out if we get one key out + // https://github.com/vector-im/element-web/issues/12604 + // then change here to reject on the timeout + // Requests can be ignored, so don't wait around forever + const timeout = new Promise<void>((resolve) => { + setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout")); + }); + + // also request and cache the key backup key + const backupKeyPromise = (async (): Promise<void> => { + const cachedKey = await client.crypto!.getSessionBackupPrivateKey(); + if (!cachedKey) { + logger.info("No cached backup key found. Requesting..."); + const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]); + const base64Key = await secretReq.promise; + logger.info("Got key backup key, decoding..."); + const decodedKey = decodeBase64(base64Key); + logger.info("Decoded backup key, storing..."); + await client.crypto!.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey)); + logger.info("Backup key stored. Starting backup restore..."); + const backupInfo = await client.getKeyBackupVersion(); + // no need to await for this - just let it go in the bg + client.restoreKeyBackupWithCache(undefined, undefined, backupInfo!).then(() => { + logger.info("Backup restored."); + }); + } + })(); + + // We call getCrossSigningKey() for its side-effects + return Promise.race<KeysDuringVerification | void>([ + Promise.all([ + crossSigning.getCrossSigningKey("master"), + crossSigning.getCrossSigningKey("self_signing"), + crossSigning.getCrossSigningKey("user_signing"), + backupKeyPromise, + ]) as Promise<KeysDuringVerification>, + timeout, + ]).then(resolve, reject); + }).catch((e) => { + logger.warn("Cross-signing: failure while requesting keys:", e); + }); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts new file mode 100644 index 0000000..a1ff0eb --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts @@ -0,0 +1,989 @@ +/* +Copyright 2017 - 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. +*/ + +/** + * Manages the list of other users' devices + */ + +import { logger } from "../logger"; +import { DeviceInfo, IDevice } from "./deviceinfo"; +import { CrossSigningInfo, ICrossSigningInfo } from "./CrossSigning"; +import * as olmlib from "./olmlib"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +import { chunkPromises, defer, IDeferred, sleep } from "../utils"; +import { DeviceKeys, IDownloadKeyResult, Keys, MatrixClient, SigningKeys } from "../client"; +import { OlmDevice } from "./OlmDevice"; +import { CryptoStore } from "./store/base"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { CryptoEvent, CryptoEventHandlerMap } from "./index"; + +/* State transition diagram for DeviceList.deviceTrackingStatus + * + * | + * stopTrackingDeviceList V + * +---------------------> NOT_TRACKED + * | | + * +<--------------------+ | startTrackingDeviceList + * | | V + * | +-------------> PENDING_DOWNLOAD <--------------------+-+ + * | | ^ | | | + * | | restart download | | start download | | invalidateUserDeviceList + * | | client failed | | | | + * | | | V | | + * | +------------ DOWNLOAD_IN_PROGRESS -------------------+ | + * | | | | + * +<-------------------+ | download successful | + * ^ V | + * +----------------------- UP_TO_DATE ------------------------+ + */ + +// constants for DeviceList.deviceTrackingStatus +export enum TrackingStatus { + NotTracked, + PendingDownload, + DownloadInProgress, + UpToDate, +} + +// user-Id → device-Id → DeviceInfo +export type DeviceInfoMap = Map<string, Map<string, DeviceInfo>>; + +type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated; + +export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHandlerMap> { + private devices: { [userId: string]: { [deviceId: string]: IDevice } } = {}; + + public crossSigningInfo: { [userId: string]: ICrossSigningInfo } = {}; + + // map of identity keys to the user who owns it + private userByIdentityKey: Record<string, string> = {}; + + // which users we are tracking device status for. + private deviceTrackingStatus: { [userId: string]: TrackingStatus } = {}; // loaded from storage in load() + + // The 'next_batch' sync token at the point the data was written, + // ie. a token representing the point immediately after the + // moment represented by the snapshot in the db. + private syncToken: string | null = null; + + private keyDownloadsInProgressByUser = new Map<string, Promise<void>>(); + + // Set whenever changes are made other than setting the sync token + private dirty = false; + + // Promise resolved when device data is saved + private savePromise: Promise<boolean> | null = null; + // Function that resolves the save promise + private resolveSavePromise: ((saved: boolean) => void) | null = null; + // The time the save is scheduled for + private savePromiseTime: number | null = null; + // The timer used to delay the save + private saveTimer: ReturnType<typeof setTimeout> | null = null; + // True if we have fetched data from the server or loaded a non-empty + // set of device data from the store + private hasFetched: boolean | null = null; + + private readonly serialiser: DeviceListUpdateSerialiser; + + public constructor( + baseApis: MatrixClient, + private readonly cryptoStore: CryptoStore, + olmDevice: OlmDevice, + // Maximum number of user IDs per request to prevent server overload (#1619) + public readonly keyDownloadChunkSize = 250, + ) { + super(); + + this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this); + } + + /** + * Load the device tracking state from storage + */ + public async load(): Promise<void> { + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { + this.hasFetched = Boolean(deviceData && deviceData.devices); + this.devices = deviceData ? deviceData.devices : {}; + this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; + this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {}; + this.syncToken = deviceData?.syncToken ?? null; + this.userByIdentityKey = {}; + for (const user of Object.keys(this.devices)) { + const userDevices = this.devices[user]; + for (const device of Object.keys(userDevices)) { + const idKey = userDevices[device].keys["curve25519:" + device]; + if (idKey !== undefined) { + this.userByIdentityKey[idKey] = user; + } + } + } + }); + }); + + for (const u of Object.keys(this.deviceTrackingStatus)) { + // if a download was in progress when we got shut down, it isn't any more. + if (this.deviceTrackingStatus[u] == TrackingStatus.DownloadInProgress) { + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; + } + } + } + + public stop(): void { + if (this.saveTimer !== null) { + clearTimeout(this.saveTimer); + } + } + + /** + * Save the device tracking state to storage, if any changes are + * pending other than updating the sync token + * + * The actual save will be delayed by a short amount of time to + * aggregate multiple writes to the database. + * + * @param delay - Time in ms before which the save actually happens. + * By default, the save is delayed for a short period in order to batch + * multiple writes, but this behaviour can be disabled by passing 0. + * + * @returns true if the data was saved, false if + * it was not (eg. because no changes were pending). The promise + * will only resolve once the data is saved, so may take some time + * to resolve. + */ + public async saveIfDirty(delay = 500): Promise<boolean> { + if (!this.dirty) return Promise.resolve(false); + // Delay saves for a bit so we can aggregate multiple saves that happen + // in quick succession (eg. when a whole room's devices are marked as known) + + const targetTime = Date.now() + delay; + if (this.savePromiseTime && targetTime < this.savePromiseTime) { + // There's a save scheduled but for after we would like: cancel + // it & schedule one for the time we want + clearTimeout(this.saveTimer!); + this.saveTimer = null; + this.savePromiseTime = null; + // (but keep the save promise since whatever called save before + // will still want to know when the save is done) + } + + let savePromise = this.savePromise; + if (savePromise === null) { + savePromise = new Promise((resolve) => { + this.resolveSavePromise = resolve; + }); + this.savePromise = savePromise; + } + + if (this.saveTimer === null) { + const resolveSavePromise = this.resolveSavePromise; + this.savePromiseTime = targetTime; + this.saveTimer = setTimeout(() => { + logger.log("Saving device tracking data", this.syncToken); + + // null out savePromise now (after the delay but before the write), + // otherwise we could return the existing promise when the save has + // actually already happened. + this.savePromiseTime = null; + this.saveTimer = null; + this.savePromise = null; + this.resolveSavePromise = null; + + this.cryptoStore + .doTxn("readwrite", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this.cryptoStore.storeEndToEndDeviceData( + { + devices: this.devices, + crossSigningInfo: this.crossSigningInfo, + trackingStatus: this.deviceTrackingStatus, + syncToken: this.syncToken ?? undefined, + }, + txn, + ); + }) + .then( + () => { + // The device list is considered dirty until the write completes. + this.dirty = false; + resolveSavePromise?.(true); + }, + (err) => { + logger.error("Failed to save device tracking data", this.syncToken); + logger.error(err); + }, + ); + }, delay); + } + + return savePromise; + } + + /** + * Gets the sync token last set with setSyncToken + * + * @returns The sync token + */ + public getSyncToken(): string | null { + return this.syncToken; + } + + /** + * Sets the sync token that the app will pass as the 'since' to the /sync + * endpoint next time it syncs. + * The sync token must always be set after any changes made as a result of + * data in that sync since setting the sync token to a newer one will mean + * those changed will not be synced from the server if a new client starts + * up with that data. + * + * @param st - The sync token + */ + public setSyncToken(st: string | null): void { + this.syncToken = st; + } + + /** + * Ensures up to date keys for a list of users are stored in the session store, + * downloading and storing them if they're not (or if forceDownload is + * true). + * @param userIds - The users to fetch. + * @param forceDownload - Always download the keys even if cached. + * + * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}. + */ + public downloadKeys(userIds: string[], forceDownload: boolean): Promise<DeviceInfoMap> { + const usersToDownload: string[] = []; + const promises: Promise<unknown>[] = []; + + userIds.forEach((u) => { + const trackingStatus = this.deviceTrackingStatus[u]; + if (this.keyDownloadsInProgressByUser.has(u)) { + // already a key download in progress/queued for this user; its results + // will be good enough for us. + logger.log(`downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`); + promises.push(this.keyDownloadsInProgressByUser.get(u)!); + } else if (forceDownload || trackingStatus != TrackingStatus.UpToDate) { + usersToDownload.push(u); + } + }); + + if (usersToDownload.length != 0) { + logger.log("downloadKeys: downloading for", usersToDownload); + const downloadPromise = this.doKeyDownload(usersToDownload); + promises.push(downloadPromise); + } + + if (promises.length === 0) { + logger.log("downloadKeys: already have all necessary keys"); + } + + return Promise.all(promises).then(() => { + return this.getDevicesFromStore(userIds); + }); + } + + /** + * Get the stored device keys for a list of user ids + * + * @param userIds - the list of users to list keys for. + * + * @returns userId-\>deviceId-\>{@link DeviceInfo}. + */ + private getDevicesFromStore(userIds: string[]): DeviceInfoMap { + const stored: DeviceInfoMap = new Map(); + userIds.forEach((userId) => { + const deviceMap = new Map(); + this.getStoredDevicesForUser(userId)?.forEach(function (device) { + deviceMap.set(device.deviceId, device); + }); + stored.set(userId, deviceMap); + }); + return stored; + } + + /** + * Returns a list of all user IDs the DeviceList knows about + * + * @returns All known user IDs + */ + public getKnownUserIds(): string[] { + return Object.keys(this.devices); + } + + /** + * Get the stored device keys for a user id + * + * @param userId - the user to list keys for. + * + * @returns list of devices, or null if we haven't + * managed to get a list of devices for this user yet. + */ + public getStoredDevicesForUser(userId: string): DeviceInfo[] | null { + const devs = this.devices[userId]; + if (!devs) { + return null; + } + const res: DeviceInfo[] = []; + for (const deviceId in devs) { + if (devs.hasOwnProperty(deviceId)) { + res.push(DeviceInfo.fromStorage(devs[deviceId], deviceId)); + } + } + return res; + } + + /** + * Get the stored device data for a user, in raw object form + * + * @param userId - the user to get data for + * + * @returns `deviceId->{object}` devices, or undefined if + * there is no data for this user. + */ + public getRawStoredDevicesForUser(userId: string): Record<string, IDevice> { + return this.devices[userId]; + } + + public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { + if (!this.crossSigningInfo[userId]) return null; + + return CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId); + } + + public storeCrossSigningForUser(userId: string, info: ICrossSigningInfo): void { + this.crossSigningInfo[userId] = info; + this.dirty = true; + } + + /** + * Get the stored keys for a single device + * + * + * @returns device, or undefined + * if we don't know about this device + */ + public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { + const devs = this.devices[userId]; + if (!devs?.[deviceId]) { + return undefined; + } + return DeviceInfo.fromStorage(devs[deviceId], deviceId); + } + + /** + * Get a user ID by one of their device's curve25519 identity key + * + * @param algorithm - encryption algorithm + * @param senderKey - curve25519 key to match + * + * @returns user ID + */ + public getUserByIdentityKey(algorithm: string, senderKey: string): string | null { + if (algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM) { + // we only deal in olm keys + return null; + } + + return this.userByIdentityKey[senderKey]; + } + + /** + * Find a device by curve25519 identity key + * + * @param algorithm - encryption algorithm + * @param senderKey - curve25519 key to match + */ + public getDeviceByIdentityKey(algorithm: string, senderKey: string): DeviceInfo | null { + const userId = this.getUserByIdentityKey(algorithm, senderKey); + if (!userId) { + return null; + } + + const devices = this.devices[userId]; + if (!devices) { + return null; + } + + for (const deviceId in devices) { + if (!devices.hasOwnProperty(deviceId)) { + continue; + } + + const device = devices[deviceId]; + for (const keyId in device.keys) { + if (!device.keys.hasOwnProperty(keyId)) { + continue; + } + if (keyId.indexOf("curve25519:") !== 0) { + continue; + } + const deviceKey = device.keys[keyId]; + if (deviceKey == senderKey) { + return DeviceInfo.fromStorage(device, deviceId); + } + } + } + + // doesn't match a known device + return null; + } + + /** + * Replaces the list of devices for a user with the given device list + * + * @param userId - The user ID + * @param devices - New device info for user + */ + public storeDevicesForUser(userId: string, devices: Record<string, IDevice>): void { + this.setRawStoredDevicesForUser(userId, devices); + this.dirty = true; + } + + /** + * flag the given user for device-list tracking, if they are not already. + * + * This will mean that a subsequent call to refreshOutdatedDeviceLists() + * will download the device list for the user, and that subsequent calls to + * invalidateUserDeviceList will trigger more updates. + * + */ + public startTrackingDeviceList(userId: string): void { + // sanity-check the userId. This is mostly paranoia, but if synapse + // can't parse the userId we give it as an mxid, it 500s the whole + // request and we can never update the device lists again (because + // the broken userId is always 'invalid' and always included in any + // refresh request). + // By checking it is at least a string, we can eliminate a class of + // silly errors. + if (typeof userId !== "string") { + throw new Error("userId must be a string; was " + userId); + } + if (!this.deviceTrackingStatus[userId]) { + logger.log("Now tracking device list for " + userId); + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; + // we don't yet persist the tracking status, since there may be a lot + // of calls; we save all data together once the sync is done + this.dirty = true; + } + } + + /** + * Mark the given user as no longer being tracked for device-list updates. + * + * This won't affect any in-progress downloads, which will still go on to + * complete; it will just mean that we don't think that we have an up-to-date + * list for future calls to downloadKeys. + * + */ + public stopTrackingDeviceList(userId: string): void { + if (this.deviceTrackingStatus[userId]) { + logger.log("No longer tracking device list for " + userId); + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; + + // we don't yet persist the tracking status, since there may be a lot + // of calls; we save all data together once the sync is done + this.dirty = true; + } + } + + /** + * Set all users we're currently tracking to untracked + * + * This will flag each user whose devices we are tracking as in need of an + * update. + */ + public stopTrackingAllDeviceLists(): void { + for (const userId of Object.keys(this.deviceTrackingStatus)) { + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; + } + this.dirty = true; + } + + /** + * Mark the cached device list for the given user outdated. + * + * If we are not tracking this user's devices, we'll do nothing. Otherwise + * we flag the user as needing an update. + * + * This doesn't actually set off an update, so that several users can be + * batched together. Call refreshOutdatedDeviceLists() for that. + * + */ + public invalidateUserDeviceList(userId: string): void { + if (this.deviceTrackingStatus[userId]) { + logger.log("Marking device list outdated for", userId); + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; + + // we don't yet persist the tracking status, since there may be a lot + // of calls; we save all data together once the sync is done + this.dirty = true; + } + } + + /** + * If we have users who have outdated device lists, start key downloads for them + * + * @returns which completes when the download completes; normally there + * is no need to wait for this (it's mostly for the unit tests). + */ + public refreshOutdatedDeviceLists(): Promise<void> { + this.saveIfDirty(); + + const usersToDownload: string[] = []; + for (const userId of Object.keys(this.deviceTrackingStatus)) { + const stat = this.deviceTrackingStatus[userId]; + if (stat == TrackingStatus.PendingDownload) { + usersToDownload.push(userId); + } + } + + return this.doKeyDownload(usersToDownload); + } + + /** + * Set the stored device data for a user, in raw object form + * Used only by internal class DeviceListUpdateSerialiser + * + * @param userId - the user to get data for + * + * @param devices - `deviceId->{object}` the new devices + */ + public setRawStoredDevicesForUser(userId: string, devices: Record<string, IDevice>): void { + // remove old devices from userByIdentityKey + if (this.devices[userId] !== undefined) { + for (const [deviceId, dev] of Object.entries(this.devices[userId])) { + const identityKey = dev.keys["curve25519:" + deviceId]; + + delete this.userByIdentityKey[identityKey]; + } + } + + this.devices[userId] = devices; + + // add new devices into userByIdentityKey + for (const [deviceId, dev] of Object.entries(devices)) { + const identityKey = dev.keys["curve25519:" + deviceId]; + + this.userByIdentityKey[identityKey] = userId; + } + } + + public setRawStoredCrossSigningForUser(userId: string, info: ICrossSigningInfo): void { + this.crossSigningInfo[userId] = info; + } + + /** + * Fire off download update requests for the given users, and update the + * device list tracking status for them, and the + * keyDownloadsInProgressByUser map for them. + * + * @param users - list of userIds + * + * @returns resolves when all the users listed have + * been updated. rejects if there was a problem updating any of the + * users. + */ + private doKeyDownload(users: string[]): Promise<void> { + if (users.length === 0) { + // nothing to do + return Promise.resolve(); + } + + const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken!).then( + () => { + finished(true); + }, + (e) => { + logger.error("Error downloading keys for " + users + ":", e); + finished(false); + throw e; + }, + ); + + users.forEach((u) => { + this.keyDownloadsInProgressByUser.set(u, prom); + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.PendingDownload) { + this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress; + } + }); + + const finished = (success: boolean): void => { + this.emit(CryptoEvent.WillUpdateDevices, users, !this.hasFetched); + users.forEach((u) => { + this.dirty = true; + + // we may have queued up another download request for this user + // since we started this request. If that happens, we should + // ignore the completion of the first one. + if (this.keyDownloadsInProgressByUser.get(u) !== prom) { + logger.log("Another update in the queue for", u, "- not marking up-to-date"); + return; + } + this.keyDownloadsInProgressByUser.delete(u); + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.DownloadInProgress) { + if (success) { + // we didn't get any new invalidations since this download started: + // this user's device list is now up to date. + this.deviceTrackingStatus[u] = TrackingStatus.UpToDate; + logger.log("Device list for", u, "now up to date"); + } else { + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; + } + } + }); + this.saveIfDirty(); + this.emit(CryptoEvent.DevicesUpdated, users, !this.hasFetched); + this.hasFetched = true; + }; + + return prom; + } +} + +/** + * Serialises updates to device lists + * + * Ensures that results from /keys/query are not overwritten if a second call + * completes *before* an earlier one. + * + * It currently does this by ensuring only one call to /keys/query happens at a + * time (and queuing other requests up). + */ +class DeviceListUpdateSerialiser { + private downloadInProgress = false; + + // users which are queued for download + // userId -> true + private keyDownloadsQueuedByUser: Record<string, boolean> = {}; + + // deferred which is resolved when the queued users are downloaded. + // non-null indicates that we have users queued for download. + private queuedQueryDeferred?: IDeferred<void>; + + private syncToken?: string; // The sync token we send with the requests + + /* + * @param baseApis - Base API object + * @param olmDevice - The Olm Device + * @param deviceList - The device list object, the device list to be updated + */ + public constructor( + private readonly baseApis: MatrixClient, + private readonly olmDevice: OlmDevice, + private readonly deviceList: DeviceList, + ) {} + + /** + * Make a key query request for the given users + * + * @param users - list of user ids + * + * @param syncToken - sync token to pass in the query request, to + * help the HS give the most recent results + * + * @returns resolves when all the users listed have + * been updated. rejects if there was a problem updating any of the + * users. + */ + public updateDevicesForUsers(users: string[], syncToken: string): Promise<void> { + users.forEach((u) => { + this.keyDownloadsQueuedByUser[u] = true; + }); + + if (!this.queuedQueryDeferred) { + this.queuedQueryDeferred = defer(); + } + + // We always take the new sync token and just use the latest one we've + // been given, since it just needs to be at least as recent as the + // sync response the device invalidation message arrived in + this.syncToken = syncToken; + + if (this.downloadInProgress) { + // just queue up these users + logger.log("Queued key download for", users); + return this.queuedQueryDeferred.promise; + } + + // start a new download. + return this.doQueuedQueries(); + } + + private doQueuedQueries(): Promise<void> { + if (this.downloadInProgress) { + throw new Error("DeviceListUpdateSerialiser.doQueuedQueries called with request active"); + } + + const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser); + this.keyDownloadsQueuedByUser = {}; + const deferred = this.queuedQueryDeferred; + this.queuedQueryDeferred = undefined; + + logger.log("Starting key download for", downloadUsers); + this.downloadInProgress = true; + + const opts: Parameters<MatrixClient["downloadKeysForUsers"]>[1] = {}; + if (this.syncToken) { + opts.token = this.syncToken; + } + + const factories: Array<() => Promise<IDownloadKeyResult>> = []; + for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) { + const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize); + factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts)); + } + + chunkPromises(factories, 3) + .then(async (responses: IDownloadKeyResult[]) => { + const dk: IDownloadKeyResult["device_keys"] = Object.assign( + {}, + ...responses.map((res) => res.device_keys || {}), + ); + const masterKeys: IDownloadKeyResult["master_keys"] = Object.assign( + {}, + ...responses.map((res) => res.master_keys || {}), + ); + const ssks: IDownloadKeyResult["self_signing_keys"] = Object.assign( + {}, + ...responses.map((res) => res.self_signing_keys || {}), + ); + const usks: IDownloadKeyResult["user_signing_keys"] = Object.assign( + {}, + ...responses.map((res) => res.user_signing_keys || {}), + ); + + // yield to other things that want to execute in between users, to + // avoid wedging the CPU + // (https://github.com/vector-im/element-web/issues/3158) + // + // of course we ought to do this in a web worker or similar, but + // this serves as an easy solution for now. + for (const userId of downloadUsers) { + await sleep(5); + try { + await this.processQueryResponseForUser(userId, dk[userId], { + master: masterKeys?.[userId], + self_signing: ssks?.[userId], + user_signing: usks?.[userId], + }); + } catch (e) { + // log the error but continue, so that one bad key + // doesn't kill the whole process + logger.error(`Error processing keys for ${userId}:`, e); + } + } + }) + .then( + () => { + logger.log("Completed key download for " + downloadUsers); + + this.downloadInProgress = false; + deferred?.resolve(); + + // if we have queued users, fire off another request. + if (this.queuedQueryDeferred) { + this.doQueuedQueries(); + } + }, + (e) => { + logger.warn("Error downloading keys for " + downloadUsers + ":", e); + this.downloadInProgress = false; + deferred?.reject(e); + }, + ); + + return deferred!.promise; + } + + private async processQueryResponseForUser( + userId: string, + dkResponse: DeviceKeys, + crossSigningResponse: { + master?: Keys; + self_signing?: SigningKeys; + user_signing?: SigningKeys; + }, + ): Promise<void> { + logger.log("got device keys for " + userId + ":", dkResponse); + logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse); + + { + // map from deviceid -> deviceinfo for this user + const userStore: Record<string, DeviceInfo> = {}; + const devs = this.deviceList.getRawStoredDevicesForUser(userId); + if (devs) { + Object.keys(devs).forEach((deviceId) => { + const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); + userStore[deviceId] = d; + }); + } + + await updateStoredDeviceKeysForUser( + this.olmDevice, + userId, + userStore, + dkResponse || {}, + this.baseApis.getUserId()!, + this.baseApis.deviceId!, + ); + + // put the updates into the object that will be returned as our results + const storage: Record<string, IDevice> = {}; + Object.keys(userStore).forEach((deviceId) => { + storage[deviceId] = userStore[deviceId].toStorage(); + }); + + this.deviceList.setRawStoredDevicesForUser(userId, storage); + } + + // now do the same for the cross-signing keys + { + // FIXME: should we be ignoring empty cross-signing responses, or + // should we be dropping the keys? + if ( + crossSigningResponse && + (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing) + ) { + const crossSigning = + this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId); + + crossSigning.setKeys(crossSigningResponse); + + this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); + + // NB. Unlike most events in the js-sdk, this one is internal to the + // js-sdk and is not re-emitted + this.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, userId); + } + } + } +} + +async function updateStoredDeviceKeysForUser( + olmDevice: OlmDevice, + userId: string, + userStore: Record<string, DeviceInfo>, + userResult: IDownloadKeyResult["device_keys"]["user_id"], + localUserId: string, + localDeviceId: string, +): Promise<boolean> { + let updated = false; + + // remove any devices in the store which aren't in the response + for (const deviceId in userStore) { + if (!userStore.hasOwnProperty(deviceId)) { + continue; + } + + if (!(deviceId in userResult)) { + if (userId === localUserId && deviceId === localDeviceId) { + logger.warn(`Local device ${deviceId} missing from sync, skipping removal`); + continue; + } + + logger.log("Device " + userId + ":" + deviceId + " has been removed"); + delete userStore[deviceId]; + updated = true; + } + } + + for (const deviceId in userResult) { + if (!userResult.hasOwnProperty(deviceId)) { + continue; + } + + const deviceResult = userResult[deviceId]; + + // check that the user_id and device_id in the response object are + // correct + if (deviceResult.user_id !== userId) { + logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId); + continue; + } + if (deviceResult.device_id !== deviceId) { + logger.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId); + continue; + } + + if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) { + updated = true; + } + } + + return updated; +} + +/* + * Process a device in a /query response, and add it to the userStore + * + * returns (a promise for) true if a change was made, else false + */ +async function storeDeviceKeys( + olmDevice: OlmDevice, + userStore: Record<string, DeviceInfo>, + deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"], +): Promise<boolean> { + if (!deviceResult.keys) { + // no keys? + return false; + } + + const deviceId = deviceResult.device_id; + const userId = deviceResult.user_id; + + const signKeyId = "ed25519:" + deviceId; + const signKey = deviceResult.keys[signKeyId]; + if (!signKey) { + logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key"); + return false; + } + + const unsigned = deviceResult.unsigned || {}; + const signatures = deviceResult.signatures || {}; + + try { + await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey); + } catch (e) { + logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e); + return false; + } + + // DeviceInfo + let deviceStore; + + if (deviceId in userStore) { + // already have this device. + deviceStore = userStore[deviceId]; + + if (deviceStore.getFingerprint() != signKey) { + // this should only happen if the list has been MITMed; we are + // best off sticking with the original keys. + // + // Should we warn the user about it somehow? + logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed"); + return false; + } + } else { + userStore[deviceId] = deviceStore = new DeviceInfo(deviceId); + } + + deviceStore.keys = deviceResult.keys || {}; + deviceStore.algorithms = deviceResult.algorithms || []; + deviceStore.unsigned = unsigned; + deviceStore.signatures = signatures; + return true; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts new file mode 100644 index 0000000..4efe677 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts @@ -0,0 +1,356 @@ +/* +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 { logger } from "../logger"; +import { IContent, MatrixEvent } from "../models/event"; +import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +import { Method, ClientPrefix } from "../http-api"; +import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index"; +import { + ClientEvent, + ClientEventHandlerMap, + CrossSigningKeys, + ICrossSigningKey, + ISignedKey, + KeySignatures, +} from "../client"; +import { IKeyBackupInfo } from "./keybackup"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { IAccountDataClient } from "./SecretStorage"; +import { SecretStorageKeyDescription } from "../secret-storage"; + +interface ICrossSigningKeys { + authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; + keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>; +} + +/** + * Builds an EncryptionSetupOperation by calling any of the add.. methods. + * Once done, `buildOperation()` can be called which allows to apply to operation. + * + * This is used as a helper by Crypto to keep track of all the network requests + * and other side-effects of bootstrapping, so it can be applied in one go (and retried in the future) + * Also keeps track of all the private keys created during bootstrapping, so we don't need to prompt for them + * more than once. + */ +export class EncryptionSetupBuilder { + public readonly accountDataClientAdapter: AccountDataClientAdapter; + public readonly crossSigningCallbacks: CrossSigningCallbacks; + public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks; + + private crossSigningKeys?: ICrossSigningKeys; + private keySignatures?: KeySignatures; + private keyBackupInfo?: IKeyBackupInfo; + private sessionBackupPrivateKey?: Uint8Array; + + /** + * @param accountData - pre-existing account data, will only be read, not written. + * @param delegateCryptoCallbacks - crypto callbacks to delegate to if the key isn't in cache yet + */ + public constructor(accountData: Map<string, MatrixEvent>, delegateCryptoCallbacks?: ICryptoCallbacks) { + this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); + this.crossSigningCallbacks = new CrossSigningCallbacks(); + this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks); + } + + /** + * Adds new cross-signing public keys + * + * @param authUpload - Function called to await an interactive auth + * flow when uploading device signing keys. + * Args: + * A function that makes the request requiring auth. Receives + * the auth data as an object. Can be called multiple times, first with + * an empty authDict, to obtain the flows. + * @param keys - the new keys + */ + public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void { + this.crossSigningKeys = { authUpload, keys }; + } + + /** + * Adds the key backup info to be updated on the server + * + * Used either to create a new key backup, or add signatures + * from the new MSK. + * + * @param keyBackupInfo - as received from/sent to the server + */ + public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void { + this.keyBackupInfo = keyBackupInfo; + } + + /** + * Adds the session backup private key to be updated in the local cache + * + * Used after fixing the format of the key + * + */ + public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void { + this.sessionBackupPrivateKey = privateKey; + } + + /** + * Add signatures from a given user and device/x-sign key + * Used to sign the new cross-signing key with the device key + * + */ + public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void { + if (!this.keySignatures) { + this.keySignatures = {}; + } + const userSignatures = this.keySignatures[userId] || {}; + this.keySignatures[userId] = userSignatures; + userSignatures[deviceId] = signature; + } + + public async setAccountData(type: string, content: object): Promise<void> { + await this.accountDataClientAdapter.setAccountData(type, content); + } + + /** + * builds the operation containing all the parts that have been added to the builder + */ + public buildOperation(): EncryptionSetupOperation { + const accountData = this.accountDataClientAdapter.values; + return new EncryptionSetupOperation(accountData, this.crossSigningKeys, this.keyBackupInfo, this.keySignatures); + } + + /** + * Stores the created keys locally. + * + * This does not yet store the operation in a way that it can be restored, + * but that is the idea in the future. + */ + public async persist(crypto: Crypto): Promise<void> { + // store private keys in cache + if (this.crossSigningKeys) { + const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice); + for (const type of ["master", "self_signing", "user_signing"]) { + logger.log(`Cache ${type} cross-signing private key locally`); + const privateKey = this.crossSigningCallbacks.privateKeys.get(type); + await cacheCallbacks.storeCrossSigningKeyCache?.(type, privateKey); + } + // store own cross-sign pubkeys as trusted + await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + crypto.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningKeys!.keys); + }); + } + // store session backup key in cache + if (this.sessionBackupPrivateKey) { + await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey); + } + } +} + +/** + * Can be created from EncryptionSetupBuilder, or + * (in a follow-up PR, not implemented yet) restored from storage, to retry. + * + * It does not have knowledge of any private keys, unlike the builder. + */ +export class EncryptionSetupOperation { + /** + */ + public constructor( + private readonly accountData: Map<string, object>, + private readonly crossSigningKeys?: ICrossSigningKeys, + private readonly keyBackupInfo?: IKeyBackupInfo, + private readonly keySignatures?: KeySignatures, + ) {} + + /** + * Runs the (remaining part of, in the future) operation by sending requests to the server. + */ + public async apply(crypto: Crypto): Promise<void> { + const baseApis = crypto.baseApis; + // upload cross-signing keys + if (this.crossSigningKeys) { + const keys: Partial<CrossSigningKeys> = {}; + for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { + keys[((name as keyof ICrossSigningKeys["keys"]) + "_key") as keyof CrossSigningKeys] = key; + } + + // We must only call `uploadDeviceSigningKeys` from inside this auth + // helper to ensure we properly handle auth errors. + await this.crossSigningKeys.authUpload?.((authDict) => { + return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys); + }); + + // pass the new keys to the main instance of our own CrossSigningInfo. + crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys); + } + // set account data + if (this.accountData) { + for (const [type, content] of this.accountData) { + await baseApis.setAccountData(type, content); + } + } + // upload first cross-signing signatures with the new key + // (e.g. signing our own device) + if (this.keySignatures) { + await baseApis.uploadKeySignatures(this.keySignatures); + } + // need to create/update key backup info + if (this.keyBackupInfo) { + if (this.keyBackupInfo.version) { + // session backup signature + // The backup is trusted because the user provided the private key. + // Sign the backup with the cross signing key so the key backup can + // be trusted via cross-signing. + await baseApis.http.authedRequest( + Method.Put, + "/room_keys/version/" + this.keyBackupInfo.version, + undefined, + { + algorithm: this.keyBackupInfo.algorithm, + auth_data: this.keyBackupInfo.auth_data, + }, + { prefix: ClientPrefix.V3 }, + ); + } else { + // add new key backup + await baseApis.http.authedRequest(Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, { + prefix: ClientPrefix.V3, + }); + } + } + } +} + +/** + * Catches account data set by SecretStorage during bootstrapping by + * implementing the methods related to account data in MatrixClient + */ +class AccountDataClientAdapter + extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> + implements IAccountDataClient +{ + // + public readonly values = new Map<string, MatrixEvent>(); + + /** + * @param existingValues - existing account data + */ + public constructor(private readonly existingValues: Map<string, MatrixEvent>) { + super(); + } + + /** + * @returns the content of the account data + */ + public getAccountDataFromServer<T extends { [k: string]: any }>(type: string): Promise<T> { + return Promise.resolve(this.getAccountData(type) as T); + } + + /** + * @returns the content of the account data + */ + public getAccountData(type: string): IContent | null { + const modifiedValue = this.values.get(type); + if (modifiedValue) { + return modifiedValue; + } + const existingValue = this.existingValues.get(type); + if (existingValue) { + return existingValue.getContent(); + } + return null; + } + + public setAccountData(type: string, content: any): Promise<{}> { + const lastEvent = this.values.get(type); + this.values.set(type, content); + // ensure accountData is emitted on the next tick, + // as SecretStorage listens for it while calling this method + // and it seems to rely on this. + return Promise.resolve().then(() => { + const event = new MatrixEvent({ type, content }); + this.emit(ClientEvent.AccountData, event, lastEvent); + return {}; + }); + } +} + +/** + * Catches the private cross-signing keys set during bootstrapping + * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks. + * See CrossSigningInfo constructor + */ +class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks { + public readonly privateKeys = new Map<string, Uint8Array>(); + + // cache callbacks + public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise<Uint8Array | null> { + return this.getCrossSigningKey(type, expectedPublicKey); + } + + public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise<void> { + this.privateKeys.set(type, key); + return Promise.resolve(); + } + + // non-cache callbacks + public getCrossSigningKey(type: string, expectedPubkey: string): Promise<Uint8Array | null> { + return Promise.resolve(this.privateKeys.get(type) ?? null); + } + + public saveCrossSigningKeys(privateKeys: Record<string, Uint8Array>): void { + for (const [type, privateKey] of Object.entries(privateKeys)) { + this.privateKeys.set(type, privateKey); + } + } +} + +/** + * Catches the 4S private key set during bootstrapping by implementing + * the SecretStorage crypto callbacks + */ +class SSSSCryptoCallbacks { + private readonly privateKeys = new Map<string, Uint8Array>(); + + public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {} + + public async getSecretStorageKey( + { keys }: { keys: Record<string, SecretStorageKeyDescription> }, + name: string, + ): Promise<[string, Uint8Array] | null> { + for (const keyId of Object.keys(keys)) { + const privateKey = this.privateKeys.get(keyId); + if (privateKey) { + return [keyId, privateKey]; + } + } + // if we don't have the key cached yet, ask + // for it to the general crypto callbacks and cache it + if (this?.delegateCryptoCallbacks?.getSecretStorageKey) { + const result = await this.delegateCryptoCallbacks.getSecretStorageKey({ keys }, name); + if (result) { + const [keyId, privateKey] = result; + this.privateKeys.set(keyId, privateKey); + } + return result; + } + return null; + } + + public addPrivateKey(keyId: string, keyInfo: SecretStorageKeyDescription, privKey: Uint8Array): void { + this.privateKeys.set(keyId, privKey); + // Also pass along to application to cache if it wishes + this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts new file mode 100644 index 0000000..82a0a9a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts @@ -0,0 +1,1496 @@ +/* +Copyright 2016 - 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 { Account, InboundGroupSession, OutboundGroupSession, Session, Utility } from "@matrix-org/olm"; + +import { logger, PrefixedLogger } from "../logger"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +import * as algorithms from "./algorithms"; +import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base"; +import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm"; +import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto"; +import { IMessage } from "./algorithms/olm"; + +// The maximum size of an event is 65K, and we base64 the content, so this is a +// reasonable approximation to the biggest plaintext we can encrypt. +const MAX_PLAINTEXT_LENGTH = (65536 * 3) / 4; + +export class PayloadTooLargeError extends Error { + public readonly data = { + errcode: "M_TOO_LARGE", + error: "Payload too large for encrypted message", + }; +} + +function checkPayloadLength(payloadString: string): void { + if (payloadString === undefined) { + throw new Error("payloadString undefined"); + } + + if (payloadString.length > MAX_PLAINTEXT_LENGTH) { + // might as well fail early here rather than letting the olm library throw + // a cryptic memory allocation error. + // + // Note that even if we manage to do the encryption, the message send may fail, + // because by the time we've wrapped the ciphertext in the event object, it may + // exceed 65K. But at least we won't just fail with "abort()" in that case. + throw new PayloadTooLargeError( + `Message too long (${payloadString.length} bytes). ` + + `The maximum for an encrypted message is ${MAX_PLAINTEXT_LENGTH} bytes.`, + ); + } +} + +interface IInitOpts { + fromExportedDevice?: IExportedDevice; + pickleKey?: string; +} + +/** data stored in the session store about an inbound group session */ +export interface InboundGroupSessionData { + room_id: string; // eslint-disable-line camelcase + /** pickled Olm.InboundGroupSession */ + session: string; + keysClaimed: Record<string, string>; + /** Devices involved in forwarding this session to us (normally empty). */ + forwardingCurve25519KeyChain: string[]; + /** whether this session is untrusted. */ + untrusted?: boolean; + /** whether this session exists during the room being set to shared history. */ + sharedHistory?: boolean; +} + +export interface IDecryptedGroupMessage { + result: string; + keysClaimed: Record<string, string>; + senderKey: string; + forwardingCurve25519KeyChain: string[]; + untrusted: boolean; +} + +export interface IInboundSession { + payload: string; + session_id: string; +} + +export interface IExportedDevice { + pickleKey: string; + pickledAccount: string; + sessions: ISessionInfo[]; +} + +interface IUnpickledSessionInfo extends Omit<ISessionInfo, "session"> { + session: Session; +} + +/* eslint-disable camelcase */ +interface IInboundGroupSessionKey { + chain_index: number; + key: string; + forwarding_curve25519_key_chain: string[]; + sender_claimed_ed25519_key: string | null; + shared_history: boolean; + untrusted?: boolean; +} +/* eslint-enable camelcase */ + +type OneTimeKeys = { curve25519: { [keyId: string]: string } }; + +/** + * Manages the olm cryptography functions. Each OlmDevice has a single + * OlmAccount and a number of OlmSessions. + * + * Accounts and sessions are kept pickled in the cryptoStore. + */ +export class OlmDevice { + public pickleKey = "DEFAULT_KEY"; // set by consumers + + /** Curve25519 key for the account, unknown until we load the account from storage in init() */ + public deviceCurve25519Key: string | null = null; + /** Ed25519 key for the account, unknown until we load the account from storage in init() */ + public deviceEd25519Key: string | null = null; + private maxOneTimeKeys: number | null = null; + + // we don't bother stashing outboundgroupsessions in the cryptoStore - + // instead we keep them here. + private outboundGroupSessionStore: Record<string, string> = {}; + + // Store a set of decrypted message indexes for each group session. + // This partially mitigates a replay attack where a MITM resends a group + // message into the room. + // + // When we decrypt a message and the message index matches a previously + // decrypted message, one possible cause of that is that we are decrypting + // the same event, and may not indicate an actual replay attack. For + // example, this could happen if we receive events, forget about them, and + // then re-fetch them when we backfill. So we store the event ID and + // timestamp corresponding to each message index when we first decrypt it, + // and compare these against the event ID and timestamp every time we use + // that same index. If they match, then we're probably decrypting the same + // event and we don't consider it a replay attack. + // + // Keys are strings of form "<senderKey>|<session_id>|<message_index>" + // Values are objects of the form "{id: <event id>, timestamp: <ts>}" + private inboundGroupSessionMessageIndexes: Record<string, { id: string; timestamp: number }> = {}; + + // Keep track of sessions that we're starting, so that we don't start + // multiple sessions for the same device at the same time. + public sessionsInProgress: Record<string, Promise<void>> = {}; // set by consumers + + // Used by olm to serialise prekey message decryptions + public olmPrekeyPromise: Promise<any> = Promise.resolve(); // set by consumers + + public constructor(private readonly cryptoStore: CryptoStore) {} + + /** + * @returns The version of Olm. + */ + public static getOlmVersion(): [number, number, number] { + return global.Olm.get_library_version(); + } + + /** + * Initialise the OlmAccount. This must be called before any other operations + * on the OlmDevice. + * + * Data from an exported Olm device can be provided + * in order to re-create this device. + * + * Attempts to load the OlmAccount from the crypto store, or creates one if none is + * found. + * + * Reads the device keys from the OlmAccount object. + * + * @param fromExportedDevice - (Optional) data from exported device + * that must be re-created. + * If present, opts.pickleKey is ignored + * (exported data already provides a pickle key) + * @param pickleKey - (Optional) pickle key to set instead of default one + */ + public async init({ pickleKey, fromExportedDevice }: IInitOpts = {}): Promise<void> { + let e2eKeys; + const account = new global.Olm.Account(); + + try { + if (fromExportedDevice) { + if (pickleKey) { + logger.warn("ignoring opts.pickleKey" + " because opts.fromExportedDevice is present."); + } + this.pickleKey = fromExportedDevice.pickleKey; + await this.initialiseFromExportedDevice(fromExportedDevice, account); + } else { + if (pickleKey) { + this.pickleKey = pickleKey; + } + await this.initialiseAccount(account); + } + e2eKeys = JSON.parse(account.identity_keys()); + + this.maxOneTimeKeys = account.max_number_of_one_time_keys(); + } finally { + account.free(); + } + + this.deviceCurve25519Key = e2eKeys.curve25519; + this.deviceEd25519Key = e2eKeys.ed25519; + } + + /** + * Populates the crypto store using data that was exported from an existing device. + * Note that for now only the “account” and “sessions” stores are populated; + * Other stores will be as with a new device. + * + * @param exportedData - Data exported from another device + * through the “export” method. + * @param account - an olm account to initialize + */ + private async initialiseFromExportedDevice(exportedData: IExportedDevice, account: Account): Promise<void> { + await this.cryptoStore.doTxn( + "readwrite", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.cryptoStore.storeAccount(txn, exportedData.pickledAccount); + exportedData.sessions.forEach((session) => { + const { deviceKey, sessionId } = session; + const sessionInfo = { + session: session.session, + lastReceivedMessageTs: session.lastReceivedMessageTs, + }; + this.cryptoStore.storeEndToEndSession(deviceKey!, sessionId!, sessionInfo, txn); + }); + }, + ); + account.unpickle(this.pickleKey, exportedData.pickledAccount); + } + + private async initialiseAccount(account: Account): Promise<void> { + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.getAccount(txn, (pickledAccount) => { + if (pickledAccount !== null) { + account.unpickle(this.pickleKey, pickledAccount); + } else { + account.create(); + pickledAccount = account.pickle(this.pickleKey); + this.cryptoStore.storeAccount(txn, pickledAccount); + } + }); + }); + } + + /** + * extract our OlmAccount from the crypto store and call the given function + * with the account object + * The `account` object is usable only within the callback passed to this + * function and will be freed as soon the callback returns. It is *not* + * usable for the rest of the lifetime of the transaction. + * This function requires a live transaction object from cryptoStore.doTxn() + * and therefore may only be called in a doTxn() callback. + * + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal + */ + private getAccount(txn: unknown, func: (account: Account) => void): void { + this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { + const account = new global.Olm.Account(); + try { + account.unpickle(this.pickleKey, pickledAccount!); + func(account); + } finally { + account.free(); + } + }); + } + + /* + * Saves an account to the crypto store. + * This function requires a live transaction object from cryptoStore.doTxn() + * and therefore may only be called in a doTxn() callback. + * + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @param Olm.Account object + * @internal + */ + private storeAccount(txn: unknown, account: Account): void { + this.cryptoStore.storeAccount(txn, account.pickle(this.pickleKey)); + } + + /** + * Export data for re-creating the Olm device later. + * TODO export data other than just account and (P2P) sessions. + * + * @returns The exported data + */ + public async export(): Promise<IExportedDevice> { + const result: Partial<IExportedDevice> = { + pickleKey: this.pickleKey, + }; + + await this.cryptoStore.doTxn( + "readonly", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { + result.pickledAccount = pickledAccount!; + }); + result.sessions = []; + // Note that the pickledSession object we get in the callback + // is not exactly the same thing you get in method _getSession + // see documentation of IndexedDBCryptoStore.getAllEndToEndSessions + this.cryptoStore.getAllEndToEndSessions(txn, (pickledSession) => { + result.sessions!.push(pickledSession!); + }); + }, + ); + return result as IExportedDevice; + } + + /** + * extract an OlmSession from the session store and call the given function + * The session is usable only within the callback passed to this + * function and will be freed as soon the callback returns. It is *not* + * usable for the rest of the lifetime of the transaction. + * + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal + */ + private getSession( + deviceKey: string, + sessionId: string, + txn: unknown, + func: (unpickledSessionInfo: IUnpickledSessionInfo) => void, + ): void { + this.cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, (sessionInfo: ISessionInfo | null) => { + this.unpickleSession(sessionInfo!, func); + }); + } + + /** + * Creates a session object from a session pickle and executes the given + * function with it. The session object is destroyed once the function + * returns. + * + * @internal + */ + private unpickleSession( + sessionInfo: ISessionInfo, + func: (unpickledSessionInfo: IUnpickledSessionInfo) => void, + ): void { + const session = new global.Olm.Session(); + try { + session.unpickle(this.pickleKey, sessionInfo.session!); + const unpickledSessInfo: IUnpickledSessionInfo = Object.assign({}, sessionInfo, { session }); + + func(unpickledSessInfo); + } finally { + session.free(); + } + } + + /** + * store our OlmSession in the session store + * + * @param sessionInfo - `{session: OlmSession, lastReceivedMessageTs: int}` + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal + */ + private saveSession(deviceKey: string, sessionInfo: IUnpickledSessionInfo, txn: unknown): void { + const sessionId = sessionInfo.session.session_id(); + logger.debug(`Saving Olm session ${sessionId} with device ${deviceKey}: ${sessionInfo.session.describe()}`); + + // Why do we re-use the input object for this, overwriting the same key with a different + // type? Is it because we want to erase the unpickled session to enforce that it's no longer + // used? A comment would be great. + const pickledSessionInfo = Object.assign(sessionInfo, { + session: sessionInfo.session.pickle(this.pickleKey), + }); + this.cryptoStore.storeEndToEndSession(deviceKey, sessionId, pickledSessionInfo, txn); + } + + /** + * get an OlmUtility and call the given function + * + * @returns result of func + * @internal + */ + private getUtility<T>(func: (utility: Utility) => T): T { + const utility = new global.Olm.Utility(); + try { + return func(utility); + } finally { + utility.free(); + } + } + + /** + * Signs a message with the ed25519 key for this account. + * + * @param message - message to be signed + * @returns base64-encoded signature + */ + public async sign(message: string): Promise<string> { + let result: string; + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + result = account.sign(message); + }); + }); + return result!; + } + + /** + * Get the current (unused, unpublished) one-time keys for this account. + * + * @returns one time keys; an object with the single property + * <tt>curve25519</tt>, which is itself an object mapping key id to Curve25519 + * key. + */ + public async getOneTimeKeys(): Promise<OneTimeKeys> { + let result: OneTimeKeys; + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account) => { + result = JSON.parse(account.one_time_keys()); + }); + }); + + return result!; + } + + /** + * Get the maximum number of one-time keys we can store. + * + * @returns number of keys + */ + public maxNumberOfOneTimeKeys(): number { + return this.maxOneTimeKeys ?? -1; + } + + /** + * Marks all of the one-time keys as published. + */ + public async markKeysAsPublished(): Promise<void> { + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + account.mark_keys_as_published(); + this.storeAccount(txn, account); + }); + }); + } + + /** + * Generate some new one-time keys + * + * @param numKeys - number of keys to generate + * @returns Resolved once the account is saved back having generated the keys + */ + public generateOneTimeKeys(numKeys: number): Promise<void> { + return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account) => { + account.generate_one_time_keys(numKeys); + this.storeAccount(txn, account); + }); + }); + } + + /** + * Generate a new fallback keys + * + * @returns Resolved once the account is saved back having generated the key + */ + public async generateFallbackKey(): Promise<void> { + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account) => { + account.generate_fallback_key(); + this.storeAccount(txn, account); + }); + }); + } + + public async getFallbackKey(): Promise<Record<string, Record<string, string>>> { + let result: Record<string, Record<string, string>>; + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + result = JSON.parse(account.unpublished_fallback_key()); + }); + }); + return result!; + } + + public async forgetOldFallbackKey(): Promise<void> { + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + account.forget_old_fallback_key(); + this.storeAccount(txn, account); + }); + }); + } + + /** + * Generate a new outbound session + * + * The new session will be stored in the cryptoStore. + * + * @param theirIdentityKey - remote user's Curve25519 identity key + * @param theirOneTimeKey - remote user's one-time Curve25519 key + * @returns sessionId for the outbound session. + */ + public async createOutboundSession(theirIdentityKey: string, theirOneTimeKey: string): Promise<string> { + let newSessionId: string; + await this.cryptoStore.doTxn( + "readwrite", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.getAccount(txn, (account: Account) => { + const session = new global.Olm.Session(); + try { + session.create_outbound(account, theirIdentityKey, theirOneTimeKey); + newSessionId = session.session_id(); + this.storeAccount(txn, account); + const sessionInfo: IUnpickledSessionInfo = { + session, + // Pretend we've received a message at this point, otherwise + // if we try to send a message to the device, it won't use + // this session + lastReceivedMessageTs: Date.now(), + }; + this.saveSession(theirIdentityKey, sessionInfo, txn); + } finally { + session.free(); + } + }); + }, + logger.withPrefix("[createOutboundSession]"), + ); + return newSessionId!; + } + + /** + * Generate a new inbound session, given an incoming message + * + * @param theirDeviceIdentityKey - remote user's Curve25519 identity key + * @param messageType - messageType field from the received message (must be 0) + * @param ciphertext - base64-encoded body from the received message + * + * @returns decrypted payload, and + * session id of new session + * + * @throws Error if the received message was not valid (for instance, it didn't use a valid one-time key). + */ + public async createInboundSession( + theirDeviceIdentityKey: string, + messageType: number, + ciphertext: string, + ): Promise<IInboundSession> { + if (messageType !== 0) { + throw new Error("Need messageType == 0 to create inbound session"); + } + + let result: { payload: string; session_id: string }; // eslint-disable-line camelcase + await this.cryptoStore.doTxn( + "readwrite", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.getAccount(txn, (account: Account) => { + const session = new global.Olm.Session(); + try { + session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext); + account.remove_one_time_keys(session); + this.storeAccount(txn, account); + + const payloadString = session.decrypt(messageType, ciphertext); + + const sessionInfo: IUnpickledSessionInfo = { + session, + // this counts as a received message: set last received message time + // to now + lastReceivedMessageTs: Date.now(), + }; + this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); + + result = { + payload: payloadString, + session_id: session.session_id(), + }; + } finally { + session.free(); + } + }); + }, + logger.withPrefix("[createInboundSession]"), + ); + + return result!; + } + + /** + * Get a list of known session IDs for the given device + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @returns a list of known session ids for the device + */ + public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise<string[]> { + const log = logger.withPrefix("[getSessionIdsForDevice]"); + + if (theirDeviceIdentityKey in this.sessionsInProgress) { + log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`); + try { + await this.sessionsInProgress[theirDeviceIdentityKey]; + } catch (e) { + // if the session failed to be created, just fall through and + // return an empty result + } + } + let sessionIds: string[]; + await this.cryptoStore.doTxn( + "readonly", + [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.cryptoStore.getEndToEndSessions(theirDeviceIdentityKey, txn, (sessions) => { + sessionIds = Object.keys(sessions); + }); + }, + log, + ); + + return sessionIds!; + } + + /** + * Get the right olm session id for encrypting messages to the given identity key + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param nowait - Don't wait for an in-progress session to complete. + * This should only be set to true of the calling function is the function + * that marked the session as being in-progress. + * @param log - A possibly customised log + * @returns session id, or null if no established session + */ + public async getSessionIdForDevice( + theirDeviceIdentityKey: string, + nowait = false, + log?: PrefixedLogger, + ): Promise<string | null> { + const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log); + + if (sessionInfos.length === 0) { + return null; + } + // Use the session that has most recently received a message + let idxOfBest = 0; + for (let i = 1; i < sessionInfos.length; i++) { + const thisSessInfo = sessionInfos[i]; + const thisLastReceived = + thisSessInfo.lastReceivedMessageTs === undefined ? 0 : thisSessInfo.lastReceivedMessageTs; + + const bestSessInfo = sessionInfos[idxOfBest]; + const bestLastReceived = + bestSessInfo.lastReceivedMessageTs === undefined ? 0 : bestSessInfo.lastReceivedMessageTs; + if ( + thisLastReceived > bestLastReceived || + (thisLastReceived === bestLastReceived && thisSessInfo.sessionId < bestSessInfo.sessionId) + ) { + idxOfBest = i; + } + } + return sessionInfos[idxOfBest].sessionId; + } + + /** + * Get information on the active Olm sessions for a device. + * <p> + * Returns an array, with an entry for each active session. The first entry in + * the result will be the one used for outgoing messages. Each entry contains + * the keys 'hasReceivedMessage' (true if the session has received an incoming + * message and is therefore past the pre-key stage), and 'sessionId'. + * + * @param deviceIdentityKey - Curve25519 identity key for the device + * @param nowait - Don't wait for an in-progress session to complete. + * This should only be set to true of the calling function is the function + * that marked the session as being in-progress. + * @param log - A possibly customised log + */ + public async getSessionInfoForDevice( + deviceIdentityKey: string, + nowait = false, + log = logger, + ): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> { + log = log.withPrefix("[getSessionInfoForDevice]"); + + if (deviceIdentityKey in this.sessionsInProgress && !nowait) { + log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`); + try { + await this.sessionsInProgress[deviceIdentityKey]; + } catch (e) { + // if the session failed to be created, then just fall through and + // return an empty result + } + } + const info: { + lastReceivedMessageTs: number; + hasReceivedMessage: boolean; + sessionId: string; + }[] = []; + + await this.cryptoStore.doTxn( + "readonly", + [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { + const sessionIds = Object.keys(sessions).sort(); + for (const sessionId of sessionIds) { + this.unpickleSession(sessions[sessionId], (sessInfo: IUnpickledSessionInfo) => { + info.push({ + lastReceivedMessageTs: sessInfo.lastReceivedMessageTs!, + hasReceivedMessage: sessInfo.session.has_received_message(), + sessionId, + }); + }); + } + }); + }, + log, + ); + + return info; + } + + /** + * Encrypt an outgoing message using an existing session + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param sessionId - the id of the active session + * @param payloadString - payload to be encrypted and sent + * + * @returns ciphertext + */ + public async encryptMessage( + theirDeviceIdentityKey: string, + sessionId: string, + payloadString: string, + ): Promise<IMessage> { + checkPayloadLength(payloadString); + + let res: IMessage; + await this.cryptoStore.doTxn( + "readwrite", + [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + const sessionDesc = sessionInfo.session.describe(); + logger.log( + "encryptMessage: Olm Session ID " + + sessionId + + " to " + + theirDeviceIdentityKey + + ": " + + sessionDesc, + ); + res = sessionInfo.session.encrypt(payloadString); + this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); + }); + }, + logger.withPrefix("[encryptMessage]"), + ); + return res!; + } + + /** + * Decrypt an incoming message using an existing session + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param sessionId - the id of the active session + * @param messageType - messageType field from the received message + * @param ciphertext - base64-encoded body from the received message + * + * @returns decrypted payload. + */ + public async decryptMessage( + theirDeviceIdentityKey: string, + sessionId: string, + messageType: number, + ciphertext: string, + ): Promise<string> { + let payloadString: string; + await this.cryptoStore.doTxn( + "readwrite", + [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo: IUnpickledSessionInfo) => { + const sessionDesc = sessionInfo.session.describe(); + logger.log( + "decryptMessage: Olm Session ID " + + sessionId + + " from " + + theirDeviceIdentityKey + + ": " + + sessionDesc, + ); + payloadString = sessionInfo.session.decrypt(messageType, ciphertext); + sessionInfo.lastReceivedMessageTs = Date.now(); + this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); + }); + }, + logger.withPrefix("[decryptMessage]"), + ); + return payloadString!; + } + + /** + * Determine if an incoming messages is a prekey message matching an existing session + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param sessionId - the id of the active session + * @param messageType - messageType field from the received message + * @param ciphertext - base64-encoded body from the received message + * + * @returns true if the received message is a prekey message which matches + * the given session. + */ + public async matchesSession( + theirDeviceIdentityKey: string, + sessionId: string, + messageType: number, + ciphertext: string, + ): Promise<boolean> { + if (messageType !== 0) { + return false; + } + + let matches: boolean; + await this.cryptoStore.doTxn( + "readonly", + [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + matches = sessionInfo.session.matches_inbound(ciphertext); + }); + }, + logger.withPrefix("[matchesSession]"), + ); + return matches!; + } + + public async recordSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { + logger.info(`Recording problem on olm session with ${deviceKey} of type ${type}. Recreating: ${fixed}`); + await this.cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed); + } + + public sessionMayHaveProblems(deviceKey: string, timestamp: number): Promise<IProblem | null> { + return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp); + } + + public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { + return this.cryptoStore.filterOutNotifiedErrorDevices(devices); + } + + // Outbound group session + // ====================== + + /** + * store an OutboundGroupSession in outboundGroupSessionStore + * + * @internal + */ + private saveOutboundGroupSession(session: OutboundGroupSession): void { + this.outboundGroupSessionStore[session.session_id()] = session.pickle(this.pickleKey); + } + + /** + * extract an OutboundGroupSession from outboundGroupSessionStore and call the + * given function + * + * @returns result of func + * @internal + */ + private getOutboundGroupSession<T>(sessionId: string, func: (session: OutboundGroupSession) => T): T { + const pickled = this.outboundGroupSessionStore[sessionId]; + if (pickled === undefined) { + throw new Error("Unknown outbound group session " + sessionId); + } + + const session = new global.Olm.OutboundGroupSession(); + try { + session.unpickle(this.pickleKey, pickled); + return func(session); + } finally { + session.free(); + } + } + + /** + * Generate a new outbound group session + * + * @returns sessionId for the outbound session. + */ + public createOutboundGroupSession(): string { + const session = new global.Olm.OutboundGroupSession(); + try { + session.create(); + this.saveOutboundGroupSession(session); + return session.session_id(); + } finally { + session.free(); + } + } + + /** + * Encrypt an outgoing message with an outbound group session + * + * @param sessionId - the id of the outboundgroupsession + * @param payloadString - payload to be encrypted and sent + * + * @returns ciphertext + */ + public encryptGroupMessage(sessionId: string, payloadString: string): string { + logger.log(`encrypting msg with megolm session ${sessionId}`); + + checkPayloadLength(payloadString); + + return this.getOutboundGroupSession(sessionId, (session: OutboundGroupSession) => { + const res = session.encrypt(payloadString); + this.saveOutboundGroupSession(session); + return res; + }); + } + + /** + * Get the session keys for an outbound group session + * + * @param sessionId - the id of the outbound group session + * + * @returns current chain index, and + * base64-encoded secret key. + */ + public getOutboundGroupSessionKey(sessionId: string): IOutboundGroupSessionKey { + return this.getOutboundGroupSession(sessionId, function (session: OutboundGroupSession) { + return { + chain_index: session.message_index(), + key: session.session_key(), + }; + }); + } + + // Inbound group session + // ===================== + + /** + * Unpickle a session from a sessionData object and invoke the given function. + * The session is valid only until func returns. + * + * @param sessionData - Object describing the session. + * @param func - Invoked with the unpickled session + * @returns result of func + */ + private unpickleInboundGroupSession<T>( + sessionData: InboundGroupSessionData, + func: (session: InboundGroupSession) => T, + ): T { + const session = new global.Olm.InboundGroupSession(); + try { + session.unpickle(this.pickleKey, sessionData.session); + return func(session); + } finally { + session.free(); + } + } + + /** + * extract an InboundGroupSession from the crypto store and call the given function + * + * @param roomId - The room ID to extract the session for, or null to fetch + * sessions for any room. + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @param func - function to call. + * + * @internal + */ + private getInboundGroupSession( + roomId: string, + senderKey: string, + sessionId: string, + txn: unknown, + func: ( + session: InboundGroupSession | null, + data: InboundGroupSessionData | null, + withheld: IWithheld | null, + ) => void, + ): void { + this.cryptoStore.getEndToEndInboundGroupSession( + senderKey, + sessionId, + txn, + (sessionData: InboundGroupSessionData | null, withheld: IWithheld | null) => { + if (sessionData === null) { + func(null, null, withheld); + return; + } + + // if we were given a room ID, check that the it matches the original one for the session. This stops + // the HS pretending a message was targeting a different room. + if (roomId !== null && roomId !== sessionData.room_id) { + throw new Error( + "Mismatched room_id for inbound group session (expected " + + sessionData.room_id + + ", was " + + roomId + + ")", + ); + } + + this.unpickleInboundGroupSession(sessionData, (session: InboundGroupSession) => { + func(session, sessionData, withheld); + }); + }, + ); + } + + /** + * Add an inbound group session to the session store + * + * @param roomId - room in which this session will be used + * @param senderKey - base64-encoded curve25519 key of the sender + * @param forwardingCurve25519KeyChain - Devices involved in forwarding + * this session to us. + * @param sessionId - session identifier + * @param sessionKey - base64-encoded secret key + * @param keysClaimed - Other keys the sender claims. + * @param exportFormat - true if the megolm keys are in export format + * (ie, they lack an ed25519 signature) + * @param extraSessionData - any other data to be include with the session + */ + public async addInboundGroupSession( + roomId: string, + senderKey: string, + forwardingCurve25519KeyChain: string[], + sessionId: string, + sessionKey: string, + keysClaimed: Record<string, string>, + exportFormat: boolean, + extraSessionData: OlmGroupSessionExtraData = {}, + ): Promise<void> { + await this.cryptoStore.doTxn( + "readwrite", + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, + ], + (txn) => { + /* if we already have this session, consider updating it */ + this.getInboundGroupSession( + roomId, + senderKey, + sessionId, + txn, + ( + existingSession: InboundGroupSession | null, + existingSessionData: InboundGroupSessionData | null, + ) => { + // new session. + const session = new global.Olm.InboundGroupSession(); + try { + if (exportFormat) { + session.import_session(sessionKey); + } else { + session.create(sessionKey); + } + if (sessionId != session.session_id()) { + throw new Error("Mismatched group session ID from senderKey: " + senderKey); + } + + if (existingSession) { + logger.log(`Update for megolm session ${senderKey}|${sessionId}`); + if (existingSession.first_known_index() <= session.first_known_index()) { + if (!existingSessionData!.untrusted || extraSessionData.untrusted) { + // existing session has less-than-or-equal index + // (i.e. can decrypt at least as much), and the + // new session's trust does not win over the old + // session's trust, so keep it + logger.log(`Keeping existing megolm session ${senderKey}|${sessionId}`); + return; + } + if (existingSession.first_known_index() < session.first_known_index()) { + // We want to upgrade the existing session's trust, + // but we can't just use the new session because we'll + // lose the lower index. Check that the sessions connect + // properly, and then manually set the existing session + // as trusted. + if ( + existingSession.export_session(session.first_known_index()) === + session.export_session(session.first_known_index()) + ) { + logger.info( + "Upgrading trust of existing megolm session " + + `${senderKey}|${sessionId} based on newly-received trusted session`, + ); + existingSessionData!.untrusted = false; + this.cryptoStore.storeEndToEndInboundGroupSession( + senderKey, + sessionId, + existingSessionData!, + txn, + ); + } else { + logger.warn( + `Newly-received megolm session ${senderKey}|$sessionId}` + + " does not match existing session! Keeping existing session", + ); + } + return; + } + // If the sessions have the same index, go ahead and store the new trusted one. + } + } + + logger.info( + `Storing megolm session ${senderKey}|${sessionId} with first index ` + + session.first_known_index(), + ); + + const sessionData = Object.assign({}, extraSessionData, { + room_id: roomId, + session: session.pickle(this.pickleKey), + keysClaimed: keysClaimed, + forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, + }); + + this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); + + if (!existingSession && extraSessionData.sharedHistory) { + this.cryptoStore.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); + } + } finally { + session.free(); + } + }, + ); + }, + logger.withPrefix("[addInboundGroupSession]"), + ); + } + + /** + * Record in the data store why an inbound group session was withheld. + * + * @param roomId - room that the session belongs to + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param code - reason code + * @param reason - human-readable version of `code` + */ + public async addInboundGroupSessionWithheld( + roomId: string, + senderKey: string, + sessionId: string, + code: string, + reason: string, + ): Promise<void> { + await this.cryptoStore.doTxn( + "readwrite", + [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], + (txn) => { + this.cryptoStore.storeEndToEndInboundGroupSessionWithheld( + senderKey, + sessionId, + { + room_id: roomId, + code: code, + reason: reason, + }, + txn, + ); + }, + ); + } + + /** + * Decrypt a received message with an inbound group session + * + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param body - base64-encoded body of the encrypted message + * @param eventId - ID of the event being decrypted + * @param timestamp - timestamp of the event being decrypted + * + * @returns null if the sessionId is unknown + */ + public async decryptGroupMessage( + roomId: string, + senderKey: string, + sessionId: string, + body: string, + eventId: string, + timestamp: number, + ): Promise<IDecryptedGroupMessage | null> { + let result: IDecryptedGroupMessage | null = null; + // when the localstorage crypto store is used as an indexeddb backend, + // exceptions thrown from within the inner function are not passed through + // to the top level, so we store exceptions in a variable and raise them at + // the end + let error: Error; + + await this.cryptoStore.doTxn( + "readwrite", + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], + (txn) => { + this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { + if (session === null || sessionData === null) { + if (withheld) { + error = new algorithms.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + calculateWithheldMessage(withheld), + { + session: senderKey + "|" + sessionId, + }, + ); + } + result = null; + return; + } + let res: ReturnType<InboundGroupSession["decrypt"]>; + try { + res = session.decrypt(body); + } catch (e) { + if ((<Error>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) { + error = new algorithms.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + calculateWithheldMessage(withheld), + { + session: senderKey + "|" + sessionId, + }, + ); + } else { + error = <Error>e; + } + return; + } + + let plaintext: string = res.plaintext; + if (plaintext === undefined) { + // @ts-ignore - Compatibility for older olm versions. + plaintext = res as string; + } else { + // Check if we have seen this message index before to detect replay attacks. + // If the event ID and timestamp are specified, and the match the event ID + // and timestamp from the last time we used this message index, then we + // don't consider it a replay attack. + const messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index; + if (messageIndexKey in this.inboundGroupSessionMessageIndexes) { + const msgInfo = this.inboundGroupSessionMessageIndexes[messageIndexKey]; + if (msgInfo.id !== eventId || msgInfo.timestamp !== timestamp) { + error = new Error( + "Duplicate message index, possible replay attack: " + messageIndexKey, + ); + return; + } + } + this.inboundGroupSessionMessageIndexes[messageIndexKey] = { + id: eventId, + timestamp: timestamp, + }; + } + + sessionData.session = session.pickle(this.pickleKey); + this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); + result = { + result: plaintext, + keysClaimed: sessionData.keysClaimed || {}, + senderKey: senderKey, + forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || [], + untrusted: !!sessionData.untrusted, + }; + }); + }, + logger.withPrefix("[decryptGroupMessage]"), + ); + + if (error!) { + throw error; + } + return result!; + } + + /** + * Determine if we have the keys for a given megolm session + * + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * + * @returns true if we have the keys to this session + */ + public async hasInboundSessionKeys(roomId: string, senderKey: string, sessionId: string): Promise<boolean> { + let result: boolean; + await this.cryptoStore.doTxn( + "readonly", + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], + (txn) => { + this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, (sessionData) => { + if (sessionData === null) { + result = false; + return; + } + + if (roomId !== sessionData.room_id) { + logger.warn( + `requested keys for inbound group session ${senderKey}|` + + `${sessionId}, with incorrect room_id ` + + `(expected ${sessionData.room_id}, ` + + `was ${roomId})`, + ); + result = false; + } else { + result = true; + } + }); + }, + logger.withPrefix("[hasInboundSessionKeys]"), + ); + + return result!; + } + + /** + * Extract the keys to a given megolm session, for sharing + * + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param chainIndex - The chain index at which to export the session. + * If omitted, export at the first index we know about. + * + * @returns + * details of the session key. The key is a base64-encoded megolm key in + * export format. + * + * @throws Error If the given chain index could not be obtained from the known + * index (ie. the given chain index is before the first we have). + */ + public async getInboundGroupSessionKey( + roomId: string, + senderKey: string, + sessionId: string, + chainIndex?: number, + ): Promise<IInboundGroupSessionKey | null> { + let result: IInboundGroupSessionKey | null = null; + await this.cryptoStore.doTxn( + "readonly", + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], + (txn) => { + this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => { + if (session === null || sessionData === null) { + result = null; + return; + } + + if (chainIndex === undefined) { + chainIndex = session.first_known_index(); + } + + const exportedSession = session.export_session(chainIndex); + + const claimedKeys = sessionData.keysClaimed || {}; + const senderEd25519Key = claimedKeys.ed25519 || null; + + const forwardingKeyChain = sessionData.forwardingCurve25519KeyChain || []; + // older forwarded keys didn't set the "untrusted" + // property, but can be identified by having a + // non-empty forwarding key chain. These keys should + // be marked as untrusted since we don't know that they + // can be trusted + const untrusted = + "untrusted" in sessionData ? sessionData.untrusted : forwardingKeyChain.length > 0; + + result = { + chain_index: chainIndex, + key: exportedSession, + forwarding_curve25519_key_chain: forwardingKeyChain, + sender_claimed_ed25519_key: senderEd25519Key, + shared_history: sessionData.sharedHistory || false, + untrusted: untrusted, + }; + }); + }, + logger.withPrefix("[getInboundGroupSessionKey]"), + ); + + return result; + } + + /** + * Export an inbound group session + * + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param sessionData - The session object from the store + * @returns exported session data + */ + public exportInboundGroupSession( + senderKey: string, + sessionId: string, + sessionData: InboundGroupSessionData, + ): IMegolmSessionData { + return this.unpickleInboundGroupSession(sessionData, (session) => { + const messageIndex = session.first_known_index(); + + return { + "sender_key": senderKey, + "sender_claimed_keys": sessionData.keysClaimed, + "room_id": sessionData.room_id, + "session_id": sessionId, + "session_key": session.export_session(messageIndex), + "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [], + "first_known_index": session.first_known_index(), + "org.matrix.msc3061.shared_history": sessionData.sharedHistory || false, + } as IMegolmSessionData; + }); + } + + public async getSharedHistoryInboundGroupSessions( + roomId: string, + ): Promise<[senderKey: string, sessionId: string][]> { + let result: Promise<[senderKey: string, sessionId: string][]>; + await this.cryptoStore.doTxn( + "readonly", + [IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], + (txn) => { + result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); + }, + logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"), + ); + return result!; + } + + // Utilities + // ========= + + /** + * Verify an ed25519 signature. + * + * @param key - ed25519 key + * @param message - message which was signed + * @param signature - base64-encoded signature to be checked + * + * @throws Error if there is a problem with the verification. If the key was + * too small then the message will be "OLM.INVALID_BASE64". If the signature + * was invalid then the message will be "OLM.BAD_MESSAGE_MAC". + */ + public verifySignature(key: string, message: string, signature: string): void { + this.getUtility(function (util: Utility) { + util.ed25519_verify(key, message, signature); + }); + } +} + +export const WITHHELD_MESSAGES: Record<string, string> = { + "m.unverified": "The sender has disabled encrypting to unverified devices.", + "m.blacklisted": "The sender has blocked you.", + "m.unauthorised": "You are not authorised to read the message.", + "m.no_olm": "Unable to establish a secure channel.", +}; + +/** + * Calculate the message to use for the exception when a session key is withheld. + * + * @param withheld - An object that describes why the key was withheld. + * + * @returns the message + * + * @internal + */ +function calculateWithheldMessage(withheld: IWithheld): string { + if (withheld.code && withheld.code in WITHHELD_MESSAGES) { + return WITHHELD_MESSAGES[withheld.code]; + } else if (withheld.reason) { + return withheld.reason; + } else { + return "decryption key withheld"; + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts new file mode 100644 index 0000000..4628b3e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts @@ -0,0 +1,485 @@ +/* +Copyright 2017 - 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 { v4 as uuidv4 } from "uuid"; + +import { logger } from "../logger"; +import { MatrixClient } from "../client"; +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index"; +import { CryptoStore, OutgoingRoomKeyRequest } from "./store/base"; +import { EventType, ToDeviceMessageId } from "../@types/event"; +import { MapWithDefault } from "../utils"; + +/** + * Internal module. Management of outgoing room key requests. + * + * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ + * for draft documentation on what we're supposed to be implementing here. + */ + +// delay between deciding we want some keys, and sending out the request, to +// allow for (a) it turning up anyway, (b) grouping requests together +const SEND_KEY_REQUESTS_DELAY_MS = 500; + +/** + * possible states for a room key request + * + * The state machine looks like: + * ``` + * + * | (cancellation sent) + * | .-------------------------------------------------. + * | | | + * V V (cancellation requested) | + * UNSENT -----------------------------+ | + * | | | + * | | | + * | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND + * V | Λ + * SENT | | + * |-------------------------------- | --------------' + * | | (cancellation requested with intent + * | | to resend the original request) + * | | + * | (cancellation requested) | + * V | + * CANCELLATION_PENDING | + * | | + * | (cancellation sent) | + * V | + * (deleted) <---------------------------+ + * ``` + */ +export enum RoomKeyRequestState { + /** request not yet sent */ + Unsent, + /** request sent, awaiting reply */ + Sent, + /** reply received, cancellation not yet sent */ + CancellationPending, + /** + * Cancellation not yet sent and will transition to UNSENT instead of + * being deleted once the cancellation has been sent. + */ + CancellationPendingAndWillResend, +} + +interface RequestMessageBase { + requesting_device_id: string; + request_id: string; +} + +interface RequestMessageRequest extends RequestMessageBase { + action: "request"; + body: IRoomKeyRequestBody; +} + +interface RequestMessageCancellation extends RequestMessageBase { + action: "request_cancellation"; +} + +type RequestMessage = RequestMessageRequest | RequestMessageCancellation; + +export class OutgoingRoomKeyRequestManager { + // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null + // if the callback has been set, or if it is still running. + private sendOutgoingRoomKeyRequestsTimer?: ReturnType<typeof setTimeout>; + + // sanity check to ensure that we don't end up with two concurrent runs + // of sendOutgoingRoomKeyRequests + private sendOutgoingRoomKeyRequestsRunning = false; + + private clientRunning = true; + + public constructor( + private readonly baseApis: MatrixClient, + private readonly deviceId: string, + private readonly cryptoStore: CryptoStore, + ) {} + + /** + * Called when the client is stopped. Stops any running background processes. + */ + public stop(): void { + logger.log("stopping OutgoingRoomKeyRequestManager"); + // stop the timer on the next run + this.clientRunning = false; + } + + /** + * Send any requests that have been queued + */ + public sendQueuedRequests(): void { + this.startTimer(); + } + + /** + * Queue up a room key request, if we haven't already queued or sent one. + * + * The `requestBody` is compared (with a deep-equality check) against + * previous queued or sent requests and if it matches, no change is made. + * Otherwise, a request is added to the pending list, and a job is started + * in the background to send it. + * + * @param resend - whether to resend the key request if there is + * already one + * + * @returns resolves when the request has been added to the + * pending list (or we have established that a similar request already + * exists) + */ + public async queueRoomKeyRequest( + requestBody: IRoomKeyRequestBody, + recipients: IRoomKeyRequestRecipient[], + resend = false, + ): Promise<void> { + const req = await this.cryptoStore.getOutgoingRoomKeyRequest(requestBody); + if (!req) { + await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({ + requestBody: requestBody, + recipients: recipients, + requestId: this.baseApis.makeTxnId(), + state: RoomKeyRequestState.Unsent, + }); + } else { + switch (req.state) { + case RoomKeyRequestState.CancellationPendingAndWillResend: + case RoomKeyRequestState.Unsent: + // nothing to do here, since we're going to send a request anyways + return; + + case RoomKeyRequestState.CancellationPending: { + // existing request is about to be cancelled. If we want to + // resend, then change the state so that it resends after + // cancelling. Otherwise, just cancel the cancellation. + const state = resend + ? RoomKeyRequestState.CancellationPendingAndWillResend + : RoomKeyRequestState.Sent; + await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, + RoomKeyRequestState.CancellationPending, + { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + }, + ); + break; + } + case RoomKeyRequestState.Sent: { + // a request has already been sent. If we don't want to + // resend, then do nothing. If we do want to, then cancel the + // existing request and send a new one. + if (resend) { + const state = RoomKeyRequestState.CancellationPendingAndWillResend; + const updatedReq = await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, + RoomKeyRequestState.Sent, + { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + // need to use a new transaction ID so that + // the request gets sent + requestTxnId: this.baseApis.makeTxnId(), + }, + ); + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the request + // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have + // raced with another tab to mark the request cancelled. + // Try again, to make sure the request is resent. + return this.queueRoomKeyRequest(requestBody, recipients, resend); + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + try { + await this.sendOutgoingRoomKeyRequestCancellation(updatedReq, true); + } catch (e) { + logger.error("Error sending room key request cancellation;" + " will retry later.", e); + } + // The request has transitioned from + // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We + // still need to resend the request which is now UNSENT, so + // start the timer if it isn't already started. + } + break; + } + default: + throw new Error("unhandled state: " + req.state); + } + } + } + + /** + * Cancel room key requests, if any match the given requestBody + * + * + * @returns resolves when the request has been updated in our + * pending list. + */ + public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<unknown> { + return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then((req): unknown => { + if (!req) { + // no request was made for this key + return; + } + switch (req.state) { + case RoomKeyRequestState.CancellationPending: + case RoomKeyRequestState.CancellationPendingAndWillResend: + // nothing to do here + return; + + case RoomKeyRequestState.Unsent: + // just delete it + + // FIXME: ghahah we may have attempted to send it, and + // not yet got a successful response. So the server + // may have seen it, so we still need to send a cancellation + // in that case :/ + + logger.log("deleting unnecessary room key request for " + stringifyRequestBody(requestBody)); + return this.cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent); + + case RoomKeyRequestState.Sent: { + // send a cancellation. + return this.cryptoStore + .updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Sent, { + state: RoomKeyRequestState.CancellationPending, + cancellationTxnId: this.baseApis.makeTxnId(), + }) + .then((updatedReq) => { + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the + // request in state ROOM_KEY_REQUEST_STATES.SENT, + // so we must have raced with another tab to mark + // the request cancelled. There is no point in + // sending another cancellation since the other tab + // will do it. + logger.log( + "Tried to cancel room key request for " + + stringifyRequestBody(requestBody) + + " but it was already cancelled in another tab", + ); + return; + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + this.sendOutgoingRoomKeyRequestCancellation(updatedReq).catch((e) => { + logger.error("Error sending room key request cancellation;" + " will retry later.", e); + this.startTimer(); + }); + }); + } + default: + throw new Error("unhandled state: " + req.state); + } + }); + } + + /** + * Look for room key requests by target device and state + * + * @param userId - Target user ID + * @param deviceId - Target device ID + * + * @returns resolves to a list of all the {@link OutgoingRoomKeyRequest} + */ + public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): Promise<OutgoingRoomKeyRequest[]> { + return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]); + } + + /** + * Find anything in `sent` state, and kick it around the loop again. + * This is intended for situations where something substantial has changed, and we + * don't really expect the other end to even care about the cancellation. + * For example, after initialization or self-verification. + * @returns An array of `queueRoomKeyRequest` outputs. + */ + public async cancelAndResendAllOutgoingRequests(): Promise<void[]> { + const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); + return Promise.all( + outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true)), + ); + } + + // start the background timer to send queued requests, if the timer isn't + // already running + private startTimer(): void { + if (this.sendOutgoingRoomKeyRequestsTimer) { + return; + } + + const startSendingOutgoingRoomKeyRequests = (): void => { + if (this.sendOutgoingRoomKeyRequestsRunning) { + throw new Error("RoomKeyRequestSend already in progress!"); + } + this.sendOutgoingRoomKeyRequestsRunning = true; + + this.sendOutgoingRoomKeyRequests() + .finally(() => { + this.sendOutgoingRoomKeyRequestsRunning = false; + }) + .catch((e) => { + // this should only happen if there is an indexeddb error, + // in which case we're a bit stuffed anyway. + logger.warn(`error in OutgoingRoomKeyRequestManager: ${e}`); + }); + }; + + this.sendOutgoingRoomKeyRequestsTimer = setTimeout( + startSendingOutgoingRoomKeyRequests, + SEND_KEY_REQUESTS_DELAY_MS, + ); + } + + // look for and send any queued requests. Runs itself recursively until + // there are no more requests, or there is an error (in which case, the + // timer will be restarted before the promise resolves). + private async sendOutgoingRoomKeyRequests(): Promise<void> { + if (!this.clientRunning) { + this.sendOutgoingRoomKeyRequestsTimer = undefined; + return; + } + + const req = await this.cryptoStore.getOutgoingRoomKeyRequestByState([ + RoomKeyRequestState.CancellationPending, + RoomKeyRequestState.CancellationPendingAndWillResend, + RoomKeyRequestState.Unsent, + ]); + + if (!req) { + this.sendOutgoingRoomKeyRequestsTimer = undefined; + return; + } + + try { + switch (req.state) { + case RoomKeyRequestState.Unsent: + await this.sendOutgoingRoomKeyRequest(req); + break; + case RoomKeyRequestState.CancellationPending: + await this.sendOutgoingRoomKeyRequestCancellation(req); + break; + case RoomKeyRequestState.CancellationPendingAndWillResend: + await this.sendOutgoingRoomKeyRequestCancellation(req, true); + break; + } + + // go around the loop again + return this.sendOutgoingRoomKeyRequests(); + } catch (e) { + logger.error("Error sending room key request; will retry later.", e); + this.sendOutgoingRoomKeyRequestsTimer = undefined; + } + } + + // given a RoomKeyRequest, send it and update the request record + private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise<unknown> { + logger.log( + `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + + ` from ${stringifyRecipientList(req.recipients)}` + + `(id ${req.requestId})`, + ); + + const requestMessage: RequestMessage = { + action: "request", + requesting_device_id: this.deviceId, + request_id: req.requestId, + body: req.requestBody, + }; + + return this.sendMessageToDevices(requestMessage, req.recipients, req.requestTxnId || req.requestId).then(() => { + return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent, { + state: RoomKeyRequestState.Sent, + }); + }); + } + + // Given a RoomKeyRequest, cancel it and delete the request record unless + // andResend is set, in which case transition to UNSENT. + private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise<unknown> { + logger.log( + `Sending cancellation for key request for ` + + `${stringifyRequestBody(req.requestBody)} to ` + + `${stringifyRecipientList(req.recipients)} ` + + `(cancellation id ${req.cancellationTxnId})`, + ); + + const requestMessage: RequestMessage = { + action: "request_cancellation", + requesting_device_id: this.deviceId, + request_id: req.requestId, + }; + + return this.sendMessageToDevices(requestMessage, req.recipients, req.cancellationTxnId).then(() => { + if (andResend) { + // We want to resend, so transition to UNSENT + return this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, + RoomKeyRequestState.CancellationPendingAndWillResend, + { state: RoomKeyRequestState.Unsent }, + ); + } + return this.cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, + RoomKeyRequestState.CancellationPending, + ); + }); + } + + // send a RoomKeyRequest to a list of recipients + private sendMessageToDevices( + message: RequestMessage, + recipients: IRoomKeyRequestRecipient[], + txnId?: string, + ): Promise<{}> { + const contentMap = new MapWithDefault<string, Map<string, Record<string, any>>>(() => new Map()); + for (const recip of recipients) { + const userDeviceMap = contentMap.getOrCreate(recip.userId); + userDeviceMap.set(recip.deviceId, { + ...message, + [ToDeviceMessageId]: uuidv4(), + }); + } + + return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId); + } +} + +function stringifyRequestBody(requestBody: IRoomKeyRequestBody): string { + // we assume that the request is for megolm keys, which are identified by + // room id and session id + return requestBody.room_id + " / " + requestBody.session_id; +} + +function stringifyRecipientList(recipients: IRoomKeyRequestRecipient[]): string { + return `[${recipients.map((r) => `${r.userId}:${r.deviceId}`).join(",")}]`; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts new file mode 100644 index 0000000..a73efcd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts @@ -0,0 +1,63 @@ +/* +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. +*/ + +/** + * Manages the list of encrypted rooms + */ + +import { CryptoStore } from "./store/base"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; + +/* eslint-disable camelcase */ +export interface IRoomEncryption { + algorithm: string; + rotation_period_ms?: number; + rotation_period_msgs?: number; +} +/* eslint-enable camelcase */ + +export class RoomList { + // Object of roomId -> room e2e info object (body of the m.room.encryption event) + private roomEncryption: Record<string, IRoomEncryption> = {}; + + public constructor(private readonly cryptoStore?: CryptoStore) {} + + public async init(): Promise<void> { + await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this.cryptoStore!.getEndToEndRooms(txn, (result) => { + this.roomEncryption = result; + }); + }); + } + + public getRoomEncryption(roomId: string): IRoomEncryption { + return this.roomEncryption[roomId] || null; + } + + public isRoomEncrypted(roomId: string): boolean { + return Boolean(this.getRoomEncryption(roomId)); + } + + public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise<void> { + // important that this happens before calling into the store + // as it prevents the Crypto::setRoomEncryption from calling + // this twice for consecutive m.room.encryption events + this.roomEncryption[roomId] = roomInfo; + await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this.cryptoStore!.storeEndToEndRoom(roomId, roomInfo, txn); + }); + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts new file mode 100644 index 0000000..5c9049f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts @@ -0,0 +1,583 @@ +/* +Copyright 2019 - 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 { v4 as uuidv4 } from "uuid"; + +import { logger } from "../logger"; +import * as olmlib from "./olmlib"; +import { randomString } from "../randomstring"; +import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes"; +import { ICryptoCallbacks, IEncryptedContent } from "."; +import { IContent, MatrixEvent } from "../models/event"; +import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client"; +import { IAddSecretStorageKeyOpts } from "./api"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { defer, IDeferred } from "../utils"; +import { ToDeviceMessageId } from "../@types/event"; +import { SecretStorageKeyDescription, SecretStorageKeyDescriptionAesV1 } from "../secret-storage"; + +export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; + +// Some of the key functions use a tuple and some use an object... +export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription]; +export type SecretStorageKeyObject = { keyId: string; keyInfo: SecretStorageKeyDescription }; + +export interface ISecretRequest { + requestId: string; + promise: Promise<string>; + cancel: (reason: string) => void; +} + +export interface IAccountDataClient extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> { + // Subset of MatrixClient (which also uses any for the event content) + getAccountDataFromServer: <T extends { [k: string]: any }>(eventType: string) => Promise<T>; + getAccountData: (eventType: string) => IContent | null; + setAccountData: (eventType: string, content: any) => Promise<{}>; +} + +interface ISecretRequestInternal { + name: string; + devices: string[]; + deferred: IDeferred<string>; +} + +interface IDecryptors { + encrypt: (plaintext: string) => Promise<IEncryptedPayload>; + decrypt: (ciphertext: IEncryptedPayload) => Promise<string>; +} + +interface ISecretInfo { + encrypted: { + [keyId: string]: IEncryptedPayload; + }; +} + +/** + * Implements Secure Secret Storage and Sharing (MSC1946) + */ +export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> { + private requests = new Map<string, ISecretRequestInternal>(); + + // In it's pure javascript days, this was relying on some proper Javascript-style + // type-abuse where sometimes we'd pass in a fake client object with just the account + // data methods implemented, which is all this class needs unless you use the secret + // sharing code, so it was fine. As a low-touch TypeScript migration, this now has + // an extra, optional param for a real matrix client, so you can not pass it as long + // as you don't request any secrets. + // A better solution would probably be to split this class up into secret storage and + // secret sharing which are really two separate things, even though they share an MSC. + public constructor( + private readonly accountDataAdapter: IAccountDataClient, + private readonly cryptoCallbacks: ICryptoCallbacks, + private readonly baseApis: B, + ) {} + + public async getDefaultKeyId(): Promise<string | null> { + const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>( + "m.secret_storage.default_key", + ); + if (!defaultKey) return null; + return defaultKey.key; + } + + public setDefaultKeyId(keyId: string): Promise<void> { + return new Promise<void>((resolve, reject) => { + const listener = (ev: MatrixEvent): void => { + if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) { + this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener); + resolve(); + } + }; + this.accountDataAdapter.on(ClientEvent.AccountData, listener); + + this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => { + this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener); + reject(e); + }); + }); + } + + /** + * Add a key for encrypting secrets. + * + * @param algorithm - the algorithm used by the key. + * @param opts - the options for the algorithm. The properties used + * depend on the algorithm given. + * @param keyId - the ID of the key. If not given, a random + * ID will be generated. + * + * @returns An object with: + * keyId: the ID of the key + * keyInfo: details about the key (iv, mac, passphrase) + */ + public async addKey( + algorithm: string, + opts: IAddSecretStorageKeyOpts = {}, + keyId?: string, + ): Promise<SecretStorageKeyObject> { + if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) { + throw new Error(`Unknown key algorithm ${algorithm}`); + } + + const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1; + + if (opts.name) { + keyInfo.name = opts.name; + } + + if (opts.passphrase) { + keyInfo.passphrase = opts.passphrase; + } + if (opts.key) { + const { iv, mac } = await calculateKeyCheck(opts.key); + keyInfo.iv = iv; + keyInfo.mac = mac; + } + + if (!keyId) { + do { + keyId = randomString(32); + } while ( + await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( + `m.secret_storage.key.${keyId}`, + ) + ); + } + + await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo); + + return { + keyId, + keyInfo, + }; + } + + /** + * Get the key information for a given ID. + * + * @param keyId - The ID of the key to check + * for. Defaults to the default key ID if not provided. + * @returns If the key was found, the return value is an array of + * the form [keyId, keyInfo]. Otherwise, null is returned. + * XXX: why is this an array when addKey returns an object? + */ + public async getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> { + if (!keyId) { + keyId = await this.getDefaultKeyId(); + } + if (!keyId) { + return null; + } + + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( + "m.secret_storage.key." + keyId, + ); + return keyInfo ? [keyId, keyInfo] : null; + } + + /** + * Check whether we have a key with a given ID. + * + * @param keyId - The ID of the key to check + * for. Defaults to the default key ID if not provided. + * @returns Whether we have the key. + */ + public async hasKey(keyId?: string): Promise<boolean> { + return Boolean(await this.getKey(keyId)); + } + + /** + * Check whether a key matches what we expect based on the key info + * + * @param key - the key to check + * @param info - the key info + * + * @returns whether or not the key matches + */ + public async checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> { + if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (info.mac) { + const { mac } = await calculateKeyCheck(key, info.iv); + return info.mac.replace(/=+$/g, "") === mac.replace(/=+$/g, ""); + } else { + // if we have no information, we have to assume the key is right + return true; + } + } else { + throw new Error("Unknown algorithm"); + } + } + + /** + * Store an encrypted secret on the server + * + * @param name - The name of the secret + * @param secret - The secret contents. + * @param keys - The IDs of the keys to use to encrypt the secret + * or null/undefined to use the default key. + */ + public async store(name: string, secret: string, keys?: string[] | null): Promise<void> { + const encrypted: Record<string, IEncryptedPayload> = {}; + + if (!keys) { + const defaultKeyId = await this.getDefaultKeyId(); + if (!defaultKeyId) { + throw new Error("No keys specified and no default key present"); + } + keys = [defaultKeyId]; + } + + if (keys.length === 0) { + throw new Error("Zero keys given to encrypt with!"); + } + + for (const keyId of keys) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( + "m.secret_storage.key." + keyId, + ); + if (!keyInfo) { + throw new Error("Unknown key: " + keyId); + } + + // encrypt secret, based on the algorithm + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + const keys = { [keyId]: keyInfo }; + const [, encryption] = await this.getSecretStorageKey(keys, name); + encrypted[keyId] = await encryption.encrypt(secret); + } else { + logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm); + // do nothing if we don't understand the encryption algorithm + } + } + + // save encrypted secret + await this.accountDataAdapter.setAccountData(name, { encrypted }); + } + + /** + * Get a secret from storage. + * + * @param name - the name of the secret + * + * @returns the contents of the secret + */ + public async get(name: string): Promise<string | undefined> { + const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name); + if (!secretInfo) { + return; + } + if (!secretInfo.encrypted) { + throw new Error("Content is not encrypted!"); + } + + // get possible keys to decrypt + const keys: Record<string, SecretStorageKeyDescription> = {}; + for (const keyId of Object.keys(secretInfo.encrypted)) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( + "m.secret_storage.key." + keyId, + ); + const encInfo = secretInfo.encrypted[keyId]; + // only use keys we understand the encryption algorithm of + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + keys[keyId] = keyInfo; + } + } + } + + if (Object.keys(keys).length === 0) { + throw new Error( + `Could not decrypt ${name} because none of ` + + `the keys it is encrypted with are for a supported algorithm`, + ); + } + + // fetch private key from app + const [keyId, decryption] = await this.getSecretStorageKey(keys, name); + const encInfo = secretInfo.encrypted[keyId]; + + return decryption.decrypt(encInfo); + } + + /** + * Check if a secret is stored on the server. + * + * @param name - the name of the secret + * + * @returns map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key + */ + public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> { + // check if secret exists + const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name); + if (!secretInfo?.encrypted) return null; + + const ret: Record<string, SecretStorageKeyDescription> = {}; + + // filter secret encryption keys with supported algorithm + for (const keyId of Object.keys(secretInfo.encrypted)) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( + "m.secret_storage.key." + keyId, + ); + if (!keyInfo) continue; + const encInfo = secretInfo.encrypted[keyId]; + + // only use keys we understand the encryption algorithm of + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + ret[keyId] = keyInfo; + } + } + } + return Object.keys(ret).length ? ret : null; + } + + /** + * Request a secret from another device + * + * @param name - the name of the secret to request + * @param devices - the devices to request the secret from + */ + public request(this: SecretStorage<MatrixClient>, name: string, devices: string[]): ISecretRequest { + const requestId = this.baseApis.makeTxnId(); + + const deferred = defer<string>(); + this.requests.set(requestId, { name, devices, deferred }); + + const cancel = (reason: string): void => { + // send cancellation event + const cancelData = { + action: "request_cancellation", + requesting_device_id: this.baseApis.deviceId, + request_id: requestId, + }; + const toDevice: Map<string, typeof cancelData> = new Map(); + for (const device of devices) { + toDevice.set(device, cancelData); + } + this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]])); + + // and reject the promise so that anyone waiting on it will be + // notified + deferred.reject(new Error(reason || "Cancelled")); + }; + + // send request to devices + const requestData = { + name, + action: "request", + requesting_device_id: this.baseApis.deviceId, + request_id: requestId, + [ToDeviceMessageId]: uuidv4(), + }; + const toDevice: Map<string, typeof requestData> = new Map(); + for (const device of devices) { + toDevice.set(device, requestData); + } + logger.info(`Request secret ${name} from ${devices}, id ${requestId}`); + this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]])); + + return { + requestId, + promise: deferred.promise, + cancel, + }; + } + + public async onRequestReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): Promise<void> { + const sender = event.getSender(); + const content = event.getContent(); + if ( + sender !== this.baseApis.getUserId() || + !(content.name && content.action && content.requesting_device_id && content.request_id) + ) { + // ignore requests from anyone else, for now + return; + } + const deviceId = content.requesting_device_id; + // check if it's a cancel + if (content.action === "request_cancellation") { + /* + Looks like we intended to emit events when we got cancelations, but + we never put anything in the _incomingRequests object, and the request + itself doesn't use events anyway so if we were to wire up cancellations, + they probably ought to use the same callback interface. I'm leaving them + disabled for now while converting this file to typescript. + if (this._incomingRequests[deviceId] + && this._incomingRequests[deviceId][content.request_id]) { + logger.info( + "received request cancellation for secret (" + sender + + ", " + deviceId + ", " + content.request_id + ")", + ); + this.baseApis.emit("crypto.secrets.requestCancelled", { + user_id: sender, + device_id: deviceId, + request_id: content.request_id, + }); + } + */ + } else if (content.action === "request") { + if (deviceId === this.baseApis.deviceId) { + // no point in trying to send ourself the secret + return; + } + + // check if we have the secret + logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")"); + if (!this.cryptoCallbacks.onSecretRequested) { + return; + } + const secret = await this.cryptoCallbacks.onSecretRequested( + sender, + deviceId, + content.request_id, + content.name, + this.baseApis.checkDeviceTrust(sender, deviceId), + ); + if (secret) { + logger.info(`Preparing ${content.name} secret for ${deviceId}`); + const payload = { + type: "m.secret.send", + content: { + request_id: content.request_id, + secret: secret, + }, + }; + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + await olmlib.ensureOlmSessionsForDevices( + this.baseApis.crypto!.olmDevice, + this.baseApis, + new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]), + ); + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.baseApis.getUserId()!, + this.baseApis.deviceId!, + this.baseApis.crypto!.olmDevice, + sender, + this.baseApis.getStoredDevice(sender, deviceId)!, + payload, + ); + const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]); + + logger.info(`Sending ${content.name} secret for ${deviceId}`); + this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } else { + logger.info(`Request denied for ${content.name} secret for ${deviceId}`); + } + } + } + + public onSecretReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): void { + if (event.getSender() !== this.baseApis.getUserId()) { + // we shouldn't be receiving secrets from anyone else, so ignore + // because someone could be trying to send us bogus data + return; + } + + if (!olmlib.isOlmEncrypted(event)) { + logger.error("secret event not properly encrypted"); + return; + } + + const content = event.getContent(); + + const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey( + olmlib.OLM_ALGORITHM, + event.getSenderKey() || "", + ); + if (senderKeyUser !== event.getSender()) { + logger.error("sending device does not belong to the user it claims to be from"); + return; + } + + logger.log("got secret share for request", content.request_id); + const requestControl = this.requests.get(content.request_id); + if (requestControl) { + // make sure that the device that sent it is one of the devices that + // we requested from + const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey( + olmlib.OLM_ALGORITHM, + event.getSenderKey()!, + ); + if (!deviceInfo) { + logger.log("secret share from unknown device with key", event.getSenderKey()); + return; + } + if (!requestControl.devices.includes(deviceInfo.deviceId)) { + logger.log("unsolicited secret share from device", deviceInfo.deviceId); + return; + } + // unsure that the sender is trusted. In theory, this check is + // unnecessary since we only accept secret shares from devices that + // we requested from, but it doesn't hurt. + const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo); + if (!deviceTrust.isVerified()) { + logger.log("secret share from unverified device"); + return; + } + + logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`); + requestControl.deferred.resolve(content.secret); + } + } + + private async getSecretStorageKey( + keys: Record<string, SecretStorageKeyDescription>, + name: string, + ): Promise<[string, IDecryptors]> { + if (!this.cryptoCallbacks.getSecretStorageKey) { + throw new Error("No getSecretStorageKey callback supplied"); + } + + const returned = await this.cryptoCallbacks.getSecretStorageKey({ keys }, name); + + if (!returned) { + throw new Error("getSecretStorageKey callback returned falsey"); + } + if (returned.length < 2) { + throw new Error("getSecretStorageKey callback returned invalid data"); + } + + const [keyId, privateKey] = returned; + if (!keys[keyId]) { + throw new Error("App returned unknown key from getSecretStorageKey!"); + } + + if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + const decryption = { + encrypt: function (secret: string): Promise<IEncryptedPayload> { + return encryptAES(secret, privateKey, name); + }, + decrypt: function (encInfo: IEncryptedPayload): Promise<string> { + return decryptAES(encInfo, privateKey, name); + }, + }; + return [keyId, decryption]; + } else { + throw new Error("Unknown key type: " + keys[keyId].algorithm); + } + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts new file mode 100644 index 0000000..48470af --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts @@ -0,0 +1,157 @@ +/* +Copyright 2020 - 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 { decodeBase64, encodeBase64 } from "./olmlib"; +import { subtleCrypto, crypto, TextEncoder } from "./crypto"; + +// salt for HKDF, with 8 bytes of zeros +const zeroSalt = new Uint8Array(8); + +export interface IEncryptedPayload { + [key: string]: any; // extensible + /** the initialization vector in base64 */ + iv: string; + /** the ciphertext in base64 */ + ciphertext: string; + /** the HMAC in base64 */ + mac: string; +} + +/** + * encrypt a string + * + * @param data - the plaintext to encrypt + * @param key - the encryption key to use + * @param name - the name of the secret + * @param ivStr - the initialization vector to use + */ +export async function encryptAES( + data: string, + key: Uint8Array, + name: string, + ivStr?: string, +): Promise<IEncryptedPayload> { + let iv: Uint8Array; + if (ivStr) { + iv = decodeBase64(ivStr); + } else { + iv = new Uint8Array(16); + crypto.getRandomValues(iv); + + // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary + // (which would mean we wouldn't be able to decrypt on Android). The loss + // of a single bit of iv is a price we have to pay. + iv[8] &= 0x7f; + } + + const [aesKey, hmacKey] = await deriveKeys(key, name); + const encodedData = new TextEncoder().encode(data); + + const ciphertext = await subtleCrypto.encrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aesKey, + encodedData, + ); + + const hmac = await subtleCrypto.sign({ name: "HMAC" }, hmacKey, ciphertext); + + return { + iv: encodeBase64(iv), + ciphertext: encodeBase64(ciphertext), + mac: encodeBase64(hmac), + }; +} + +/** + * decrypt a string + * + * @param data - the encrypted data + * @param key - the encryption key to use + * @param name - the name of the secret + */ +export async function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise<string> { + const [aesKey, hmacKey] = await deriveKeys(key, name); + + const ciphertext = decodeBase64(data.ciphertext); + + if (!(await subtleCrypto.verify({ name: "HMAC" }, hmacKey, decodeBase64(data.mac), ciphertext))) { + throw new Error(`Error decrypting secret ${name}: bad MAC`); + } + + const plaintext = await subtleCrypto.decrypt( + { + name: "AES-CTR", + counter: decodeBase64(data.iv), + length: 64, + }, + aesKey, + ciphertext, + ); + + return new TextDecoder().decode(new Uint8Array(plaintext)); +} + +async function deriveKeys(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { + const hkdfkey = await subtleCrypto.importKey("raw", key, { name: "HKDF" }, false, ["deriveBits"]); + const keybits = await subtleCrypto.deriveBits( + { + name: "HKDF", + salt: zeroSalt, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 + info: new TextEncoder().encode(name), + hash: "SHA-256", + }, + hkdfkey, + 512, + ); + + const aesKey = keybits.slice(0, 32); + const hmacKey = keybits.slice(32); + + const aesProm = subtleCrypto.importKey("raw", aesKey, { name: "AES-CTR" }, false, ["encrypt", "decrypt"]); + + const hmacProm = subtleCrypto.importKey( + "raw", + hmacKey, + { + name: "HMAC", + hash: { name: "SHA-256" }, + }, + false, + ["sign", "verify"], + ); + + return Promise.all([aesProm, hmacProm]); +} + +// string of zeroes, for calculating the key check +const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + +/** Calculate the MAC for checking the key. + * + * @param key - the key to use + * @param iv - The initialization vector as a base64-encoded string. + * If omitted, a random initialization vector will be created. + * @returns An object that contains, `mac` and `iv` properties. + */ +export function calculateKeyCheck(key: Uint8Array, iv?: string): Promise<IEncryptedPayload> { + return encryptAES(ZERO_STR, key, "", iv); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts new file mode 100644 index 0000000..6473009 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts @@ -0,0 +1,268 @@ +/* +Copyright 2016 - 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. +*/ + +/** + * Internal module. Defines the base classes of the encryption implementations + */ + +import type { IMegolmSessionData } from "../../@types/crypto"; +import { MatrixClient } from "../../client"; +import { Room } from "../../models/room"; +import { OlmDevice } from "../OlmDevice"; +import { IContent, MatrixEvent, RoomMember } from "../../matrix"; +import { Crypto, IEncryptedContent, IEventDecryptionResult, IncomingRoomKeyRequest } from ".."; +import { DeviceInfo } from "../deviceinfo"; +import { IRoomEncryption } from "../RoomList"; +import { DeviceInfoMap } from "../DeviceList"; + +/** + * Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class + */ +export const ENCRYPTION_CLASSES = new Map<string, new (params: IParams) => EncryptionAlgorithm>(); + +export type DecryptionClassParams<P extends IParams = IParams> = Omit<P, "deviceId" | "config">; + +/** + * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class + */ +export const DECRYPTION_CLASSES = new Map<string, new (params: DecryptionClassParams) => DecryptionAlgorithm>(); + +export interface IParams { + /** The UserID for the local user */ + userId: string; + /** The identifier for this device. */ + deviceId: string; + /** crypto core */ + crypto: Crypto; + /** olm.js wrapper */ + olmDevice: OlmDevice; + /** base matrix api interface */ + baseApis: MatrixClient; + /** The ID of the room we will be sending to */ + roomId?: string; + /** The body of the m.room.encryption event */ + config: IRoomEncryption & object; +} + +/** + * base type for encryption implementations + */ +export abstract class EncryptionAlgorithm { + protected readonly userId: string; + protected readonly deviceId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId?: string; + + /** + * @param params - parameters + */ + public constructor(params: IParams) { + this.userId = params.userId; + this.deviceId = params.deviceId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param room - the room the event is in + */ + public prepareToEncrypt(room: Room): void {} + + /** + * Encrypt a message event + * + * @public + * + * @param content - event content + * + * @returns Promise which resolves to the new event body + */ + public abstract encryptMessage(room: Room, eventType: string, content: IContent): Promise<IEncryptedContent>; + + /** + * Called when the membership of a member of the room changes. + * + * @param event - event causing the change + * @param member - user whose membership changed + * @param oldMembership - previous membership + * @public + */ + public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} + + public reshareKeyWithDevice?( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise<void>; + + public forceDiscardSession?(): void; +} + +/** + * base type for decryption implementations + */ +export abstract class DecryptionAlgorithm { + protected readonly userId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId?: string; + + public constructor(params: DecryptionClassParams) { + this.userId = params.userId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; + } + + /** + * Decrypt an event + * + * @param event - undecrypted event + * + * @returns promise which + * resolves once we have finished decrypting. Rejects with an + * `algorithms.DecryptionError` if there is a problem decrypting the event. + */ + public abstract decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>; + + /** + * Handle a key event + * + * @param params - event key event + */ + public async onRoomKeyEvent(params: MatrixEvent): Promise<void> { + // ignore by default + } + + /** + * Import a room key + * + * @param opts - object + */ + public async importRoomKey(session: IMegolmSessionData, opts: object): Promise<void> { + // ignore by default + } + + /** + * Determine if we have the keys necessary to respond to a room key request + * + * @returns true if we have the keys and could (theoretically) share + * them; else false. + */ + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> { + return Promise.resolve(false); + } + + /** + * Send the response to a room key request + * + */ + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { + throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); + } + + /** + * Retry decrypting all the events from a sender that haven't been + * decrypted yet. + * + * @param senderKey - the sender's key + */ + public async retryDecryptionFromSender(senderKey: string): Promise<boolean> { + // ignore by default + return false; + } + + public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise<void>; + public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>; +} + +/** + * Exception thrown when decryption fails + * + * @param msg - user-visible message describing the problem + * + * @param details - key/value pairs reported in the logs but not shown + * to the user. + */ +export class DecryptionError extends Error { + public readonly detailedString: string; + + public constructor(public readonly code: string, msg: string, details?: Record<string, string | Error>) { + super(msg); + this.code = code; + this.name = "DecryptionError"; + this.detailedString = detailedStringForDecryptionError(this, details); + } +} + +function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string { + let result = err.name + "[msg: " + err.message; + + if (details) { + result += + ", " + + Object.keys(details) + .map((k) => k + ": " + details[k]) + .join(", "); + } + + result += "]"; + + return result; +} + +export class UnknownDeviceError extends Error { + /** + * Exception thrown specifically when we want to warn the user to consider + * the security of their conversation before continuing + * + * @param msg - message describing the problem + * @param devices - set of unknown devices per user we're warning about + */ + public constructor(msg: string, public readonly devices: DeviceInfoMap, public event?: MatrixEvent) { + super(msg); + this.name = "UnknownDeviceError"; + this.devices = devices; + } +} + +/** + * Registers an encryption/decryption class for a particular algorithm + * + * @param algorithm - algorithm tag to register for + * + * @param encryptor - {@link EncryptionAlgorithm} implementation + * + * @param decryptor - {@link DecryptionAlgorithm} implementation + */ +export function registerAlgorithm<P extends IParams = IParams>( + algorithm: string, + encryptor: new (params: P) => EncryptionAlgorithm, + decryptor: new (params: DecryptionClassParams<P>) => DecryptionAlgorithm, +): void { + ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm); + DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts new file mode 100644 index 0000000..b3c5b0e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts @@ -0,0 +1,20 @@ +/* +Copyright 2016 - 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 "./olm"; +import "./megolm"; + +export * from "./base"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts new file mode 100644 index 0000000..061e169 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts @@ -0,0 +1,2208 @@ +/* +Copyright 2015 - 2021, 2023 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. +*/ + +/** + * Defines m.olm encryption/decryption + */ + +import { v4 as uuidv4 } from "uuid"; + +import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto"; +import { logger, PrefixedLogger } from "../../logger"; +import * as olmlib from "../olmlib"; +import { + DecryptionAlgorithm, + DecryptionClassParams, + DecryptionError, + EncryptionAlgorithm, + IParams, + registerAlgorithm, + UnknownDeviceError, +} from "./base"; +import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from "../OlmDevice"; +import { Room } from "../../models/room"; +import { DeviceInfo } from "../deviceinfo"; +import { IOlmSessionResult } from "../olmlib"; +import { DeviceInfoMap } from "../DeviceList"; +import { IContent, MatrixEvent } from "../../models/event"; +import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event"; +import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } from "../index"; +import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; +import { OlmGroupSessionExtraData } from "../../@types/crypto"; +import { MatrixError } from "../../http-api"; +import { immediate, MapWithDefault } from "../../utils"; + +// determine whether the key can be shared with invitees +export function isRoomSharedHistory(room: Room): boolean { + const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", ""); + // NOTE: if the room visibility is unset, it would normally default to + // "world_readable". + // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) + // But we will be paranoid here, and treat it as a situation where the room + // is not shared-history + const visibility = visibilityEvent?.getContent()?.history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + +interface IBlockedDevice { + code: string; + reason: string; + deviceInfo: DeviceInfo; +} + +// map user Id → device Id → IBlockedDevice +type BlockedMap = Map<string, Map<string, IBlockedDevice>>; + +export interface IOlmDevice<T = DeviceInfo> { + userId: string; + deviceInfo: T; +} + +/** + * Tests whether an encrypted content has a ciphertext. + * Ciphertext can be a string or object depending on the content type {@link IEncryptedContent}. + * + * @param content - Encrypted content + * @returns true: has ciphertext, else false + */ +const hasCiphertext = (content: IEncryptedContent): boolean => { + return typeof content.ciphertext === "string" + ? !!content.ciphertext.length + : !!Object.keys(content.ciphertext).length; +}; + +/** The result of parsing the an `m.room_key` or `m.forwarded_room_key` to-device event */ +interface RoomKey { + /** + * The Curve25519 key of the megolm session creator. + * + * For `m.room_key`, this is also the sender of the `m.room_key` to-device event. + * For `m.forwarded_room_key`, the two are different (and the key of the sender of the + * `m.forwarded_room_key` event is included in `forwardingKeyChain`) + */ + senderKey: string; + sessionId: string; + sessionKey: string; + exportFormat: boolean; + roomId: string; + algorithm: string; + /** + * A list of the curve25519 keys of the users involved in forwarding this key, most recent last. + * For `m.room_key` events, this is empty. + */ + forwardingKeyChain: string[]; + keysClaimed: Partial<Record<"ed25519", string>>; + extraSessionData: OlmGroupSessionExtraData; +} + +export interface IOutboundGroupSessionKey { + chain_index: number; + key: string; +} + +interface IMessage { + type: string; + content: { + "algorithm": string; + "room_id": string; + "sender_key"?: string; + "sender_claimed_ed25519_key"?: string; + "session_id": string; + "session_key": string; + "chain_index": number; + "forwarding_curve25519_key_chain"?: string[]; + "org.matrix.msc3061.shared_history": boolean; + }; +} + +interface IKeyForwardingMessage extends IMessage { + type: "m.forwarded_room_key"; +} + +interface IPayload extends Partial<IMessage> { + code?: string; + reason?: string; + room_id?: string; + session_id?: string; + algorithm?: string; + sender_key?: string; +} + +interface SharedWithData { + // The identity key of the device we shared with + deviceKey: string; + // The message index of the ratchet we shared with that device + messageIndex: number; +} + +/** + * @internal + */ +class OutboundSessionInfo { + /** number of times this session has been used */ + public useCount = 0; + /** when the session was created (ms since the epoch) */ + public creationTime: number; + /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */ + public sharedWithDevices: MapWithDefault<string, Map<string, SharedWithData>> = new MapWithDefault(() => new Map()); + public blockedDevicesNotified: MapWithDefault<string, Map<string, boolean>> = new MapWithDefault(() => new Map()); + + /** + * @param sharedHistory - whether the session can be freely shared with + * other group members, according to the room history visibility settings + */ + public constructor(public readonly sessionId: string, public readonly sharedHistory = false) { + this.creationTime = new Date().getTime(); + } + + /** + * Check if it's time to rotate the session + */ + public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { + const sessionLifetime = new Date().getTime() - this.creationTime; + + if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { + logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms"); + return true; + } + + return false; + } + + public markSharedWithDevice(userId: string, deviceId: string, deviceKey: string, chainIndex: number): void { + this.sharedWithDevices.getOrCreate(userId).set(deviceId, { deviceKey, messageIndex: chainIndex }); + } + + public markNotifiedBlockedDevice(userId: string, deviceId: string): void { + this.blockedDevicesNotified.getOrCreate(userId).set(deviceId, true); + } + + /** + * Determine if this session has been shared with devices which it shouldn't + * have been. + * + * @param devicesInRoom - `userId -> {deviceId -> object}` + * devices we should shared the session with. + * + * @returns true if we have shared the session with devices which aren't + * in devicesInRoom. + */ + public sharedWithTooManyDevices(devicesInRoom: DeviceInfoMap): boolean { + for (const [userId, devices] of this.sharedWithDevices) { + if (!devicesInRoom.has(userId)) { + logger.log("Starting new megolm session because we shared with " + userId); + return true; + } + + for (const [deviceId] of devices) { + if (!devicesInRoom.get(userId)?.get(deviceId)) { + logger.log("Starting new megolm session because we shared with " + userId + ":" + deviceId); + return true; + } + } + } + + return false; + } +} + +/** + * Megolm encryption implementation + * + * @param params - parameters, as per {@link EncryptionAlgorithm} + */ +export class MegolmEncryption extends EncryptionAlgorithm { + // the most recent attempt to set up a session. This is used to serialise + // the session setups, so that we have a race-free view of which session we + // are using, and which devices we have shared the keys with. It resolves + // with an OutboundSessionInfo (or undefined, for the first message in the + // room). + private setupPromise = Promise.resolve<OutboundSessionInfo | null>(null); + + // Map of outbound sessions by sessions ID. Used if we need a particular + // session (the session we're currently using to send is always obtained + // using setupPromise). + private outboundSessions: Record<string, OutboundSessionInfo> = {}; + + private readonly sessionRotationPeriodMsgs: number; + private readonly sessionRotationPeriodMs: number; + private encryptionPreparation?: { + promise: Promise<void>; + startTime: number; + cancel: () => void; + }; + + protected readonly roomId: string; + private readonly prefixedLogger: PrefixedLogger; + + public constructor(params: IParams & Required<Pick<IParams, "roomId">>) { + super(params); + this.roomId = params.roomId; + this.prefixedLogger = logger.withPrefix(`[${this.roomId} encryption]`); + + this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; + this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; + } + + /** + * @internal + * + * @param devicesInRoom - The devices in this room, indexed by user ID + * @param blocked - The devices that are blocked, indexed by user ID + * @param singleOlmCreationPhase - Only perform one round of olm + * session creation + * + * This method updates the setupPromise field of the class by chaining a new + * call on top of the existing promise, and then catching and discarding any + * errors that might happen while setting up the outbound group session. This + * is done to ensure that `setupPromise` always resolves to `null` or the + * `OutboundSessionInfo`. + * + * Using `>>=` to represent the promise chaining operation, it does the + * following: + * + * ``` + * setupPromise = previousSetupPromise >>= setup >>= discardErrors + * ``` + * + * The initial value for the `setupPromise` is a promise that resolves to + * `null`. The forceDiscardSession() resets setupPromise to this initial + * promise. + * + * @returns Promise which resolves to the + * OutboundSessionInfo when setup is complete. + */ + private async ensureOutboundSession( + room: Room, + devicesInRoom: DeviceInfoMap, + blocked: BlockedMap, + singleOlmCreationPhase = false, + ): Promise<OutboundSessionInfo> { + // takes the previous OutboundSessionInfo, and considers whether to create + // a new one. Also shares the key with any (new) devices in the room. + // + // returns a promise which resolves once the keyshare is successful. + const setup = async (oldSession: OutboundSessionInfo | null): Promise<OutboundSessionInfo> => { + const sharedHistory = isRoomSharedHistory(room); + const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession); + + await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); + + return session; + }; + + // first wait for the previous share to complete + const fallible = this.setupPromise.then(setup); + + // Ensure any failures are logged for debugging and make sure that the + // promise chain remains unbroken + // + // setupPromise resolves to `null` or the `OutboundSessionInfo` whether + // or not the share succeeds + this.setupPromise = fallible.catch((e) => { + this.prefixedLogger.error(`Failed to setup outbound session`, e); + return null; + }); + + // but we return a promise which only resolves if the share was successful. + return fallible; + } + + private async prepareSession( + devicesInRoom: DeviceInfoMap, + sharedHistory: boolean, + session: OutboundSessionInfo | null, + ): Promise<OutboundSessionInfo> { + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + + // need to make a brand new session? + if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) { + this.prefixedLogger.log("Starting new megolm session because we need to rotate."); + session = null; + } + + // determine if we have shared with anyone we shouldn't have + if (session?.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } + + if (!session) { + this.prefixedLogger.log("Starting new megolm session"); + session = await this.prepareNewSession(sharedHistory); + this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`); + this.outboundSessions[session.sessionId] = session; + } + + return session; + } + + private async shareSession( + devicesInRoom: DeviceInfoMap, + sharedHistory: boolean, + singleOlmCreationPhase: boolean, + blocked: BlockedMap, + session: OutboundSessionInfo, + ): Promise<void> { + // now check if we need to share with any devices + const shareMap: Record<string, DeviceInfo[]> = {}; + + for (const [userId, userDevices] of devicesInRoom) { + for (const [deviceId, deviceInfo] of userDevices) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + + if (!session.sharedWithDevices.get(userId)?.get(deviceId)) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } + } + + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload: IPayload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, + }, + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( + this.olmDevice, + this.baseApis, + shareMap, + ); + + await Promise.all([ + (async (): Promise<void> => { + // share keys with devices that we already have a session for + const olmSessionList = Array.from(olmSessions.entries()) + .map(([userId, sessionsByUser]) => + Array.from(sessionsByUser.entries()).map( + ([deviceId, session]) => `${userId}/${deviceId}: ${session.sessionId}`, + ), + ) + .flat(1); + this.prefixedLogger.debug("Sharing keys with devices with existing Olm sessions:", olmSessionList); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + this.prefixedLogger.debug("Shared keys with existing Olm sessions"); + })(), + (async (): Promise<void> => { + const deviceList = Array.from(devicesWithoutSession.entries()) + .map(([userId, devicesByUser]) => devicesByUser.map((device) => `${userId}/${device.deviceId}`)) + .flat(1); + this.prefixedLogger.debug( + "Sharing keys (start phase 1) with devices without existing Olm sessions:", + deviceList, + ); + const errorDevices: IOlmDevice[] = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers: string[] = []; + await this.shareKeyWithDevices( + session, + key, + payload, + devicesWithoutSession, + errorDevices, + singleOlmCreationPhase ? 10000 : 2000, + failedServers, + ); + this.prefixedLogger.debug("Shared keys (end phase 1) with devices without existing Olm sessions"); + + if (!singleOlmCreationPhase && Date.now() - start < 10000) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async (): Promise<void> => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []); + const failedServerMap = new Set(); + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices: IOlmDevice[] = []; + for (const { userId, deviceInfo } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices.getOrCreate(userId).push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ userId, deviceInfo }); + } + } + + const retryDeviceList = Array.from(retryDevices.entries()) + .map(([userId, devicesByUser]) => + devicesByUser.map((device) => `${userId}/${device.deviceId}`), + ) + .flat(1); + + if (retryDeviceList.length > 0) { + this.prefixedLogger.debug( + "Sharing keys (start phase 2) with devices without existing Olm sessions:", + retryDeviceList, + ); + await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000); + this.prefixedLogger.debug( + "Shared keys (end phase 2) with devices without existing Olm sessions", + ); + } + + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + })(), + (async (): Promise<void> => { + this.prefixedLogger.debug( + `There are ${blocked.size} blocked devices:`, + Array.from(blocked.entries()) + .map(([userId, blockedByUser]) => + Array.from(blockedByUser.entries()).map( + ([deviceId, _deviceInfo]) => `${userId}/${deviceId}`, + ), + ) + .flat(1), + ); + + // also, notify newly blocked devices that they're blocked + const blockedMap: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new MapWithDefault( + () => new Map(), + ); + let blockedCount = 0; + for (const [userId, userBlockedDevices] of blocked) { + for (const [deviceId, device] of userBlockedDevices) { + if (session.blockedDevicesNotified.get(userId)?.get(deviceId) === undefined) { + blockedMap.getOrCreate(userId).set(deviceId, { device }); + blockedCount++; + } + } + } + + if (blockedCount) { + this.prefixedLogger.debug( + `Notifying ${blockedCount} newly blocked devices:`, + Array.from(blockedMap.entries()) + .map(([userId, blockedByUser]) => + Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`), + ) + .flat(1), + ); + await this.notifyBlockedDevices(session, blockedMap); + this.prefixedLogger.debug(`Notified ${blockedCount} newly blocked devices`); + } + })(), + ]); + } + + /** + * @internal + * + * + * @returns session + */ + private async prepareNewSession(sharedHistory: boolean): Promise<OutboundSessionInfo> { + const sessionId = this.olmDevice.createOutboundGroupSession(); + const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); + + await this.olmDevice.addInboundGroupSession( + this.roomId, + this.olmDevice.deviceCurve25519Key!, + [], + sessionId, + key.key, + { ed25519: this.olmDevice.deviceEd25519Key! }, + false, + { sharedHistory }, + ); + + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key!, sessionId); + + return new OutboundSessionInfo(sessionId, sharedHistory); + } + + /** + * Determines what devices in devicesByUser don't have an olm session as given + * in devicemap. + * + * @internal + * + * @param deviceMap - the devices that have olm sessions, as returned by + * olmlib.ensureOlmSessionsForDevices. + * @param devicesByUser - a map of user IDs to array of deviceInfo + * @param noOlmDevices - an array to fill with devices that don't have + * olm sessions + * + * @returns an array of devices that don't have olm sessions. If + * noOlmDevices is specified, then noOlmDevices will be returned. + */ + private getDevicesWithoutSessions( + deviceMap: Map<string, Map<string, IOlmSessionResult>>, + devicesByUser: Map<string, DeviceInfo[]>, + noOlmDevices: IOlmDevice[] = [], + ): IOlmDevice[] { + for (const [userId, devicesToShareWith] of devicesByUser) { + const sessionResults = deviceMap.get(userId); + + for (const deviceInfo of devicesToShareWith) { + const deviceId = deviceInfo.deviceId; + + const sessionResult = sessionResults?.get(deviceId); + if (!sessionResult?.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + + noOlmDevices.push({ userId, deviceInfo }); + sessionResults?.delete(deviceId); + + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + continue; + } + } + } + + return noOlmDevices; + } + + /** + * Splits the user device map into multiple chunks to reduce the number of + * devices we encrypt to per API call. + * + * @internal + * + * @param devicesByUser - map from userid to list of devices + * + * @returns the blocked devices, split into chunks + */ + private splitDevices<T extends DeviceInfo | IBlockedDevice>( + devicesByUser: Map<string, Map<string, { device: T }>>, + ): IOlmDevice<T>[][] { + const maxDevicesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice: IOlmDevice<T>[] = []; + const mapSlices = [currentSlice]; + + for (const [userId, userDevices] of devicesByUser) { + for (const deviceInfo of userDevices.values()) { + currentSlice.push({ + userId: userId, + deviceInfo: deviceInfo.device, + }); + } + + // We do this in the per-user loop as we prefer that all messages to the + // same user end up in the same API call to make it easier for the + // server (e.g. only have to send one EDU if a remote user, etc). This + // does mean that if a user has many devices we may go over the desired + // limit, but its not a hard limit so that is fine. + if (currentSlice.length > maxDevicesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + currentSlice = []; + mapSlices.push(currentSlice); + } + } + if (currentSlice.length === 0) { + mapSlices.pop(); + } + return mapSlices; + } + + /** + * @internal + * + * + * @param chainIndex - current chain index + * + * @param userDeviceMap - mapping from userId to deviceInfo + * + * @param payload - fields to include in the encrypted payload + * + * @returns Promise which resolves once the key sharing + * for the given userDeviceMap is generated and has been sent. + */ + private encryptAndSendKeysToDevices( + session: OutboundSessionInfo, + chainIndex: number, + devices: IOlmDevice[], + payload: IPayload, + ): Promise<void> { + return this.crypto + .encryptAndSendToDevices(devices, payload) + .then(() => { + // store that we successfully uploaded the keys of the current slice + for (const device of devices) { + session.markSharedWithDevice( + device.userId, + device.deviceInfo.deviceId, + device.deviceInfo.getIdentityKey(), + chainIndex, + ); + } + }) + .catch((error) => { + this.prefixedLogger.error("failed to encryptAndSendToDevices", error); + throw error; + }); + } + + /** + * @internal + * + * + * @param userDeviceMap - list of blocked devices to notify + * + * @param payload - fields to include in the notification payload + * + * @returns Promise which resolves once the notifications + * for the given userDeviceMap is generated and has been sent. + */ + private async sendBlockedNotificationsToDevices( + session: OutboundSessionInfo, + userDeviceMap: IOlmDevice<IBlockedDevice>[], + payload: IPayload, + ): Promise<void> { + const contentMap: MapWithDefault<string, Map<string, IPayload>> = new MapWithDefault(() => new Map()); + + for (const val of userDeviceMap) { + const userId = val.userId; + const blockedInfo = val.deviceInfo; + const deviceInfo = blockedInfo.deviceInfo; + const deviceId = deviceInfo.deviceId; + + const message = { + ...payload, + code: blockedInfo.code, + reason: blockedInfo.reason, + [ToDeviceMessageId]: uuidv4(), + }; + + if (message.code === "m.no_olm") { + delete message.room_id; + delete message.session_id; + } + + contentMap.getOrCreate(userId).set(deviceId, message); + } + + await this.baseApis.sendToDevice("m.room_key.withheld", contentMap); + + // record the fact that we notified these blocked devices + for (const [userId, userDeviceMap] of contentMap) { + for (const deviceId of userDeviceMap.keys()) { + session.markNotifiedBlockedDevice(userId, deviceId); + } + } + } + + /** + * Re-shares a megolm session key with devices if the key has already been + * sent to them. + * + * @param senderKey - The key of the originating device for the session + * @param sessionId - ID of the outbound session to share + * @param userId - ID of the user who owns the target device + * @param device - The target device + */ + public async reshareKeyWithDevice( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise<void> { + const obSessionInfo = this.outboundSessions[sessionId]; + if (!obSessionInfo) { + this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`); + return; + } + + // The chain index of the key we previously sent this device + if (!obSessionInfo.sharedWithDevices.has(userId)) { + this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`); + return; + } + const sessionSharedData = obSessionInfo.sharedWithDevices.get(userId)?.get(device.deviceId); + if (sessionSharedData === undefined) { + this.prefixedLogger.debug( + `megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`, + ); + return; + } + + if (sessionSharedData.deviceKey !== device.getIdentityKey()) { + this.prefixedLogger.warn( + `Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` + + `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`, + ); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this.olmDevice.getInboundGroupSessionKey( + this.roomId, + senderKey, + sessionId, + sessionSharedData.messageIndex, + ); + + if (!key) { + this.prefixedLogger.warn( + `No inbound session key found for megolm session ${senderKey}|${sessionId}: not re-sharing keys`, + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [device]]])); + + const payload = { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + device, + payload, + ); + + await this.baseApis.sendToDevice( + "m.room.encrypted", + new Map([[userId, new Map([[device.deviceId, encryptedContent]])]]), + ); + this.prefixedLogger.debug( + `Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`, + ); + } + + /** + * @internal + * + * + * @param key - the session key as returned by + * OlmDevice.getOutboundGroupSessionKey + * + * @param payload - the base to-device message payload for sharing keys + * + * @param devicesByUser - map from userid to list of devices + * + * @param errorDevices - array that will be populated with the devices that we can't get an + * olm session for + * + * @param otkTimeout - The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param failedServers - An array to fill with remote servers that + * failed to respond to one-time-key requests. + */ + private async shareKeyWithDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicesByUser: Map<string, DeviceInfo[]>, + errorDevices: IOlmDevice[], + otkTimeout: number, + failedServers?: string[], + ): Promise<void> { + const devicemap = await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, + this.baseApis, + devicesByUser, + false, + otkTimeout, + failedServers, + this.prefixedLogger, + ); + this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); + await this.shareKeyWithOlmSessions(session, key, payload, devicemap); + } + + private async shareKeyWithOlmSessions( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + deviceMap: Map<string, Map<string, IOlmSessionResult>>, + ): Promise<void> { + const userDeviceMaps = this.splitDevices(deviceMap); + + for (let i = 0; i < userDeviceMaps.length; i++) { + const taskDetail = `megolm keys for ${session.sessionId} (slice ${i + 1}/${userDeviceMaps.length})`; + try { + this.prefixedLogger.debug( + `Sharing ${taskDetail}`, + userDeviceMaps[i].map((d) => `${d.userId}/${d.deviceInfo.deviceId}`), + ); + await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload); + this.prefixedLogger.debug(`Shared ${taskDetail}`); + } catch (e) { + this.prefixedLogger.error(`Failed to share ${taskDetail}`); + throw e; + } + } + } + + /** + * Notify devices that we weren't able to create olm sessions. + * + * + * + * @param failedDevices - the devices that we were unable to + * create olm sessions for, as returned by shareKeyWithDevices + */ + private async notifyFailedOlmDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + failedDevices: IOlmDevice[], + ): Promise<void> { + this.prefixedLogger.debug(`Notifying ${failedDevices.length} devices we failed to create Olm sessions`); + + // mark the devices that failed as "handled" because we don't want to try + // to claim a one-time-key for dead devices on every message. + for (const { userId, deviceInfo } of failedDevices) { + const deviceId = deviceInfo.deviceId; + + session.markSharedWithDevice(userId, deviceId, deviceInfo.getIdentityKey(), key.chain_index); + } + + const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices); + this.prefixedLogger.debug( + `Need to notify ${unnotifiedFailedDevices.length} failed devices which haven't been notified before`, + ); + const blockedMap: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new MapWithDefault( + () => new Map(), + ); + for (const { userId, deviceInfo } of unnotifiedFailedDevices) { + // we use a similar format to what + // olmlib.ensureOlmSessionsForDevices returns, so that + // we can use the same function to split + blockedMap.getOrCreate(userId).set(deviceInfo.deviceId, { + device: { + code: "m.no_olm", + reason: WITHHELD_MESSAGES["m.no_olm"], + deviceInfo, + }, + }); + } + + // send the notifications + await this.notifyBlockedDevices(session, blockedMap); + this.prefixedLogger.debug( + `Notified ${unnotifiedFailedDevices.length} devices we failed to create Olm sessions`, + ); + } + + /** + * Notify blocked devices that they have been blocked. + * + * + * @param devicesByUser - map from userid to device ID to blocked data + */ + private async notifyBlockedDevices( + session: OutboundSessionInfo, + devicesByUser: Map<string, Map<string, { device: IBlockedDevice }>>, + ): Promise<void> { + const payload: IPayload = { + room_id: this.roomId, + session_id: session.sessionId, + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + }; + + const userDeviceMaps = this.splitDevices(devicesByUser); + + for (let i = 0; i < userDeviceMaps.length; i++) { + try { + await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); + this.prefixedLogger.log( + `Completed blacklist notification for ${session.sessionId} ` + + `(slice ${i + 1}/${userDeviceMaps.length})`, + ); + } catch (e) { + this.prefixedLogger.log( + `blacklist notification for ${session.sessionId} ` + + `(slice ${i + 1}/${userDeviceMaps.length}) failed`, + ); + + throw e; + } + } + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param room - the room the event is in + * @returns A function that, when called, will stop the preparation + */ + public prepareToEncrypt(room: Room): () => void { + if (room.roomId !== this.roomId) { + throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room"); + } + + if (this.encryptionPreparation != null) { + // We're already preparing something, so don't do anything else. + const elapsedTime = Date.now() - this.encryptionPreparation.startTime; + this.prefixedLogger.debug( + `Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`, + ); + return this.encryptionPreparation.cancel; + } + + this.prefixedLogger.debug("Preparing to encrypt events"); + + let cancelled = false; + const isCancelled = (): boolean => cancelled; + + this.encryptionPreparation = { + startTime: Date.now(), + promise: (async (): Promise<void> => { + try { + // Attempt to enumerate the devices in room, and gracefully + // handle cancellation if it occurs. + const getDevicesResult = await this.getDevicesInRoom(room, false, isCancelled); + if (getDevicesResult === null) return; + const [devicesInRoom, blocked] = getDevicesResult; + + if (this.crypto.globalErrorOnUnknownDevices) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } + + this.prefixedLogger.debug("Ensuring outbound megolm session"); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + + this.prefixedLogger.debug("Ready to encrypt events"); + } catch (e) { + this.prefixedLogger.error("Failed to prepare to encrypt events", e); + } finally { + delete this.encryptionPreparation; + } + })(), + + cancel: (): void => { + // The caller has indicated that the process should be cancelled, + // so tell the promise that we'd like to halt, and reset the preparation state. + cancelled = true; + delete this.encryptionPreparation; + }, + }; + + return this.encryptionPreparation.cancel; + } + + /** + * @param content - plaintext event content + * + * @returns Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IMegolmEncryptedContent> { + this.prefixedLogger.log("Starting to encrypt event"); + + if (this.encryptionPreparation != null) { + // If we started sending keys, wait for it to be done. + // FIXME: check if we need to cancel + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + try { + await this.encryptionPreparation.promise; + } catch (e) { + // ignore any errors -- if the preparation failed, we'll just + // restart everything here + } + } + + /** + * When using in-room messages and the room has encryption enabled, + * clients should ensure that encryption does not hinder the verification. + */ + const forceDistributeToUnverified = this.isVerificationEvent(eventType, content); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room, forceDistributeToUnverified); + + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + if (this.crypto.globalErrorOnUnknownDevices) { + this.checkForUnknownDevices(devicesInRoom); + } + + const session = await this.ensureOutboundSession(room, devicesInRoom, blocked); + const payloadJson = { + room_id: this.roomId, + type: eventType, + content: content, + }; + + const ciphertext = this.olmDevice.encryptGroupMessage(session.sessionId, JSON.stringify(payloadJson)); + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: ciphertext, + session_id: session.sessionId, + // Include our device ID so that recipients can send us a + // m.new_device message if they don't have our session key. + // XXX: Do we still need this now that m.new_device messages + // no longer exist since #483? + device_id: this.deviceId, + }; + + session.useCount++; + return encryptedContent; + } + + private isVerificationEvent(eventType: string, content: IContent): boolean { + switch (eventType) { + case EventType.KeyVerificationCancel: + case EventType.KeyVerificationDone: + case EventType.KeyVerificationMac: + case EventType.KeyVerificationStart: + case EventType.KeyVerificationKey: + case EventType.KeyVerificationReady: + case EventType.KeyVerificationAccept: { + return true; + } + case EventType.RoomMessage: { + return content["msgtype"] === MsgType.KeyVerificationRequest; + } + default: { + return false; + } + } + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * This should not normally be necessary. + */ + public forceDiscardSession(): void { + this.setupPromise = this.setupPromise.then(() => null); + } + + /** + * Checks the devices we're about to send to and see if any are entirely + * unknown to the user. If so, warn the user, and mark them as known to + * give the user a chance to go verify them before re-sending this message. + * + * @param devicesInRoom - `userId -> {deviceId -> object}` + * devices we should shared the session with. + */ + private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { + const unknownDevices: MapWithDefault<string, Map<string, DeviceInfo>> = new MapWithDefault(() => new Map()); + + for (const [userId, userDevices] of devicesInRoom) { + for (const [deviceId, device] of userDevices) { + if (device.isUnverified() && !device.isKnown()) { + unknownDevices.getOrCreate(userId).set(deviceId, device); + } + } + } + + if (unknownDevices.size) { + // it'd be kind to pass unknownDevices up to the user in this error + throw new UnknownDeviceError( + "This room contains unknown devices which have not been verified. " + + "We strongly recommend you verify them before continuing.", + unknownDevices, + ); + } + } + + /** + * Remove unknown devices from a set of devices. The devicesInRoom parameter + * will be modified. + * + * @param devicesInRoom - `userId -> {deviceId -> object}` + * devices we should shared the session with. + */ + private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { + for (const [userId, userDevices] of devicesInRoom) { + for (const [deviceId, device] of userDevices) { + if (device.isUnverified() && !device.isKnown()) { + userDevices.delete(deviceId); + } + } + + if (userDevices.size === 0) { + devicesInRoom.delete(userId); + } + } + } + + /** + * Get the list of unblocked devices for all users in the room + * + * @param forceDistributeToUnverified - if set to true will include the unverified devices + * even if setting is set to block them (useful for verification) + * @param isCancelled - will cause the procedure to abort early if and when it starts + * returning `true`. If omitted, cancellation won't happen. + * + * @returns Promise which resolves to `null`, or an array whose + * first element is a {@link DeviceInfoMap} indicating + * the devices that messages should be encrypted to, and whose second + * element is a map from userId to deviceId to data indicating the devices + * that are in the room but that have been blocked. + * If `isCancelled` is provided and returns `true` while processing, `null` + * will be returned. + * If `isCancelled` is not provided, the Promise will never resolve to `null`. + */ + private async getDevicesInRoom( + room: Room, + forceDistributeToUnverified?: boolean, + ): Promise<[DeviceInfoMap, BlockedMap]>; + private async getDevicesInRoom( + room: Room, + forceDistributeToUnverified?: boolean, + isCancelled?: () => boolean, + ): Promise<null | [DeviceInfoMap, BlockedMap]>; + private async getDevicesInRoom( + room: Room, + forceDistributeToUnverified = false, + isCancelled?: () => boolean, + ): Promise<null | [DeviceInfoMap, BlockedMap]> { + const members = await room.getEncryptionTargetMembers(); + this.prefixedLogger.debug( + `Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`, + members.map((u) => `${u.userId} (${u.membership})`), + ); + + const roomMembers = members.map(function (u) { + return u.userId; + }); + + // The global value is treated as a default for when rooms don't specify a value. + let isBlacklisting = this.crypto.globalBlacklistUnverifiedDevices; + const isRoomBlacklisting = room.getBlacklistUnverifiedDevices(); + if (typeof isRoomBlacklisting === "boolean") { + isBlacklisting = isRoomBlacklisting; + } + + // We are happy to use a cached version here: we assume that if we already + // have a list of the user's devices, then we already share an e2e room + // with them, which means that they will have announced any new devices via + // device_lists in their /sync response. This cache should then be maintained + // using all the device_lists changes and left fields. + // See https://github.com/vector-im/element-web/issues/2305 for details. + const devices = await this.crypto.downloadKeys(roomMembers, false); + + if (isCancelled?.() === true) { + return null; + } + + const blocked = new MapWithDefault<string, Map<string, IBlockedDevice>>(() => new Map()); + // remove any blocked devices + for (const [userId, userDevices] of devices) { + for (const [deviceId, userDevice] of userDevices) { + // Yield prior to checking each device so that we don't block + // updating/rendering for too long. + // See https://github.com/vector-im/element-web/issues/21612 + if (isCancelled !== undefined) await immediate(); + if (isCancelled?.() === true) return null; + const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); + + if ( + userDevice.isBlocked() || + (!deviceTrust.isVerified() && isBlacklisting && !forceDistributeToUnverified) + ) { + const blockedDevices = blocked.getOrCreate(userId); + const isBlocked = userDevice.isBlocked(); + blockedDevices.set(deviceId, { + code: isBlocked ? "m.blacklisted" : "m.unverified", + reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], + deviceInfo: userDevice, + }); + userDevices.delete(deviceId); + } + } + } + + return [devices, blocked]; + } +} + +/** + * Megolm decryption implementation + * + * @param params - parameters, as per {@link DecryptionAlgorithm} + */ +export class MegolmDecryption extends DecryptionAlgorithm { + // events which we couldn't decrypt due to unknown sessions / + // indexes, or which we could only decrypt with untrusted keys: + // map from senderKey|sessionId to Set of MatrixEvents + private pendingEvents = new Map<string, Map<string, Set<MatrixEvent>>>(); + + // this gets stubbed out by the unit tests. + private olmlib = olmlib; + + protected readonly roomId: string; + private readonly prefixedLogger: PrefixedLogger; + + public constructor(params: DecryptionClassParams<IParams & Required<Pick<IParams, "roomId">>>) { + super(params); + this.roomId = params.roomId; + this.prefixedLogger = logger.withPrefix(`[${this.roomId} decryption]`); + } + + /** + * returns a promise which resolves to a + * {@link EventDecryptionResult} once we have finished + * decrypting, or rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { + const content = event.getWireContent(); + + if (!content.sender_key || !content.session_id || !content.ciphertext) { + throw new DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input"); + } + + // we add the event to the pending list *before* we start decryption. + // + // then, if the key turns up while decryption is in progress (and + // decryption fails), we will schedule a retry. + // (fixes https://github.com/vector-im/element-web/issues/5001) + this.addEventToPendingList(event); + + let res: IDecryptedGroupMessage | null; + try { + res = await this.olmDevice.decryptGroupMessage( + event.getRoomId()!, + content.sender_key, + content.session_id, + content.ciphertext, + event.getId()!, + event.getTs(), + ); + } catch (e) { + if ((<Error>e).name === "DecryptionError") { + // re-throw decryption errors as-is + throw e; + } + + let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; + + if ((<MatrixError>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") { + this.requestKeysForEvent(event); + + errorCode = "OLM_UNKNOWN_MESSAGE_INDEX"; + } + + throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", { + session: content.sender_key + "|" + content.session_id, + }); + } + + if (res === null) { + // We've got a message for a session we don't have. + // try and get the missing key from the backup first + this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {}); + + // (XXX: We might actually have received this key since we started + // decrypting, in which case we'll have scheduled a retry, and this + // request will be redundant. We could probably check to see if the + // event is still in the pending list; if not, a retry will have been + // scheduled, so we needn't send out the request here.) + this.requestKeysForEvent(event); + + // See if there was a problem with the olm session at the time the + // event was sent. Use a fuzz factor of 2 minutes. + const problem = await this.olmDevice.sessionMayHaveProblems(content.sender_key, event.getTs() - 120000); + if (problem) { + this.prefixedLogger.info( + `When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` + + `recent session problem with that sender:`, + problem, + ); + let problemDescription = PROBLEM_DESCRIPTIONS[problem.type as "no_olm"] || PROBLEM_DESCRIPTIONS.unknown; + if (problem.fixed) { + problemDescription += " Trying to create a new secure channel and re-requesting the keys."; + } + throw new DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, { + session: content.sender_key + "|" + content.session_id, + }); + } + + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + "The sender's device has not sent us the keys for this message.", + { + session: content.sender_key + "|" + content.session_id, + }, + ); + } + + // Success. We can remove the event from the pending list, if + // that hasn't already happened. However, if the event was + // decrypted with an untrusted key, leave it on the pending + // list so it will be retried if we find a trusted key later. + if (!res.untrusted) { + this.removeEventFromPendingList(event); + } + + const payload = JSON.parse(res.result); + + // belt-and-braces check that the room id matches that indicated by the HS + // (this is somewhat redundant, since the megolm session is scoped to the + // room, so neither the sender nor a MITM can lie about the room_id). + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id); + } + + return { + clearEvent: payload, + senderCurve25519Key: res.senderKey, + claimedEd25519Key: res.keysClaimed.ed25519, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + untrusted: res.untrusted, + }; + } + + private requestKeysForEvent(event: MatrixEvent): void { + const wireContent = event.getWireContent(); + + const recipients = event.getKeyRequestRecipients(this.userId); + + this.crypto.requestRoomKey( + { + room_id: event.getRoomId()!, + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id, + }, + recipients, + ); + } + + /** + * Add an event to the list of those awaiting their session keys. + * + * @internal + * + */ + private addEventToPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + if (!this.pendingEvents.has(senderKey)) { + this.pendingEvents.set(senderKey, new Map<string, Set<MatrixEvent>>()); + } + const senderPendingEvents = this.pendingEvents.get(senderKey)!; + if (!senderPendingEvents.has(sessionId)) { + senderPendingEvents.set(sessionId, new Set()); + } + senderPendingEvents.get(sessionId)?.add(event); + } + + /** + * Remove an event from the list of those awaiting their session keys. + * + * @internal + * + */ + private removeEventFromPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.pendingEvents.get(senderKey); + const pendingEvents = senderPendingEvents?.get(sessionId); + if (!pendingEvents) { + return; + } + + pendingEvents.delete(event); + if (pendingEvents.size === 0) { + senderPendingEvents!.delete(sessionId); + } + if (senderPendingEvents!.size === 0) { + this.pendingEvents.delete(senderKey); + } + } + + /** + * Parse a RoomKey out of an `m.room_key` event. + * + * @param event - the event containing the room key. + * + * @returns The `RoomKey` if it could be successfully parsed out of the + * event. + * + * @internal + * + */ + private roomKeyFromEvent(event: MatrixEvent): RoomKey | undefined { + const senderKey = event.getSenderKey()!; + const content = event.getContent<Partial<IMessage["content"]>>(); + const extraSessionData: OlmGroupSessionExtraData = {}; + + if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) { + this.prefixedLogger.error("key event is missing fields"); + return; + } + + if (!olmlib.isOlmEncrypted(event)) { + this.prefixedLogger.error("key event not properly encrypted"); + return; + } + + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + + const roomKey: RoomKey = { + senderKey: senderKey, + sessionId: content.session_id, + sessionKey: content.session_key, + extraSessionData, + exportFormat: false, + roomId: content.room_id, + algorithm: content.algorithm, + forwardingKeyChain: [], + keysClaimed: event.getKeysClaimed(), + }; + + return roomKey; + } + + /** + * Parse a RoomKey out of an `m.forwarded_room_key` event. + * + * @param event - the event containing the forwarded room key. + * + * @returns The `RoomKey` if it could be successfully parsed out of the + * event. + * + * @internal + * + */ + private forwardedRoomKeyFromEvent(event: MatrixEvent): RoomKey | undefined { + // the properties in m.forwarded_room_key are a superset of those in m.room_key, so + // start by parsing the m.room_key fields. + const roomKey = this.roomKeyFromEvent(event); + + if (!roomKey) { + return; + } + + const senderKey = event.getSenderKey()!; + const content = event.getContent<Partial<IMessage["content"]>>(); + + const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); + + // We received this to-device event from event.getSenderKey(), but the original + // creator of the room key is claimed in the content. + const claimedCurve25519Key = content.sender_key; + const claimedEd25519Key = content.sender_claimed_ed25519_key; + + let forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) + ? content.forwarding_curve25519_key_chain + : []; + + // copy content before we modify it + forwardingKeyChain = forwardingKeyChain.slice(); + forwardingKeyChain.push(senderKey); + + // Check if we have all the fields we need. + if (senderKeyUser !== event.getSender()) { + this.prefixedLogger.error("sending device does not belong to the user it claims to be from"); + return; + } + + if (!claimedCurve25519Key) { + this.prefixedLogger.error("forwarded_room_key event is missing sender_key field"); + return; + } + + if (!claimedEd25519Key) { + this.prefixedLogger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`); + return; + } + + const keysClaimed = { + ed25519: claimedEd25519Key, + }; + + // FIXME: We're reusing the same field to track both: + // + // 1. The Olm identity we've received this room key from. + // 2. The Olm identity deduced (in the trusted case) or claiming (in the + // untrusted case) to be the original creator of this room key. + // + // We now overwrite the value tracking usage 1 with the value tracking usage 2. + roomKey.senderKey = claimedCurve25519Key; + // Replace our keysClaimed as well. + roomKey.keysClaimed = keysClaimed; + roomKey.exportFormat = true; + roomKey.forwardingKeyChain = forwardingKeyChain; + // forwarded keys are always untrusted + roomKey.extraSessionData.untrusted = true; + + return roomKey; + } + + /** + * Determine if we should accept the forwarded room key that was found in the given + * event. + * + * @param event - An `m.forwarded_room_key` event. + * @param roomKey - The room key that was found in the event. + * + * @returns promise that will resolve to a boolean telling us if it's ok to + * accept the given forwarded room key. + * + * @internal + * + */ + private async shouldAcceptForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise<boolean> { + const senderKey = event.getSenderKey()!; + + const sendingDevice = + this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey) ?? undefined; + const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender()!, sendingDevice); + + // Using the plaintext sender here is fine since we checked that the + // sender matches to the user id in the device keys when this event was + // originally decrypted. This can obviously only happen if the device + // keys have been downloaded, but if they haven't the + // `deviceTrust.isVerified()` flag would be false as well. + // + // It would still be far nicer if the `sendingDevice` had a user ID + // attached to it that went through signature checks. + const fromUs = event.getSender() === this.baseApis.getUserId(); + const keyFromOurVerifiedDevice = deviceTrust.isVerified() && fromUs; + const weRequested = await this.wasRoomKeyRequested(event, roomKey); + const fromInviter = this.wasRoomKeyForwardedByInviter(event, roomKey); + const sharedAsHistory = this.wasRoomKeyForwardedAsHistory(roomKey); + + return (weRequested && keyFromOurVerifiedDevice) || (fromInviter && sharedAsHistory); + } + + /** + * Did we ever request the given room key from the event sender and its + * accompanying device. + * + * @param event - An `m.forwarded_room_key` event. + * @param roomKey - The room key that was found in the event. + * + * @internal + * + */ + private async wasRoomKeyRequested(event: MatrixEvent, roomKey: RoomKey): Promise<boolean> { + // We send the `m.room_key_request` out as a wildcard to-device request, + // otherwise we would have to duplicate the same content for each + // device. This is why we need to pass in "*" as the device id here. + const outgoingRequests = await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget( + event.getSender()!, + "*", + [RoomKeyRequestState.Sent], + ); + + return outgoingRequests.some( + (req) => req.requestBody.room_id === roomKey.roomId && req.requestBody.session_id === roomKey.sessionId, + ); + } + + private wasRoomKeyForwardedByInviter(event: MatrixEvent, roomKey: RoomKey): boolean { + // TODO: This is supposed to have a time limit. We should only accept + // such keys if we happen to receive them for a recently joined room. + const room = this.baseApis.getRoom(roomKey.roomId); + const senderKey = event.getSenderKey(); + + if (!senderKey) { + return false; + } + + const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); + + if (!senderKeyUser) { + return false; + } + + const memberEvent = room?.getMember(this.userId)?.events.member; + const fromInviter = + memberEvent?.getSender() === senderKeyUser || + (memberEvent?.getUnsigned()?.prev_sender === senderKeyUser && + memberEvent?.getPrevContent()?.membership === "invite"); + + if (room && fromInviter) { + return true; + } else { + return false; + } + } + + private wasRoomKeyForwardedAsHistory(roomKey: RoomKey): boolean { + const room = this.baseApis.getRoom(roomKey.roomId); + + // If the key is not for a known room, then something fishy is going on, + // so we reject the key out of caution. In practice, this is a bit moot + // because we'll only accept shared_history forwarded by the inviter, and + // we won't know who was the inviter for an unknown room, so we'll reject + // it anyway. + if (room && roomKey.extraSessionData.sharedHistory) { + return true; + } else { + return false; + } + } + + /** + * Check if a forwarded room key should be parked. + * + * A forwarded room key should be parked if it's a key for a room we're not + * in. We park the forwarded room key in case *this sender* invites us to + * that room later. + */ + private shouldParkForwardedKey(roomKey: RoomKey): boolean { + const room = this.baseApis.getRoom(roomKey.roomId); + + if (!room && roomKey.extraSessionData.sharedHistory) { + return true; + } else { + return false; + } + } + + /** + * Park the given room key to our store. + * + * @param event - An `m.forwarded_room_key` event. + * @param roomKey - The room key that was found in the event. + * + * @internal + * + */ + private async parkForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise<void> { + const parkedData = { + senderId: event.getSender()!, + senderKey: roomKey.senderKey, + sessionId: roomKey.sessionId, + sessionKey: roomKey.sessionKey, + keysClaimed: roomKey.keysClaimed, + forwardingCurve25519KeyChain: roomKey.forwardingKeyChain, + }; + await this.crypto.cryptoStore.doTxn( + "readwrite", + ["parked_shared_history"], + (txn) => this.crypto.cryptoStore.addParkedSharedHistory(roomKey.roomId, parkedData, txn), + logger.withPrefix("[addParkedSharedHistory]"), + ); + } + + /** + * Add the given room key to our store. + * + * @param roomKey - The room key that should be added to the store. + * + * @internal + * + */ + private async addRoomKey(roomKey: RoomKey): Promise<void> { + try { + await this.olmDevice.addInboundGroupSession( + roomKey.roomId, + roomKey.senderKey, + roomKey.forwardingKeyChain, + roomKey.sessionId, + roomKey.sessionKey, + roomKey.keysClaimed, + roomKey.exportFormat, + roomKey.extraSessionData, + ); + + // have another go at decrypting events sent with this session. + if (await this.retryDecryption(roomKey.senderKey, roomKey.sessionId, !roomKey.extraSessionData.untrusted)) { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + this.crypto.cancelRoomKeyRequest({ + algorithm: roomKey.algorithm, + room_id: roomKey.roomId, + session_id: roomKey.sessionId, + sender_key: roomKey.senderKey, + }); + } + + // don't wait for the keys to be backed up for the server + await this.crypto.backupManager.backupGroupSession(roomKey.senderKey, roomKey.sessionId); + } catch (e) { + this.prefixedLogger.error(`Error handling m.room_key_event: ${e}`); + } + } + + /** + * Handle room keys that have been forwarded to us as an + * `m.forwarded_room_key` event. + * + * Forwarded room keys need special handling since we have no way of knowing + * who the original creator of the room key was. This naturally means that + * forwarded room keys are always untrusted and should only be accepted in + * some cases. + * + * @param event - An `m.forwarded_room_key` event. + * + * @internal + * + */ + private async onForwardedRoomKey(event: MatrixEvent): Promise<void> { + const roomKey = this.forwardedRoomKeyFromEvent(event); + + if (!roomKey) { + return; + } + + if (await this.shouldAcceptForwardedKey(event, roomKey)) { + await this.addRoomKey(roomKey); + } else if (this.shouldParkForwardedKey(roomKey)) { + await this.parkForwardedKey(event, roomKey); + } + } + + public async onRoomKeyEvent(event: MatrixEvent): Promise<void> { + if (event.getType() == "m.forwarded_room_key") { + await this.onForwardedRoomKey(event); + } else { + const roomKey = this.roomKeyFromEvent(event); + + if (!roomKey) { + return; + } + + await this.addRoomKey(roomKey); + } + } + + /** + * @param event - key event + */ + public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise<void> { + const content = event.getContent(); + const senderKey = content.sender_key; + + if (content.code === "m.no_olm") { + await this.onNoOlmWithheldEvent(event); + } else if (content.code === "m.unavailable") { + // this simply means that the other device didn't have the key, which isn't very useful information. Don't + // record it in the storage + } else { + await this.olmDevice.addInboundGroupSessionWithheld( + content.room_id, + senderKey, + content.session_id, + content.code, + content.reason, + ); + } + + // Having recorded the problem, retry decryption on any affected messages. + // It's unlikely we'll be able to decrypt sucessfully now, but this will + // update the error message. + // + if (content.session_id) { + await this.retryDecryption(senderKey, content.session_id); + } else { + // no_olm messages aren't specific to a given megolm session, so + // we trigger retrying decryption for all the messages from the sender's + // key, so that we can update the error message to indicate the olm + // session problem. + await this.retryDecryptionFromSender(senderKey); + } + } + + private async onNoOlmWithheldEvent(event: MatrixEvent): Promise<void> { + const content = event.getContent(); + const senderKey = content.sender_key; + const sender = event.getSender()!; + this.prefixedLogger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + if (await this.olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + this.prefixedLogger.debug("New session already created. Not creating a new one."); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + return; + } + let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.crypto.downloadKeys([sender], false); + device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); + if (!device) { + this.prefixedLogger.info( + "Couldn't find device for identity key " + senderKey + ": not establishing session", + ); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); + return; + } + } + + // XXX: switch this to use encryptAndSendToDevices() rather than duplicating it? + + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[sender, [device]]]), false); + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + + await this.baseApis.sendToDevice( + "m.room.encrypted", + new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]), + ); + } + + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> { + const body = keyRequest.requestBody; + + return this.olmDevice.hasInboundSessionKeys( + body.room_id, + body.sender_key, + body.session_id, + // TODO: ratchet index + ); + } + + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const deviceInfo = this.crypto.getStoredDevice(userId, deviceId)!; + const body = keyRequest.requestBody; + + // XXX: switch this to use encryptAndSendToDevices()? + + this.olmlib + .ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [deviceInfo]]])) + .then((devicemap) => { + const olmSessionResult = devicemap.get(userId)?.get(deviceId); + if (!olmSessionResult?.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return null; + } + + this.prefixedLogger.log( + "sharing keys for session " + + body.sender_key + + "|" + + body.session_id + + " with device " + + userId + + ":" + + deviceId, + ); + + return this.buildKeyForwardingMessage(body.room_id, body.sender_key, body.session_id); + }) + .then((payload) => { + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + + return this.olmlib + .encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + userId, + deviceInfo, + payload!, + ) + .then(() => { + // TODO: retries + return this.baseApis.sendToDevice( + "m.room.encrypted", + new Map([[userId, new Map([[deviceId, encryptedContent]])]]), + ); + }); + }); + } + + private async buildKeyForwardingMessage( + roomId: string, + senderKey: string, + sessionId: string, + ): Promise<IKeyForwardingMessage> { + const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId); + + return { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key!.sender_claimed_ed25519_key!, + "session_id": sessionId, + "session_key": key!.key, + "chain_index": key!.chain_index, + "forwarding_curve25519_key_chain": key!.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key!.shared_history || false, + }, + }; + } + + /** + * @param untrusted - whether the key should be considered as untrusted + * @param source - where the key came from + */ + public importRoomKey( + session: IMegolmSessionData, + { untrusted, source }: { untrusted?: boolean; source?: string } = {}, + ): Promise<void> { + const extraSessionData: OlmGroupSessionExtraData = {}; + if (untrusted || session.untrusted) { + extraSessionData.untrusted = true; + } + if (session["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice + .addInboundGroupSession( + session.room_id, + session.sender_key, + session.forwarding_curve25519_key_chain, + session.session_id, + session.session_key, + session.sender_claimed_keys, + true, + extraSessionData, + ) + .then(() => { + if (source !== "backup") { + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => { + // This throws if the upload failed, but this is fine + // since it will have written it to the db and will retry. + this.prefixedLogger.log("Failed to back up megolm session", e); + }); + } + // have another go at decrypting events sent with this session. + this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted); + }); + } + + /** + * Have another go at decrypting events after we receive a key. Resolves once + * decryption has been re-attempted on all events. + * + * @internal + * @param forceRedecryptIfUntrusted - whether messages that were already + * successfully decrypted using untrusted keys should be re-decrypted + * + * @returns whether all messages were successfully + * decrypted with trusted keys + */ + private async retryDecryption( + senderKey: string, + sessionId: string, + forceRedecryptIfUntrusted?: boolean, + ): Promise<boolean> { + const senderPendingEvents = this.pendingEvents.get(senderKey); + if (!senderPendingEvents) { + return true; + } + + const pending = senderPendingEvents.get(sessionId); + if (!pending) { + return true; + } + + const pendingList = [...pending]; + this.prefixedLogger.debug( + "Retrying decryption on events:", + pendingList.map((e) => `${e.getId()}`), + ); + + await Promise.all( + pendingList.map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted }); + } catch (e) { + // don't die if something goes wrong + } + }), + ); + + // If decrypted successfully with trusted keys, they'll have + // been removed from pendingEvents + return !this.pendingEvents.get(senderKey)?.has(sessionId); + } + + public async retryDecryptionFromSender(senderKey: string): Promise<boolean> { + const senderPendingEvents = this.pendingEvents.get(senderKey); + if (!senderPendingEvents) { + return true; + } + + this.pendingEvents.delete(senderKey); + + await Promise.all( + [...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all( + [...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto); + } catch (e) { + // don't die if something goes wrong + } + }), + ); + }), + ); + + return !this.pendingEvents.has(senderKey); + } + + public async sendSharedHistoryInboundSessions(devicesByUser: Map<string, DeviceInfo[]>): Promise<void> { + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); + + const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); + this.prefixedLogger.log( + `Sharing history in with users ${Array.from(devicesByUser.keys())}`, + sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`), + ); + for (const [senderKey, sessionId] of sharedHistorySessions) { + const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId); + + // FIXME: use encryptAndSendToDevices() rather than duplicating it here. + const promises: Promise<unknown>[] = []; + const contentMap: Map<string, Map<string, IEncryptedContent>> = new Map(); + for (const [userId, devices] of devicesByUser) { + const deviceMessages = new Map(); + contentMap.set(userId, deviceMessages); + for (const deviceInfo of devices) { + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + deviceMessages.set(deviceInfo.deviceId, encryptedContent); + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + } + await Promise.all(promises); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const [userId, deviceMessages] of contentMap) { + for (const [deviceId, content] of deviceMessages) { + if (!hasCiphertext(content)) { + this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning"); + deviceMessages.delete(deviceId); + } + } + // No devices left for that user? Strip that too. + if (deviceMessages.size === 0) { + this.prefixedLogger.log("Pruned all devices for user " + userId); + contentMap.delete(userId); + } + } + + // Is there anything left? + if (contentMap.size === 0) { + this.prefixedLogger.log("No users left to send to: aborting"); + return; + } + + await this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } + } +} + +const PROBLEM_DESCRIPTIONS = { + no_olm: "The sender was unable to establish a secure channel.", + unknown: "The secure channel with the sender was corrupted.", +}; + +registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts new file mode 100644 index 0000000..1a79554 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts @@ -0,0 +1,329 @@ +/* +Copyright 2016 - 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. +*/ + +/** + * Defines m.olm encryption/decryption + */ + +import type { IEventDecryptionResult } from "../../@types/crypto"; +import { logger } from "../../logger"; +import * as olmlib from "../olmlib"; +import { DeviceInfo } from "../deviceinfo"; +import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base"; +import { Room } from "../../models/room"; +import { IContent, MatrixEvent } from "../../models/event"; +import { IEncryptedContent, IOlmEncryptedContent } from "../index"; +import { IInboundSession } from "../OlmDevice"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +export interface IMessage { + type: number; + body: string; +} + +/** + * Olm encryption implementation + * + * @param params - parameters, as per {@link EncryptionAlgorithm} + */ +class OlmEncryption extends EncryptionAlgorithm { + private sessionPrepared = false; + private prepPromise: Promise<void> | null = null; + + /** + * @internal + + * @param roomMembers - list of currently-joined users in the room + * @returns Promise which resolves when setup is complete + */ + private ensureSession(roomMembers: string[]): Promise<void> { + if (this.prepPromise) { + // prep already in progress + return this.prepPromise; + } + + if (this.sessionPrepared) { + // prep already done + return Promise.resolve(); + } + + this.prepPromise = this.crypto + .downloadKeys(roomMembers) + .then(() => { + return this.crypto.ensureOlmSessionsForUsers(roomMembers); + }) + .then(() => { + this.sessionPrepared = true; + }) + .finally(() => { + this.prepPromise = null; + }); + + return this.prepPromise; + } + + /** + * @param content - plaintext event content + * + * @returns Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IOlmEncryptedContent> { + // pick the list of recipients based on the membership list. + // + // TODO: there is a race condition here! What if a new user turns up + // just as you are sending a secret message? + + const members = await room.getEncryptionTargetMembers(); + + const users = members.map(function (u) { + return u.userId; + }); + + await this.ensureSession(users); + + const payloadFields = { + room_id: room.roomId, + type: eventType, + content: content, + }; + + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + }; + + const promises: Promise<void>[] = []; + + for (const userId of users) { + const devices = this.crypto.getStoredDevicesForUser(userId) || []; + + for (const deviceInfo of devices) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payloadFields, + ), + ); + } + } + + return Promise.all(promises).then(() => encryptedContent); + } +} + +/** + * Olm decryption implementation + * + * @param params - parameters, as per {@link DecryptionAlgorithm} + */ +class OlmDecryption extends DecryptionAlgorithm { + /** + * returns a promise which resolves to a + * {@link EventDecryptionResult} once we have finished + * decrypting. Rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { + const content = event.getWireContent(); + const deviceKey = content.sender_key; + const ciphertext = content.ciphertext; + + if (!ciphertext) { + throw new DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext"); + } + + if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) { + throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients"); + } + const message = ciphertext[this.olmDevice.deviceCurve25519Key!]; + let payloadString: string; + + try { + payloadString = await this.decryptMessage(deviceKey, message); + } catch (e) { + throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", { + sender: deviceKey, + err: e as Error, + }); + } + + const payload = JSON.parse(payloadString); + + // check that we were the intended recipient, to avoid unknown-key attack + // https://github.com/vector-im/vector-web/issues/2483 + if (payload.recipient != this.userId) { + throw new DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient); + } + + if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { + throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", { + intended: payload.recipient_keys.ed25519, + our_key: this.olmDevice.deviceEd25519Key!, + }); + } + + // check that the device that encrypted the event belongs to the user + // that the event claims it's from. We need to make sure that our + // device list is up-to-date. If the device is unknown, we can only + // assume that the device logged out. Some event handlers, such as + // secret sharing, may be more strict and reject events that come from + // unknown devices. + await this.crypto.deviceList.downloadKeys([event.getSender()!], false); + const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey); + if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) { + throw new DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), { + real_sender: senderKeyUser, + }); + } + + // check that the original sender matches what the homeserver told us, to + // avoid people masquerading as others. + // (this check is also provided via the sender's embedded ed25519 key, + // which is checked elsewhere). + if (payload.sender != event.getSender()) { + throw new DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, { + reported_sender: event.getSender()!, + }); + } + + // Olm events intended for a room have a room_id. + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, { + reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED", + }); + } + + const claimedKeys = payload.keys || {}; + + return { + clearEvent: payload, + senderCurve25519Key: deviceKey, + claimedEd25519Key: claimedKeys.ed25519 || null, + }; + } + + /** + * Attempt to decrypt an Olm message + * + * @param theirDeviceIdentityKey - Curve25519 identity key of the sender + * @param message - message object, with 'type' and 'body' fields + * + * @returns payload, if decrypted successfully. + */ + private decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> { + // This is a wrapper that serialises decryptions of prekey messages, because + // otherwise we race between deciding we have no active sessions for the message + // and creating a new one, which we can only do once because it removes the OTK. + if (message.type !== 0) { + // not a prekey message: we can safely just try & decrypt it + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + } else { + const myPromise = this.olmDevice.olmPrekeyPromise.then(() => { + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + }); + // we want the error, but don't propagate it to the next decryption + this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {}); + return myPromise; + } + } + + private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> { + const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); + + // try each session in turn. + const decryptionErrors: Record<string, string> = {}; + for (const sessionId of sessionIds) { + try { + const payload = await this.olmDevice.decryptMessage( + theirDeviceIdentityKey, + sessionId, + message.type, + message.body, + ); + logger.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId); + return payload; + } catch (e) { + const foundSession = await this.olmDevice.matchesSession( + theirDeviceIdentityKey, + sessionId, + message.type, + message.body, + ); + + if (foundSession) { + // decryption failed, but it was a prekey message matching this + // session, so it should have worked. + throw new Error( + "Error decrypting prekey message with existing session id " + + sessionId + + ": " + + (<Error>e).message, + ); + } + + // otherwise it's probably a message for another session; carry on, but + // keep a record of the error + decryptionErrors[sessionId] = (<Error>e).message; + } + } + + if (message.type !== 0) { + // not a prekey message, so it should have matched an existing session, but it + // didn't work. + + if (sessionIds.length === 0) { + throw new Error("No existing sessions"); + } + + throw new Error( + "Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors), + ); + } + + // prekey message which doesn't match any existing sessions: make a new + // session. + + let res: IInboundSession; + try { + res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body); + } catch (e) { + decryptionErrors["(new)"] = (<Error>e).message; + throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors)); + } + + logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey); + return res.payload; + } +} + +registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts new file mode 100644 index 0000000..9e9ba52 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts @@ -0,0 +1,127 @@ +/* +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 { DeviceInfo } from "./deviceinfo"; +import { IKeyBackupInfo } from "./keybackup"; +import { PassphraseInfo } from "../secret-storage"; + +/* re-exports for backwards compatibility. */ +export { + PassphraseInfo as IPassphraseInfo, + SecretStorageKeyDescription as ISecretStorageKeyInfo, +} from "../secret-storage"; + +// TODO: Merge this with crypto.js once converted + +export enum CrossSigningKey { + Master = "master", + SelfSigning = "self_signing", + UserSigning = "user_signing", +} + +export interface IEncryptedEventInfo { + /** + * whether the event is encrypted (if not encrypted, some of the other properties may not be set) + */ + encrypted: boolean; + + /** + * the sender's key + */ + senderKey: string; + + /** + * the algorithm used to encrypt the event + */ + algorithm: string; + + /** + * whether we can be sure that the owner of the senderKey sent the event + */ + authenticated: boolean; + + /** + * the sender's device information, if available + */ + sender?: DeviceInfo; + + /** + * if the event's ed25519 and curve25519 keys don't match (only meaningful if `sender` is set) + */ + mismatchedSender: boolean; +} + +export interface IRecoveryKey { + keyInfo?: IAddSecretStorageKeyOpts; + privateKey: Uint8Array; + encodedPrivateKey?: string; +} + +export interface ICreateSecretStorageOpts { + /** + * Function called to await a secret storage key creation flow. + * @returns Promise resolving to an object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + */ + createSecretStorageKey?: () => Promise<IRecoveryKey>; + + /** + * The current key backup object. If passed, + * the passphrase and recovery key from this backup will be used. + */ + keyBackupInfo?: IKeyBackupInfo; + + /** + * If true, a new key backup version will be + * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo + * is supplied. + */ + setupNewKeyBackup?: boolean; + + /** + * Reset even if keys already exist. + */ + setupNewSecretStorage?: boolean; + + /** + * Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Uint8Array + * containing the key, or rejects if the key cannot be obtained. + */ + getKeyBackupPassphrase?: () => Promise<Uint8Array>; +} + +export interface IAddSecretStorageKeyOpts { + pubkey?: string; + passphrase?: PassphraseInfo; + name?: string; + key?: Uint8Array; +} + +export interface IImportOpts { + stage: string; // TODO: Enum + successes: number; + failures: number; + total: number; +} + +export interface IImportRoomKeysOpts { + /** called with an object that has a "stage" param */ + progressCallback?: (stage: IImportOpts) => void; + untrusted?: boolean; + source?: string; // TODO: Enum +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts new file mode 100644 index 0000000..d240bda --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts @@ -0,0 +1,813 @@ +/* +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. +*/ + +/** + * Classes for dealing with key backup. + */ + +import type { IMegolmSessionData } from "../@types/crypto"; +import { MatrixClient } from "../client"; +import { logger } from "../logger"; +import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib"; +import { DeviceInfo } from "./deviceinfo"; +import { DeviceTrustLevel } from "./CrossSigning"; +import { keyFromPassphrase } from "./key_passphrase"; +import { safeSet, sleep } from "../utils"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +import { encodeRecoveryKey } from "./recoverykey"; +import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes"; +import { + Curve25519SessionData, + IAes256AuthData, + ICurve25519AuthData, + IKeyBackupInfo, + IKeyBackupSession, +} from "./keybackup"; +import { UnstableValue } from "../NamespacedValue"; +import { CryptoEvent } from "./index"; +import { crypto } from "./crypto"; +import { HTTPError, MatrixError } from "../http-api"; + +const KEY_BACKUP_KEYS_PER_REQUEST = 200; +const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms + +type AuthData = IKeyBackupInfo["auth_data"]; + +type SigInfo = { + deviceId: string; + valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation + device?: DeviceInfo | null; + crossSigningId?: boolean; + deviceTrust?: DeviceTrustLevel; +}; + +export type TrustInfo = { + usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device + sigs: SigInfo[]; + // eslint-disable-next-line camelcase + trusted_locally?: boolean; +}; + +export interface IKeyBackupCheck { + backupInfo?: IKeyBackupInfo; + trustInfo: TrustInfo; +} + +/* eslint-disable camelcase */ +export interface IPreparedKeyBackupVersion { + algorithm: string; + auth_data: AuthData; + recovery_key: string; + privateKey: Uint8Array; +} +/* eslint-enable camelcase */ + +/** A function used to get the secret key for a backup. + */ +type GetKey = () => Promise<ArrayLike<number>>; + +interface BackupAlgorithmClass { + algorithmName: string; + // initialize from an existing backup + init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>; + + // prepare a brand new backup + prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]>; + + checkBackupVersion(info: IKeyBackupInfo): void; +} + +interface BackupAlgorithm { + untrusted: boolean; + encryptSession(data: Record<string, any>): Promise<Curve25519SessionData | IEncryptedPayload>; + decryptSessions(ciphertexts: Record<string, IKeyBackupSession>): Promise<IMegolmSessionData[]>; + authData: AuthData; + keyMatches(key: ArrayLike<number>): Promise<boolean>; + free(): void; +} + +export interface IKeyBackup { + rooms: { + [roomId: string]: { + sessions: { + [sessionId: string]: IKeyBackupSession; + }; + }; + }; +} + +/** + * Manages the key backup. + */ +export class BackupManager { + private algorithm: BackupAlgorithm | undefined; + public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version + public checkedForBackup: boolean; // Have we checked the server for a backup we can use? + private sendingBackups: boolean; // Are we currently sending backups? + private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id? + + public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { + this.checkedForBackup = false; + this.sendingBackups = false; + } + + public get version(): string | undefined { + return this.backupInfo && this.backupInfo.version; + } + + /** + * Performs a quick check to ensure that the backup info looks sane. + * + * Throws an error if a problem is detected. + * + * @param info - the key backup info + */ + public static checkBackupVersion(info: IKeyBackupInfo): void { + const Algorithm = algorithmsByName[info.algorithm]; + if (!Algorithm) { + throw new Error("Unknown backup algorithm: " + info.algorithm); + } + if (typeof info.auth_data !== "object") { + throw new Error("Invalid backup data returned"); + } + return Algorithm.checkBackupVersion(info); + } + + public static makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> { + const Algorithm = algorithmsByName[info.algorithm]; + if (!Algorithm) { + throw new Error("Unknown backup algorithm"); + } + return Algorithm.init(info.auth_data, getKey); + } + + public async enableKeyBackup(info: IKeyBackupInfo): Promise<void> { + this.backupInfo = info; + if (this.algorithm) { + this.algorithm.free(); + } + + this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); + + this.baseApis.emit(CryptoEvent.KeyBackupStatus, true); + + // There may be keys left over from a partially completed backup, so + // schedule a send to check. + this.scheduleKeyBackupSend(); + } + + /** + * Disable backing up of keys. + */ + public disableKeyBackup(): void { + if (this.algorithm) { + this.algorithm.free(); + } + this.algorithm = undefined; + + this.backupInfo = undefined; + + this.baseApis.emit(CryptoEvent.KeyBackupStatus, false); + } + + public getKeyBackupEnabled(): boolean | null { + if (!this.checkedForBackup) { + return null; + } + return Boolean(this.algorithm); + } + + public async prepareKeyBackupVersion( + key?: string | Uint8Array | null, + algorithm?: string | undefined, + ): Promise<IPreparedKeyBackupVersion> { + const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; + if (!Algorithm) { + throw new Error("Unknown backup algorithm"); + } + + const [privateKey, authData] = await Algorithm.prepare(key); + const recoveryKey = encodeRecoveryKey(privateKey)!; + return { + algorithm: Algorithm.algorithmName, + auth_data: authData, + recovery_key: recoveryKey, + privateKey, + }; + } + + public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<void> { + this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); + } + + /** + * Check the server for an active key backup and + * if one is present and has a valid signature from + * one of the user's verified devices, start backing up + * to it. + */ + public async checkAndStart(): Promise<IKeyBackupCheck | null> { + logger.log("Checking key backup status..."); + if (this.baseApis.isGuest()) { + logger.log("Skipping key backup check since user is guest"); + this.checkedForBackup = true; + return null; + } + let backupInfo: IKeyBackupInfo | undefined; + try { + backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined; + } catch (e) { + logger.log("Error checking for active key backup", e); + if ((<HTTPError>e).httpStatus === 404) { + // 404 is returned when the key backup does not exist, so that + // counts as successfully checking. + this.checkedForBackup = true; + } + return null; + } + this.checkedForBackup = true; + + const trustInfo = await this.isKeyBackupTrusted(backupInfo); + + if (trustInfo.usable && !this.backupInfo) { + logger.log(`Found usable key backup v${backupInfo!.version}: enabling key backups`); + await this.enableKeyBackup(backupInfo!); + } else if (!trustInfo.usable && this.backupInfo) { + logger.log("No usable key backup: disabling key backup"); + this.disableKeyBackup(); + } else if (!trustInfo.usable && !this.backupInfo) { + logger.log("No usable key backup: not enabling key backup"); + } else if (trustInfo.usable && this.backupInfo) { + // may not be the same version: if not, we should switch + if (backupInfo!.version !== this.backupInfo.version) { + logger.log( + `On backup version ${this.backupInfo.version} but ` + + `found version ${backupInfo!.version}: switching.`, + ); + this.disableKeyBackup(); + await this.enableKeyBackup(backupInfo!); + // We're now using a new backup, so schedule all the keys we have to be + // uploaded to the new backup. This is a bit of a workaround to upload + // keys to a new backup in *most* cases, but it won't cover all cases + // because we don't remember what backup version we uploaded keys to: + // see https://github.com/vector-im/element-web/issues/14833 + await this.scheduleAllGroupSessionsForBackup(); + } else { + logger.log(`Backup version ${backupInfo!.version} still current`); + } + } + + return { backupInfo, trustInfo }; + } + + /** + * Forces a re-check of the key backup and enables/disables it + * as appropriate. + * + * @returns Object with backup info (as returned by + * getKeyBackupVersion) in backupInfo and + * trust information (as returned by isKeyBackupTrusted) + * in trustInfo. + */ + public async checkKeyBackup(): Promise<IKeyBackupCheck | null> { + this.checkedForBackup = false; + return this.checkAndStart(); + } + + /** + * Attempts to retrieve a session from a key backup, if enough time + * has elapsed since the last check for this session id. + */ + public async queryKeyBackupRateLimited( + targetRoomId: string | undefined, + targetSessionId: string | undefined, + ): Promise<void> { + if (!this.backupInfo) { + return; + } + + const now = new Date().getTime(); + if ( + !this.sessionLastCheckAttemptedTime[targetSessionId!] || + now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT + ) { + this.sessionLastCheckAttemptedTime[targetSessionId!] = now; + await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {}); + } + } + + /** + * Check if the given backup info is trusted. + * + * @param backupInfo - key backup info dict from /room_keys/version + */ + public async isKeyBackupTrusted(backupInfo?: IKeyBackupInfo): Promise<TrustInfo> { + const ret = { + usable: false, + trusted_locally: false, + sigs: [] as SigInfo[], + }; + + if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) { + logger.info("Key backup is absent or missing required data"); + return ret; + } + + const userId = this.baseApis.getUserId()!; + const privKey = await this.baseApis.crypto!.getSessionBackupPrivateKey(); + if (privKey) { + let algorithm: BackupAlgorithm | null = null; + try { + algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey); + + if (await algorithm.keyMatches(privKey)) { + logger.info("Backup is trusted locally"); + ret.trusted_locally = true; + } + } catch { + // do nothing -- if we have an error, then we don't mark it as + // locally trusted + } finally { + algorithm?.free(); + } + } + + const mySigs = backupInfo.auth_data.signatures[userId] || {}; + + for (const keyId of Object.keys(mySigs)) { + const keyIdParts = keyId.split(":"); + if (keyIdParts[0] !== "ed25519") { + logger.log("Ignoring unknown signature type: " + keyIdParts[0]); + continue; + } + // Could be a cross-signing master key, but just say this is the device + // ID for backwards compat + const sigInfo: SigInfo = { deviceId: keyIdParts[1] }; + + // first check to see if it's from our cross-signing key + const crossSigningId = this.baseApis.crypto!.crossSigningInfo.getId(); + if (crossSigningId === sigInfo.deviceId) { + sigInfo.crossSigningId = true; + try { + await verifySignature( + this.baseApis.crypto!.olmDevice, + backupInfo.auth_data, + userId, + sigInfo.deviceId, + crossSigningId, + ); + sigInfo.valid = true; + } catch (e) { + logger.warn("Bad signature from cross signing key " + crossSigningId, e); + sigInfo.valid = false; + } + ret.sigs.push(sigInfo); + continue; + } + + // Now look for a sig from a device + // At some point this can probably go away and we'll just support + // it being signed by the cross-signing master key + const device = this.baseApis.crypto!.deviceList.getStoredDevice(userId, sigInfo.deviceId); + if (device) { + sigInfo.device = device; + sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(userId, sigInfo.deviceId); + try { + await verifySignature( + this.baseApis.crypto!.olmDevice, + backupInfo.auth_data, + userId, + device.deviceId, + device.getFingerprint(), + ); + sigInfo.valid = true; + } catch (e) { + logger.info( + "Bad signature from key ID " + + keyId + + " userID " + + this.baseApis.getUserId() + + " device ID " + + device.deviceId + + " fingerprint: " + + device.getFingerprint(), + backupInfo.auth_data, + e, + ); + sigInfo.valid = false; + } + } else { + sigInfo.valid = null; // Can't determine validity because we don't have the signing device + logger.info("Ignoring signature from unknown key " + keyId); + } + ret.sigs.push(sigInfo); + } + + ret.usable = ret.sigs.some((s) => { + return s.valid && ((s.device && s.deviceTrust?.isVerified()) || s.crossSigningId); + }); + return ret; + } + + /** + * Schedules sending all keys waiting to be sent to the backup, if not already + * scheduled. Retries if necessary. + * + * @param maxDelay - Maximum delay to wait in ms. 0 means no delay. + */ + public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> { + if (this.sendingBackups) return; + + this.sendingBackups = true; + + try { + // wait between 0 and `maxDelay` seconds, to avoid backup + // requests from different clients hitting the server all at + // the same time when a new key is sent + const delay = Math.random() * maxDelay; + await sleep(delay); + let numFailures = 0; // number of consecutive failures + for (;;) { + if (!this.algorithm) { + return; + } + try { + const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST); + if (numBackedUp === 0) { + // no sessions left needing backup: we're done + return; + } + numFailures = 0; + } catch (err) { + numFailures++; + logger.log("Key backup request failed", err); + if ((<MatrixError>err).data) { + if ( + (<MatrixError>err).data.errcode == "M_NOT_FOUND" || + (<MatrixError>err).data.errcode == "M_WRONG_ROOM_KEYS_VERSION" + ) { + // Re-check key backup status on error, so we can be + // sure to present the current situation when asked. + await this.checkKeyBackup(); + // Backup version has changed or this backup version + // has been deleted + this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, (<MatrixError>err).data.errcode!); + throw err; + } + } + } + if (numFailures) { + // exponential backoff if we have failures + await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); + } + } + } finally { + this.sendingBackups = false; + } + } + + /** + * Take some e2e keys waiting to be backed up and send them + * to the backup. + * + * @param limit - Maximum number of keys to back up + * @returns Number of sessions backed up + */ + public async backupPendingKeys(limit: number): Promise<number> { + const sessions = await this.baseApis.crypto!.cryptoStore.getSessionsNeedingBackup(limit); + if (!sessions.length) { + return 0; + } + + let remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); + this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); + + const rooms: IKeyBackup["rooms"] = {}; + for (const session of sessions) { + const roomId = session.sessionData!.room_id; + safeSet(rooms, roomId, rooms[roomId] || { sessions: {} }); + + const sessionData = this.baseApis.crypto!.olmDevice.exportInboundGroupSession( + session.senderKey, + session.sessionId, + session.sessionData!, + ); + sessionData.algorithm = MEGOLM_ALGORITHM; + + const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; + + const userId = this.baseApis.crypto!.deviceList.getUserByIdentityKey(MEGOLM_ALGORITHM, session.senderKey); + const device = + this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(MEGOLM_ALGORITHM, session.senderKey) ?? + undefined; + const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified(); + + safeSet(rooms[roomId]["sessions"], session.sessionId, { + first_message_index: sessionData.first_known_index, + forwarded_count: forwardedCount, + is_verified: verified, + session_data: await this.algorithm!.encryptSession(sessionData), + }); + } + + await this.baseApis.sendKeyBackup(undefined, undefined, this.backupInfo!.version, { rooms }); + + await this.baseApis.crypto!.cryptoStore.unmarkSessionsNeedingBackup(sessions); + remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); + this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); + + return sessions.length; + } + + public async backupGroupSession(senderKey: string, sessionId: string): Promise<void> { + await this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([ + { + senderKey: senderKey, + sessionId: sessionId, + }, + ]); + + if (this.backupInfo) { + // don't wait for this to complete: it will delay so + // happens in the background + this.scheduleKeyBackupSend(); + } + // if this.backupInfo is not set, then the keys will be backed up when + // this.enableKeyBackup is called + } + + /** + * Marks all group sessions as needing to be backed up and schedules them to + * upload in the background as soon as possible. + */ + public async scheduleAllGroupSessionsForBackup(): Promise<void> { + await this.flagAllGroupSessionsForBackup(); + + // Schedule keys to upload in the background as soon as possible. + this.scheduleKeyBackupSend(0 /* maxDelay */); + } + + /** + * Marks all group sessions as needing to be backed up without scheduling + * them to upload in the background. + * @returns Promise which resolves to the number of sessions now requiring a backup + * (which will be equal to the number of sessions in the store). + */ + public async flagAllGroupSessionsForBackup(): Promise<number> { + await this.baseApis.crypto!.cryptoStore.doTxn( + "readwrite", + [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP], + (txn) => { + this.baseApis.crypto!.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { + if (session !== null) { + this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([session], txn); + } + }); + }, + ); + + const remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); + this.baseApis.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); + return remaining; + } + + /** + * Counts the number of end to end session keys that are waiting to be backed up + * @returns Promise which resolves to the number of sessions requiring backup + */ + public countSessionsNeedingBackup(): Promise<number> { + return this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); + } +} + +export class Curve25519 implements BackupAlgorithm { + public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2"; + + public constructor( + public authData: ICurve25519AuthData, + private publicKey: any, // FIXME: PkEncryption + private getKey: () => Promise<Uint8Array>, + ) {} + + public static async init(authData: AuthData, getKey: () => Promise<Uint8Array>): Promise<Curve25519> { + if (!authData || !("public_key" in authData)) { + throw new Error("auth_data missing required information"); + } + const publicKey = new global.Olm.PkEncryption(); + publicKey.set_recipient_key(authData.public_key); + return new Curve25519(authData as ICurve25519AuthData, publicKey, getKey); + } + + public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { + const decryption = new global.Olm.PkDecryption(); + try { + const authData: Partial<ICurve25519AuthData> = {}; + if (!key) { + authData.public_key = decryption.generate_key(); + } else if (key instanceof Uint8Array) { + authData.public_key = decryption.init_with_private_key(key); + } else { + const derivation = await keyFromPassphrase(key); + authData.private_key_salt = derivation.salt; + authData.private_key_iterations = derivation.iterations; + authData.public_key = decryption.init_with_private_key(derivation.key); + } + const publicKey = new global.Olm.PkEncryption(); + publicKey.set_recipient_key(authData.public_key); + + return [decryption.get_private_key(), authData as AuthData]; + } finally { + decryption.free(); + } + } + + public static checkBackupVersion(info: IKeyBackupInfo): void { + if (!("public_key" in info.auth_data)) { + throw new Error("Invalid backup data returned"); + } + } + + public get untrusted(): boolean { + return true; + } + + public async encryptSession(data: Record<string, any>): Promise<Curve25519SessionData> { + const plainText: Record<string, any> = Object.assign({}, data); + delete plainText.session_id; + delete plainText.room_id; + delete plainText.first_known_index; + return this.publicKey.encrypt(JSON.stringify(plainText)); + } + + public async decryptSessions( + sessions: Record<string, IKeyBackupSession<Curve25519SessionData>>, + ): Promise<IMegolmSessionData[]> { + const privKey = await this.getKey(); + const decryption = new global.Olm.PkDecryption(); + try { + const backupPubKey = decryption.init_with_private_key(privKey); + + if (backupPubKey !== this.authData.public_key) { + throw new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }); + } + + const keys: IMegolmSessionData[] = []; + + for (const [sessionId, sessionData] of Object.entries(sessions)) { + try { + const decrypted = JSON.parse( + decryption.decrypt( + sessionData.session_data.ephemeral, + sessionData.session_data.mac, + sessionData.session_data.ciphertext, + ), + ); + decrypted.session_id = sessionId; + keys.push(decrypted); + } catch (e) { + logger.log("Failed to decrypt megolm session from backup", e, sessionData); + } + } + return keys; + } finally { + decryption.free(); + } + } + + public async keyMatches(key: Uint8Array): Promise<boolean> { + const decryption = new global.Olm.PkDecryption(); + let pubKey: string; + try { + pubKey = decryption.init_with_private_key(key); + } finally { + decryption.free(); + } + + return pubKey === this.authData.public_key; + } + + public free(): void { + this.publicKey.free(); + } +} + +function randomBytes(size: number): Uint8Array { + const buf = new Uint8Array(size); + crypto.getRandomValues(buf); + return buf; +} + +const UNSTABLE_MSC3270_NAME = new UnstableValue( + "m.megolm_backup.v1.aes-hmac-sha2", + "org.matrix.msc3270.v1.aes-hmac-sha2", +); + +export class Aes256 implements BackupAlgorithm { + public static algorithmName = UNSTABLE_MSC3270_NAME.name; + + public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {} + + public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> { + if (!authData) { + throw new Error("auth_data missing"); + } + const key = await getKey(); + if (authData.mac) { + const { mac } = await calculateKeyCheck(key, authData.iv); + if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) { + throw new Error("Key does not match"); + } + } + return new Aes256(authData, key); + } + + public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { + let outKey: Uint8Array; + const authData: Partial<IAes256AuthData> = {}; + if (!key) { + outKey = randomBytes(32); + } else if (key instanceof Uint8Array) { + outKey = new Uint8Array(key); + } else { + const derivation = await keyFromPassphrase(key); + authData.private_key_salt = derivation.salt; + authData.private_key_iterations = derivation.iterations; + outKey = derivation.key; + } + + const { iv, mac } = await calculateKeyCheck(outKey); + authData.iv = iv; + authData.mac = mac; + + return [outKey, authData as AuthData]; + } + + public static checkBackupVersion(info: IKeyBackupInfo): void { + if (!("iv" in info.auth_data && "mac" in info.auth_data)) { + throw new Error("Invalid backup data returned"); + } + } + + public get untrusted(): boolean { + return false; + } + + public encryptSession(data: Record<string, any>): Promise<IEncryptedPayload> { + const plainText: Record<string, any> = Object.assign({}, data); + delete plainText.session_id; + delete plainText.room_id; + delete plainText.first_known_index; + return encryptAES(JSON.stringify(plainText), this.key, data.session_id); + } + + public async decryptSessions( + sessions: Record<string, IKeyBackupSession<IEncryptedPayload>>, + ): Promise<IMegolmSessionData[]> { + const keys: IMegolmSessionData[] = []; + + for (const [sessionId, sessionData] of Object.entries(sessions)) { + try { + const decrypted = JSON.parse(await decryptAES(sessionData.session_data, this.key, sessionId)); + decrypted.session_id = sessionId; + keys.push(decrypted); + } catch (e) { + logger.log("Failed to decrypt megolm session from backup", e, sessionData); + } + } + return keys; + } + + public async keyMatches(key: Uint8Array): Promise<boolean> { + if (this.authData.mac) { + const { mac } = await calculateKeyCheck(key, this.authData.iv); + return this.authData.mac.replace(/=+$/g, "") === mac.replace(/=+/g, ""); + } else { + // if we have no information, we have to assume the key is right + return true; + } + } + + public free(): void { + this.key.fill(0); + } +} + +export const algorithmsByName: Record<string, BackupAlgorithmClass> = { + [Curve25519.algorithmName]: Curve25519, + [Aes256.algorithmName]: Aes256, +}; + +export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts new file mode 100644 index 0000000..704754f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts @@ -0,0 +1,50 @@ +/* +Copyright 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. +*/ + +import { logger } from "../logger"; + +export let crypto = global.window?.crypto; +export let subtleCrypto = global.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle; +export let TextEncoder = global.window?.TextEncoder; + +/* eslint-disable @typescript-eslint/no-var-requires */ +if (!crypto) { + try { + crypto = require("crypto").webcrypto; + } catch (e) { + logger.error("Failed to load webcrypto", e); + } +} +if (!subtleCrypto) { + subtleCrypto = crypto?.subtle; +} +if (!TextEncoder) { + try { + TextEncoder = require("util").TextEncoder; + } catch (e) { + logger.error("Failed to load TextEncoder util", e); + } +} +/* eslint-enable @typescript-eslint/no-var-requires */ + +export function setCrypto(_crypto: Crypto): void { + crypto = _crypto; + subtleCrypto = _crypto.subtle ?? _crypto.webkitSubtle; +} + +export function setTextEncoder(_TextEncoder: typeof TextEncoder): void { + TextEncoder = _TextEncoder; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts new file mode 100644 index 0000000..373b236 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts @@ -0,0 +1,271 @@ +/* +Copyright 2020-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 anotherjson from "another-json"; + +import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto"; +import { decodeBase64, encodeBase64 } from "./olmlib"; +import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store"; +import { decryptAES, encryptAES } from "./aes"; +import { logger } from "../logger"; +import { Crypto } from "./index"; +import { Method } from "../http-api"; +import { SecretStorageKeyDescription } from "../secret-storage"; + +export interface IDehydratedDevice { + device_id: string; // eslint-disable-line camelcase + device_data: SecretStorageKeyDescription & { + // eslint-disable-line camelcase + algorithm: string; + account: string; // pickle + }; +} + +export interface IDehydratedDeviceKeyInfo { + passphrase?: string; +} + +export const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; + +const oneweek = 7 * 24 * 60 * 60 * 1000; + +export class DehydrationManager { + private inProgress = false; + private timeoutId: any; + private key?: Uint8Array; + private keyInfo?: { [props: string]: any }; + private deviceDisplayName?: string; + + public constructor(private readonly crypto: Crypto) { + this.getDehydrationKeyFromCache(); + } + + public getDehydrationKeyFromCache(): Promise<void> { + return this.crypto.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.crypto.cryptoStore.getSecretStorePrivateKey( + txn, + async (result) => { + if (result) { + const { key, keyInfo, deviceDisplayName, time } = result; + const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); + const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); + this.key = decodeBase64(decrypted); + this.keyInfo = keyInfo; + this.deviceDisplayName = deviceDisplayName; + const now = Date.now(); + const delay = Math.max(1, time + oneweek - now); + this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), delay); + } + }, + "dehydration", + ); + }); + } + + /** set the key, and queue periodic dehydration to the server in the background */ + public async setKeyAndQueueDehydration( + key: Uint8Array, + keyInfo: { [props: string]: any } = {}, + deviceDisplayName?: string, + ): Promise<void> { + const matches = await this.setKey(key, keyInfo, deviceDisplayName); + if (!matches) { + // start dehydration in the background + this.dehydrateDevice(); + } + } + + public async setKey( + key: Uint8Array, + keyInfo: { [props: string]: any } = {}, + deviceDisplayName?: string, + ): Promise<boolean | undefined> { + if (!key) { + // unsetting the key -- cancel any pending dehydration task + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + // clear storage + await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", null); + }); + this.key = undefined; + this.keyInfo = undefined; + return; + } + + // Check to see if it's the same key as before. If it's different, + // dehydrate a new device. If it's the same, we can keep the same + // device. (Assume that keyInfo and deviceDisplayName will be the + // same if the key is the same.) + let matches: boolean = !!this.key && key.length == this.key.length; + for (let i = 0; matches && i < key.length; i++) { + if (key[i] != this.key![i]) { + matches = false; + } + } + if (!matches) { + this.key = key; + this.keyInfo = keyInfo; + this.deviceDisplayName = deviceDisplayName; + } + return matches; + } + + /** returns the device id of the newly created dehydrated device */ + public async dehydrateDevice(): Promise<string | undefined> { + if (this.inProgress) { + logger.log("Dehydration already in progress -- not starting new dehydration"); + return; + } + this.inProgress = true; + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + try { + const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); + + // update the crypto store with the timestamp + const key = await encryptAES(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM); + await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", { + keyInfo: this.keyInfo, + key, + deviceDisplayName: this.deviceDisplayName!, + time: Date.now(), + }); + }); + logger.log("Attempting to dehydrate device"); + + logger.log("Creating account"); + // create the account and all the necessary keys + const account = new global.Olm.Account(); + account.create(); + const e2eKeys = JSON.parse(account.identity_keys()); + + const maxKeys = account.max_number_of_one_time_keys(); + // FIXME: generate in small batches? + account.generate_one_time_keys(maxKeys / 2); + account.generate_fallback_key(); + const otks: Record<string, string> = JSON.parse(account.one_time_keys()); + const fallbacks: Record<string, string> = JSON.parse(account.fallback_key()); + account.mark_keys_as_published(); + + // dehydrate the account and store it on the server + const pickledAccount = account.pickle(new Uint8Array(this.key!)); + + const deviceData: { [props: string]: any } = { + algorithm: DEHYDRATION_ALGORITHM, + account: pickledAccount, + }; + if (this.keyInfo!.passphrase) { + deviceData.passphrase = this.keyInfo!.passphrase; + } + + logger.log("Uploading account to server"); + // eslint-disable-next-line camelcase + const dehydrateResult = await this.crypto.baseApis.http.authedRequest<{ device_id: string }>( + Method.Put, + "/dehydrated_device", + undefined, + { + device_data: deviceData, + initial_device_display_name: this.deviceDisplayName, + }, + { + prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", + }, + ); + + // send the keys to the server + const deviceId = dehydrateResult.device_id; + logger.log("Preparing device keys", deviceId); + const deviceKeys: IDeviceKeys = { + algorithms: this.crypto.supportedAlgorithms, + device_id: deviceId, + user_id: this.crypto.userId, + keys: { + [`ed25519:${deviceId}`]: e2eKeys.ed25519, + [`curve25519:${deviceId}`]: e2eKeys.curve25519, + }, + }; + const deviceSignature = account.sign(anotherjson.stringify(deviceKeys)); + deviceKeys.signatures = { + [this.crypto.userId]: { + [`ed25519:${deviceId}`]: deviceSignature, + }, + }; + if (this.crypto.crossSigningInfo.getId("self_signing")) { + await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing"); + } + + logger.log("Preparing one-time keys"); + const oneTimeKeys: Record<string, IOneTimeKey> = {}; + for (const [keyId, key] of Object.entries(otks.curve25519)) { + const k: IOneTimeKey = { key }; + const signature = account.sign(anotherjson.stringify(k)); + k.signatures = { + [this.crypto.userId]: { + [`ed25519:${deviceId}`]: signature, + }, + }; + oneTimeKeys[`signed_curve25519:${keyId}`] = k; + } + + logger.log("Preparing fallback keys"); + const fallbackKeys: Record<string, IOneTimeKey> = {}; + for (const [keyId, key] of Object.entries(fallbacks.curve25519)) { + const k: IOneTimeKey = { key, fallback: true }; + const signature = account.sign(anotherjson.stringify(k)); + k.signatures = { + [this.crypto.userId]: { + [`ed25519:${deviceId}`]: signature, + }, + }; + fallbackKeys[`signed_curve25519:${keyId}`] = k; + } + + logger.log("Uploading keys to server"); + await this.crypto.baseApis.http.authedRequest( + Method.Post, + "/keys/upload/" + encodeURI(deviceId), + undefined, + { + "device_keys": deviceKeys, + "one_time_keys": oneTimeKeys, + "org.matrix.msc2732.fallback_keys": fallbackKeys, + }, + ); + logger.log("Done dehydrating"); + + // dehydrate again in a week + this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), oneweek); + + return deviceId; + } finally { + this.inProgress = false; + } + } + + public stop(): void { + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts new file mode 100644 index 0000000..b4bb4fd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts @@ -0,0 +1,161 @@ +/* +Copyright 2016 - 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 { ISignatures } from "../@types/signed"; + +export interface IDevice { + keys: Record<string, string>; + algorithms: string[]; + verified: DeviceVerification; + known: boolean; + unsigned?: Record<string, any>; + signatures?: ISignatures; +} + +enum DeviceVerification { + Blocked = -1, + Unverified = 0, + Verified = 1, +} + +/** + * Information about a user's device + */ +export class DeviceInfo { + /** + * rehydrate a DeviceInfo from the session store + * + * @param obj - raw object from session store + * @param deviceId - id of the device + * + * @returns new DeviceInfo + */ + public static fromStorage(obj: Partial<IDevice>, deviceId: string): DeviceInfo { + const res = new DeviceInfo(deviceId); + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + // @ts-ignore - this is messy and typescript doesn't like it + res[prop as keyof IDevice] = obj[prop as keyof IDevice]; + } + } + return res; + } + + public static DeviceVerification = { + VERIFIED: DeviceVerification.Verified, + UNVERIFIED: DeviceVerification.Unverified, + BLOCKED: DeviceVerification.Blocked, + }; + + /** list of algorithms supported by this device */ + public algorithms: string[] = []; + /** a map from `<key type>:<id> -> <base64-encoded key>` */ + public keys: Record<string, string> = {}; + /** whether the device has been verified/blocked by the user */ + public verified = DeviceVerification.Unverified; + /** + * whether the user knows of this device's existence + * (useful when warning the user that a user has added new devices) + */ + public known = false; + /** additional data from the homeserver */ + public unsigned: Record<string, any> = {}; + public signatures: ISignatures = {}; + + /** + * @param deviceId - id of the device + */ + public constructor(public readonly deviceId: string) {} + + /** + * Prepare a DeviceInfo for JSON serialisation in the session store + * + * @returns deviceinfo with non-serialised members removed + */ + public toStorage(): IDevice { + return { + algorithms: this.algorithms, + keys: this.keys, + verified: this.verified, + known: this.known, + unsigned: this.unsigned, + signatures: this.signatures, + }; + } + + /** + * Get the fingerprint for this device (ie, the Ed25519 key) + * + * @returns base64-encoded fingerprint of this device + */ + public getFingerprint(): string { + return this.keys["ed25519:" + this.deviceId]; + } + + /** + * Get the identity key for this device (ie, the Curve25519 key) + * + * @returns base64-encoded identity key of this device + */ + public getIdentityKey(): string { + return this.keys["curve25519:" + this.deviceId]; + } + + /** + * Get the configured display name for this device, if any + * + * @returns displayname + */ + public getDisplayName(): string | null { + return this.unsigned.device_display_name || null; + } + + /** + * Returns true if this device is blocked + * + * @returns true if blocked + */ + public isBlocked(): boolean { + return this.verified == DeviceVerification.Blocked; + } + + /** + * Returns true if this device is verified + * + * @returns true if verified + */ + public isVerified(): boolean { + return this.verified == DeviceVerification.Verified; + } + + /** + * Returns true if this device is unverified + * + * @returns true if unverified + */ + public isUnverified(): boolean { + return this.verified == DeviceVerification.Unverified; + } + + /** + * Returns true if the user knows about this device's existence + * + * @returns true if known + */ + public isKnown(): boolean { + return this.known === true; + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts new file mode 100644 index 0000000..68df6ca --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts @@ -0,0 +1,3936 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector Ltd +Copyright 2019-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 anotherjson from "another-json"; +import { v4 as uuidv4 } from "uuid"; + +import type { IDeviceKeys, IEventDecryptionResult, IMegolmSessionData, IOneTimeKey } from "../@types/crypto"; +import type { PkDecryption, PkSigning } from "@matrix-org/olm"; +import { EventType, ToDeviceMessageId } from "../@types/event"; +import { TypedReEmitter } from "../ReEmitter"; +import { logger } from "../logger"; +import { IExportedDevice, OlmDevice } from "./OlmDevice"; +import { IOlmDevice } from "./algorithms/megolm"; +import * as olmlib from "./olmlib"; +import { DeviceInfoMap, DeviceList } from "./DeviceList"; +import { DeviceInfo, IDevice } from "./deviceinfo"; +import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms"; +import * as algorithms from "./algorithms"; +import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning"; +import { EncryptionSetupBuilder } from "./EncryptionSetup"; +import { + IAccountDataClient, + ISecretRequest, + SECRET_STORAGE_ALGORITHM_V1_AES, + SecretStorage, + SecretStorageKeyObject, + SecretStorageKeyTuple, +} from "./SecretStorage"; +import { + IAddSecretStorageKeyOpts, + ICreateSecretStorageOpts, + IEncryptedEventInfo, + IImportRoomKeysOpts, + IRecoveryKey, +} from "./api"; +import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +import { VerificationBase } from "./verification/Base"; +import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from "./verification/QRCode"; +import { SAS as SASVerification } from "./verification/SAS"; +import { keyFromPassphrase } from "./key_passphrase"; +import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey"; +import { VerificationRequest } from "./verification/request/VerificationRequest"; +import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; +import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel"; +import { IllegalMethod } from "./verification/IllegalMethod"; +import { KeySignatureUploadError } from "../errors"; +import { calculateKeyCheck, decryptAES, encryptAES } from "./aes"; +import { DehydrationManager } from "./dehydration"; +import { BackupManager } from "./backup"; +import { IStore } from "../store"; +import { Room, RoomEvent } from "../models/room"; +import { RoomMember, RoomMemberEvent } from "../models/room-member"; +import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event"; +import { ToDeviceBatch } from "../models/ToDeviceMessage"; +import { + ClientEvent, + ICrossSigningKey, + IKeysUploadResponse, + ISignedKey, + IUploadKeySignaturesResponse, + MatrixClient, +} from "../client"; +import type { IRoomEncryption, RoomList } from "./RoomList"; +import { IKeyBackupInfo } from "./keybackup"; +import { ISyncStateData } from "../sync"; +import { CryptoStore } from "./store/base"; +import { IVerificationChannel } from "./verification/request/Channel"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { IContent } from "../models/event"; +import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator"; +import { ISignatures } from "../@types/signed"; +import { IMessage } from "./algorithms/olm"; +import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend"; +import { RoomState, RoomStateEvent } from "../models/room-state"; +import { MapWithDefault, recursiveMapToObject } from "../utils"; +import { SecretStorageKeyDescription } from "../secret-storage"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +const defaultVerificationMethods = { + [ReciprocateQRCode.NAME]: ReciprocateQRCode, + [SASVerification.NAME]: SASVerification, + + // These two can't be used for actual verification, but we do + // need to be able to define them here for the verification flows + // to start. + [SHOW_QR_CODE_METHOD]: IllegalMethod, + [SCAN_QR_CODE_METHOD]: IllegalMethod, +} as const; + +/** + * verification method names + */ +// legacy export identifier +export const verificationMethods = { + RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, + SAS: SASVerification.NAME, +} as const; + +export type VerificationMethod = keyof typeof verificationMethods | string; + +export function isCryptoAvailable(): boolean { + return Boolean(global.Olm); +} + +const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; + +interface IInitOpts { + exportedOlmDevice?: IExportedDevice; + pickleKey?: string; +} + +export interface IBootstrapCrossSigningOpts { + /** Optional. Reset even if keys already exist. */ + setupNewCrossSigning?: boolean; + /** + * A function that makes the request requiring auth. Receives the auth data as an object. + * Can be called multiple times, first with an empty authDict, to obtain the flows. + */ + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>; +} + +export interface ICryptoCallbacks { + getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>; + saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void; + shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>; + getSecretStorageKey?: ( + keys: { keys: Record<string, SecretStorageKeyDescription> }, + name: string, + ) => Promise<[string, Uint8Array] | null>; + cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; + onSecretRequested?: ( + userId: string, + deviceId: string, + requestId: string, + secretName: string, + deviceTrust: DeviceTrustLevel, + ) => Promise<string | undefined>; + getDehydrationKey?: ( + keyInfo: SecretStorageKeyDescription, + checkFunc: (key: Uint8Array) => void, + ) => Promise<Uint8Array>; + getBackupKey?: () => Promise<Uint8Array>; +} + +/* eslint-disable camelcase */ +interface IRoomKey { + room_id: string; + algorithm: string; +} + +/** + * The parameters of a room key request. The details of the request may + * vary with the crypto algorithm, but the management and storage layers for + * outgoing requests expect it to have 'room_id' and 'session_id' properties. + */ +export interface IRoomKeyRequestBody extends IRoomKey { + session_id: string; + sender_key: string; +} + +/* eslint-enable camelcase */ + +interface IDeviceVerificationUpgrade { + devices: DeviceInfo[]; + crossSigningInfo: CrossSigningInfo; +} + +export interface ICheckOwnCrossSigningTrustOpts { + allowPrivateKeyRequests?: boolean; +} + +interface IUserOlmSession { + deviceIdKey: string; + sessions: { + sessionId: string; + hasReceivedMessage: boolean; + }[]; +} + +export interface IRoomKeyRequestRecipient { + userId: string; + deviceId: string; +} + +interface ISignableObject { + signatures?: ISignatures; + unsigned?: object; +} + +export interface IRequestsMap { + getRequest(event: MatrixEvent): VerificationRequest | undefined; + getRequestByChannel(channel: IVerificationChannel): VerificationRequest | undefined; + setRequest(event: MatrixEvent, request: VerificationRequest): void; + setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void; +} + +/* eslint-disable camelcase */ +export interface IOlmEncryptedContent { + algorithm: typeof olmlib.OLM_ALGORITHM; + sender_key: string; + ciphertext: Record<string, IMessage>; + [ToDeviceMessageId]?: string; +} + +export interface IMegolmEncryptedContent { + algorithm: typeof olmlib.MEGOLM_ALGORITHM; + sender_key: string; + session_id: string; + device_id: string; + ciphertext: string; + [ToDeviceMessageId]?: string; +} +/* eslint-enable camelcase */ + +export type IEncryptedContent = IOlmEncryptedContent | IMegolmEncryptedContent; + +export enum CryptoEvent { + DeviceVerificationChanged = "deviceVerificationChanged", + UserTrustStatusChanged = "userTrustStatusChanged", + UserCrossSigningUpdated = "userCrossSigningUpdated", + RoomKeyRequest = "crypto.roomKeyRequest", + RoomKeyRequestCancellation = "crypto.roomKeyRequestCancellation", + KeyBackupStatus = "crypto.keyBackupStatus", + KeyBackupFailed = "crypto.keyBackupFailed", + KeyBackupSessionsRemaining = "crypto.keyBackupSessionsRemaining", + KeySignatureUploadFailure = "crypto.keySignatureUploadFailure", + VerificationRequest = "crypto.verification.request", + Warning = "crypto.warning", + WillUpdateDevices = "crypto.willUpdateDevices", + DevicesUpdated = "crypto.devicesUpdated", + KeysChanged = "crossSigning.keysChanged", +} + +export type CryptoEventHandlerMap = { + /** + * Fires when a device is marked as verified/unverified/blocked/unblocked by + * {@link MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or + * {@link MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}. + * + * @param userId - the owner of the verified device + * @param deviceId - the id of the verified device + * @param deviceInfo - updated device information + */ + [CryptoEvent.DeviceVerificationChanged]: (userId: string, deviceId: string, device: DeviceInfo) => void; + /** + * Fires when the trust status of a user changes + * If userId is the userId of the logged-in user, this indicated a change + * in the trust status of the cross-signing data on the account. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * @experimental + * + * @param userId - the userId of the user in question + * @param trustLevel - The new trust level of the user + */ + [CryptoEvent.UserTrustStatusChanged]: (userId: string, trustLevel: UserTrustLevel) => void; + /** + * Fires when we receive a room key request + * + * @param req - request details + */ + [CryptoEvent.RoomKeyRequest]: (request: IncomingRoomKeyRequest) => void; + /** + * Fires when we receive a room key request cancellation + */ + [CryptoEvent.RoomKeyRequestCancellation]: (request: IncomingRoomKeyRequestCancellation) => void; + /** + * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled() + * @param enabled - true if key backup has been enabled, otherwise false + * @example + * ``` + * matrixClient.on("crypto.keyBackupStatus", function(enabled){ + * if (enabled) { + * [...] + * } + * }); + * ``` + */ + [CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void; + [CryptoEvent.KeyBackupFailed]: (errcode: string) => void; + [CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void; + [CryptoEvent.KeySignatureUploadFailure]: ( + failures: IUploadKeySignaturesResponse["failures"], + source: "checkOwnCrossSigningTrust" | "afterCrossSigningLocalKeyChange" | "setDeviceVerification", + upload: (opts: { shouldEmit: boolean }) => Promise<void>, + ) => void; + /** + * Fires when a key verification is requested. + */ + [CryptoEvent.VerificationRequest]: (request: VerificationRequest<any>) => void; + /** + * Fires when the app may wish to warn the user about something related + * the end-to-end crypto. + * + * @param type - One of the strings listed above + */ + [CryptoEvent.Warning]: (type: string) => void; + /** + * Fires when the user's cross-signing keys have changed or cross-signing + * has been enabled/disabled. The client can use getStoredCrossSigningForUser + * with the user ID of the logged in user to check if cross-signing is + * enabled on the account. If enabled, it can test whether the current key + * is trusted using with checkUserTrust with the user ID of the logged + * in user. The checkOwnCrossSigningTrust function may be used to reconcile + * the trust in the account key. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * @experimental + */ + [CryptoEvent.KeysChanged]: (data: {}) => void; + /** + * Fires whenever the stored devices for a user will be updated + * @param users - A list of user IDs that will be updated + * @param initialFetch - If true, the store is empty (apart + * from our own device) and is being seeded. + */ + [CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void; + /** + * Fires whenever the stored devices for a user have changed + * @param users - A list of user IDs that were updated + * @param initialFetch - If true, the store was empty (apart + * from our own device) and has been seeded. + */ + [CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void; + [CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void; +}; + +export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap> implements CryptoBackend { + /** + * @returns The version of Olm. + */ + public static getOlmVersion(): [number, number, number] { + return OlmDevice.getOlmVersion(); + } + + public readonly backupManager: BackupManager; + public readonly crossSigningInfo: CrossSigningInfo; + public readonly olmDevice: OlmDevice; + public readonly deviceList: DeviceList; + public readonly dehydrationManager: DehydrationManager; + public readonly secretStorage: SecretStorage; + + private readonly reEmitter: TypedReEmitter<CryptoEvent, CryptoEventHandlerMap>; + private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>; + public readonly supportedAlgorithms: string[]; + private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; + private readonly toDeviceVerificationRequests: ToDeviceRequests; + public readonly inRoomVerificationRequests: InRoomRequests; + + private trustCrossSignedDevices = true; + // the last time we did a check for the number of one-time-keys on the server. + private lastOneTimeKeyCheck: number | null = null; + private oneTimeKeyCheckInProgress = false; + + // EncryptionAlgorithm instance for each room + private roomEncryptors = new Map<string, EncryptionAlgorithm>(); + // map from algorithm to DecryptionAlgorithm instance, for each room + private roomDecryptors = new Map<string, Map<string, DecryptionAlgorithm>>(); + + private deviceKeys: Record<string, string> = {}; // type: key + + public globalBlacklistUnverifiedDevices = false; + public globalErrorOnUnknownDevices = true; + + // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations + // we received in the current sync. + private receivedRoomKeyRequests: IncomingRoomKeyRequest[] = []; + private receivedRoomKeyRequestCancellations: IncomingRoomKeyRequestCancellation[] = []; + // true if we are currently processing received room key requests + private processingRoomKeyRequests = false; + // controls whether device tracking is delayed + // until calling encryptEvent or trackRoomDevices, + // or done immediately upon enabling room encryption. + private lazyLoadMembers = false; + // in case lazyLoadMembers is true, + // track if an initial tracking of all the room members + // has happened for a given room. This is delayed + // to avoid loading room members as long as possible. + private roomDeviceTrackingState: { [roomId: string]: Promise<void> } = {}; + + // The timestamp of the last time we forced establishment + // of a new session for each device, in milliseconds. + // { + // userId: { + // deviceId: 1234567890000, + // }, + // } + // Map: user Id → device Id → timestamp + private lastNewSessionForced: MapWithDefault<string, MapWithDefault<string, number>> = new MapWithDefault( + () => new MapWithDefault(() => 0), + ); + + // This flag will be unset whilst the client processes a sync response + // so that we don't start requesting keys until we've actually finished + // processing the response. + private sendKeyRequestsImmediately = false; + + private oneTimeKeyCount?: number; + private needsNewFallback?: boolean; + private fallbackCleanup?: ReturnType<typeof setTimeout>; + + /** + * Cryptography bits + * + * This module is internal to the js-sdk; the public API is via MatrixClient. + * + * @internal + * + * @param baseApis - base matrix api interface + * + * @param userId - The user ID for the local user + * + * @param deviceId - The identifier for this device. + * + * @param clientStore - the MatrixClient data store. + * + * @param cryptoStore - storage for the crypto layer. + * + * @param roomList - An initialised RoomList object + * + * @param verificationMethods - Array of verification methods to use. + * Each element can either be a string from MatrixClient.verificationMethods + * or a class that implements a verification method. + */ + public constructor( + public readonly baseApis: MatrixClient, + public readonly userId: string, + private readonly deviceId: string, + private readonly clientStore: IStore, + public readonly cryptoStore: CryptoStore, + private readonly roomList: RoomList, + verificationMethods: Array<VerificationMethod | (typeof VerificationBase & { NAME: string })>, + ) { + super(); + this.reEmitter = new TypedReEmitter(this); + + if (verificationMethods) { + this.verificationMethods = new Map(); + for (const method of verificationMethods) { + if (typeof method === "string") { + if (defaultVerificationMethods[method]) { + this.verificationMethods.set( + method, + <typeof VerificationBase>defaultVerificationMethods[method], + ); + } + } else if (method["NAME"]) { + this.verificationMethods.set(method["NAME"], method as typeof VerificationBase); + } else { + logger.warn(`Excluding unknown verification method ${method}`); + } + } + } else { + this.verificationMethods = new Map(Object.entries(defaultVerificationMethods)) as Map< + VerificationMethod, + typeof VerificationBase + >; + } + + this.backupManager = new BackupManager(baseApis, async () => { + // try to get key from cache + const cachedKey = await this.getSessionBackupPrivateKey(); + if (cachedKey) { + return cachedKey; + } + + // try to get key from secret storage + const storedKey = await this.getSecret("m.megolm_backup.v1"); + + if (storedKey) { + // ensure that the key is in the right format. If not, fix the key and + // store the fixed version + const fixedKey = fixBackupKey(storedKey); + if (fixedKey) { + const keys = await this.getSecretStorageKey(); + await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]); + } + + return olmlib.decodeBase64(fixedKey || storedKey); + } + + // try to get key from app + if (this.baseApis.cryptoCallbacks && this.baseApis.cryptoCallbacks.getBackupKey) { + return this.baseApis.cryptoCallbacks.getBackupKey(); + } + + throw new Error("Unable to get private key"); + }); + + this.olmDevice = new OlmDevice(cryptoStore); + this.deviceList = new DeviceList(baseApis, cryptoStore, this.olmDevice); + + // XXX: This isn't removed at any point, but then none of the event listeners + // this class sets seem to be removed at any point... :/ + this.deviceList.on(CryptoEvent.UserCrossSigningUpdated, this.onDeviceListUserCrossSigningUpdated); + this.reEmitter.reEmit(this.deviceList, [CryptoEvent.DevicesUpdated, CryptoEvent.WillUpdateDevices]); + + this.supportedAlgorithms = Array.from(algorithms.DECRYPTION_CLASSES.keys()); + + this.outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( + baseApis, + this.deviceId, + this.cryptoStore, + ); + + this.toDeviceVerificationRequests = new ToDeviceRequests(); + this.inRoomVerificationRequests = new InRoomRequests(); + + const cryptoCallbacks = this.baseApis.cryptoCallbacks || {}; + const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this.olmDevice); + + this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks); + // Yes, we pass the client twice here: see SecretStorage + this.secretStorage = new SecretStorage(baseApis as IAccountDataClient, cryptoCallbacks, baseApis); + this.dehydrationManager = new DehydrationManager(this); + + // Assuming no app-supplied callback, default to getting from SSSS. + if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { + cryptoCallbacks.getCrossSigningKey = async (type): Promise<Uint8Array | null> => { + return CrossSigningInfo.getFromSecretStorage(type, this.secretStorage); + }; + } + } + + /** + * Initialise the crypto module so that it is ready for use + * + * Returns a promise which resolves once the crypto module is ready for use. + * + * @param exportedOlmDevice - (Optional) data from exported device + * that must be re-created. + */ + public async init({ exportedOlmDevice, pickleKey }: IInitOpts = {}): Promise<void> { + logger.log("Crypto: initialising Olm..."); + await global.Olm.init(); + logger.log( + exportedOlmDevice + ? "Crypto: initialising Olm device from exported device..." + : "Crypto: initialising Olm device...", + ); + await this.olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); + logger.log("Crypto: loading device list..."); + await this.deviceList.load(); + + // build our device keys: these will later be uploaded + this.deviceKeys["ed25519:" + this.deviceId] = this.olmDevice.deviceEd25519Key!; + this.deviceKeys["curve25519:" + this.deviceId] = this.olmDevice.deviceCurve25519Key!; + + logger.log("Crypto: fetching own devices..."); + let myDevices = this.deviceList.getRawStoredDevicesForUser(this.userId); + + if (!myDevices) { + myDevices = {}; + } + + if (!myDevices[this.deviceId]) { + // add our own deviceinfo to the cryptoStore + logger.log("Crypto: adding this device to the store..."); + const deviceInfo = { + keys: this.deviceKeys, + algorithms: this.supportedAlgorithms, + verified: DeviceVerification.VERIFIED, + known: true, + }; + + myDevices[this.deviceId] = deviceInfo; + this.deviceList.storeDevicesForUser(this.userId, myDevices); + this.deviceList.saveIfDirty(); + } + + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.getCrossSigningKeys(txn, (keys) => { + // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys + if (keys && Object.keys(keys).length !== 0) { + logger.log("Loaded cross-signing public keys from crypto store"); + this.crossSigningInfo.setKeys(keys); + } + }); + }); + // make sure we are keeping track of our own devices + // (this is important for key backups & things) + this.deviceList.startTrackingDeviceList(this.userId); + + logger.log("Crypto: checking for key backup..."); + this.backupManager.checkAndStart(); + } + + /** + * Whether to trust a others users signatures of their devices. + * If false, devices will only be considered 'verified' if we have + * verified that device individually (effectively disabling cross-signing). + * + * Default: true + * + * @returns True if trusting cross-signed devices + */ + public getCryptoTrustCrossSignedDevices(): boolean { + return this.trustCrossSignedDevices; + } + + /** + * See getCryptoTrustCrossSignedDevices + + * This may be set before initCrypto() is called to ensure no races occur. + * + * @param val - True to trust cross-signed devices + */ + public setCryptoTrustCrossSignedDevices(val: boolean): void { + this.trustCrossSignedDevices = val; + + for (const userId of this.deviceList.getKnownUserIds()) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + for (const deviceId of Object.keys(devices)) { + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + // If the device is locally verified then isVerified() is always true, + // so this will only have caused the value to change if the device is + // cross-signing verified but not locally verified + if (!deviceTrust.isLocallyVerified() && deviceTrust.isCrossSigningVerified()) { + const deviceObj = this.deviceList.getStoredDevice(userId, deviceId)!; + this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); + } + } + } + } + + /** + * Create a recovery key from a user-supplied passphrase. + * + * @param password - Passphrase string that can be entered by the user + * when restoring the backup as an alternative to entering the recovery key. + * Optional. + * @returns Object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + */ + public async createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> { + const decryption = new global.Olm.PkDecryption(); + try { + const keyInfo: Partial<IRecoveryKey["keyInfo"]> = {}; + if (password) { + const derivation = await keyFromPassphrase(password); + keyInfo.passphrase = { + algorithm: "m.pbkdf2", + iterations: derivation.iterations, + salt: derivation.salt, + }; + keyInfo.pubkey = decryption.init_with_private_key(derivation.key); + } else { + keyInfo.pubkey = decryption.generate_key(); + } + const privateKey = decryption.get_private_key(); + const encodedPrivateKey = encodeRecoveryKey(privateKey); + return { + keyInfo: keyInfo as IRecoveryKey["keyInfo"], + encodedPrivateKey, + privateKey, + }; + } finally { + decryption?.free(); + } + } + + /** + * Checks if the user has previously published cross-signing keys + * + * This means downloading the devicelist for the user and checking if the list includes + * the cross-signing pseudo-device. + * + * @internal + */ + public async userHasCrossSigningKeys(): Promise<boolean> { + await this.downloadKeys([this.userId]); + return this.deviceList.getStoredCrossSigningForUser(this.userId) !== null; + } + + /** + * Checks whether cross signing: + * - is enabled on this account and trusted by this device + * - has private keys either cached locally or stored in secret storage + * + * If this function returns false, bootstrapCrossSigning() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapCrossSigning() completes successfully, this function should + * return true. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @returns True if cross-signing is ready to be used on this device + */ + public async isCrossSigningReady(): Promise<boolean> { + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysExistSomewhere = + (await this.crossSigningInfo.isStoredInKeyCache()) || + (await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage)); + + return !!(publicKeysOnDevice && privateKeysExistSomewhere); + } + + /** + * Checks whether secret storage: + * - is enabled on this account + * - is storing cross-signing private keys + * - is storing session backup key (if enabled) + * + * If this function returns false, bootstrapSecretStorage() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapSecretStorage() completes successfully, this function should + * return true. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @returns True if secret storage is ready to be used on this device + */ + public async isSecretStorageReady(): Promise<boolean> { + const secretStorageKeyInAccount = await this.secretStorage.hasKey(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage); + const sessionBackupInStorage = + !this.backupManager.getKeyBackupEnabled() || (await this.baseApis.isKeyBackupKeyStored()); + + return !!(secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage); + } + + /** + * Bootstrap cross-signing by creating keys if needed. If everything is already + * set up, then no changes are made, so this is safe to run to ensure + * cross-signing is ready for use. + * + * This function: + * - creates new cross-signing keys if they are not found locally cached nor in + * secret storage (if it has been setup) + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param authUploadDeviceSigningKeys - Function + * called to await an interactive auth flow when uploading device signing keys. + * @param setupNewCrossSigning - Optional. Reset even if keys + * already exist. + * Args: + * A function that makes the request requiring auth. Receives the + * auth data as an object. Can be called multiple times, first with an empty + * authDict, to obtain the flows. + */ + public async bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + setupNewCrossSigning, + }: IBootstrapCrossSigningOpts = {}): Promise<void> { + logger.log("Bootstrapping cross-signing"); + + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); + const crossSigningInfo = new CrossSigningInfo( + this.userId, + builder.crossSigningCallbacks, + builder.crossSigningCallbacks, + ); + + // Reset the cross-signing keys + const resetCrossSigning = async (): Promise<void> => { + crossSigningInfo.resetKeys(); + // Sign master key with device key + await this.signObject(crossSigningInfo.keys.master); + + // Store auth flow helper function, as we need to call it when uploading + // to ensure we handle auth errors properly. + builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); + + // Cross-sign own device + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; + const deviceSignature = await crossSigningInfo.signDevice(this.userId, device); + builder.addKeySignature(this.userId, this.deviceId, deviceSignature!); + + // Sign message key backup with cross-signing master key + if (this.backupManager.backupInfo) { + await crossSigningInfo.signObject(this.backupManager.backupInfo.auth_data, "master"); + builder.addSessionBackup(this.backupManager.backupInfo); + } + }; + + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysInCache = await this.crossSigningInfo.isStoredInKeyCache(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage); + const privateKeysExistSomewhere = privateKeysInCache || privateKeysInStorage; + + // Log all relevant state for easier parsing of debug logs. + logger.log({ + setupNewCrossSigning, + publicKeysOnDevice, + privateKeysInCache, + privateKeysInStorage, + privateKeysExistSomewhere, + }); + + if (!privateKeysExistSomewhere || setupNewCrossSigning) { + logger.log("Cross-signing private keys not found locally or in secret storage, " + "creating new keys"); + // If a user has multiple devices, it important to only call bootstrap + // as part of some UI flow (and not silently during startup), as they + // may have setup cross-signing on a platform which has not saved keys + // to secret storage, and this would reset them. In such a case, you + // should prompt the user to verify any existing devices first (and + // request private keys from those devices) before calling bootstrap. + await resetCrossSigning(); + } else if (publicKeysOnDevice && privateKeysInCache) { + logger.log("Cross-signing public keys trusted and private keys found locally"); + } else if (privateKeysInStorage) { + logger.log( + "Cross-signing private keys not found locally, but they are available " + + "in secret storage, reading storage and caching locally", + ); + await this.checkOwnCrossSigningTrust({ + allowPrivateKeyRequests: true, + }); + } + + // Assuming no app-supplied callback, default to storing new private keys in + // secret storage if it exists. If it does not, it is assumed this will be + // done as part of setting up secret storage later. + const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; + if (crossSigningPrivateKeys.size && !this.baseApis.cryptoCallbacks.saveCrossSigningKeys) { + const secretStorage = new SecretStorage( + builder.accountDataClientAdapter, + builder.ssssCryptoCallbacks, + undefined, + ); + if (await secretStorage.hasKey()) { + logger.log("Storing new cross-signing private keys in secret storage"); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); + } + } + + const operation = builder.buildOperation(); + await operation.apply(this); + // This persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + + logger.log("Cross-signing ready"); + } + + /** + * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is + * already set up, then no changes are made, so this is safe to run to ensure secret + * storage is ready for use. + * + * This function + * - creates a new Secure Secret Storage key if no default key exists + * - if a key backup exists, it is migrated to store the key in the Secret + * Storage + * - creates a backup if none exists, and one is requested + * - migrates Secure Secret Storage to use the latest algorithm, if an outdated + * algorithm is found + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param createSecretStorageKey - Optional. Function + * called to await a secret storage key creation flow. + * Returns a Promise which resolves to an object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + * @param keyBackupInfo - The current key backup object. If passed, + * the passphrase and recovery key from this backup will be used. + * @param setupNewKeyBackup - If true, a new key backup version will be + * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo + * is supplied. + * @param setupNewSecretStorage - Optional. Reset even if keys already exist. + * @param getKeyBackupPassphrase - Optional. Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Buffer + * containing the key, or rejects if the key cannot be obtained. + * Returns: + * A promise which resolves to key creation data for + * SecretStorage#addKey: an object with `passphrase` etc fields. + */ + // TODO this does not resolve with what it says it does + public async bootstrapSecretStorage({ + createSecretStorageKey = async (): Promise<IRecoveryKey> => ({} as IRecoveryKey), + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + getKeyBackupPassphrase, + }: ICreateSecretStorageOpts = {}): Promise<void> { + logger.log("Bootstrapping Secure Secret Storage"); + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); + const secretStorage = new SecretStorage( + builder.accountDataClientAdapter, + builder.ssssCryptoCallbacks, + undefined, + ); + + // the ID of the new SSSS key, if we create one + let newKeyId: string | null = null; + + // create a new SSSS key and set it as default + const createSSSS = async (opts: IAddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => { + if (privateKey) { + opts.key = privateKey; + } + + const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts); + + if (privateKey) { + // make the private key available to encrypt 4S secrets + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + } + + await secretStorage.setDefaultKeyId(keyId); + return keyId; + }; + + const ensureCanCheckPassphrase = async (keyId: string, keyInfo: SecretStorageKeyDescription): Promise<void> => { + if (!keyInfo.mac) { + const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey?.( + { keys: { [keyId]: keyInfo } }, + "", + ); + if (key) { + const privateKey = key[1]; + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + const { iv, mac } = await calculateKeyCheck(privateKey); + keyInfo.iv = iv; + keyInfo.mac = mac; + + await builder.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo); + } + } + }; + + const signKeyBackupWithCrossSigning = async (keyBackupAuthData: IKeyBackupInfo["auth_data"]): Promise<void> => { + if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) { + try { + logger.log("Adding cross-signing signature to key backup"); + await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); + } catch (e) { + // This step is not critical (just helpful), so we catch here + // and continue if it fails. + logger.error("Signing key backup with cross-signing keys failed", e); + } + } else { + logger.warn("Cross-signing keys not available, skipping signature on key backup"); + } + }; + + const oldSSSSKey = await this.getSecretStorageKey(); + const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; + const storageExists = + !setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES; + + // Log all relevant state for easier parsing of debug logs. + logger.log({ + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + storageExists, + oldKeyInfo, + }); + + if (!storageExists && !keyBackupInfo) { + // either we don't have anything, or we've been asked to restart + // from scratch + logger.log("Secret storage does not exist, creating new storage key"); + + // if we already have a usable default SSSS key and aren't resetting + // SSSS just use it. otherwise, create a new one + // Note: we leave the old SSSS key in place: there could be other + // secrets using it, in theory. We could move them to the new key but a) + // that would mean we'd need to prompt for the old passphrase, and b) + // it's not clear that would be the right thing to do anyway. + const { keyInfo = {} as IAddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey(); + newKeyId = await createSSSS(keyInfo, privateKey); + } else if (!storageExists && keyBackupInfo) { + // we have an existing backup, but no SSSS + logger.log("Secret storage does not exist, using key backup key"); + + // if we have the backup key already cached, use it; otherwise use the + // callback to prompt for the key + const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.()); + + // create a new SSSS key and use the backup key as the new SSSS key + const opts = {} as IAddSecretStorageKeyOpts; + + if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) { + // FIXME: ??? + opts.passphrase = { + algorithm: "m.pbkdf2", + iterations: keyBackupInfo.auth_data.private_key_iterations, + salt: keyBackupInfo.auth_data.private_key_salt, + bits: 256, + }; + } + + newKeyId = await createSSSS(opts, backupKey); + + // store the backup key in secret storage + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey!), [newKeyId]); + + // The backup is trusted because the user provided the private key. + // Sign the backup with the cross-signing key so the key backup can + // be trusted via cross-signing. + await signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); + + builder.addSessionBackup(keyBackupInfo); + } else { + // 4S is already set up + logger.log("Secret storage exists"); + + if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + // make sure that the default key has the information needed to + // check the passphrase + await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); + } + } + + // If we have cross-signing private keys cached, store them in secret + // storage if they are not there already. + if ( + !this.baseApis.cryptoCallbacks.saveCrossSigningKeys && + (await this.isCrossSigningReady()) && + (newKeyId || !(await this.crossSigningInfo.isStoredInSecretStorage(secretStorage))) + ) { + logger.log("Copying cross-signing private keys from cache to secret storage"); + const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); + } + + if (setupNewKeyBackup && !keyBackupInfo) { + logger.log("Creating new message key backup version"); + const info = await this.baseApis.prepareKeyBackupVersion( + null /* random key */, + // don't write to secret storage, as it will write to this.secretStorage. + // Here, we want to capture all the side-effects of bootstrapping, + // and want to write to the local secretStorage object + { secureSecretStorage: false }, + ); + // write the key ourselves to 4S + const privateKey = decodeRecoveryKey(info.recovery_key); + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); + + // create keyBackupInfo object to add to builder + const data: IKeyBackupInfo = { + algorithm: info.algorithm, + auth_data: info.auth_data, + }; + + // Sign with cross-signing master key + await signKeyBackupWithCrossSigning(data.auth_data); + + // sign with the device fingerprint + await this.signObject(data.auth_data); + + builder.addSessionBackup(data); + } + + // Cache the session backup key + const sessionBackupKey = await secretStorage.get("m.megolm_backup.v1"); + if (sessionBackupKey) { + logger.info("Got session backup key from secret storage: caching"); + // fix up the backup key if it's in the wrong format, and replace + // in secret storage + const fixedBackupKey = fixBackupKey(sessionBackupKey); + if (fixedBackupKey) { + const keyId = newKeyId || oldKeyId; + await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, keyId ? [keyId] : null); + } + const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(fixedBackupKey || sessionBackupKey)); + builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); + } else if (this.backupManager.getKeyBackupEnabled()) { + // key backup is enabled but we don't have a session backup key in SSSS: see if we have one in + // the cache or the user can provide one, and if so, write it to SSSS + const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.()); + if (!backupKey) { + // This will require user intervention to recover from since we don't have the key + // backup key anywhere. The user should probably just set up a new key backup and + // the key for the new backup will be stored. If we hit this scenario in the wild + // with any frequency, we should do more than just log an error. + logger.error("Key backup is enabled but couldn't get key backup key!"); + return; + } + logger.info("Got session backup key from cache/user that wasn't in SSSS: saving to SSSS"); + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey)); + } + + const operation = builder.buildOperation(); + await operation.apply(this); + // this persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + + logger.log("Secure Secret Storage ready"); + } + + public addSecretStorageKey( + algorithm: string, + opts: IAddSecretStorageKeyOpts, + keyID?: string, + ): Promise<SecretStorageKeyObject> { + return this.secretStorage.addKey(algorithm, opts, keyID); + } + + public hasSecretStorageKey(keyID?: string): Promise<boolean> { + return this.secretStorage.hasKey(keyID); + } + + public getSecretStorageKey(keyID?: string): Promise<SecretStorageKeyTuple | null> { + return this.secretStorage.getKey(keyID); + } + + public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> { + return this.secretStorage.store(name, secret, keys); + } + + public getSecret(name: string): Promise<string | undefined> { + return this.secretStorage.get(name); + } + + public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> { + return this.secretStorage.isStored(name); + } + + public requestSecret(name: string, devices: string[]): ISecretRequest { + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId)); + } + return this.secretStorage.request(name, devices); + } + + public getDefaultSecretStorageKeyId(): Promise<string | null> { + return this.secretStorage.getDefaultKeyId(); + } + + public setDefaultSecretStorageKeyId(k: string): Promise<void> { + return this.secretStorage.setDefaultKeyId(k); + } + + public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> { + return this.secretStorage.checkKey(key, info); + } + + /** + * Checks that a given secret storage private key matches a given public key. + * This can be used by the getSecretStorageKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param privateKey - The private key + * @param expectedPublicKey - The public key + * @returns true if the key matches, otherwise false + */ + public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { + let decryption: PkDecryption | null = null; + try { + decryption = new global.Olm.PkDecryption(); + const gotPubkey = decryption.init_with_private_key(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + decryption?.free(); + } + } + + /** + * Fetches the backup private key, if cached + * @returns the key, if any, or null + */ + public async getSessionBackupPrivateKey(): Promise<Uint8Array | null> { + let key = await new Promise<any>((resolve) => { + // TODO types + this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1"); + }); + }); + + // make sure we have a Uint8Array, rather than a string + if (key && typeof key === "string") { + key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); + await this.storeSessionBackupPrivateKey(key); + } + if (key && key.ciphertext) { + const pickleKey = Buffer.from(this.olmDevice.pickleKey); + const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1"); + key = olmlib.decodeBase64(decrypted); + } + return key; + } + + /** + * Stores the session backup key to the cache + * @param key - the private key + * @returns a promise so you can catch failures + */ + public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> { + if (!(key instanceof Uint8Array)) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); + } + const pickleKey = Buffer.from(this.olmDevice.pickleKey); + const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); + return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); + }); + } + + /** + * Checks that a given cross-signing private key matches a given public key. + * This can be used by the getCrossSigningKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param privateKey - The private key + * @param expectedPublicKey - The public key + * @returns true if the key matches, otherwise false + */ + public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { + let signing: PkSigning | null = null; + try { + signing = new global.Olm.PkSigning(); + const gotPubkey = signing.init_with_seed(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + signing?.free(); + } + } + + /** + * Run various follow-up actions after cross-signing keys have changed locally + * (either by resetting the keys for the account or by getting them from secret + * storage), such as signing the current device, upgrading device + * verifications, etc. + */ + private async afterCrossSigningLocalKeyChange(): Promise<void> { + logger.info("Starting cross-signing key change post-processing"); + + // sign the current device with the new key, and upload to the server + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; + const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); + logger.info(`Starting background key sig upload for ${this.deviceId}`); + + const upload = ({ shouldEmit = false }): Promise<void> => { + return this.baseApis + .uploadKeySignatures({ + [this.userId]: { + [this.deviceId]: signedDevice!, + }, + }) + .then((response) => { + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + CryptoEvent.KeySignatureUploadFailure, + failures, + "afterCrossSigningLocalKeyChange", + upload, // continuation + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + logger.info(`Finished background key sig upload for ${this.deviceId}`); + }) + .catch((e) => { + logger.error(`Error during background key sig upload for ${this.deviceId}`, e); + }); + }; + upload({ shouldEmit: true }); + + const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; + if (shouldUpgradeCb) { + logger.info("Starting device verification upgrade"); + + // Check all users for signatures if upgrade callback present + // FIXME: do this in batches + const users: Record<string, IDeviceVerificationUpgrade> = {}; + for (const [userId, crossSigningInfo] of Object.entries(this.deviceList.crossSigningInfo)) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade( + userId, + CrossSigningInfo.fromStorage(crossSigningInfo, userId), + ); + if (upgradeInfo) { + users[userId] = upgradeInfo; + } + } + + if (Object.keys(users).length > 0) { + logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); + try { + const usersToUpgrade = await shouldUpgradeCb({ users: users }); + if (usersToUpgrade) { + for (const userId of usersToUpgrade) { + if (userId in users) { + await this.baseApis.setDeviceVerified(userId, users[userId].crossSigningInfo.getId()!); + } + } + } + } catch (e) { + logger.log("shouldUpgradeDeviceVerifications threw an error: not upgrading", e); + } + } + + logger.info("Finished device verification upgrade"); + } + + logger.info("Finished cross-signing key change post-processing"); + } + + /** + * Check if a user's cross-signing key is a candidate for upgrading from device + * verification. + * + * @param userId - the user whose cross-signing information is to be checked + * @param crossSigningInfo - the cross-signing information to check + */ + private async checkForDeviceVerificationUpgrade( + userId: string, + crossSigningInfo: CrossSigningInfo, + ): Promise<IDeviceVerificationUpgrade | undefined> { + // only upgrade if this is the first cross-signing key that we've seen for + // them, and if their cross-signing key isn't already verified + const trustLevel = this.crossSigningInfo.checkUserTrust(crossSigningInfo); + if (crossSigningInfo.firstUse && !trustLevel.isVerified()) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + const deviceIds = await this.checkForValidDeviceSignature(userId, crossSigningInfo.keys.master, devices); + if (deviceIds.length) { + return { + devices: deviceIds.map((deviceId) => DeviceInfo.fromStorage(devices[deviceId], deviceId)), + crossSigningInfo, + }; + } + } + } + + /** + * Check if the cross-signing key is signed by a verified device. + * + * @param userId - the user ID whose key is being checked + * @param key - the key that is being checked + * @param devices - the user's devices. Should be a map from device ID + * to device info + */ + private async checkForValidDeviceSignature( + userId: string, + key: ICrossSigningKey, + devices: Record<string, IDevice>, + ): Promise<string[]> { + const deviceIds: string[] = []; + if (devices && key.signatures && key.signatures[userId]) { + for (const signame of Object.keys(key.signatures[userId])) { + const [, deviceId] = signame.split(":", 2); + if (deviceId in devices && devices[deviceId].verified === DeviceVerification.VERIFIED) { + try { + await olmlib.verifySignature( + this.olmDevice, + key, + userId, + deviceId, + devices[deviceId].keys[signame], + ); + deviceIds.push(deviceId); + } catch (e) {} + } + } + } + return deviceIds; + } + + /** + * Get the user's cross-signing key ID. + * + * @param type - The type of key to get the ID of. One of + * "master", "self_signing", or "user_signing". Defaults to "master". + * + * @returns the key ID + */ + public getCrossSigningId(type: string): string | null { + return this.crossSigningInfo.getId(type); + } + + /** + * Get the cross signing information for a given user. + * + * @param userId - the user ID to get the cross-signing info for. + * + * @returns the cross signing information for the user. + */ + public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { + return this.deviceList.getStoredCrossSigningForUser(userId); + } + + /** + * Check whether a given user is trusted. + * + * @param userId - The ID of the user to check. + * + * @returns + */ + public checkUserTrust(userId: string): UserTrustLevel { + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!userCrossSigning) { + return new UserTrustLevel(false, false, false); + } + return this.crossSigningInfo.checkUserTrust(userCrossSigning); + } + + /** + * Check whether a given device is trusted. + * + * @param userId - The ID of the user whose devices is to be checked. + * @param deviceId - The ID of the device to check + * + * @returns + */ + public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { + const device = this.deviceList.getStoredDevice(userId, deviceId); + return this.checkDeviceInfoTrust(userId, device); + } + + /** + * Check whether a given deviceinfo is trusted. + * + * @param userId - The ID of the user whose devices is to be checked. + * @param device - The device info object to check + * + * @returns + */ + public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel { + const trustedLocally = !!device?.isVerified(); + + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (device && userCrossSigning) { + // The trustCrossSignedDevices only affects trust of other people's cross-signing + // signatures + const trustCrossSig = this.trustCrossSignedDevices || userId === this.userId; + return this.crossSigningInfo.checkDeviceTrust(userCrossSigning, device, trustedLocally, trustCrossSig); + } else { + return new DeviceTrustLevel(false, false, trustedLocally, false); + } + } + + /** + * Check whether one of our own devices is cross-signed by our + * user's stored keys, regardless of whether we trust those keys yet. + * + * @param deviceId - The ID of the device to check + * + * @returns true if the device is cross-signed + */ + public checkIfOwnDeviceCrossSigned(deviceId: string): boolean { + const device = this.deviceList.getStoredDevice(this.userId, deviceId); + if (!device) return false; + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(this.userId); + return ( + userCrossSigning?.checkDeviceTrust(userCrossSigning, device, false, true).isCrossSigningVerified() ?? false + ); + } + + /* + * Event handler for DeviceList's userNewDevices event + */ + private onDeviceListUserCrossSigningUpdated = async (userId: string): Promise<void> => { + if (userId === this.userId) { + // An update to our own cross-signing key. + // Get the new key first: + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; + const currentPubkey = this.crossSigningInfo.getId(); + const changed = currentPubkey !== seenPubkey; + + if (currentPubkey && seenPubkey && !changed) { + // If it's not changed, just make sure everything is up to date + await this.checkOwnCrossSigningTrust(); + } else { + // We'll now be in a state where cross-signing on the account is not trusted + // because our locally stored cross-signing keys will not match the ones + // on the server for our account. So we clear our own stored cross-signing keys, + // effectively disabling cross-signing until the user gets verified by the device + // that reset the keys + this.storeTrustedSelfKeys(null); + // emit cross-signing has been disabled + this.emit(CryptoEvent.KeysChanged, {}); + // as the trust for our own user has changed, + // also emit an event for this + this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); + } + } else { + await this.checkDeviceVerifications(userId); + + // Update verified before latch using the current state and save the new + // latch value in the device list store. + const crossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigning) { + crossSigning.updateCrossSigningVerifiedBefore(this.checkUserTrust(userId).isCrossSigningVerified()); + this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); + } + + this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); + } + }; + + /** + * Check the copy of our cross-signing key that we have in the device list and + * see if we can get the private key. If so, mark it as trusted. + */ + public async checkOwnCrossSigningTrust({ + allowPrivateKeyRequests = false, + }: ICheckOwnCrossSigningTrustOpts = {}): Promise<void> { + const userId = this.userId; + + // Before proceeding, ensure our cross-signing public keys have been + // downloaded via the device list. + await this.downloadKeys([this.userId]); + + // Also check which private keys are locally cached. + const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); + + // If we see an update to our own master key, check it against the master + // key we have and, if it matches, mark it as verified + + // First, get the new cross-signing info + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!newCrossSigning) { + logger.error( + "Got cross-signing update event for user " + userId + " but no new cross-signing information found!", + ); + return; + } + + const seenPubkey = newCrossSigning.getId()!; + const masterChanged = this.crossSigningInfo.getId() !== seenPubkey; + const masterExistsNotLocallyCached = newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); + if (masterChanged) { + logger.info("Got new master public key", seenPubkey); + } + if (allowPrivateKeyRequests && (masterChanged || masterExistsNotLocallyCached)) { + logger.info("Attempting to retrieve cross-signing master private key"); + let signing: PkSigning | null = null; + // It's important for control flow that we leave any errors alone for + // higher levels to handle so that e.g. cancelling access properly + // aborts any larger operation as well. + try { + const ret = await this.crossSigningInfo.getCrossSigningKey("master", seenPubkey); + signing = ret[1]; + logger.info("Got cross-signing master private key"); + } finally { + signing?.free(); + } + } + + const oldSelfSigningId = this.crossSigningInfo.getId("self_signing"); + const oldUserSigningId = this.crossSigningInfo.getId("user_signing"); + + // Update the version of our keys in our cross-signing object and the local store + this.storeTrustedSelfKeys(newCrossSigning.keys); + + const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); + const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); + + const selfSigningExistsNotLocallyCached = + newCrossSigning.getId("self_signing") && !crossSigningPrivateKeys.has("self_signing"); + const userSigningExistsNotLocallyCached = + newCrossSigning.getId("user_signing") && !crossSigningPrivateKeys.has("user_signing"); + + const keySignatures: Record<string, ISignedKey> = {}; + + if (selfSigningChanged) { + logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); + } + if (allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached)) { + logger.info("Attempting to retrieve cross-signing self-signing private key"); + let signing: PkSigning | null = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + "self_signing", + newCrossSigning.getId("self_signing")!, + ); + signing = ret[1]; + logger.info("Got cross-signing self-signing private key"); + } finally { + signing?.free(); + } + + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; + const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); + keySignatures[this.deviceId] = signedDevice!; + } + if (userSigningChanged) { + logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); + } + if (allowPrivateKeyRequests && (userSigningChanged || userSigningExistsNotLocallyCached)) { + logger.info("Attempting to retrieve cross-signing user-signing private key"); + let signing: PkSigning | null = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + "user_signing", + newCrossSigning.getId("user_signing")!, + ); + signing = ret[1]; + logger.info("Got cross-signing user-signing private key"); + } finally { + signing?.free(); + } + } + + if (masterChanged) { + const masterKey = this.crossSigningInfo.keys.master; + await this.signObject(masterKey); + const deviceSig = masterKey.signatures![this.userId]["ed25519:" + this.deviceId]; + // Include only the _new_ device signature in the upload. + // We may have existing signatures from deleted devices, which will cause + // the entire upload to fail. + keySignatures[this.crossSigningInfo.getId()!] = Object.assign({} as ISignedKey, masterKey, { + signatures: { + [this.userId]: { + ["ed25519:" + this.deviceId]: deviceSig, + }, + }, + }); + } + + const keysToUpload = Object.keys(keySignatures); + if (keysToUpload.length) { + const upload = ({ shouldEmit = false }): Promise<void> => { + logger.info(`Starting background key sig upload for ${keysToUpload}`); + return this.baseApis + .uploadKeySignatures({ [this.userId]: keySignatures }) + .then((response) => { + const { failures } = response || {}; + logger.info(`Finished background key sig upload for ${keysToUpload}`); + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + CryptoEvent.KeySignatureUploadFailure, + failures, + "checkOwnCrossSigningTrust", + upload, + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + }) + .catch((e) => { + logger.error(`Error during background key sig upload for ${keysToUpload}`, e); + }); + }; + upload({ shouldEmit: true }); + } + + this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); + + if (masterChanged) { + this.emit(CryptoEvent.KeysChanged, {}); + await this.afterCrossSigningLocalKeyChange(); + } + + // Now we may be able to trust our key backup + await this.backupManager.checkKeyBackup(); + // FIXME: if we previously trusted the backup, should we automatically sign + // the backup with the new key (if not already signed)? + } + + /** + * Store a set of keys as our own, trusted, cross-signing keys. + * + * @param keys - The new trusted set of keys + */ + private async storeTrustedSelfKeys(keys: Record<string, ICrossSigningKey> | null): Promise<void> { + if (keys) { + this.crossSigningInfo.setKeys(keys); + } else { + this.crossSigningInfo.clearKeys(); + } + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); + }); + } + + /** + * Check if the master key is signed by a verified device, and if so, prompt + * the application to mark it as verified. + * + * @param userId - the user ID whose key should be checked + */ + private async checkDeviceVerifications(userId: string): Promise<void> { + const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; + if (!shouldUpgradeCb) { + // Upgrading skipped when callback is not present. + return; + } + logger.info(`Starting device verification upgrade for ${userId}`); + if (this.crossSigningInfo.keys.user_signing) { + const crossSigningInfo = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigningInfo) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade(userId, crossSigningInfo); + if (upgradeInfo) { + const usersToUpgrade = await shouldUpgradeCb({ + users: { + [userId]: upgradeInfo, + }, + }); + if (usersToUpgrade.includes(userId)) { + await this.baseApis.setDeviceVerified(userId, crossSigningInfo.getId()!); + } + } + } + } + logger.info(`Finished device verification upgrade for ${userId}`); + } + + /** + */ + public enableLazyLoading(): void { + this.lazyLoadMembers = true; + } + + /** + * Tell the crypto module to register for MatrixClient events which it needs to + * listen for + * + * @param eventEmitter - event source where we can register + * for event notifications + */ + public registerEventHandlers( + eventEmitter: TypedEventEmitter< + RoomMemberEvent.Membership | ClientEvent.ToDeviceEvent | RoomEvent.Timeline | MatrixEventEvent.Decrypted, + any + >, + ): void { + eventEmitter.on(RoomMemberEvent.Membership, this.onMembership); + eventEmitter.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + eventEmitter.on(RoomEvent.Timeline, this.onTimelineEvent); + eventEmitter.on(MatrixEventEvent.Decrypted, this.onTimelineEvent); + } + + /** + * @deprecated this does nothing and will be removed in a future version + */ + public start(): void { + logger.warn("MatrixClient.crypto.start() is deprecated"); + } + + /** Stop background processes related to crypto */ + public stop(): void { + this.outgoingRoomKeyRequestManager.stop(); + this.deviceList.stop(); + this.dehydrationManager.stop(); + } + + /** + * Get the Ed25519 key for this device + * + * @returns base64-encoded ed25519 key. + */ + public getDeviceEd25519Key(): string | null { + return this.olmDevice.deviceEd25519Key; + } + + /** + * Get the Curve25519 key for this device + * + * @returns base64-encoded curve25519 key. + */ + public getDeviceCurve25519Key(): string | null { + return this.olmDevice.deviceCurve25519Key; + } + + /** + * Set the global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * @param value - whether to blacklist all unverified devices by default + * + * @deprecated For external code, use {@link MatrixClient#setGlobalBlacklistUnverifiedDevices}. For + * internal code, set {@link MatrixClient#globalBlacklistUnverifiedDevices} directly. + */ + public setGlobalBlacklistUnverifiedDevices(value: boolean): void { + this.globalBlacklistUnverifiedDevices = value; + } + + /** + * @returns whether to blacklist all unverified devices by default + * + * @deprecated For external code, use {@link MatrixClient#getGlobalBlacklistUnverifiedDevices}. For + * internal code, reference {@link MatrixClient#globalBlacklistUnverifiedDevices} directly. + */ + public getGlobalBlacklistUnverifiedDevices(): boolean { + return this.globalBlacklistUnverifiedDevices; + } + + /** + * Upload the device keys to the homeserver. + * @returns A promise that will resolve when the keys are uploaded. + */ + public uploadDeviceKeys(): Promise<IKeysUploadResponse> { + const deviceKeys = { + algorithms: this.supportedAlgorithms, + device_id: this.deviceId, + keys: this.deviceKeys, + user_id: this.userId, + }; + + return this.signObject(deviceKeys).then(() => { + return this.baseApis.uploadKeysRequest({ + device_keys: deviceKeys as Required<IDeviceKeys>, + }); + }); + } + + /** + * Stores the current one_time_key count which will be handled later (in a call of + * onSyncCompleted). The count is e.g. coming from a /sync response. + * + * @param currentCount - The current count of one_time_keys to be stored + */ + public updateOneTimeKeyCount(currentCount: number): void { + if (isFinite(currentCount)) { + this.oneTimeKeyCount = currentCount; + } else { + throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); + } + } + + public setNeedsNewFallback(needsNewFallback: boolean): void { + this.needsNewFallback = needsNewFallback; + } + + public getNeedsNewFallback(): boolean { + return !!this.needsNewFallback; + } + + // check if it's time to upload one-time keys, and do so if so. + private maybeUploadOneTimeKeys(): void { + // frequency with which to check & upload one-time keys + const uploadPeriod = 1000 * 60; // one minute + + // max number of keys to upload at once + // Creating keys can be an expensive operation so we limit the + // number we generate in one go to avoid blocking the application + // for too long. + const maxKeysPerCycle = 5; + + if (this.oneTimeKeyCheckInProgress) { + return; + } + + const now = Date.now(); + if (this.lastOneTimeKeyCheck !== null && now - this.lastOneTimeKeyCheck < uploadPeriod) { + // we've done a key upload recently. + return; + } + + this.lastOneTimeKeyCheck = now; + + // We need to keep a pool of one time public keys on the server so that + // other devices can start conversations with us. But we can only store + // a finite number of private keys in the olm Account object. + // To complicate things further then can be a delay between a device + // claiming a public one time key from the server and it sending us a + // message. We need to keep the corresponding private key locally until + // we receive the message. + // But that message might never arrive leaving us stuck with duff + // private keys clogging up our local storage. + // So we need some kind of engineering compromise to balance all of + // these factors. + + // Check how many keys we can store in the Account object. + const maxOneTimeKeys = this.olmDevice.maxNumberOfOneTimeKeys(); + // Try to keep at most half that number on the server. This leaves the + // rest of the slots free to hold keys that have been claimed from the + // server but we haven't received a message for. + // If we run out of slots when generating new keys then olm will + // discard the oldest private keys first. This will eventually clean + // out stale private keys that won't receive a message. + const keyLimit = Math.floor(maxOneTimeKeys / 2); + + const uploadLoop = async (keyCount: number): Promise<void> => { + while (keyLimit > keyCount || this.getNeedsNewFallback()) { + // Ask olm to generate new one time keys, then upload them to synapse. + if (keyLimit > keyCount) { + logger.info("generating oneTimeKeys"); + const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); + await this.olmDevice.generateOneTimeKeys(keysThisLoop); + } + + if (this.getNeedsNewFallback()) { + const fallbackKeys = await this.olmDevice.getFallbackKey(); + // if fallbackKeys is non-empty, we've already generated a + // fallback key, but it hasn't been published yet, so we + // can use that instead of generating a new one + if (!fallbackKeys.curve25519 || Object.keys(fallbackKeys.curve25519).length == 0) { + logger.info("generating fallback key"); + if (this.fallbackCleanup) { + // cancel any pending fallback cleanup because generating + // a new fallback key will already drop the old fallback + // that would have been dropped, and we don't want to kill + // the current key + clearTimeout(this.fallbackCleanup); + delete this.fallbackCleanup; + } + await this.olmDevice.generateFallbackKey(); + } + } + + logger.info("calling uploadOneTimeKeys"); + const res = await this.uploadOneTimeKeys(); + if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { + // if the response contains a more up to date value use this + // for the next loop + keyCount = res.one_time_key_counts.signed_curve25519; + } else { + throw new Error( + "response for uploading keys does not contain " + "one_time_key_counts.signed_curve25519", + ); + } + } + }; + + this.oneTimeKeyCheckInProgress = true; + Promise.resolve() + .then(() => { + if (this.oneTimeKeyCount !== undefined) { + // We already have the current one_time_key count from a /sync response. + // Use this value instead of asking the server for the current key count. + return Promise.resolve(this.oneTimeKeyCount); + } + // ask the server how many keys we have + return this.baseApis.uploadKeysRequest({}).then((res) => { + return res.one_time_key_counts.signed_curve25519 || 0; + }); + }) + .then((keyCount) => { + // Start the uploadLoop with the current keyCount. The function checks if + // we need to upload new keys or not. + // If there are too many keys on the server then we don't need to + // create any more keys. + return uploadLoop(keyCount); + }) + .catch((e) => { + logger.error("Error uploading one-time keys", e.stack || e); + }) + .finally(() => { + // reset oneTimeKeyCount to prevent start uploading based on old data. + // it will be set again on the next /sync-response + this.oneTimeKeyCount = undefined; + this.oneTimeKeyCheckInProgress = false; + }); + } + + // returns a promise which resolves to the response + private async uploadOneTimeKeys(): Promise<IKeysUploadResponse> { + const promises: Promise<unknown>[] = []; + + let fallbackJson: Record<string, IOneTimeKey> | undefined; + if (this.getNeedsNewFallback()) { + fallbackJson = {}; + const fallbackKeys = await this.olmDevice.getFallbackKey(); + for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { + const k = { key, fallback: true }; + fallbackJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + this.setNeedsNewFallback(false); + } + + const oneTimeKeys = await this.olmDevice.getOneTimeKeys(); + const oneTimeJson: Record<string, { key: string }> = {}; + + for (const keyId in oneTimeKeys.curve25519) { + if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { + const k = { + key: oneTimeKeys.curve25519[keyId], + }; + oneTimeJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + } + + await Promise.all(promises); + + const requestBody: Record<string, any> = { + one_time_keys: oneTimeJson, + }; + + if (fallbackJson) { + requestBody["org.matrix.msc2732.fallback_keys"] = fallbackJson; + requestBody["fallback_keys"] = fallbackJson; + } + + const res = await this.baseApis.uploadKeysRequest(requestBody); + + if (fallbackJson) { + this.fallbackCleanup = setTimeout(() => { + delete this.fallbackCleanup; + this.olmDevice.forgetOldFallbackKey(); + }, 60 * 60 * 1000); + } + + await this.olmDevice.markKeysAsPublished(); + return res; + } + + /** + * Download the keys for a list of users and stores the keys in the session + * store. + * @param userIds - The users to fetch. + * @param forceDownload - Always download the keys even if cached. + * + * @returns A promise which resolves to a map `userId->deviceId->{@link DeviceInfo}`. + */ + public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> { + return this.deviceList.downloadKeys(userIds, !!forceDownload); + } + + /** + * Get the stored device keys for a user id + * + * @param userId - the user to list keys for. + * + * @returns list of devices, or null if we haven't + * managed to get a list of devices for this user yet. + */ + public getStoredDevicesForUser(userId: string): Array<DeviceInfo> | null { + return this.deviceList.getStoredDevicesForUser(userId); + } + + /** + * Get the stored keys for a single device + * + * + * @returns device, or undefined + * if we don't know about this device + */ + public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { + return this.deviceList.getStoredDevice(userId, deviceId); + } + + /** + * Save the device list, if necessary + * + * @param delay - Time in ms before which the save actually happens. + * By default, the save is delayed for a short period in order to batch + * multiple writes, but this behaviour can be disabled by passing 0. + * + * @returns true if the data was saved, false if + * it was not (eg. because no changes were pending). The promise + * will only resolve once the data is saved, so may take some time + * to resolve. + */ + public saveDeviceList(delay: number): Promise<boolean> { + return this.deviceList.saveIfDirty(delay); + } + + /** + * Update the blocked/verified state of the given device + * + * @param userId - owner of the device + * @param deviceId - unique identifier for the device or user's + * cross-signing public key ID. + * + * @param verified - whether to mark the device as verified. Null to + * leave unchanged. + * + * @param blocked - whether to mark the device as blocked. Null to + * leave unchanged. + * + * @param known - whether to mark that the user has been made aware of + * the existence of this device. Null to leave unchanged + * + * @param keys - The list of keys that was present + * during the device verification. This will be double checked with the list + * of keys the given device has currently. + * + * @returns updated DeviceInfo + */ + public async setDeviceVerification( + userId: string, + deviceId: string, + verified: boolean | null = null, + blocked: boolean | null = null, + known: boolean | null = null, + keys?: Record<string, string>, + ): Promise<DeviceInfo | CrossSigningInfo> { + // Check if the 'device' is actually a cross signing key + // The js-sdk's verification treats cross-signing keys as devices + // and so uses this method to mark them verified. + const xsk = this.deviceList.getStoredCrossSigningForUser(userId); + if (xsk && xsk.getId() === deviceId) { + if (blocked !== null || known !== null) { + throw new Error("Cannot set blocked or known for a cross-signing key"); + } + if (!verified) { + throw new Error("Cannot set a cross-signing key as unverified"); + } + const gotKeyId = keys ? Object.values(keys)[0] : null; + if (keys && (Object.values(keys).length !== 1 || gotKeyId !== xsk.getId())) { + throw new Error(`Key did not match expected value: expected ${xsk.getId()}, got ${gotKeyId}`); + } + + if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) { + this.storeTrustedSelfKeys(xsk.keys); + // This will cause our own user trust to change, so emit the event + this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); + } + + // Now sign the master key with our user signing key (unless it's ourself) + if (userId !== this.userId) { + logger.info("Master key " + xsk.getId() + " for " + userId + " marked verified. Signing..."); + const device = await this.crossSigningInfo.signUser(xsk); + if (device) { + const upload = async ({ shouldEmit = false }): Promise<void> => { + logger.info("Uploading signature for " + userId + "..."); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device, + }, + }); + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + CryptoEvent.KeySignatureUploadFailure, + failures, + "setDeviceVerification", + upload, + ); + } + /* Throwing here causes the process to be cancelled and the other + * user to be notified */ + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + }; + await upload({ shouldEmit: true }); + + // This will emit events when it comes back down the sync + // (we could do local echo to speed things up) + } + return device as any; // TODO types + } else { + return xsk; + } + } + + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + if (!devices || !devices[deviceId]) { + throw new Error("Unknown device " + userId + ":" + deviceId); + } + + const dev = devices[deviceId]; + let verificationStatus = dev.verified; + + if (verified) { + if (keys) { + for (const [keyId, key] of Object.entries(keys)) { + if (dev.keys[keyId] !== key) { + throw new Error(`Key did not match expected value: expected ${key}, got ${dev.keys[keyId]}`); + } + } + } + verificationStatus = DeviceVerification.VERIFIED; + } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + + if (blocked) { + verificationStatus = DeviceVerification.BLOCKED; + } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + + let knownStatus = dev.known; + if (known !== null) { + knownStatus = known; + } + + if (dev.verified !== verificationStatus || dev.known !== knownStatus) { + dev.verified = verificationStatus; + dev.known = knownStatus; + this.deviceList.storeDevicesForUser(userId, devices); + this.deviceList.saveIfDirty(); + } + + // do cross-signing + if (verified && userId === this.userId) { + logger.info("Own device " + deviceId + " marked verified: signing"); + + // Signing only needed if other device not already signed + let device: ISignedKey | undefined; + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + if (deviceTrust.isCrossSigningVerified()) { + logger.log(`Own device ${deviceId} already cross-signing verified`); + } else { + device = (await this.crossSigningInfo.signDevice(userId, DeviceInfo.fromStorage(dev, deviceId)))!; + } + + if (device) { + const upload = async ({ shouldEmit = false }): Promise<void> => { + logger.info("Uploading signature for " + deviceId); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device!, + }, + }); + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + CryptoEvent.KeySignatureUploadFailure, + failures, + "setDeviceVerification", + upload, // continuation + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + }; + await upload({ shouldEmit: true }); + // XXX: we'll need to wait for the device list to be updated + } + } + + const deviceObj = DeviceInfo.fromStorage(dev, deviceId); + this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); + return deviceObj; + } + + public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined { + return this.inRoomVerificationRequests.findRequestInProgress(roomId); + } + + public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { + return this.toDeviceVerificationRequests.getRequestsInProgress(userId); + } + + public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> { + const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new InRoomChannel(this.baseApis, roomId, userId); + return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests); + } + + public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> { + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId)); + } + const existingRequest = this.toDeviceVerificationRequests.findRequestInProgress(userId, devices); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new ToDeviceChannel(this.baseApis, userId, devices, ToDeviceChannel.makeTransactionId()); + return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests); + } + + private async requestVerificationWithChannel( + userId: string, + channel: IVerificationChannel, + requestsMap: IRequestsMap, + ): Promise<VerificationRequest> { + let request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + // if transaction id is already known, add request + if (channel.transactionId) { + requestsMap.setRequestByChannel(channel, request); + } + await request.sendRequest(); + // don't replace the request created by a racing remote echo + const racingRequest = requestsMap.getRequestByChannel(channel); + if (racingRequest) { + request = racingRequest; + } else { + logger.log( + `Crypto: adding new request to ` + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`, + ); + requestsMap.setRequestByChannel(channel, request); + } + return request; + } + + public beginKeyVerification( + method: string, + userId: string, + deviceId: string, + transactionId: string | null = null, + ): VerificationBase<any, any> { + let request: Request | undefined; + if (transactionId) { + request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); + if (!request) { + throw new Error(`No request found for user ${userId} with ` + `transactionId ${transactionId}`); + } + } else { + transactionId = ToDeviceChannel.makeTransactionId(); + const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); + request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); + } + return request.beginKeyVerification(method, { userId, deviceId }); + } + + public async legacyDeviceVerification( + userId: string, + deviceId: string, + method: VerificationMethod, + ): Promise<VerificationRequest> { + const transactionId = ToDeviceChannel.makeTransactionId(); + const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); + const request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); + const verifier = request.beginKeyVerification(method, { userId, deviceId }); + // either reject by an error from verify() while sending .start + // or resolve when the request receives the + // local (fake remote) echo for sending the .start event + await Promise.race([verifier.verify(), request.waitFor((r) => r.started)]); + return request; + } + + /** + * Get information on the active olm sessions with a user + * <p> + * Returns a map from device id to an object with keys 'deviceIdKey' (the + * device's curve25519 identity key) and 'sessions' (an array of objects in the + * same format as that returned by + * {@link OlmDevice#getSessionInfoForDevice}). + * <p> + * This method is provided for debugging purposes. + * + * @param userId - id of user to inspect + */ + public async getOlmSessionsForUser(userId: string): Promise<Record<string, IUserOlmSession>> { + const devices = this.getStoredDevicesForUser(userId) || []; + const result: { [deviceId: string]: IUserOlmSession } = {}; + for (const device of devices) { + const deviceKey = device.getIdentityKey(); + const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey); + + result[device.deviceId] = { + deviceIdKey: deviceKey, + sessions: sessions, + }; + } + return result; + } + + /** + * Get the device which sent an event + * + * @param event - event to be checked + */ + public getEventSenderDeviceInfo(event: MatrixEvent): DeviceInfo | null { + const senderKey = event.getSenderKey(); + const algorithm = event.getWireContent().algorithm; + + if (!senderKey || !algorithm) { + return null; + } + + if (event.isKeySourceUntrusted()) { + // we got the key for this event from a source that we consider untrusted + return null; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + const device = this.deviceList.getDeviceByIdentityKey(algorithm, senderKey); + + if (device === null) { + // we haven't downloaded the details of this device yet. + return null; + } + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); + return null; + } + + if (claimedKey !== device.getFingerprint()) { + logger.warn( + "Event " + + event.getId() + + " claims ed25519 key " + + claimedKey + + " but sender device has key " + + device.getFingerprint(), + ); + return null; + } + + return device; + } + + /** + * Get information about the encryption of an event + * + * @param event - event to be checked + * + * @returns An object with the fields: + * - encrypted: whether the event is encrypted (if not encrypted, some of the + * other properties may not be set) + * - senderKey: the sender's key + * - algorithm: the algorithm used to encrypt the event + * - authenticated: whether we can be sure that the owner of the senderKey + * sent the event + * - sender: the sender's device information, if available + * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match + * (only meaningful if `sender` is set) + */ + public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { + const ret: Partial<IEncryptedEventInfo> = {}; + + ret.senderKey = event.getSenderKey() ?? undefined; + ret.algorithm = event.getWireContent().algorithm; + + if (!ret.senderKey || !ret.algorithm) { + ret.encrypted = false; + return ret as IEncryptedEventInfo; + } + ret.encrypted = true; + + if (event.isKeySourceUntrusted()) { + // we got the key this event from somewhere else + // TODO: check if we can trust the forwarders. + ret.authenticated = false; + } else { + ret.authenticated = true; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + ret.sender = this.deviceList.getDeviceByIdentityKey(ret.algorithm, ret.senderKey) ?? undefined; + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); + ret.mismatchedSender = true; + } + + if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { + logger.warn( + "Event " + + event.getId() + + " claims ed25519 key " + + claimedKey + + "but sender device has key " + + ret.sender.getFingerprint(), + ); + ret.mismatchedSender = true; + } + + return ret as IEncryptedEventInfo; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * @param roomId - The ID of the room to discard the session for + * + * This should not normally be necessary. + */ + public forceDiscardSession(roomId: string): Promise<void> { + const alg = this.roomEncryptors.get(roomId); + if (alg === undefined) throw new Error("Room not encrypted"); + if (alg.forceDiscardSession === undefined) { + throw new Error("Room encryption algorithm doesn't support session discarding"); + } + alg.forceDiscardSession(); + return Promise.resolve(); + } + + /** + * Configure a room to use encryption (ie, save a flag in the cryptoStore). + * + * @param roomId - The room ID to enable encryption in. + * + * @param config - The encryption config for the room. + * + * @param inhibitDeviceQuery - true to suppress device list query for + * users in the room (for now). In case lazy loading is enabled, + * the device query is always inhibited as the members are not tracked. + * + * @deprecated It is normally incorrect to call this method directly. Encryption + * is enabled by receiving an `m.room.encryption` event (which we may have sent + * previously). + */ + public async setRoomEncryption( + roomId: string, + config: IRoomEncryption, + inhibitDeviceQuery?: boolean, + ): Promise<void> { + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to enable encryption tracking devices in unknown room ${roomId}`); + } + await this.setRoomEncryptionImpl(room, config); + if (!this.lazyLoadMembers && !inhibitDeviceQuery) { + this.deviceList.refreshOutdatedDeviceLists(); + } + } + + /** + * Set up encryption for a room. + * + * This is called when an <tt>m.room.encryption</tt> event is received. It saves a flag + * for the room in the cryptoStore (if it wasn't already set), sets up an "encryptor" for + * the room, and enables device-list tracking for the room. + * + * It does <em>not</em> initiate a device list query for the room. That is normally + * done once we finish processing the sync, in onSyncCompleted. + * + * @param room - The room to enable encryption in. + * @param config - The encryption config for the room. + */ + private async setRoomEncryptionImpl(room: Room, config: IRoomEncryption): Promise<void> { + const roomId = room.roomId; + + // ignore crypto events with no algorithm defined + // This will happen if a crypto event is redacted before we fetch the room state + // It would otherwise just throw later as an unknown algorithm would, but we may + // as well catch this here + if (!config.algorithm) { + logger.log("Ignoring setRoomEncryption with no algorithm"); + return; + } + + // if state is being replayed from storage, we might already have a configuration + // for this room as they are persisted as well. + // We just need to make sure the algorithm is initialized in this case. + // However, if the new config is different, + // we should bail out as room encryption can't be changed once set. + const existingConfig = this.roomList.getRoomEncryption(roomId); + if (existingConfig) { + if (JSON.stringify(existingConfig) != JSON.stringify(config)) { + logger.error("Ignoring m.room.encryption event which requests " + "a change of config in " + roomId); + return; + } + } + // if we already have encryption in this room, we should ignore this event, + // as it would reset the encryption algorithm. + // This is at least expected to be called twice, as sync calls onCryptoEvent + // for both the timeline and state sections in the /sync response, + // the encryption event would appear in both. + // If it's called more than twice though, + // it signals a bug on client or server. + const existingAlg = this.roomEncryptors.get(roomId); + if (existingAlg) { + return; + } + + // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption + // because it first stores in memory. We should await the promise only + // after all the in-memory state (roomEncryptors and _roomList) has been updated + // to avoid races when calling this method multiple times. Hence keep a hold of the promise. + let storeConfigPromise: Promise<void> | null = null; + if (!existingConfig) { + storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); + } + + const AlgClass = algorithms.ENCRYPTION_CLASSES.get(config.algorithm); + if (!AlgClass) { + throw new Error("Unable to encrypt with " + config.algorithm); + } + + const alg = new AlgClass({ + userId: this.userId, + deviceId: this.deviceId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId, + config, + }); + this.roomEncryptors.set(roomId, alg); + + if (storeConfigPromise) { + await storeConfigPromise; + } + + logger.log(`Enabling encryption in ${roomId}`); + + // we don't want to force a download of the full membership list of this room, but as soon as we have that + // list we can start tracking the device list. + if (room.membersLoaded()) { + await this.trackRoomDevicesImpl(room); + } else { + // wait for the membership list to be loaded + const onState = (_state: RoomState): void => { + room.off(RoomStateEvent.Update, onState); + if (room.membersLoaded()) { + this.trackRoomDevicesImpl(room).catch((e) => { + logger.error(`Error enabling device tracking in ${roomId}`, e); + }); + } + }; + room.on(RoomStateEvent.Update, onState); + } + } + + /** + * Make sure we are tracking the device lists for all users in this room. + * + * @param roomId - The room ID to start tracking devices in. + * @returns when all devices for the room have been fetched and marked to track + * @deprecated there's normally no need to call this function: device list tracking + * will be enabled as soon as we have the full membership list. + */ + public trackRoomDevices(roomId: string): Promise<void> { + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); + } + return this.trackRoomDevicesImpl(room); + } + + /** + * Make sure we are tracking the device lists for all users in this room. + * + * This is normally called when we are about to send an encrypted event, to make sure + * we have all the devices in the room; but it is also called when processing an + * m.room.encryption state event (if lazy-loading is disabled), or when members are + * loaded (if lazy-loading is enabled), to prepare the device list. + * + * @param room - Room to enable device-list tracking in + */ + private trackRoomDevicesImpl(room: Room): Promise<void> { + const roomId = room.roomId; + const trackMembers = async (): Promise<void> => { + // not an encrypted room + if (!this.roomEncryptors.has(roomId)) { + return; + } + logger.log(`Starting to track devices for room ${roomId} ...`); + const members = await room.getEncryptionTargetMembers(); + members.forEach((m) => { + this.deviceList.startTrackingDeviceList(m.userId); + }); + }; + + let promise = this.roomDeviceTrackingState[roomId]; + if (!promise) { + promise = trackMembers(); + this.roomDeviceTrackingState[roomId] = promise.catch((err) => { + delete this.roomDeviceTrackingState[roomId]; + throw err; + }); + } + return promise; + } + + /** + * Try to make sure we have established olm sessions for all known devices for + * the given users. + * + * @param users - list of user ids + * @param force - If true, force a new Olm session to be created. Default false. + * + * @returns resolves once the sessions are complete, to + * an Object mapping from userId to deviceId to + * {@link OlmSessionResult} + */ + public ensureOlmSessionsForUsers( + users: string[], + force?: boolean, + ): Promise<Map<string, Map<string, olmlib.IOlmSessionResult>>> { + // map user Id → DeviceInfo[] + const devicesByUser: Map<string, DeviceInfo[]> = new Map(); + + for (const userId of users) { + const userDevices: DeviceInfo[] = []; + devicesByUser.set(userId, userDevices); + + const devices = this.getStoredDevicesForUser(userId) || []; + for (const deviceInfo of devices) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother setting up session to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + userDevices.push(deviceInfo); + } + } + + return olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, force); + } + + /** + * Get a list containing all of the room keys + * + * @returns a list of session export objects + */ + public async exportRoomKeys(): Promise<IMegolmSessionData[]> { + const exportedSessions: IMegolmSessionData[] = []; + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { + if (s === null) return; + + const sess = this.olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData!); + delete sess.first_known_index; + sess.algorithm = olmlib.MEGOLM_ALGORITHM; + exportedSessions.push(sess); + }); + }); + + return exportedSessions; + } + + /** + * Import a list of room keys previously exported by exportRoomKeys + * + * @param keys - a list of session export objects + * @returns a promise which resolves once the keys have been imported + */ + public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts = {}): Promise<void> { + let successes = 0; + let failures = 0; + const total = keys.length; + + function updateProgress(): void { + opts.progressCallback?.({ + stage: "load_keys", + successes, + failures, + total, + }); + } + + return Promise.all( + keys.map((key) => { + if (!key.room_id || !key.algorithm) { + logger.warn("ignoring room key entry with missing fields", key); + failures++; + if (opts.progressCallback) { + updateProgress(); + } + return null; + } + + const alg = this.getRoomDecryptor(key.room_id, key.algorithm); + return alg.importRoomKey(key, opts).finally(() => { + successes++; + if (opts.progressCallback) { + updateProgress(); + } + }); + }), + ).then(); + } + + /** + * Counts the number of end to end session keys that are waiting to be backed up + * @returns Promise which resolves to the number of sessions requiring backup + */ + public countSessionsNeedingBackup(): Promise<number> { + return this.backupManager.countSessionsNeedingBackup(); + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param room - the room the event is in + */ + public prepareToEncrypt(room: Room): void { + const alg = this.roomEncryptors.get(room.roomId); + if (alg) { + alg.prepareToEncrypt(room); + } + } + + /** + * Encrypt an event according to the configuration of the room. + * + * @param event - event to be sent + * + * @param room - destination room. + * + * @returns Promise which resolves when the event has been + * encrypted, or null if nothing was needed + */ + public async encryptEvent(event: MatrixEvent, room: Room): Promise<void> { + const roomId = event.getRoomId()!; + + const alg = this.roomEncryptors.get(roomId); + if (!alg) { + // MatrixClient has already checked that this room should be encrypted, + // so this is an unexpected situation. + throw new Error( + "Room " + + roomId + + " was previously configured to use encryption, but is " + + "no longer. Perhaps the homeserver is hiding the " + + "configuration event.", + ); + } + + // wait for all the room devices to be loaded + await this.trackRoomDevicesImpl(room); + + let content = event.getContent(); + // If event has an m.relates_to then we need + // to put this on the wrapping event instead + const mRelatesTo = content["m.relates_to"]; + if (mRelatesTo) { + // Clone content here so we don't remove `m.relates_to` from the local-echo + content = Object.assign({}, content); + delete content["m.relates_to"]; + } + + // Treat element's performance metrics the same as `m.relates_to` (when present) + const elementPerfMetrics = content["io.element.performance_metrics"]; + if (elementPerfMetrics) { + content = Object.assign({}, content); + delete content["io.element.performance_metrics"]; + } + + const encryptedContent = (await alg.encryptMessage(room, event.getType(), content)) as IContent; + + if (mRelatesTo) { + encryptedContent["m.relates_to"] = mRelatesTo; + } + if (elementPerfMetrics) { + encryptedContent["io.element.performance_metrics"] = elementPerfMetrics; + } + + event.makeEncrypted( + "m.room.encrypted", + encryptedContent, + this.olmDevice.deviceCurve25519Key!, + this.olmDevice.deviceEd25519Key!, + ); + } + + /** + * Decrypt a received event + * + * + * @returns resolves once we have + * finished decrypting. Rejects with an `algorithms.DecryptionError` if there + * is a problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { + if (event.isRedacted()) { + // Try to decrypt the redaction event, to support encrypted + // redaction reasons. If we can't decrypt, just fall back to using + // the original redacted_because. + const redactionEvent = new MatrixEvent({ + room_id: event.getRoomId(), + ...event.getUnsigned().redacted_because, + }); + let redactedBecause: IEvent = event.getUnsigned().redacted_because!; + if (redactionEvent.isEncrypted()) { + try { + const decryptedEvent = await this.decryptEvent(redactionEvent); + redactedBecause = decryptedEvent.clearEvent as IEvent; + } catch (e) { + logger.warn("Decryption of redaction failed. Falling back to unencrypted event.", e); + } + } + + return { + clearEvent: { + room_id: event.getRoomId(), + type: "m.room.message", + content: {}, + unsigned: { + redacted_because: redactedBecause, + }, + }, + }; + } else { + const content = event.getWireContent(); + const alg = this.getRoomDecryptor(event.getRoomId()!, content.algorithm); + return alg.decryptEvent(event); + } + } + + /** + * Handle the notification from /sync or /keys/changes that device lists have + * been changed. + * + * @param syncData - Object containing sync tokens associated with this sync + * @param syncDeviceLists - device_lists field from /sync, or response from + * /keys/changes + */ + public async handleDeviceListChanges( + syncData: ISyncStateData, + syncDeviceLists: Required<ISyncResponse>["device_lists"], + ): Promise<void> { + // Initial syncs don't have device change lists. We'll either get the complete list + // of changes for the interval or will have invalidated everything in willProcessSync + if (!syncData.oldSyncToken) return; + + // Here, we're relying on the fact that we only ever save the sync data after + // sucessfully saving the device list data, so we're guaranteed that the device + // list store is at least as fresh as the sync token from the sync store, ie. + // any device changes received in sync tokens prior to the 'next' token here + // have been processed and are reflected in the current device list. + // If we didn't make this assumption, we'd have to use the /keys/changes API + // to get key changes between the sync token in the device list and the 'old' + // sync token used here to make sure we didn't miss any. + await this.evalDeviceListChanges(syncDeviceLists); + } + + /** + * Send a request for some room keys, if we have not already done so + * + * @param resend - whether to resend the key request if there is + * already one + * + * @returns a promise that resolves when the key request is queued + */ + public requestRoomKey( + requestBody: IRoomKeyRequestBody, + recipients: IRoomKeyRequestRecipient[], + resend = false, + ): Promise<void> { + return this.outgoingRoomKeyRequestManager + .queueRoomKeyRequest(requestBody, recipients, resend) + .then(() => { + if (this.sendKeyRequestsImmediately) { + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + } + }) + .catch((e) => { + // this normally means we couldn't talk to the store + logger.error("Error requesting key for event", e); + }); + } + + /** + * Cancel any earlier room key request + * + * @param requestBody - parameters to match for cancellation + */ + public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): void { + this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch((e) => { + logger.warn("Error clearing pending room key requests", e); + }); + } + + /** + * Re-send any outgoing key requests, eg after verification + * @returns + */ + public async cancelAndResendAllOutgoingKeyRequests(): Promise<void> { + await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); + } + + /** + * handle an m.room.encryption event + * + * @param room - in which the event was received + * @param event - encryption event to be processed + */ + public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> { + const content = event.getContent<IRoomEncryption>(); + await this.setRoomEncryptionImpl(room, content); + } + + /** + * Called before the result of a sync is processed + * + * @param syncData - the data from the 'MatrixClient.sync' event + */ + public async onSyncWillProcess(syncData: ISyncStateData): Promise<void> { + if (!syncData.oldSyncToken) { + // If there is no old sync token, we start all our tracking from + // scratch, so mark everything as untracked. onCryptoEvent will + // be called for all e2e rooms during the processing of the sync, + // at which point we'll start tracking all the users of that room. + logger.log("Initial sync performed - resetting device tracking state"); + this.deviceList.stopTrackingAllDeviceLists(); + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + this.roomDeviceTrackingState = {}; + } + + this.sendKeyRequestsImmediately = false; + } + + /** + * handle the completion of a /sync + * + * This is called after the processing of each successful /sync response. + * It is an opportunity to do a batch process on the information received. + * + * @param syncData - the data from the 'MatrixClient.sync' event + */ + public async onSyncCompleted(syncData: OnSyncCompletedData): Promise<void> { + this.deviceList.setSyncToken(syncData.nextSyncToken ?? null); + this.deviceList.saveIfDirty(); + + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + + this.deviceList.refreshOutdatedDeviceLists(); + + // we don't start uploading one-time keys until we've caught up with + // to-device messages, to help us avoid throwing away one-time-keys that we + // are about to receive messages for + // (https://github.com/vector-im/element-web/issues/2782). + if (!syncData.catchingUp) { + this.maybeUploadOneTimeKeys(); + this.processReceivedRoomKeyRequests(); + + // likewise don't start requesting keys until we've caught up + // on to_device messages, otherwise we'll request keys that we're + // just about to get. + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + + // Sync has finished so send key requests straight away. + this.sendKeyRequestsImmediately = true; + } + } + + /** + * Trigger the appropriate invalidations and removes for a given + * device list + * + * @param deviceLists - device_lists field from /sync, or response from + * /keys/changes + */ + private async evalDeviceListChanges(deviceLists: Required<ISyncResponse>["device_lists"]): Promise<void> { + if (Array.isArray(deviceLists?.changed)) { + deviceLists.changed.forEach((u) => { + this.deviceList.invalidateUserDeviceList(u); + }); + } + + if (Array.isArray(deviceLists?.left) && deviceLists.left.length) { + // Check we really don't share any rooms with these users + // any more: the server isn't required to give us the + // exact correct set. + const e2eUserIds = new Set(await this.getTrackedE2eUsers()); + + deviceLists.left.forEach((u) => { + if (!e2eUserIds.has(u)) { + this.deviceList.stopTrackingDeviceList(u); + } + }); + } + } + + /** + * Get a list of all the IDs of users we share an e2e room with + * for which we are tracking devices already + * + * @returns List of user IDs + */ + private async getTrackedE2eUsers(): Promise<string[]> { + const e2eUserIds: string[] = []; + for (const room of this.getTrackedE2eRooms()) { + const members = await room.getEncryptionTargetMembers(); + for (const member of members) { + e2eUserIds.push(member.userId); + } + } + return e2eUserIds; + } + + /** + * Get a list of the e2e-enabled rooms we are members of, + * and for which we are already tracking the devices + * + * @returns + */ + private getTrackedE2eRooms(): Room[] { + return this.clientStore.getRooms().filter((room) => { + // check for rooms with encryption enabled + const alg = this.roomEncryptors.get(room.roomId); + if (!alg) { + return false; + } + if (!this.roomDeviceTrackingState[room.roomId]) { + return false; + } + + // ignore any rooms which we have left + const myMembership = room.getMyMembership(); + return myMembership === "join" || myMembership === "invite"; + }); + } + + /** + * Encrypts and sends a given object via Olm to-device messages to a given + * set of devices. + * @param userDeviceInfoArr - the devices to send to + * @param payload - fields to include in the encrypted payload + * @returns Promise which + * resolves once the message has been encrypted and sent to the given + * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }` + * of the successfully sent messages. + */ + public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> { + const toDeviceBatch: ToDeviceBatch = { + eventType: EventType.RoomMessageEncrypted, + batch: [], + }; + + try { + await Promise.all( + userDeviceInfoArr.map(async ({ userId, deviceInfo }) => { + const deviceId = deviceInfo.deviceId; + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + + toDeviceBatch.batch.push({ + userId, + deviceId, + payload: encryptedContent, + }); + + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, + this.baseApis, + new Map([[userId, [deviceInfo]]]), + ); + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ); + }), + ); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + toDeviceBatch.batch = toDeviceBatch.batch.filter((msg) => { + if (Object.keys(msg.payload.ciphertext).length > 0) { + return true; + } else { + logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`); + return false; + } + }); + + try { + await this.baseApis.queueToDevice(toDeviceBatch); + } catch (e) { + logger.error("sendToDevice failed", e); + throw e; + } + } catch (e) { + logger.error("encryptAndSendToDevices promises failed", e); + throw e; + } + } + + private onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string): void => { + try { + this.onRoomMembership(event, member, oldMembership); + } catch (e) { + logger.error("Error handling membership change:", e); + } + }; + + public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> { + // all we do here is filter out encrypted to-device messages with the wrong algorithm. Decryption + // happens later in decryptEvent, via the EventMapper + return events.filter((toDevice) => { + if ( + toDevice.type === EventType.RoomMessageEncrypted && + !["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm) + ) { + logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender); + return false; + } + return true; + }); + } + + public preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> { + const currentCount = oneTimeKeysCounts.get("signed_curve25519") || 0; + this.updateOneTimeKeyCount(currentCount); + return Promise.resolve(); + } + + public preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> { + this.setNeedsNewFallback(!unusedFallbackKeys.has("signed_curve25519")); + return Promise.resolve(); + } + + private onToDeviceEvent = (event: MatrixEvent): void => { + try { + logger.log( + `received to-device ${event.getType()} from: ` + + `${event.getSender()} id: ${event.getContent()[ToDeviceMessageId]}`, + ); + + if (event.getType() == "m.room_key" || event.getType() == "m.forwarded_room_key") { + this.onRoomKeyEvent(event); + } else if (event.getType() == "m.room_key_request") { + this.onRoomKeyRequestEvent(event); + } else if (event.getType() === "m.secret.request") { + this.secretStorage.onRequestReceived(event); + } else if (event.getType() === "m.secret.send") { + this.secretStorage.onSecretReceived(event); + } else if (event.getType() === "m.room_key.withheld") { + this.onRoomKeyWithheldEvent(event); + } else if (event.getContent().transaction_id) { + this.onKeyVerificationMessage(event); + } else if (event.getContent().msgtype === "m.bad.encrypted") { + this.onToDeviceBadEncrypted(event); + } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + if (!event.isBeingDecrypted()) { + event.attemptDecryption(this); + } + // once the event has been decrypted, try again + event.once(MatrixEventEvent.Decrypted, (ev) => { + this.onToDeviceEvent(ev); + }); + } + } catch (e) { + logger.error("Error handling toDeviceEvent:", e); + } + }; + + /** + * Handle a key event + * + * @internal + * @param event - key event + */ + private onRoomKeyEvent(event: MatrixEvent): void { + const content = event.getContent(); + + if (!content.room_id || !content.algorithm) { + logger.error("key event is missing fields"); + return; + } + + if (!this.backupManager.checkedForBackup) { + // don't bother awaiting on this - the important thing is that we retry if we + // haven't managed to check before + this.backupManager.checkAndStart(); + } + + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + alg.onRoomKeyEvent(event); + } + + /** + * Handle a key withheld event + * + * @internal + * @param event - key withheld event + */ + private onRoomKeyWithheldEvent(event: MatrixEvent): void { + const content = event.getContent(); + + if ( + (content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) || + !content.algorithm || + !content.sender_key + ) { + logger.error("key withheld event is missing fields"); + return; + } + + logger.info( + `Got room key withheld event from ${event.getSender()} ` + + `for ${content.algorithm} session ${content.sender_key}|${content.session_id} ` + + `in room ${content.room_id} with code ${content.code} (${content.reason})`, + ); + + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + if (alg.onRoomKeyWithheldEvent) { + alg.onRoomKeyWithheldEvent(event); + } + if (!content.room_id) { + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const roomDecryptors = this.getRoomDecryptors(content.algorithm); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(content.sender_key); + } + } + } + + /** + * Handle a general key verification event. + * + * @internal + * @param event - verification start event + */ + private onKeyVerificationMessage(event: MatrixEvent): void { + if (!ToDeviceChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = (event: MatrixEvent): VerificationRequest | undefined => { + if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { + return; + } + const content = event.getContent(); + const deviceId = content && content.from_device; + if (!deviceId) { + return; + } + const userId = event.getSender()!; + const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId]); + return new VerificationRequest(channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent(event, this.toDeviceVerificationRequests, createRequest); + } + + /** + * Handle key verification requests sent as timeline events + * + * @internal + * @param event - the timeline event + * @param room - not used + * @param atStart - not used + * @param removed - not used + * @param whether - this is a live event + */ + private onTimelineEvent = ( + event: MatrixEvent, + room: Room, + atStart: boolean, + removed: boolean, + { liveEvent = true } = {}, + ): void => { + if (!InRoomChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = (event: MatrixEvent): VerificationRequest => { + const channel = new InRoomChannel(this.baseApis, event.getRoomId()!); + return new VerificationRequest(channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent(event, this.inRoomVerificationRequests, createRequest, liveEvent); + }; + + private async handleVerificationEvent( + event: MatrixEvent, + requestsMap: IRequestsMap, + createRequest: (event: MatrixEvent) => VerificationRequest | undefined, + isLiveEvent = true, + ): Promise<void> { + // Wait for event to get its final ID with pendingEventOrdering: "chronological", since DM channels depend on it. + if (event.isSending() && event.status != EventStatus.SENT) { + let eventIdListener: () => void; + let statusListener: () => void; + try { + await new Promise<void>((resolve, reject) => { + eventIdListener = resolve; + statusListener = (): void => { + if (event.status == EventStatus.CANCELLED) { + reject(new Error("Event status set to CANCELLED.")); + } + }; + event.once(MatrixEventEvent.LocalEventIdReplaced, eventIdListener); + event.on(MatrixEventEvent.Status, statusListener); + }); + } catch (err) { + logger.error("error while waiting for the verification event to be sent: ", err); + return; + } finally { + event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener!); + event.removeListener(MatrixEventEvent.Status, statusListener!); + } + } + let request: VerificationRequest | undefined = requestsMap.getRequest(event); + let isNewRequest = false; + if (!request) { + request = createRequest(event); + // a request could not be made from this event, so ignore event + if (!request) { + logger.log( + `Crypto: could not find VerificationRequest for ` + + `${event.getType()}, and could not create one, so ignoring.`, + ); + return; + } + isNewRequest = true; + requestsMap.setRequest(event, request); + } + event.setVerificationRequest(request); + try { + await request.channel.handleEvent(event, request, isLiveEvent); + } catch (err) { + logger.error("error while handling verification event", err); + } + const shouldEmit = + isNewRequest && + !request.initiatedByMe && + !request.invalid && // check it has enough events to pass the UNSENT stage + !request.observeOnly; + if (shouldEmit) { + this.baseApis.emit(CryptoEvent.VerificationRequest, request); + } + } + + /** + * Handle a toDevice event that couldn't be decrypted + * + * @internal + * @param event - undecryptable event + */ + private async onToDeviceBadEncrypted(event: MatrixEvent): Promise<void> { + const content = event.getWireContent(); + const sender = event.getSender(); + const algorithm = content.algorithm; + const deviceKey = content.sender_key; + + this.baseApis.emit(ClientEvent.UndecryptableToDeviceEvent, event); + + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const retryDecryption = (): void => { + const roomDecryptors = this.getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(deviceKey); + } + }; + + if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { + return; + } + + // check when we last forced a new session with this device: if we've already done so + // recently, don't do it again. + const lastNewSessionDevices = this.lastNewSessionForced.getOrCreate(sender); + const lastNewSessionForced = lastNewSessionDevices.getOrCreate(deviceKey); + if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { + logger.debug( + "New session already forced with device " + + sender + + ":" + + deviceKey + + " at " + + lastNewSessionForced + + ": not forcing another", + ); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + return; + } + + // establish a new olm session with this device since we're failing to decrypt messages + // on a current session. + // Note that an undecryptable message from another device could easily be spoofed - + // is there anything we can do to mitigate this? + let device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.downloadKeys([sender], false); + device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + logger.info("Couldn't find device for identity key " + deviceKey + ": not re-establishing session"); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", false); + retryDecryption(); + return; + } + } + const devicesByUser = new Map([[sender, [device]]]); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true); + + lastNewSessionDevices.set(deviceKey, Date.now()); + + // Now send a blank message on that session so the other side knows about it. + // (The keyshare request is sent in the clear so that won't do) + // We send this first such that, as long as the toDevice messages arrive in the + // same order we sent them, the other end will get this first, set up the new session, + // then get the keyshare request and send the key over this new session (because it + // is the session it has most recently received a message on). + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + + await this.baseApis.sendToDevice( + "m.room.encrypted", + new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]), + ); + + // Most of the time this probably won't be necessary since we'll have queued up a key request when + // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending + // it. This won't always be the case though so we need to re-send any that have already been sent + // to avoid races. + const requestsToResend = await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( + sender, + device.deviceId, + ); + for (const keyReq of requestsToResend) { + this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); + } + } + + /** + * Handle a change in the membership state of a member of a room + * + * @internal + * @param event - event causing the change + * @param member - user whose membership changed + * @param oldMembership - previous membership + */ + private onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void { + // this event handler is registered on the *client* (as opposed to the room + // member itself), which means it is only called on changes to the *live* + // membership state (ie, it is not called when we back-paginate, nor when + // we load the state in the initialsync). + // + // Further, it is automatically registered and called when new members + // arrive in the room. + + const roomId = member.roomId; + + const alg = this.roomEncryptors.get(roomId); + if (!alg) { + // not encrypting in this room + return; + } + // only mark users in this room as tracked if we already started tracking in this room + // this way we don't start device queries after sync on behalf of this room which we won't use + // the result of anyway, as we'll need to do a query again once all the members are fetched + // by calling _trackRoomDevices + if (roomId in this.roomDeviceTrackingState) { + if (member.membership == "join") { + logger.log("Join event for " + member.userId + " in " + roomId); + // make sure we are tracking the deviceList for this user + this.deviceList.startTrackingDeviceList(member.userId); + } else if ( + member.membership == "invite" && + this.clientStore.getRoom(roomId)?.shouldEncryptForInvitedMembers() + ) { + logger.log("Invite event for " + member.userId + " in " + roomId); + this.deviceList.startTrackingDeviceList(member.userId); + } + } + + alg.onRoomMembership(event, member, oldMembership); + } + + /** + * Called when we get an m.room_key_request event. + * + * @internal + * @param event - key request event + */ + private onRoomKeyRequestEvent(event: MatrixEvent): void { + const content = event.getContent(); + if (content.action === "request") { + // Queue it up for now, because they tend to arrive before the room state + // events at initial sync, and we want to see if we know anything about the + // room before passing them on to the app. + const req = new IncomingRoomKeyRequest(event); + this.receivedRoomKeyRequests.push(req); + } else if (content.action === "request_cancellation") { + const req = new IncomingRoomKeyRequestCancellation(event); + this.receivedRoomKeyRequestCancellations.push(req); + } + } + + /** + * Process any m.room_key_request events which were queued up during the + * current sync. + * + * @internal + */ + private async processReceivedRoomKeyRequests(): Promise<void> { + if (this.processingRoomKeyRequests) { + // we're still processing last time's requests; keep queuing new ones + // up for now. + return; + } + this.processingRoomKeyRequests = true; + + try { + // we need to grab and clear the queues in the synchronous bit of this method, + // so that we don't end up racing with the next /sync. + const requests = this.receivedRoomKeyRequests; + this.receivedRoomKeyRequests = []; + const cancellations = this.receivedRoomKeyRequestCancellations; + this.receivedRoomKeyRequestCancellations = []; + + // Process all of the requests, *then* all of the cancellations. + // + // This makes sure that if we get a request and its cancellation in the + // same /sync result, then we process the request before the + // cancellation (and end up with a cancelled request), rather than the + // cancellation before the request (and end up with an outstanding + // request which should have been cancelled.) + await Promise.all(requests.map((req) => this.processReceivedRoomKeyRequest(req))); + await Promise.all( + cancellations.map((cancellation) => this.processReceivedRoomKeyRequestCancellation(cancellation)), + ); + } catch (e) { + logger.error(`Error processing room key requsts: ${e}`); + } finally { + this.processingRoomKeyRequests = false; + } + } + + /** + * Helper for processReceivedRoomKeyRequests + * + */ + private async processReceivedRoomKeyRequest(req: IncomingRoomKeyRequest): Promise<void> { + const userId = req.userId; + const deviceId = req.deviceId; + + const body = req.requestBody; + const roomId = body.room_id; + const alg = body.algorithm; + + logger.log( + `m.room_key_request from ${userId}:${deviceId}` + + ` for ${roomId} / ${body.session_id} (id ${req.requestId})`, + ); + + if (userId !== this.userId) { + if (!this.roomEncryptors.get(roomId)) { + logger.debug(`room key request for unencrypted room ${roomId}`); + return; + } + const encryptor = this.roomEncryptors.get(roomId)!; + const device = this.deviceList.getStoredDevice(userId, deviceId); + if (!device) { + logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); + return; + } + + try { + await encryptor.reshareKeyWithDevice!(body.sender_key, body.session_id, userId, device); + } catch (e) { + logger.warn( + "Failed to re-share keys for session " + + body.session_id + + " with device " + + userId + + ":" + + device.deviceId, + e, + ); + } + return; + } + + if (deviceId === this.deviceId) { + // We'll always get these because we send room key requests to + // '*' (ie. 'all devices') which includes the sending device, + // so ignore requests from ourself because apart from it being + // very silly, it won't work because an Olm session cannot send + // messages to itself. + // The log here is probably superfluous since we know this will + // always happen, but let's log anyway for now just in case it + // causes issues. + logger.log("Ignoring room key request from ourselves"); + return; + } + + // todo: should we queue up requests we don't yet have keys for, + // in case they turn up later? + + // if we don't have a decryptor for this room/alg, we don't have + // the keys for the requested events, and can drop the requests. + if (!this.roomDecryptors.has(roomId)) { + logger.log(`room key request for unencrypted room ${roomId}`); + return; + } + + const decryptor = this.roomDecryptors.get(roomId)!.get(alg); + if (!decryptor) { + logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); + return; + } + + if (!(await decryptor.hasKeysForKeyRequest(req))) { + logger.log(`room key request for unknown session ${roomId} / ` + body.session_id); + return; + } + + req.share = (): void => { + decryptor.shareKeysWithDevice(req); + }; + + // if the device is verified already, share the keys + if (this.checkDeviceTrust(userId, deviceId).isVerified()) { + logger.log("device is already verified: sharing keys"); + req.share(); + return; + } + + this.emit(CryptoEvent.RoomKeyRequest, req); + } + + /** + * Helper for processReceivedRoomKeyRequests + * + */ + private async processReceivedRoomKeyRequestCancellation( + cancellation: IncomingRoomKeyRequestCancellation, + ): Promise<void> { + logger.log( + `m.room_key_request cancellation for ${cancellation.userId}:` + + `${cancellation.deviceId} (id ${cancellation.requestId})`, + ); + + // we should probably only notify the app of cancellations we told it + // about, but we don't currently have a record of that, so we just pass + // everything through. + this.emit(CryptoEvent.RoomKeyRequestCancellation, cancellation); + } + + /** + * Get a decryptor for a given room and algorithm. + * + * If we already have a decryptor for the given room and algorithm, return + * it. Otherwise try to instantiate it. + * + * @internal + * + * @param roomId - room id for decryptor. If undefined, a temporary + * decryptor is instantiated. + * + * @param algorithm - crypto algorithm + * + * @throws {@link DecryptionError} if the algorithm is unknown + */ + public getRoomDecryptor(roomId: string | null, algorithm: string): DecryptionAlgorithm { + let decryptors: Map<string, DecryptionAlgorithm> | undefined; + let alg: DecryptionAlgorithm | undefined; + + if (roomId) { + decryptors = this.roomDecryptors.get(roomId); + if (!decryptors) { + decryptors = new Map<string, DecryptionAlgorithm>(); + this.roomDecryptors.set(roomId, decryptors); + } + + alg = decryptors.get(algorithm); + if (alg) { + return alg; + } + } + + const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm); + if (!AlgClass) { + throw new algorithms.DecryptionError( + "UNKNOWN_ENCRYPTION_ALGORITHM", + 'Unknown encryption algorithm "' + algorithm + '".', + ); + } + alg = new AlgClass({ + userId: this.userId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId: roomId ?? undefined, + }); + + if (decryptors) { + decryptors.set(algorithm, alg); + } + return alg; + } + + /** + * Get all the room decryptors for a given encryption algorithm. + * + * @param algorithm - The encryption algorithm + * + * @returns An array of room decryptors + */ + private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] { + const decryptors: DecryptionAlgorithm[] = []; + for (const d of this.roomDecryptors.values()) { + if (d.has(algorithm)) { + decryptors.push(d.get(algorithm)!); + } + } + return decryptors; + } + + /** + * sign the given object with our ed25519 key + * + * @param obj - Object to which we will add a 'signatures' property + */ + public async signObject<T extends ISignableObject & object>(obj: T): Promise<void> { + const sigs = new Map(Object.entries(obj.signatures || {})); + const unsigned = obj.unsigned; + + delete obj.signatures; + delete obj.unsigned; + + const userSignatures = sigs.get(this.userId) || {}; + sigs.set(this.userId, userSignatures); + userSignatures["ed25519:" + this.deviceId] = await this.olmDevice.sign(anotherjson.stringify(obj)); + obj.signatures = recursiveMapToObject(sigs); + if (unsigned !== undefined) obj.unsigned = unsigned; + } +} + +/** + * Fix up the backup key, that may be in the wrong format due to a bug in a + * migration step. Some backup keys were stored as a comma-separated list of + * integers, rather than a base64-encoded byte array. If this function is + * passed a string that looks like a list of integers rather than a base64 + * string, it will attempt to convert it to the right format. + * + * @param key - the key to check + * @returns If the key is in the wrong format, then the fixed + * key will be returned. Otherwise null will be returned. + * + */ +export function fixBackupKey(key?: string): string | null { + if (typeof key !== "string" || key.indexOf(",") < 0) { + return null; + } + const fixedKey = Uint8Array.from(key.split(","), (x) => parseInt(x)); + return olmlib.encodeBase64(fixedKey); +} + +/** + * Represents a received m.room_key_request event + */ +export class IncomingRoomKeyRequest { + /** user requesting the key */ + public readonly userId: string; + /** device requesting the key */ + public readonly deviceId: string; + /** unique id for the request */ + public readonly requestId: string; + public readonly requestBody: IRoomKeyRequestBody; + /** + * callback which, when called, will ask + * the relevant crypto algorithm implementation to share the keys for + * this request. + */ + public share: () => void; + + public constructor(event: MatrixEvent) { + const content = event.getContent(); + + this.userId = event.getSender()!; + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + this.requestBody = content.body || {}; + this.share = (): void => { + throw new Error("don't know how to share keys for this request yet"); + }; + } +} + +/** + * Represents a received m.room_key_request cancellation + */ +class IncomingRoomKeyRequestCancellation { + /** user requesting the cancellation */ + public readonly userId: string; + /** device requesting the cancellation */ + public readonly deviceId: string; + /** unique id for the request to be cancelled */ + public readonly requestId: string; + + public constructor(event: MatrixEvent) { + const content = event.getContent(); + + this.userId = event.getSender()!; + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + } +} + +// a number of types are re-exported for backwards compatibility, in case any applications are referencing it. +export type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts new file mode 100644 index 0000000..f6fe7b6 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts @@ -0,0 +1,93 @@ +/* +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 { randomString } from "../randomstring"; +import { subtleCrypto, TextEncoder } from "./crypto"; + +const DEFAULT_ITERATIONS = 500000; + +const DEFAULT_BITSIZE = 256; + +/* eslint-disable camelcase */ +interface IAuthData { + private_key_salt?: string; + private_key_iterations?: number; + private_key_bits?: number; +} +/* eslint-enable camelcase */ + +interface IKey { + key: Uint8Array; + salt: string; + iterations: number; +} + +export function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> { + if (!global.Olm) { + throw new Error("Olm is not available"); + } + + if (!authData.private_key_salt || !authData.private_key_iterations) { + throw new Error("Salt and/or iterations not found: " + "this backup cannot be restored with a passphrase"); + } + + return deriveKey( + password, + authData.private_key_salt, + authData.private_key_iterations, + authData.private_key_bits || DEFAULT_BITSIZE, + ); +} + +export async function keyFromPassphrase(password: string): Promise<IKey> { + if (!global.Olm) { + throw new Error("Olm is not available"); + } + + const salt = randomString(32); + + const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE); + + return { key, salt, iterations: DEFAULT_ITERATIONS }; +} + +export async function deriveKey( + password: string, + salt: string, + iterations: number, + numBits = DEFAULT_BITSIZE, +): Promise<Uint8Array> { + if (!subtleCrypto || !TextEncoder) { + throw new Error("Password-based backup is not available on this platform"); + } + + const key = await subtleCrypto.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, [ + "deriveBits", + ]); + + const keybits = await subtleCrypto.deriveBits( + { + name: "PBKDF2", + salt: new TextEncoder().encode(salt), + iterations: iterations, + hash: "SHA-512", + }, + key, + numBits, + ); + + return new Uint8Array(keybits); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts new file mode 100644 index 0000000..67e213c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts @@ -0,0 +1,77 @@ +/* +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 { ISigned } from "../@types/signed"; +import { IEncryptedPayload } from "./aes"; + +export interface Curve25519SessionData { + ciphertext: string; + ephemeral: string; + mac: string; +} + +/* eslint-disable camelcase */ +export interface IKeyBackupSession<T = Curve25519SessionData | IEncryptedPayload> { + first_message_index: number; + forwarded_count: number; + is_verified: boolean; + session_data: T; +} + +export interface IKeyBackupRoomSessions { + [sessionId: string]: IKeyBackupSession; +} + +export interface ICurve25519AuthData { + public_key: string; + private_key_salt?: string; + private_key_iterations?: number; + private_key_bits?: number; +} + +export interface IAes256AuthData { + iv: string; + mac: string; + private_key_salt?: string; + private_key_iterations?: number; +} + +export interface IKeyBackupInfo { + algorithm: string; + auth_data: ISigned & (ICurve25519AuthData | IAes256AuthData); + count?: number; + etag?: string; + version?: string; // number contained within +} +/* eslint-enable camelcase */ + +export interface IKeyBackupPrepareOpts { + /** + * Whether to use Secure Secret Storage to store the key encrypting key backups. + * Optional, defaults to false. + */ + secureSecretStorage: boolean; +} + +export interface IKeyBackupRestoreResult { + total: number; + imported: number; +} + +export interface IKeyBackupRestoreOpts { + cacheCompleteCallback?: () => void; + progressCallback?: (progress: { stage: string }) => void; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts new file mode 100644 index 0000000..c37b7f0 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts @@ -0,0 +1,566 @@ +/* +Copyright 2016 - 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. +*/ + +/** + * Utilities common to olm encryption algorithms + */ + +import anotherjson from "another-json"; + +import type { PkSigning } from "@matrix-org/olm"; +import type { IOneTimeKey } from "../@types/crypto"; +import { OlmDevice } from "./OlmDevice"; +import { DeviceInfo } from "./deviceinfo"; +import { logger } from "../logger"; +import { IClaimOTKsResult, MatrixClient } from "../client"; +import { ISignatures } from "../@types/signed"; +import { MatrixEvent } from "../models/event"; +import { EventType } from "../@types/event"; +import { IMessage } from "./algorithms/olm"; +import { MapWithDefault } from "../utils"; + +enum Algorithm { + Olm = "m.olm.v1.curve25519-aes-sha2", + Megolm = "m.megolm.v1.aes-sha2", + MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2", +} + +/** + * matrix algorithm tag for olm + */ +export const OLM_ALGORITHM = Algorithm.Olm; + +/** + * matrix algorithm tag for megolm + */ +export const MEGOLM_ALGORITHM = Algorithm.Megolm; + +/** + * matrix algorithm tag for megolm backups + */ +export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; + +export interface IOlmSessionResult { + /** device info */ + device: DeviceInfo; + /** base64 olm session id; null if no session could be established */ + sessionId: string | null; +} + +/** + * Encrypt an event payload for an Olm device + * + * @param resultsObject - The `ciphertext` property + * of the m.room.encrypted event to which to add our result + * + * @param olmDevice - olm.js wrapper + * @param payloadFields - fields to include in the encrypted payload + * + * Returns a promise which resolves (to undefined) when the payload + * has been encrypted into `resultsObject` + */ +export async function encryptMessageForDevice( + resultsObject: Record<string, IMessage>, + ourUserId: string, + ourDeviceId: string | undefined, + olmDevice: OlmDevice, + recipientUserId: string, + recipientDevice: DeviceInfo, + payloadFields: Record<string, any>, +): Promise<void> { + const deviceKey = recipientDevice.getIdentityKey(); + const sessionId = await olmDevice.getSessionIdForDevice(deviceKey); + if (sessionId === null) { + // If we don't have a session for a device then + // we can't encrypt a message for it. + logger.log( + `[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` + + `${recipientUserId}:${recipientDevice.deviceId}`, + ); + return; + } + + logger.log( + `[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` + + `${recipientUserId}:${recipientDevice.deviceId}`, + ); + + const payload = { + sender: ourUserId, + // TODO this appears to no longer be used whatsoever + sender_device: ourDeviceId, + + // Include the Ed25519 key so that the recipient knows what + // device this message came from. + // We don't need to include the curve25519 key since the + // recipient will already know this from the olm headers. + // When combined with the device keys retrieved from the + // homeserver signed by the ed25519 key this proves that + // the curve25519 key and the ed25519 key are owned by + // the same device. + keys: { + ed25519: olmDevice.deviceEd25519Key, + }, + + // include the recipient device details in the payload, + // to avoid unknown key attacks, per + // https://github.com/vector-im/vector-web/issues/2483 + recipient: recipientUserId, + recipient_keys: { + ed25519: recipientDevice.getFingerprint(), + }, + ...payloadFields, + }; + + // TODO: technically, a bunch of that stuff only needs to be included for + // pre-key messages: after that, both sides know exactly which devices are + // involved in the session. If we're looking to reduce data transfer in the + // future, we could elide them for subsequent messages. + + resultsObject[deviceKey] = await olmDevice.encryptMessage(deviceKey, sessionId, JSON.stringify(payload)); +} + +interface IExistingOlmSession { + device: DeviceInfo; + sessionId: string | null; +} + +/** + * Get the existing olm sessions for the given devices, and the devices that + * don't have olm sessions. + * + * + * + * @param devicesByUser - map from userid to list of devices to ensure sessions for + * + * @returns resolves to an array. The first element of the array is a + * a map of user IDs to arrays of deviceInfo, representing the devices that + * don't have established olm sessions. The second element of the array is + * a map from userId to deviceId to {@link OlmSessionResult} + */ +export async function getExistingOlmSessions( + olmDevice: OlmDevice, + baseApis: MatrixClient, + devicesByUser: Record<string, DeviceInfo[]>, +): Promise<[Map<string, DeviceInfo[]>, Map<string, Map<string, IExistingOlmSession>>]> { + // map user Id → DeviceInfo[] + const devicesWithoutSession: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []); + // map user Id → device Id → IExistingOlmSession + const sessions: MapWithDefault<string, Map<string, IExistingOlmSession>> = new MapWithDefault(() => new Map()); + + const promises: Promise<void>[] = []; + + for (const [userId, devices] of Object.entries(devicesByUser)) { + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + promises.push( + (async (): Promise<void> => { + const sessionId = await olmDevice.getSessionIdForDevice(key, true); + if (sessionId === null) { + devicesWithoutSession.getOrCreate(userId).push(deviceInfo); + } else { + sessions.getOrCreate(userId).set(deviceId, { + device: deviceInfo, + sessionId: sessionId, + }); + } + })(), + ); + } + } + + await Promise.all(promises); + + return [devicesWithoutSession, sessions]; +} + +/** + * Try to make sure we have established olm sessions for the given devices. + * + * @param devicesByUser - map from userid to list of devices to ensure sessions for + * + * @param force - If true, establish a new session even if one + * already exists. + * + * @param otkTimeout - The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param failedServers - An array to fill with remote servers that + * failed to respond to one-time-key requests. + * + * @param log - A possibly customised log + * + * @returns resolves once the sessions are complete, to + * an Object mapping from userId to deviceId to + * {@link OlmSessionResult} + */ +export async function ensureOlmSessionsForDevices( + olmDevice: OlmDevice, + baseApis: MatrixClient, + devicesByUser: Map<string, DeviceInfo[]>, + force = false, + otkTimeout?: number, + failedServers?: string[], + log = logger, +): Promise<Map<string, Map<string, IOlmSessionResult>>> { + const devicesWithoutSession: [string, string][] = [ + // [userId, deviceId], ... + ]; + // map user Id → device Id → IExistingOlmSession + const result: Map<string, Map<string, IExistingOlmSession>> = new Map(); + // map device key → resolve session fn + const resolveSession: Map<string, (sessionId?: string) => void> = new Map(); + + // Mark all sessions this task intends to update as in progress. It is + // important to do this for all devices this task cares about in a single + // synchronous operation, as otherwise it is possible to have deadlocks + // where multiple tasks wait indefinitely on another task to update some set + // of common devices. + for (const devices of devicesByUser.values()) { + for (const deviceInfo of devices) { + const key = deviceInfo.getIdentityKey(); + + if (key === olmDevice.deviceCurve25519Key) { + // We don't start sessions with ourself, so there's no need to + // mark it in progress. + continue; + } + + if (!olmDevice.sessionsInProgress[key]) { + // pre-emptively mark the session as in-progress to avoid race + // conditions. If we find that we already have a session, then + // we'll resolve + olmDevice.sessionsInProgress[key] = new Promise((resolve) => { + resolveSession.set(key, (v: any): void => { + delete olmDevice.sessionsInProgress[key]; + resolve(v); + }); + }); + } + } + } + + for (const [userId, devices] of devicesByUser) { + const resultDevices = new Map(); + result.set(userId, resultDevices); + + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + + if (key === olmDevice.deviceCurve25519Key) { + // We should never be trying to start a session with ourself. + // Apart from talking to yourself being the first sign of madness, + // olm sessions can't do this because they get confused when + // they get a message and see that the 'other side' has started a + // new chain when this side has an active sender chain. + // If you see this message being logged in the wild, we should find + // the thing that is trying to send Olm messages to itself and fix it. + log.info("Attempted to start session with ourself! Ignoring"); + // We must fill in the section in the return value though, as callers + // expect it to be there. + resultDevices.set(deviceId, { + device: deviceInfo, + sessionId: null, + }); + continue; + } + + const forWhom = `for ${key} (${userId}:${deviceId})`; + const sessionId = await olmDevice.getSessionIdForDevice(key, !!resolveSession.get(key), log); + const resolveSessionFn = resolveSession.get(key); + if (sessionId !== null && resolveSessionFn) { + // we found a session, but we had marked the session as + // in-progress, so resolve it now, which will unmark it and + // unblock anything that was waiting + resolveSessionFn(); + } + if (sessionId === null || force) { + if (force) { + log.info(`Forcing new Olm session ${forWhom}`); + } else { + log.info(`Making new Olm session ${forWhom}`); + } + devicesWithoutSession.push([userId, deviceId]); + } + resultDevices.set(deviceId, { + device: deviceInfo, + sessionId: sessionId, + }); + } + } + + if (devicesWithoutSession.length === 0) { + return result; + } + + const oneTimeKeyAlgorithm = "signed_curve25519"; + let res: IClaimOTKsResult; + let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; + try { + log.debug(`Claiming ${taskDetail}`); + res = await baseApis.claimOneTimeKeys(devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout); + log.debug(`Claimed ${taskDetail}`); + } catch (e) { + for (const resolver of resolveSession.values()) { + resolver(); + } + log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); + throw e; + } + + if (failedServers && "failures" in res) { + failedServers.push(...Object.keys(res.failures)); + } + + const otkResult = res.one_time_keys || ({} as IClaimOTKsResult["one_time_keys"]); + const promises: Promise<void>[] = []; + for (const [userId, devices] of devicesByUser) { + const userRes = otkResult[userId] || {}; + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + + if (key === olmDevice.deviceCurve25519Key) { + // We've already logged about this above. Skip here too + // otherwise we'll log saying there are no one-time keys + // which will be confusing. + continue; + } + + if (result.get(userId)?.get(deviceId)?.sessionId && !force) { + // we already have a result for this device + continue; + } + + const deviceRes = userRes[deviceId] || {}; + let oneTimeKey: IOneTimeKey | null = null; + for (const keyId in deviceRes) { + if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) { + oneTimeKey = deviceRes[keyId]; + } + } + + if (!oneTimeKey) { + log.warn(`No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`); + resolveSession.get(key)?.(); + continue; + } + + promises.push( + _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then( + (sid) => { + resolveSession.get(key)?.(sid ?? undefined); + const deviceInfo = result.get(userId)?.get(deviceId); + if (deviceInfo) deviceInfo.sessionId = sid; + }, + (e) => { + resolveSession.get(key)?.(); + throw e; + }, + ), + ); + } + } + + taskDetail = `Olm sessions for ${promises.length} devices`; + log.debug(`Starting ${taskDetail}`); + await Promise.all(promises); + log.debug(`Started ${taskDetail}`); + return result; +} + +async function _verifyKeyAndStartSession( + olmDevice: OlmDevice, + oneTimeKey: IOneTimeKey, + userId: string, + deviceInfo: DeviceInfo, +): Promise<string | null> { + const deviceId = deviceInfo.deviceId; + try { + await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint()); + } catch (e) { + logger.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e); + return null; + } + + let sid; + try { + sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key); + } catch (e) { + // possibly a bad key + logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e); + return null; + } + + logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId); + return sid; +} + +export interface IObject { + unsigned?: object; + signatures?: ISignatures; +} + +/** + * Verify the signature on an object + * + * @param olmDevice - olm wrapper to use for verify op + * + * @param obj - object to check signature on. + * + * @param signingUserId - ID of the user whose signature should be checked + * + * @param signingDeviceId - ID of the device whose signature should be checked + * + * @param signingKey - base64-ed ed25519 public key + * + * Returns a promise which resolves (to undefined) if the the signature is good, + * or rejects with an Error if it is bad. + */ +export async function verifySignature( + olmDevice: OlmDevice, + obj: IOneTimeKey | IObject, + signingUserId: string, + signingDeviceId: string, + signingKey: string, +): Promise<void> { + const signKeyId = "ed25519:" + signingDeviceId; + const signatures = obj.signatures || {}; + const userSigs = signatures[signingUserId] || {}; + const signature = userSigs[signKeyId]; + if (!signature) { + throw Error("No signature"); + } + + // prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson + const mangledObj = Object.assign({}, obj); + if ("unsigned" in mangledObj) { + delete mangledObj.unsigned; + } + delete mangledObj.signatures; + const json = anotherjson.stringify(mangledObj); + + olmDevice.verifySignature(signingKey, json, signature); +} + +/** + * Sign a JSON object using public key cryptography + * @param obj - Object to sign. The object will be modified to include + * the new signature + * @param key - the signing object or the private key + * seed + * @param userId - The user ID who owns the signing key + * @param pubKey - The public key (ignored if key is a seed) + * @returns the signature for the object + */ +export function pkSign(obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string { + let createdKey = false; + if (key instanceof Uint8Array) { + const keyObj = new global.Olm.PkSigning(); + pubKey = keyObj.init_with_seed(key); + key = keyObj; + createdKey = true; + } + const sigs = obj.signatures || {}; + delete obj.signatures; + const unsigned = obj.unsigned; + if (obj.unsigned) delete obj.unsigned; + try { + const mysigs = sigs[userId] || {}; + sigs[userId] = mysigs; + + return (mysigs["ed25519:" + pubKey] = key.sign(anotherjson.stringify(obj))); + } finally { + obj.signatures = sigs; + if (unsigned) obj.unsigned = unsigned; + if (createdKey) { + key.free(); + } + } +} + +/** + * Verify a signed JSON object + * @param obj - Object to verify + * @param pubKey - The public key to use to verify + * @param userId - The user ID who signed the object + */ +export function pkVerify(obj: IObject, pubKey: string, userId: string): void { + const keyId = "ed25519:" + pubKey; + if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { + throw new Error("No signature"); + } + const signature = obj.signatures[userId][keyId]; + const util = new global.Olm.Utility(); + const sigs = obj.signatures; + delete obj.signatures; + const unsigned = obj.unsigned; + if (obj.unsigned) delete obj.unsigned; + try { + util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature); + } finally { + obj.signatures = sigs; + if (unsigned) obj.unsigned = unsigned; + util.free(); + } +} + +/** + * Check that an event was encrypted using olm. + */ +export function isOlmEncrypted(event: MatrixEvent): boolean { + if (!event.getSenderKey()) { + logger.error("Event has no sender key (not encrypted?)"); + return false; + } + if ( + event.getWireType() !== EventType.RoomMessageEncrypted || + !["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm) + ) { + logger.error("Event was not encrypted using an appropriate algorithm"); + return false; + } + return true; +} + +/** + * Encode a typed array of uint8 as base64. + * @param uint8Array - The data to encode. + * @returns The base64. + */ +export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { + return Buffer.from(uint8Array).toString("base64"); +} + +/** + * Encode a typed array of uint8 as unpadded base64. + * @param uint8Array - The data to encode. + * @returns The unpadded base64. + */ +export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { + return encodeBase64(uint8Array).replace(/=+$/g, ""); +} + +/** + * Decode a base64 string to a typed array of uint8. + * @param base64 - The base64 to decode. + * @returns The decoded data. + */ +export function decodeBase64(base64: string): Uint8Array { + return Buffer.from(base64, "base64"); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts new file mode 100644 index 0000000..4107b76 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts @@ -0,0 +1,62 @@ +/* +Copyright 2018 New Vector Ltd + +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 * as bs58 from "bs58"; + +// picked arbitrarily but to try & avoid clashing with any bitcoin ones +// (which are also base58 encoded, but bitcoin's involve a lot more hashing) +const OLM_RECOVERY_KEY_PREFIX = [0x8b, 0x01]; + +export function encodeRecoveryKey(key: ArrayLike<number>): string | undefined { + const buf = Buffer.alloc(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); + buf.set(OLM_RECOVERY_KEY_PREFIX, 0); + buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); + + let parity = 0; + for (let i = 0; i < buf.length - 1; ++i) { + parity ^= buf[i]; + } + buf[buf.length - 1] = parity; + const base58key = bs58.encode(buf); + + return base58key.match(/.{1,4}/g)?.join(" "); +} + +export function decodeRecoveryKey(recoveryKey: string): Uint8Array { + const result = bs58.decode(recoveryKey.replace(/ /g, "")); + + let parity = 0; + for (const b of result) { + parity ^= b; + } + if (parity !== 0) { + throw new Error("Incorrect parity"); + } + + for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) { + if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) { + throw new Error("Incorrect prefix"); + } + } + + if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1) { + throw new Error("Incorrect length"); + } + + return Uint8Array.from( + result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH), + ); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts new file mode 100644 index 0000000..4c88ec2 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts @@ -0,0 +1,226 @@ +/* +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 { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; +import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; +import { ICrossSigningKey } from "../../client"; +import { IOlmDevice } from "../algorithms/megolm"; +import { TrackingStatus } from "../DeviceList"; +import { IRoomEncryption } from "../RoomList"; +import { IDevice } from "../deviceinfo"; +import { ICrossSigningInfo } from "../CrossSigning"; +import { PrefixedLogger } from "../../logger"; +import { InboundGroupSessionData } from "../OlmDevice"; +import { MatrixEvent } from "../../models/event"; +import { DehydrationManager } from "../dehydration"; +import { IEncryptedPayload } from "../aes"; + +/** + * Internal module. Definitions for storage for the crypto module + */ + +export interface SecretStorePrivateKeys { + "dehydration": { + keyInfo: DehydrationManager["keyInfo"]; + key: IEncryptedPayload; + deviceDisplayName: string; + time: number; + } | null; + "m.megolm_backup.v1": IEncryptedPayload; +} + +/** + * Abstraction of things that can store data required for end-to-end encryption + */ +export interface CryptoStore { + startup(): Promise<CryptoStore>; + deleteAllData(): Promise<void>; + getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>; + getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>; + getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>; + getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]>; + getOutgoingRoomKeyRequestsByTarget( + userId: string, + deviceId: string, + wantedStates: number[], + ): Promise<OutgoingRoomKeyRequest[]>; + updateOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + updates: Partial<OutgoingRoomKeyRequest>, + ): Promise<OutgoingRoomKeyRequest | null>; + deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise<OutgoingRoomKeyRequest | null>; + + // Olm Account + getAccount(txn: unknown, func: (accountPickle: string | null) => void): void; + storeAccount(txn: unknown, accountPickle: string): void; + getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void; + getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: unknown, + func: (key: SecretStorePrivateKeys[K] | null) => void, + type: K, + ): void; + storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void; + storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: unknown, + type: K, + key: SecretStorePrivateKeys[K], + ): void; + + // Olm Sessions + countEndToEndSessions(txn: unknown, func: (count: number) => void): void; + getEndToEndSession( + deviceKey: string, + sessionId: string, + txn: unknown, + func: (session: ISessionInfo | null) => void, + ): void; + getEndToEndSessions( + deviceKey: string, + txn: unknown, + func: (sessions: { [sessionId: string]: ISessionInfo }) => void, + ): void; + getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo | null) => void): void; + storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void; + storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void>; + getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>; + filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>; + + // Inbound Group Sessions + getEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + txn: unknown, + func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, + ): void; + getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void; + addEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: unknown, + ): void; + storeEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: unknown, + ): void; + storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key: string, + sessionId: string, + sessionData: IWithheld, + txn: unknown, + ): void; + + // Device Data + getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void; + storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void; + storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void; + getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void; + getSessionsNeedingBackup(limit: number): Promise<ISession[]>; + countSessionsNeedingBackup(txn?: unknown): Promise<number>; + unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>; + markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>; + addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string, txn?: unknown): void; + getSharedHistoryInboundGroupSessions( + roomId: string, + txn?: unknown, + ): Promise<[senderKey: string, sessionId: string][]>; + addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void; + takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>; + + // Session key backups + doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>; +} + +export type Mode = "readonly" | "readwrite"; + +export interface ISession { + senderKey: string; + sessionId: string; + sessionData?: InboundGroupSessionData; +} + +export interface ISessionInfo { + deviceKey?: string; + sessionId?: string; + session?: string; + lastReceivedMessageTs?: number; +} + +export interface IDeviceData { + devices: { + [userId: string]: { + [deviceId: string]: IDevice; + }; + }; + trackingStatus: { + [userId: string]: TrackingStatus; + }; + crossSigningInfo?: Record<string, ICrossSigningInfo>; + syncToken?: string; +} + +export interface IProblem { + type: string; + fixed: boolean; + time: number; +} + +export interface IWithheld { + // eslint-disable-next-line camelcase + room_id: string; + code: string; + reason: string; +} + +/** + * Represents an outgoing room key request + */ +export interface OutgoingRoomKeyRequest { + /** + * Unique id for this request. Used for both an id within the request for later pairing with a cancellation, + * and for the transaction id when sending the to_device messages to our local server. + */ + requestId: string; + requestTxnId?: string; + /** + * Transaction id for the cancellation, if any + */ + cancellationTxnId?: string; + /** + * List of recipients for the request + */ + recipients: IRoomKeyRequestRecipient[]; + /** + * Parameters for the request + */ + requestBody: IRoomKeyRequestBody; + /** + * current state of this request (states are defined in {@link OutgoingRoomKeyRequestManager}) + */ + state: RoomKeyRequestState; +} + +export interface ParkedSharedHistory { + senderId: string; + senderKey: string; + sessionId: string; + sessionKey: string; + keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent + forwardingCurve25519KeyChain: string[]; +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts new file mode 100644 index 0000000..7827697 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts @@ -0,0 +1,1062 @@ +/* +Copyright 2017 - 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, PrefixedLogger } from "../../logger"; +import * as utils from "../../utils"; +import { + CryptoStore, + IDeviceData, + IProblem, + ISession, + ISessionInfo, + IWithheld, + Mode, + OutgoingRoomKeyRequest, + ParkedSharedHistory, + SecretStorePrivateKeys, +} from "./base"; +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; +import { ICrossSigningKey } from "../../client"; +import { IOlmDevice } from "../algorithms/megolm"; +import { IRoomEncryption } from "../RoomList"; +import { InboundGroupSessionData } from "../OlmDevice"; + +const PROFILE_TRANSACTIONS = false; + +/** + * Implementation of a CryptoStore which is backed by an existing + * IndexedDB connection. Generally you want IndexedDBCryptoStore + * which connects to the database and defers to one of these. + */ +export class Backend implements CryptoStore { + private nextTxnId = 0; + + /** + */ + public constructor(private db: IDBDatabase) { + // make sure we close the db on `onversionchange` - otherwise + // attempts to delete the database will block (and subsequent + // attempts to re-create it will also block). + db.onversionchange = (): void => { + logger.log(`versionchange for indexeddb ${this.db.name}: closing`); + db.close(); + }; + } + + public async startup(): Promise<CryptoStore> { + // No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore) + // by passing us a ready IDBDatabase instance + return this; + } + public async deleteAllData(): Promise<void> { + throw Error("This is not implemented, call IDBFactory::deleteDatabase(dbName) instead."); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> { + const requestBody = request.requestBody; + + return new Promise((resolve, reject) => { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); + txn.onerror = reject; + + // first see if we already have an entry for this request. + this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { + if (existing) { + // this entry matches the request - return it. + logger.log( + `already have key request outstanding for ` + + `${requestBody.room_id} / ${requestBody.session_id}: ` + + `not sending another`, + ); + resolve(existing); + return; + } + + // we got to the end of the list without finding a match + // - add the new request. + logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); + txn.oncomplete = (): void => { + resolve(request); + }; + const store = txn.objectStore("outgoingRoomKeyRequests"); + store.add(request); + }); + }); + } + + /** + * Look for an existing room key request + * + * @param requestBody - existing request to look for + * + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found + */ + public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> { + return new Promise((resolve, reject) => { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + txn.onerror = reject; + + this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { + resolve(existing); + }); + }); + } + + /** + * look for an existing room key request in the db + * + * @internal + * @param txn - database transaction + * @param requestBody - existing request to look for + * @param callback - function to call with the results of the + * search. Either passed a matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + private _getOutgoingRoomKeyRequest( + txn: IDBTransaction, + requestBody: IRoomKeyRequestBody, + callback: (req: OutgoingRoomKeyRequest | null) => void, + ): void { + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const idx = store.index("session"); + const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]); + + cursorReq.onsuccess = (): void => { + const cursor = cursorReq.result; + if (!cursor) { + // no match found + callback(null); + return; + } + + const existing = cursor.value; + + if (utils.deepCompare(existing.requestBody, requestBody)) { + // got a match + callback(existing); + return; + } + + // look at the next entry in the index + cursor.continue(); + }; + } + + /** + * Look for room key requests by state + * + * @param wantedStates - list of acceptable states + * + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> { + if (wantedStates.length === 0) { + return Promise.resolve(null); + } + + // this is a bit tortuous because we need to make sure we do the lookup + // in a single transaction, to avoid having a race with the insertion + // code. + + // index into the wantedStates array + let stateIndex = 0; + let result: OutgoingRoomKeyRequest; + + function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void { + const cursor = this.result; + if (cursor) { + // got a match + result = cursor.value; + return; + } + + // try the next state in the list + stateIndex++; + if (stateIndex >= wantedStates.length) { + // no matches + return; + } + + const wantedState = wantedStates[stateIndex]; + const cursorReq = (this.source as IDBIndex).openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + + return promiseifyTxn(txn).then(() => result); + } + + /** + * + * @returns All elements in a given state + */ + public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> { + return new Promise((resolve, reject) => { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + const index = store.index("state"); + const request = index.getAll(wantedState); + + request.onsuccess = (): void => resolve(request.result); + request.onerror = (): void => reject(request.error); + }); + } + + public getOutgoingRoomKeyRequestsByTarget( + userId: string, + deviceId: string, + wantedStates: number[], + ): Promise<OutgoingRoomKeyRequest[]> { + let stateIndex = 0; + const results: OutgoingRoomKeyRequest[] = []; + + function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void { + const cursor = this.result; + if (cursor) { + const keyReq = cursor.value; + if ( + keyReq.recipients.some( + (recipient: IRoomKeyRequestRecipient) => + recipient.userId === userId && recipient.deviceId === deviceId, + ) + ) { + results.push(keyReq); + } + cursor.continue(); + } else { + // try the next state in the list + stateIndex++; + if (stateIndex >= wantedStates.length) { + // no matches + return; + } + + const wantedState = wantedStates[stateIndex]; + const cursorReq = (this.source as IDBIndex).openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + } + + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + + return promiseifyTxn(txn).then(() => results); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + public updateOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + updates: Partial<OutgoingRoomKeyRequest>, + ): Promise<OutgoingRoomKeyRequest | null> { + let result: OutgoingRoomKeyRequest | null = null; + + function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void { + const cursor = this.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + logger.warn( + `Cannot update room key request from ${expectedState} ` + + `as it was already updated to ${data.state}`, + ); + return; + } + Object.assign(data, updates); + cursor.update(data); + result = data; + } + + const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); + cursorReq.onsuccess = onsuccess; + return promiseifyTxn(txn).then(() => result); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * + * @returns resolves once the operation is completed + */ + public deleteOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + ): Promise<OutgoingRoomKeyRequest | null> { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); + cursorReq.onsuccess = (): void => { + const cursor = cursorReq.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`); + return; + } + cursor.delete(); + }; + return promiseifyTxn<OutgoingRoomKeyRequest | null>(txn); + } + + // Olm Account + + public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void { + const objectStore = txn.objectStore("account"); + const getReq = objectStore.get("-"); + getReq.onsuccess = function (): void { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public storeAccount(txn: IDBTransaction, accountPickle: string): void { + const objectStore = txn.objectStore("account"); + objectStore.put(accountPickle, "-"); + } + + public getCrossSigningKeys( + txn: IDBTransaction, + func: (keys: Record<string, ICrossSigningKey> | null) => void, + ): void { + const objectStore = txn.objectStore("account"); + const getReq = objectStore.get("crossSigningKeys"); + getReq.onsuccess = function (): void { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: IDBTransaction, + func: (key: SecretStorePrivateKeys[K] | null) => void, + type: K, + ): void { + const objectStore = txn.objectStore("account"); + const getReq = objectStore.get(`ssss_cache:${type}`); + getReq.onsuccess = function (): void { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void { + const objectStore = txn.objectStore("account"); + objectStore.put(keys, "crossSigningKeys"); + } + + public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: IDBTransaction, + type: K, + key: SecretStorePrivateKeys[K], + ): void { + const objectStore = txn.objectStore("account"); + objectStore.put(key, `ssss_cache:${type}`); + } + + // Olm Sessions + + public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void { + const objectStore = txn.objectStore("sessions"); + const countReq = objectStore.count(); + countReq.onsuccess = function (): void { + try { + func(countReq.result); + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public getEndToEndSessions( + deviceKey: string, + txn: IDBTransaction, + func: (sessions: { [sessionId: string]: ISessionInfo }) => void, + ): void { + const objectStore = txn.objectStore("sessions"); + const idx = objectStore.index("deviceKey"); + const getReq = idx.openCursor(deviceKey); + const results: Parameters<Parameters<Backend["getEndToEndSessions"]>[2]>[0] = {}; + getReq.onsuccess = function (): void { + const cursor = getReq.result; + if (cursor) { + results[cursor.value.sessionId] = { + session: cursor.value.session, + lastReceivedMessageTs: cursor.value.lastReceivedMessageTs, + }; + cursor.continue(); + } else { + try { + func(results); + } catch (e) { + abortWithException(txn, <Error>e); + } + } + }; + } + + public getEndToEndSession( + deviceKey: string, + sessionId: string, + txn: IDBTransaction, + func: (session: ISessionInfo | null) => void, + ): void { + const objectStore = txn.objectStore("sessions"); + const getReq = objectStore.get([deviceKey, sessionId]); + getReq.onsuccess = function (): void { + try { + if (getReq.result) { + func({ + session: getReq.result.session, + lastReceivedMessageTs: getReq.result.lastReceivedMessageTs, + }); + } else { + func(null); + } + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void { + const objectStore = txn.objectStore("sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function (): void { + try { + const cursor = getReq.result; + if (cursor) { + func(cursor.value); + cursor.continue(); + } else { + func(null); + } + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public storeEndToEndSession( + deviceKey: string, + sessionId: string, + sessionInfo: ISessionInfo, + txn: IDBTransaction, + ): void { + const objectStore = txn.objectStore("sessions"); + objectStore.put({ + deviceKey, + sessionId, + session: sessionInfo.session, + lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs, + }); + } + + public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { + const txn = this.db.transaction("session_problems", "readwrite"); + const objectStore = txn.objectStore("session_problems"); + objectStore.put({ + deviceKey, + type, + fixed, + time: Date.now(), + }); + await promiseifyTxn(txn); + } + + public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { + let result: IProblem | null = null; + const txn = this.db.transaction("session_problems", "readwrite"); + const objectStore = txn.objectStore("session_problems"); + const index = objectStore.index("deviceKey"); + const req = index.getAll(deviceKey); + req.onsuccess = (): void => { + const problems = req.result; + if (!problems.length) { + result = null; + return; + } + problems.sort((a, b) => { + return a.time - b.time; + }); + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + result = Object.assign({}, problem, { fixed: lastProblem.fixed }); + return; + } + } + if (lastProblem.fixed) { + result = null; + } else { + result = lastProblem; + } + }; + await promiseifyTxn(txn); + return result; + } + + // FIXME: we should probably prune this when devices get deleted + public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { + const txn = this.db.transaction("notified_error_devices", "readwrite"); + const objectStore = txn.objectStore("notified_error_devices"); + + const ret: IOlmDevice[] = []; + + await Promise.all( + devices.map((device) => { + return new Promise<void>((resolve) => { + const { userId, deviceInfo } = device; + const getReq = objectStore.get([userId, deviceInfo.deviceId]); + getReq.onsuccess = function (): void { + if (!getReq.result) { + objectStore.put({ userId, deviceId: deviceInfo.deviceId }); + ret.push(device); + } + resolve(); + }; + }); + }), + ); + + return ret; + } + + // Inbound group sessions + + public getEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + txn: IDBTransaction, + func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, + ): void { + let session: InboundGroupSessionData | null | boolean = false; + let withheld: IWithheld | null | boolean = false; + const objectStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.get([senderCurve25519Key, sessionId]); + getReq.onsuccess = function (): void { + try { + if (getReq.result) { + session = getReq.result.session; + } else { + session = null; + } + if (withheld !== false) { + func(session as InboundGroupSessionData, withheld as IWithheld); + } + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + + const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld"); + const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]); + withheldGetReq.onsuccess = function (): void { + try { + if (withheldGetReq.result) { + withheld = withheldGetReq.result.session; + } else { + withheld = null; + } + if (session !== false) { + func(session as InboundGroupSessionData, withheld as IWithheld); + } + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { + const objectStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function (): void { + const cursor = getReq.result; + if (cursor) { + try { + func({ + senderKey: cursor.value.senderCurve25519Key, + sessionId: cursor.value.sessionId, + sessionData: cursor.value.session, + }); + } catch (e) { + abortWithException(txn, <Error>e); + } + cursor.continue(); + } else { + try { + func(null); + } catch (e) { + abortWithException(txn, <Error>e); + } + } + }; + } + + public addEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: IDBTransaction, + ): void { + const objectStore = txn.objectStore("inbound_group_sessions"); + const addReq = objectStore.add({ + senderCurve25519Key, + sessionId, + session: sessionData, + }); + addReq.onerror = (ev): void => { + if (addReq.error?.name === "ConstraintError") { + // This stops the error from triggering the txn's onerror + ev.stopPropagation(); + // ...and this stops it from aborting the transaction + ev.preventDefault(); + logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId); + } else { + abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error)); + } + }; + } + + public storeEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: IDBTransaction, + ): void { + const objectStore = txn.objectStore("inbound_group_sessions"); + objectStore.put({ + senderCurve25519Key, + sessionId, + session: sessionData, + }); + } + + public storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key: string, + sessionId: string, + sessionData: IWithheld, + txn: IDBTransaction, + ): void { + const objectStore = txn.objectStore("inbound_group_sessions_withheld"); + objectStore.put({ + senderCurve25519Key, + sessionId, + session: sessionData, + }); + } + + public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void { + const objectStore = txn.objectStore("device_data"); + const getReq = objectStore.get("-"); + getReq.onsuccess = function (): void { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, <Error>e); + } + }; + } + + public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void { + const objectStore = txn.objectStore("device_data"); + objectStore.put(deviceData, "-"); + } + + public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void { + const objectStore = txn.objectStore("rooms"); + objectStore.put(roomInfo, roomId); + } + + public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void { + const rooms: Parameters<Parameters<Backend["getEndToEndRooms"]>[1]>[0] = {}; + const objectStore = txn.objectStore("rooms"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function (): void { + const cursor = getReq.result; + if (cursor) { + rooms[cursor.key as string] = cursor.value; + cursor.continue(); + } else { + try { + func(rooms); + } catch (e) { + abortWithException(txn, <Error>e); + } + } + }; + } + + // session backups + + public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { + return new Promise((resolve, reject) => { + const sessions: ISession[] = []; + + const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly"); + txn.onerror = reject; + txn.oncomplete = function (): void { + resolve(sessions); + }; + const objectStore = txn.objectStore("sessions_needing_backup"); + const sessionStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function (): void { + const cursor = getReq.result; + if (cursor) { + const sessionGetReq = sessionStore.get(cursor.key); + sessionGetReq.onsuccess = function (): void { + sessions.push({ + senderKey: sessionGetReq.result.senderCurve25519Key, + sessionId: sessionGetReq.result.sessionId, + sessionData: sessionGetReq.result.session, + }); + }; + if (!limit || sessions.length < limit) { + cursor.continue(); + } + } + }; + }); + } + + public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> { + if (!txn) { + txn = this.db.transaction("sessions_needing_backup", "readonly"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + return new Promise((resolve, reject) => { + const req = objectStore.count(); + req.onerror = reject; + req.onsuccess = (): void => resolve(req.result); + }); + } + + public async unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { + if (!txn) { + txn = this.db.transaction("sessions_needing_backup", "readwrite"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + await Promise.all( + sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.delete([session.senderKey, session.sessionId]); + req.onsuccess = resolve; + req.onerror = reject; + }); + }), + ); + } + + public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { + if (!txn) { + txn = this.db.transaction("sessions_needing_backup", "readwrite"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + await Promise.all( + sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.put({ + senderCurve25519Key: session.senderKey, + sessionId: session.sessionId, + }); + req.onsuccess = resolve; + req.onerror = reject; + }); + }), + ); + } + + public addSharedHistoryInboundGroupSession( + roomId: string, + senderKey: string, + sessionId: string, + txn?: IDBTransaction, + ): void { + if (!txn) { + txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite"); + } + const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); + const req = objectStore.get([roomId]); + req.onsuccess = (): void => { + const { sessions } = req.result || { sessions: [] }; + sessions.push([senderKey, sessionId]); + objectStore.put({ roomId, sessions }); + }; + } + + public getSharedHistoryInboundGroupSessions( + roomId: string, + txn?: IDBTransaction, + ): Promise<[senderKey: string, sessionId: string][]> { + if (!txn) { + txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly"); + } + const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); + const req = objectStore.get([roomId]); + return new Promise((resolve, reject) => { + req.onsuccess = (): void => { + const { sessions } = req.result || { sessions: [] }; + resolve(sessions); + }; + req.onerror = reject; + }); + } + + public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void { + if (!txn) { + txn = this.db.transaction("parked_shared_history", "readwrite"); + } + const objectStore = txn.objectStore("parked_shared_history"); + const req = objectStore.get([roomId]); + req.onsuccess = (): void => { + const { parked } = req.result || { parked: [] }; + parked.push(parkedData); + objectStore.put({ roomId, parked }); + }; + } + + public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> { + if (!txn) { + txn = this.db.transaction("parked_shared_history", "readwrite"); + } + const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId); + return new Promise((resolve, reject) => { + cursorReq.onsuccess = (): void => { + const cursor = cursorReq.result; + if (!cursor) { + resolve([]); + return; + } + const data = cursor.value; + cursor.delete(); + resolve(data); + }; + cursorReq.onerror = reject; + }); + } + + public doTxn<T>( + mode: Mode, + stores: string | string[], + func: (txn: IDBTransaction) => T, + log: PrefixedLogger = logger, + ): Promise<T> { + let startTime: number; + let description: string; + if (PROFILE_TRANSACTIONS) { + const txnId = this.nextTxnId++; + startTime = Date.now(); + description = `${mode} crypto store transaction ${txnId} in ${stores}`; + log.debug(`Starting ${description}`); + } + const txn = this.db.transaction(stores, mode); + const promise = promiseifyTxn(txn); + const result = func(txn); + if (PROFILE_TRANSACTIONS) { + promise.then( + () => { + const elapsedTime = Date.now() - startTime; + log.debug(`Finished ${description}, took ${elapsedTime} ms`); + }, + () => { + const elapsedTime = Date.now() - startTime; + log.error(`Failed ${description}, took ${elapsedTime} ms`); + }, + ); + } + return promise.then(() => { + return result; + }); + } +} + +type DbMigration = (db: IDBDatabase) => void; +const DB_MIGRATIONS: DbMigration[] = [ + (db): void => { + createDatabase(db); + }, + (db): void => { + db.createObjectStore("account"); + }, + (db): void => { + const sessionsStore = db.createObjectStore("sessions", { + keyPath: ["deviceKey", "sessionId"], + }); + sessionsStore.createIndex("deviceKey", "deviceKey"); + }, + (db): void => { + db.createObjectStore("inbound_group_sessions", { + keyPath: ["senderCurve25519Key", "sessionId"], + }); + }, + (db): void => { + db.createObjectStore("device_data"); + }, + (db): void => { + db.createObjectStore("rooms"); + }, + (db): void => { + db.createObjectStore("sessions_needing_backup", { + keyPath: ["senderCurve25519Key", "sessionId"], + }); + }, + (db): void => { + db.createObjectStore("inbound_group_sessions_withheld", { + keyPath: ["senderCurve25519Key", "sessionId"], + }); + }, + (db): void => { + const problemsStore = db.createObjectStore("session_problems", { + keyPath: ["deviceKey", "time"], + }); + problemsStore.createIndex("deviceKey", "deviceKey"); + + db.createObjectStore("notified_error_devices", { + keyPath: ["userId", "deviceId"], + }); + }, + (db): void => { + db.createObjectStore("shared_history_inbound_group_sessions", { + keyPath: ["roomId"], + }); + }, + (db): void => { + db.createObjectStore("parked_shared_history", { + keyPath: ["roomId"], + }); + }, + // Expand as needed. +]; +export const VERSION = DB_MIGRATIONS.length; + +export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { + logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`); + DB_MIGRATIONS.forEach((migration, index) => { + if (oldVersion <= index) migration(db); + }); +} + +function createDatabase(db: IDBDatabase): void { + const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" }); + + // we assume that the RoomKeyRequestBody will have room_id and session_id + // properties, to make the index efficient. + outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]); + + outgoingRoomKeyRequestsStore.createIndex("state", "state"); +} + +interface IWrappedIDBTransaction extends IDBTransaction { + _mx_abortexception: Error; // eslint-disable-line camelcase +} + +/* + * Aborts a transaction with a given exception + * The transaction promise will be rejected with this exception. + */ +function abortWithException(txn: IDBTransaction, e: Error): void { + // We cheekily stick our exception onto the transaction object here + // We could alternatively make the thing we pass back to the app + // an object containing the transaction and exception. + (txn as IWrappedIDBTransaction)._mx_abortexception = e; + try { + txn.abort(); + } catch (e) { + // sometimes we won't be able to abort the transaction + // (ie. if it's aborted or completed) + } +} + +function promiseifyTxn<T>(txn: IDBTransaction): Promise<T | null> { + return new Promise((resolve, reject) => { + txn.oncomplete = (): void => { + if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) { + reject((txn as IWrappedIDBTransaction)._mx_abortexception); + } + resolve(null); + }; + txn.onerror = (event): void => { + if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) { + reject((txn as IWrappedIDBTransaction)._mx_abortexception); + } else { + logger.log("Error performing indexeddb txn", event); + reject(txn.error); + } + }; + txn.onabort = (event): void => { + if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) { + reject((txn as IWrappedIDBTransaction)._mx_abortexception); + } else { + logger.log("Error performing indexeddb txn", event); + reject(txn.error); + } + }; + }); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts new file mode 100644 index 0000000..320235f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts @@ -0,0 +1,708 @@ +/* +Copyright 2017 - 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, PrefixedLogger } from "../../logger"; +import { LocalStorageCryptoStore } from "./localStorage-crypto-store"; +import { MemoryCryptoStore } from "./memory-crypto-store"; +import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend"; +import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors"; +import * as IndexedDBHelpers from "../../indexeddb-helpers"; +import { + CryptoStore, + IDeviceData, + IProblem, + ISession, + ISessionInfo, + IWithheld, + Mode, + OutgoingRoomKeyRequest, + ParkedSharedHistory, + SecretStorePrivateKeys, +} from "./base"; +import { IRoomKeyRequestBody } from "../index"; +import { ICrossSigningKey } from "../../client"; +import { IOlmDevice } from "../algorithms/megolm"; +import { IRoomEncryption } from "../RoomList"; +import { InboundGroupSessionData } from "../OlmDevice"; + +/** + * Internal module. indexeddb storage for e2e. + */ + +/** + * An implementation of CryptoStore, which is normally backed by an indexeddb, + * but with fallback to MemoryCryptoStore. + */ +export class IndexedDBCryptoStore implements CryptoStore { + public static STORE_ACCOUNT = "account"; + public static STORE_SESSIONS = "sessions"; + public static STORE_INBOUND_GROUP_SESSIONS = "inbound_group_sessions"; + public static STORE_INBOUND_GROUP_SESSIONS_WITHHELD = "inbound_group_sessions_withheld"; + public static STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = "shared_history_inbound_group_sessions"; + public static STORE_PARKED_SHARED_HISTORY = "parked_shared_history"; + public static STORE_DEVICE_DATA = "device_data"; + public static STORE_ROOMS = "rooms"; + public static STORE_BACKUP = "sessions_needing_backup"; + + public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> { + return IndexedDBHelpers.exists(indexedDB, dbName); + } + + private backendPromise?: Promise<CryptoStore>; + private backend?: CryptoStore; + + /** + * Create a new IndexedDBCryptoStore + * + * @param indexedDB - global indexedDB instance + * @param dbName - name of db to connect to + */ + public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {} + + /** + * Ensure the database exists and is up-to-date, or fall back to + * a local storage or in-memory store. + * + * This must be called before the store can be used. + * + * @returns resolves to either an IndexedDBCryptoStoreBackend.Backend, + * or a MemoryCryptoStore + */ + public startup(): Promise<CryptoStore> { + if (this.backendPromise) { + return this.backendPromise; + } + + this.backendPromise = new Promise<CryptoStore>((resolve, reject) => { + if (!this.indexedDB) { + reject(new Error("no indexeddb support available")); + return; + } + + logger.log(`connecting to indexeddb ${this.dbName}`); + + const req = this.indexedDB.open(this.dbName, IndexedDBCryptoStoreBackend.VERSION); + + req.onupgradeneeded = (ev): void => { + const db = req.result; + const oldVersion = ev.oldVersion; + IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion); + }; + + req.onblocked = (): void => { + logger.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`); + }; + + req.onerror = (ev): void => { + logger.log("Error connecting to indexeddb", ev); + reject(req.error); + }; + + req.onsuccess = (): void => { + const db = req.result; + + logger.log(`connected to indexeddb ${this.dbName}`); + resolve(new IndexedDBCryptoStoreBackend.Backend(db)); + }; + }) + .then((backend) => { + // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively. + // Try a dummy query which will fail if the browser doesn't support compund keys, so + // we can fall back to a different backend. + return backend + .doTxn( + "readonly", + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], + (txn) => { + backend.getEndToEndInboundGroupSession("", "", txn, () => {}); + }, + ) + .then(() => backend); + }) + .catch((e) => { + if (e.name === "VersionError") { + logger.warn("Crypto DB is too new for us to use!", e); + // don't fall back to a different store: the user has crypto data + // in this db so we should use it or nothing at all. + throw new InvalidCryptoStoreError(InvalidCryptoStoreState.TooNew); + } + logger.warn( + `unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`, + ); + + try { + return new LocalStorageCryptoStore(global.localStorage); + } catch (e) { + logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`); + return new MemoryCryptoStore(); + } + }) + .then((backend) => { + this.backend = backend; + return backend; + }); + + return this.backendPromise; + } + + /** + * Delete all data from this store. + * + * @returns resolves when the store has been cleared. + */ + public deleteAllData(): Promise<void> { + return new Promise<void>((resolve, reject) => { + if (!this.indexedDB) { + reject(new Error("no indexeddb support available")); + return; + } + + logger.log(`Removing indexeddb instance: ${this.dbName}`); + const req = this.indexedDB.deleteDatabase(this.dbName); + + req.onblocked = (): void => { + logger.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`); + }; + + req.onerror = (ev): void => { + logger.log("Error deleting data from indexeddb", ev); + reject(req.error); + }; + + req.onsuccess = (): void => { + logger.log(`Removed indexeddb instance: ${this.dbName}`); + resolve(); + }; + }).catch((e) => { + // in firefox, with indexedDB disabled, this fails with a + // DOMError. We treat this as non-fatal, so that people can + // still use the app. + logger.warn(`unable to delete IndexedDBCryptoStore: ${e}`); + }); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> { + return this.backend!.getOrAddOutgoingRoomKeyRequest(request); + } + + /** + * Look for an existing room key request + * + * @param requestBody - existing request to look for + * + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found + */ + public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> { + return this.backend!.getOutgoingRoomKeyRequest(requestBody); + } + + /** + * Look for room key requests by state + * + * @param wantedStates - list of acceptable states + * + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> { + return this.backend!.getOutgoingRoomKeyRequestByState(wantedStates); + } + + /** + * Look for room key requests by state – + * unlike above, return a list of all entries in one state. + * + * @returns Returns an array of requests in the given state + */ + public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> { + return this.backend!.getAllOutgoingRoomKeyRequestsByState(wantedState); + } + + /** + * Look for room key requests by target device and state + * + * @param userId - Target user ID + * @param deviceId - Target device ID + * @param wantedStates - list of acceptable states + * + * @returns resolves to a list of all the + * {@link OutgoingRoomKeyRequest} + */ + public getOutgoingRoomKeyRequestsByTarget( + userId: string, + deviceId: string, + wantedStates: number[], + ): Promise<OutgoingRoomKeyRequest[]> { + return this.backend!.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + public updateOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + updates: Partial<OutgoingRoomKeyRequest>, + ): Promise<OutgoingRoomKeyRequest | null> { + return this.backend!.updateOutgoingRoomKeyRequest(requestId, expectedState, updates); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * + * @returns resolves once the operation is completed + */ + public deleteOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + ): Promise<OutgoingRoomKeyRequest | null> { + return this.backend!.deleteOutgoingRoomKeyRequest(requestId, expectedState); + } + + // Olm Account + + /* + * Get the account pickle from the store. + * This requires an active transaction. See doTxn(). + * + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the account pickle + */ + public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void { + this.backend!.getAccount(txn, func); + } + + /** + * Write the account pickle to the store. + * This requires an active transaction. See doTxn(). + * + * @param txn - An active transaction. See doTxn(). + * @param accountPickle - The new account pickle to store. + */ + public storeAccount(txn: IDBTransaction, accountPickle: string): void { + this.backend!.storeAccount(txn, accountPickle); + } + + /** + * Get the public part of the cross-signing keys (eg. self-signing key, + * user signing key). + * + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the account keys object: + * `{ key_type: base64 encoded seed }` where key type = user_signing_key_seed or self_signing_key_seed + */ + public getCrossSigningKeys( + txn: IDBTransaction, + func: (keys: Record<string, ICrossSigningKey> | null) => void, + ): void { + this.backend!.getCrossSigningKeys(txn, func); + } + + /** + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the private key + * @param type - A key type + */ + public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: IDBTransaction, + func: (key: SecretStorePrivateKeys[K] | null) => void, + type: K, + ): void { + this.backend!.getSecretStorePrivateKey(txn, func, type); + } + + /** + * Write the cross-signing keys back to the store + * + * @param txn - An active transaction. See doTxn(). + * @param keys - keys object as getCrossSigningKeys() + */ + public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void { + this.backend!.storeCrossSigningKeys(txn, keys); + } + + /** + * Write the cross-signing private keys back to the store + * + * @param txn - An active transaction. See doTxn(). + * @param type - The type of cross-signing private key to store + * @param key - keys object as getCrossSigningKeys() + */ + public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: IDBTransaction, + type: K, + key: SecretStorePrivateKeys[K], + ): void { + this.backend!.storeSecretStorePrivateKey(txn, type, key); + } + + // Olm sessions + + /** + * Returns the number of end-to-end sessions in the store + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the count of sessions + */ + public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void { + this.backend!.countEndToEndSessions(txn, func); + } + + /** + * Retrieve a specific end-to-end session between the logged-in user + * and another device. + * @param deviceKey - The public key of the other device. + * @param sessionId - The ID of the session to retrieve + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. + */ + public getEndToEndSession( + deviceKey: string, + sessionId: string, + txn: IDBTransaction, + func: (session: ISessionInfo | null) => void, + ): void { + this.backend!.getEndToEndSession(deviceKey, sessionId, txn, func); + } + + /** + * Retrieve the end-to-end sessions between the logged-in user and another + * device. + * @param deviceKey - The public key of the other device. + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. + */ + public getEndToEndSessions( + deviceKey: string, + txn: IDBTransaction, + func: (sessions: { [sessionId: string]: ISessionInfo }) => void, + ): void { + this.backend!.getEndToEndSessions(deviceKey, txn, func); + } + + /** + * Retrieve all end-to-end sessions + * @param txn - An active transaction. See doTxn(). + * @param func - Called one for each session with + * an object with, deviceKey, lastReceivedMessageTs, sessionId + * and session keys. + */ + public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void { + this.backend!.getAllEndToEndSessions(txn, func); + } + + /** + * Store a session between the logged-in user and another device + * @param deviceKey - The public key of the other device. + * @param sessionId - The ID for this end-to-end session. + * @param sessionInfo - Session information object + * @param txn - An active transaction. See doTxn(). + */ + public storeEndToEndSession( + deviceKey: string, + sessionId: string, + sessionInfo: ISessionInfo, + txn: IDBTransaction, + ): void { + this.backend!.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn); + } + + public storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { + return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed); + } + + public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { + return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp); + } + + public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { + return this.backend!.filterOutNotifiedErrorDevices(devices); + } + + // Inbound group sessions + + /** + * Retrieve the end-to-end inbound group session for a given + * server key and session ID + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId + * to Base64 end-to-end session. + */ + public getEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + txn: IDBTransaction, + func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, + ): void { + this.backend!.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func); + } + + /** + * Fetches all inbound group sessions in the store + * @param txn - An active transaction. See doTxn(). + * @param func - Called once for each group session + * in the store with an object having keys `{senderKey, sessionId, sessionData}`, + * then once with null to indicate the end of the list. + */ + public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { + this.backend!.getAllEndToEndInboundGroupSessions(txn, func); + } + + /** + * Adds an end-to-end inbound group session to the store. + * If there already exists an inbound group session with the same + * senderCurve25519Key and sessionID, the session will not be added. + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param sessionData - The session data structure + * @param txn - An active transaction. See doTxn(). + */ + public addEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: IDBTransaction, + ): void { + this.backend!.addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); + } + + /** + * Writes an end-to-end inbound group session to the store. + * If there already exists an inbound group session with the same + * senderCurve25519Key and sessionID, it will be overwritten. + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param sessionData - The session data structure + * @param txn - An active transaction. See doTxn(). + */ + public storeEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: IDBTransaction, + ): void { + this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); + } + + public storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key: string, + sessionId: string, + sessionData: IWithheld, + txn: IDBTransaction, + ): void { + this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn); + } + + // End-to-end device tracking + + /** + * Store the state of all tracked devices + * This contains devices for each user, a tracking state for each user + * and a sync token matching the point in time the snapshot represents. + * These all need to be written out in full each time such that the snapshot + * is always consistent, so they are stored in one object. + * + * @param txn - An active transaction. See doTxn(). + */ + public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void { + this.backend!.storeEndToEndDeviceData(deviceData, txn); + } + + /** + * Get the state of all tracked devices + * + * @param txn - An active transaction. See doTxn(). + * @param func - Function called with the + * device data + */ + public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void { + this.backend!.getEndToEndDeviceData(txn, func); + } + + // End to End Rooms + + /** + * Store the end-to-end state for a room. + * @param roomId - The room's ID. + * @param roomInfo - The end-to-end info for the room. + * @param txn - An active transaction. See doTxn(). + */ + public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void { + this.backend!.storeEndToEndRoom(roomId, roomInfo, txn); + } + + /** + * Get an object of `roomId->roomInfo` for all e2e rooms in the store + * @param txn - An active transaction. See doTxn(). + * @param func - Function called with the end-to-end encrypted rooms + */ + public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void { + this.backend!.getEndToEndRooms(txn, func); + } + + // session backups + + /** + * Get the inbound group sessions that need to be backed up. + * @param limit - The maximum number of sessions to retrieve. 0 + * for no limit. + * @returns resolves to an array of inbound group sessions + */ + public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { + return this.backend!.getSessionsNeedingBackup(limit); + } + + /** + * Count the inbound group sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves to the number of sessions + */ + public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> { + return this.backend!.countSessionsNeedingBackup(txn); + } + + /** + * Unmark sessions as needing to be backed up. + * @param sessions - The sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves when the sessions are unmarked + */ + public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { + return this.backend!.unmarkSessionsNeedingBackup(sessions, txn); + } + + /** + * Mark sessions as needing to be backed up. + * @param sessions - The sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves when the sessions are marked + */ + public markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { + return this.backend!.markSessionsNeedingBackup(sessions, txn); + } + + /** + * Add a shared-history group session for a room. + * @param roomId - The room that the key belongs to + * @param senderKey - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param txn - An active transaction. See doTxn(). (optional) + */ + public addSharedHistoryInboundGroupSession( + roomId: string, + senderKey: string, + sessionId: string, + txn?: IDBTransaction, + ): void { + this.backend!.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); + } + + /** + * Get the shared-history group session for a room. + * @param roomId - The room that the key belongs to + * @param txn - An active transaction. See doTxn(). (optional) + * @returns Promise which resolves to an array of [senderKey, sessionId] + */ + public getSharedHistoryInboundGroupSessions( + roomId: string, + txn?: IDBTransaction, + ): Promise<[senderKey: string, sessionId: string][]> { + return this.backend!.getSharedHistoryInboundGroupSessions(roomId, txn); + } + + /** + * Park a shared-history group session for a room we may be invited to later. + */ + public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void { + this.backend!.addParkedSharedHistory(roomId, parkedData, txn); + } + + /** + * Pop out all shared-history group sessions for a room. + */ + public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> { + return this.backend!.takeParkedSharedHistory(roomId, txn); + } + + /** + * Perform a transaction on the crypto store. Any store methods + * that require a transaction (txn) object to be passed in may + * only be called within a callback of either this function or + * one of the store functions operating on the same transaction. + * + * @param mode - 'readwrite' if you need to call setter + * functions with this transaction. Otherwise, 'readonly'. + * @param stores - List IndexedDBCryptoStore.STORE_* + * options representing all types of object that will be + * accessed or written to with this transaction. + * @param func - Function called with the + * transaction object: an opaque object that should be passed + * to store functions. + * @param log - A possibly customised log + * @returns Promise that resolves with the result of the `func` + * when the transaction is complete. If the backend is + * async (ie. the indexeddb backend) any of the callback + * functions throwing an exception will cause this promise to + * reject with that exception. On synchronous backends, the + * exception will propagate to the caller of the getFoo method. + */ + public doTxn<T>( + mode: Mode, + stores: Iterable<string>, + func: (txn: IDBTransaction) => T, + log?: PrefixedLogger, + ): Promise<T> { + return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log); + } +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts new file mode 100644 index 0000000..5552540 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts @@ -0,0 +1,403 @@ +/* +Copyright 2017 - 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 { MemoryCryptoStore } from "./memory-crypto-store"; +import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base"; +import { IOlmDevice } from "../algorithms/megolm"; +import { IRoomEncryption } from "../RoomList"; +import { ICrossSigningKey } from "../../client"; +import { InboundGroupSessionData } from "../OlmDevice"; +import { safeSet } from "../../utils"; + +/** + * Internal module. Partial localStorage backed storage for e2e. + * This is not a full crypto store, just the in-memory store with + * some things backed by localStorage. It exists because indexedDB + * is broken in Firefox private mode or set to, "will not remember + * history". + */ + +const E2E_PREFIX = "crypto."; +const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; +const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys"; +const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices"; +const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; +const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; +const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/"; +const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; +const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup"; + +function keyEndToEndSessions(deviceKey: string): string { + return E2E_PREFIX + "sessions/" + deviceKey; +} + +function keyEndToEndSessionProblems(deviceKey: string): string { + return E2E_PREFIX + "session.problems/" + deviceKey; +} + +function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string { + return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; +} + +function keyEndToEndInboundGroupSessionWithheld(senderKey: string, sessionId: string): string { + return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId; +} + +function keyEndToEndRoomsPrefix(roomId: string): string { + return KEY_ROOMS_PREFIX + roomId; +} + +export class LocalStorageCryptoStore extends MemoryCryptoStore { + public static exists(store: Storage): boolean { + const length = store.length; + for (let i = 0; i < length; i++) { + if (store.key(i)?.startsWith(E2E_PREFIX)) { + return true; + } + } + return false; + } + + public constructor(private readonly store: Storage) { + super(); + } + + // Olm Sessions + + public countEndToEndSessions(txn: unknown, func: (count: number) => void): void { + let count = 0; + for (let i = 0; i < this.store.length; ++i) { + if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count; + } + func(count); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + private _getEndToEndSessions(deviceKey: string): Record<string, ISessionInfo> { + const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + const fixedSessions: Record<string, ISessionInfo> = {}; + + // fix up any old sessions to be objects rather than just the base64 pickle + for (const [sid, val] of Object.entries(sessions || {})) { + if (typeof val === "string") { + fixedSessions[sid] = { + session: val, + }; + } else { + fixedSessions[sid] = val; + } + } + + return fixedSessions; + } + + public getEndToEndSession( + deviceKey: string, + sessionId: string, + txn: unknown, + func: (session: ISessionInfo) => void, + ): void { + const sessions = this._getEndToEndSessions(deviceKey); + func(sessions[sessionId] || {}); + } + + public getEndToEndSessions( + deviceKey: string, + txn: unknown, + func: (sessions: { [sessionId: string]: ISessionInfo }) => void, + ): void { + func(this._getEndToEndSessions(deviceKey) || {}); + } + + public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void { + for (let i = 0; i < this.store.length; ++i) { + if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) { + const deviceKey = this.store.key(i)!.split("/")[1]; + for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) { + func(sess); + } + } + } + } + + public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void { + const sessions = this._getEndToEndSessions(deviceKey) || {}; + sessions[sessionId] = sessionInfo; + setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions); + } + + public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem<IProblem[]>(this.store, key) || []; + problems.push({ type, fixed, time: Date.now() }); + problems.sort((a, b) => { + return a.time - b.time; + }); + setJsonItem(this.store, key, problems); + } + + public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem<IProblem[]>(this.store, key) || []; + if (!problems.length) { + return null; + } + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + return Object.assign({}, problem, { fixed: lastProblem.fixed }); + } + } + if (lastProblem.fixed) { + return null; + } else { + return lastProblem; + } + } + + public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { + const notifiedErrorDevices = + getJsonItem<MemoryCryptoStore["notifiedErrorDevices"]>(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {}; + const ret: IOlmDevice[] = []; + + for (const device of devices) { + const { userId, deviceInfo } = device; + if (userId in notifiedErrorDevices) { + if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { + ret.push(device); + safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true); + } + } else { + ret.push(device); + safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true }); + } + } + + setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices); + + return ret; + } + + // Inbound Group Sessions + + public getEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + txn: unknown, + func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, + ): void { + func( + getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)), + getJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId)), + ); + } + + public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void { + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) { + // we can't use split, as the components we are trying to split out + // might themselves contain '/' characters. We rely on the + // senderKey being a (32-byte) curve25519 key, base64-encoded + // (hence 43 characters long). + + func({ + senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43), + sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44), + sessionData: getJsonItem(this.store, key)!, + }); + } + } + func(null); + } + + public addEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: unknown, + ): void { + const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)); + if (!existing) { + this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); + } + } + + public storeEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: unknown, + ): void { + setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData); + } + + public storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key: string, + sessionId: string, + sessionData: IWithheld, + txn: unknown, + ): void { + setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData); + } + + public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void { + func(getJsonItem(this.store, KEY_DEVICE_DATA)); + } + + public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void { + setJsonItem(this.store, KEY_DEVICE_DATA, deviceData); + } + + public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void { + setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo); + } + + public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void { + const result: Record<string, IRoomEncryption> = {}; + const prefix = keyEndToEndRoomsPrefix(""); + + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + if (key?.startsWith(prefix)) { + const roomId = key.slice(prefix.length); + result[roomId] = getJsonItem(this.store, key)!; + } + } + func(result); + } + + public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { + const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + const sessions: ISession[] = []; + + for (const session in sessionsNeedingBackup) { + if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) { + // see getAllEndToEndInboundGroupSessions for the magic number explanations + const senderKey = session.slice(0, 43); + const sessionId = session.slice(44); + this.getEndToEndInboundGroupSession(senderKey, sessionId, null, (sessionData) => { + sessions.push({ + senderKey: senderKey, + sessionId: sessionId, + sessionData: sessionData!, + }); + }); + if (limit && sessions.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + + public countSessionsNeedingBackup(): Promise<number> { + const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + return Promise.resolve(Object.keys(sessionsNeedingBackup).length); + } + + public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> { + const sessionsNeedingBackup = + getJsonItem<{ + [senderKeySessionId: string]: string; + }>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId]; + } + setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup); + return Promise.resolve(); + } + + public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> { + const sessionsNeedingBackup = + getJsonItem<{ + [senderKeySessionId: string]: boolean; + }>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + sessionsNeedingBackup[session.senderKey + "/" + session.sessionId] = true; + } + setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup); + return Promise.resolve(); + } + + /** + * Delete all data from this store. + * + * @returns Promise which resolves when the store has been cleared. + */ + public deleteAllData(): Promise<void> { + this.store.removeItem(KEY_END_TO_END_ACCOUNT); + return Promise.resolve(); + } + + // Olm account + + public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void { + const accountPickle = getJsonItem<string>(this.store, KEY_END_TO_END_ACCOUNT); + func(accountPickle); + } + + public storeAccount(txn: unknown, accountPickle: string): void { + setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle); + } + + public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void { + const keys = getJsonItem<Record<string, ICrossSigningKey>>(this.store, KEY_CROSS_SIGNING_KEYS); + func(keys); + } + + public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: unknown, + func: (key: SecretStorePrivateKeys[K] | null) => void, + type: K, + ): void { + const key = getJsonItem<SecretStorePrivateKeys[K]>(this.store, E2E_PREFIX + `ssss_cache.${type}`); + func(key); + } + + public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void { + setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys); + } + + public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: unknown, + type: K, + key: SecretStorePrivateKeys[K], + ): void { + setJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`, key); + } + + public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T): Promise<T> { + return Promise.resolve(func(null)); + } +} + +function getJsonItem<T>(store: Storage, key: string): T | null { + try { + // if the key is absent, store.getItem() returns null, and + // JSON.parse(null) === null, so this returns null. + return JSON.parse(store.getItem(key)!); + } catch (e) { + logger.log("Error: Failed to get key %s: %s", key, (<Error>e).message); + logger.log((<Error>e).stack); + } + return null; +} + +function setJsonItem<T>(store: Storage, key: string, val: T): void { + store.setItem(key, JSON.stringify(val)); +} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts new file mode 100644 index 0000000..29ae81b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts @@ -0,0 +1,533 @@ +/* +Copyright 2017 - 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 * as utils from "../../utils"; +import { + CryptoStore, + IDeviceData, + IProblem, + ISession, + ISessionInfo, + IWithheld, + Mode, + OutgoingRoomKeyRequest, + ParkedSharedHistory, + SecretStorePrivateKeys, +} from "./base"; +import { IRoomKeyRequestBody } from "../index"; +import { ICrossSigningKey } from "../../client"; +import { IOlmDevice } from "../algorithms/megolm"; +import { IRoomEncryption } from "../RoomList"; +import { InboundGroupSessionData } from "../OlmDevice"; +import { safeSet } from "../../utils"; + +/** + * Internal module. in-memory storage for e2e. + */ + +export class MemoryCryptoStore implements CryptoStore { + private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = []; + private account: string | null = null; + private crossSigningKeys: Record<string, ICrossSigningKey> | null = null; + private privateKeys: Partial<SecretStorePrivateKeys> = {}; + + private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {}; + private sessionProblems: { [deviceKey: string]: IProblem[] } = {}; + private notifiedErrorDevices: { [userId: string]: { [deviceId: string]: boolean } } = {}; + private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {}; + private inboundGroupSessionsWithheld: Record<string, IWithheld> = {}; + // Opaque device data object + private deviceData: IDeviceData | null = null; + private rooms: { [roomId: string]: IRoomEncryption } = {}; + private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {}; + private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {}; + private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID + + /** + * Ensure the database exists and is up-to-date. + * + * This must be called before the store can be used. + * + * @returns resolves to the store. + */ + public async startup(): Promise<CryptoStore> { + // No startup work to do for the memory store. + return this; + } + + /** + * Delete all data from this store. + * + * @returns Promise which resolves when the store has been cleared. + */ + public deleteAllData(): Promise<void> { + return Promise.resolve(); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> { + const requestBody = request.requestBody; + + return utils.promiseTry(() => { + // first see if we already have an entry for this request. + const existing = this._getOutgoingRoomKeyRequest(requestBody); + + if (existing) { + // this entry matches the request - return it. + logger.log( + `already have key request outstanding for ` + + `${requestBody.room_id} / ${requestBody.session_id}: ` + + `not sending another`, + ); + return existing; + } + + // we got to the end of the list without finding a match + // - add the new request. + logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); + this.outgoingRoomKeyRequests.push(request); + return request; + }); + } + + /** + * Look for an existing room key request + * + * @param requestBody - existing request to look for + * + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found + */ + public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> { + return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody)); + } + + /** + * Looks for existing room key request, and returns the result synchronously. + * + * @internal + * + * @param requestBody - existing request to look for + * + * @returns + * the matching request, or null if not found + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null { + for (const existing of this.outgoingRoomKeyRequests) { + if (utils.deepCompare(existing.requestBody, requestBody)) { + return existing; + } + } + return null; + } + + /** + * Look for room key requests by state + * + * @param wantedStates - list of acceptable states + * + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states + */ + public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> { + for (const req of this.outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state) { + return Promise.resolve(req); + } + } + } + return Promise.resolve(null); + } + + /** + * + * @returns All OutgoingRoomKeyRequests in state + */ + public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> { + return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState)); + } + + public getOutgoingRoomKeyRequestsByTarget( + userId: string, + deviceId: string, + wantedStates: number[], + ): Promise<OutgoingRoomKeyRequest[]> { + const results: OutgoingRoomKeyRequest[] = []; + + for (const req of this.outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if ( + req.state === state && + req.recipients.some((recipient) => recipient.userId === userId && recipient.deviceId === deviceId) + ) { + results.push(req); + } + } + } + return Promise.resolve(results); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + public updateOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + updates: Partial<OutgoingRoomKeyRequest>, + ): Promise<OutgoingRoomKeyRequest | null> { + for (const req of this.outgoingRoomKeyRequests) { + if (req.requestId !== requestId) { + continue; + } + + if (req.state !== expectedState) { + logger.warn( + `Cannot update room key request from ${expectedState} ` + + `as it was already updated to ${req.state}`, + ); + return Promise.resolve(null); + } + Object.assign(req, updates); + return Promise.resolve(req); + } + + return Promise.resolve(null); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * + * @returns resolves once the operation is completed + */ + public deleteOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + ): Promise<OutgoingRoomKeyRequest | null> { + for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) { + const req = this.outgoingRoomKeyRequests[i]; + + if (req.requestId !== requestId) { + continue; + } + + if (req.state != expectedState) { + logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`); + return Promise.resolve(null); + } + + this.outgoingRoomKeyRequests.splice(i, 1); + return Promise.resolve(req); + } + + return Promise.resolve(null); + } + + // Olm Account + + public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void { + func(this.account); + } + + public storeAccount(txn: unknown, accountPickle: string): void { + this.account = accountPickle; + } + + public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void { + func(this.crossSigningKeys); + } + + public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: unknown, + func: (key: SecretStorePrivateKeys[K] | null) => void, + type: K, + ): void { + const result = this.privateKeys[type] as SecretStorePrivateKeys[K] | undefined; + func(result || null); + } + + public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void { + this.crossSigningKeys = keys; + } + + public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( + txn: unknown, + type: K, + key: SecretStorePrivateKeys[K], + ): void { + this.privateKeys[type] = key; + } + + // Olm Sessions + + public countEndToEndSessions(txn: unknown, func: (count: number) => void): void { + func(Object.keys(this.sessions).length); + } + + public getEndToEndSession( + deviceKey: string, + sessionId: string, + txn: unknown, + func: (session: ISessionInfo) => void, + ): void { + const deviceSessions = this.sessions[deviceKey] || {}; + func(deviceSessions[sessionId] || null); + } + + public getEndToEndSessions( + deviceKey: string, + txn: unknown, + func: (sessions: { [sessionId: string]: ISessionInfo }) => void, + ): void { + func(this.sessions[deviceKey] || {}); + } + + public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void { + Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => { + Object.entries(deviceSessions).forEach(([sessionId, session]) => { + func({ + ...session, + deviceKey, + sessionId, + }); + }); + }); + } + + public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void { + let deviceSessions = this.sessions[deviceKey]; + if (deviceSessions === undefined) { + deviceSessions = {}; + this.sessions[deviceKey] = deviceSessions; + } + deviceSessions[sessionId] = sessionInfo; + } + + public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { + const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []); + problems.push({ type, fixed, time: Date.now() }); + problems.sort((a, b) => { + return a.time - b.time; + }); + } + + public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { + const problems = this.sessionProblems[deviceKey] || []; + if (!problems.length) { + return null; + } + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + return Object.assign({}, problem, { fixed: lastProblem.fixed }); + } + } + if (lastProblem.fixed) { + return null; + } else { + return lastProblem; + } + } + + public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { + const notifiedErrorDevices = this.notifiedErrorDevices; + const ret: IOlmDevice[] = []; + + for (const device of devices) { + const { userId, deviceInfo } = device; + if (userId in notifiedErrorDevices) { + if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { + ret.push(device); + safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true); + } + } else { + ret.push(device); + safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true }); + } + } + + return ret; + } + + // Inbound Group Sessions + + public getEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + txn: unknown, + func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, + ): void { + const k = senderCurve25519Key + "/" + sessionId; + func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null); + } + + public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void { + for (const key of Object.keys(this.inboundGroupSessions)) { + // we can't use split, as the components we are trying to split out + // might themselves contain '/' characters. We rely on the + // senderKey being a (32-byte) curve25519 key, base64-encoded + // (hence 43 characters long). + + func({ + senderKey: key.slice(0, 43), + sessionId: key.slice(44), + sessionData: this.inboundGroupSessions[key], + }); + } + func(null); + } + + public addEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: unknown, + ): void { + const k = senderCurve25519Key + "/" + sessionId; + if (this.inboundGroupSessions[k] === undefined) { + this.inboundGroupSessions[k] = sessionData; + } + } + + public storeEndToEndInboundGroupSession( + senderCurve25519Key: string, + sessionId: string, + sessionData: InboundGroupSessionData, + txn: unknown, + ): void { + this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData; + } + + public storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key: string, + sessionId: string, + sessionData: IWithheld, + txn: unknown, + ): void { + const k = senderCurve25519Key + "/" + sessionId; + this.inboundGroupSessionsWithheld[k] = sessionData; + } + + // Device Data + + public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void { + func(this.deviceData); + } + + public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void { + this.deviceData = deviceData; + } + + // E2E rooms + + public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void { + this.rooms[roomId] = roomInfo; + } + + public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void { + func(this.rooms); + } + + public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { + const sessions: ISession[] = []; + for (const session in this.sessionsNeedingBackup) { + if (this.inboundGroupSessions[session]) { + sessions.push({ + senderKey: session.slice(0, 43), + sessionId: session.slice(44), + sessionData: this.inboundGroupSessions[session], + }); + if (limit && session.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + + public countSessionsNeedingBackup(): Promise<number> { + return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length); + } + + public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> { + for (const session of sessions) { + const sessionKey = session.senderKey + "/" + session.sessionId; + delete this.sessionsNeedingBackup[sessionKey]; + } + return Promise.resolve(); + } + + public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> { + for (const session of sessions) { + const sessionKey = session.senderKey + "/" + session.sessionId; + this.sessionsNeedingBackup[sessionKey] = true; + } + return Promise.resolve(); + } + + public addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string): void { + const sessions = this.sharedHistoryInboundGroupSessions[roomId] || []; + sessions.push([senderKey, sessionId]); + this.sharedHistoryInboundGroupSessions[roomId] = sessions; + } + + public getSharedHistoryInboundGroupSessions(roomId: string): Promise<[senderKey: string, sessionId: string][]> { + return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []); + } + + public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + parked.push(parkedData); + this.parkedSharedHistory.set(roomId, parked); + } + + public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + this.parkedSharedHistory.delete(roomId); + return Promise.resolve(parked); + } + + // Session key backups + + public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> { + return Promise.resolve(func(null)); + } +} 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); + } +} |