diff --git a/examples/encryption_appservice.ts b/examples/encryption_appservice.ts index 50571cc1..c1356e1b 100644 --- a/examples/encryption_appservice.ts +++ b/examples/encryption_appservice.ts @@ -36,7 +36,7 @@ const worksImage = fs.readFileSync("./examples/static/it-works.png"); const registration: IAppserviceRegistration = { "as_token": creds?.['asToken'] ?? "change_me", "hs_token": creds?.['hsToken'] ?? "change_me", - "sender_localpart": "crypto_test_appservice_rust3", + "sender_localpart": "crypto_main_bot_user", "namespaces": { users: [{ regex: "@crypto.*:localhost", @@ -65,8 +65,8 @@ const options: IAppserviceOptions = { }; const appservice = new Appservice(options); -// const bot = appservice.botIntent; -const bot = appservice.getIntentForUserId("@crypto_nondefault_test3:localhost"); +const bot = appservice.botIntent; +// const bot = appservice.getIntentForUserId("@crypto_bot1:localhost"); (async function() { await bot.enableEncryption(); diff --git a/package.json b/package.json index abafc775..0919db1d 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,10 @@ "tsconfig.json" ], "dependencies": { - "@turt2live/matrix-sdk-crypto-nodejs": "^0.1.0-beta.10", + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.1.0-beta.1", "@types/express": "^4.17.13", "another-json": "^0.2.0", + "async-lock": "^1.3.2", "chalk": "^4", "express": "^4.18.1", "glob-to-regexp": "^0.4.1", diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index b078c44c..2053f0e2 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -26,9 +26,6 @@ import { Space, SpaceCreateOptions } from "./models/Spaces"; import { PowerLevelAction } from "./models/PowerLevelAction"; import { CryptoClient } from "./e2ee/CryptoClient"; import { - DeviceKeyAlgorithm, - DeviceKeyLabel, - EncryptionAlgorithm, FallbackKey, IToDeviceMessage, MultiUserDeviceListResponse, @@ -133,6 +130,14 @@ export class MatrixClient extends EventEmitter { throw new Error("Cannot support custom encryption stores: Use a RustSdkCryptoStorageProvider"); } this.crypto = new CryptoClient(this); + this.on("room.event", (roomId, event) => { + // noinspection JSIgnoredPromiseFromCall + this.crypto.onRoomEvent(roomId, event); + }); + this.on("room.join", (roomId) => { + // noinspection JSIgnoredPromiseFromCall + this.crypto.onRoomJoin(roomId); + }); LogService.debug("MatrixClientLite", "End-to-end encryption client created"); } else { // LogService.trace("MatrixClientLite", "Not setting up encryption"); @@ -1762,27 +1767,6 @@ export class MatrixClient extends EventEmitter { return new Space(roomId, this); } - /** - * Uploads new identity keys for the current device. - * @param {EncryptionAlgorithm[]} algorithms The supported algorithms. - * @param {Record, string>} keys The keys for the device. - * @returns {Promise} Resolves to the current One Time Key counts when complete. - */ - @timedMatrixClientFunctionCall() - @requiresCrypto() - public async uploadDeviceKeys(algorithms: EncryptionAlgorithm[], keys: Record, string>): Promise { - const obj = { - user_id: await this.getUserId(), - device_id: this.crypto.clientDeviceId, - algorithms: algorithms, - keys: keys, - }; - obj['signatures'] = await this.crypto.sign(obj); - return this.doRequest("POST", "/_matrix/client/v3/keys/upload", null, { - device_keys: obj, - }).then(r => r['one_time_key_counts']); - } - /** * Uploads One Time Keys for the current device. * @param {OTKs} keys The keys to upload. diff --git a/src/appservice/Appservice.ts b/src/appservice/Appservice.ts index 2dae4ad9..7fb65d96 100644 --- a/src/appservice/Appservice.ts +++ b/src/appservice/Appservice.ts @@ -612,11 +612,13 @@ export class Appservice extends EventEmitter { if (!event["content"]) return; // Update the target intent's joined rooms (fixes transition errors with the cache, like join->kick->join) - await this.getIntentForUserId(event['state_key']).refreshJoinedRooms(); + const intent = this.getIntentForUserId(event['state_key']); + await intent.refreshJoinedRooms(); const targetMembership = event["content"]["membership"]; if (targetMembership === "join") { this.emit("room.join", event["room_id"], event); + await intent.underlyingClient.crypto?.onRoomJoin(event["room_id"]); } else if (targetMembership === "ban" || targetMembership === "leave") { this.emit("room.leave", event["room_id"], event); } else if (targetMembership === "invite") { @@ -729,6 +731,9 @@ export class Appservice extends EventEmitter { removed: [], }; + if (!deviceLists.changed) deviceLists.changed = []; + if (!deviceLists.removed) deviceLists.removed = []; + const otks = req.body["org.matrix.msc3202.device_one_time_key_counts"]; if (otks) { for (const userId of Object.keys(otks)) { diff --git a/src/appservice/Intent.ts b/src/appservice/Intent.ts index eae58141..8147b21c 100644 --- a/src/appservice/Intent.ts +++ b/src/appservice/Intent.ts @@ -170,6 +170,12 @@ export class Intent { // Now set up crypto await this.client.crypto.prepare(await this.client.getJoinedRooms()); + + this.appservice.on("room.event", (roomId, event) => { + if (!this.knownJoinedRooms.includes(roomId)) return; + this.client.crypto.onRoomEvent(roomId, event); + }); + resolve(); } catch (e) { reject(e); diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index efc8199a..0adcc4b0 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -1,13 +1,16 @@ import { - decryptFile as rustDecryptFile, - encryptFile as rustEncryptFile, + DeviceId, OlmMachine, -} from "@turt2live/matrix-sdk-crypto-nodejs"; + UserId, + DeviceLists, + RoomId, + Attachment, + EncryptedAttachment, +} from "@matrix-org/matrix-sdk-crypto-nodejs"; import { MatrixClient } from "../MatrixClient"; import { LogService } from "../logging/LogService"; import { - DeviceKeyAlgorithm, IMegolmEncrypted, IOlmEncrypted, IToDeviceMessage, @@ -21,8 +24,8 @@ import { EncryptedRoomEvent } from "../models/events/EncryptedRoomEvent"; import { RoomEvent } from "../models/events/RoomEvent"; import { EncryptedFile } from "../models/events/MessageEvent"; import { RustSdkCryptoStorageProvider } from "../storage/RustSdkCryptoStorageProvider"; -import { SdkOlmEngine } from "./SdkOlmEngine"; -import { InternalOlmMachineFactory } from "./InternalOlmMachineFactory"; +import { RustEngine, SYNC_LOCK_NAME } from "./RustEngine"; +import { MembershipEvent } from "../models/events/MembershipEvent"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly @@ -35,7 +38,7 @@ export class CryptoClient { private deviceEd25519: string; private deviceCurve25519: string; private roomTracker: RoomTracker; - private machine: OlmMachine; + private engine: RustEngine; public constructor(private client: MatrixClient) { this.roomTracker = new RoomTracker(this.client); @@ -83,16 +86,45 @@ export class CryptoClient { LogService.debug("CryptoClient", "Starting with device ID:", this.deviceId); - this.machine = new InternalOlmMachineFactory(await this.client.getUserId(), this.deviceId, new SdkOlmEngine(this.client), this.storage.storagePath).build(); - await this.machine.runEngine(); + const machine = await OlmMachine.initialize(new UserId(await this.client.getUserId()), new DeviceId(this.deviceId), this.storage.storagePath); + this.engine = new RustEngine(machine, this.client); + await this.engine.run(); - const identity = this.machine.identityKeys; - this.deviceCurve25519 = identity[DeviceKeyAlgorithm.Curve25519]; - this.deviceEd25519 = identity[DeviceKeyAlgorithm.Ed25519]; + const identity = this.engine.machine.identityKeys; + this.deviceCurve25519 = identity.curve25519.toBase64(); + this.deviceEd25519 = identity.ed25519.toBase64(); this.ready = true; } + /** + * Handles a room event. + * @internal + * @param roomId The room ID. + * @param event The event. + */ + public async onRoomEvent(roomId: string, event: any) { + await this.roomTracker.onRoomEvent(roomId, event); + if (typeof event['state_key'] !== 'string') return; + if (event['type'] === 'm.room.member') { + const membership = new MembershipEvent(event); + if (membership.effectiveMembership !== 'join' && membership.effectiveMembership !== 'invite') return; + await this.engine.addTrackedUsers([membership.membershipFor]); + } else if (event['type'] === 'm.room.encryption') { + const members = await this.client.getRoomMembers(roomId, null, ['join', 'invite']); + await this.engine.addTrackedUsers(members.map(e => e.membershipFor)); + } + } + + /** + * Handles a room join. + * @internal + * @param roomId The room ID. + */ + public async onRoomJoin(roomId: string) { + await this.roomTracker.onRoomJoin(roomId); + } + /** * Checks if a room is encrypted. * @param {string} roomId The room ID to check. @@ -121,10 +153,22 @@ export class CryptoClient { changedDeviceLists: string[], leftDeviceLists: string[], ): Promise { - await this.machine.pushSync(toDeviceMessages, { - changed: changedDeviceLists, - left: leftDeviceLists, - }, otkCounts, unusedFallbackKeyAlgs); + const deviceMessages = JSON.stringify({ events: toDeviceMessages }); + const deviceLists = new DeviceLists( + changedDeviceLists.map(u => new UserId(u)), + leftDeviceLists.map(u => new UserId(u))); + + await this.engine.lock.acquire(SYNC_LOCK_NAME, async () => { + const syncResp = await this.engine.machine.receiveSyncChanges(deviceMessages, deviceLists, otkCounts, unusedFallbackKeyAlgs); + const decryptedToDeviceMessages = JSON.parse(syncResp); + if (Array.isArray(decryptedToDeviceMessages?.events)) { + for (const msg of decryptedToDeviceMessages.events) { + this.client.emit("to_device.decrypted", msg); + } + } + + await this.engine.run(); + }); } /** @@ -140,7 +184,16 @@ export class CryptoClient { delete obj['signatures']; delete obj['unsigned']; - const sig = await this.machine.sign(obj); + const container = await this.engine.machine.sign(JSON.stringify(obj)); + const userSignature = container.get(new UserId(await this.client.getUserId())); + const sig: Signatures = { + [await this.client.getUserId()]: {}, + }; + for (const [key, maybeSignature] of Object.entries(userSignature)) { + if (maybeSignature.isValid) { + sig[await this.client.getUserId()][key] = maybeSignature.signature.toBase64(); + } + } return { ...sig, ...existingSignatures, @@ -164,7 +217,10 @@ export class CryptoClient { throw new Error("Room is not encrypted"); } - const encrypted = await this.machine.encryptRoomEvent(roomId, eventType, content); + await this.engine.prepareEncrypt(roomId, await this.roomTracker.getRoomCryptoConfig(roomId)); + + const encrypted = JSON.parse(await this.engine.machine.encryptRoomEvent(new RoomId(roomId), eventType, JSON.stringify(content))); + await this.engine.run(); return encrypted as IMegolmEncrypted; } @@ -177,12 +233,13 @@ export class CryptoClient { */ @requiresReady() public async decryptRoomEvent(event: EncryptedRoomEvent, roomId: string): Promise> { - const decrypted = await this.machine.decryptRoomEvent(roomId, event.raw); + const decrypted = await this.engine.machine.decryptRoomEvent(JSON.stringify(event.raw), new RoomId(roomId)); + const clearEvent = JSON.parse(decrypted.event); return new RoomEvent({ ...event.raw, - type: decrypted.clearEvent.type || "io.t2bot.unknown", - content: (typeof (decrypted.clearEvent.content) === 'object') ? decrypted.clearEvent.content : {}, + type: clearEvent.type || "io.t2bot.unknown", + content: (typeof (clearEvent.content) === 'object') ? clearEvent.content : {}, }); } @@ -195,15 +252,11 @@ export class CryptoClient { */ @requiresReady() public async encryptMedia(file: Buffer): Promise<{ buffer: Buffer, file: Omit }> { - const encrypted = rustEncryptFile(file); + const encrypted = Attachment.encrypt(file); + const info = JSON.parse(encrypted.mediaEncryptionInfo); return { - buffer: encrypted.data, - file: { - iv: encrypted.file.iv, - key: encrypted.file.web_key, - v: encrypted.file.v, - hashes: encrypted.file.hashes as { sha256: string }, - }, + buffer: Buffer.from(encrypted.encryptedData), + file: info, }; } @@ -214,9 +267,12 @@ export class CryptoClient { */ @requiresReady() public async decryptMedia(file: EncryptedFile): Promise { - return rustDecryptFile((await this.client.downloadContent(file.url)).data, { - ...file, - web_key: file.key as any, // we know it is compatible - }); + const contents = (await this.client.downloadContent(file.url)).data; + const encrypted = new EncryptedAttachment( + contents, + JSON.stringify(file), + ); + const decrypted = Attachment.decrypt(encrypted); + return Buffer.from(decrypted); } } diff --git a/src/e2ee/ICryptoRoomInformation.ts b/src/e2ee/ICryptoRoomInformation.ts new file mode 100644 index 00000000..7f41caad --- /dev/null +++ b/src/e2ee/ICryptoRoomInformation.ts @@ -0,0 +1,9 @@ +import { EncryptionEventContent } from "../models/events/EncryptionEvent"; + +/** + * Information about a room for the purposes of crypto. + * @category Encryption + */ +export interface ICryptoRoomInformation extends Partial { + historyVisibility?: string; +} diff --git a/src/e2ee/InternalOlmMachineFactory.ts b/src/e2ee/InternalOlmMachineFactory.ts deleted file mode 100644 index 2d32f6bf..00000000 --- a/src/e2ee/InternalOlmMachineFactory.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { OlmEngine, OlmMachine } from "@turt2live/matrix-sdk-crypto-nodejs"; - -/** - * @internal - */ -export class InternalOlmMachineFactory { - public static FACTORY_OVERRIDE: (userId: string, deviceId: string, engine: OlmEngine, storagePath: string) => OlmMachine; - - constructor(private userId: string, private deviceId: string, private engine: OlmEngine, private storagePath: string) { - } - - public build(): OlmMachine { - if (InternalOlmMachineFactory.FACTORY_OVERRIDE) { - // eslint-disable-next-line new-cap - return InternalOlmMachineFactory.FACTORY_OVERRIDE(this.userId, this.deviceId, this.engine, this.storagePath); - } - return OlmMachine.withSledBackend(this.userId, this.deviceId, this.engine, this.storagePath); - } -} diff --git a/src/e2ee/RoomTracker.ts b/src/e2ee/RoomTracker.ts index f3c35fbc..98ed5bda 100644 --- a/src/e2ee/RoomTracker.ts +++ b/src/e2ee/RoomTracker.ts @@ -1,5 +1,6 @@ import { MatrixClient } from "../MatrixClient"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; +import { ICryptoRoomInformation } from "./ICryptoRoomInformation"; // noinspection ES6RedundantAwait /** @@ -8,17 +9,28 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; */ export class RoomTracker { public constructor(private client: MatrixClient) { - this.client.on("room.join", (roomId: string) => { - // noinspection JSIgnoredPromiseFromCall - this.queueRoomCheck(roomId); - }); + } - this.client.on("room.event", (roomId: string, event: any) => { - if (event['type'] === 'm.room.encryption' && event['state_key'] === '') { - // noinspection JSIgnoredPromiseFromCall - this.queueRoomCheck(roomId); - } - }); + /** + * Handles a room join + * @internal + * @param roomId The room ID. + */ + public async onRoomJoin(roomId: string) { + await this.queueRoomCheck(roomId); + } + + /** + * Handles a room event. + * @internal + * @param roomId The room ID. + * @param event The event. + */ + public async onRoomEvent(roomId: string, event: any) { + if (event['state_key'] !== '') return; // we don't care about anything else + if (event['type'] === 'm.room.encryption' || event['type'] === 'm.room.history_visibility') { + await this.queueRoomCheck(roomId); + } } /** @@ -51,16 +63,29 @@ export class RoomTracker { } catch (e) { return; // failure == no encryption } - await this.client.cryptoStore.storeRoom(roomId, encEvent); + + // Pick out the history visibility setting too + let historyVisibility: string; + try { + const ev = await this.client.getRoomStateEvent(roomId, "m.room.history_visibility", ""); + historyVisibility = ev.history_visibility; + } catch (e) { + // ignore - we'll just treat history visibility as normal + } + + await this.client.cryptoStore.storeRoom(roomId, { + ...encEvent, + historyVisibility, + }); } /** * Gets the room's crypto configuration, as known by the underlying store. If the room is * not encrypted then this will return an empty object. * @param {string} roomId The room ID to get the config for. - * @returns {Promise>} Resolves to the encryption config. + * @returns {Promise} Resolves to the encryption config. */ - public async getRoomCryptoConfig(roomId: string): Promise> { + public async getRoomCryptoConfig(roomId: string): Promise { let config = await this.client.cryptoStore.getRoom(roomId); if (!config) { await this.queueRoomCheck(roomId); diff --git a/src/e2ee/RustEngine.ts b/src/e2ee/RustEngine.ts new file mode 100644 index 00000000..da9369a7 --- /dev/null +++ b/src/e2ee/RustEngine.ts @@ -0,0 +1,140 @@ +import { + EncryptionSettings, + KeysClaimRequest, + OlmMachine, + RequestType, + RoomId, + UserId, + EncryptionAlgorithm as RustEncryptionAlgorithm, + HistoryVisibility, + KeysUploadRequest, + KeysQueryRequest, + ToDeviceRequest, +} from "@matrix-org/matrix-sdk-crypto-nodejs"; +import * as AsyncLock from "async-lock"; + +import { MatrixClient } from "../MatrixClient"; +import { ICryptoRoomInformation } from "./ICryptoRoomInformation"; +import { EncryptionAlgorithm } from "../models/Crypto"; +import { EncryptionEvent } from "../models/events/EncryptionEvent"; + +/** + * @internal + */ +export const SYNC_LOCK_NAME = "sync"; + +/** + * @internal + */ +export class RustEngine { + public readonly lock = new AsyncLock(); + + public constructor(public readonly machine: OlmMachine, private client: MatrixClient) { + } + + public async run() { + // Note: we should not be running this until it runs out, so cache the value into a variable + const requests = await this.machine.outgoingRequests(); + for (const request of requests) { + switch (request.type) { + case RequestType.KeysUpload: + await this.processKeysUploadRequest(request); + break; + case RequestType.KeysQuery: + await this.processKeysQueryRequest(request); + break; + case RequestType.KeysClaim: + await this.processKeysClaimRequest(request); + break; + case RequestType.ToDevice: + await this.processToDeviceRequest(request); + break; + case RequestType.RoomMessage: + throw new Error("Bindings error: Sending room messages is not supported"); + case RequestType.SignatureUpload: + throw new Error("Bindings error: Backup feature not possible"); + case RequestType.KeysBackup: + throw new Error("Bindings error: Backup feature not possible"); + default: + throw new Error("Bindings error: Unrecognized request type: " + request.type); + } + } + } + + public async addTrackedUsers(userIds: string[]) { + await this.lock.acquire(SYNC_LOCK_NAME, async () => { + await this.machine.updateTrackedUsers(userIds.map(u => new UserId(u))); + + const keysClaim = await this.machine.getMissingSessions([]); + if (keysClaim) { + await this.processKeysClaimRequest(keysClaim); + } + }); + } + + public async prepareEncrypt(roomId: string, roomInfo: ICryptoRoomInformation) { + // TODO: Handle pre-shared invite keys too + const members = (await this.client.getJoinedRoomMembers(roomId)).map(u => new UserId(u)); + + let historyVis = HistoryVisibility.Joined; + switch (roomInfo.historyVisibility) { + case "world_readable": + historyVis = HistoryVisibility.WorldReadable; + break; + case "invited": + historyVis = HistoryVisibility.Invited; + break; + case "shared": + historyVis = HistoryVisibility.Shared; + break; + case "joined": + default: + // Default and other cases handled by assignment before switch + } + + const encEv = new EncryptionEvent({ + type: "m.room.encryption", + content: roomInfo, + }); + + const settings = new EncryptionSettings(); + settings.algorithm = roomInfo.algorithm === EncryptionAlgorithm.MegolmV1AesSha2 + ? RustEncryptionAlgorithm.MegolmV1AesSha2 + : undefined; + settings.historyVisibility = historyVis; + settings.rotationPeriod = BigInt(encEv.rotationPeriodMs); + settings.rotationPeriodMessages = BigInt(encEv.rotationPeriodMessages); + + await this.lock.acquire(roomId, async () => { + const requests = JSON.parse(await this.machine.shareRoomKey(new RoomId(roomId), members, settings)); + for (const req of requests) { + await this.actuallyProcessToDeviceRequest(req.txn_id, req.event_type, req.messages); + } + }); + } + + private async processKeysClaimRequest(request: KeysClaimRequest) { + const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/claim", null, JSON.parse(request.body)); + await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); + } + + private async processKeysUploadRequest(request: KeysUploadRequest) { + const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/upload", null, JSON.parse(request.body)); + await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); + } + + private async processKeysQueryRequest(request: KeysQueryRequest) { + const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/query", null, JSON.parse(request.body)); + await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); + } + + private async processToDeviceRequest(request: ToDeviceRequest) { + const req = JSON.parse(request.body); + await this.actuallyProcessToDeviceRequest(req.id, req.event_type, req.messages); + } + + private async actuallyProcessToDeviceRequest(id: string, type: string, messages: Record>) { + const resp = await this.client.sendToDevices(type, messages); + await this.machine.markRequestAsSent(id, RequestType.ToDevice, JSON.stringify(resp)); + } +} diff --git a/src/e2ee/SdkOlmEngine.ts b/src/e2ee/SdkOlmEngine.ts deleted file mode 100644 index 0153279a..00000000 --- a/src/e2ee/SdkOlmEngine.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - DeviceKeys, - GenericKeys, - KeyClaim, - KeyClaimResponse, - KeyQueryResults, - OlmEngine, - OTKCounts, - ToDeviceMessages, -} from "@turt2live/matrix-sdk-crypto-nodejs"; - -import { MatrixClient } from "../MatrixClient"; -import { OTKAlgorithm } from "../models/Crypto"; - -/** - * A representation of a rust-sdk OlmEngine for the bot-sdk. You should not need to - * instantiate this yourself. - * @category Encryption - */ -export class SdkOlmEngine implements OlmEngine { - public constructor(private client: MatrixClient) { - } - - public claimOneTimeKeys(claim: KeyClaim): Promise { - const reconstructed: Record> = {}; - for (const userId of Object.keys(claim)) { - if (!reconstructed[userId]) reconstructed[userId] = {}; - - for (const deviceId of Object.keys(claim[userId])) { - reconstructed[userId][deviceId] = claim[userId][deviceId] as OTKAlgorithm; - } - } - return this.client.claimOneTimeKeys(reconstructed); - } - - public queryOneTimeKeys(userIds: string[]): Promise { - return this.client.getUserDevices(userIds); - } - - public uploadOneTimeKeys(body: { device_keys?: DeviceKeys, one_time_keys?: GenericKeys }): Promise { - return this.client.doRequest("POST", "/_matrix/client/v3/keys/upload", null, body); - } - - public getEffectiveJoinedUsersInRoom(roomId: string): Promise { - // TODO: Handle pre-shared invite keys too - return this.client.getJoinedRoomMembers(roomId); - } - - public sendToDevices(eventType: string, messages: ToDeviceMessages): Promise { - return this.client.sendToDevices(eventType, messages); - } -} diff --git a/src/index.ts b/src/index.ts index 8b77b91e..b1f9bc51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,8 @@ export * from "./appservice/UnstableAppserviceApis"; export * from "./e2ee/RoomTracker"; export * from "./e2ee/CryptoClient"; export * from "./e2ee/decorators"; -export * from "./e2ee/SdkOlmEngine"; -// export * from "./e2ee/InternalOlmMachineFactory"; +// export * from "./e2ee/RustEngine"; +export * from "./e2ee/ICryptoRoomInformation"; // Helpers export * from "./helpers/RichReply"; diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index a198449a..9269a95f 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -112,18 +112,6 @@ export interface OwnUserDevice { last_seen_ts?: number; } -/** - * Represents a user's stored device. - * @category Models - */ -export interface StoredUserDevice extends UserDevice { - unsigned: { - [k: string]: any; - device_display_name?: string; - bsdkIsActive: boolean; - }; -} - /** * Device list response for a multi-user query. * @category Models @@ -162,60 +150,6 @@ export interface OTKClaimResponse { one_time_keys: Record>; } -/** - * An outbound group session. - * @category Models - */ -export interface IOutboundGroupSession { - sessionId: string; - roomId: string; - pickled: string; - isCurrent: boolean; - usesLeft: number; - expiresTs: number; -} - -/** - * An inbound group session. - * @category Models - */ -export interface IInboundGroupSession { - sessionId: string; - roomId: string; - senderUserId: string; - senderDeviceId: string; - pickled: string; - - // TODO: Store `keys` from the m.room_key alongside the session for "verified sender" support. -} - -/** - * An Olm session. - * @category Models - */ -export interface IOlmSession { - sessionId: string; - pickled: string; - lastDecryptionTs: number; -} - -/** - * An Olm payload (plaintext). - * @category Models - */ -export interface IOlmPayload { - type: string; - content: any; - sender: string; - recipient: string; // user ID - recipient_keys: { - ed25519: string; - }; - keys: { - ed25519: string; // sender's key - }; -} - /** * An encrypted Olm payload. * @category Models @@ -241,17 +175,6 @@ export interface IToDeviceMessage { content: T; } -/** - * An m.room_key to-device message's content. - * @category Models - */ -export interface IMRoomKey { - algorithm: EncryptionAlgorithm.MegolmV1AesSha2; - room_id: string; - session_id: string; - session_key: string; -} - /** * Encrypted event content for a Megolm-encrypted m.room.encrypted event * @category Models diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index 8af65007..f5db1dbe 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,4 +1,4 @@ -import { EncryptionEventContent } from "../models/events/EncryptionEvent"; +import { ICryptoRoomInformation } from "../e2ee/ICryptoRoomInformation"; /** * A storage provider capable of only providing crypto-related storage. @@ -21,16 +21,16 @@ export interface ICryptoStorageProvider { /** * Stores a room's configuration. * @param {string} roomId The room ID to store the configuration for. - * @param {Partial} config The room's encryption config. May be empty. + * @param {ICryptoRoomInformation} config The room's encryption config. May be empty. * @returns {Promise} Resolves when complete. */ - storeRoom(roomId: string, config: Partial): Promise; + storeRoom(roomId: string, config: ICryptoRoomInformation): Promise; /** * Gets a room's configuration. If the room is unknown, a falsy value is returned. * @param {string} roomId The room ID to get the configuration for. - * @returns {Promise>} Resolves to the room's configuration, or + * @returns {Promise} Resolves to the room's configuration, or * to falsy if the room is unknown. */ - getRoom(roomId: string): Promise>; + getRoom(roomId: string): Promise; } diff --git a/src/storage/RustSdkCryptoStorageProvider.ts b/src/storage/RustSdkCryptoStorageProvider.ts index b8f8e962..aedb478b 100644 --- a/src/storage/RustSdkCryptoStorageProvider.ts +++ b/src/storage/RustSdkCryptoStorageProvider.ts @@ -5,9 +5,9 @@ import * as path from "path"; import * as sha512 from "hash.js/lib/hash/sha/512"; import * as sha256 from "hash.js/lib/hash/sha/256"; -import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { IAppserviceCryptoStorageProvider } from "./IAppserviceStorageProvider"; +import { ICryptoRoomInformation } from "../e2ee/ICryptoRoomInformation"; /** * A crypto storage provider for the default rust-sdk store (sled, file-based). @@ -41,12 +41,12 @@ export class RustSdkCryptoStorageProvider implements ICryptoStorageProvider { this.db.set('deviceId', deviceId).write(); } - public async getRoom(roomId: string): Promise> { + public async getRoom(roomId: string): Promise { const key = sha512().update(roomId).digest('hex'); return this.db.get(`rooms.${key}`).value(); } - public async storeRoom(roomId: string, config: Partial): Promise { + public async storeRoom(roomId: string, config: ICryptoRoomInformation): Promise { const key = sha512().update(roomId).digest('hex'); this.db.set(`rooms.${key}`, config).write(); } diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index b62967e4..8cb4dd4f 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -1,11 +1,7 @@ import * as tmp from "tmp"; import * as simple from "simple-mock"; -import { OlmMachine, Signatures } from "@turt2live/matrix-sdk-crypto-nodejs"; import { - DeviceKeyAlgorithm, - DeviceKeyLabel, - EncryptionAlgorithm, EventKind, IJoinRoomStrategy, IPreprocessor, @@ -26,15 +22,10 @@ import { setRequestFn, } from "../src"; import { createTestClient, expectArrayEquals, TEST_DEVICE_ID } from "./TestUtils"; -import { InternalOlmMachineFactory } from "../src/e2ee/InternalOlmMachineFactory"; tmp.setGracefulCleanup(); describe('MatrixClient', () => { - afterEach(() => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = null; - }); - describe("constructor", () => { it('should pass through the homeserver URL and access token', () => { const homeserverUrl = "https://example.org"; @@ -6410,70 +6401,6 @@ describe('MatrixClient', () => { }); }); - describe('uploadDeviceKeys', () => { - it('should fail when no encryption', async () => { - try { - const { client } = createTestClient(); - await client.uploadDeviceKeys([], {}); - - // noinspection ExceptionCaughtLocallyJS - throw new Error("Failed to fail"); - } catch (e) { - expect(e.message).toEqual("End-to-end encryption is not enabled"); - } - }); - - it('should call the right endpoint', async () => { - const userId = "@test:example.org"; - - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - sign: async (_) => ({ - [userId]: { - [DeviceKeyAlgorithm.Ed25519 + ":" + TEST_DEVICE_ID]: "SIGNATURE_GOES_HERE", - }, - } as Signatures), - } as OlmMachine); - - const { client, http } = createTestClient(null, userId, true); - - client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); - await client.crypto.prepare([]); - - const algorithms = [EncryptionAlgorithm.MegolmV1AesSha2, EncryptionAlgorithm.OlmV1Curve25519AesSha2]; - const keys: Record, string> = { - [DeviceKeyAlgorithm.Curve25519 + ":" + TEST_DEVICE_ID]: "key1", - [DeviceKeyAlgorithm.Ed25519 + ":" + TEST_DEVICE_ID]: "key2", - }; - const counts: OTKCounts = { - [OTKAlgorithm.Signed]: 12, - [OTKAlgorithm.Unsigned]: 14, - }; - - // noinspection TypeScriptValidateJSTypes - http.when("POST", "/_matrix/client/v3/keys/upload").respond(200, (path, content) => { - expect(content).toMatchObject({ - device_keys: { - user_id: userId, - device_id: TEST_DEVICE_ID, - algorithms: algorithms, - keys: keys, - signatures: { - [userId]: { - [DeviceKeyAlgorithm.Ed25519 + ":" + TEST_DEVICE_ID]: expect.any(String), - }, - }, - }, - }); - return { one_time_key_counts: counts }; - }); - - const [result] = await Promise.all([client.uploadDeviceKeys(algorithms, keys), http.flushAllExpected()]); - expect(result).toMatchObject(counts); - }); - }); - describe('uploadDeviceOneTimeKeys', () => { it('should fail when no encryption is available', async () => { try { diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 71029796..02ed769e 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1,30 +1,31 @@ import * as simple from "simple-mock"; -import { OlmMachine, Signatures } from "@turt2live/matrix-sdk-crypto-nodejs"; - -import { - ConsoleLogger, - DeviceKeyAlgorithm, - EncryptedFile, - LogService, - MatrixClient, - RoomEncryptionAlgorithm, -} from "../../src"; -import { InternalOlmMachineFactory } from "../../src/e2ee/InternalOlmMachineFactory"; +import HttpBackend from 'matrix-mock-request'; + +import { EncryptedFile, MatrixClient, MembershipEvent, OTKAlgorithm, RoomEncryptionAlgorithm } from "../../src"; import { createTestClient, TEST_DEVICE_ID } from "../TestUtils"; -describe('CryptoClient', () => { - afterEach(() => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = null; +export function bindNullEngine(http: HttpBackend) { + http.when("POST", "/keys/upload").respond(200, (path, obj) => { + expect(obj).toMatchObject({ + + }); + return { + one_time_key_counts: { + // Enough to trick the OlmMachine into thinking it has enough keys + [OTKAlgorithm.Signed]: 1000, + }, + }; + }); + // Some oddity with the rust-sdk bindings during setup + http.when("POST", "/keys/query").respond(200, (path, obj) => { + return {}; }); +} +describe('CryptoClient', () => { it('should not have a device ID or be ready until prepared', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; - const { client } = createTestClient(null, userId, true); + const { client, http } = createTestClient(null, userId, true); client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); @@ -32,7 +33,11 @@ describe('CryptoClient', () => { expect(client.crypto.clientDeviceId).toBeFalsy(); expect(client.crypto.isReady).toEqual(false); - await client.crypto.prepare([]); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID); expect(client.crypto.isReady).toEqual(true); @@ -40,14 +45,9 @@ describe('CryptoClient', () => { describe('prepare', () => { it('should prepare the room tracker', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; const roomIds = ["!a:example.org", "!b:example.org"]; - const { client } = createTestClient(null, userId, true); + const { client, http } = createTestClient(null, userId, true); client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); @@ -58,25 +58,28 @@ describe('CryptoClient', () => { (client.crypto).roomTracker.prepare = prepareSpy; // private member access - await client.crypto.prepare(roomIds); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare(roomIds), + http.flushAllExpected(), + ]); expect(prepareSpy.callCount).toEqual(1); }); it('should use a stored device ID', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; - const { client } = createTestClient(null, userId, true); + const { client, http } = createTestClient(null, userId, true); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: "wrong" })); client.getWhoAmI = whoamiSpy; - await client.crypto.prepare([]); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); expect(whoamiSpy.callCount).toEqual(0); expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID); }); @@ -84,11 +87,6 @@ describe('CryptoClient', () => { describe('isRoomEncrypted', () => { it('should fail when the crypto has not been prepared', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); @@ -106,68 +104,68 @@ describe('CryptoClient', () => { }); it('should return false for unknown rooms', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; - const { client } = createTestClient(null, userId, true); + const { client, http } = createTestClient(null, userId, true); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.reject(new Error("not used")); - await client.crypto.prepare([]); + + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(false); }); it('should return false for unencrypted rooms', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; - const { client } = createTestClient(null, userId, true); + const { client, http } = createTestClient(null, userId, true); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.reject(new Error("implied 404")); - await client.crypto.prepare([]); + + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(false); }); it('should return true for encrypted rooms (redacted state)', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; - const { client } = createTestClient(null, userId, true); + const { client, http } = createTestClient(null, userId, true); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.resolve({}); - await client.crypto.prepare([]); + + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(true); }); it('should return true for encrypted rooms', async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const userId = "@alice:example.org"; - const { client } = createTestClient(null, userId, true); + const { client, http } = createTestClient(null, userId, true); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.resolve({ algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2 }); - await client.crypto.prepare([]); + + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(true); @@ -177,20 +175,12 @@ describe('CryptoClient', () => { describe('sign', () => { const userId = "@alice:example.org"; let client: MatrixClient; + let http: HttpBackend; beforeEach(async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - sign: async (_) => ({ - [userId]: { - [DeviceKeyAlgorithm.Ed25519 + ":" + TEST_DEVICE_ID]: "SIGNATURE_GOES_HERE", - }, - } as Signatures), - } as OlmMachine); - - const { client: mclient } = createTestClient(null, userId, true); + const { client: mclient, http: mhttp } = createTestClient(null, userId, true); client = mclient; + http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); @@ -209,7 +199,11 @@ describe('CryptoClient', () => { }); it('should sign the object while retaining signatures without mutation', async () => { - await client.crypto.prepare([]); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const obj = { sign: "me", @@ -238,15 +232,12 @@ describe('CryptoClient', () => { describe('encryptRoomEvent', () => { const userId = "@alice:example.org"; let client: MatrixClient; + let http: HttpBackend; beforeEach(async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - - const { client: mclient } = createTestClient(null, userId, true); + const { client: mclient, http: mhttp } = createTestClient(null, userId, true); client = mclient; + http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); @@ -265,7 +256,11 @@ describe('CryptoClient', () => { }); it('should fail in unencrypted rooms', async () => { - await client.crypto.prepare([]); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); // Force unencrypted rooms client.crypto.isRoomEncrypted = async () => false; @@ -290,11 +285,6 @@ describe('CryptoClient', () => { let client: MatrixClient; beforeEach(async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - const { client: mclient } = createTestClient(null, userId, true); client = mclient; @@ -303,10 +293,6 @@ describe('CryptoClient', () => { // client crypto not prepared for the one test which wants that state }); - afterEach(async () => { - LogService.setLogger(new ConsoleLogger()); - }); - it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.decryptRoomEvent(null, null); @@ -322,25 +308,18 @@ describe('CryptoClient', () => { describe('encryptMedia', () => { const userId = "@alice:example.org"; let client: MatrixClient; + let http: HttpBackend; beforeEach(async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - - const { client: mclient } = createTestClient(null, userId, true); + const { client: mclient, http: mhttp } = createTestClient(null, userId, true); client = mclient; + http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // client crypto not prepared for the one test which wants that state }); - afterEach(async () => { - LogService.setLogger(new ConsoleLogger()); - }); - it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.encryptMedia(null); @@ -353,7 +332,11 @@ describe('CryptoClient', () => { }); it('should encrypt media', async () => { - await client.crypto.prepare([]); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const inputBuffer = Buffer.from("test"); const inputStr = inputBuffer.join(''); @@ -385,6 +368,7 @@ describe('CryptoClient', () => { describe('decryptMedia', () => { const userId = "@alice:example.org"; let client: MatrixClient; + let http: HttpBackend; // Created from Element Web const testFileContents = "THIS IS A TEST FILE."; @@ -413,23 +397,15 @@ describe('CryptoClient', () => { } beforeEach(async () => { - InternalOlmMachineFactory.FACTORY_OVERRIDE = () => ({ - identityKeys: {}, - runEngine: () => Promise.resolve(), - } as OlmMachine); - - const { client: mclient } = createTestClient(null, userId, true); + const { client: mclient, http: mhttp } = createTestClient(null, userId, true); client = mclient; + http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // client crypto not prepared for the one test which wants that state }); - afterEach(async () => { - LogService.setLogger(new ConsoleLogger()); - }); - it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.encryptMedia(null); @@ -442,7 +418,11 @@ describe('CryptoClient', () => { }); it('should be symmetrical', async () => { - await client.crypto.prepare([]); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const mxc = "mxc://example.org/test"; const inputBuffer = Buffer.from("test"); @@ -463,7 +443,11 @@ describe('CryptoClient', () => { }); it('should decrypt', async () => { - await client.crypto.prepare([]); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); const downloadSpy = simple.stub().callFn(async (u) => { expect(u).toEqual(testFile.url); @@ -477,4 +461,99 @@ describe('CryptoClient', () => { expect(downloadSpy.callCount).toBe(1); }); }); + + describe('User Tracking', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + let http: HttpBackend; + + beforeEach(async () => { + const { client: mclient, http: mhttp } = createTestClient(null, userId, true); + client = mclient; + http = mhttp; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); + }); + + it('should update tracked users on membership changes', async () => { + const targetUserIds = ["@bob:example.org", "@charlie:example.org"]; + const prom = new Promise(extResolve => { + const trackSpy = simple.mock().callFn((uids) => { + expect(uids.length).toBe(1); + expect(uids[0]).toEqual(targetUserIds[trackSpy.callCount - 1]); + if (trackSpy.callCount === 2) extResolve(); + return Promise.resolve(); + }); + (client.crypto as any).engine.addTrackedUsers = trackSpy; + }); + + for (const targetUserId of targetUserIds) { + client.emit("room.event", "!unused:example.org", { + type: "m.room.member", + state_key: targetUserId, + content: { membership: "join" }, + sender: targetUserId + ".notthisuser", + }); + } + + // Emit a fake update too, to try and trip up the processing + client.emit("room.event", "!unused:example.org", { + type: "m.room.member", + state_key: "@notjoined:example.org", + content: { membership: "ban" }, + sender: "@notme:example.org", + }); + + // We do weird promise things because `emit()` is sync and we're using async code, so it can + // end up not running fast enough for our callCount checks. + await prom; + }); + + it('should add all tracked users when the encryption config changes', async () => { + // Stub the room tracker + (client.crypto as any).roomTracker.onRoomEvent = () => {}; + + const targetUserIds = ["@bob:example.org", "@charlie:example.org"]; + const prom1 = new Promise(extResolve => { + (client.crypto as any).engine.addTrackedUsers = simple.mock().callFn((uids) => { + expect(uids).toEqual(targetUserIds); + extResolve(); + return Promise.resolve(); + }); + }); + + const roomId = "!room:example.org"; + const prom2 = new Promise(extResolve => { + client.getRoomMembers = simple.mock().callFn((rid, token, memberships) => { + expect(rid).toEqual(roomId); + expect(token).toBeFalsy(); + expect(memberships).toEqual(["join", "invite"]); + extResolve(); + return Promise.resolve(targetUserIds.map(u => new MembershipEvent({ + type: "m.room.member", + state_key: u, + content: { membership: "join" }, + sender: u, + }))); + }); + }); + + client.emit("room.event", roomId, { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, + }, + }); + + // We do weird promise things because `emit()` is sync and we're using async code, so it can + // end up not running fast enough for our callCount checks. + await Promise.all([prom1, prom2]); + }); + }); }); diff --git a/test/encryption/RoomTrackerTest.ts b/test/encryption/RoomTrackerTest.ts index a7650381..2dd1453e 100644 --- a/test/encryption/RoomTrackerTest.ts +++ b/test/encryption/RoomTrackerTest.ts @@ -1,7 +1,8 @@ import * as simple from "simple-mock"; import { EncryptionEventContent, MatrixClient, RoomEncryptionAlgorithm, RoomTracker } from "../../src"; -import { createTestClient } from "../TestUtils"; +import { createTestClient, TEST_DEVICE_ID } from "../TestUtils"; +import { bindNullEngine } from "./CryptoClientTest"; function prepareQueueSpies( client: MatrixClient, @@ -40,9 +41,17 @@ describe('RoomTracker', () => { it('should queue room updates when rooms are joined', async () => { const roomId = "!a:example.org"; - const { client } = createTestClient(); + const { client, http } = createTestClient(null, "@user:example.org", true); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); + (client.crypto as any).engine.addTrackedUsers = () => Promise.resolve(); + client.getRoomMembers = () => Promise.resolve([]); - const tracker = new RoomTracker(client); + const tracker = (client.crypto as any).roomTracker; let queueSpy: simple.Stub; await new Promise(resolve => { @@ -60,9 +69,15 @@ describe('RoomTracker', () => { it('should queue room updates when encryption events are received', async () => { const roomId = "!a:example.org"; - const { client } = createTestClient(); + const { client, http } = createTestClient(null, "@user:example.org", true); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare([]), + http.flushAllExpected(), + ]); - const tracker = new RoomTracker(client); + const tracker = (client.crypto as any).roomTracker; let queueSpy: simple.Stub; await new Promise(resolve => { @@ -119,7 +134,7 @@ describe('RoomTracker', () => { const tracker = new RoomTracker(client); await tracker.queueRoomCheck(roomId); expect(readSpy.callCount).toEqual(1); - expect(stateSpy.callCount).toEqual(1); + expect(stateSpy.callCount).toEqual(2); // m.room.encryption and m.room.history_visibility expect(storeSpy.callCount).toEqual(1); }); diff --git a/yarn.lock b/yarn.lock index c36d6ca6..0e205339 100644 --- a/yarn.lock +++ b/yarn.lock @@ -584,10 +584,12 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@napi-rs/cli@^2.2.0": - version "2.9.0" - resolved "https://registry.yarnpkg.com/@napi-rs/cli/-/cli-2.9.0.tgz#3db950b97ea8f2823021383b89cd2f05addeac3a" - integrity sha512-qZ49ORZJ8rsHlvnnLsJCg9Ik+KfhiIgSea1HDMf6A9KBVxwWCijNhSO8zFvvBr7m1m44cx9WWhnQDnwPqSDeLA== +"@matrix-org/matrix-sdk-crypto-nodejs@^0.1.0-beta.1": + version "0.1.0-beta.1" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.1.tgz#8a9058226916a258e5b4e28d76680e895b6203b2" + integrity sha512-jCSKrmNh6kaqnOwS/Pqgqkeb+CAvwGuS0oNEW3LaWKrJWFAfUrt+lXBCs7kAP79Qo5ZKBU06BekbZuwYhWbhkQ== + dependencies: + node-downloader-helper "^2.1.1" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -637,14 +639,6 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@turt2live/matrix-sdk-crypto-nodejs@^0.1.0-beta.10": - version "0.1.0-beta.10" - resolved "https://registry.yarnpkg.com/@turt2live/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.10.tgz#9b0a8e1f48badeb37a0b0f8eb0fb6dc9bbb1949a" - integrity sha512-y5TA8fD5a7xaIwjZhQ66eT3scDsU47GkcCuQ0vjlXB0shY2cCMB4MF1nY/7c1/DniM+KvDXxrhs2VXphlPLpaA== - dependencies: - "@napi-rs/cli" "^2.2.0" - shelljs "^0.8.4" - "@types/babel-types@*", "@types/babel-types@^7.0.0": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.11.tgz#263b113fa396fac4373188d73225297fb86f19a9" @@ -1113,6 +1107,11 @@ ast-types@^0.14.2: dependencies: tslib "^2.0.1" +async-lock@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.3.2.tgz#56668613f91c1c55432b4db73e65c9ced664e789" + integrity sha512-phnXdS3RP7PPcmP6NWWzWMU0sLTeyvtZCxBPpZdkYE3seGLKSQZs9FrmVO/qwypq98FUtWWUEYxziLkdGk5nnA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -2345,7 +2344,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^7.0.0, glob@^7.1.3, glob@^7.1.4: +glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -2570,11 +2569,6 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" -interpret@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" - integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== - ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -3618,6 +3612,11 @@ node-dir@^0.1.10: dependencies: minimatch "^3.0.2" +node-downloader-helper@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/node-downloader-helper/-/node-downloader-helper-2.1.1.tgz#533427a3cdc163931b106d0fe6d522f83deac7ab" + integrity sha512-ouk8MGmJj1gYymbJwi1L8Mr6PdyheJLwfsmyx0KtsvyJ+7Fpf0kBBzM8Gmx8Mt/JBfRWP1PQm6dAGV6x7eNedw== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -4170,13 +4169,6 @@ recast@^0.17.3: private "^0.1.8" source-map "~0.6.1" -rechoir@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" - integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q= - dependencies: - resolve "^1.1.6" - regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" @@ -4415,15 +4407,6 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shelljs@^0.8.4: - version "0.8.5" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" - integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== - dependencies: - glob "^7.0.0" - interpret "^1.0.0" - rechoir "^0.6.2" - side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"