1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-05 00:22:28 +03:00
Files
matrix-js-sdk/spec/unit/crypto.spec.ts
Andy Balaam 92342c07ed Introduce Membership TS type (take 2) (#4107)
* Introduce Membership TS type

* Adapt the Membership TS type to be an enum

* Add docstrings for KnownMembership and Membership

* Move Membership types into a separate file, exported from types.ts

---------

Co-authored-by: Stanislav Demydiuk <s.demydiuk@gmail.com>
2024-03-18 12:47:23 +00:00

1356 lines
58 KiB
TypeScript

import "../olm-loader";
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
import type { PkDecryption, PkSigning } from "@matrix-org/olm";
import { IClaimOTKsResult, MatrixClient } from "../../src/client";
import { Crypto } from "../../src/crypto";
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../MockStorageApi";
import { TestClient } from "../TestClient";
import { MatrixEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
import * as olmlib from "../../src/crypto/olmlib";
import { sleep } from "../../src/utils";
import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from "../../src/logger";
import { DeviceVerification, MemoryStore } from "../../src";
import { RoomKeyRequestState } from "../../src/crypto/OutgoingRoomKeyRequestManager";
import { RoomMember } from "../../src/models/room-member";
import { IStore } from "../../src/store";
import { IRoomEncryption, RoomList } from "../../src/crypto/RoomList";
import { EventShieldColour, EventShieldReason } from "../../src/crypto-api";
import { UserTrustLevel } from "../../src/crypto/CrossSigning";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { EventDecryptionResult } from "../../src/common-crypto/CryptoBackend";
import * as testData from "../test-utils/test-data";
import { KnownMembership } from "../../src/@types/membership";
const Olm = global.Olm;
function awaitEvent(emitter: EventEmitter, event: string): Promise<void> {
return new Promise((resolve) => {
emitter.once(event, (result) => {
resolve(result);
});
});
}
async function keyshareEventForEvent(client: MatrixClient, event: MatrixEvent, index?: number): Promise<MatrixEvent> {
const roomId = event.getRoomId()!;
const eventContent = event.getWireContent();
const key = await client.crypto!.olmDevice.getInboundGroupSessionKey(
roomId,
eventContent.sender_key,
eventContent.session_id,
index,
);
const ksEvent = new MatrixEvent({
type: "m.forwarded_room_key",
sender: client.getUserId()!,
content: {
"algorithm": olmlib.MEGOLM_ALGORITHM,
"room_id": roomId,
"sender_key": eventContent.sender_key,
"sender_claimed_ed25519_key": key?.sender_claimed_ed25519_key,
"session_id": eventContent.session_id,
"session_key": key?.key,
"chain_index": key?.chain_index,
"forwarding_curve25519_key_chain": key?.forwarding_curve25519_key_chain,
"org.matrix.msc3061.shared_history": true,
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = "akey";
ksEvent.getWireType = () => "m.room.encrypted";
ksEvent.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
return ksEvent;
}
function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixEvent {
const roomId = event.getRoomId();
const eventContent = event.getWireContent();
const key = client.crypto!.olmDevice.getOutboundGroupSessionKey(eventContent.session_id);
const ksEvent = new MatrixEvent({
type: "m.room_key",
sender: client.getUserId()!,
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
session_id: eventContent.session_id,
session_key: key.key,
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = event.getSenderKey();
ksEvent.getWireType = () => "m.room.encrypted";
ksEvent.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
return ksEvent;
}
describe("Crypto", function () {
if (!CRYPTO_ENABLED) {
return;
}
beforeAll(function () {
return Olm.init();
});
afterEach(() => {
jest.useRealTimers();
});
it("Crypto exposes the correct olm library version", function () {
expect(Crypto.getOlmVersion()[0]).toEqual(3);
});
it("getVersion() should return the current version of the olm library", async () => {
const client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
const olmVersionTuple = Crypto.getOlmVersion();
expect(client.getCrypto()?.getVersion()).toBe(
`Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`,
);
});
describe("encrypted events", function () {
it("provides encryption information for events from unverified senders", async function () {
const client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
// unencrypted event
const event = {
getId: () => "$event_id",
getSender: () => "@bob:example.com",
getSenderKey: () => null,
getWireContent: () => {
return {};
},
} as unknown as MatrixEvent;
let encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeFalsy();
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toBe(null);
// unknown sender (e.g. deleted device), forwarded megolm key (untrusted)
event.getSenderKey = () => "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI";
event.getWireContent = () => {
return { algorithm: olmlib.MEGOLM_ALGORITHM };
};
event.getForwardingCurve25519KeyChain = () => ["not empty"];
event.isKeySourceUntrusted = () => true;
event.getClaimedEd25519Key = () => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeFalsy();
expect(encryptionInfo.sender).toBeFalsy();
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
});
// known sender, megolm key from backup
event.getForwardingCurve25519KeyChain = () => [];
event.isKeySourceUntrusted = () => true;
const device = new DeviceInfo("FLIBBLE");
device.keys["curve25519:FLIBBLE"] = "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI";
device.keys["ed25519:FLIBBLE"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
client.crypto!.deviceList.getDeviceByIdentityKey = () => device;
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeFalsy();
expect(encryptionInfo.sender).toBeTruthy();
expect(encryptionInfo.mismatchedSender).toBeFalsy();
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
});
// known sender, trusted megolm key, but bad ed25519key
event.isKeySourceUntrusted = () => false;
device.keys["ed25519:FLIBBLE"] = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeTruthy();
expect(encryptionInfo.sender).toBeTruthy();
expect(encryptionInfo.mismatchedSender).toBeTruthy();
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
shieldColour: EventShieldColour.RED,
shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY,
});
client.stopClient();
});
describe("provides encryption information for events from verified senders", function () {
const testDeviceId = testData.BOB_TEST_DEVICE_ID;
const testDevice = testData.BOB_SIGNED_TEST_DEVICE_DATA;
let client: MatrixClient;
beforeEach(async () => {
client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
// mock out the verification check
client.crypto!.checkUserTrust = (userId) => new UserTrustLevel(true, false, false);
});
afterEach(() => {
client.stopClient();
});
async function buildEncryptedEvent(
decryptionResult: Partial<EventDecryptionResult> = {},
): Promise<MatrixEvent> {
const mockCryptoBackend = {
decryptEvent: async (event: MatrixEvent): Promise<EventDecryptionResult> => {
return {
claimedEd25519Key: testDevice.keys["ed25519:" + testDeviceId],
clearEvent: {
room_id: "!room_id",
type: "m.room.message",
content: { body: "test" },
},
forwardingCurve25519KeyChain: [],
senderCurve25519Key: testDevice.keys["curve25519:" + testDeviceId],
...decryptionResult,
};
},
} as unknown as CryptoBackend;
const event = new MatrixEvent({
event_id: "$event_id",
sender: testData.BOB_TEST_USER_ID,
type: "m.room.encrypted",
content: { algorithm: "m.megolm.v1.aes-sha2" },
});
await event.attemptDecryption(mockCryptoBackend);
return event;
}
it("unknown device", async () => {
const event = await buildEncryptedEvent();
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
});
});
it("known but unsigned device", async () => {
client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, {
[testDeviceId]: {
keys: testDevice.keys,
algorithms: testDevice.algorithms,
verified: DeviceVerification.Unverified,
known: true,
},
});
const event = await buildEncryptedEvent();
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
shieldColour: EventShieldColour.RED,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
});
});
describe("known and verified device", () => {
beforeEach(() => {
client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, {
[testDeviceId]: {
keys: testDevice.keys,
algorithms: testDevice.algorithms,
verified: DeviceVerification.Verified,
known: true,
},
});
});
it("regular key", async () => {
const event = await buildEncryptedEvent();
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
shieldColour: EventShieldColour.NONE,
shieldReason: null,
});
});
it("unauthenticated key", async () => {
const event = await buildEncryptedEvent({ untrusted: true });
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
});
});
});
});
it("doesn't throw an error when attempting to decrypt a redacted event", async () => {
const client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
const event = new MatrixEvent({
content: {},
event_id: "$event_id",
room_id: "!room_id",
sender: "@bob:example.com",
type: "m.room.encrypted",
unsigned: {
redacted_because: {
content: {},
event_id: "$redaction_event_id",
redacts: "$event_id",
room_id: "!room_id",
origin_server_ts: 1234567890,
sender: "@bob:example.com",
type: "m.room.redaction",
unsigned: {},
},
},
});
await event.attemptDecryption(client.crypto!);
expect(event.isDecryptionFailure()).toBeFalsy();
// since the redaction event isn't encrypted, the redacted_because
// should be the same as in the original event
expect(event.getRedactionEvent()).toEqual(event.getUnsigned().redacted_because);
client.stopClient();
});
});
describe("Session management", function () {
const otkResponse: IClaimOTKsResult = {
failures: {},
one_time_keys: {
"@alice:home.server": {
aliceDevice: {
"signed_curve25519:FLIBBLE": {
key: "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI",
signatures: {
"@alice:home.server": {
"ed25519:aliceDevice": "totally a valid signature",
},
},
},
},
},
},
};
let crypto: Crypto;
let mockBaseApis: MatrixClient;
let fakeEmitter: EventEmitter;
beforeEach(async function () {
const mockStorage = new MockStorageApi() as unknown as Storage;
const clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore;
const cryptoStore = new MemoryCryptoStore();
cryptoStore.storeEndToEndDeviceData(
{
devices: {
"@bob:home.server": {
BOBDEVICE: {
algorithms: [],
verified: 1,
known: false,
keys: {
"curve25519:BOBDEVICE": "this is a key",
},
},
},
},
trackingStatus: {},
},
{},
);
mockBaseApis = {
sendToDevice: jest.fn(),
getKeyBackupVersion: jest.fn(),
isGuest: jest.fn(),
emit: jest.fn(),
} as unknown as MatrixClient;
fakeEmitter = new EventEmitter();
crypto = new Crypto(mockBaseApis, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
crypto.registerEventHandlers(fakeEmitter as any);
await crypto.init();
});
afterEach(async function () {
await crypto.stop();
});
it("restarts wedged Olm sessions", async function () {
const prom = new Promise<void>((resolve) => {
mockBaseApis.claimOneTimeKeys = function () {
resolve();
return Promise.resolve(otkResponse);
};
});
fakeEmitter.emit("toDeviceEvent", {
getId: jest.fn().mockReturnValue("$wedged"),
getType: jest.fn().mockReturnValue("m.room.message"),
getContent: jest.fn().mockReturnValue({
msgtype: "m.bad.encrypted",
}),
getWireContent: jest.fn().mockReturnValue({
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: "this is a key",
}),
getSender: jest.fn().mockReturnValue("@bob:home.server"),
});
await prom;
});
});
describe("Key requests", function () {
let aliceClient: MatrixClient;
let secondAliceClient: MatrixClient;
let bobClient: MatrixClient;
let claraClient: MatrixClient;
beforeEach(async function () {
aliceClient = new TestClient("@alice:example.com", "alicedevice").client;
secondAliceClient = new TestClient("@alice:example.com", "secondAliceDevice").client;
bobClient = new TestClient("@bob:example.com", "bobdevice").client;
claraClient = new TestClient("@clara:example.com", "claradevice").client;
await aliceClient.initCrypto();
await secondAliceClient.initCrypto();
await bobClient.initCrypto();
await claraClient.initCrypto();
});
afterEach(async function () {
aliceClient.stopClient();
secondAliceClient.stopClient();
bobClient.stopClient();
claraClient.stopClient();
});
it("does not cancel keyshare requests until all messages are decrypted with trusted keys", async function () {
const encryptionCfg = {
algorithm: "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
// Make Bob invited by Alice so Bob will accept Alice's forwarded keys
bobRoom.currentState.setStateEvents([
new MatrixEvent({
type: "m.room.member",
sender: "@alice:example.com",
room_id: roomId,
content: { membership: KnownMembership.Invite },
state_key: "@bob:example.com",
}),
]);
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(
events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto!.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
const device = new DeviceInfo(aliceClient.deviceId!);
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
const decryptEventsPromise = Promise.all(
events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}),
);
// keyshare the session key starting at the second message, so
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventsPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient.crypto!.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
// the room key request should still be there, since we haven't
// decrypted everything
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined();
// keyshare the session key starting at the first message, so
// that it can now be decrypted
const decryptEventPromise = awaitEvent(events[0], "Event.decrypted");
ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[0].isKeySourceUntrusted()).toBeTruthy();
await sleep(1);
// the room key request should still be there, since we've
// decrypted everything with an untrusted key
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined();
// Now share a trusted room key event so Bob will re-decrypt the messages.
// Bob will backfill trust when they receive a trusted session with a higher
// index that connects to an untrusted session with a lower index.
const roomKeyEvent = roomKeyEventForEvent(aliceClient, events[1]);
const trustedDecryptEventPromise = awaitEvent(events[0], "Event.decrypted");
await bobDecryptor.onRoomKeyEvent(roomKeyEvent);
await trustedDecryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[0].isKeySourceUntrusted()).toBeFalsy();
await sleep(1);
// now the room key request should be gone, since there's
// no better key to wait for
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy();
});
it("should error if a forwarded room key lacks a content.sender_key", async function () {
const encryptionCfg = {
algorithm: "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const event = new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
});
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto!.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private property
event.clearEvent = undefined;
// @ts-ignore private property
event.senderCurve25519Key = null;
// @ts-ignore private property
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
const device = new DeviceInfo(aliceClient.deviceId!);
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
const ksEvent = await keyshareEventForEvent(aliceClient, event, 1);
ksEvent.getContent().sender_key = undefined; // test
bobClient.crypto!.olmDevice.addInboundGroupSession = jest.fn();
await bobDecryptor.onRoomKeyEvent(ksEvent);
expect(bobClient.crypto!.olmDevice.addInboundGroupSession).not.toHaveBeenCalled();
});
it("creates a new keyshare request if we request a keyshare", async function () {
// make sure that cancelAndResend... creates a new keyshare request
// if there wasn't an already-existing one
const event = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
session_id: "sessionid",
sender_key: "senderkey",
},
});
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
const cryptoStore = aliceClient.crypto!.cryptoStore;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: "!someroom",
session_id: "sessionid",
sender_key: "senderkey",
};
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined();
});
it("uses a new txnid for re-requesting keys", async function () {
jest.useFakeTimers();
const event = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
session_id: "sessionid",
sender_key: "senderkey",
},
});
// replace Alice's sendToDevice function with a mock
const aliceSendToDevice = jest.fn().mockResolvedValue(undefined);
aliceClient.sendToDevice = aliceSendToDevice;
aliceClient.startClient();
// make a room key request, and record the transaction ID for the
// sendToDevice call
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
// 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(aliceSendToDevice).toHaveBeenCalledTimes(1);
const txnId = aliceSendToDevice.mock.calls[0][2];
// give the room key request manager time to update the state
// of the request
await Promise.resolve();
// cancel and resend the room key request
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
jest.runAllTimers();
await Promise.resolve();
// 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(aliceSendToDevice).toHaveBeenCalledTimes(3);
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
it("should accept forwarded keys it requested from one of its own user's other devices", async function () {
const encryptionCfg = {
algorithm: "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, secondAliceClient, "@alice:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
secondAliceClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await secondAliceClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(
events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto!.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
const device = new DeviceInfo(aliceClient.deviceId!);
device.verified = DeviceInfo.DeviceVerification.VERIFIED;
secondAliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
secondAliceClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const cryptoStore = secondAliceClient.crypto!.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody);
expect(outgoingReq).toBeDefined();
await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, {
state: RoomKeyRequestState.Sent,
});
const bobDecryptor = secondAliceClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
const decryptEventsPromise = Promise.all(
events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}),
);
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await secondAliceClient.crypto!.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should accept forwarded keys from the user who invited it to the room", async function () {
const encryptionCfg = {
algorithm: "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {});
// Make Bob invited by Clara
bobRoom.currentState.setStateEvents([
new MatrixEvent({
type: "m.room.member",
sender: "@clara:example.com",
room_id: roomId,
content: { membership: KnownMembership.Invite },
state_key: "@bob:example.com",
}),
]);
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
claraClient.store.storeRoom(claraRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
await claraClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(
events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto!.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
const device = new DeviceInfo(claraClient.deviceId!);
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@clara:example.com";
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
const decryptEventsPromise = Promise.all(
events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}),
);
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = claraClient.getUserId()!;
ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()!);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should not accept requested forwarded keys from other users", async function () {
const encryptionCfg = {
algorithm: "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(
events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto!.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
const cryptoStore = bobClient.crypto!.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody);
expect(outgoingReq).toBeDefined();
await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, {
state: RoomKeyRequestState.Sent,
});
const device = new DeviceInfo(aliceClient.deviceId!);
device.verified = DeviceInfo.DeviceVerification.VERIFIED;
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = aliceClient.getUserId()!;
ksEvent.sender = new RoomMember(roomId, aliceClient.getUserId()!);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).toBeNull();
});
it("should not accept unexpected forwarded keys for a room it's in", async function () {
const encryptionCfg = {
algorithm: "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
claraClient.store.storeRoom(claraRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
await claraClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(
events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto!.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
const device = new DeviceInfo(claraClient.deviceId!);
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = claraClient.getUserId()!;
ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()!);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).toBeNull();
});
it("should park forwarded keys for a room it's not in", async function () {
const encryptionCfg = {
algorithm: "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(
events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto!.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
}),
);
const device = new DeviceInfo(aliceClient.deviceId!);
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
const content = events[0].getWireContent();
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const bobKey = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey(
roomId,
content.sender_key,
content.session_id,
);
expect(bobKey).toBeNull();
const aliceKey = await aliceClient.crypto!.olmDevice.getInboundGroupSessionKey(
roomId,
content.sender_key,
content.session_id,
);
const parked = await bobClient.crypto!.cryptoStore.takeParkedSharedHistory(roomId);
expect(parked).toEqual([
{
senderId: aliceClient.getUserId(),
senderKey: content.sender_key,
sessionId: content.session_id,
sessionKey: aliceKey!.key,
keysClaimed: { ed25519: aliceKey!.sender_claimed_ed25519_key },
forwardingCurve25519KeyChain: ["akey"],
},
]);
});
});
describe("Secret storage", function () {
it("creates secret storage even if there is no keyInfo", async function () {
jest.spyOn(logger, "debug").mockImplementation(() => {});
jest.setTimeout(10000);
const client = new TestClient("@a:example.com", "dev").client;
await client.initCrypto();
client.crypto!.isCrossSigningReady = async () => false;
client.crypto!.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
client.crypto!.baseApis.setAccountData = jest.fn().mockResolvedValue(null);
client.crypto!.baseApis.uploadKeySignatures = jest.fn();
client.crypto!.baseApis.http.authedRequest = jest.fn();
const createSecretStorageKey = async () => {
return {
keyInfo: undefined, // Returning undefined here used to cause a crash
privateKey: Uint8Array.of(32, 33),
};
};
await client.crypto!.bootstrapSecretStorage({
createSecretStorageKey,
});
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(new Map());
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => {
result.plaintext = { type: 0, body: JSON.stringify(payload) };
});
client = new TestClient("@alice:example.org", "aliceweb");
// running initCrypto should trigger a key upload
client.httpBackend.when("POST", "/keys/upload").respond(200, {});
await Promise.all([client.client.initCrypto(), client.httpBackend.flush("/keys/upload", 1)]);
encryptedPayload = {
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: client.client.crypto!.olmDevice.deviceCurve25519Key,
ciphertext: { plaintext: { type: 0, body: 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")
.check((request) => {
const data = request.data;
delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"];
delete data.messages["@bob:example.org"]["bobmobile"]["org.matrix.msgid"];
delete data.messages["@carol:example.org"]["caroldesktop"]["org.matrix.msgid"];
expect(data).toStrictEqual({
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 = { type: 0, body: JSON.stringify(payload) };
});
client.httpBackend
.when("PUT", "/sendToDevice/m.room.encrypted")
.check((req) => {
const data = req.data;
delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"];
// Carol is nowhere to be seen
expect(data).toStrictEqual({
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();
});
});
describe("checkSecretStoragePrivateKey", () => {
let client: TestClient;
beforeEach(async () => {
client = new TestClient("@alice:example.org", "aliceweb");
await client.client.initCrypto();
});
afterEach(async () => {
await client.stop();
});
it("should free PkDecryption", () => {
const free = jest.fn();
jest.spyOn(Olm, "PkDecryption").mockImplementation(
() =>
({
init_with_private_key: jest.fn(),
free,
}) as unknown as PkDecryption,
);
client.client.checkSecretStoragePrivateKey(new Uint8Array(), "");
expect(free).toHaveBeenCalled();
});
});
describe("checkCrossSigningPrivateKey", () => {
let client: TestClient;
beforeEach(async () => {
client = new TestClient("@alice:example.org", "aliceweb");
await client.client.initCrypto();
});
afterEach(async () => {
await client.stop();
});
it("should free PkSigning", () => {
const free = jest.fn();
jest.spyOn(Olm, "PkSigning").mockImplementation(
() =>
({
init_with_seed: jest.fn(),
free,
}) as unknown as PkSigning,
);
client.client.checkCrossSigningPrivateKey(new Uint8Array(), "");
expect(free).toHaveBeenCalled();
});
});
describe("start", () => {
let client: TestClient;
beforeEach(async () => {
client = new TestClient("@alice:example.org", "aliceweb");
await client.client.initCrypto();
});
afterEach(async function () {
await client!.stop();
});
// start() is a no-op nowadays, so there's not much to test here.
it("should complete successfully", async () => {
await client!.client.crypto!.start();
});
});
describe("setRoomEncryption", () => {
let mockClient: MatrixClient;
let mockRoomList: RoomList;
let clientStore: IStore;
let crypto: Crypto;
beforeEach(async function () {
mockClient = {} as MatrixClient;
const mockStorage = new MockStorageApi() as unknown as Storage;
clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore;
const cryptoStore = new MemoryCryptoStore();
mockRoomList = {
getRoomEncryption: jest.fn().mockReturnValue(null),
setRoomEncryption: jest.fn().mockResolvedValue(undefined),
} as unknown as RoomList;
crypto = new Crypto(mockClient, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
// @ts-ignore we are injecting a mock into a private property
crypto.roomList = mockRoomList;
});
it("should set the algorithm if called for a known room", async () => {
const room = new Room("!room:id", mockClient, "@my.user:id");
await clientStore.storeRoom(room);
await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption);
expect(mockRoomList!.setRoomEncryption).toHaveBeenCalledTimes(1);
expect(jest.mocked(mockRoomList!.setRoomEncryption).mock.calls[0][0]).toEqual("!room:id");
});
it("should raise if called for an unknown room", async () => {
await expect(async () => {
await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption);
}).rejects.toThrow(/unknown room/);
expect(mockRoomList!.setRoomEncryption).not.toHaveBeenCalled();
});
});
});