1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-30 04:23:07 +03:00

Add support for sending user-defined encrypted to-device messages (#2528)

* Add support for sending user-defined encrypted to-device messages

This is a port of the same change from the robertlong/group-call branch.

* Fix tests

* Expose the method in MatrixClient

* Fix a code smell

* Fix types

* Test the MatrixClient method

* Fix some types in Crypto test suite

* Test the Crypto method

* Fix tests

* Upgrade matrix-mock-request

* Move useRealTimers to afterEach
This commit is contained in:
Robin
2022-08-03 12:16:48 -04:00
committed by GitHub
parent 7e784da00a
commit c36bfc821c
8 changed files with 303 additions and 107 deletions

View File

@ -102,7 +102,7 @@
"jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6",
"matrix-mock-request": "^2.1.1",
"matrix-mock-request": "^2.1.2",
"rimraf": "^3.0.2",
"terser": "^5.5.1",
"tsify": "^5.0.2",

View File

@ -2,6 +2,7 @@ import '../olm-loader';
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
import { MatrixClient } from "../../src/client";
import { Crypto } from "../../src/crypto";
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../MockStorageApi";
@ -64,6 +65,10 @@ describe("Crypto", function() {
return Olm.init();
});
afterEach(() => {
jest.useRealTimers();
});
it("Crypto exposes the correct olm library version", function() {
expect(Crypto.getOlmVersion()[0]).toEqual(3);
});
@ -225,8 +230,8 @@ describe("Crypto", function() {
});
describe('Key requests', function() {
let aliceClient;
let bobClient;
let aliceClient: MatrixClient;
let bobClient: MatrixClient;
beforeEach(async function() {
aliceClient = (new TestClient(
@ -313,7 +318,7 @@ describe("Crypto", function() {
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient.cryptoStore;
const cryptoStore = bobClient.crypto.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
@ -383,9 +388,9 @@ describe("Crypto", function() {
const ksEvent = await keyshareEventForEvent(aliceClient, event, 1);
ksEvent.getContent().sender_key = undefined; // test
bobClient.crypto.addInboundGroupSession = jest.fn();
bobClient.crypto.olmDevice.addInboundGroupSession = jest.fn();
await bobDecryptor.onRoomKeyEvent(ksEvent);
expect(bobClient.crypto.addInboundGroupSession).not.toHaveBeenCalled();
expect(bobClient.crypto.olmDevice.addInboundGroupSession).not.toHaveBeenCalled();
});
it("creates a new keyshare request if we request a keyshare", async function() {
@ -401,7 +406,7 @@ describe("Crypto", function() {
},
});
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
const cryptoStore = aliceClient.cryptoStore;
const cryptoStore = aliceClient.crypto.cryptoStore;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: "!someroom",
@ -425,7 +430,8 @@ describe("Crypto", function() {
},
});
// replace Alice's sendToDevice function with a mock
aliceClient.sendToDevice = jest.fn().mockResolvedValue(undefined);
const aliceSendToDevice = jest.fn().mockResolvedValue(undefined);
aliceClient.sendToDevice = aliceSendToDevice;
aliceClient.startClient();
// make a room key request, and record the transaction ID for the
@ -434,11 +440,12 @@ describe("Crypto", function() {
// key requests get queued until the sync has finished, but we don't
// let the client set up enough for that to happen, so gut-wrench a bit
// to force it to send now.
// @ts-ignore
aliceClient.crypto.outgoingRoomKeyRequestManager.sendQueuedRequests();
jest.runAllTimers();
await Promise.resolve();
expect(aliceClient.sendToDevice).toBeCalledTimes(1);
const txnId = aliceClient.sendToDevice.mock.calls[0][2];
expect(aliceSendToDevice).toBeCalledTimes(1);
const txnId = aliceSendToDevice.mock.calls[0][2];
// give the room key request manager time to update the state
// of the request
@ -451,8 +458,8 @@ describe("Crypto", function() {
// cancelAndResend will call sendToDevice twice:
// the first call to sendToDevice will be the cancellation
// the second call to sendToDevice will be the key request
expect(aliceClient.sendToDevice).toBeCalledTimes(3);
expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId);
expect(aliceSendToDevice).toBeCalledTimes(3);
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
});
@ -480,4 +487,105 @@ describe("Crypto", function() {
client.stopClient();
});
});
describe("encryptAndSendToDevices", () => {
let client: TestClient;
let ensureOlmSessionsForDevices: jest.SpiedFunction<typeof olmlib.ensureOlmSessionsForDevices>;
let encryptMessageForDevice: jest.SpiedFunction<typeof olmlib.encryptMessageForDevice>;
const payload = { hello: "world" };
let encryptedPayload: object;
beforeEach(async () => {
ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices");
ensureOlmSessionsForDevices.mockResolvedValue({});
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
encryptMessageForDevice.mockImplementation(async (...[result,,,,,, payload]) => {
result.plaintext = JSON.stringify(payload);
});
client = new TestClient("@alice:example.org", "aliceweb");
await client.client.initCrypto();
encryptedPayload = {
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: client.client.crypto.olmDevice.deviceCurve25519Key,
ciphertext: { plaintext: JSON.stringify(payload) },
};
});
afterEach(async () => {
ensureOlmSessionsForDevices.mockRestore();
encryptMessageForDevice.mockRestore();
await client.stop();
});
it("encrypts and sends to devices", async () => {
client.httpBackend
.when("PUT", "/sendToDevice/m.room.encrypted", {
messages: {
"@bob:example.org": {
bobweb: encryptedPayload,
bobmobile: encryptedPayload,
},
"@carol:example.org": {
caroldesktop: encryptedPayload,
},
},
})
.respond(200, {});
await Promise.all([
client.client.encryptAndSendToDevices(
[
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobmobile") },
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
],
payload,
),
client.httpBackend.flushAllExpected(),
]);
});
it("sends nothing to devices that couldn't be encrypted to", async () => {
encryptMessageForDevice.mockImplementation(async (...[result,,,, userId, device, payload]) => {
// Refuse to encrypt to Carol's desktop device
if (userId === "@carol:example.org" && device.deviceId === "caroldesktop") return;
result.plaintext = JSON.stringify(payload);
});
client.httpBackend
.when("PUT", "/sendToDevice/m.room.encrypted", {
// Carol is nowhere to be seen
messages: { "@bob:example.org": { bobweb: encryptedPayload } },
})
.respond(200, {});
await Promise.all([
client.client.encryptAndSendToDevices(
[
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
],
payload,
),
client.httpBackend.flushAllExpected(),
]);
});
it("no-ops if no devices can be encrypted to", async () => {
// Refuse to encrypt to anybody
encryptMessageForDevice.mockResolvedValue(undefined);
// Get the room keys version request out of the way
client.httpBackend.when("GET", "/room_keys/version").respond(404, {});
await client.httpBackend.flush("/room_keys/version", 1);
await client.client.encryptAndSendToDevices(
[{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }],
payload,
);
client.httpBackend.verifyNoOutstandingRequests();
});
});
});

View File

@ -360,6 +360,16 @@ describe("MegolmDecryption", function() {
rotation_period_ms: rotationPeriodMs,
},
});
// Splice the real method onto the mock object as megolm uses this method
// on the crypto class in order to encrypt / start sessions
// @ts-ignore Mock
mockCrypto.encryptAndSendToDevices = Crypto.prototype.encryptAndSendToDevices;
// @ts-ignore Mock
mockCrypto.olmDevice = olmDevice;
// @ts-ignore Mock
mockCrypto.baseApis = mockBaseApis;
mockRoom = {
getEncryptionTargetMembers: jest.fn().mockReturnValue(
[{ userId: "@alice:home.server" }],

View File

@ -27,6 +27,7 @@ import {
UNSTABLE_MSC3089_TREE_SUBTYPE,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto";
import { EventStatus, MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials";
import { ReceiptType } from "../../src/@types/read_receipts";
@ -1297,4 +1298,19 @@ describe("MatrixClient", function() {
expect(result!.aliases).toEqual(response.aliases);
});
});
describe("encryptAndSendToDevices", () => {
it("throws an error if crypto is unavailable", () => {
client.crypto = undefined;
expect(() => client.encryptAndSendToDevices([], {})).toThrow();
});
it("is an alias for the crypto method", async () => {
client.crypto = testUtils.mock(Crypto, "Crypto");
const deviceInfos = [];
const payload = {};
await client.encryptAndSendToDevices(deviceInfos, payload);
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
});
});
});

View File

@ -40,9 +40,11 @@ import { sleep } from './utils';
import { Direction, EventTimeline } from "./models/event-timeline";
import { IActionsObject, PushProcessor } from "./pushprocessor";
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
import { IEncryptAndSendToDevicesResult } from "./crypto";
import * as olmlib from "./crypto/olmlib";
import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
import { IExportedDevice as IOlmDevice } from "./crypto/OlmDevice";
import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
import { IOlmDevice } from "./crypto/algorithms/megolm";
import { TypedReEmitter } from './ReEmitter';
import { IRoomEncryption, RoomList } from './crypto/RoomList';
import { logger } from './logger';
@ -208,7 +210,7 @@ const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
interface IExportedDevice {
olmDevice: IOlmDevice;
olmDevice: IExportedOlmDevice;
userId: string;
deviceId: string;
}
@ -936,7 +938,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected turnServers: ITurnServer[] = [];
protected turnServersExpiry = 0;
protected checkTurnServersIntervalID: ReturnType<typeof setInterval>;
protected exportedOlmDeviceToImport: IOlmDevice;
protected exportedOlmDeviceToImport: IExportedOlmDevice;
protected txnCtr = 0;
protected mediaHandler = new MediaHandler(this);
protected pendingEventEncryption = new Map<string, Promise<void>>();
@ -2558,6 +2560,30 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.roomList.isRoomEncrypted(roomId);
}
/**
* Encrypts and sends a given object via Olm to-device messages to a given
* set of devices.
*
* @param {object[]} userDeviceInfoArr
* mapping from userId to deviceInfo
*
* @param {object} payload fields to include in the encrypted payload
* *
* @return {Promise<{contentMap, deviceInfoByDeviceId}>} 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 encryptAndSendToDevices(
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
payload: object,
): Promise<IEncryptAndSendToDevicesResult> {
if (!this.crypto) {
throw new Error("End-to-End encryption disabled");
}
return this.crypto.encryptAndSendToDevices(userDeviceInfoArr, payload);
}
/**
* Forces the current outbound group session to be discarded such
* that another one will be created next time an event is sent.

View File

@ -22,7 +22,6 @@ limitations under the License.
import { logger } from '../../logger';
import * as olmlib from "../olmlib";
import { EventType } from '../../@types/event';
import {
DecryptionAlgorithm,
DecryptionError,
@ -38,7 +37,6 @@ import { IOlmSessionResult } from "../olmlib";
import { DeviceInfoMap } from "../DeviceList";
import { MatrixEvent } from "../..";
import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
import { ToDeviceBatch, ToDeviceMessage } from '../../models/ToDeviceMessage';
// determine whether the key can be shared with invitees
export function isRoomSharedHistory(room: Room): boolean {
@ -611,77 +609,10 @@ class MegolmEncryption extends EncryptionAlgorithm {
userDeviceMap: IOlmDevice[],
payload: IPayload,
): Promise<void> {
const toDeviceBatch: ToDeviceBatch = {
eventType: EventType.RoomMessageEncrypted,
batch: [],
};
// Map from userId to a map of deviceId to deviceInfo
const deviceInfoByUserIdAndDeviceId = new Map<string, Map<string, DeviceInfo>>();
const promises: Promise<unknown>[] = [];
for (let i = 0; i < userDeviceMap.length; i++) {
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key,
ciphertext: {},
};
const val = userDeviceMap[i];
const userId = val.userId;
const deviceInfo = val.deviceInfo;
const deviceId = deviceInfo.deviceId;
// Assign to temp value to make type-checking happy
let userIdDeviceInfo = deviceInfoByUserIdAndDeviceId.get(userId);
if (userIdDeviceInfo === undefined) {
userIdDeviceInfo = new Map<string, DeviceInfo>();
deviceInfoByUserIdAndDeviceId.set(userId, userIdDeviceInfo);
}
// We hold by reference, this updates deviceInfoByUserIdAndDeviceId[userId]
userIdDeviceInfo.set(deviceId, deviceInfo);
toDeviceBatch.batch.push({
userId,
deviceId,
payload: encryptedContent,
});
promises.push(
olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId,
this.deviceId,
this.olmDevice,
userId,
deviceInfo,
return this.crypto.encryptAndSendToDevices(
userDeviceMap,
payload,
),
);
}
return Promise.all(promises).then(() => {
// 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.
const prunedBatch: ToDeviceMessage[] = [];
for (const msg of toDeviceBatch.batch) {
if (Object.keys(msg.payload.ciphertext).length > 0) {
prunedBatch.push(msg);
} else {
logger.log(
"No ciphertext for device " +
msg.userId + ":" + msg.deviceId + ": pruning",
);
}
}
toDeviceBatch.batch = prunedBatch;
return this.baseApis.queueToDevice(toDeviceBatch).then(() => {
).then(({ toDeviceBatch, deviceInfoByUserIdAndDeviceId }) => {
// store that we successfully uploaded the keys of the current slice
for (const msg of toDeviceBatch.batch) {
session.markSharedWithDevice(
@ -691,7 +622,9 @@ class MegolmEncryption extends EncryptionAlgorithm {
chainIndex,
);
}
});
}).catch((error) => {
logger.error("failed to encryptAndSendToDevices", error);
throw error;
});
}

View File

@ -23,9 +23,11 @@ limitations under the License.
import anotherjson from "another-json";
import { EventType } 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";
@ -68,6 +70,7 @@ import { IStore } from "../store";
import { Room, RoomEvent } from "../models/room";
import { RoomMember, RoomMemberEvent } from "../models/room-member";
import { EventStatus, IClearEvent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { ToDeviceBatch } from "../models/ToDeviceMessage";
import {
ClientEvent,
ICrossSigningKey,
@ -201,6 +204,19 @@ export interface IRequestsMap {
setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void;
}
/* eslint-disable camelcase */
export interface IEncryptedContent {
algorithm: string;
sender_key: string;
ciphertext: Record<string, string>;
}
/* eslint-enable camelcase */
export interface IEncryptAndSendToDevicesResult {
toDeviceBatch: ToDeviceBatch;
deviceInfoByUserIdAndDeviceId: Map<string, Map<string, DeviceInfo>>;
}
export enum CryptoEvent {
DeviceVerificationChanged = "deviceVerificationChanged",
UserTrustStatusChanged = "userTrustStatusChanged",
@ -3100,6 +3116,94 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
});
}
/**
* Encrypts and sends a given object via Olm to-device messages to a given
* set of devices.
* @param {object[]} userDeviceInfoArr the devices to send to
* @param {object} payload fields to include in the encrypted payload
* @return {Promise<{contentMap, deviceInfoByDeviceId}>} 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<IEncryptAndSendToDevicesResult> {
const toDeviceBatch: ToDeviceBatch = {
eventType: EventType.RoomMessageEncrypted,
batch: [],
};
const deviceInfoByUserIdAndDeviceId = new Map<string, Map<string, DeviceInfo>>();
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: {},
};
// Assign to temp value to make type-checking happy
let userIdDeviceInfo = deviceInfoByUserIdAndDeviceId.get(userId);
if (userIdDeviceInfo === undefined) {
userIdDeviceInfo = new Map<string, DeviceInfo>();
deviceInfoByUserIdAndDeviceId.set(userId, userIdDeviceInfo);
}
// We hold by reference, this updates deviceInfoByUserIdAndDeviceId[userId]
userIdDeviceInfo.set(deviceId, deviceInfo);
toDeviceBatch.batch.push({
userId,
deviceId,
payload: encryptedContent,
});
await olmlib.ensureOlmSessionsForDevices(
this.olmDevice,
this.baseApis,
{ [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);
return { toDeviceBatch, deviceInfoByUserIdAndDeviceId };
} 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) => {
try {
this.onRoomMembership(event, member, oldMembership);

View File

@ -1307,7 +1307,6 @@
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz":
version "3.2.12"
uid "0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz#0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9"
"@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3":
@ -1438,9 +1437,9 @@
"@octokit/openapi-types" "^12.10.0"
"@sinclair/typebox@^0.24.1":
version "0.24.20"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.20.tgz#11a657875de6008622d53f56e063a6347c51a6dd"
integrity sha512-kVaO5aEFZb33nPMTZBxiPEkY+slxiPtqC7QX8f9B3eGOMBvEfuMfxp9DSTTCsRJPumPKjrge4yagyssO4q6qzQ==
version "0.24.26"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.26.tgz#84f9e8c1d93154e734a7947609a1dc7c7a81cc22"
integrity sha512-1ZVIyyS1NXDRVT8GjWD5jULjhDyM3IsIHef2VGUMdnWOlX2tkPjyEX/7K0TGSH2S8EaPhp1ylFdjSjUGQ+gecg==
"@sinonjs/commons@^1.7.0":
version "1.8.3"
@ -1581,9 +1580,9 @@
integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==
"@types/node@*":
version "18.6.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.1.tgz#828e4785ccca13f44e2fb6852ae0ef11e3e20ba5"
integrity sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==
version "18.6.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.3.tgz#4e4a95b6fe44014563ceb514b2598b3e623d1c98"
integrity sha512-6qKpDtoaYLM+5+AFChLhHermMQxc3TOEFIDzrZLPRGHPrLEwqFkkT5Kx3ju05g6X7uDPazz3jHbKPX0KzCjntg==
"@types/node@16":
version "16.11.45"
@ -4802,10 +4801,10 @@ matrix-events-sdk@^0.0.1-beta.7:
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934"
integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==
matrix-mock-request@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.1.1.tgz#a8fc03a2816464bb95445df4cc8885ac36786b23"
integrity sha512-CxdaUPRVB4o8JxTBMASstS2loRe+hlqeJu0Q7yyS1r36LkSSo/KAP4AuomsqxuKqaqYYnEJFJzkG0gOhxV7aqA==
matrix-mock-request@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.1.2.tgz#11e38ed1233dced88a6f2bfba1684d5c5b3aa2c2"
integrity sha512-/OXCIzDGSLPJ3fs+uzDrtaOHI/Sqp4iEuniRn31U8S06mPXbvAnXknHqJ4c6A/KVwJj/nPFbGXpK4wPM038I6A==
dependencies:
expect "^28.1.0"