diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b5cd5127..c241a616 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* turt2live +* @vector-im/bridges diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 91aa8502..b4b29a62 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,11 +10,11 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18' + node-version-file: .node-version - run: yarn install - run: yarn docs - name: Build and deploy docs uses: JamesIves/github-pages-deploy-action@v4 with: branch: gh-pages - folder: .jsdoc/matrix-bot-sdk/develop + folder: .jsdoc/@vector-im/matrix-bot-sdk/develop diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index eb1e9a53..944006f6 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18' # Target desired node version + node-version-file: .node-version - run: yarn install - run: yarn lint diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..3c032078 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +18 diff --git a/LICENSE b/LICENSE index fd25fa70..77022f36 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,6 @@ MIT License +Copyright (c) 2022 - 2023 New Vector Ltd Copyright (c) 2018 - 2023 Travis Ralston Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/README.md b/README.md index 33d05d5d..da2b7c0f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # matrix-bot-sdk -[![npm version](https://badge.fury.io/js/matrix-bot-sdk.svg)](https://www.npmjs.com/package/matrix-bot-sdk) +[![npm version](https://badge.fury.io/js/@vector-im%2Fmatrix-bot-sdk.svg)](https://www.npmjs.com/package/@vector-im/matrix-bot-sdk) TypeScript/JavaScript SDK for Matrix bots. For help and support, visit [#matrix-bot-sdk:t2bot.io](https://matrix.to/#/#matrix-bot-sdk:t2bot.io) diff --git a/docs/index.md b/docs/index.md index 291897b9..7164bf4c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -[![npm version](https://badge.fury.io/js/matrix-bot-sdk.svg)](https://www.npmjs.com/package/matrix-bot-sdk) +[![npm version](https://badge.fury.io/js/@vector-im%2Fmatrix-bot-sdk.svg)](https://www.npmjs.com/package/@vector-im/matrix-bot-sdk) TypeScript/JavaScript SDK for Matrix bots. For help and support, visit [#matrix-bot-sdk:t2bot.io](https://matrix.to/#/#matrix-bot-sdk:t2bot.io) @@ -11,7 +11,7 @@ TypeScript/JavaScript SDK for Matrix bots. For help and support, visit [#matrix- ## Installing -This package can be found on [npm](https://www.npmjs.com/package/matrix-bot-sdk): +This package can be found on [npm](https://www.npmjs.com/package/@vector-im/matrix-bot-sdk): ``` -npm install matrix-bot-sdk +npm install @vector-im/matrix-bot-sdk ``` diff --git a/examples/bot.ts b/examples/bot.ts index a1ac99c1..b958df9b 100644 --- a/examples/bot.ts +++ b/examples/bot.ts @@ -27,7 +27,7 @@ const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost"; const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008"; const accessToken = creds?.['accessToken'] ?? 'YOUR_TOKEN'; const storage = new SimpleFsStorageProvider("./examples/storage/bot.json"); -const crypto = new RustSdkCryptoStorageProvider("./examples/storage/bot_sled", StoreType.Sled); +const crypto = new RustSdkCryptoStorageProvider("./examples/storage/bot_sqlite", StoreType.Sqlite); const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); AutojoinRoomsMixin.setupOnClient(client); diff --git a/examples/encryption_appservice.ts b/examples/encryption_appservice.ts index 90c8f3f6..4621897f 100644 --- a/examples/encryption_appservice.ts +++ b/examples/encryption_appservice.ts @@ -31,7 +31,7 @@ try { const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost"; const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008"; const storage = new SimpleFsStorageProvider("./examples/storage/encryption_appservice.json"); -const crypto = new RustSdkAppserviceCryptoStorageProvider("./examples/storage/encryption_appservice_sled", StoreType.Sled); +const crypto = new RustSdkAppserviceCryptoStorageProvider("./examples/storage/encryption_appservice_sqlite", StoreType.Sqlite); const worksImage = fs.readFileSync("./examples/static/it-works.png"); const registration: IAppserviceRegistration = { diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index 011b1565..98bae196 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -29,15 +29,15 @@ const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost"; const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008"; const accessToken = creds?.['accessToken'] ?? 'YOUR_TOKEN'; const storage = new SimpleFsStorageProvider("./examples/storage/encryption_bot.json"); -const crypto = new RustSdkCryptoStorageProvider("./examples/storage/encryption_bot_sled", StoreType.Sled); +const crypto = new RustSdkCryptoStorageProvider("./examples/storage/encryption_bot_sqlite", StoreType.Sqlite); const worksImage = fs.readFileSync("./examples/static/it-works.png"); const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); (async function() { - let encryptedRoomId: string; + let encryptedRoomId: string|undefined = undefined; const joinedRooms = await client.getJoinedRooms(); - await client.crypto.prepare(joinedRooms); // init crypto because we're doing things before the client is started + await client.crypto.prepare(); // init crypto because we're doing things before the client is started for (const roomId of joinedRooms) { if (await client.crypto.isRoomEncrypted(roomId)) { encryptedRoomId = roomId; diff --git a/package.json b/package.json index 95b66d93..4615a7fc 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,20 @@ { - "name": "matrix-bot-sdk", - "version": "develop", + "name": "@vector-im/matrix-bot-sdk", + "version": "0.7.0", "description": "TypeScript/JavaScript SDK for Matrix bots and appservices", "repository": { "type": "git", - "url": "git+https://github.com/turt2live/matrix-bot-sdk.git" + "url": "git+https://github.com/vector-im/matrix-bot-sdk.git" }, - "author": "turt2live", + "author": "Element", "license": "MIT", "bugs": { - "url": "https://github.com/turt2live/matrix-bot-sdk/issues" + "url": "https://github.com/vector-im/matrix-bot-sdk/issues" + }, + "homepage": "https://github.com/vector-im/matrix-bot-sdk#readme", + "publishConfig": { + "access": "public" }, - "homepage": "https://github.com/turt2live/matrix-bot-sdk#readme", "scripts": { "prepublishOnly": "yarn build", "docs": "jsdoc -c jsdoc.json -P package.json -u docs/tutorials", @@ -52,8 +55,8 @@ "tsconfig.json" ], "dependencies": { - "@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.6", - "@types/express": "^4.17.20", + "@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.11", + "@types/express": "^4.17.21", "another-json": "^0.2.0", "async-lock": "^1.4.0", "chalk": "4", diff --git a/scripts/fetch-remotes b/scripts/fetch-remotes new file mode 100755 index 00000000..734cf0a0 --- /dev/null +++ b/scripts/fetch-remotes @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +for REMOTE in $(git remote); do + URL=$(git remote get-url $REMOTE) + if [[ $URL =~ "turt2live" ]]; then + UPSTREAM_REPO=$REMOTE + elif [[ $URL =~ "vector-im" ]]; then + FORK_REPO=$REMOTE + fi +done + +function echoAndDo { + echo "$*" + $* +} + +if [[ -z $UPSTREAM_REPO ]]; then + echo -n 'Adding remote for upstream repo: ' + UPSTREAM_REPO=turt2live + echoAndDo git remote add $UPSTREAM_REPO git@github.com:turt2live/matrix-bot-sdk.git +fi + +if [[ -z $FORK_REPO ]]; then + echo -n 'Adding remote for fork repo: ' + FORK_REPO=vector-im + echoAndDo git remote add $FORK_REPO git@github.com:vector-im/matrix-bot-sdk.git +fi + +for REPO in $UPSTREAM_REPO $FORK_REPO; do + git fetch $REPO >/dev/null + git remote set-head $REPO -a >/dev/null +done diff --git a/scripts/prepare-patch-branch b/scripts/prepare-patch-branch new file mode 100755 index 00000000..fd360c1e --- /dev/null +++ b/scripts/prepare-patch-branch @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +if [[ $# -lt 1 ]]; then + echo 'Please provide a title for your patch branch' >&2 + exit 1 +fi +PATCH_TITLE=$1 + +. $(dirname $0)/fetch-remotes + +git checkout -b $PATCH_TITLE $(git merge-base $UPSTREAM_REPO $FORK_REPO) + +echo "Branch '$PATCH_TITLE' is now ready. Push changes to this branch when preparing a PR, and aim to merge it to both upstream and the fork." diff --git a/scripts/tag-release b/scripts/tag-release new file mode 100755 index 00000000..03741ab9 --- /dev/null +++ b/scripts/tag-release @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +if [[ -n $(git status --porcelain) ]]; then + echo 'Working dir is dirty, aborting' >&2 + exit 1 +fi + +. $(dirname $0)/fetch-remotes + +git checkout element-release +git reset --hard $FORK_REPO + +PREID=element + +# The latest upstream release tag reachable from the current commit +PREV_UPST_TAG=$(git log --decorate=short --decorate-refs=refs/tags/ --simplify-by-decoration --oneline | awk '/ \(tag: / && !/beta|element/ {sub(/)$/, "", $3); print $3; exit}') + +# The commit hash of the retrieved tag (not of the tag itself) +PREV_UPST_TAG_HASH=$(git rev-parse ${PREV_UPST_TAG}~0) + +# The immediate child commit of the release commit, +# to consider the 'Revert version back to "develop"' commits +PREV_UPST_NXT_HASH=$(git rev-list ${PREV_UPST_TAG}..${UPSTREAM_REPO} | tail -n 1) + +# Check if the current branch is a direct merge of the previous upstream release +for MERGE_PARENT in $(git show -s | awk '/^Merge: / {print $2; print $3; exit}'); do + if [[ $PREV_UPST_TAG_HASH =~ ^$MERGE_PARENT || $PREV_UPST_NXT_HASH =~ ^$MERGE_PARENT ]]; then + RELEASE_MERGE=1 + break + fi +done + +if [[ $RELEASE_MERGE -eq 1 ]]; then + THIS_TAG="${PREV_UPST_TAG}-${PREID}" + THIS_VER=${THIS_TAG#v} +else + THIS_VER=$(npx semver --preid ${PREID} -i prerelease ${PREV_UPST_TAG#v}) + while [[ -n $(git tag -l "v${THIS_VER}") ]]; do + THIS_VER=$(npx semver --preid ${PREID} -i prerelease $THIS_VER) + done + THIS_TAG="v${THIS_VER}" +fi + +sed -i 's/\("version": "\).*\("\)/\1'$THIS_VER'\2/' package.json +git add package.json +git commit -m $THIS_TAG +git tag -sm $THIS_TAG{,} + +echo "Tag '$THIS_TAG' is now ready and may be pushed" diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index f9ca9e44..ddba1827 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -44,6 +44,8 @@ import { DMs } from "./DMs"; import { ServerVersions } from "./models/ServerVersions"; import { RoomCreateOptions } from "./models/CreateRoom"; import { PresenceState } from './models/events/PresenceEvent'; +import { IKeyBackupInfo, IKeyBackupInfoRetrieved, IKeyBackupInfoUnsigned, IKeyBackupInfoUpdate, IKeyBackupVersion, KeyBackupVersion } from "./models/KeyBackup"; +import { MatrixError } from "./models/MatrixError"; const SYNC_BACKOFF_MIN_MS = 5000; const SYNC_BACKOFF_MAX_MS = 15000; @@ -654,7 +656,7 @@ export class MatrixClient extends EventEmitter { if (this.crypto) { LogService.debug("MatrixClientLite", "Preparing end-to-end encryption"); - await this.crypto.prepare(this.lastJoinedRoomIds); + await this.crypto.prepare(); LogService.info("MatrixClientLite", "End-to-end encryption enabled"); } @@ -996,6 +998,18 @@ export class MatrixClient extends EventEmitter { }; } + /** + * Get the nearest event to a given timestamp, either forwards or backwards. + * @param roomId The room ID to get the context in. + * @param ts The event ID to get the context of. + * @param dir The maximum number of events to return on either side of the event. + * @returns The ID and origin server timestamp of the event. + */ + @timedMatrixClientFunctionCall() + public async getEventNearestToTimestamp(roomId: string, ts: number, dir: "f"|"b"): Promise<{event_id: string, origin_server_ts: number}> { + return await this.doRequest("GET", "/_matrix/client/v1/rooms/" + encodeURIComponent(roomId) + "/timestamp_to_event", { ts, dir }); + } + /** * Gets the profile for a given user * @param {string} userId the user ID to lookup @@ -1968,6 +1982,85 @@ export class MatrixClient extends EventEmitter { }); } + /** + * Get information about the latest room key backup version. + * @returns {Promise} Resolves to the retrieved key backup info, + * or null if there is no existing backup. + */ + public async getKeyBackupVersion(): Promise { + try { + return await this.doRequest("GET", "/_matrix/client/v3/room_keys/version"); + } catch (e) { + if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") { + return null; + } else { + throw e; + } + } + } + + /** + * Create a new room key backup. + * @param {IKeyBackupInfoUnsigned} info The properties of the key backup to create, + * with its auth_data left unsigned. + * @returns {Promise} Resolves to the version id of the new backup. + */ + @requiresCrypto() + public async signAndCreateKeyBackupVersion(info: IKeyBackupInfoUnsigned): Promise { + const data: IKeyBackupInfo = { + ...info, + auth_data: { + ...info.auth_data, + signatures: await this.crypto.sign(info), + }, + }; + return this.doRequest("POST", "/_matrix/client/v3/room_keys/version", null, data); + } + + /** + * Update an existing room key backup. + * @param {KeyBackupVersion} version The key backup version to update. + * @param {IKeyBackupInfoUpdate} info The properties of the key backup to be applied. + * @returns {Promise} Resolves when complete. + */ + @requiresCrypto() + public updateKeyBackupVersion(version: KeyBackupVersion, info: IKeyBackupInfoUpdate): Promise { + const data = { + ...info, + signatures: this.crypto.sign(info), + }; + return this.doRequest("PUT", `/_matrix/client/v3/room_keys/version/${version}`, null, data); + } + + /** + * Enable backing up of room keys. + * @param {IKeyBackupInfoRetrieved} info The configuration for key backup behaviour, + * as returned by {@link getKeyBackupVersion}. + * @returns {Promise} Resolves when complete. + */ + @requiresCrypto() + public enableKeyBackup(info: IKeyBackupInfoRetrieved): Promise { + return this.crypto.enableKeyBackup(info); + } + + /** + * Disable backing up of room keys. + */ + public disableKeyBackup(): Promise { + return this.crypto?.disableKeyBackup() ?? Promise.resolve(); + } + + /** + * Exports a set of keys for a given session. + * @param roomId The room ID for the session. + * @param sessionId The session ID. + * @returns An array of session keys. + */ + @requiresCrypto() + public exportRoomKeysForSession(roomId: string, sessionId: string) { + return this.crypto.exportRoomKeysForSession(roomId, sessionId); + } + /** * Get relations for a given event. * @param {string} roomId The room ID to for the given event. diff --git a/src/SynapseAdminApis.ts b/src/SynapseAdminApis.ts index 84bcdecd..fc4b8ebd 100644 --- a/src/SynapseAdminApis.ts +++ b/src/SynapseAdminApis.ts @@ -479,4 +479,16 @@ export class SynapseAdminApis { public async makeRoomAdmin(roomId: string, userId?: string): Promise { return this.client.doRequest("POST", `/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/make_room_admin`, {}, { user_id: userId }); } + + /** + * Get the nearest event to a given timestamp, either forwards or backwards. You do not + * need to be joined to the room to retrieve this information. + * @param roomId The room ID to get the context in. + * @param ts The event ID to get the context of. + * @param dir The maximum number of events to return on either side of the event. + * @returns The ID and origin server timestamp of the event. + */ + public async getEventNearestToTimestamp(roomId: string, ts: number, dir: "f"|"b"): Promise<{event_id: string, origin_server_ts: number}> { + return await this.client.doRequest("GET", "/_synapse/admin/v1/rooms/" + encodeURIComponent(roomId) + "/timestamp_to_event", { ts, dir }); + } } diff --git a/src/appservice/Appservice.ts b/src/appservice/Appservice.ts index fb87cfe7..fa162818 100644 --- a/src/appservice/Appservice.ts +++ b/src/appservice/Appservice.ts @@ -236,7 +236,12 @@ export class Appservice extends EventEmitter { private appServer: any; private intentsCache: LRU.LRUCache; private eventProcessors: { [eventType: string]: IPreprocessor[] } = {}; - private pendingTransactions: { [txnId: string]: Promise } = {}; + private pendingTransactions = new Map>(); + + /** + * A cache of intents for the purposes of decrypting rooms + */ + private cryptoClientForRoomId: LRU.LRUCache; /** * Creates a new application service. @@ -248,14 +253,19 @@ export class Appservice extends EventEmitter { options.joinStrategy = new AppserviceJoinRoomStrategy(options.joinStrategy, this); if (!options.intentOptions) options.intentOptions = {}; - if (!options.intentOptions.maxAgeMs) options.intentOptions.maxAgeMs = 60 * 60 * 1000; - if (!options.intentOptions.maxCached) options.intentOptions.maxCached = 10000; + if (options.intentOptions.maxAgeMs === undefined) options.intentOptions.maxAgeMs = 60 * 60 * 1000; + if (options.intentOptions.maxCached === undefined) options.intentOptions.maxCached = 10000; this.intentsCache = new LRU.LRUCache({ max: options.intentOptions.maxCached, ttl: options.intentOptions.maxAgeMs, }); + this.cryptoClientForRoomId = new LRU.LRUCache({ + max: options.intentOptions.maxCached, + ttl: options.intentOptions.maxAgeMs, + }); + this.registration = options.registration; // If protocol is not defined, define an empty array. @@ -454,6 +464,7 @@ export class Appservice extends EventEmitter { if (!intent) { intent = new Intent(this.options, userId, this); this.intentsCache.set(userId, intent); + this.emit("intent.new", intent); if (this.options.intentOptions.encryption) { intent.enableEncryption().catch(e => { LogService.error("Appservice", `Failed to set up crypto on intent ${userId}`, e); @@ -633,9 +644,7 @@ export class Appservice extends EventEmitter { const botDomain = new UserID(this.botUserId).domain; if (domain !== botDomain) return; // can't be impersonated, so don't try - // Update the target intent's joined rooms (fixes transition errors with the cache, like join->kick->join) const intent = this.getIntentForUserId(event['state_key']); - await intent.refreshJoinedRooms(); const targetMembership = event["content"]["membership"]; if (targetMembership === "join") { @@ -659,248 +668,297 @@ export class Appservice extends EventEmitter { return providedToken === this.registration.hs_token; } - private async onTransaction(req: express.Request, res: express.Response): Promise { - if (!this.isAuthed(req)) { - res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); - return; + private async decryptAppserviceEvent(roomId: string, encrypted: EncryptedRoomEvent): ReturnType { + const existingClient = this.cryptoClientForRoomId.get(roomId); + const decryptFn = async (client: MatrixClient) => { + // Also fetches state in order to decrypt room. We should throw if the client is confused. + if (!await client.crypto.isRoomEncrypted(roomId)) { + throw new Error("Client detected that the room is not encrypted."); + } + let event = (await client.crypto.decryptRoomEvent(encrypted, roomId)).raw; + event = await this.processEvent(event); + this.cryptoClientForRoomId.set(roomId, client); + // For logging purposes: show that the event was decrypted + LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); + return event; + }; + // 1. Try cached client + if (existingClient) { + try { + return await decryptFn(existingClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via cached client ${await existingClient.getUserId()}`, error); + LogService.warn("Appservice", `Cached client was not able to decrypt ${roomId} ${encrypted.eventId} - trying other intents`); + } } - - if (typeof (req.body) !== "object") { - res.status(400).json({ errcode: "BAD_REQUEST", error: "Expected JSON" }); - return; + this.cryptoClientForRoomId.delete(roomId); + // 2. Try the bot client + if (this.botClient.crypto?.isReady) { + try { + return await decryptFn(this.botClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via bot client`, error); + LogService.warn("Appservice", `Bot client was not able to decrypt ${roomId} ${encrypted.eventId} - trying other intents`); + } } - if (!req.body["events"] || !Array.isArray(req.body["events"])) { - res.status(400).json({ errcode: "BAD_REQUEST", error: "Invalid JSON: expected events" }); - return; + const userIdsInRoom = (await this.botClient.getJoinedRoomMembers(roomId)).filter(u => this.isNamespacedUser(u)); + // 3. Try existing clients with crypto enabled. + for (const intentCacheEntry of this.intentsCache.entries()) { + const [userId, intent] = intentCacheEntry as [string, Intent]; + if (!userIdsInRoom.includes(userId)) { + // Not in this room. + continue; + } + // Is this client crypto enabled? + if (!intent.underlyingClient.crypto?.isReady) { + continue; + } + try { + return await decryptFn(intent.underlyingClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via ${userId}`, error); + LogService.warn("Appservice", `Existing encrypted client was not able to decrypt ${roomId} ${encrypted.eventId} - trying other intents`); + } } - const txnId = req.params["txnId"]; - - if (await Promise.resolve(this.storage.isTransactionCompleted(txnId))) { - res.status(200).json({}); - return; + // 4. Try to enable crypto on any client to decrypt it. + // We deliberately do not enable crypto on every client for performance reasons. + const userInRoom = this.intentsCache.find((intent, userId) => !intent.underlyingClient.crypto?.isReady && userIdsInRoom.includes(userId)); + if (!userInRoom) { + throw Error('No users in room, cannot decrypt'); + } + try { + await userInRoom.enableEncryption(); + return await decryptFn(userInRoom.underlyingClient); + } catch (error) { + LogService.debug("Appservice", `Failed to decrypt via random user ${userInRoom.userId}`, error); + throw new Error("Unable to decrypt event", { cause: error }); } + } - if (this.pendingTransactions[txnId]) { - try { - await this.pendingTransactions[txnId]; - res.status(200).json({}); - } catch (e) { - LogService.error("Appservice", e); - res.status(500).json({}); - } + private async handleTransaction(txnId: string, body: Record) { + // Process all the crypto stuff first to ensure that future transactions (if not this one) + // will decrypt successfully. We start with EDUs because we need structures to put counts + // and such into in a later stage, and EDUs are independent of crypto. + if (await this.storage.isTransactionCompleted(txnId)) { + // Duplicate. return; } - LogService.info("Appservice", "Processing transaction " + txnId); - // eslint-disable-next-line no-async-promise-executor - this.pendingTransactions[txnId] = new Promise(async (resolve) => { - // Process all the crypto stuff first to ensure that future transactions (if not this one) - // will decrypt successfully. We start with EDUs because we need structures to put counts - // and such into in a later stage, and EDUs are independent of crypto. - - const byUserId: { - [userId: string]: { - counts: Record; - toDevice: any[]; - unusedFallbacks: OTKAlgorithm[]; - }; - } = {}; - - const orderedEdus = []; - if (Array.isArray(req.body["de.sorunome.msc2409.to_device"])) { - orderedEdus.push(...req.body["de.sorunome.msc2409.to_device"].map(e => ({ - ...e, - unsigned: { - ...e['unsigned'], - [EDU_ANNOTATION_KEY]: EduAnnotation.ToDevice, - }, - }))); - } - if (Array.isArray(req.body["de.sorunome.msc2409.ephemeral"])) { - orderedEdus.push(...req.body["de.sorunome.msc2409.ephemeral"].map(e => ({ - ...e, - unsigned: { - ...e['unsigned'], - [EDU_ANNOTATION_KEY]: EduAnnotation.Ephemeral, - }, - }))); - } - for (let event of orderedEdus) { - if (event['edu_type']) event['type'] = event['edu_type']; // handle property change during MSC2409's course + const byUserId: { + [userId: string]: { + counts: Record; + toDevice: any[]; + unusedFallbacks: OTKAlgorithm[]; + }; + } = {}; + + const orderedEdus = []; + if (Array.isArray(body["de.sorunome.msc2409.to_device"])) { + orderedEdus.push(...body["de.sorunome.msc2409.to_device"].map(e => ({ + ...e, + unsigned: { + ...e['unsigned'], + [EDU_ANNOTATION_KEY]: EduAnnotation.ToDevice, + }, + }))); + } + if (Array.isArray(body["de.sorunome.msc2409.ephemeral"])) { + orderedEdus.push(...body["de.sorunome.msc2409.ephemeral"].map(e => ({ + ...e, + unsigned: { + ...e['unsigned'], + [EDU_ANNOTATION_KEY]: EduAnnotation.Ephemeral, + }, + }))); + } + for (let event of orderedEdus) { + if (event['edu_type']) event['type'] = event['edu_type']; // handle property change during MSC2409's course - LogService.info("Appservice", `Processing ${event['unsigned'][EDU_ANNOTATION_KEY]} event of type ${event["type"]}`); - event = await this.processEphemeralEvent(event); + LogService.info("Appservice", `Processing ${event['unsigned'][EDU_ANNOTATION_KEY]} event of type ${event["type"]}`); + event = await this.processEphemeralEvent(event); - // These events aren't tied to rooms, so just emit them generically - this.emit("ephemeral.event", event); + // These events aren't tied to rooms, so just emit them generically + this.emit("ephemeral.event", event); - if (this.cryptoStorage && (event["type"] === "m.room.encrypted" || event.unsigned?.[EDU_ANNOTATION_KEY] === EduAnnotation.ToDevice)) { - const toUser = event["to_user_id"]; - const intent = this.getIntentForUserId(toUser); - await intent.enableEncryption(); + if (this.cryptoStorage && (event["type"] === "m.room.encrypted" || event.unsigned?.[EDU_ANNOTATION_KEY] === EduAnnotation.ToDevice)) { + const toUser = event["to_user_id"]; + const intent = this.getIntentForUserId(toUser); + await intent.enableEncryption(); - if (!byUserId[toUser]) byUserId[toUser] = { counts: null, toDevice: null, unusedFallbacks: null }; - if (!byUserId[toUser].toDevice) byUserId[toUser].toDevice = []; - byUserId[toUser].toDevice.push(event); - } + if (!byUserId[toUser]) byUserId[toUser] = { counts: null, toDevice: null, unusedFallbacks: null }; + if (!byUserId[toUser].toDevice) byUserId[toUser].toDevice = []; + byUserId[toUser].toDevice.push(event); } + } - const deviceLists: { changed: string[], removed: string[] } = req.body["org.matrix.msc3202.device_lists"] ?? { - changed: [], - removed: [], - }; + const deviceLists = body["org.matrix.msc3202.device_lists"] as { changed: string[], removed: string[] } ?? { + changed: [], + removed: [], + }; - if (!deviceLists.changed) deviceLists.changed = []; - if (!deviceLists.removed) deviceLists.removed = []; + if (!deviceLists.changed) deviceLists.changed = []; + if (!deviceLists.removed) deviceLists.removed = []; - if (deviceLists.changed.length || deviceLists.removed.length) { - this.emit("device_lists", deviceLists); - } + if (deviceLists.changed.length || deviceLists.removed.length) { + this.emit("device_lists", deviceLists); + } - let otks = req.body["org.matrix.msc3202.device_one_time_keys_count"]; - const otks2 = req.body["org.matrix.msc3202.device_one_time_key_counts"]; - if (otks2 && !otks) { - LogService.warn( - "Appservice", - "Your homeserver is using an outdated field (device_one_time_key_counts) to talk to this appservice. " + - "If you're using Synapse, please upgrade to 1.73.0 or higher.", - ); - otks = otks2; - } - if (otks) { - this.emit("otk.counts", otks); - } - if (otks && this.cryptoStorage) { - for (const userId of Object.keys(otks)) { - const intent = this.getIntentForUserId(userId); - await intent.enableEncryption(); - const otksForUser = otks[userId][intent.underlyingClient.crypto.clientDeviceId]; - if (otksForUser) { - if (!byUserId[userId]) { - byUserId[userId] = { - counts: null, - toDevice: null, - unusedFallbacks: null, - }; - } - byUserId[userId].counts = otksForUser; + let otks = body["org.matrix.msc3202.device_one_time_keys_count"]; + const otks2 = body["org.matrix.msc3202.device_one_time_key_counts"]; + if (otks2 && !otks) { + LogService.warn( + "Appservice", + "Your homeserver is using an outdated field (device_one_time_key_counts) to talk to this appservice. " + + "If you're using Synapse, please upgrade to 1.73.0 or higher.", + ); + otks = otks2; + } + if (otks) { + this.emit("otk.counts", otks); + } + if (otks && this.cryptoStorage) { + for (const userId of Object.keys(otks)) { + const intent = this.getIntentForUserId(userId); + await intent.enableEncryption(); + const otksForUser = otks[userId][intent.underlyingClient.crypto.clientDeviceId]; + if (otksForUser) { + if (!byUserId[userId]) { + byUserId[userId] = { + counts: null, + toDevice: null, + unusedFallbacks: null, + }; } + byUserId[userId].counts = otksForUser; } } + } - const fallbacks = req.body["org.matrix.msc3202.device_unused_fallback_key_types"]; - if (fallbacks) { - this.emit("otk.unused_fallback_keys", fallbacks); - } - if (fallbacks && this.cryptoStorage) { - for (const userId of Object.keys(fallbacks)) { - const intent = this.getIntentForUserId(userId); - await intent.enableEncryption(); - const fallbacksForUser = fallbacks[userId][intent.underlyingClient.crypto.clientDeviceId]; - if (Array.isArray(fallbacksForUser) && !fallbacksForUser.includes(OTKAlgorithm.Signed)) { - if (!byUserId[userId]) { - byUserId[userId] = { - counts: null, - toDevice: null, - unusedFallbacks: null, - }; - } - byUserId[userId].unusedFallbacks = fallbacksForUser; + const fallbacks = body["org.matrix.msc3202.device_unused_fallback_key_types"]; + if (fallbacks) { + this.emit("otk.unused_fallback_keys", fallbacks); + } + if (fallbacks && this.cryptoStorage) { + for (const userId of Object.keys(fallbacks)) { + const intent = this.getIntentForUserId(userId); + await intent.enableEncryption(); + const fallbacksForUser = fallbacks[userId][intent.underlyingClient.crypto.clientDeviceId]; + if (Array.isArray(fallbacksForUser) && !fallbacksForUser.includes(OTKAlgorithm.Signed)) { + if (!byUserId[userId]) { + byUserId[userId] = { + counts: null, + toDevice: null, + unusedFallbacks: null, + }; } + byUserId[userId].unusedFallbacks = fallbacksForUser; } } + } - if (this.cryptoStorage) { - for (const userId of Object.keys(byUserId)) { - const intent = this.getIntentForUserId(userId); - await intent.enableEncryption(); - const info = byUserId[userId]; - const userStorage = this.storage.storageForUser(userId); + if (this.cryptoStorage) { + for (const userId of Object.keys(byUserId)) { + const intent = this.getIntentForUserId(userId); + await intent.enableEncryption(); + const info = byUserId[userId]; + const userStorage = this.storage.storageForUser(userId); - if (!info.toDevice) info.toDevice = []; - if (!info.unusedFallbacks) info.unusedFallbacks = JSON.parse(await userStorage.readValue("last_unused_fallbacks") || "[]"); - if (!info.counts) info.counts = JSON.parse(await userStorage.readValue("last_counts") || "{}"); + if (!info.toDevice) info.toDevice = []; + if (!info.unusedFallbacks) info.unusedFallbacks = JSON.parse(await userStorage.readValue("last_unused_fallbacks") || "[]"); + if (!info.counts) info.counts = JSON.parse(await userStorage.readValue("last_counts") || "{}"); - LogService.info("Appservice", `Updating crypto state for ${userId}`); - await intent.underlyingClient.crypto.updateSyncData(info.toDevice, info.counts, info.unusedFallbacks, deviceLists.changed, deviceLists.removed); - } + LogService.info("Appservice", `Updating crypto state for ${userId}`); + await intent.underlyingClient.crypto.updateSyncData(info.toDevice, info.counts, info.unusedFallbacks, deviceLists.changed, deviceLists.removed); } + } - for (let event of req.body["events"]) { - LogService.info("Appservice", `Processing event of type ${event["type"]}`); - event = await this.processEvent(event); - if (event['type'] === 'm.room.encrypted') { - this.emit("room.encrypted_event", event["room_id"], event); - if (this.cryptoStorage) { - try { - const encrypted = new EncryptedRoomEvent(event); - const roomId = event['room_id']; - try { - event = (await this.botClient.crypto.decryptRoomEvent(encrypted, roomId)).raw; - event = await this.processEvent(event); - this.emit("room.decrypted_event", roomId, event); - - // For logging purposes: show that the event was decrypted - LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); - } catch (e1) { - LogService.warn("Appservice", `Bot client was not able to decrypt ${roomId} ${event['event_id']} - trying other intents`); - - let tryUserId: string; - try { - // TODO: This could be more efficient - const userIdsInRoom = await this.botClient.getJoinedRoomMembers(roomId); - tryUserId = userIdsInRoom.find(u => this.isNamespacedUser(u)); - } catch (e) { - LogService.error("Appservice", "Failed to get members of room - cannot decrypt message"); - } - - if (tryUserId) { - const intent = this.getIntentForUserId(tryUserId); - - event = (await intent.underlyingClient.crypto.decryptRoomEvent(encrypted, roomId)).raw; - event = await this.processEvent(event); - this.emit("room.decrypted_event", roomId, event); - - // For logging purposes: show that the event was decrypted - LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); - } else { - // noinspection ExceptionCaughtLocallyJS - throw e1; - } - } - } catch (e) { - LogService.error("Appservice", `Decryption error on ${event['room_id']} ${event['event_id']}`, e); - this.emit("room.failed_decryption", event['room_id'], event, e); - } + for (let event of body.events as any[]) { + LogService.info("Appservice", `Processing event of type ${event["type"]}`); + event = await this.processEvent(event); + if (event['type'] === 'm.room.encrypted') { + this.emit("room.encrypted_event", event["room_id"], event); + if (this.cryptoStorage) { + try { + const encrypted = new EncryptedRoomEvent(event); + const roomId = event['room_id']; + event = await this.decryptAppserviceEvent(roomId, encrypted); + this.emit("room.decrypted_event", roomId, event); + + // For logging purposes: show that the event was decrypted + LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); + } catch (e) { + LogService.error("Appservice", `Decryption error on ${event['room_id']} ${event['event_id']}`, e); + this.emit("room.failed_decryption", event['room_id'], event, e); } } - this.emit("room.event", event["room_id"], event); - if (event['type'] === 'm.room.message') { - this.emit("room.message", event["room_id"], event); - } - if (event['type'] === 'm.room.member' && this.isNamespacedUser(event['state_key'])) { - await this.processMembershipEvent(event); - } - if (event['type'] === 'm.room.tombstone' && event['state_key'] === '') { - this.emit("room.archived", event['room_id'], event); - } - if (event['type'] === 'm.room.create' && event['state_key'] === '' && event['content'] && event['content']['predecessor']) { - this.emit("room.upgraded", event['room_id'], event); - } } + this.emit("room.event", event["room_id"], event); + if (event['type'] === 'm.room.message') { + this.emit("room.message", event["room_id"], event); + } + if (event['type'] === 'm.room.member' && this.isNamespacedUser(event['state_key'])) { + await this.processMembershipEvent(event); + } + if (event['type'] === 'm.room.tombstone' && event['state_key'] === '') { + this.emit("room.archived", event['room_id'], event); + } + if (event['type'] === 'm.room.create' && event['state_key'] === '' && event['content'] && event['content']['predecessor']) { + this.emit("room.upgraded", event['room_id'], event); + } + } + } - resolve(); - }); + private async onTransaction(req: express.Request, res: express.Response): Promise { + if (!this.isAuthed(req)) { + res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); + return; + } + + if (typeof (req.body) !== "object") { + res.status(400).json({ errcode: "BAD_REQUEST", error: "Expected JSON" }); + return; + } + + if (!req.body["events"] || !Array.isArray(req.body["events"])) { + res.status(400).json({ errcode: "BAD_REQUEST", error: "Invalid JSON: expected events" }); + return; + } + + const { txnId } = req.params; + + if (this.pendingTransactions.has(txnId)) { + // The homeserver has retried a transaction while we're still handling it. + try { + await this.pendingTransactions.get(txnId); + res.status(200).json({}); + } catch (e) { + LogService.error("Appservice", e); + res.status(500).json({}); + } + return; + } + + LogService.info("Appservice", `Processing transaction ${txnId}`); + const txnHandler = this.handleTransaction(txnId, req.body); + this.pendingTransactions.set(txnId, txnHandler); try { - await this.pendingTransactions[txnId]; - await Promise.resolve(this.storage.setTransactionCompleted(txnId)); + await txnHandler; + try { + await this.storage.setTransactionCompleted(txnId); + } catch (ex) { + // Not fatal for the transaction since we *did* process it, but we should + // warn loudly. + LogService.warn("Appservice", "Failed to store completed transaction", ex); + } res.status(200).json({}); } catch (e) { LogService.error("Appservice", e); res.status(500).json({}); + } finally { + this.pendingTransactions.delete(txnId); } } diff --git a/src/appservice/Intent.ts b/src/appservice/Intent.ts index 961d1752..38c7d1ce 100644 --- a/src/appservice/Intent.ts +++ b/src/appservice/Intent.ts @@ -34,7 +34,6 @@ export class Intent { private client: MatrixClient; private unstableApisInstance: UnstableAppserviceApis; - private knownJoinedRooms: string[] = []; private cryptoSetupPromise: Promise; /** @@ -98,10 +97,12 @@ export class Intent { /** * Sets up crypto on the client if it hasn't already been set up. + * @param providedDeviceId Optional device ID. If given, this will used instead of trying to + * masquerade as the first non-key enabled device. * @returns {Promise} Resolves when complete. */ @timedIntentFunctionCall() - public async enableEncryption(): Promise { + public async enableEncryption(providedDeviceId?: string): Promise { if (!this.cryptoSetupPromise) { // eslint-disable-next-line no-async-promise-executor this.cryptoSetupPromise = new Promise(async (resolve, reject) => { @@ -117,24 +118,38 @@ export class Intent { throw new Error("Failed to create crypto store"); } - // Try to impersonate a device ID - const ownDevices = await this.client.getOwnDevices(); let deviceId = await cryptoStore.getDeviceId(); - if (!deviceId || !ownDevices.some(d => d.device_id === deviceId)) { - const deviceKeys = await this.client.getUserDevices([this.userId]); - const userDeviceKeys = deviceKeys.device_keys[this.userId]; - if (userDeviceKeys) { - // We really should be validating signatures here, but we're actively looking - // for devices without keys to impersonate, so it should be fine. In theory, - // those devices won't even be present but we're cautious. - const devicesWithKeys = Array.from(Object.entries(userDeviceKeys)) - .filter(d => d[0] === d[1].device_id && !!d[1].keys?.[`${DeviceKeyAlgorithm.Curve25519}:${d[1].device_id}`]) - .map(t => t[0]); // grab device ID from tuple - deviceId = ownDevices.find(d => !devicesWithKeys.includes(d.device_id))?.device_id; + if (!providedDeviceId) { + // Try to impersonate a device ID + const ownDevices = await this.client.getOwnDevices(); + let deviceId = await cryptoStore.getDeviceId(); + if (!deviceId || !ownDevices.some(d => d.device_id === deviceId)) { + const deviceKeys = await this.client.getUserDevices([this.userId]); + const userDeviceKeys = deviceKeys.device_keys[this.userId]; + if (userDeviceKeys) { + // We really should be validating signatures here, but we're actively looking + // for devices without keys to impersonate, so it should be fine. In theory, + // those devices won't even be present but we're cautious. + const devicesWithKeys = Array.from(Object.entries(userDeviceKeys)) + .filter(d => d[0] === d[1].device_id && !!d[1].keys?.[`${DeviceKeyAlgorithm.Curve25519}:${d[1].device_id}`]) + .map(t => t[0]); // grab device ID from tuple + deviceId = ownDevices.find(d => !devicesWithKeys.includes(d.device_id))?.device_id; + } } + } else { + if (deviceId && deviceId !== providedDeviceId) { + LogService.warn(`Storage already configured with an existing device ${deviceId}. Old storage will be cleared.`); + } + deviceId = providedDeviceId; } let prepared = false; + if (deviceId) { + const cryptoStore = this.cryptoStorage?.storageForUser(this.userId); + const existingDeviceId = await cryptoStore.getDeviceId(); + if (existingDeviceId && existingDeviceId !== deviceId) { + LogService.warn("Intent", `Device ID has changed for user ${this.userId} from ${existingDeviceId} to ${deviceId}`); + } this.makeClient(true); this.client.impersonateUserId(this.userId, deviceId); @@ -169,10 +184,9 @@ export class Intent { } // Now set up crypto - await this.client.crypto.prepare(await this.refreshJoinedRooms()); + await this.client.crypto.prepare(); this.appservice.on("room.event", (roomId, event) => { - if (!this.knownJoinedRooms.includes(roomId)) return; this.client.crypto.onRoomEvent(roomId, event); }); @@ -186,16 +200,14 @@ export class Intent { } /** - * Gets the joined rooms for the intent. Note that by working around - * the intent to join rooms may yield inaccurate results. + * Gets the joined rooms for the intent. * @returns {Promise} Resolves to an array of room IDs where * the intent is joined. */ @timedIntentFunctionCall() public async getJoinedRooms(): Promise { await this.ensureRegistered(); - if (this.knownJoinedRooms.length === 0) await this.refreshJoinedRooms(); - return this.knownJoinedRooms.map(r => r); // clone + return await this.client.getJoinedRooms(); } /** @@ -207,10 +219,7 @@ export class Intent { @timedIntentFunctionCall() public async leaveRoom(roomId: string, reason?: string): Promise { await this.ensureRegistered(); - return this.client.leaveRoom(roomId, reason).then(async () => { - // Recalculate joined rooms now that we've left a room - await this.refreshJoinedRooms(); - }); + return this.client.leaveRoom(roomId, reason); } /** @@ -221,11 +230,7 @@ export class Intent { @timedIntentFunctionCall() public async joinRoom(roomIdOrAlias: string): Promise { await this.ensureRegistered(); - return this.client.joinRoom(roomIdOrAlias).then(async roomId => { - // Recalculate joined rooms now that we've joined a room - await this.refreshJoinedRooms(); - return roomId; - }); + return this.client.joinRoom(roomIdOrAlias); } /** @@ -267,35 +272,23 @@ export class Intent { * Ensures the user is joined to the given room * @param {string} roomId The room ID to join * @returns {Promise} Resolves when complete + * @deprecated Use `joinRoom()` instead */ @timedIntentFunctionCall() public async ensureJoined(roomId: string) { - if (this.knownJoinedRooms.indexOf(roomId) !== -1) { - return; - } - - await this.refreshJoinedRooms(); - - if (this.knownJoinedRooms.indexOf(roomId) !== -1) { - return; - } - const returnedRoomId = await this.client.joinRoom(roomId); - if (!this.knownJoinedRooms.includes(returnedRoomId)) { - this.knownJoinedRooms.push(returnedRoomId); - } return returnedRoomId; } /** * Refreshes which rooms the user is joined to, potentially saving time on * calls like ensureJoined() + * @deprecated There is no longer a joined rooms cache, use `getJoinedRooms()` instead * @returns {Promise} Resolves to the joined room IDs for the user. */ @timedIntentFunctionCall() public async refreshJoinedRooms(): Promise { - this.knownJoinedRooms = await this.client.getJoinedRooms(); - return this.knownJoinedRooms.map(r => r); // clone + return await this.getJoinedRooms(); } /** diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 74ddfb50..be8b789d 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -26,6 +26,7 @@ import { EncryptedFile } from "../models/events/MessageEvent"; import { RustSdkCryptoStorageProvider } from "../storage/RustSdkCryptoStorageProvider"; import { RustEngine, SYNC_LOCK_NAME } from "./RustEngine"; import { MembershipEvent } from "../models/events/MembershipEvent"; +import { IKeyBackupInfoRetrieved } from "../models/KeyBackup"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly @@ -67,29 +68,29 @@ export class CryptoClient { * Prepares the crypto client for usage. * @param {string[]} roomIds The room IDs the MatrixClient is joined to. */ - public async prepare(roomIds: string[]) { - await this.roomTracker.prepare(roomIds); - + public async prepare() { if (this.ready) return; // stop re-preparing here const storedDeviceId = await this.client.cryptoStore.getDeviceId(); - if (storedDeviceId) { - this.deviceId = storedDeviceId; - } else { - const deviceId = (await this.client.getWhoAmI())['device_id']; - if (!deviceId) { - throw new Error("Encryption not possible: server not revealing device ID"); - } - this.deviceId = deviceId; - await this.client.cryptoStore.setDeviceId(this.deviceId); + const { user_id: userId, device_id: deviceId } = (await this.client.getWhoAmI()); + + if (!deviceId) { + throw new Error("Encryption not possible: server not revealing device ID"); } - LogService.debug("CryptoClient", "Starting with device ID:", this.deviceId); + const storagePath = await this.storage.getMachineStoragePath(deviceId); + + if (storedDeviceId !== deviceId) { + this.client.cryptoStore.setDeviceId(deviceId); + } + this.deviceId = deviceId; + + LogService.debug("CryptoClient", `Starting ${userId} with device ID:`, this.deviceId); const machine = await OlmMachine.initialize( - new UserId(await this.client.getUserId()), + new UserId(userId), new DeviceId(this.deviceId), - this.storage.storagePath, "", + storagePath, "", this.storage.storageType, ); this.engine = new RustEngine(machine, this.client); @@ -108,7 +109,7 @@ export class CryptoClient { * @param roomId The room ID. * @param event The event. */ - public async onRoomEvent(roomId: string, event: any) { + public async onRoomEvent(roomId: string, event: any): Promise { await this.roomTracker.onRoomEvent(roomId, event); if (typeof event['state_key'] !== 'string') return; if (event['type'] === 'm.room.member') { @@ -116,8 +117,10 @@ export class CryptoClient { 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)); + return this.client.getRoomMembers(roomId, null, ['join', 'invite']).then( + members => this.engine.addTrackedUsers(members.map(e => e.membershipFor)), + e => void LogService.warn("CryptoClient", `Unable to get members of room ${roomId}`), + ); } } @@ -134,6 +137,16 @@ export class CryptoClient { } } + /** + * Exports a set of keys for a given session. + * @param roomId The room ID for the session. + * @param sessionId The session ID. + * @returns An array of session keys. + */ + public async exportRoomKeysForSession(roomId: string, sessionId: string) { + return this.engine.exportRoomKeysForSession(roomId, sessionId); + } + /** * Checks if a room is encrypted. * @param {string} roomId The room ID to check. @@ -168,12 +181,13 @@ export class CryptoClient { 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)) { - for (const msg of decryptedToDeviceMessages) { + const syncResp = JSON.parse(await this.engine.machine.receiveSyncChanges(deviceMessages, deviceLists, otkCounts, unusedFallbackKeyAlgs)); + if (Array.isArray(syncResp) && syncResp.length === 2 && Array.isArray(syncResp[0])) { + for (const msg of syncResp[0] as IToDeviceMessage[]) { this.client.emit("to_device.decrypted", msg); } + } else { + LogService.error("CryptoClient", "OlmMachine.receiveSyncChanges did not return an expected value of [to-device events, room key changes]"); } await this.engine.run(); @@ -284,4 +298,36 @@ export class CryptoClient { const decrypted = Attachment.decrypt(encrypted); return Buffer.from(decrypted); } + + /** + * Enable backing up of room keys. + * @param {IKeyBackupInfoRetrieved} info The configuration for key backup behaviour, + * as returned by {@link MatrixClient#getKeyBackupVersion}. + * @returns {Promise} Resolves once backups have been enabled. + */ + @requiresReady() + public async enableKeyBackup(info: IKeyBackupInfoRetrieved): Promise { + if (!this.engine.isBackupEnabled()) { + // Only add the listener if we didn't add it already + this.client.on("to_device.decrypted", this.onToDeviceMessage); + } + await this.engine.enableKeyBackup(info); + // Back up any pending keys now, but asynchronously + void this.engine.backupRoomKeys(); + } + + /** + * Disable backing up of room keys. + */ + @requiresReady() + public async disableKeyBackup(): Promise { + await this.engine.disableKeyBackup(); + this.client.removeListener("to_device.decrypted", this.onToDeviceMessage); + } + + private readonly onToDeviceMessage = (msg: IToDeviceMessage): void => { + if (msg.type === "m.room_key") { + this.engine.backupRoomKeys(); + } + }; } diff --git a/src/e2ee/RoomTracker.ts b/src/e2ee/RoomTracker.ts index 98ed5bda..9b3c0e82 100644 --- a/src/e2ee/RoomTracker.ts +++ b/src/e2ee/RoomTracker.ts @@ -1,4 +1,5 @@ import { MatrixClient } from "../MatrixClient"; +import { MatrixError } from "../models/MatrixError"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import { ICryptoRoomInformation } from "./ICryptoRoomInformation"; @@ -33,16 +34,6 @@ export class RoomTracker { } } - /** - * Prepares the room tracker to track the given rooms. - * @param {string[]} roomIds The room IDs to track. This should be the joined rooms set. - */ - public async prepare(roomIds: string[]) { - for (const roomId of roomIds) { - await this.queueRoomCheck(roomId); - } - } - /** * Queues a room check for the tracker. If the room needs an update to the store, an * update will be made. @@ -61,7 +52,11 @@ export class RoomTracker { encEvent = await this.client.getRoomStateEvent(roomId, "m.room.encryption", ""); encEvent.algorithm = encEvent.algorithm ?? 'UNKNOWN'; } catch (e) { - return; // failure == no encryption + if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") { + encEvent = {}; + } else { + return; // Other failures should not be cached. + } } // Pick out the history visibility setting too diff --git a/src/e2ee/RustEngine.ts b/src/e2ee/RustEngine.ts index c9eae9c8..d9466f78 100644 --- a/src/e2ee/RustEngine.ts +++ b/src/e2ee/RustEngine.ts @@ -10,13 +10,17 @@ import { KeysUploadRequest, KeysQueryRequest, ToDeviceRequest, + KeysBackupRequest, } from "@matrix-org/matrix-sdk-crypto-nodejs"; import * as AsyncLock from "async-lock"; import { MatrixClient } from "../MatrixClient"; +import { extractRequestError, LogService } from "../logging/LogService"; import { ICryptoRoomInformation } from "./ICryptoRoomInformation"; import { EncryptionAlgorithm } from "../models/Crypto"; import { EncryptionEvent } from "../models/events/EncryptionEvent"; +import { ICurve25519AuthData, IKeyBackupInfoRetrieved, IMegolmSessionDataExport, KeyBackupEncryptionAlgorithm, KeyBackupVersion } from "../models/KeyBackup"; +import { Membership } from "../models/events/MembershipEvent"; /** * @internal @@ -29,6 +33,17 @@ export const SYNC_LOCK_NAME = "sync"; export class RustEngine { public readonly lock = new AsyncLock(); + public readonly trackedUsersToAdd = new Set(); + public addTrackedUsersPromise: Promise|undefined; + + private keyBackupVersion: KeyBackupVersion|undefined; + private keyBackupWaiter = Promise.resolve(); + + private backupEnabled = false; + public isBackupEnabled() { + return this.backupEnabled; + } + public constructor(public readonly machine: OlmMachine, private client: MatrixClient) { } @@ -59,7 +74,8 @@ export class RustEngine { case RequestType.SignatureUpload: throw new Error("Bindings error: Backup feature not possible"); case RequestType.KeysBackup: - throw new Error("Bindings error: Backup feature not possible"); + await this.processKeysBackupRequest(request); + break; default: throw new Error("Bindings error: Unrecognized request type: " + request.type); } @@ -67,8 +83,22 @@ export class RustEngine { } public async addTrackedUsers(userIds: string[]) { - await this.lock.acquire(SYNC_LOCK_NAME, async () => { - const uids = userIds.map(u => new UserId(u)); + // Add the new set of users to the pool + userIds.forEach(uId => this.trackedUsersToAdd.add(uId)); + if (this.addTrackedUsersPromise) { + // If we have a pending promise, don't create another lock requirement. + return; + } + return this.addTrackedUsersPromise = this.lock.acquire(SYNC_LOCK_NAME, async () => { + // Immediately clear this promise so that a new promise is queued up. + this.addTrackedUsersPromise = undefined; + const uids = new Array(this.trackedUsersToAdd.size); + let idx = 0; + for (const u of this.trackedUsersToAdd.values()) { + uids[idx++] = new UserId(u); + } + // Clear the existing pool + this.trackedUsersToAdd.clear(); await this.machine.updateTrackedUsers(uids); const keysClaim = await this.machine.getMissingSessions(uids); @@ -79,9 +109,7 @@ export class RustEngine { } 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 memberships: Membership[] = ["join", "invite"]; let historyVis = HistoryVisibility.Joined; switch (roomInfo.historyVisibility) { case "world_readable": @@ -95,9 +123,24 @@ export class RustEngine { break; case "joined": default: - // Default and other cases handled by assignment before switch + memberships = ["join"]; } + const members = new Set(); + for (const membership of memberships) { + try { + (await this.client.getRoomMembersByMembership(roomId, membership)) + .map(u => new UserId(u.membershipFor)) + .forEach(u => void members.add(u)); + } catch (err) { + LogService.warn("RustEngine", `Failed to get room members for membership type "${membership}" in ${roomId}`, extractRequestError(err)); + } + } + if (members.size === 0) { + return; + } + const membersArray = Array.from(members); + const encEv = new EncryptionEvent({ type: "m.room.encryption", content: roomInfo, @@ -112,20 +155,86 @@ export class RustEngine { settings.rotationPeriodMessages = BigInt(encEv.rotationPeriodMessages); await this.lock.acquire(SYNC_LOCK_NAME, async () => { - await this.machine.updateTrackedUsers(members); // just in case we missed some + await this.machine.updateTrackedUsers(membersArray); // just in case we missed some await this.runOnly(RequestType.KeysQuery); - const keysClaim = await this.machine.getMissingSessions(members); + const keysClaim = await this.machine.getMissingSessions(membersArray); if (keysClaim) { await this.processKeysClaimRequest(keysClaim); } }); await this.lock.acquire(roomId, async () => { - const requests = JSON.parse(await this.machine.shareRoomKey(new RoomId(roomId), members, settings)); + const requests = await this.machine.shareRoomKey(new RoomId(roomId), membersArray, settings); for (const req of requests) { - await this.actuallyProcessToDeviceRequest(req.txn_id, req.event_type, req.messages); + await this.processToDeviceRequest(req); + } + // Back up keys asynchronously + void this.backupRoomKeysIfEnabled(); + }); + } + + public enableKeyBackup(info: IKeyBackupInfoRetrieved): Promise { + this.keyBackupWaiter = this.keyBackupWaiter.then(async () => { + if (this.backupEnabled) { + // Finish any pending backups before changing the backup version/pubkey + await this.actuallyDisableKeyBackup(); + } + let publicKey: string; + switch (info.algorithm) { + case KeyBackupEncryptionAlgorithm.MegolmBackupV1Curve25519AesSha2: + publicKey = (info.auth_data as ICurve25519AuthData).public_key; + break; + default: + throw new Error("Key backup error: cannot enable backups with unsupported backup algorithm " + info.algorithm); } + await this.machine.enableBackupV1(publicKey, info.version); + this.keyBackupVersion = info.version; + this.backupEnabled = true; }); + return this.keyBackupWaiter; + } + + public disableKeyBackup(): Promise { + this.keyBackupWaiter = this.keyBackupWaiter.then(async () => { + await this.actuallyDisableKeyBackup(); + }); + return this.keyBackupWaiter; + } + + private async actuallyDisableKeyBackup(): Promise { + await this.machine.disableBackup(); + this.keyBackupVersion = undefined; + this.backupEnabled = false; + } + + public backupRoomKeys(): Promise { + this.keyBackupWaiter = this.keyBackupWaiter.then(async () => { + if (!this.backupEnabled) { + throw new Error("Key backup error: attempted to create a backup before having enabled backups"); + } + await this.actuallyBackupRoomKeys(); + }); + return this.keyBackupWaiter; + } + + public async exportRoomKeysForSession(roomId: string, sessionId: string): Promise { + return JSON.parse(await this.machine.exportRoomKeysForSession(roomId, sessionId)) as IMegolmSessionDataExport[]; + } + + private backupRoomKeysIfEnabled(): Promise { + this.keyBackupWaiter = this.keyBackupWaiter.then(async () => { + if (this.backupEnabled) { + await this.actuallyBackupRoomKeys(); + } + }); + return this.keyBackupWaiter; + } + + private async actuallyBackupRoomKeys(): Promise { + const request = await this.machine.backupRoomKeys(); + if (request) { + await this.processKeysBackupRequest(request); + } } private async processKeysClaimRequest(request: KeysClaimRequest) { @@ -146,12 +255,21 @@ export class RustEngine { } private async processToDeviceRequest(request: ToDeviceRequest) { - const req = JSON.parse(request.body); - await this.actuallyProcessToDeviceRequest(req.txn_id, req.event_type, req.messages); + const resp = await this.client.sendToDevices(request.eventType, JSON.parse(request.body).messages); + await this.machine.markRequestAsSent(request.txnId, RequestType.ToDevice, JSON.stringify(resp)); } - 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)); + private async processKeysBackupRequest(request: KeysBackupRequest) { + let resp: Awaited>; + try { + if (!this.keyBackupVersion) { + throw new Error("Key backup version missing"); + } + resp = await this.client.doRequest("PUT", "/_matrix/client/v3/room_keys/keys", { version: this.keyBackupVersion }, JSON.parse(request.body)); + } catch (e) { + this.client.emit("crypto.failed_backup", e); + return; + } + await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } } diff --git a/src/helpers/Types.ts b/src/helpers/Types.ts new file mode 100644 index 00000000..14a717b5 --- /dev/null +++ b/src/helpers/Types.ts @@ -0,0 +1,5 @@ +export type Json = string | number | boolean | null | undefined | Json[] | { [key: string]: Json }; + +export interface IJsonType { + [key: string]: Json; +} diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index 9269a95f..af945641 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -23,13 +23,20 @@ export interface Signatures { }; } +/** + * Interface that can be extended by + * any object that needs a signature. + */ +export interface Signed { + signatures: Signatures; +} + /** * A signed_curve25519 one time key. * @category Models */ -export interface SignedCurve25519OTK { +export interface SignedCurve25519OTK extends Signed { key: string; - signatures: Signatures; fallback?: boolean; } @@ -89,12 +96,11 @@ export type DeviceKeyLabel, string>; - signatures: Signatures; unsigned?: { [k: string]: any; device_display_name?: string; diff --git a/src/models/KeyBackup.ts b/src/models/KeyBackup.ts new file mode 100644 index 00000000..812f034e --- /dev/null +++ b/src/models/KeyBackup.ts @@ -0,0 +1,62 @@ +import { IJsonType } from "../helpers/Types"; +import { Signed } from "./Crypto"; +import { RoomEncryptionAlgorithm } from "./events/EncryptionEvent"; + +/** + * The kinds of key backup encryption algorithms allowed by the spec. + * @category Models + */ +export enum KeyBackupEncryptionAlgorithm { + MegolmBackupV1Curve25519AesSha2 = "m.megolm_backup.v1.curve25519-aes-sha2", +} + +export interface ICurve25519AuthDataUnsigned { + public_key: string; +} +export type ICurve25519AuthData = ICurve25519AuthDataUnsigned & Signed; + +export type IKeyBackupAuthData = IJsonType | ICurve25519AuthDataUnsigned; + +/** + * Information about a server-side key backup, + * with its auth_data left unsigned. + */ +export interface IKeyBackupInfoUnsigned { + algorithm: string | KeyBackupEncryptionAlgorithm; + auth_data: IKeyBackupAuthData; +} + +/** + * Information about a server-side key backup, + * with its auth_data signed by the entity that created it. + */ +export type IKeyBackupInfo = IKeyBackupInfoUnsigned & { + auth_data: Signed & IKeyBackupAuthData; +}; + +export type KeyBackupVersion = string; + +export interface IKeyBackupVersion { + version: KeyBackupVersion; +} + +export interface IKeyBackupUpdateResponse { + count: number; + etag: string; +} + +export type IKeyBackupInfoRetrieved = IKeyBackupInfo & IKeyBackupVersion & IKeyBackupUpdateResponse; + +export type IKeyBackupInfoUpdate = IKeyBackupInfo & Partial; + +export interface IMegolmSessionDataExport { + algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2; + room_id: string; + sender_key: string; + session_id: string; + session_key: string; + sender_claimed_keys: { + [algorithm: string]: string; + }; + forwarding_curve25519_key_chain: string[]; +} diff --git a/src/storage/IAppserviceStorageProvider.ts b/src/storage/IAppserviceStorageProvider.ts index da9eb2c0..aca046bb 100644 --- a/src/storage/IAppserviceStorageProvider.ts +++ b/src/storage/IAppserviceStorageProvider.ts @@ -47,8 +47,8 @@ export interface IAppserviceStorageProvider { export interface IAppserviceCryptoStorageProvider { /** * Gets a storage provider to use for the given user ID. - * @param {string} userId The user ID. - * @returns {ICryptoStorageProvider} The storage provider. + * @param userId The user ID. + * @returns The storage provider. */ storageForUser(userId: string): ICryptoStorageProvider; } diff --git a/src/storage/RustSdkCryptoStorageProvider.ts b/src/storage/RustSdkCryptoStorageProvider.ts index fc4be490..ce67976b 100644 --- a/src/storage/RustSdkCryptoStorageProvider.ts +++ b/src/storage/RustSdkCryptoStorageProvider.ts @@ -2,6 +2,8 @@ import * as lowdb from "lowdb"; import * as FileSync from "lowdb/adapters/FileSync"; import * as mkdirp from "mkdirp"; import * as path from "path"; +import { stat, rename, mkdir } from "fs/promises"; +import { PathLike } from "fs"; import * as sha512 from "hash.js/lib/hash/sha/512"; import * as sha256 from "hash.js/lib/hash/sha/256"; import { StoreType as RustSdkCryptoStoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; @@ -9,9 +11,14 @@ import { StoreType as RustSdkCryptoStoreType } from "@matrix-org/matrix-sdk-cryp import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { IAppserviceCryptoStorageProvider } from "./IAppserviceStorageProvider"; import { ICryptoRoomInformation } from "../e2ee/ICryptoRoomInformation"; +import { LogService } from "../logging/LogService"; export { RustSdkCryptoStoreType }; +async function doesFileExist(path: PathLike) { + return stat(path).then(() => true).catch(() => false); +} + /** * A crypto storage provider for the file-based rust-sdk store. * @category Storage providers @@ -26,7 +33,7 @@ export class RustSdkCryptoStorageProvider implements ICryptoStorageProvider { */ public constructor( public readonly storagePath: string, - public readonly storageType: RustSdkCryptoStoreType = RustSdkCryptoStoreType.Sled, + public readonly storageType: RustSdkCryptoStoreType, ) { this.storagePath = path.resolve(this.storagePath); mkdirp.sync(storagePath); @@ -40,6 +47,37 @@ export class RustSdkCryptoStorageProvider implements ICryptoStorageProvider { }); } + public async getMachineStoragePath(deviceId: string): Promise { + const newPath = path.join(this.storagePath, sha256().update(deviceId).digest('hex')); + if (await doesFileExist(newPath)) { + // Already exists, short circuit. + return newPath; + } // else: If the path does NOT exist we might need to perform a migration. + + const legacyFilePath = path.join(this.storagePath, 'matrix-sdk-crypto.sqlite3'); + // XXX: Slightly gross cross-dependency file name expectations. + if (await doesFileExist(legacyFilePath) === false) { + // No machine files at all, we can skip. + return newPath; + } + const legacyDeviceId = await this.getDeviceId(); + // We need to move the file. + const previousDevicePath = path.join(this.storagePath, sha256().update(legacyDeviceId).digest('hex')); + LogService.warn("RustSdkCryptoStorageProvider", `Migrating path for SDK database for legacy device ${legacyDeviceId}`); + await mkdir(previousDevicePath); + await rename(legacyFilePath, path.join(previousDevicePath, 'matrix-sdk-crypto.sqlite3')).catch((ex) => + LogService.warn("RustSdkCryptoStorageProvider", `Could not migrate matrix-sdk-crypto.sqlite3`, ex), + ); + await rename(legacyFilePath, path.join(previousDevicePath, 'matrix-sdk-crypto.sqlite3-shm')).catch((ex) => + LogService.warn("RustSdkCryptoStorageProvider", `Could not migrate matrix-sdk-crypto.sqlite3-shm`, ex), + ); + await rename(legacyFilePath, path.join(previousDevicePath, 'matrix-sdk-crypto.sqlite3-wal')).catch((ex) => + LogService.warn("RustSdkCryptoStorageProvider", `Could not migrate matrix-sdk-crypto.sqlite3-wal`, ex), + ); + + return newPath; + } + public async getDeviceId(): Promise { return this.db.get('deviceId').value(); } @@ -69,13 +107,13 @@ export class RustSdkAppserviceCryptoStorageProvider extends RustSdkCryptoStorage * @param baseStoragePath The *directory* to persist database details to. * @param storageType The storage type to use. Must be supported by the rust-sdk. */ - public constructor(private baseStoragePath: string, storageType: RustSdkCryptoStoreType = RustSdkCryptoStoreType.Sled) { + public constructor(private baseStoragePath: string, storageType: RustSdkCryptoStoreType) { super(path.join(baseStoragePath, "_default"), storageType); } public storageForUser(userId: string): ICryptoStorageProvider { // sha256 because sha512 is a bit big for some operating systems - const key = sha256().update(userId).digest('hex'); - return new RustSdkCryptoStorageProvider(path.join(this.baseStoragePath, key), this.storageType); + const storagePath = path.join(this.baseStoragePath, sha256().update(userId).digest('hex')); + return new RustSdkCryptoStorageProvider(storagePath, this.storageType); } } diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index 6abdf93e..99c3c404 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -1,6 +1,5 @@ import * as tmp from "tmp"; import * as simple from "simple-mock"; -import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { EventKind, @@ -48,13 +47,13 @@ describe('MatrixClient', () => { expect(client.accessToken).toEqual(accessToken); }); - it('should create a crypto client when requested', () => { + it('should create a crypto client when requested', () => testCryptoStores(async (cryptoStoreType) => { const homeserverUrl = "https://example.org"; const accessToken = "example_token"; - const client = new MatrixClient(homeserverUrl, accessToken, null, new RustSdkCryptoStorageProvider(tmp.dirSync().name, StoreType.Sled)); + const client = new MatrixClient(homeserverUrl, accessToken, null, new RustSdkCryptoStorageProvider(tmp.dirSync().name, cryptoStoreType)); expect(client.crypto).toBeDefined(); - }); + })); it('should NOT create a crypto client when requested', () => { const homeserverUrl = "https://example.org"; @@ -1044,7 +1043,7 @@ describe('MatrixClient', () => { }); it('should request the user ID if it is not known', async () => { - const { client, http } = createTestClient(); + const { client, http } = createTestClient(undefined, undefined, undefined, { handleWhoAmI: false }); const userId = "@example:example.org"; const response = { @@ -1062,7 +1061,7 @@ describe('MatrixClient', () => { describe('getWhoAmI', () => { it('should call the right endpoint', async () => { - const { client, http } = createTestClient(); + const { client, http } = createTestClient(undefined, undefined, undefined, { handleWhoAmI: false }); const response = { user_id: "@user:example.org", @@ -1401,8 +1400,7 @@ describe('MatrixClient', () => { describe('processSync', () => { interface ProcessSyncClient { userId: string; - - processSync(raw: any): Promise; + processSync(raw: any): MatrixClient["processSync"]; } it('should process non-room account data', async () => { @@ -2759,6 +2757,34 @@ describe('MatrixClient', () => { }); }); + describe('getEventNearestToTimestamp', () => { + it('should use the right endpoint', async () => { + const { client, http, hsUrl } = createTestClient(); + const roomId = "!abc123:example.org"; + const dir = "f"; + const timestamp = 1234; + + const eventId = "$def456:example.org"; + const originServerTs = 4567; + + http.when("GET", "/_matrix/client/v1/rooms").respond(200, (path, _content, req) => { + expect(path).toEqual(`${hsUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/timestamp_to_event`); + expect(req.queryParams['dir']).toEqual(dir); + expect(req.queryParams['ts']).toEqual(timestamp); + + return { + event_id: eventId, + origin_server_ts: originServerTs, + }; + }); + + const [result] = await Promise.all([client.getEventNearestToTimestamp(roomId, timestamp, dir), http.flushAllExpected()]); + expect(result).toBeDefined(); + expect(result.event_id).toEqual(eventId); + expect(result.origin_server_ts).toEqual(originServerTs); + }); + }); + describe('getUserProfile', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); diff --git a/test/SynapseAdminApisTest.ts b/test/SynapseAdminApisTest.ts index b9d9f5f6..ee3b266a 100644 --- a/test/SynapseAdminApisTest.ts +++ b/test/SynapseAdminApisTest.ts @@ -24,7 +24,7 @@ export function createTestSynapseAdminClient( hsUrl: string; accessToken: string; } { - const result = createTestClient(storage); + const result = createTestClient(storage, undefined, undefined, { handleWhoAmI: false }); const mxClient = result.client; const client = new SynapseAdminApis(mxClient); @@ -531,5 +531,33 @@ describe('SynapseAdminApis', () => { await Promise.all([client.makeRoomAdmin(roomId, userId), http.flushAllExpected()]); }); }); + + describe('getEventNearestToTimestamp', () => { + it('should use the right endpoint', async () => { + const { client, http, hsUrl } = createTestSynapseAdminClient(); + const roomId = "!abc123:example.org"; + const dir = "f"; + const timestamp = 1234; + + const eventId = "$def456:example.org"; + const originServerTs = 4567; + + http.when("GET", "/_synapse/admin/v1/rooms").respond(200, (path, _content, req) => { + expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/timestamp_to_event`); + expect(req.queryParams['dir']).toEqual(dir); + expect(req.queryParams['ts']).toEqual(timestamp); + + return { + event_id: eventId, + origin_server_ts: originServerTs, + }; + }); + + const [result] = await Promise.all([client.getEventNearestToTimestamp(roomId, timestamp, dir), http.flushAllExpected()]); + expect(result).toBeDefined(); + expect(result.event_id).toEqual(eventId); + expect(result.origin_server_ts).toEqual(originServerTs); + }); + }); }); }); diff --git a/test/TestUtils.ts b/test/TestUtils.ts index 7d6f9f8d..e8d2cfa3 100644 --- a/test/TestUtils.ts +++ b/test/TestUtils.ts @@ -2,7 +2,7 @@ import * as tmp from "tmp"; import HttpBackend from "matrix-mock-request"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; -import { IStorageProvider, MatrixClient, RustSdkCryptoStorageProvider, setRequestFn } from "../src"; +import { IStorageProvider, MatrixClient, OTKAlgorithm, RustSdkCryptoStorageProvider, UnpaddedBase64, setRequestFn } from "../src"; export const TEST_DEVICE_ID = "TEST_DEVICE"; @@ -31,6 +31,7 @@ export function createTestClient( storage: IStorageProvider = null, userId: string = null, cryptoStoreType?: StoreType, + opts = { handleWhoAmI: true }, ): { client: MatrixClient; http: HttpBackend; @@ -40,17 +41,61 @@ export function createTestClient( const http = new HttpBackend(); const hsUrl = "https://localhost"; const accessToken = "s3cret"; - const client = new MatrixClient(hsUrl, accessToken, storage, cryptoStoreType !== undefined ? new RustSdkCryptoStorageProvider(tmp.dirSync().name, cryptoStoreType) : null); + const client = new MatrixClient(hsUrl, accessToken, storage, (cryptoStoreType !== undefined) ? new RustSdkCryptoStorageProvider(tmp.dirSync().name, cryptoStoreType) : null); (client).userId = userId; // private member access setRequestFn(http.requestFn); + if (opts.handleWhoAmI) { + // Ensure we always respond to a whoami + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + } + return { http, hsUrl, accessToken, client }; } -const CRYPTO_STORE_TYPES = [StoreType.Sled, StoreType.Sqlite]; +const CRYPTO_STORE_TYPES: StoreType[] = [StoreType.Sqlite]; export async function testCryptoStores(fn: (StoreType) => Promise): Promise { for (const st of CRYPTO_STORE_TYPES) { await fn(st); } } + +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 + bindNullQuery(http); +} + +export function bindNullQuery(http: HttpBackend) { + http.when("POST", "/keys/query").respond(200, (path, obj) => { + return {}; + }); +} + +/** + * Generate a string that can be used as a curve25519 public key. + * @returns A 32-byte string comprised of Unpadded Base64 characters. + */ +export function generateCurve25519PublicKey() { + return UnpaddedBase64.encodeString(generateAZString(32)); +} + +/** + * Generate an arbitrary string with characters in the range A-Z. + * @param length The length of the string to generate. + * @returns The generated string. + */ +function generateAZString(length: number) { + return String.fromCharCode(...Array.from({ length }, () => Math.floor(65 + Math.random()*25))); +} diff --git a/test/appservice/AppserviceTest.ts b/test/appservice/AppserviceTest.ts index 2f3e7c94..454af2a1 100644 --- a/test/appservice/AppserviceTest.ts +++ b/test/appservice/AppserviceTest.ts @@ -385,6 +385,47 @@ describe('Appservice', () => { expect(intent.userId).toEqual(userId); }); + it('should emit an event for a created intent', async () => { + const appservice = new Appservice({ + port: 0, + bindAddress: '', + homeserverName: 'example.org', + homeserverUrl: 'https://localhost', + registration: { + as_token: "", + hs_token: "", + sender_localpart: "_bot_", + namespaces: { + users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], + rooms: [], + aliases: [], + }, + }, + }); + + let newIntent: Intent | undefined; + const intentSpy = simple.stub().callFn(intent => { + expect(intent).toBeInstanceOf(Intent); + newIntent = intent; + const sameIntent = appservice.getIntentForUserId(newIntent.userId); + expect(newIntent).toBe(sameIntent); + }); + appservice.on("intent.new", intentSpy); + + [ + "@alice:example.org", + "@_prefix_testing:example.org", + "@_bot_:example.org", + "@test_prefix_:example.org", + ].forEach((userId, index) => { + const intent = appservice.getIntentForUserId(userId); + expect(intentSpy.callCount).toBe(index+1); + expect(intent).toBeDefined(); + expect(intent.userId).toEqual(userId); + expect(intent).toBe(newIntent); + }); + }); + it('should return a user ID for any namespaced localpart', async () => { const appservice = new Appservice({ port: 0, @@ -1752,7 +1793,7 @@ describe('Appservice', () => { }; const intent = appservice.getIntentForSuffix("test"); - intent.refreshJoinedRooms = () => Promise.resolve([]); + intent.getJoinedRooms = () => Promise.resolve([]); await appservice.begin(); @@ -1857,98 +1898,6 @@ describe('Appservice', () => { } }); - it('should refresh membership information of intents when actions are performed against them', async () => { - const port = await getPort(); - const hsToken = "s3cret_token"; - const appservice = new Appservice({ - port: port, - bindAddress: '', - homeserverName: 'example.org', - homeserverUrl: 'https://localhost', - registration: { - as_token: "", - hs_token: hsToken, - sender_localpart: "_bot_", - namespaces: { - users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], - rooms: [], - aliases: [], - }, - }, - }); - appservice.botIntent.ensureRegistered = () => { - return null; - }; - - await appservice.begin(); - - try { - const intent = appservice.getIntentForSuffix("test"); - const refreshSpy = simple.stub().callFn(() => Promise.resolve([])); - intent.refreshJoinedRooms = refreshSpy; - - // polyfill the dummy user too - const intent2 = appservice.getIntentForSuffix("test___WRONGUSER"); - intent2.refreshJoinedRooms = () => Promise.resolve([]); - - const joinTxn = { - events: [ - { - type: "m.room.member", - room_id: "!AAA:example.org", - content: { membership: "join" }, - state_key: "@_prefix_test:example.org", - sender: "@_prefix_test:example.org", - }, - { - type: "m.room.member", - room_id: "!AAA:example.org", - content: { membership: "join" }, - state_key: "@_prefix_test___WRONGUSER:example.org", - sender: "@_prefix_test:example.org", - }, - ], - }; - const kickTxn = { - events: [ - { - type: "m.room.member", - room_id: "!AAA:example.org", - content: { membership: "leave" }, - state_key: "@_prefix_test:example.org", - sender: "@someone_else:example.org", - }, - { - type: "m.room.member", - room_id: "!AAA:example.org", - content: { membership: "leave" }, - state_key: "@_prefix_test___WRONGUSER:example.org", - sender: "@someone_else:example.org", - }, - ], - }; - - // eslint-disable-next-line no-inner-declarations - async function doCall(route: string, opts: any = {}) { - const res = await requestPromise({ - uri: `http://localhost:${port}${route}`, - method: "PUT", - qs: { access_token: hsToken }, - ...opts, - }); - expect(res).toMatchObject({}); - - expect(refreshSpy.callCount).toBe(1); - refreshSpy.callCount = 0; - } - - await doCall("/transactions/1", { json: joinTxn }); - await doCall("/_matrix/app/v1/transactions/2", { json: kickTxn }); - } finally { - appservice.stop(); - } - }); - it('should handle room upgrade events in transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; diff --git a/test/appservice/IntentTest.ts b/test/appservice/IntentTest.ts index f0aa2c24..9a6327de 100644 --- a/test/appservice/IntentTest.ts +++ b/test/appservice/IntentTest.ts @@ -3,7 +3,6 @@ import HttpBackend from 'matrix-mock-request'; import * as tmp from "tmp"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; -import { expectArrayEquals } from "../TestUtils"; import { Appservice, IAppserviceCryptoStorageProvider, @@ -268,280 +267,12 @@ describe('Intent', () => { }); }); - describe('getJoinedRooms', () => { - it('should fetch rooms if none are cached', async () => { - const userId = "@someone:example.org"; - const botUserId = "@bot:example.org"; - const asToken = "s3cret"; - const hsUrl = "https://localhost"; - const roomsPartA = ['!a:example.org', '!b:example.org']; - const appservice = { botUserId: botUserId }; - const storage = new MemoryStorageProvider(); - const options = { - homeserverUrl: hsUrl, - storage: storage, - registration: { - as_token: asToken, - }, - }; - - const intent = new Intent(options, userId, appservice); - - const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { - return Promise.resolve(); - }); - - const getJoinedSpy = simple.stub().callFn(() => { - return Promise.resolve(roomsPartA); - }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; - - const joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals(roomsPartA, joinedRooms); - expect(registeredSpy.callCount).toBe(1); - expect(getJoinedSpy.callCount).toBe(1); - }); - - it('should cache rooms on join', async () => { - const userId = "@someone:example.org"; - const botUserId = "@bot:example.org"; - const asToken = "s3cret"; - const hsUrl = "https://localhost"; - const roomId = "!test:example.org"; - const appservice = { botUserId: botUserId }; - const storage = new MemoryStorageProvider(); - const options = { - homeserverUrl: hsUrl, - storage: storage, - registration: { - as_token: asToken, - }, - }; - - const intent = new Intent(options, userId, appservice); - - const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { - return Promise.resolve(); - }); - - const getJoinedSpy = simple.stub().callFn(() => { - if (getJoinedSpy.callCount === 1) return Promise.resolve([]); - return Promise.resolve([roomId]); - }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; - - const joinSpy = simple.stub().callFn((rid) => { - expect(rid).toBe(roomId); - return Promise.resolve(rid); - }); - intent.underlyingClient.joinRoom = joinSpy; - - let joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals([], joinedRooms); - expect(registeredSpy.callCount).toBe(1); - expect(getJoinedSpy.callCount).toBe(1); - expect(joinSpy.callCount).toBe(0); - - await intent.joinRoom(roomId); - - joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals([roomId], joinedRooms); - expect(registeredSpy.callCount).toBe(3); - expect(getJoinedSpy.callCount).toBe(2); - expect(joinSpy.callCount).toBe(1); - }); - - it('should cache rooms on leave', async () => { - const userId = "@someone:example.org"; - const botUserId = "@bot:example.org"; - const asToken = "s3cret"; - const hsUrl = "https://localhost"; - const roomId = "!test:example.org"; - const appservice = { botUserId: botUserId }; - const storage = new MemoryStorageProvider(); - const options = { - homeserverUrl: hsUrl, - storage: storage, - registration: { - as_token: asToken, - }, - }; - - const intent = new Intent(options, userId, appservice); - - const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { - return Promise.resolve(); - }); - - const getJoinedSpy = simple.stub().callFn(() => { - if (getJoinedSpy.callCount > 1) return Promise.resolve([]); - return Promise.resolve([roomId]); - }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; - - const leaveSpy = simple.stub().callFn((rid) => { - expect(rid).toBe(roomId); - return Promise.resolve(rid); - }); - intent.underlyingClient.leaveRoom = leaveSpy; - - let joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals([roomId], joinedRooms); - expect(registeredSpy.callCount).toBe(1); - expect(getJoinedSpy.callCount).toBe(1); - expect(leaveSpy.callCount).toBe(0); - - await intent.leaveRoom(roomId); - - joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals([], joinedRooms); - expect(registeredSpy.callCount).toBe(3); - expect(getJoinedSpy.callCount).toBe(3); - expect(leaveSpy.callCount).toBe(1); - }); - - it('should cache rooms on ensureJoined', async () => { - const userId = "@someone:example.org"; - const botUserId = "@bot:example.org"; - const asToken = "s3cret"; - const hsUrl = "https://localhost"; - const roomId = "!test:example.org"; - const appservice = { botUserId: botUserId }; - const storage = new MemoryStorageProvider(); - const options = { - homeserverUrl: hsUrl, - storage: storage, - registration: { - as_token: asToken, - }, - }; - - const intent = new Intent(options, userId, appservice); - - const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { - return Promise.resolve(); - }); - - const getJoinedSpy = simple.stub().callFn(() => { - if (getJoinedSpy.callCount <= 2) return Promise.resolve([]); - return Promise.resolve([roomId]); - }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; - - const joinSpy = simple.stub().callFn((rid) => { - expect(rid).toBe(roomId); - return Promise.resolve(rid); - }); - intent.underlyingClient.joinRoom = joinSpy; - - let joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals([], joinedRooms); - expect(registeredSpy.callCount).toBe(1); - expect(getJoinedSpy.callCount).toBe(1); - expect(joinSpy.callCount).toBe(0); - - await intent.ensureJoined(roomId); - - joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals([roomId], joinedRooms); - expect(registeredSpy.callCount).toBe(2); - expect(getJoinedSpy.callCount).toBe(2); - expect(joinSpy.callCount).toBe(1); - - // Duplicate just to prove it caches it - await intent.ensureJoined(roomId); - - joinedRooms = await intent.getJoinedRooms(); - expectArrayEquals([roomId], joinedRooms); - expect(registeredSpy.callCount).toBe(3); - expect(getJoinedSpy.callCount).toBe(2); - expect(joinSpy.callCount).toBe(1); - }); - }); - - describe('refreshJoinedRooms', () => { - it('should overwrite any previously known joined rooms', async () => { - const userId = "@someone:example.org"; - const botUserId = "@bot:example.org"; - const asToken = "s3cret"; - const hsUrl = "https://localhost"; - const roomsPartA = ['!a:example.org', '!b:example.org']; - const roomsPartB = ['!c:example.org', '!d:example.org']; - const appservice = { botUserId: botUserId }; - const storage = new MemoryStorageProvider(); - const options = { - homeserverUrl: hsUrl, - storage: storage, - registration: { - as_token: asToken, - }, - }; - - const intent = new Intent(options, userId, appservice); - - // We have to do private access to ensure that the intent actually overwrites - // its cache. - const getJoinedRooms = () => (intent).knownJoinedRooms; - const setJoinedRooms = (rooms) => (intent).knownJoinedRooms = rooms; - - const getJoinedSpy = simple.stub().callFn(() => { - return Promise.resolve(roomsPartB); - }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; - - // Do a quick assert to prove that our private access hooks work - expectArrayEquals([], getJoinedRooms()); - setJoinedRooms(roomsPartA); - expectArrayEquals(roomsPartA, getJoinedRooms()); - - const result = await intent.refreshJoinedRooms(); - expect(getJoinedSpy.callCount).toBe(1); - expectArrayEquals(roomsPartB, result); - expectArrayEquals(roomsPartB, getJoinedRooms()); - }); - }); - describe('ensureJoined', () => { - it('should fetch the rooms the user is joined to', async () => { - const userId = "@someone:example.org"; - const botUserId = "@bot:example.org"; - const asToken = "s3cret"; - const hsUrl = "https://localhost"; - const roomIds = ["!a:example.org", "!b:example.org"]; - const targetRoomId = "!a:example.org"; - const appservice = { botUserId: botUserId }; - const storage = new MemoryStorageProvider(); - const options = { - homeserverUrl: hsUrl, - storage: storage, - registration: { - as_token: asToken, - }, - }; - - const intent = new Intent(options, userId, appservice); - - const getJoinedSpy = simple.stub().callFn(() => { - return Promise.resolve(roomIds); - }); - const joinSpy = simple.stub().callFn((rid) => { - expect(rid).toEqual(targetRoomId); - return Promise.resolve("!joined:example.org"); - }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; - intent.underlyingClient.joinRoom = joinSpy; - - await intent.ensureJoined(targetRoomId); - expect(getJoinedSpy.callCount).toBe(1); - expect(joinSpy.callCount).toBe(0); - }); - it('should attempt to join rooms a user is not in', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; - const roomIds = ["!a:example.org", "!b:example.org"]; const targetRoomId = "!c:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); @@ -555,18 +286,13 @@ describe('Intent', () => { const intent = new Intent(options, userId, appservice); - const getJoinedSpy = simple.stub().callFn(() => { - return Promise.resolve(roomIds); - }); const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve("!joined:example.org"); }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; intent.underlyingClient.joinRoom = joinSpy; await intent.ensureJoined(targetRoomId); - expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); @@ -575,7 +301,6 @@ describe('Intent', () => { const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; - const roomIds = ["!a:example.org", "!b:example.org"]; const targetRoomId = "!c:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); @@ -589,14 +314,10 @@ describe('Intent', () => { const intent = new Intent(options, userId, appservice); - const getJoinedSpy = simple.stub().callFn(() => { - return Promise.resolve(roomIds); - }); const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); throw new Error("Simulated failure"); }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; intent.underlyingClient.joinRoom = joinSpy; try { @@ -607,49 +328,8 @@ describe('Intent', () => { } catch (e) { expect(e.message).toEqual("Simulated failure"); } - expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); - - it('should proxy failure for getting joined rooms', async () => { - const userId = "@someone:example.org"; - const botUserId = "@bot:example.org"; - const asToken = "s3cret"; - const hsUrl = "https://localhost"; - const targetRoomId = "!c:example.org"; - const appservice = { botUserId: botUserId }; - const storage = new MemoryStorageProvider(); - const options = { - homeserverUrl: hsUrl, - storage: storage, - registration: { - as_token: asToken, - }, - }; - - const intent = new Intent(options, userId, appservice); - - const getJoinedSpy = simple.stub().callFn(() => { - throw new Error("Simulated failure"); - }); - const joinSpy = simple.stub().callFn((rid) => { - expect(rid).toEqual(targetRoomId); - return Promise.resolve("!joined:example.org"); - }); - intent.underlyingClient.getJoinedRooms = getJoinedSpy; - intent.underlyingClient.joinRoom = joinSpy; - - try { - await intent.ensureJoined(targetRoomId); - - // noinspection ExceptionCaughtLocallyJS - throw new Error("Request completed when it should have failed"); - } catch (e) { - expect(e.message).toEqual("Simulated failure"); - } - expect(getJoinedSpy.callCount).toBe(1); - expect(joinSpy.callCount).toBe(0); - }); }); describe('ensureRegisteredAndJoined', () => { @@ -969,23 +649,17 @@ describe('Intent', () => { expect(rid).toEqual(targetRoomId); return {}; }); - const refreshJoinedRoomsSpy = simple.stub().callFn(() => { - return Promise.resolve([]); - }); - const joinRoomSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve(targetRoomId); }); intent.underlyingClient.joinRoom = joinRoomSpy; - intent.refreshJoinedRooms = refreshJoinedRoomsSpy; const result = await intent.joinRoom(targetRoomId); expect(result).toEqual(targetRoomId); expect(joinRoomSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); - expect(refreshJoinedRoomsSpy.callCount).toBe(1); }); it('should proxy errors upwards', async () => { @@ -1060,22 +734,17 @@ describe('Intent', () => { expect(rid).toEqual(targetRoomId); return {}; }); - const refreshJoinedRoomsSpy = simple.stub().callFn(() => { - return Promise.resolve([]); - }); const leaveRoomSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve(targetRoomId); }); intent.underlyingClient.leaveRoom = leaveRoomSpy; - intent.refreshJoinedRooms = refreshJoinedRoomsSpy; await intent.leaveRoom(targetRoomId); expect(leaveRoomSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); - expect(refreshJoinedRoomsSpy.callCount).toBe(1); }); it('should proxy errors upwards', async () => { @@ -1137,7 +806,7 @@ describe('Intent', () => { beforeEach(() => { storage = new MemoryStorageProvider(); - cryptoStorage = new RustSdkAppserviceCryptoStorageProvider(tmp.dirSync().name, StoreType.Sled); + cryptoStorage = new RustSdkAppserviceCryptoStorageProvider(tmp.dirSync().name, StoreType.Sqlite); options = { homeserverUrl: hsUrl, storage: storage, @@ -1160,7 +829,7 @@ describe('Intent', () => { }, }, }; - intent = new Intent(options, userId, appservice); // eslint-disable-line @typescript-eslint/no-unused-vars + intent = new Intent(options, userId, appservice); }); // TODO: Test once device_id impersonation set up diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index bab445f6..e5d17322 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1,32 +1,13 @@ import * as simple from "simple-mock"; import HttpBackend from 'matrix-mock-request'; -import { EncryptedFile, MatrixClient, MembershipEvent, OTKAlgorithm, RoomEncryptionAlgorithm } from "../../src"; -import { createTestClient, testCryptoStores, TEST_DEVICE_ID } from "../TestUtils"; - -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 {}; - }); -} +import { EncryptedFile, EncryptionAlgorithm, IOlmEncrypted, IToDeviceMessage, MatrixClient, MembershipEvent, OTKAlgorithm, RoomEncryptionAlgorithm } from "../../src"; +import { bindNullEngine, createTestClient, testCryptoStores, TEST_DEVICE_ID } from "../TestUtils"; describe('CryptoClient', () => { it('should not have a device ID or be ready until prepared', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); - client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); expect(client.crypto).toBeDefined(); @@ -35,7 +16,7 @@ describe('CryptoClient', () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -46,24 +27,17 @@ describe('CryptoClient', () => { describe('prepare', () => { it('should prepare the room tracker', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; - const roomIds = ["!a:example.org", "!b:example.org"]; const { client, http } = createTestClient(null, userId, cryptoStoreType); client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); - const prepareSpy = simple.stub().callFn((rids: string[]) => { - expect(rids).toBe(roomIds); - return Promise.resolve(); - }); - - (client.crypto).roomTracker.prepare = prepareSpy; // private member access - bindNullEngine(http); + // Prepare first await Promise.all([ - client.crypto.prepare(roomIds), + client.crypto.prepare(), http.flushAllExpected(), ]); - expect(prepareSpy.callCount).toEqual(1); + expect(client.crypto.isReady).toBe(true); })); it('should use a stored device ID', () => testCryptoStores(async (cryptoStoreType) => { @@ -71,17 +45,85 @@ describe('CryptoClient', () => { const { client, http } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + const CORRECT_DEVICE = "new_device"; - const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: "wrong" })); + const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: CORRECT_DEVICE })); client.getWhoAmI = whoamiSpy; bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); - expect(whoamiSpy.callCount).toEqual(0); - expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID); + // This should be called to check + expect(whoamiSpy.callCount).toEqual(1); + expect(client.crypto.clientDeviceId).toEqual(CORRECT_DEVICE); + expect(await client.cryptoStore.getDeviceId()).toEqual(CORRECT_DEVICE); + })); + }); + + describe('processSync', () => { + /** + * Helper class to be able to call {@link MatrixClient#processSync}, which is otherwise private. + */ + interface ProcessSyncClient { + processSync: MatrixClient["processSync"]; + } + + it('should process encrypted to-device messages', () => testCryptoStores(async (cryptoStoreType) => { + const userId = "@alice:example.org"; + const { client, http } = createTestClient(null, userId, cryptoStoreType); + const psClient = (client); + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + + const toDeviceMessage: IToDeviceMessage = { + type: "m.room.encrypted", + sender: userId, + content: { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2, + sender_key: "sender_curve25519_key", + ciphertext: { + ["device_curve25519_key"]: { + type: 0, + body: "encrypted_payload_base_64", + }, + }, + }, + }; + const sync = { + to_device: { events: [toDeviceMessage] }, + device_unused_fallback_key_types: [OTKAlgorithm.Signed], + device_one_time_keys_count: { + [OTKAlgorithm.Signed]: 12, + [OTKAlgorithm.Unsigned]: 14, + }, + device_lists: { + changed: ["@bob:example.org"], + left: ["@charlie:example.org"], + }, + }; + + const toDeviceSpy = simple.stub().callFn((ev) => { + for (const prop in toDeviceMessage) { + expect(ev).toHaveProperty(prop); + } + }); + client.on("to_device.decrypted", toDeviceSpy); + + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare(), + http.flushAllExpected(), + ]); + + bindNullEngine(http); + await Promise.all([ + psClient.processSync(sync), + http.flushAllExpected(), + ]); + + expect(toDeviceSpy.callCount).toBe(1); })); }); @@ -91,7 +133,7 @@ describe('CryptoClient', () => { const { client } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); - // await client.crypto.prepare([]); // deliberately commented + // await client.crypto.prepare(); // deliberately commented try { await client.crypto.isRoomEncrypted("!new:example.org"); @@ -112,7 +154,7 @@ describe('CryptoClient', () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -129,7 +171,7 @@ describe('CryptoClient', () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -146,7 +188,7 @@ describe('CryptoClient', () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -163,7 +205,7 @@ describe('CryptoClient', () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -201,7 +243,7 @@ describe('CryptoClient', () => { it('should sign the object while retaining signatures without mutation', async () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -258,7 +300,7 @@ describe('CryptoClient', () => { it('should fail in unencrypted rooms', async () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -334,7 +376,7 @@ describe('CryptoClient', () => { it('should encrypt media', async () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -420,7 +462,7 @@ describe('CryptoClient', () => { it('should be symmetrical', async () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -445,7 +487,7 @@ describe('CryptoClient', () => { it('should decrypt', async () => { bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -475,7 +517,7 @@ describe('CryptoClient', () => { await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); })); diff --git a/test/encryption/KeyBackupTest.ts b/test/encryption/KeyBackupTest.ts new file mode 100644 index 00000000..100a8d8e --- /dev/null +++ b/test/encryption/KeyBackupTest.ts @@ -0,0 +1,304 @@ +import * as simple from "simple-mock"; +import HttpBackend from 'matrix-mock-request'; + +import { + ICurve25519AuthDataUnsigned, + IKeyBackupInfo, + IKeyBackupInfoRetrieved, + IKeyBackupInfoUnsigned, + IKeyBackupUpdateResponse, + KeyBackupEncryptionAlgorithm, +} from "../../src/models/KeyBackup"; +import { ICryptoRoomInformation, IToDeviceMessage, MatrixClient, MembershipEvent, RoomEncryptionAlgorithm, RoomTracker } from "../../src"; +import { bindNullEngine, createTestClient, testCryptoStores, TEST_DEVICE_ID, generateCurve25519PublicKey, bindNullQuery } from "../TestUtils"; + +const USER_ID = "@alice:example.org"; + +describe('KeyBackups', () => { + let client: MatrixClient; + let http: HttpBackend; + + const prepareCrypto = async () => { + bindNullEngine(http); + await Promise.all([ + client.crypto.prepare(), + http.flushAllExpected(), + ]); + }; + + beforeEach(() => testCryptoStores(async (cryptoStoreType) => { + const { client: mclient, http: mhttp } = createTestClient(null, USER_ID, cryptoStoreType); + client = mclient; + http = mhttp; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + })); + + it('should retrieve a missing backup version', () => testCryptoStores(async (cryptoStoreType) => { + http.when("GET", "/room_keys/version").respond(400, (path, obj) => { + return { + errcode: "M_NOT_FOUND", + error: "No current backup version", + }; + }); + + await Promise.all([ + (async () => { + const keyBackupInfo = await client.getKeyBackupVersion(); + expect(keyBackupInfo).toBeNull(); + })(), + http.flushAllExpected(), + ]); + })); + + it('should fail to create a backup version when the crypto has not been prepared', () => testCryptoStores(async (cryptoStoreType) => { + try { + await client.signAndCreateKeyBackupVersion({ + algorithm: KeyBackupEncryptionAlgorithm.MegolmBackupV1Curve25519AesSha2, + auth_data: { + public_key: "fake_key", + }, + }); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + })); + + it('should create and retrieve a backup version', () => testCryptoStores(async (cryptoStoreType) => { + await prepareCrypto(); + + const authDataUnsigned: ICurve25519AuthDataUnsigned = { + public_key: generateCurve25519PublicKey(), + }; + + const keyBackupInfo: IKeyBackupInfoUnsigned = { + algorithm: KeyBackupEncryptionAlgorithm.MegolmBackupV1Curve25519AesSha2, + auth_data: authDataUnsigned, + }; + + let keyBackupInfoOnServer: IKeyBackupInfoRetrieved|undefined; + + http.when("POST", "/room_keys/version").respond(200, (path, obj: IKeyBackupInfo) => { + expect(obj.auth_data.signatures[USER_ID]).toHaveProperty(`ed25519:${TEST_DEVICE_ID}`); + + keyBackupInfoOnServer = { + ...obj, + version: "1", + count: 0, + etag: "etag0", + }; + return keyBackupInfoOnServer.version; + }); + + http.when("GET", "/room_keys/version").respond(200, (path, obj) => { + expect(keyBackupInfoOnServer).toBeDefined(); + expect(keyBackupInfoOnServer.version).toBe("1"); + + return keyBackupInfoOnServer; + }); + + await Promise.all([ + (async () => { + const keyBackupVersion = await client.signAndCreateKeyBackupVersion(keyBackupInfo); + expect(keyBackupVersion).toStrictEqual(keyBackupInfoOnServer.version); + + const keyBackupInfoRetrieved = await client.getKeyBackupVersion(); + expect(keyBackupInfoRetrieved).toStrictEqual(keyBackupInfoOnServer); + })(), + http.flushAllExpected(), + ]); + })); + + it('should fail to enable backups when the crypto has not been prepared', () => testCryptoStores(async (cryptoStoreType) => { + try { + await client.enableKeyBackup({ + algorithm: KeyBackupEncryptionAlgorithm.MegolmBackupV1Curve25519AesSha2, + auth_data: { + public_key: "fake_key", + signatures: {}, + }, + version: "1", + count: 0, + etag: "etag0", + }); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + })); + + it('should fail to enable backups with an unsupported algorithm', () => testCryptoStores(async (cryptoStoreType) => { + await prepareCrypto(); + + const algorithm = "bogocrypt"; + + try { + await client.enableKeyBackup({ + algorithm, + auth_data: { + signatures: {}, + }, + version: "0", + count: 0, + etag: "zz", + }); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Key backup error: cannot enable backups with unsupported backup algorithm " + algorithm); + } + })); + + it('correctly backs up keys', () => testCryptoStores(async (cryptoStoreType) => { + await prepareCrypto(); + + // --- Generate a room key by preparing encryption for that room + + const roomId = "!a:example.org"; + client.getRoomMembersByMembership = async () => [new MembershipEvent({ + type: "m.room.member", + sender: USER_ID, + state_key: USER_ID, + content: { + membership: "join", + }, + })]; + client.crypto.isRoomEncrypted = async () => true; + + const roomCryptoConfig: ICryptoRoomInformation = { + algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, + rotation_period_msgs: 1, + }; + ((client.crypto as any).roomTracker as RoomTracker).getRoomCryptoConfig = async () => roomCryptoConfig; + + const encryptRoomEvent = async () => { + bindNullQuery(http); + const encryptPromise = client.crypto.encryptRoomEvent(roomId, "m.room.message", "my message"); + await http.flushAllExpected({ timeout: 10000 }); + + // This is because encryptRoomEvent calls "/keys/query" after encrypting too. + bindNullQuery(http); + await Promise.all([ + encryptPromise, + http.flushAllExpected({ timeout: 10000 }), + ]); + }; + + await encryptRoomEvent(); + + // --- Back up the generated room key by enabling backups + + const authDataUnsigned: ICurve25519AuthDataUnsigned = { + public_key: generateCurve25519PublicKey(), + }; + const keyBackupInfo: IKeyBackupInfoRetrieved = { + algorithm: KeyBackupEncryptionAlgorithm.MegolmBackupV1Curve25519AesSha2, + auth_data: { + ...authDataUnsigned, + signatures: await client.crypto.sign(authDataUnsigned), + }, + version: "1", + count: 0, + etag: "etag0", + }; + + const knownSessions: Set = new Set(); + let expectedSessions = 0; + let etagCount = 0; + + const onBackupRequest = (path, obj: Record): IKeyBackupUpdateResponse => { + const sessions = obj?.rooms[roomId]?.sessions; + expect(sessions).toBeDefined(); + + Object.keys(sessions).forEach(session => { knownSessions.add(session); }); + return { + count: knownSessions.size, + etag: `etag${++etagCount}`, + }; + }; + + const expectToPutRoomKey = () => { + http.when("PUT", "/room_keys/keys").respond(200, onBackupRequest); + }; + + expectToPutRoomKey(); + await Promise.all([ + client.enableKeyBackup(keyBackupInfo), + http.flushAllExpected(), + ]); + expect(knownSessions.size).toBe(++expectedSessions); + + // --- Test that it's safe to re-enable backups + + // Re-enabling backups replays all existing keys, so expect another request to be made + expectToPutRoomKey(); + await Promise.all([ + client.enableKeyBackup(keyBackupInfo), + http.flushAllExpected(), + ]); + // No new session expected this time + expect(knownSessions.size).toBe(expectedSessions); + + // --- Back up a new room key by generating one while backups are enabled + + expectToPutRoomKey(); + await encryptRoomEvent(); + expect(knownSessions.size).toBe(++expectedSessions); + + // --- Back up a room key received via a to-device message + + const onRoomKeySpy = simple.mock((client.crypto as any).engine, "backupRoomKeys"); + + // TODO: Encrypt this so that it will actually be included in the backup. + // Until then, no backup request or new session are expected. + const toDeviceMessage: IToDeviceMessage = { + type: "m.room_key", + sender: USER_ID, + content: { + algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, + room_id: roomId, + session_id: "abc", + session_key: "def", + }, + }; + + bindNullEngine(http); + await Promise.all([ + client.crypto.updateSyncData( + [toDeviceMessage], + {}, [], [], [], + ), + http.flushAllExpected(), + ]); + expect(knownSessions.size).toBe(expectedSessions); + expect(onRoomKeySpy.callCount).toBe(1); + + // --- Export a room key + // TODO: consider moving this to a test dedicated to key exports + + for (const session of knownSessions) { + const roomKeys = await client.exportRoomKeysForSession(roomId, session); + expect(roomKeys).toHaveLength(roomCryptoConfig.rotation_period_msgs); + for (const roomKey of roomKeys) { + expect(roomKey.algorithm).toStrictEqual(RoomEncryptionAlgorithm.MegolmV1AesSha2); + expect(roomKey.room_id).toStrictEqual(roomId); + expect(roomKey.sender_key).toBeTruthy(); + expect(roomKey.session_id).toStrictEqual(session); + expect(roomKey.session_key).toBeTruthy(); + expect(roomKey.sender_claimed_keys).toBeTruthy(); + expect(roomKey.forwarding_curve25519_key_chain).toBeTruthy(); + } + } + + // --- Should not time out due to a mistake in the promise queue + await client.disableKeyBackup(); + }), + // Use longer timeout to give more time for encryption + 30000); +}); diff --git a/test/encryption/RoomTrackerTest.ts b/test/encryption/RoomTrackerTest.ts index 4ce0a00c..0968544f 100644 --- a/test/encryption/RoomTrackerTest.ts +++ b/test/encryption/RoomTrackerTest.ts @@ -1,8 +1,7 @@ import * as simple from "simple-mock"; import { EncryptionEventContent, MatrixClient, RoomEncryptionAlgorithm, RoomTracker } from "../../src"; -import { createTestClient, testCryptoStores, TEST_DEVICE_ID } from "../TestUtils"; -import { bindNullEngine } from "./CryptoClientTest"; +import { bindNullEngine, createTestClient, testCryptoStores, TEST_DEVICE_ID } from "../TestUtils"; function prepareQueueSpies( client: MatrixClient, @@ -45,7 +44,7 @@ describe('RoomTracker', () => { await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); (client.crypto as any).engine.addTrackedUsers = () => Promise.resolve(); @@ -73,7 +72,7 @@ describe('RoomTracker', () => { await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); bindNullEngine(http); await Promise.all([ - client.crypto.prepare([]), + client.crypto.prepare(), http.flushAllExpected(), ]); @@ -104,24 +103,6 @@ describe('RoomTracker', () => { expect(queueSpy.callCount).toEqual(1); })); - describe('prepare', () => { - it('should queue updates for rooms', async () => { - const roomIds = ["!a:example.org", "!b:example.org"]; - - const { client } = createTestClient(); - - const queueSpy = simple.stub().callFn((rid: string) => { - expect(rid).toEqual(roomIds[queueSpy.callCount - 1]); - return Promise.resolve(); - }); - - const tracker = new RoomTracker(client); - tracker.queueRoomCheck = queueSpy; - await tracker.prepare(roomIds); - expect(queueSpy.callCount).toEqual(2); - }); - }); - describe('queueRoomCheck', () => { it('should store unknown rooms', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!b:example.org"; diff --git a/tsconfig-examples.json b/tsconfig-examples.json index f8f7df60..ffc89cbf 100644 --- a/tsconfig-examples.json +++ b/tsconfig-examples.json @@ -4,7 +4,7 @@ "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2015", + "target": "es2022", "noImplicitAny": false, "sourceMap": false, "outDir": "./lib", diff --git a/tsconfig-release.json b/tsconfig-release.json index 9c46a11a..2eef97bf 100644 --- a/tsconfig-release.json +++ b/tsconfig-release.json @@ -4,7 +4,7 @@ "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2020", + "target": "es2022", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", diff --git a/tsconfig.json b/tsconfig.json index b3e34a85..72a7c3c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2020", + "target": "ES2022", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", diff --git a/yarn.lock b/yarn.lock index e68b6468..093247ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -654,10 +654,10 @@ dependencies: lodash "^4.17.21" -"@matrix-org/matrix-sdk-crypto-nodejs@0.1.0-beta.6": - version "0.1.0-beta.6" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.6.tgz#0ecae51103ee3c107af0d6d0738f33eb7cc9857e" - integrity sha512-JXyrHuCVMydUGgSetWsfqbbvHj3aUMOX5TUghlMtLFromyEu7wIsNgYt7PjJ+k3WdF4GVABRy4P6GNjaEMy2uA== +"@matrix-org/matrix-sdk-crypto-nodejs@0.1.0-beta.11": + version "0.1.0-beta.11" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.11.tgz#537cd7a7bbce1d9745b812a5a7ffa9a5944e146c" + integrity sha512-z5adcQo4o0UAry4zs6JHGxbTDlYTUMKUfpOpigmso65ETBDumbeTSQCWRw8UeUV7aCAyVoHARqDTol9SrauEFA== dependencies: https-proxy-agent "^5.0.1" node-downloader-helper "^2.1.5" @@ -804,10 +804,10 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express@^4.17.20": - version "4.17.20" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.20.tgz#e7c9b40276d29e38a4e3564d7a3d65911e2aa433" - integrity sha512-rOaqlkgEvOW495xErXMsmyX3WKBInbhG5eqojXYi3cGUaLoRDlXa5d52fkfWZT963AZ3v2eZ4MbKE6WpDAGVsw== +"@types/express@^4.17.21": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "^4.17.33"