/* Copyright 2024 New Vector Ltd. Copyright 2019-2023 The Matrix.org Foundation C.I.C. Copyright 2017, 2018 , 2019 New Vector Ltd Copyright 2017 Vector Creations Ltd. Copyright 2015, 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { EventTimeline, EventTimelineSet, ICreateClientOpts, IStartClientOpts, MatrixClient, MemoryStore, PendingEventOrdering, RoomNameState, RoomNameType, TokenRefreshFunction, } from "matrix-js-sdk/src/matrix"; import { VerificationMethod } from "matrix-js-sdk/src/types"; import * as utils from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import createMatrixClient from "./utils/createMatrixClient"; import SettingsStore from "./settings/SettingsStore"; import MatrixActionCreators from "./actions/MatrixActionCreators"; import Modal from "./Modal"; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from "./utils/StorageManager"; import IdentityAuthClient from "./IdentityAuthClient"; import { crossSigningCallbacks } from "./SecurityManager"; import { SlidingSyncManager } from "./SlidingSyncManager"; import { _t, UserFriendlyError } from "./languageHandler"; import { SettingLevel } from "./settings/SettingLevel"; import MatrixClientBackedController from "./settings/controllers/MatrixClientBackedController"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import PlatformPeg from "./PlatformPeg"; import { formatList } from "./utils/FormattingUtils"; import SdkConfig from "./SdkConfig"; import { Features } from "./settings/Settings"; import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts"; export interface IMatrixClientCreds { homeserverUrl: string; identityServerUrl?: string; userId: string; deviceId?: string; accessToken: string; refreshToken?: string; guest?: boolean; pickleKey?: string; freshLogin?: boolean; } export interface MatrixClientPegAssignOpts { /** * If we are using Rust crypto, a key with which to encrypt the indexeddb. * * If provided, it must be exactly 32 bytes of data. If both this and * {@link MatrixClientPegAssignOpts.rustCryptoStorePassword} are undefined, * the store will be unencrypted. */ rustCryptoStoreKey?: Uint8Array; /** * If we are using Rust crypto, a password which will be used to derive a key to encrypt the store with. * * An alternative to {@link MatrixClientPegAssignOpts.rustCryptoStoreKey}. Ignored if `rustCryptoStoreKey` is set. * * Deriving a key from a password is (deliberately) a slow operation, so prefer to pass a `rustCryptoStoreKey` * directly where possible. */ rustCryptoStorePassword?: string; } /** * Holds the current instance of the `MatrixClient` to use across the codebase. * Looking for an `MatrixClient`? Just look for the `MatrixClientPeg` on the peg * board. "Peg" is the literal meaning of something you hang something on. So * you'll find a `MatrixClient` hanging on the `MatrixClientPeg`. */ export interface IMatrixClientPeg { /** * The opts used to start the client */ opts: IStartClientOpts; /** * Get the current MatrixClient, if any */ get(): MatrixClient | null; /** * Get the current MatrixClient, throwing an error if there isn't one */ safeGet(): MatrixClient; /** * Unset the current MatrixClient */ unset(): void; /** * Prepare the MatrixClient for use, including initialising the store and crypto, but do not start it. */ assign(opts?: MatrixClientPegAssignOpts): Promise; /** * Prepare the MatrixClient for use, including initialising the store and crypto, and start it. */ start(opts?: MatrixClientPegAssignOpts): Promise; /** * If we've registered a user ID we set this to the ID of the * user we've just registered. If they then go & log in, we * can send them to the welcome user (obviously this doesn't * guarantee they'll get a chat with the welcome user). * * @param {string} uid The user ID of the user we've just registered */ setJustRegisteredUserId(uid: string | null): void; /** * Returns true if the current user has just been registered by this * client as determined by setJustRegisteredUserId() * * @returns {bool} True if user has just been registered */ currentUserIsJustRegistered(): boolean; /** * If the current user has been registered by this device then this * returns a boolean of whether it was within the last N hours given. */ userRegisteredWithinLastHours(hours: number): boolean; /** * If the current user has been registered by this device then this * returns a boolean of whether it was after a given timestamp. */ userRegisteredAfter(date: Date): boolean; /** * Replace this MatrixClientPeg's client with a client instance that has * homeserver / identity server URLs and active credentials * * @param {IMatrixClientCreds} creds The new credentials to use. * @param {TokenRefreshFunction} tokenRefreshFunction OPTIONAL function used by MatrixClient to attempt token refresh * see {@link ICreateClientOpts.tokenRefreshFunction} */ replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void; } /** * Wrapper object for handling the js-sdk Matrix Client object in the react-sdk * Handles the creation/initialisation of client objects. * This module provides a singleton instance of this class so the 'current' * Matrix Client object is available easily. */ class MatrixClientPegClass implements IMatrixClientPeg { // These are the default options used when when the // client is started in 'start'. These can be altered // at any time up to after the 'will_start_client' // event is finished processing. public opts: IStartClientOpts = { initialSyncLimit: 20, }; private matrixClient: MatrixClient | null = null; private justRegisteredUserId: string | null = null; public get(): MatrixClient | null { return this.matrixClient; } public safeGet(): MatrixClient { if (!this.matrixClient) { throw new UserFriendlyError("error_user_not_logged_in"); } return this.matrixClient; } public unset(): void { this.matrixClient = null; MatrixActionCreators.stop(); } public setJustRegisteredUserId(uid: string | null): void { this.justRegisteredUserId = uid; if (uid) { const registrationTime = Date.now().toString(); window.localStorage.setItem("mx_registration_time", registrationTime); } } public currentUserIsJustRegistered(): boolean { return !!this.matrixClient && this.matrixClient.credentials.userId === this.justRegisteredUserId; } public userRegisteredWithinLastHours(hours: number): boolean { if (hours <= 0) { return false; } try { const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10); const diff = Date.now() - registrationTime; return diff / 36e5 <= hours; } catch { return false; } } public userRegisteredAfter(timestamp: Date): boolean { try { const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10); return timestamp.getTime() <= registrationTime; } catch { return false; } } public replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void { this.createClient(creds, tokenRefreshFunction); } private onUnexpectedStoreClose = async (): Promise => { if (!this.matrixClient) return; this.matrixClient.stopClient(); // stop the client as the database has failed this.matrixClient.store.destroy(); if (!this.matrixClient.isGuest()) { // If the user is not a guest then prompt them to reload rather than doing it for them // For guests this is likely to happen during e-mail verification as part of registration const brand = SdkConfig.get().brand; const platform = PlatformPeg.get()?.getHumanReadableName(); // Determine the description based on the platform const description = platform === "Web Platform" ? _t("error_database_closed_description|for_web", { brand }) : _t("error_database_closed_description|for_desktop"); const [reload] = await Modal.createDialog(ErrorDialog, { title: _t("error_database_closed_title", { brand }), description, button: _t("action|reload"), }).finished; if (!reload) return; } PlatformPeg.get()?.reload(); }; /** * Implementation of {@link IMatrixClientPeg.assign}. */ public async assign(assignOpts: MatrixClientPegAssignOpts = {}): Promise { if (!this.matrixClient) { throw new Error("createClient must be called first"); } for (const dbType of ["indexeddb", "memory"]) { try { const promise = this.matrixClient.store.startup(); logger.log("MatrixClientPeg: waiting for MatrixClient store to initialise"); await promise; break; } catch (err) { if (dbType === "indexeddb") { logger.error("Error starting matrixclient store - falling back to memory store", err); this.matrixClient.store = new MemoryStore({ localStorage: localStorage, }); } else { logger.error("Failed to start memory store!", err); throw err; } } } this.matrixClient.store.on?.("closed", this.onUnexpectedStoreClose); // try to initialise e2e on the new client if (!SettingsStore.getValue("lowBandwidth")) { await this.initClientCrypto(assignOpts.rustCryptoStoreKey, assignOpts.rustCryptoStorePassword); } const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.threadSupport = true; if (SettingsStore.getValue("feature_sliding_sync")) { opts.slidingSync = await SlidingSyncManager.instance.setup(this.matrixClient); } else { SlidingSyncManager.instance.checkSupport(this.matrixClient); } // Connect the matrix client to the dispatcher and setting handlers MatrixActionCreators.start(this.matrixClient); MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient; MatrixClientBackedController.matrixClient = this.matrixClient; return opts; } /** * Attempt to initialize the crypto layer on a newly-created MatrixClient * * @param rustCryptoStoreKey - A key with which to encrypt the rust crypto indexeddb. * If provided, it must be exactly 32 bytes of data. If both this and `rustCryptoStorePassword` are * undefined, the store will be unencrypted. * * @param rustCryptoStorePassword - An alternative to `rustCryptoStoreKey`. Ignored if `rustCryptoStoreKey` is set. * A password which will be used to derive a key to encrypt the store with. Deriving a key from a password is * (deliberately) a slow operation, so prefer to pass a `rustCryptoStoreKey` directly where possible. */ private async initClientCrypto(rustCryptoStoreKey?: Uint8Array, rustCryptoStorePassword?: string): Promise { if (!this.matrixClient) { throw new Error("createClient must be called first"); } if (!rustCryptoStoreKey && !rustCryptoStorePassword) { logger.error("Warning! Not using an encryption key for rust crypto store."); } // Record the fact that we used the Rust crypto stack with this client. This just guards against people // rolling back to versions of EW that did not default to Rust crypto (which would lead to an error, since // we cannot migrate from Rust to Legacy crypto). await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, true); await this.matrixClient.initRustCrypto({ storageKey: rustCryptoStoreKey, storagePassword: rustCryptoStorePassword, }); StorageManager.setCryptoInitialised(true); setDeviceIsolationMode(this.matrixClient, SettingsStore.getValue("feature_exclude_insecure_devices")); // TODO: device dehydration and whathaveyou return; } /** * Implementation of {@link IMatrixClientPeg.start}. */ public async start(assignOpts?: MatrixClientPegAssignOpts): Promise { const opts = await this.assign(assignOpts); logger.log(`MatrixClientPeg: really starting MatrixClient`); await this.matrixClient!.startClient(opts); logger.log(`MatrixClientPeg: MatrixClient started`); } private namesToRoomName(names: string[], count: number): string | undefined { const countWithoutMe = count - 1; if (!names.length) { return _t("empty_room"); } if (names.length === 1 && countWithoutMe <= 1) { return names[0]; } } private memberNamesToRoomName(names: string[], count: number): string { const name = this.namesToRoomName(names, count); if (name) return name; if (names.length === 2 && count === 2) { return formatList(names); } return formatList(names, 1); } private inviteeNamesToRoomName(names: string[], count: number): string { const name = this.namesToRoomName(names, count); if (name) return name; if (names.length === 2 && count === 2) { return _t("inviting_user1_and_user2", { user1: names[0], user2: names[1], }); } return _t("inviting_user_and_n_others", { user: names[0], count: count - 1, }); } private createClient(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void { const opts: ICreateClientOpts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, accessToken: creds.accessToken, refreshToken: creds.refreshToken, tokenRefreshFunction, userId: creds.userId, deviceId: creds.deviceId, pickleKey: creds.pickleKey, timelineSupport: true, forceTURN: !SettingsStore.getValue("webRtcAllowPeerToPeer"), fallbackICEServerAllowed: !!SettingsStore.getValue("fallbackICEServerAllowed"), // Gather up to 20 ICE candidates when a call arrives: this should be more than we'd // ever normally need, so effectively this should make all the gathering happen when // the call arrives. iceCandidatePoolSize: 20, verificationMethods: [ VerificationMethod.Sas, VerificationMethod.ShowQrCode, VerificationMethod.Reciprocate, ], identityServer: new IdentityAuthClient(), // These are always installed regardless of the labs flag so that cross-signing features // can toggle on without reloading and also be accessed immediately after login. cryptoCallbacks: { ...crossSigningCallbacks }, roomNameGenerator: (_: string, state: RoomNameState) => { switch (state.type) { case RoomNameType.Generated: switch (state.subtype) { case "Inviting": return this.inviteeNamesToRoomName(state.names, state.count); default: return this.memberNamesToRoomName(state.names, state.count); } case RoomNameType.EmptyRoom: if (state.oldName) { return _t("empty_room_was_name", { oldName: state.oldName, }); } else { return _t("empty_room"); } default: return null; } }, }; this.matrixClient = createMatrixClient(opts); this.matrixClient.setGuest(Boolean(creds.guest)); const notifTimelineSet = new EventTimelineSet(undefined, { timelineSupport: true, pendingEvents: false, }); // XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync. notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); this.matrixClient.setNotifTimelineSet(notifTimelineSet); } } /** * Note: You should be using a React context with access to a client rather than * using this, as in a multi-account world this will not exist! */ export const MatrixClientPeg: IMatrixClientPeg = new MatrixClientPegClass(); if (!window.mxMatrixClientPeg) { window.mxMatrixClientPeg = MatrixClientPeg; }