You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-25 05:23:13 +03:00
Implement experimental encrypted state events. (#4994)
* feat: Implement experimental encrypted state events. Signed-off-by: Skye Elliot <actuallyori@gmail.com> * fix: Add cast from StateEvents[K] to IContent. --------- Signed-off-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
@@ -49,7 +49,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.2.0",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^15.3.0",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^1.0.4",
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ import {
|
|||||||
encryptMegolmEventRawPlainText,
|
encryptMegolmEventRawPlainText,
|
||||||
establishOlmSession,
|
establishOlmSession,
|
||||||
getTestOlmAccountKeys,
|
getTestOlmAccountKeys,
|
||||||
} from "./olm-utils";
|
expectSendRoomKey,
|
||||||
|
expectSendMegolmMessageEvent,
|
||||||
|
expectEncryptedSendMessageEvent,
|
||||||
|
} from "./olm-utils.ts";
|
||||||
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
|
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
|
||||||
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
|
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
|
||||||
import { KnownMembership } from "../../../src/@types/membership";
|
import { KnownMembership } from "../../../src/@types/membership";
|
||||||
@@ -104,107 +107,6 @@ afterEach(() => {
|
|||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Expect that the client shares keys with the given recipient
|
|
||||||
*
|
|
||||||
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
|
|
||||||
* to establish an Olm InboundGroupSession.
|
|
||||||
*
|
|
||||||
* @param recipientUserID - the user id of the expected recipient
|
|
||||||
*
|
|
||||||
* @param recipientOlmAccount - Olm.Account for the recipient
|
|
||||||
*
|
|
||||||
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
|
|
||||||
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
|
|
||||||
*
|
|
||||||
* @returns the established inbound group session
|
|
||||||
*/
|
|
||||||
async function expectSendRoomKey(
|
|
||||||
recipientUserID: string,
|
|
||||||
recipientOlmAccount: Olm.Account,
|
|
||||||
recipientOlmSession: Olm.Session | null = null,
|
|
||||||
): Promise<Olm.InboundGroupSession> {
|
|
||||||
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
|
|
||||||
|
|
||||||
function onSendRoomKey(content: any): Olm.InboundGroupSession {
|
|
||||||
const m = content.messages[recipientUserID].DEVICE_ID;
|
|
||||||
const ct = m.ciphertext[testRecipientKey];
|
|
||||||
|
|
||||||
if (!recipientOlmSession) {
|
|
||||||
expect(ct.type).toEqual(0); // pre-key message
|
|
||||||
recipientOlmSession = new Olm.Session();
|
|
||||||
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
|
|
||||||
} else {
|
|
||||||
expect(ct.type).toEqual(1); // regular message
|
|
||||||
}
|
|
||||||
|
|
||||||
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
|
|
||||||
expect(decrypted.type).toEqual("m.room_key");
|
|
||||||
const inboundGroupSession = new Olm.InboundGroupSession();
|
|
||||||
inboundGroupSession.create(decrypted.content.session_key);
|
|
||||||
return inboundGroupSession;
|
|
||||||
}
|
|
||||||
return await new Promise<Olm.InboundGroupSession>((resolve) => {
|
|
||||||
fetchMock.putOnce(
|
|
||||||
new RegExp("/sendToDevice/m.room.encrypted/"),
|
|
||||||
(url: string, opts: RequestInit): FetchMock.MockResponse => {
|
|
||||||
const content = JSON.parse(opts.body as string);
|
|
||||||
resolve(onSendRoomKey(content));
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// append to the list of intercepts on this path (since we have some tests that call
|
|
||||||
// this function multiple times)
|
|
||||||
overwriteRoutes: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
|
|
||||||
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
|
|
||||||
* @returns the content of the encrypted event
|
|
||||||
*/
|
|
||||||
function expectEncryptedSendMessage() {
|
|
||||||
return new Promise<IContent>((resolve) => {
|
|
||||||
fetchMock.putOnce(
|
|
||||||
new RegExp("/send/m.room.encrypted/"),
|
|
||||||
(url, request) => {
|
|
||||||
const content = JSON.parse(request.body as string);
|
|
||||||
resolve(content);
|
|
||||||
return { event_id: "$event_id" };
|
|
||||||
},
|
|
||||||
// append to the list of intercepts on this path (since we have some tests that call
|
|
||||||
// this function multiple times)
|
|
||||||
{ overwriteRoutes: false },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expect that the client sends an encrypted event
|
|
||||||
*
|
|
||||||
* Waits for an HTTP request to send an encrypted message in the test room.
|
|
||||||
*
|
|
||||||
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
|
|
||||||
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
|
|
||||||
*
|
|
||||||
* @returns The content of the successfully-decrypted event
|
|
||||||
*/
|
|
||||||
async function expectSendMegolmMessage(
|
|
||||||
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
|
|
||||||
): Promise<Partial<IEvent>> {
|
|
||||||
const encryptedMessageContent = await expectEncryptedSendMessage();
|
|
||||||
|
|
||||||
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
|
|
||||||
const inboundGroupSession = await inboundGroupSessionPromise;
|
|
||||||
|
|
||||||
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
|
|
||||||
logger.log("Decrypted received megolm message", r);
|
|
||||||
return JSON.parse(r.plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("crypto", () => {
|
describe("crypto", () => {
|
||||||
let testOlmAccount = {} as unknown as Olm.Account;
|
let testOlmAccount = {} as unknown as Olm.Account;
|
||||||
let testSenderKey = "";
|
let testSenderKey = "";
|
||||||
@@ -991,7 +893,7 @@ describe("crypto", () => {
|
|||||||
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
|
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||||
expectSendMegolmMessage(inboundGroupSessionPromise),
|
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1018,7 +920,7 @@ describe("crypto", () => {
|
|||||||
// Send the first message, and check we can decrypt it.
|
// Send the first message, and check we can decrypt it.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||||
expectSendMegolmMessage(inboundGroupSessionPromise),
|
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Finally the interesting part: discard the session.
|
// Finally the interesting part: discard the session.
|
||||||
@@ -1026,7 +928,7 @@ describe("crypto", () => {
|
|||||||
|
|
||||||
// Now when we send the next message, we should get a *new* megolm session.
|
// Now when we send the next message, we should get a *new* megolm session.
|
||||||
const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||||
const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2);
|
const p2 = expectSendMegolmMessageEvent(inboundGroupSessionPromise2);
|
||||||
await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]);
|
await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1037,7 +939,7 @@ describe("crypto", () => {
|
|||||||
*/
|
*/
|
||||||
async function sendEncryptedMessage(): Promise<IContent> {
|
async function sendEncryptedMessage(): Promise<IContent> {
|
||||||
const [encryptedMessage] = await Promise.all([
|
const [encryptedMessage] = await Promise.all([
|
||||||
expectEncryptedSendMessage(),
|
expectEncryptedSendMessageEvent(),
|
||||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||||
]);
|
]);
|
||||||
return encryptedMessage;
|
return encryptedMessage;
|
||||||
@@ -1159,7 +1061,7 @@ describe("crypto", () => {
|
|||||||
let [, , encryptedMessage] = await Promise.all([
|
let [, , encryptedMessage] = await Promise.all([
|
||||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||||
expectEncryptedSendMessage(),
|
expectEncryptedSendMessageEvent(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check that the session id exists
|
// Check that the session id exists
|
||||||
@@ -1187,7 +1089,7 @@ describe("crypto", () => {
|
|||||||
[, , encryptedMessage] = await Promise.all([
|
[, , encryptedMessage] = await Promise.all([
|
||||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||||
expectEncryptedSendMessage(),
|
expectEncryptedSendMessageEvent(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check that the new session id exists
|
// Check that the new session id exists
|
||||||
@@ -1385,7 +1287,7 @@ describe("crypto", () => {
|
|||||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
|
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
|
||||||
|
|
||||||
// and finally the megolm message
|
// and finally the megolm message
|
||||||
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
|
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
|
||||||
|
|
||||||
// kick it off
|
// kick it off
|
||||||
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
|
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
|
||||||
@@ -1408,7 +1310,7 @@ describe("crypto", () => {
|
|||||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
|
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
|
||||||
|
|
||||||
// and finally the megolm message
|
// and finally the megolm message
|
||||||
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
|
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
|
||||||
|
|
||||||
// kick it off
|
// kick it off
|
||||||
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
|
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
|
||||||
@@ -2300,7 +2202,7 @@ describe("crypto", () => {
|
|||||||
await syncPromise(client1);
|
await syncPromise(client1);
|
||||||
|
|
||||||
// Send a message, and expect to get an `m.room.encrypted` event.
|
// Send a message, and expect to get an `m.room.encrypted` event.
|
||||||
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]);
|
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessageEvent()]);
|
||||||
|
|
||||||
// We now replace the client, and allow the new one to resync, *without* the encryption event.
|
// We now replace the client, and allow the new one to resync, *without* the encryption event.
|
||||||
client2 = await replaceClient(client1);
|
client2 = await replaceClient(client1);
|
||||||
@@ -2321,7 +2223,7 @@ describe("crypto", () => {
|
|||||||
// Send a message, and expect to get an `m.room.encrypted` event.
|
// Send a message, and expect to get an `m.room.encrypted` event.
|
||||||
const [, msg1Content] = await Promise.all([
|
const [, msg1Content] = await Promise.all([
|
||||||
client1.sendTextMessage(ROOM_ID, "test1"),
|
client1.sendTextMessage(ROOM_ID, "test1"),
|
||||||
expectEncryptedSendMessage(),
|
expectEncryptedSendMessageEvent(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Replace the state with one which bumps the rotation period. This should be ignored, though it's not
|
// Replace the state with one which bumps the rotation period. This should be ignored, though it's not
|
||||||
@@ -2340,12 +2242,12 @@ describe("crypto", () => {
|
|||||||
// use a different one.
|
// use a different one.
|
||||||
const [, msg2Content] = await Promise.all([
|
const [, msg2Content] = await Promise.all([
|
||||||
client1.sendTextMessage(ROOM_ID, "test2"),
|
client1.sendTextMessage(ROOM_ID, "test2"),
|
||||||
expectEncryptedSendMessage(),
|
expectEncryptedSendMessageEvent(),
|
||||||
]);
|
]);
|
||||||
expect(msg2Content.session_id).toEqual(msg1Content.session_id);
|
expect(msg2Content.session_id).toEqual(msg1Content.session_id);
|
||||||
const [, msg3Content] = await Promise.all([
|
const [, msg3Content] = await Promise.all([
|
||||||
client1.sendTextMessage(ROOM_ID, "test3"),
|
client1.sendTextMessage(ROOM_ID, "test3"),
|
||||||
expectEncryptedSendMessage(),
|
expectEncryptedSendMessageEvent(),
|
||||||
]);
|
]);
|
||||||
expect(msg3Content.session_id).not.toEqual(msg1Content.session_id);
|
expect(msg3Content.session_id).not.toEqual(msg1Content.session_id);
|
||||||
});
|
});
|
||||||
@@ -2357,7 +2259,7 @@ describe("crypto", () => {
|
|||||||
await syncPromise(client1);
|
await syncPromise(client1);
|
||||||
|
|
||||||
// Send a message, and expect to get an `m.room.encrypted` event.
|
// Send a message, and expect to get an `m.room.encrypted` event.
|
||||||
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]);
|
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessageEvent()]);
|
||||||
|
|
||||||
// We now replace the client, and allow the new one to resync with a *different* encryption event.
|
// We now replace the client, and allow the new one to resync with a *different* encryption event.
|
||||||
client2 = await replaceClient(client1);
|
client2 = await replaceClient(client1);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import Olm from "@matrix-org/olm";
|
import Olm from "@matrix-org/olm";
|
||||||
import anotherjson from "another-json";
|
import anotherjson from "another-json";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type IContent,
|
type IContent,
|
||||||
@@ -30,6 +31,8 @@ import { type IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
|||||||
import { type ISyncResponder } from "../../test-utils/SyncResponder";
|
import { type ISyncResponder } from "../../test-utils/SyncResponder";
|
||||||
import { syncPromise } from "../../test-utils/test-utils";
|
import { syncPromise } from "../../test-utils/test-utils";
|
||||||
import { type KeyBackupInfo } from "../../../src/crypto-api";
|
import { type KeyBackupInfo } from "../../../src/crypto-api";
|
||||||
|
import { logger } from "../../../src/logger";
|
||||||
|
import type FetchMock from "fetch-mock";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @module
|
* @module
|
||||||
@@ -302,6 +305,7 @@ export function encryptMegolmEventRawPlainText(opts: {
|
|||||||
},
|
},
|
||||||
type: "m.room.encrypted",
|
type: "m.room.encrypted",
|
||||||
unsigned: {},
|
unsigned: {},
|
||||||
|
state_key: opts.plaintext.state_key ? `${opts.plaintext.type}:${opts.plaintext.state_key}` : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,3 +418,148 @@ export async function establishOlmSession(
|
|||||||
await syncPromise(testClient);
|
await syncPromise(testClient);
|
||||||
return p2pSession;
|
return p2pSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect that the client shares keys with the given recipient
|
||||||
|
*
|
||||||
|
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
|
||||||
|
* to establish an Olm InboundGroupSession.
|
||||||
|
*
|
||||||
|
* @param recipientUserID - the user id of the expected recipient
|
||||||
|
*
|
||||||
|
* @param recipientOlmAccount - Olm.Account for the recipient
|
||||||
|
*
|
||||||
|
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
|
||||||
|
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
|
||||||
|
*
|
||||||
|
* @returns the established inbound group session
|
||||||
|
*/
|
||||||
|
export async function expectSendRoomKey(
|
||||||
|
recipientUserID: string,
|
||||||
|
recipientOlmAccount: Olm.Account,
|
||||||
|
recipientOlmSession: Olm.Session | null = null,
|
||||||
|
): Promise<Olm.InboundGroupSession> {
|
||||||
|
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
|
||||||
|
|
||||||
|
function onSendRoomKey(content: any): Olm.InboundGroupSession {
|
||||||
|
const m = content.messages[recipientUserID].DEVICE_ID;
|
||||||
|
const ct = m.ciphertext[testRecipientKey];
|
||||||
|
|
||||||
|
if (!recipientOlmSession) {
|
||||||
|
expect(ct.type).toEqual(0); // pre-key message
|
||||||
|
recipientOlmSession = new Olm.Session();
|
||||||
|
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
|
||||||
|
} else {
|
||||||
|
expect(ct.type).toEqual(1); // regular message
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
|
||||||
|
expect(decrypted.type).toEqual("m.room_key");
|
||||||
|
const inboundGroupSession = new Olm.InboundGroupSession();
|
||||||
|
inboundGroupSession.create(decrypted.content.session_key);
|
||||||
|
return inboundGroupSession;
|
||||||
|
}
|
||||||
|
return await new Promise<Olm.InboundGroupSession>((resolve) => {
|
||||||
|
fetchMock.putOnce(
|
||||||
|
new RegExp("/sendToDevice/m.room.encrypted/"),
|
||||||
|
(url: string, opts: RequestInit): FetchMock.MockResponse => {
|
||||||
|
const content = JSON.parse(opts.body as string);
|
||||||
|
resolve(onSendRoomKey(content));
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// append to the list of intercepts on this path (since we have some tests that call
|
||||||
|
// this function multiple times)
|
||||||
|
overwriteRoutes: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
|
||||||
|
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
|
||||||
|
* @returns the content of the encrypted event
|
||||||
|
*/
|
||||||
|
export function expectEncryptedSendMessageEvent() {
|
||||||
|
return new Promise<IContent>((resolve) => {
|
||||||
|
fetchMock.putOnce(
|
||||||
|
new RegExp("/send/m.room.encrypted/"),
|
||||||
|
(url, request) => {
|
||||||
|
const content = JSON.parse(request.body as string);
|
||||||
|
resolve(content);
|
||||||
|
return { event_id: "$event_id" };
|
||||||
|
},
|
||||||
|
// append to the list of intercepts on this path (since we have some tests that call
|
||||||
|
// this function multiple times)
|
||||||
|
{ overwriteRoutes: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the event received on rooms/{roomId}/state/m.room.encrypted/{stateKey} endpoint.
|
||||||
|
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey
|
||||||
|
* @returns the content of the encrypted event
|
||||||
|
*/
|
||||||
|
function expectEncryptedSendStateEvent() {
|
||||||
|
return new Promise<IContent>((resolve) => {
|
||||||
|
fetchMock.putOnce(
|
||||||
|
new RegExp("/state/m.room.encrypted/"),
|
||||||
|
(url, request) => {
|
||||||
|
const content = JSON.parse(request.body as string);
|
||||||
|
resolve(content);
|
||||||
|
return { event_id: "$event_id" };
|
||||||
|
},
|
||||||
|
// append to the list of intercepts on this path (since we have some tests that call
|
||||||
|
// this function multiple times)
|
||||||
|
{ overwriteRoutes: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect that the client sends an encrypted message event
|
||||||
|
*
|
||||||
|
* Waits for an HTTP request to send an encrypted message in the test room.
|
||||||
|
*
|
||||||
|
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
|
||||||
|
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
|
||||||
|
*
|
||||||
|
* @returns The content of the successfully-decrypted event
|
||||||
|
*/
|
||||||
|
export async function expectSendMegolmMessageEvent(
|
||||||
|
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
|
||||||
|
): Promise<Partial<IEvent>> {
|
||||||
|
const encryptedMessageContent = await expectEncryptedSendMessageEvent();
|
||||||
|
|
||||||
|
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
|
||||||
|
const inboundGroupSession = await inboundGroupSessionPromise;
|
||||||
|
|
||||||
|
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
|
||||||
|
logger.log("Decrypted received megolm message", r);
|
||||||
|
return JSON.parse(r.plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect that the client sends an encrypted state event
|
||||||
|
*
|
||||||
|
* Waits for an HTTP request to send an encrypted state event in the test room.
|
||||||
|
*
|
||||||
|
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
|
||||||
|
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
|
||||||
|
*
|
||||||
|
* @returns The content of the successfully-decrypted state event
|
||||||
|
*/
|
||||||
|
export async function expectSendMegolmStateEvent(
|
||||||
|
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
|
||||||
|
): Promise<Partial<IEvent>> {
|
||||||
|
const encryptedStateContent = await expectEncryptedSendStateEvent();
|
||||||
|
|
||||||
|
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
|
||||||
|
const inboundGroupSession = await inboundGroupSessionPromise;
|
||||||
|
|
||||||
|
const r: any = inboundGroupSession.decrypt(encryptedStateContent!.ciphertext);
|
||||||
|
logger.log("Decrypted received megolm state event", r);
|
||||||
|
return JSON.parse(r.plaintext);
|
||||||
|
}
|
||||||
|
|||||||
219
spec/integ/crypto/state-events.spec.ts
Normal file
219
spec/integ/crypto/state-events.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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 fetchMock from "fetch-mock-jest";
|
||||||
|
import "fake-indexeddb/auto";
|
||||||
|
import Olm from "@matrix-org/olm";
|
||||||
|
|
||||||
|
import * as testUtils from "../../test-utils/test-utils";
|
||||||
|
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||||
|
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
|
||||||
|
import { logger } from "../../../src/logger";
|
||||||
|
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
|
||||||
|
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||||
|
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||||
|
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||||
|
import {
|
||||||
|
createOlmAccount,
|
||||||
|
createOlmSession,
|
||||||
|
encryptGroupSessionKey,
|
||||||
|
encryptMegolmEvent,
|
||||||
|
getTestOlmAccountKeys,
|
||||||
|
expectSendRoomKey,
|
||||||
|
expectSendMegolmStateEvent,
|
||||||
|
} from "./olm-utils";
|
||||||
|
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||||
|
|
||||||
|
describe("Encrypted State Events", () => {
|
||||||
|
let testOlmAccount = {} as unknown as Olm.Account;
|
||||||
|
let testSenderKey = "";
|
||||||
|
|
||||||
|
/** the MatrixClient under test */
|
||||||
|
let aliceClient: MatrixClient;
|
||||||
|
|
||||||
|
/** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */
|
||||||
|
let keyReceiver: E2EKeyReceiver;
|
||||||
|
|
||||||
|
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||||
|
let syncResponder: ISyncResponder;
|
||||||
|
|
||||||
|
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
|
||||||
|
logger.log(aliceClient.getUserId() + ": starting");
|
||||||
|
|
||||||
|
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||||
|
|
||||||
|
// we let the client do a very basic initial sync, which it needs before
|
||||||
|
// it will upload one-time keys.
|
||||||
|
syncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
|
||||||
|
|
||||||
|
aliceClient.startClient({
|
||||||
|
// set this so that we can get hold of failed events
|
||||||
|
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncPromise(aliceClient);
|
||||||
|
logger.log(aliceClient.getUserId() + ": started");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fetchMock.catch(404);
|
||||||
|
fetchMock.config.warnOnFallback = false;
|
||||||
|
|
||||||
|
const homeserverUrl = "https://alice-server.com";
|
||||||
|
aliceClient = createClient({
|
||||||
|
baseUrl: homeserverUrl,
|
||||||
|
userId: "@alice:localhost",
|
||||||
|
accessToken: "akjgkrgjs",
|
||||||
|
deviceId: "xzcvb",
|
||||||
|
logger: logger.getChild("aliceClient"),
|
||||||
|
enableEncryptedStateEvents: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
keyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||||
|
syncResponder = new SyncResponder(homeserverUrl);
|
||||||
|
|
||||||
|
await aliceClient.initRustCrypto();
|
||||||
|
|
||||||
|
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||||
|
testOlmAccount = await createOlmAccount();
|
||||||
|
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
|
||||||
|
testSenderKey = testE2eKeys.curve25519;
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await aliceClient.stopClient();
|
||||||
|
await jest.runAllTimersAsync();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectAliceKeyQuery(response: any) {
|
||||||
|
fetchMock.postOnce(new RegExp("/keys/query"), (url: string, opts: RequestInit) => response, {
|
||||||
|
overwriteRoutes: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectAliceKeyClaim(response: any) {
|
||||||
|
fetchMock.postOnce(new RegExp("/keys/claim"), response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTestKeysClaimResponse(userId: string) {
|
||||||
|
testOlmAccount.generate_one_time_keys(1);
|
||||||
|
const testOneTimeKeys = JSON.parse(testOlmAccount.one_time_keys());
|
||||||
|
testOlmAccount.mark_keys_as_published();
|
||||||
|
|
||||||
|
const keyId = Object.keys(testOneTimeKeys.curve25519)[0];
|
||||||
|
const oneTimeKey: string = testOneTimeKeys.curve25519[keyId];
|
||||||
|
const unsignedKeyResult = { key: oneTimeKey };
|
||||||
|
const j = anotherjson.stringify(unsignedKeyResult);
|
||||||
|
const sig = testOlmAccount.sign(j);
|
||||||
|
const keyResult = {
|
||||||
|
...unsignedKeyResult,
|
||||||
|
signatures: { [userId]: { "ed25519:DEVICE_ID": sig } },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
one_time_keys: { [userId]: { DEVICE_ID: { ["signed_curve25519:" + keyId]: keyResult } } },
|
||||||
|
failures: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("Should receive an encrypted state event", async () => {
|
||||||
|
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||||
|
await startClientAndAwaitFirstSync();
|
||||||
|
|
||||||
|
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
|
||||||
|
const groupSession = new Olm.OutboundGroupSession();
|
||||||
|
groupSession.create();
|
||||||
|
|
||||||
|
// make the room_key event
|
||||||
|
const roomKeyEncrypted = encryptGroupSessionKey({
|
||||||
|
recipient: aliceClient.getUserId()!,
|
||||||
|
recipientCurve25519Key: keyReceiver.getDeviceKey(),
|
||||||
|
recipientEd25519Key: keyReceiver.getSigningKey(),
|
||||||
|
olmAccount: testOlmAccount,
|
||||||
|
p2pSession: p2pSession,
|
||||||
|
groupSession: groupSession,
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
// encrypt a state event with the group session
|
||||||
|
const eventEncrypted = encryptMegolmEvent({
|
||||||
|
senderKey: testSenderKey,
|
||||||
|
groupSession: groupSession,
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
plaintext: {
|
||||||
|
type: "m.room.topic",
|
||||||
|
state_key: "",
|
||||||
|
content: {
|
||||||
|
topic: "Secret!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alice gets both the events in a single sync
|
||||||
|
const syncResponse = {
|
||||||
|
next_batch: 1,
|
||||||
|
to_device: {
|
||||||
|
events: [roomKeyEncrypted],
|
||||||
|
},
|
||||||
|
rooms: {
|
||||||
|
join: {
|
||||||
|
[ROOM_ID]: { timeline: { events: [eventEncrypted] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||||
|
await syncPromise(aliceClient);
|
||||||
|
|
||||||
|
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||||
|
const event = room.getLiveTimeline().getEvents()[0];
|
||||||
|
expect(event.isEncrypted()).toBe(true);
|
||||||
|
|
||||||
|
// it probably won't be decrypted yet, because it takes a while to process the olm keys
|
||||||
|
const decryptedEvent = await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||||
|
expect(decryptedEvent.getContent().topic).toEqual("Secret!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should send an encrypted state event", async () => {
|
||||||
|
const homeserverUrl = aliceClient.getHomeserverUrl();
|
||||||
|
const keyResponder = new E2EKeyResponder(homeserverUrl);
|
||||||
|
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
|
||||||
|
|
||||||
|
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
|
||||||
|
keyResponder.addDeviceKeys(testDeviceKeys);
|
||||||
|
|
||||||
|
await startClientAndAwaitFirstSync();
|
||||||
|
|
||||||
|
// Alice shares a room with Bob
|
||||||
|
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
|
||||||
|
await syncPromise(aliceClient);
|
||||||
|
|
||||||
|
// ... and claim one of Bob's OTKs ...
|
||||||
|
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
|
||||||
|
|
||||||
|
// ... and send an m.room.topic message
|
||||||
|
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||||
|
|
||||||
|
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
|
||||||
|
await Promise.all([
|
||||||
|
aliceClient.setRoomTopic(ROOM_ID, "Secret!"),
|
||||||
|
expectSendMegolmStateEvent(inboundGroupSessionPromise),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,11 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
|
|||||||
*
|
*
|
||||||
* @returns the sync response
|
* @returns the sync response
|
||||||
*/
|
*/
|
||||||
export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse {
|
export function getSyncResponse(
|
||||||
|
roomMembers: string[],
|
||||||
|
roomId = TEST_ROOM_ID,
|
||||||
|
encryptStateEvents = false,
|
||||||
|
): ISyncResponse {
|
||||||
const roomResponse: IJoinedRoom = {
|
const roomResponse: IJoinedRoom = {
|
||||||
summary: {
|
summary: {
|
||||||
"m.heroes": [],
|
"m.heroes": [],
|
||||||
@@ -77,7 +81,8 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I
|
|||||||
type: "m.room.encryption",
|
type: "m.room.encryption",
|
||||||
state_key: "",
|
state_key: "",
|
||||||
content: {
|
content: {
|
||||||
algorithm: "m.megolm.v1.aes-sha2",
|
"algorithm": "m.megolm.v1.aes-sha2",
|
||||||
|
"io.element.msc3414.encrypt_state_events": encryptStateEvents,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import { type MockedObject } from "jest-mock";
|
import { type MockedObject } from "jest-mock";
|
||||||
|
|
||||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||||
import { emitPromise } from "../../test-utils/test-utils";
|
import { emitPromise } from "../../test-utils/test-utils";
|
||||||
import {
|
import {
|
||||||
type IAnnotatedPushRule,
|
type IAnnotatedPushRule,
|
||||||
@@ -335,6 +335,31 @@ describe("MatrixEvent", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("state key packing", () => {
|
||||||
|
it("should pack the state key during encryption", () => {
|
||||||
|
const ev = createStateEvent("$event1:server", "m.room.topic", "", { topic: "" });
|
||||||
|
expect(ev.getStateKey()).toStrictEqual("");
|
||||||
|
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
|
||||||
|
expect(ev.getStateKey()).toStrictEqual("");
|
||||||
|
expect(ev.getWireStateKey()).toStrictEqual("m.room.topic:");
|
||||||
|
|
||||||
|
const keyedEv = createStateEvent("$event2:server", "m.beacon_info", "@alice:server", {});
|
||||||
|
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
|
||||||
|
keyedEv.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
|
||||||
|
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
|
||||||
|
expect(keyedEv.getWireStateKey()).toStrictEqual("m.beacon_info:@alice:server");
|
||||||
|
});
|
||||||
|
|
||||||
|
function createStateEvent(eventId: string, type: string, stateKey: string, content?: IContent): MatrixEvent {
|
||||||
|
return new MatrixEvent({
|
||||||
|
type,
|
||||||
|
state_key: stateKey,
|
||||||
|
content,
|
||||||
|
event_id: eventId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe("applyVisibilityEvent", () => {
|
describe("applyVisibilityEvent", () => {
|
||||||
it("should emit VisibilityChange if a change was made", async () => {
|
it("should emit VisibilityChange if a change was made", async () => {
|
||||||
const ev = new MatrixEvent({
|
const ev = new MatrixEvent({
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe("RoomEncryptor", () => {
|
|||||||
body: text,
|
body: text,
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
}),
|
}),
|
||||||
|
isState: () => false,
|
||||||
makeEncrypted: jest.fn().mockReturnValue(undefined),
|
makeEncrypted: jest.fn().mockReturnValue(undefined),
|
||||||
} as unknown as Mocked<MatrixEvent>;
|
} as unknown as Mocked<MatrixEvent>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -830,6 +830,7 @@ describe("RustCrypto", () => {
|
|||||||
TEST_DEVICE_ID,
|
TEST_DEVICE_ID,
|
||||||
secretStorage,
|
secretStorage,
|
||||||
{} as CryptoCallbacks,
|
{} as CryptoCallbacks,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
async function createSecretStorageKey() {
|
async function createSecretStorageKey() {
|
||||||
|
|||||||
@@ -105,9 +105,10 @@ export interface RoomPinnedEventsEventContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomEncryptionEventContent {
|
export interface RoomEncryptionEventContent {
|
||||||
algorithm: "m.megolm.v1.aes-sha2";
|
"algorithm": "m.megolm.v1.aes-sha2";
|
||||||
rotation_period_ms?: number;
|
"io.element.msc3414.encrypt_state_events"?: boolean;
|
||||||
rotation_period_msgs?: number;
|
"rotation_period_ms"?: number;
|
||||||
|
"rotation_period_msgs"?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomHistoryVisibilityEventContent {
|
export interface RoomHistoryVisibilityEventContent {
|
||||||
|
|||||||
@@ -425,6 +425,11 @@ export interface ICreateClientOpts {
|
|||||||
*/
|
*/
|
||||||
cryptoCallbacks?: CryptoCallbacks;
|
cryptoCallbacks?: CryptoCallbacks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable encrypted state events.
|
||||||
|
*/
|
||||||
|
enableEncryptedStateEvents?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to generate room names for empty rooms and rooms names based on membership.
|
* Method to generate room names for empty rooms and rooms names based on membership.
|
||||||
* Defaults to a built-in English handler with basic pluralisation.
|
* Defaults to a built-in English handler with basic pluralisation.
|
||||||
@@ -1205,6 +1210,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
|
public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
|
||||||
|
|
||||||
private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
|
private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
|
||||||
|
private readonly enableEncryptedStateEvents: boolean;
|
||||||
public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code.
|
public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code.
|
||||||
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
|
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
|
||||||
public groupCallEventHandler?: GroupCallEventHandler;
|
public groupCallEventHandler?: GroupCallEventHandler;
|
||||||
@@ -1363,6 +1369,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
this.legacyCryptoStore = opts.cryptoStore;
|
this.legacyCryptoStore = opts.cryptoStore;
|
||||||
this.verificationMethods = opts.verificationMethods;
|
this.verificationMethods = opts.verificationMethods;
|
||||||
this.cryptoCallbacks = opts.cryptoCallbacks || {};
|
this.cryptoCallbacks = opts.cryptoCallbacks || {};
|
||||||
|
this.enableEncryptedStateEvents = opts.enableEncryptedStateEvents ?? false;
|
||||||
|
|
||||||
this.forceTURN = opts.forceTURN || false;
|
this.forceTURN = opts.forceTURN || false;
|
||||||
this.iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize;
|
this.iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize;
|
||||||
@@ -1979,6 +1986,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
legacyMigrationProgressListener: (progress: number, total: number): void => {
|
legacyMigrationProgressListener: (progress: number, total: number): void => {
|
||||||
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
|
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
enableEncryptedStateEvents: this.enableEncryptedStateEvents,
|
||||||
});
|
});
|
||||||
|
|
||||||
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
|
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
|
||||||
@@ -6034,6 +6043,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
* @returns A decryption promise
|
* @returns A decryption promise
|
||||||
*/
|
*/
|
||||||
public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
|
public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
|
||||||
|
if (event.isState() && !this.enableEncryptedStateEvents) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
if (event.shouldAttemptDecryption() && this.getCrypto()) {
|
if (event.shouldAttemptDecryption() && this.getCrypto()) {
|
||||||
event.attemptDecryption(this.cryptoBackend!, options);
|
event.attemptDecryption(this.cryptoBackend!, options);
|
||||||
}
|
}
|
||||||
@@ -6618,23 +6631,83 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
* @returns Promise which resolves: TODO
|
* @returns Promise which resolves: TODO
|
||||||
* @returns Rejects: with an error response.
|
* @returns Rejects: with an error response.
|
||||||
*/
|
*/
|
||||||
public sendStateEvent<K extends keyof StateEvents>(
|
public async sendStateEvent<K extends keyof StateEvents>(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
eventType: K,
|
eventType: K,
|
||||||
content: StateEvents[K],
|
content: StateEvents[K],
|
||||||
stateKey = "",
|
stateKey = "",
|
||||||
opts: IRequestOpts = {},
|
opts: IRequestOpts = {},
|
||||||
): Promise<ISendEventResponse> {
|
): Promise<ISendEventResponse> {
|
||||||
|
const room = this.getRoom(roomId);
|
||||||
|
const event = new MatrixEvent({
|
||||||
|
room_id: roomId,
|
||||||
|
type: eventType,
|
||||||
|
state_key: stateKey,
|
||||||
|
// Cast safety: StateEvents[K] is a stronger bound than IContent, which has [key: string]: any
|
||||||
|
content: content as IContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.encryptStateEventIfNeeded(event, room ?? undefined);
|
||||||
|
|
||||||
const pathParams = {
|
const pathParams = {
|
||||||
$roomId: roomId,
|
$roomId: roomId,
|
||||||
$eventType: eventType,
|
$eventType: event.getWireType(),
|
||||||
$stateKey: stateKey,
|
$stateKey: event.getWireStateKey(),
|
||||||
};
|
};
|
||||||
let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
|
let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
|
||||||
if (stateKey !== undefined) {
|
if (stateKey !== undefined) {
|
||||||
path = utils.encodeUri(path + "/$stateKey", pathParams);
|
path = utils.encodeUri(path + "/$stateKey", pathParams);
|
||||||
}
|
}
|
||||||
return this.http.authedRequest(Method.Put, path, undefined, content as Body, opts);
|
return this.http.authedRequest(Method.Put, path, undefined, event.getWireContent(), opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async encryptStateEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> {
|
||||||
|
if (!this.enableEncryptedStateEvents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the room is unknown, we cannot encrypt for it
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
if (!this.cryptoBackend && this.usingExternalCrypto) {
|
||||||
|
// The client has opted to allow sending messages to encrypted
|
||||||
|
// rooms even if the room is encrypted, and we haven't set up
|
||||||
|
// crypto. This is useful for users of matrix-org/pantalaimon
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.cryptoBackend) {
|
||||||
|
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check regular encryption conditions.
|
||||||
|
if (!(await this.shouldEncryptEventForRoom(event, room))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the crypto impl thinks we shouldn't encrypt, then we shouldn't.
|
||||||
|
// Safety: we checked the crypto impl exists above.
|
||||||
|
if (!(await this.cryptoBackend!.isStateEncryptionEnabledInRoom(room.roomId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the event is excluded under MSC3414
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"m.room.create",
|
||||||
|
"m.room.member",
|
||||||
|
"m.room.join_rules",
|
||||||
|
"m.room.power_levels",
|
||||||
|
"m.room.third_party_invite",
|
||||||
|
"m.room.history_visibility",
|
||||||
|
"m.room.guest_access",
|
||||||
|
"m.room.encryption",
|
||||||
|
].includes(event.getType())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cryptoBackend.encryptEvent(event, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export interface CryptoApi {
|
|||||||
*/
|
*/
|
||||||
isEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
|
isEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we believe the given room supports encrypted state events.
|
||||||
|
*/
|
||||||
|
isStateEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform any background tasks that can be done before a message is ready to
|
* Perform any background tasks that can be done before a message is ready to
|
||||||
* send, in order to speed up sending of the message.
|
* send, in order to speed up sending of the message.
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export interface IMarkedUnreadEvent {
|
|||||||
export interface IClearEvent {
|
export interface IClearEvent {
|
||||||
room_id?: string;
|
room_id?: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
state_key?: string;
|
||||||
content: Omit<IContent, "membership" | "avatar_url" | "displayname" | "m.relates_to">;
|
content: Omit<IContent, "membership" | "avatar_url" | "displayname" | "m.relates_to">;
|
||||||
unsigned?: IUnsigned;
|
unsigned?: IUnsigned;
|
||||||
}
|
}
|
||||||
@@ -728,11 +729,25 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event state_key if it has one. This will return <code>undefined
|
* Get the event state_key if it has one. If necessary, this will perform
|
||||||
* </code> for message events.
|
* string-unpacking on the state key, as per MSC3414. This will return
|
||||||
|
* <code>undefined</code> for message events.
|
||||||
* @returns The event's `state_key`.
|
* @returns The event's `state_key`.
|
||||||
*/
|
*/
|
||||||
public getStateKey(): string | undefined {
|
public getStateKey(): string | undefined {
|
||||||
|
if (this.clearEvent) {
|
||||||
|
return this.clearEvent.state_key;
|
||||||
|
}
|
||||||
|
return this.event.state_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the raw event state_key if it has one. This may be string-packed as per
|
||||||
|
* MSC3414 if the state event is encrypted. This will return <code>undefined
|
||||||
|
* </code> for message events.
|
||||||
|
* @returns The event's `state_key`.
|
||||||
|
*/
|
||||||
|
public getWireStateKey(): string | undefined {
|
||||||
return this.event.state_key;
|
return this.event.state_key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,11 +800,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
|||||||
this.clearEvent = {
|
this.clearEvent = {
|
||||||
type: this.event.type!,
|
type: this.event.type!,
|
||||||
content: this.event.content!,
|
content: this.event.content!,
|
||||||
|
state_key: this.event.state_key,
|
||||||
};
|
};
|
||||||
this.event.type = cryptoType;
|
this.event.type = cryptoType;
|
||||||
this.event.content = cryptoContent;
|
this.event.content = cryptoContent;
|
||||||
this.senderCurve25519Key = senderCurve25519Key;
|
this.senderCurve25519Key = senderCurve25519Key;
|
||||||
this.claimedEd25519Key = claimedEd25519Key;
|
this.claimedEd25519Key = claimedEd25519Key;
|
||||||
|
|
||||||
|
// if this is a state event, pack cleartext type and statekey
|
||||||
|
if (this.isState()) {
|
||||||
|
this.event.state_key = `${this.clearEvent!.type}:${this.clearEvent!.state_key}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1025,7 +1046,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
|||||||
* @returns True if this event is encrypted.
|
* @returns True if this event is encrypted.
|
||||||
*/
|
*/
|
||||||
public isEncrypted(): boolean {
|
public isEncrypted(): boolean {
|
||||||
return !this.isState() && this.event.type === EventType.RoomMessageEncrypted;
|
return this.event.type === EventType.RoomMessageEncrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||||
|
|
||||||
import { EventType } from "../@types/event.ts";
|
import { EventType } from "../@types/event.ts";
|
||||||
import { type IContent, type MatrixEvent } from "../models/event.ts";
|
import { type MatrixEvent, type IContent } from "../models/event.ts";
|
||||||
import { type Room } from "../models/room.ts";
|
import { type Room } from "../models/room.ts";
|
||||||
import { type Logger, LogSpan } from "../logger.ts";
|
import { type Logger, LogSpan } from "../logger.ts";
|
||||||
import { type KeyClaimManager } from "./KeyClaimManager.ts";
|
import { type KeyClaimManager } from "./KeyClaimManager.ts";
|
||||||
@@ -314,11 +314,23 @@ export class RoomEncryptor {
|
|||||||
|
|
||||||
private async encryptEventInner(logger: LogSpan, event: MatrixEvent): Promise<void> {
|
private async encryptEventInner(logger: LogSpan, event: MatrixEvent): Promise<void> {
|
||||||
logger.debug("Encrypting actual message content");
|
logger.debug("Encrypting actual message content");
|
||||||
const encryptedContent = await this.olmMachine.encryptRoomEvent(
|
|
||||||
new RoomId(this.room.roomId),
|
const room = new RoomId(this.room.roomId);
|
||||||
event.getType(),
|
const type = event.getType();
|
||||||
JSON.stringify(event.getContent()),
|
const content = JSON.stringify(event.getContent());
|
||||||
);
|
|
||||||
|
let encryptedContent;
|
||||||
|
if (event.isState()) {
|
||||||
|
encryptedContent = await this.olmMachine.encryptStateEvent(
|
||||||
|
room,
|
||||||
|
type,
|
||||||
|
// Safety: we've already checked above that this is a state event, so the state key must exist.
|
||||||
|
event.getStateKey()!,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
encryptedContent = await this.olmMachine.encryptRoomEvent(room, type, content);
|
||||||
|
}
|
||||||
|
|
||||||
event.makeEncrypted(
|
event.makeEncrypted(
|
||||||
EventType.RoomMessageEncrypted,
|
EventType.RoomMessageEncrypted,
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ export async function initRustCrypto(args: {
|
|||||||
* Called with (-1, -1) to mark the end of migration.
|
* Called with (-1, -1) to mark the end of migration.
|
||||||
*/
|
*/
|
||||||
legacyMigrationProgressListener?: (progress: number, total: number) => void;
|
legacyMigrationProgressListener?: (progress: number, total: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to enable support for encrypting state events.
|
||||||
|
*/
|
||||||
|
enableEncryptedStateEvents?: boolean;
|
||||||
}): Promise<RustCrypto> {
|
}): Promise<RustCrypto> {
|
||||||
const { logger } = args;
|
const { logger } = args;
|
||||||
|
|
||||||
@@ -128,6 +133,7 @@ export async function initRustCrypto(args: {
|
|||||||
args.cryptoCallbacks,
|
args.cryptoCallbacks,
|
||||||
storeHandle,
|
storeHandle,
|
||||||
args.legacyCryptoStore,
|
args.legacyCryptoStore,
|
||||||
|
args.enableEncryptedStateEvents,
|
||||||
);
|
);
|
||||||
|
|
||||||
storeHandle.free();
|
storeHandle.free();
|
||||||
@@ -145,6 +151,7 @@ async function initOlmMachine(
|
|||||||
cryptoCallbacks: CryptoCallbacks,
|
cryptoCallbacks: CryptoCallbacks,
|
||||||
storeHandle: StoreHandle,
|
storeHandle: StoreHandle,
|
||||||
legacyCryptoStore?: CryptoStore,
|
legacyCryptoStore?: CryptoStore,
|
||||||
|
enableEncryptedStateEvents?: boolean,
|
||||||
): Promise<RustCrypto> {
|
): Promise<RustCrypto> {
|
||||||
logger.debug("Init OlmMachine");
|
logger.debug("Init OlmMachine");
|
||||||
|
|
||||||
@@ -167,7 +174,16 @@ async function initOlmMachine(
|
|||||||
// Disable room key requests, per https://github.com/vector-im/element-web/issues/26524.
|
// Disable room key requests, per https://github.com/vector-im/element-web/issues/26524.
|
||||||
olmMachine.roomKeyRequestsEnabled = false;
|
olmMachine.roomKeyRequestsEnabled = false;
|
||||||
|
|
||||||
const rustCrypto = new RustCrypto(logger, olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
|
const rustCrypto = new RustCrypto(
|
||||||
|
logger,
|
||||||
|
olmMachine,
|
||||||
|
http,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
secretStorage,
|
||||||
|
cryptoCallbacks,
|
||||||
|
enableEncryptedStateEvents,
|
||||||
|
);
|
||||||
|
|
||||||
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
||||||
rustCrypto.onRoomKeysUpdated(sessions),
|
rustCrypto.onRoomKeysUpdated(sessions),
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
|||||||
|
|
||||||
/** Crypto callbacks provided by the application */
|
/** Crypto callbacks provided by the application */
|
||||||
private readonly cryptoCallbacks: CryptoCallbacks,
|
private readonly cryptoCallbacks: CryptoCallbacks,
|
||||||
|
|
||||||
|
/** Enable support for encrypted state events under MSC3414. */
|
||||||
|
private readonly enableEncryptedStateEvents: boolean = false,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, http);
|
this.outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, http);
|
||||||
@@ -421,6 +424,16 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
|||||||
return Boolean(roomSettings?.algorithm);
|
return Boolean(roomSettings?.algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of {@link CryptoApi#isStateEncryptionEnabledInRoom}.
|
||||||
|
*/
|
||||||
|
public async isStateEncryptionEnabledInRoom(roomId: string): Promise<boolean> {
|
||||||
|
const roomSettings: RustSdkCryptoJs.RoomSettings | undefined = await this.olmMachine.getRoomSettings(
|
||||||
|
new RustSdkCryptoJs.RoomId(roomId),
|
||||||
|
);
|
||||||
|
return Boolean(roomSettings?.encryptStateEvents);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
|
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
|
||||||
*/
|
*/
|
||||||
@@ -1717,7 +1730,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
|||||||
await this.receiveSyncChanges({ devices });
|
await this.receiveSyncChanges({ devices });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** called by the sync loop on m.room.encrypted events
|
/** called by the sync loop on m.room.encryption events
|
||||||
*
|
*
|
||||||
* @param room - in which the event was received
|
* @param room - in which the event was received
|
||||||
* @param event - encryption event to be processed
|
* @param event - encryption event to be processed
|
||||||
@@ -1734,6 +1747,11 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config["io.element.msc3414.encrypt_state_events"] && this.enableEncryptedStateEvents) {
|
||||||
|
this.logger.info("crypto Enabling state event encryption...");
|
||||||
|
settings.encryptStateEvents = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
settings.sessionRotationPeriodMs = config.rotation_period_ms;
|
settings.sessionRotationPeriodMs = config.rotation_period_ms;
|
||||||
settings.sessionRotationPeriodMessages = config.rotation_period_msgs;
|
settings.sessionRotationPeriodMessages = config.rotation_period_msgs;
|
||||||
|
|||||||
@@ -1366,6 +1366,13 @@ export class SyncApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proactively decrypt state events: normally we decrypt on demand, but for state
|
||||||
|
// events we need them immediately, so we handle them here. Specifically, consumers
|
||||||
|
// (e.g. Element Web) expect state events to be unencrypted upon receipt.
|
||||||
|
for (const ev of timelineEvents.filter((ev) => ev.isState())) {
|
||||||
|
await this.client.decryptEventIfNeeded(ev);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ("org.matrix.msc4222.state_after" in joinObj) {
|
if ("org.matrix.msc4222.state_after" in joinObj) {
|
||||||
await this.injectRoomEvents(
|
await this.injectRoomEvents(
|
||||||
|
|||||||
@@ -1763,7 +1763,7 @@
|
|||||||
"@jridgewell/resolve-uri" "^3.1.0"
|
"@jridgewell/resolve-uri" "^3.1.0"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||||
|
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm@^15.2.0":
|
"@matrix-org/matrix-sdk-crypto-wasm@^15.3.0":
|
||||||
version "15.3.0"
|
version "15.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz#141fd041ae382b793369bcee4394b0b577bdea0c"
|
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz#141fd041ae382b793369bcee4394b0b577bdea0c"
|
||||||
integrity sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==
|
integrity sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==
|
||||||
@@ -2314,12 +2314,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.21.0.tgz#58f30aec8db8212fd886835dc5969cdf47cb29f5"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.21.0.tgz#58f30aec8db8212fd886835dc5969cdf47cb29f5"
|
||||||
integrity sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==
|
integrity sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==
|
||||||
|
|
||||||
"@typescript-eslint/types@8.44.0", "@typescript-eslint/types@^8.44.0":
|
"@typescript-eslint/types@8.44.0", "@typescript-eslint/types@^8.41.0", "@typescript-eslint/types@^8.44.0":
|
||||||
version "8.44.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.0.tgz#4b9154ab164a0beff22d3217ff0fdc8d10bce924"
|
|
||||||
integrity sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==
|
|
||||||
|
|
||||||
"@typescript-eslint/types@^8.41.0":
|
|
||||||
version "8.44.0"
|
version "8.44.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.0.tgz#4b9154ab164a0beff22d3217ff0fdc8d10bce924"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.0.tgz#4b9154ab164a0beff22d3217ff0fdc8d10bce924"
|
||||||
integrity sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==
|
integrity sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==
|
||||||
|
|||||||
Reference in New Issue
Block a user