diff options
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts')
-rw-r--r-- | includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts | 3936 |
1 files changed, 3936 insertions, 0 deletions
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"; |