From 4b85ab6b40b8f3b12603e273925d4f2401174830 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 23 Jul 2021 22:16:46 -0600 Subject: [PATCH 01/26] Add early support for encryption: Bootstrap process --- .gitignore | 1 + examples/encryption_bot.ts | 23 +++++ package.json | 9 +- src/MatrixClient.ts | 133 +++++++++++++++++++------- src/appservice/Appservice.ts | 4 +- src/e2ee/CryptoClient.ts | 179 +++++++++++++++++++++++++++++++++++ src/e2ee/decorators.ts | 38 ++++++++ src/isCryptoCapable.ts | 22 +++++ src/logging/LogService.ts | 14 +++ src/models/Crypto.ts | 71 ++++++++++++++ yarn.lock | 16 +++- 11 files changed, 473 insertions(+), 37 deletions(-) create mode 100644 examples/encryption_bot.ts create mode 100644 src/e2ee/CryptoClient.ts create mode 100644 src/e2ee/decorators.ts create mode 100644 src/isCryptoCapable.ts create mode 100644 src/models/Crypto.ts diff --git a/.gitignore b/.gitignore index cdd6b3ae..e2f8490c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/ lib/ +examples/storage/ # Logs logs diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts new file mode 100644 index 00000000..f4eab1ca --- /dev/null +++ b/examples/encryption_bot.ts @@ -0,0 +1,23 @@ +import { LogLevel, LogService, MatrixClient, RichConsoleLogger, SimpleFsStorageProvider } from "../src"; + +LogService.setLogger(new RichConsoleLogger()); +LogService.setLevel(LogLevel.TRACE); +LogService.muteModule("Metrics"); +LogService.trace = LogService.debug; + +let creds = null; +try { + creds = require("../../examples/storage/encryption_bot.creds.json"); +} catch (e) { + // ignore +} + +const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008"; +const accessToken = creds?.['accessToken'] ?? 'YOUR_TOKEN'; +const storage = new SimpleFsStorageProvider("./examples/storage/encryption_bot.json"); + +const client = new MatrixClient(homeserverUrl, accessToken, storage, true); + +(async function() { + await client.crypto.prepare(); +})(); diff --git a/package.json b/package.json index 1c5c2fa7..7c250fdd 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test": "ts-mocha --project ./tsconfig.json test/*Test.ts test/**/*.ts", "build:examples": "tsc -p tsconfig-examples.json", "example:appservice": "yarn build:examples && node lib/examples/appservice.js", - "example:login_register": "yarn build:examples && node lib/examples/login_register.js" + "example:login_register": "yarn build:examples && node lib/examples/login_register.js", + "example:encryption_bot": "yarn build:examples && node lib/examples/encryption_bot.js" }, "main": "./lib/index.js", "typings": "./lib/index.d.ts", @@ -47,8 +48,12 @@ "scripts/*", "tsconfig.json" ], + "optionalDependencies": { + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.4.tgz" + }, "dependencies": { "@types/express": "^4.17.7", + "another-json": "^0.2.0", "chalk": "^4.1.0", "express": "^4.17.1", "glob-to-regexp": "^0.4.1", @@ -81,6 +86,6 @@ "tmp": "^0.2.1", "ts-mocha": "^7.0.0", "tslint": "^6.1.3", - "typescript": "^3.9.7" + "typescript": "^4.3.5" } } diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 7a3859c8..b7ff6e64 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -24,6 +24,10 @@ import { htmlToText } from "html-to-text"; import { MatrixProfileInfo } from "./models/MatrixProfile"; import { Space, SpaceCreateOptions } from "./models/Spaces"; import { PowerLevelAction } from "./models/PowerLevelAction"; +import { CryptoClient } from "./e2ee/CryptoClient"; +import { isCryptoCapable } from "./isCryptoCapable"; +import { DeviceKeyAlgorithm, DeviceKeyLabel, EncryptionAlgorithm, OTKCounts, OTKs } from "./models/Crypto"; +import { requiresCrypto } from "./e2ee/decorators"; /** * A client that is capable of interacting with a matrix homeserver. @@ -46,6 +50,14 @@ export class MatrixClient extends EventEmitter { */ public syncingTimeout = 10000; + /** + * The crypto manager instance for this client. Generally speaking, this shouldn't + * need to be accessed but is made available. + * + * Will be null/undefined if crypto is not possible. + */ + public readonly crypto: CryptoClient; + private userId: string; private requestId = 0; private lastJoinedRoomIds: string[] = []; @@ -69,14 +81,28 @@ export class MatrixClient extends EventEmitter { * @param {string} homeserverUrl The homeserver's client-server API URL * @param {string} accessToken The access token for the homeserver * @param {IStorageProvider} storage The storage provider to use. Defaults to MemoryStorageProvider. + * @param {boolean} withCrypto True to enable end-to-end encryption, false (default) otherwise. */ - constructor(public readonly homeserverUrl: string, public readonly accessToken: string, private storage: IStorageProvider = null) { + constructor(public readonly homeserverUrl: string, public readonly accessToken: string, private storage: IStorageProvider = null, withCrypto = false) { super(); if (this.homeserverUrl.endsWith("/")) { this.homeserverUrl = this.homeserverUrl.substring(0, this.homeserverUrl.length - 1); } + const e2eeCapable = isCryptoCapable(); + if (withCrypto && !e2eeCapable) { + throw new Error("Cannot enable encryption: missing dependencies"); + } else if (withCrypto) { + if (!this.storage || this.storage instanceof MemoryStorageProvider) { + LogService.warn("MatrixClientLite", "Starting an encryption-capable client with a memory store is not considered a good idea."); + } + this.crypto = new CryptoClient(this); + LogService.debug("MatrixClientLite", "End-to-end encryption client created"); + } else { + LogService.trace("MatrixClientLite", "Not setting up encryption"); + } + if (!this.storage) this.storage = new MemoryStorageProvider(); } @@ -485,12 +511,20 @@ export class MatrixClient extends EventEmitter { public getUserId(): Promise { if (this.userId) return Promise.resolve(this.userId); - return this.doRequest("GET", "/_matrix/client/r0/account/whoami").then(response => { + return this.getWhoAmI().then(response => { this.userId = response["user_id"]; return this.userId; }); } + /** + * Gets the user's information from the server directly. + * @returns {Promise<{user_id: string, device_id?: string}>} The "who am I" response. + */ + public getWhoAmI(): Promise<{user_id: string, device_id?: string}> { + return this.doRequest("GET", "/_matrix/client/r0/account/whoami"); + } + /** * Stops the client from syncing. */ @@ -503,47 +537,50 @@ export class MatrixClient extends EventEmitter { * @param {any} filter The filter to use, or null for none * @returns {Promise} Resolves when the client has started syncing */ - public start(filter: any = null): Promise { + public async start(filter: any = null): Promise { this.stopSyncing = false; if (!filter || typeof (filter) !== "object") { LogService.trace("MatrixClientLite", "No filter given or invalid object - using defaults."); filter = null; } - return this.getUserId().then(async userId => { - let createFilter = false; + const userId = await this.getUserId(); - let existingFilter = await Promise.resolve(this.storage.getFilter()); - if (existingFilter) { - LogService.trace("MatrixClientLite", "Found existing filter. Checking consistency with given filter"); - if (JSON.stringify(existingFilter.filter) === JSON.stringify(filter)) { - LogService.trace("MatrixClientLite", "Filters match"); - this.filterId = existingFilter.id; - } else { - createFilter = true; - } + if (this.crypto) { + LogService.debug("MatrixClientLite", "Preparing end-to-end encryption"); + await this.crypto.prepare(); + LogService.info("MatrixClientLite", "End-to-end encryption enabled"); + } + + let createFilter = false; + + // noinspection ES6RedundantAwait + let existingFilter = await Promise.resolve(this.storage.getFilter()); + if (existingFilter) { + LogService.trace("MatrixClientLite", "Found existing filter. Checking consistency with given filter"); + if (JSON.stringify(existingFilter.filter) === JSON.stringify(filter)) { + LogService.trace("MatrixClientLite", "Filters match"); + this.filterId = existingFilter.id; } else { createFilter = true; } + } else { + createFilter = true; + } - if (createFilter && filter) { - LogService.trace("MatrixClientLite", "Creating new filter"); - return this.doRequest("POST", "/_matrix/client/r0/user/" + encodeURIComponent(userId) + "/filter", null, filter).then(async response => { - this.filterId = response["filter_id"]; - await Promise.resolve(this.storage.setSyncToken(null)); - await Promise.resolve(this.storage.setFilter({ - id: this.filterId, - filter: filter, - })); - }); - } - }).then(async () => { - LogService.trace("MatrixClientLite", "Populating joined rooms to avoid excessive join emits"); - this.lastJoinedRoomIds = await this.getJoinedRooms(); - - LogService.trace("MatrixClientLite", "Starting sync with filter ID " + this.filterId); - this.startSyncInternal(); - }); + if (createFilter && filter) { + LogService.trace("MatrixClientLite", "Creating new filter"); + return this.doRequest("POST", "/_matrix/client/r0/user/" + encodeURIComponent(userId) + "/filter", null, filter).then(async response => { + this.filterId = response["filter_id"]; + // noinspection ES6RedundantAwait + await Promise.resolve(this.storage.setSyncToken(null)); + // noinspection ES6RedundantAwait + await Promise.resolve(this.storage.setFilter({ + id: this.filterId, + filter: filter, + })); + }); + } } protected startSyncInternal(): Promise { @@ -1479,6 +1516,7 @@ export class MatrixClient extends EventEmitter { * @param {SpaceCreateOptions} opts The creation options. * @returns {Promise} Resolves to the created space. */ + @timedMatrixClientFunctionCall() public async createSpace(opts: SpaceCreateOptions): Promise { const roomCreateOpts = { name: opts.name, @@ -1534,6 +1572,7 @@ export class MatrixClient extends EventEmitter { * @throws If the room is not a space or there was an error * @returns {Promise} Resolves to the space. */ + @timedMatrixClientFunctionCall() public async getSpace(roomIdOrAlias: string): Promise { const roomId = await this.resolveRoom(roomIdOrAlias); const createEvent = await this.getRoomStateEvent(roomId, "m.room.create", ""); @@ -1543,6 +1582,36 @@ export class MatrixClient extends EventEmitter { return new Space(roomId, this); } + @timedMatrixClientFunctionCall() + @requiresCrypto() + public async uploadDeviceKeys(algorithms: EncryptionAlgorithm[], keys: Record, string>): Promise { + const obj = { + user_id: await this.getUserId(), + device_id: this.crypto.clientDeviceId, + algorithms: algorithms, + keys: keys, + }; + obj['signatures'] = await this.crypto.sign(obj); + return this.doRequest("POST", "/_matrix/client/r0/keys/upload", null, { + device_keys: obj, + }).then(r => r['one_time_key_counts']); + } + + @timedMatrixClientFunctionCall() + @requiresCrypto() + public async uploadDeviceOneTimeKeys(keys: OTKs): Promise { + return this.doRequest("POST", "/_matrix/client/r0/keys/upload", null, { + one_time_keys: keys, + }).then(r => r['one_time_key_counts']); + } + + @timedMatrixClientFunctionCall() + @requiresCrypto() + public async checkOneTimeKeyCounts(): Promise { + return this.doRequest("POST", "/_matrix/client/r0/keys/upload", null, {}) + .then(r => r['one_time_key_counts']); + } + /** * Performs a web request to the homeserver, applying appropriate authorization headers for * this client. diff --git a/src/appservice/Appservice.ts b/src/appservice/Appservice.ts index 041e5056..48b074dd 100644 --- a/src/appservice/Appservice.ts +++ b/src/appservice/Appservice.ts @@ -331,7 +331,7 @@ export class Appservice extends EventEmitter { * @returns {Promise} resolves when started */ public begin(): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { this.appServer = this.app.listen(this.options.port, this.options.bindAddress, () => resolve()); }).then(() => this.botIntent.ensureRegistered()); } @@ -613,7 +613,7 @@ export class Appservice extends EventEmitter { } LogService.info("Appservice", "Processing transaction " + txnId); - this.pendingTransactions[txnId] = new Promise(async (resolve) => { + this.pendingTransactions[txnId] = new Promise(async (resolve) => { for (let event of req.body["events"]) { LogService.info("Appservice", `Processing event of type ${event["type"]}`); event = await this.processEvent(event); diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts new file mode 100644 index 00000000..990a25e9 --- /dev/null +++ b/src/e2ee/CryptoClient.ts @@ -0,0 +1,179 @@ +import { MatrixClient } from "../MatrixClient"; +import { LogService } from "../logging/LogService"; +import * as Olm from "@matrix-org/olm"; +import * as crypto from "crypto"; +import * as anotherJson from "another-json"; +import { + DeviceKeyAlgorithm, + EncryptionAlgorithm, + OTKAlgorithm, + OTKCounts, OTKs, + Signatures, + SignedCurve25519OTK +} from "../models/Crypto"; +import { requiresReady } from "./decorators"; + +const DEVICE_ID_STORAGE_KEY = "device_id"; +const E25519_STORAGE_KEY = "device_ed25519"; +const C25519_STORAGE_KEY = "device_Curve25519"; +const PICKLE_STORAGE_KEY = "device_pickle_key"; +const OLM_ACCOUNT_STORAGE_KEY = "device_olm_account"; + +// noinspection ES6RedundantAwait +/** + * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly + * rather than creating one manually. + * @category Encryption + */ +export class CryptoClient { + private ready = false; + private deviceId: string; + private pickleKey: string; + private pickledAccount: string; + private deviceEd25519: string; + private deviceCurve25519: string; + private maxOTKs: number; + + public constructor(private client: MatrixClient) { + } + + public get clientDeviceId(): string { + return this.deviceId; + } + + public get isReady(): boolean { + return this.ready; + } + + private async getOlmAccount(): Promise { + const account = new Olm.Account(); + account.unpickle(this.pickleKey, this.pickledAccount); + return account; + } + + private async storeAndFreeOlmAccount(account: Olm.Account) { + const pickled = account.pickle(this.pickleKey); + await Promise.resolve(this.client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, pickled)); + account.free(); + } + + public async prepare() { + const storedDeviceId = await Promise.resolve(this.client.storageProvider.readValue(DEVICE_ID_STORAGE_KEY)); + 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 Promise.resolve(this.client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, this.deviceId)); + } + + LogService.debug("CryptoClient", "Starting with device ID:", this.deviceId); + + // We should be in a ready enough shape to kick off Olm + await Olm.init(); + + let pickled = await (Promise.resolve(this.client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY))); + let deviceC25519 = await (Promise.resolve(this.client.storageProvider.readValue(C25519_STORAGE_KEY))); + let deviceE25519 = await (Promise.resolve(this.client.storageProvider.readValue(E25519_STORAGE_KEY))); + let pickleKey = await (Promise.resolve(this.client.storageProvider.readValue(PICKLE_STORAGE_KEY))); + + const account = new Olm.Account(); + try { + if (!pickled || !deviceC25519 || !deviceE25519 || !pickleKey) { + LogService.debug("CryptoClient", "Creating new Olm account: previous session lost or not set up"); + + account.create(); + pickleKey = crypto.randomBytes(64).toString('hex'); + pickled = account.pickle(pickleKey); + await Promise.resolve(this.client.storageProvider.storeValue(PICKLE_STORAGE_KEY, pickleKey)); + await Promise.resolve(this.client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, pickled)); + + this.pickleKey = pickleKey; + this.pickledAccount = pickled; + + const keys = JSON.parse(account.identity_keys()); + this.deviceCurve25519 = keys['curve25519']; + this.deviceEd25519 = keys['ed25519']; + + await Promise.resolve(this.client.storageProvider.storeValue(E25519_STORAGE_KEY, this.deviceEd25519)); + await Promise.resolve(this.client.storageProvider.storeValue(C25519_STORAGE_KEY, this.deviceCurve25519)); + + this.maxOTKs = account.max_number_of_one_time_keys(); + this.ready = true; + + const counts = await this.client.uploadDeviceKeys([ + EncryptionAlgorithm.MegolmV1AesSha2, + EncryptionAlgorithm.OlmV1Curve25519AesSha2, + ], { + [`${DeviceKeyAlgorithm.Ed25119}:${this.deviceId}`]: this.deviceEd25519, + [`${DeviceKeyAlgorithm.Curve25519}:${this.deviceId}`]: this.deviceCurve25519, + }); + await this.tryOtkUpload(counts); + } else { + account.unpickle(pickleKey, pickled); + this.pickleKey = pickleKey; + this.pickledAccount = pickled; + this.deviceEd25519 = deviceE25519; + this.deviceCurve25519 = deviceC25519; + this.maxOTKs = account.max_number_of_one_time_keys(); + this.ready = true; + await this.tryOtkUpload(await this.client.checkOneTimeKeyCounts()); + } + } finally { + account.free(); + } + } + + @requiresReady() + private async tryOtkUpload(counts: OTKCounts) { + const have = counts[OTKAlgorithm.Signed] || 0; + const need = Math.floor(this.maxOTKs / 2) - have; + if (need <= 0) return; + + const account = await this.getOlmAccount(); + try { + account.generate_one_time_keys(need); + const { curve25519: keys } = JSON.parse(account.one_time_keys()); + const signed: OTKs = {}; + for (const keyId in keys) { + if (!keys.hasOwnProperty(keyId)) continue; + const obj = {key: keys[keyId]}; + obj['signatures'] = await this.sign(obj); + signed[`${OTKAlgorithm.Signed}:${keyId}`] = obj; + } + await this.client.uploadDeviceOneTimeKeys(signed); + account.mark_keys_as_published(); + } finally { + await this.storeAndFreeOlmAccount(account); + } + } + + /** + * Signs an object using the device keys. + * @param {object} obj The object to sign. + * @returns {Promise} The signatures for the object. + */ + @requiresReady() + public async sign(obj: object): Promise { + const existingSignatures = obj['signatures'] || {}; + + delete obj['signatures']; + delete obj['unsigned']; + + const account = await this.getOlmAccount(); + try { + const sig = account.sign(anotherJson.stringify(obj)); + return { + ...existingSignatures, + [await this.client.getUserId()]: { + [`${DeviceKeyAlgorithm.Ed25119}:${this.deviceId}`]: sig, + }, + }; + } finally { + account.free(); + } + } +} diff --git a/src/e2ee/decorators.ts b/src/e2ee/decorators.ts new file mode 100644 index 00000000..e9616fc6 --- /dev/null +++ b/src/e2ee/decorators.ts @@ -0,0 +1,38 @@ +import { MatrixClient } from "../MatrixClient"; +import { CryptoClient } from "./CryptoClient"; + +/** + * Flags a MatrixClient function as needing end-to-end encryption enabled. + * @category Encryption + */ +export function requiresCrypto() { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + descriptor.value = function (...args: any[]) { + const client: MatrixClient = this; + if (!client.crypto) { + throw new Error("End-to-end encryption is not enabled"); + } + + return originalMethod.apply(this, args); + }; + }; +} + +/** + * Flags a CryptoClient function as needing the CryptoClient to be ready. + * @category Encryption + */ +export function requiresReady() { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + descriptor.value = function (...args: any[]) { + const crypto: CryptoClient = this; + if (!crypto.isReady) { + throw new Error("End-to-end encryption has not initialized"); + } + + return originalMethod.apply(this, args); + }; + }; +} diff --git a/src/isCryptoCapable.ts b/src/isCryptoCapable.ts new file mode 100644 index 00000000..9af57ae4 --- /dev/null +++ b/src/isCryptoCapable.ts @@ -0,0 +1,22 @@ +import { LogService } from "./logging/LogService"; + +let hasDependency: boolean = null; + +/** + * Determines if the project is capable of running end-to-end encryption, aside + * from solutions like Pantalaimon. + * @category Encryption + */ +export function isCryptoCapable(): boolean { + if (hasDependency !== null) return hasDependency; + + try { + require("@matrix-org/olm"); + hasDependency = true; + } catch (e) { + LogService.error("isCryptoCapable", "Failed check: ", e); + hasDependency = false; + } + + return hasDependency; +} diff --git a/src/logging/LogService.ts b/src/logging/LogService.ts index ba77d66b..77648f21 100644 --- a/src/logging/LogService.ts +++ b/src/logging/LogService.ts @@ -62,6 +62,7 @@ export class LogService { private static logger: ILogger = new ConsoleLogger(); private static logLevel: LogLevel = LogLevel.INFO; + private static mutedModules: string[] = []; private constructor() { } @@ -89,6 +90,14 @@ export class LogService { LogService.logger = logger; } + /** + * Mutes a module from the logger. + * @param {string} name The module name to mute. + */ + public static muteModule(name: string) { + LogService.mutedModules.push(name); + } + /** * Logs to the TRACE channel * @param {string} module The module being logged @@ -96,6 +105,7 @@ export class LogService { */ public static trace(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.TRACE)) return; + if (LogService.mutedModules.includes(module)) return; LogService.logger.trace(module, ...messageOrObject); } @@ -106,6 +116,7 @@ export class LogService { */ public static debug(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.DEBUG)) return; + if (LogService.mutedModules.includes(module)) return; LogService.logger.debug(module, ...messageOrObject); } @@ -116,6 +127,7 @@ export class LogService { */ public static error(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.ERROR)) return; + if (LogService.mutedModules.includes(module)) return; LogService.logger.error(module, ...messageOrObject); } @@ -126,6 +138,7 @@ export class LogService { */ public static info(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.INFO)) return; + if (LogService.mutedModules.includes(module)) return; LogService.logger.info(module, ...messageOrObject); } @@ -136,6 +149,7 @@ export class LogService { */ public static warn(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.WARN)) return; + if (LogService.mutedModules.includes(module)) return; LogService.logger.warn(module, ...messageOrObject); } } diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts new file mode 100644 index 00000000..1070179b --- /dev/null +++ b/src/models/Crypto.ts @@ -0,0 +1,71 @@ +/** + * One time key algorithms. + * @category Models + */ +export enum OTKAlgorithm { + Signed = "signed_curve25519", + Unsigned = "curve25519", +} + +/** + * Label for a one time key. + * @category Models + */ +export type OTKLabel = `${Algorithm}:${ID}`; + +/** + * Signatures object. + * @category Models + */ +export interface Signatures { + [entity: string]: { + [keyLabel: string]: string; + }; +} + +/** + * A signed_curve25519 one time key. + * @category Models + */ +export interface SignedCurve25519OTK { + key: string; + signatures: Signatures; +} + +/** + * One Time Keys structure model. + * @category Models + */ +export type OTKs = Record, SignedCurve25519OTK> & Record, string>; + +/** + * The counts of each one time key by algorithm. + * @category Models + */ +export type OTKCounts = { + [alg in OTKAlgorithm]?: number; +}; + +/** + * The available encryption algorithms. + * @category Models + */ +export enum EncryptionAlgorithm { + OlmV1Curve25519AesSha2 = "m.olm.v1.curve25519-aes-sha2", + MegolmV1AesSha2 = "m.megolm.v1.aes-sha2", +} + +/** + * The key algorithms for device keys. + * @category Models + */ +export enum DeviceKeyAlgorithm { + Ed25119 = "ed25519", + Curve25519 = "curve25519", +} + +/** + * Label for a device key. + * @category Models + */ +export type DeviceKeyLabel = `${Algorithm}:${ID}`; diff --git a/yarn.lock b/yarn.lock index f29e2b32..0269d376 100644 --- a/yarn.lock +++ b/yarn.lock @@ -268,6 +268,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.4.tgz": + version "3.2.4" + resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.4.tgz#edc0156a17eb1087df44f6e0b153ef0c9d454495" + "@types/babel-types@*", "@types/babel-types@^7.0.0": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.9.tgz#01d7b86949f455402a94c788883fe4ba574cad41" @@ -545,6 +549,11 @@ align-text@^0.1.1, align-text@^0.1.3: longest "^1.0.1" repeat-string "^1.5.2" +another-json@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/another-json/-/another-json-0.2.0.tgz#b5f4019c973b6dd5c6506a2d93469cb6d32aeedc" + integrity sha1-tfQBnJc7bdXGUGotk0acttMq7tw= + ansi-colors@4.1.1, ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -4227,11 +4236,16 @@ typescript@^3.2.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.2.tgz#27e489b95fa5909445e9fef5ee48d81697ad18fb" integrity sha512-ml7V7JfiN2Xwvcer+XAf2csGO1bPBdRbFCkYBczNZggrBZ9c7G3riSUeJmqEU5uOtXNPMhE3n+R4FA/3YOAWOQ== -typescript@^3.7.5, typescript@^3.9.7: +typescript@^3.7.5: version "3.9.7" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== +typescript@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" + integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== + uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" From cc2f5ea58732687998366dae7f0f8b1bcdf412f2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Jul 2021 13:08:46 -0600 Subject: [PATCH 02/26] Link encryption up to sync --- examples/encryption_bot.ts | 8 +++- src/MatrixClient.ts | 10 +++++ src/e2ee/CryptoClient.ts | 25 +++++++++++ src/e2ee/RoomTracker.ts | 59 ++++++++++++++++++++++++++ src/models/events/EncryptionEvent.ts | 63 ++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/e2ee/RoomTracker.ts create mode 100644 src/models/events/EncryptionEvent.ts diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index f4eab1ca..a37b7331 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -1,4 +1,5 @@ import { LogLevel, LogService, MatrixClient, RichConsoleLogger, SimpleFsStorageProvider } from "../src"; +import { RoomEncryptionAlgorithm } from "../src/models/events/EncryptionEvent"; LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.TRACE); @@ -19,5 +20,10 @@ const storage = new SimpleFsStorageProvider("./examples/storage/encryption_bot.j const client = new MatrixClient(homeserverUrl, accessToken, storage, true); (async function() { - await client.crypto.prepare(); + client.on("room.event", (roomId: string, event: any) => { + LogService.debug("index", `${roomId}`, event); + }); + + LogService.info("index", "Starting bot..."); + await client.start(); })(); diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index b7ff6e64..ac37930d 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -581,6 +581,12 @@ export class MatrixClient extends EventEmitter { })); }); } + + LogService.trace("MatrixClientLite", "Populating joined rooms to avoid excessive join emits"); + this.lastJoinedRoomIds = await this.getJoinedRooms(); + + LogService.trace("MatrixClientLite", "Starting sync with filter ID " + this.filterId); + return this.startSyncInternal(); } protected startSyncInternal(): Promise { @@ -642,6 +648,10 @@ export class MatrixClient extends EventEmitter { if (!raw) return; // nothing to process + if (raw['device_one_time_keys_count']) { + this.crypto?.updateCounts(raw['device_one_time_keys_count']); + } + if (raw['groups']) { const leave = raw['groups']['leave'] || {}; for (const groupId of Object.keys(leave)) { diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 990a25e9..c441635f 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -12,6 +12,7 @@ import { SignedCurve25519OTK } from "../models/Crypto"; import { requiresReady } from "./decorators"; +import { RoomTracker } from "./RoomTracker"; const DEVICE_ID_STORAGE_KEY = "device_id"; const E25519_STORAGE_KEY = "device_ed25519"; @@ -33,8 +34,10 @@ export class CryptoClient { private deviceEd25519: string; private deviceCurve25519: string; private maxOTKs: number; + private roomTracker: RoomTracker; public constructor(private client: MatrixClient) { + this.roomTracker = new RoomTracker(this.client); } public get clientDeviceId(): string { @@ -127,12 +130,34 @@ export class CryptoClient { } } + /** + * Updates the One Time Key counts, potentially triggering an async upload of more + * one time keys. + * @param {OTKCounts} counts The current counts to work within. + */ + public updateCounts(counts: OTKCounts) { + // noinspection JSIgnoredPromiseFromCall + this.tryOtkUpload(counts); + } + + /** + * Checks if a room is encrypted. + * @param {string} roomId The room ID to check. + * @returns {Promise} Resolves to true if encrypted, false otherwise. + */ + public async isRoomEncrypted(roomId: string): Promise { + const config = await this.roomTracker.getRoomCryptoConfig(roomId); + return !!config?.algorithm; + } + @requiresReady() private async tryOtkUpload(counts: OTKCounts) { const have = counts[OTKAlgorithm.Signed] || 0; const need = Math.floor(this.maxOTKs / 2) - have; if (need <= 0) return; + LogService.debug("CryptoClient", `Creating ${need} more OTKs`); + const account = await this.getOlmAccount(); try { account.generate_one_time_keys(need); diff --git a/src/e2ee/RoomTracker.ts b/src/e2ee/RoomTracker.ts new file mode 100644 index 00000000..d3bb7bd5 --- /dev/null +++ b/src/e2ee/RoomTracker.ts @@ -0,0 +1,59 @@ +import { MatrixClient } from "../MatrixClient"; +import { EncryptionEventContent } from "../models/events/EncryptionEvent"; + +const ROOM_STORAGE_PREFIX = "tracked_room."; + +// noinspection ES6RedundantAwait +/** + * Tracks room encryption status for a MatrixClient. + * @category Encryption + */ +export class RoomTracker { + public constructor(private client: MatrixClient) { + this.client.getJoinedRooms().then(roomIds => { + for (const roomId of roomIds) { + // noinspection JSIgnoredPromiseFromCall + this.queueRoomCheck(roomId); + } + }); + + this.client.on("room.join", (roomId: string) => { + // noinspection JSIgnoredPromiseFromCall + this.queueRoomCheck(roomId); + }); + + this.client.on("room.event", (roomId: string, event: any) => { + if (event['type'] === 'm.room.encryption' && event['state_key'] === '') { + // noinspection JSIgnoredPromiseFromCall + this.queueRoomCheck(roomId); + } + }); + } + + public async queueRoomCheck(roomId: string) { + const key = `${ROOM_STORAGE_PREFIX}${roomId}`; + const config = await Promise.resolve(this.client.storageProvider.readValue(key)); + if (config) { + const parsed: EncryptionEventContent = JSON.parse(config); + if (parsed.algorithm !== undefined) { + return; // assume no change to encryption config + } + } + + const encEvent = await this.client.getRoomStateEvent(roomId, "m.room.encryption", ""); + await Promise.resolve(this.client.storageProvider.storeValue(key, JSON.stringify(encEvent))); + } + + public async getRoomCryptoConfig(roomId: string): Promise> { + const key = `${ROOM_STORAGE_PREFIX}${roomId}`; + let config = await Promise.resolve(this.client.storageProvider.readValue(key)); + if (!config) { + await this.queueRoomCheck(roomId); + config = await Promise.resolve(this.client.storageProvider.readValue(key)); + } + if (!config) { + return {}; + } + return JSON.parse(config); + } +} diff --git a/src/models/events/EncryptionEvent.ts b/src/models/events/EncryptionEvent.ts new file mode 100644 index 00000000..522efeb2 --- /dev/null +++ b/src/models/events/EncryptionEvent.ts @@ -0,0 +1,63 @@ +import { StateEvent } from "./RoomEvent"; + +/** + * The kinds of room encryption algorithms allowed by the spec. + * @category Models + * @see EncryptionEvent + */ +export enum RoomEncryptionAlgorithm { + MegolmV1AesSha2 = "m.megolm.v1.aes-sha2", +} + +/** + * The content definition for m.room.encryption events + * @category Matrix event contents + * @see EncryptionEvent + */ +export interface EncryptionEventContent { + /** + * The encryption algorithm for the room. + */ + algorithm: string | RoomEncryptionAlgorithm; + + /** + * How long a session should be used before changing it. + */ + rotation_period_ms?: number; + + /** + * How many messages should be sent before changing the session. + */ + rotation_period_msgs?: number; +} + +/** + * Represents an m.room.encryption state event + * @category Matrix events + */ +export class EncryptionEvent extends StateEvent { + constructor(event: any) { + super(event); + } + + /** + * The encryption algorithm for the room. + */ + public get algorithm(): string | RoomEncryptionAlgorithm { + return this.content.algorithm; + } + + /** + * How long a session should be used before changing it. Defaults to a week. + */ + public get rotationPeriodMs(): number { + return this.content.rotation_period_ms ?? 604800000; // 1 week + } + + /** + * How many messages should be sent before a session changes. Defaults to 100. + */ + public get rotationPeriodMessages(): number { + return this.content.rotation_period_msgs ?? 100; + } +} From aab56103574238fde669699aefd60ae35b17e45e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Jul 2021 23:07:07 -0600 Subject: [PATCH 03/26] First round of tests --- package.json | 1 + src/MatrixClient.ts | 9 +- src/e2ee/CryptoClient.ts | 26 ++- src/e2ee/RoomTracker.ts | 36 +++- src/index.ts | 8 + test/MatrixClientTest.ts | 211 ++++++++++++++++++-- test/TestUtils.ts | 32 +++ test/encryption/RoomTrackerTest.ts | 228 ++++++++++++++++++++++ test/encryption/decoratorsTest.ts | 120 ++++++++++++ test/isCryptoCapableTest.ts | 17 ++ test/logging/LogServiceTest.ts | 27 +++ test/models/events/EncryptionEventTest.ts | 31 +++ yarn.lock | 5 + 13 files changed, 720 insertions(+), 31 deletions(-) create mode 100644 test/encryption/RoomTrackerTest.ts create mode 100644 test/encryption/decoratorsTest.ts create mode 100644 test/isCryptoCapableTest.ts create mode 100644 test/models/events/EncryptionEventTest.ts diff --git a/package.json b/package.json index 7c250fdd..fa796a65 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/expect": "^24.3.0", "@types/mocha": "^8.0.1", "@types/node": "10", + "@types/simple-mock": "^0.8.2", "@typescript-eslint/eslint-plugin": "^3.8.0", "@typescript-eslint/eslint-plugin-tslint": "^3.8.0", "@typescript-eslint/parser": "^3.8.0", diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index ac37930d..7e2813fb 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -544,11 +544,14 @@ export class MatrixClient extends EventEmitter { filter = null; } + LogService.trace("MatrixClientLite", "Populating joined rooms to avoid excessive join emits"); + this.lastJoinedRoomIds = await this.getJoinedRooms(); + const userId = await this.getUserId(); if (this.crypto) { LogService.debug("MatrixClientLite", "Preparing end-to-end encryption"); - await this.crypto.prepare(); + await this.crypto.prepare(this.lastJoinedRoomIds); LogService.info("MatrixClientLite", "End-to-end encryption enabled"); } @@ -582,9 +585,6 @@ export class MatrixClient extends EventEmitter { }); } - LogService.trace("MatrixClientLite", "Populating joined rooms to avoid excessive join emits"); - this.lastJoinedRoomIds = await this.getJoinedRooms(); - LogService.trace("MatrixClientLite", "Starting sync with filter ID " + this.filterId); return this.startSyncInternal(); } @@ -594,6 +594,7 @@ export class MatrixClient extends EventEmitter { } protected async startSync(emitFn: (emitEventType: string, ...payload: any[]) => Promise = null) { + // noinspection ES6RedundantAwait let token = await Promise.resolve(this.storage.getSyncToken()); const promiseWhile = async () => { diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index c441635f..27ef73a6 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -9,16 +9,15 @@ import { OTKAlgorithm, OTKCounts, OTKs, Signatures, - SignedCurve25519OTK } from "../models/Crypto"; import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; -const DEVICE_ID_STORAGE_KEY = "device_id"; -const E25519_STORAGE_KEY = "device_ed25519"; -const C25519_STORAGE_KEY = "device_Curve25519"; -const PICKLE_STORAGE_KEY = "device_pickle_key"; -const OLM_ACCOUNT_STORAGE_KEY = "device_olm_account"; +export const DEVICE_ID_STORAGE_KEY = "device_id"; +export const E25519_STORAGE_KEY = "device_ed25519"; +export const C25519_STORAGE_KEY = "device_Curve25519"; +export const PICKLE_STORAGE_KEY = "device_pickle_key"; +export const OLM_ACCOUNT_STORAGE_KEY = "device_olm_account"; // noinspection ES6RedundantAwait /** @@ -40,10 +39,17 @@ export class CryptoClient { this.roomTracker = new RoomTracker(this.client); } + /** + * The device ID for the MatrixClient. + */ public get clientDeviceId(): string { return this.deviceId; } + /** + * Whether or not the crypto client is ready to be used. If not ready, prepare() should be called. + * @see prepare + */ public get isReady(): boolean { return this.ready; } @@ -60,7 +66,13 @@ export class CryptoClient { account.free(); } - public async prepare() { + /** + * 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); + const storedDeviceId = await Promise.resolve(this.client.storageProvider.readValue(DEVICE_ID_STORAGE_KEY)); if (storedDeviceId) { this.deviceId = storedDeviceId; diff --git a/src/e2ee/RoomTracker.ts b/src/e2ee/RoomTracker.ts index d3bb7bd5..11b772a5 100644 --- a/src/e2ee/RoomTracker.ts +++ b/src/e2ee/RoomTracker.ts @@ -10,13 +10,6 @@ const ROOM_STORAGE_PREFIX = "tracked_room."; */ export class RoomTracker { public constructor(private client: MatrixClient) { - this.client.getJoinedRooms().then(roomIds => { - for (const roomId of roomIds) { - // noinspection JSIgnoredPromiseFromCall - this.queueRoomCheck(roomId); - } - }); - this.client.on("room.join", (roomId: string) => { // noinspection JSIgnoredPromiseFromCall this.queueRoomCheck(roomId); @@ -30,6 +23,21 @@ 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. + * @param {string} roomId The room ID to check. + */ public async queueRoomCheck(roomId: string) { const key = `${ROOM_STORAGE_PREFIX}${roomId}`; const config = await Promise.resolve(this.client.storageProvider.readValue(key)); @@ -40,10 +48,22 @@ export class RoomTracker { } } - const encEvent = await this.client.getRoomStateEvent(roomId, "m.room.encryption", ""); + let encEvent: Partial; + try { + encEvent = await this.client.getRoomStateEvent(roomId, "m.room.encryption", ""); + encEvent.algorithm = encEvent.algorithm ?? 'UNKNOWN'; + } catch (e) { + return; // failure == no encryption + } await Promise.resolve(this.client.storageProvider.storeValue(key, JSON.stringify(encEvent))); } + /** + * Gets the room's crypto configuration, as known by the underlying store. If the room is + * not encrypted then this will return an empty object. + * @param {string} roomId The room ID to get the config for. + * @returns {Promise>} Resolves to the encryption config. + */ public async getRoomCryptoConfig(roomId: string): Promise> { const key = `${ROOM_STORAGE_PREFIX}${roomId}`; let config = await Promise.resolve(this.client.storageProvider.readValue(key)); diff --git a/src/index.ts b/src/index.ts index f9abf7b2..a80be9a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,11 @@ export * from "./appservice/Intent"; export * from "./appservice/MatrixBridge"; export * from "./appservice/http_responses"; +// Encryption +export * from "./e2ee/RoomTracker"; +export * from "./e2ee/CryptoClient"; +export * from "./e2ee/decorators"; + // Helpers export * from "./helpers/RichReply"; export * from "./helpers/MentionPill"; @@ -40,6 +45,7 @@ export * from "./models/Policies"; export * from "./models/Threepid"; export * from "./models/Spaces"; export * from "./models/IdentityServerModels"; +export * from "./models/Crypto"; // Event models export * from "./models/events/EventKind"; @@ -61,6 +67,7 @@ export * from "./models/events/RoomAvatarEvent"; export * from "./models/events/RoomNameEvent"; export * from "./models/events/RoomTopicEvent"; export * from "./models/events/SpaceChildEvent"; +export * from "./models/events/EncryptionEvent"; // Preprocessors export * from "./preprocessors/IPreprocessor"; @@ -90,3 +97,4 @@ export * from "./PantalaimonClient"; export * from "./SynchronousMatrixClient"; export * from "./SynapseAdminApis"; export * from "./simple-validation"; +export * from "./isCryptoCapable"; diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index 21980e40..b531f8a9 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -1,26 +1,33 @@ import * as expect from "expect"; import { + C25519_STORAGE_KEY, + DeviceKeyAlgorithm, + DeviceKeyLabel, E25519_STORAGE_KEY, + EncryptionAlgorithm, EventKind, IJoinRoomStrategy, IPreprocessor, IStorageProvider, MatrixClient, Membership, - MemoryStorageProvider, - OpenIDConnectToken, + MemoryStorageProvider, OLM_ACCOUNT_STORAGE_KEY, + OpenIDConnectToken, OTKAlgorithm, OTKCounts, OTKs, PICKLE_STORAGE_KEY, RoomDirectoryLookupResponse, setRequestFn, } from "../src"; import * as simple from "simple-mock"; import * as MockHttpBackend from 'matrix-mock-request'; -import { expectArrayEquals } from "./TestUtils"; +import { expectArrayEquals, feedOlmAccount } from "./TestUtils"; import { redactObjectForLogging } from "../src/http"; import { PowerLevelAction } from "../src/models/PowerLevelAction"; +import { cryptoIt, notCryptoIt } from "./isCryptoCapableTest"; -export function createTestClient(storage: IStorageProvider = null, userId: string = null): { client: MatrixClient, http: MockHttpBackend, hsUrl: string, accessToken: string } { +export const TEST_DEVICE_ID = "TEST_DEVICE"; + +export function createTestClient(storage: IStorageProvider = null, userId: string = null, crypto = false): { client: MatrixClient, http: MockHttpBackend, hsUrl: string, accessToken: string } { const http = new MockHttpBackend(); const hsUrl = "https://localhost"; const accessToken = "s3cret"; - const client = new MatrixClient(hsUrl, accessToken, storage); + const client = new MatrixClient(hsUrl, accessToken, storage, crypto); (client).userId = userId; // private member access setRequestFn(http.requestFn); @@ -48,6 +55,36 @@ describe('MatrixClient', () => { expect(client.homeserverUrl).toEqual(homeserverUrl); expect(client.accessToken).toEqual(accessToken); }); + + cryptoIt('should create a crypto client when requested', () => { + const homeserverUrl = "https://example.org"; + const accessToken = "example_token"; + + const client = new MatrixClient(homeserverUrl, accessToken, null, true); + expect(client.crypto).toBeDefined(); + }); + + it('should NOT create a crypto client when requested', () => { + const homeserverUrl = "https://example.org"; + const accessToken = "example_token"; + + const client = new MatrixClient(homeserverUrl, accessToken, null, false); + expect(client.crypto).toBeUndefined(); + }); + + notCryptoIt('should fail to create a crypto client', () => { + const homeserverUrl = "https://example.org"; + const accessToken = "example_token"; + + try { + new MatrixClient(homeserverUrl, accessToken, null, true); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Cannot enable encryption: missing dependencies"); + } + }); }); describe("doRequest", () => { @@ -661,7 +698,7 @@ describe('MatrixClient', () => { const roomId = "!abc123:example.org"; const alias = "#test:example.org"; - const spy = simple.stub().returnWith(new Promise(((resolve, reject) => resolve({ + const spy = simple.stub().returnWith(new Promise(((resolve, reject) => resolve({ roomId: roomId, residentServers: [] })))); @@ -836,18 +873,33 @@ describe('MatrixClient', () => { }); it('should request the user ID if it is not known', async () => { - const {client, http} = createTestClient(); + const {client} = createTestClient(); const userId = "@example:matrix.org"; + client.getWhoAmI = () => Promise.resolve({user_id: userId}); - http.when("GET", "/_matrix/client/r0/account/whoami").respond(200, {user_id: userId}); - - http.flushAllExpected(); const result = await client.getUserId(); expect(result).toEqual(userId); }); }); + describe('getWhoAmI', () => { + it('should call the right endpoint', async () => { + const {client, http} = createTestClient(); + + const response = { + user_id: "@user:example.org", + device_id: "DEVICE", + }; + + http.when("GET", "/_matrix/client/r0/account/whoami").respond(200, response); + + http.flushAllExpected(); + const result = await client.getWhoAmI(); + expect(result).toMatchObject(response); + }); + }); + describe('stop', () => { it('should stop when requested', async () => { const {client, http} = createTestClient(); @@ -1007,7 +1059,7 @@ describe('MatrixClient', () => { const filterId = "abc12345"; const secondToken = "second"; - const waitPromise = new Promise(((resolve, reject) => { + const waitPromise = new Promise(((resolve, reject) => { simple.mock(storage, "getFilter").returnWith({id: filterId, filter: filter}); const setSyncTokenFn = simple.mock(storage, "setSyncToken").callFn(newToken => { expect(newToken).toEqual(secondToken); @@ -1047,7 +1099,7 @@ describe('MatrixClient', () => { simple.mock(storage, "getFilter").returnWith({id: filterId, filter: filter}); const getSyncTokenFn = simple.mock(storage, "getSyncToken").returnWith(syncToken); - const waitPromise = new Promise(((resolve, reject) => { + const waitPromise = new Promise(((resolve, reject) => { simple.mock(storage, "setSyncToken").callFn(newToken => { expect(newToken).toEqual(syncToken); resolve(); @@ -5156,6 +5208,141 @@ describe('MatrixClient', () => { }); }); + describe('uploadDeviceKeys', () => { + notCryptoIt('it should fail', async () => { + try { + const { client } = createTestClient(); + await client.uploadDeviceKeys([], {}); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption is not enabled"); + } + }); + + cryptoIt('it should call the right endpoint', async () => { + const userId = "@test:example.org"; + const { client, http } = createTestClient(null, userId, true); + + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + (client.crypto).tryOtkUpload = () => Promise.resolve(); // private member access + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await feedOlmAccount(client); + await client.crypto.prepare([]); + + const algorithms = [EncryptionAlgorithm.MegolmV1AesSha2, EncryptionAlgorithm.OlmV1Curve25519AesSha2]; + const keys: Record, string> = { + [DeviceKeyAlgorithm.Curve25519 + ":" + TEST_DEVICE_ID]: "key1", + [DeviceKeyAlgorithm.Ed25119 + ":" + TEST_DEVICE_ID]: "key2", + }; + const counts: OTKCounts = { + [OTKAlgorithm.Signed]: 12, + [OTKAlgorithm.Unsigned]: 14, + }; + + http.when("POST", "/_matrix/client/r0/keys/upload").respond(200, (path, content) => { + expect(content).toMatchObject({ + device_keys: { + user_id: userId, + device_id: TEST_DEVICE_ID, + algorithms: algorithms, + keys: keys, + signatures: { + [userId]: { + [DeviceKeyAlgorithm.Ed25119 + ":" + TEST_DEVICE_ID]: expect.any(String), + }, + }, + }, + }); + return { one_time_key_counts: counts }; + }); + + http.flushAllExpected(); + const result = await client.uploadDeviceKeys(algorithms, keys); + expect(result).toMatchObject(counts); + }); + }); + + describe('uploadDeviceOneTimeKeys', () => { + notCryptoIt('it should fail', async () => { + try { + const { client } = createTestClient(); + await client.uploadDeviceOneTimeKeys({}); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption is not enabled"); + } + }); + + cryptoIt('it should call the right endpoint', async () => { + const userId = "@test:example.org"; + const { client, http } = createTestClient(null, userId, true); + + const keys: OTKs = { + [OTKAlgorithm.Signed]: { + key: "test", + signatures: { + "entity": { + "device": "sig", + }, + }, + }, + [OTKAlgorithm.Unsigned]: "unsigned", + }; + const counts: OTKCounts = { + [OTKAlgorithm.Signed]: 12, + [OTKAlgorithm.Unsigned]: 14, + }; + + http.when("POST", "/_matrix/client/r0/keys/upload").respond(200, (path, content) => { + expect(content).toMatchObject({ + one_time_keys: keys, + }); + return { one_time_key_counts: counts }; + }); + + http.flushAllExpected(); + const result = await client.uploadDeviceOneTimeKeys(keys); + expect(result).toMatchObject(counts); + }); + }); + + describe('checkOneTimeKeyCounts', () => { + notCryptoIt('it should fail', async () => { + try { + const { client } = createTestClient(); + await client.checkOneTimeKeyCounts(); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption is not enabled"); + } + }); + + cryptoIt('it should call the right endpoint', async () => { + const userId = "@test:example.org"; + const { client, http } = createTestClient(null, userId, true); + + const counts: OTKCounts = { + [OTKAlgorithm.Signed]: 12, + [OTKAlgorithm.Unsigned]: 14, + }; + + http.when("POST", "/_matrix/client/r0/keys/upload").respond(200, (path, content) => { + expect(content).toMatchObject({}); + return { one_time_key_counts: counts }; + }); + + http.flushAllExpected(); + const result = await client.checkOneTimeKeyCounts(); + expect(result).toMatchObject(counts); + }); + }); + describe('redactObjectForLogging', () => { it('should redact multilevel objects', () => { const input = { diff --git a/test/TestUtils.ts b/test/TestUtils.ts index bcb8cfe3..62f08c32 100644 --- a/test/TestUtils.ts +++ b/test/TestUtils.ts @@ -1,4 +1,12 @@ import * as expect from "expect"; +import { + C25519_STORAGE_KEY, + E25519_STORAGE_KEY, + MatrixClient, + OLM_ACCOUNT_STORAGE_KEY, + PICKLE_STORAGE_KEY +} from "../src"; +import * as crypto from "crypto"; export function expectArrayEquals(expected: any[], actual: any[]) { expect(expected).toBeDefined(); @@ -20,3 +28,27 @@ export function testDelay(ms: number): Promise { setTimeout(resolve, ms); }); } + +let olmInstance; +export async function prepareOlm(): Promise { + if (olmInstance) return olmInstance; + olmInstance = require("@matrix-org/olm"); + await olmInstance.init({}); + return olmInstance; +} + +export async function feedOlmAccount(client: MatrixClient) { + const pickleKey = crypto.randomBytes(64).toString('hex'); + const account = new (await prepareOlm()).Account(); + try { + const pickled = account.pickle(pickleKey); + const keys = JSON.parse(account.identity_keys()); + + await Promise.resolve(client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, pickled)); + await Promise.resolve(client.storageProvider.storeValue(PICKLE_STORAGE_KEY, pickleKey)); + await Promise.resolve(client.storageProvider.storeValue(E25519_STORAGE_KEY, keys['ed25519'])); + await Promise.resolve(client.storageProvider.storeValue(C25519_STORAGE_KEY, keys['curve25519'])); + } finally { + account.free(); + } +} diff --git a/test/encryption/RoomTrackerTest.ts b/test/encryption/RoomTrackerTest.ts new file mode 100644 index 00000000..86ab28ae --- /dev/null +++ b/test/encryption/RoomTrackerTest.ts @@ -0,0 +1,228 @@ +import * as expect from "expect"; +import * as simple from "simple-mock"; +import { EncryptionEventContent, MatrixClient, RoomEncryptionAlgorithm, RoomTracker } from "../../src"; +import { createTestClient } from "../MatrixClientTest"; + +function prepareQueueSpies(client: MatrixClient, roomId: string, content: Partial = {}, storedContent: Partial = null): simple.Stub[] { + const readSpy = simple.stub().callFn((key: string) => { + expect(key).toEqual("tracked_room." + roomId); + return Promise.resolve(storedContent ? JSON.stringify(storedContent) : null); + }); + + const stateSpy = simple.stub().callFn((rid: string, eventType: string, stateKey: string) => { + expect(rid).toEqual(roomId); + expect(eventType).toEqual("m.room.encryption"); + expect(stateKey).toEqual(""); + return Promise.resolve(content); + }); + + const storeSpy = simple.stub().callFn((key: string, s: string) => { + expect(key).toEqual("tracked_room." + roomId); + const tryStoreContent = JSON.parse(s); + expect(tryStoreContent).toMatchObject({ + ...content, + algorithm: content['algorithm'] ?? 'UNKNOWN', + }); + return Promise.resolve(); + }); + + client.storageProvider.readValue = readSpy; + client.storageProvider.storeValue = storeSpy; + client.getRoomStateEvent = stateSpy; + + return [readSpy, stateSpy, storeSpy]; +} + +describe('RoomTracker', () => { + it('should queue room updates when rooms are joined', async () => { + const roomId = "!a:example.org"; + + const { client } = createTestClient(); + + const tracker = new RoomTracker(client); + + let queueSpy: simple.Stub; + await new Promise(resolve => { + queueSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); + resolve(); + return Promise.resolve(); + }); + tracker.queueRoomCheck = queueSpy; + client.emit("room.join", roomId); + }); + expect(queueSpy.callCount).toEqual(1); + }); + + it('should queue room updates when encryption events are received', async () => { + const roomId = "!a:example.org"; + + const { client } = createTestClient(); + + const tracker = new RoomTracker(client); + + let queueSpy: simple.Stub; + await new Promise(resolve => { + queueSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); + resolve(); + return Promise.resolve(); + }); + tracker.queueRoomCheck = queueSpy; + client.emit("room.event", roomId, { + type: "not-m.room.encryption", + state_key: "", + }); + client.emit("room.event", roomId, { + type: "m.room.encryption", + state_key: "2", + }); + client.emit("room.event", roomId, { + type: "m.room.encryption", + state_key: "", + }); + }); + await new Promise(resolve => setTimeout(() => resolve(), 250)); + 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', async () => { + const roomId = "!b:example.org"; + const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; + + const { client } = createTestClient(); + + const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, content); + + const tracker = new RoomTracker(client); + await tracker.queueRoomCheck(roomId); + expect(readSpy.callCount).toEqual(1); + expect(stateSpy.callCount).toEqual(1); + expect(storeSpy.callCount).toEqual(1); + }); + + it('should skip known rooms', async () => { + const roomId = "!b:example.org"; + const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; + + const { client } = createTestClient(); + + const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, { algorithm: "no" }, content); + + const tracker = new RoomTracker(client); + await tracker.queueRoomCheck(roomId); + expect(readSpy.callCount).toEqual(1); + expect(stateSpy.callCount).toEqual(0); + expect(storeSpy.callCount).toEqual(0); + }); + + it('should not store unencrypted rooms', async () => { + const roomId = "!b:example.org"; + const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; + + const { client } = createTestClient(); + + const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, content); + client.getRoomStateEvent = async (rid: string, et: string, sk: string) => { + await stateSpy(rid, et, sk); + throw new Error("Simulated 404"); + }; + + const tracker = new RoomTracker(client); + await tracker.queueRoomCheck(roomId); + expect(readSpy.callCount).toEqual(1); + expect(stateSpy.callCount).toEqual(1); + expect(storeSpy.callCount).toEqual(0); + }); + }); + + describe('getRoomCryptoConfig', () => { + it('should return the config as-is', async () => { + const roomId = "!a:example.org"; + const content: Partial = {algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2}; + + const { client } = createTestClient(); + + const readSpy = simple.stub().callFn((key: string) => { + expect(key).toEqual("tracked_room." + roomId); + return Promise.resolve(JSON.stringify(content)); + }); + + client.storageProvider.readValue = readSpy; + + const tracker = new RoomTracker(client); + const config = await tracker.getRoomCryptoConfig(roomId); + expect(readSpy.callCount).toEqual(1); + expect(config).toMatchObject(content); + }); + + it('should queue unknown rooms', async () => { + const roomId = "!a:example.org"; + const content: Partial = {algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2}; + + const { client } = createTestClient(); + + const readSpy = simple.stub().callFn((key: string) => { + expect(key).toEqual("tracked_room." + roomId); + if (readSpy.callCount === 1) return Promise.resolve(null); + return Promise.resolve(JSON.stringify(content)); + }); + const queueSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); + return Promise.resolve(); + }); + + client.storageProvider.readValue = readSpy; + + const tracker = new RoomTracker(client); + tracker.queueRoomCheck = queueSpy; + const config = await tracker.getRoomCryptoConfig(roomId); + expect(readSpy.callCount).toEqual(2); + expect(queueSpy.callCount).toEqual(1); + expect(config).toMatchObject(content); + }); + + it('should return empty for unencrypted rooms', async () => { + const roomId = "!a:example.org"; + + const { client } = createTestClient(); + + const readSpy = simple.stub().callFn((key: string) => { + expect(key).toEqual("tracked_room." + roomId); + return Promise.resolve(null); + }); + const queueSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); + return Promise.resolve(); + }); + + client.storageProvider.readValue = readSpy; + + const tracker = new RoomTracker(client); + tracker.queueRoomCheck = queueSpy; + const config = await tracker.getRoomCryptoConfig(roomId); + expect(readSpy.callCount).toEqual(2); + expect(queueSpy.callCount).toEqual(1); + expect(config).toMatchObject({ }); + }); + }); +}); diff --git a/test/encryption/decoratorsTest.ts b/test/encryption/decoratorsTest.ts new file mode 100644 index 00000000..b948bad2 --- /dev/null +++ b/test/encryption/decoratorsTest.ts @@ -0,0 +1,120 @@ +import * as expect from "expect"; +import * as simple from "simple-mock"; +import { requiresCrypto, requiresReady } from "../../src"; + +class InterceptedClass { + constructor(private interceptedFn: (i: number) => number, public crypto: any) { + } + + public get isReady() { + return this.crypto; + } + + @requiresCrypto() + async reqCryptoIntercepted(i: number): Promise { + return this.interceptedFn(i); + } + + @requiresReady() + async reqReadyIntercepted(i: number): Promise { + return this.interceptedFn(i); + } +} + +describe('decorators', () => { + describe('requiresCrypto', () => { + it('should call the intercepted method with provided args', async () => { + const amount = 1234; + const interceptedFn = simple.stub().callFn((i: number) => { + expect(i).toBe(amount); + return -1; + }); + + const interceptedClass = new InterceptedClass(interceptedFn, true); + await interceptedClass.reqCryptoIntercepted(amount); + + expect(interceptedFn.callCount).toBe(1); + }); + + it('should return the result of the intercepted method', async () => { + const amount = 1234; + + const interceptedClass = new InterceptedClass((i) => amount, true); + const result = await interceptedClass.reqCryptoIntercepted(amount * 2); + + expect(result).toBe(amount); + }); + + it('should throw if there is no crypto member', async () => { + const amount = 1234; + + const interceptedClass = new InterceptedClass((i) => amount, false); + + try { + await interceptedClass.reqCryptoIntercepted(amount * 2); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to throw"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption is not enabled"); + } + }); + + it('should throw if the function throws', async () => { + const reason = "Bad things"; + const interceptedClass = new InterceptedClass(() => { + throw new Error(reason); + }, true); + + await expect(interceptedClass.reqCryptoIntercepted(1234)).rejects.toThrow(reason); + }); + }); + + describe('requiresReady', () => { + it('should call the intercepted method with provided args', async () => { + const amount = 1234; + const interceptedFn = simple.stub().callFn((i: number) => { + expect(i).toBe(amount); + return -1; + }); + + const interceptedClass = new InterceptedClass(interceptedFn, true); + await interceptedClass.reqReadyIntercepted(amount); + + expect(interceptedFn.callCount).toBe(1); + }); + + it('should return the result of the intercepted method', async () => { + const amount = 1234; + + const interceptedClass = new InterceptedClass((i) => amount, true); + const result = await interceptedClass.reqReadyIntercepted(amount * 2); + + expect(result).toBe(amount); + }); + + it('should throw if not ready', async () => { + const amount = 1234; + + const interceptedClass = new InterceptedClass((i) => amount, false); + + try { + await interceptedClass.reqReadyIntercepted(amount * 2); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to throw"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should throw if the function throws', async () => { + const reason = "Bad things"; + const interceptedClass = new InterceptedClass(() => { + throw new Error(reason); + }, true); + + await expect(interceptedClass.reqReadyIntercepted(1234)).rejects.toThrow(reason); + }); + }); +}); diff --git a/test/isCryptoCapableTest.ts b/test/isCryptoCapableTest.ts new file mode 100644 index 00000000..d66933e2 --- /dev/null +++ b/test/isCryptoCapableTest.ts @@ -0,0 +1,17 @@ +import * as expect from "expect"; +import { isCryptoCapable } from "../src"; + +export const IS_CRYPTO_TEST_ENV = !process.env.BOTSDK_NO_CRYPTO_TESTS; +export const cryptoDescribe = (IS_CRYPTO_TEST_ENV ? describe : describe.skip); +export const cryptoIt = (IS_CRYPTO_TEST_ENV ? it : it.skip); +export const notCryptoDescribe = (!IS_CRYPTO_TEST_ENV ? describe : describe.skip); +export const notCryptoIt = (!IS_CRYPTO_TEST_ENV ? it : it.skip); + +describe('isCryptoCapable', () => { + cryptoIt('should return true', () => { + expect(isCryptoCapable()).toEqual(true); + }); + notCryptoIt('should return false', () => { + expect(isCryptoCapable()).toEqual(false); + }); +}); diff --git a/test/logging/LogServiceTest.ts b/test/logging/LogServiceTest.ts index 5255b9b3..c5d29051 100644 --- a/test/logging/LogServiceTest.ts +++ b/test/logging/LogServiceTest.ts @@ -154,4 +154,31 @@ describe('LogService', () => { LogService.warn(module, a1, a2); expect(logSpy.callCount).toBe(0); }); + + it('should mute the requested modules', () => { + const mutedModule = "Mute Me"; + const unmutedModule = "Hello World"; + + const logSpy = simple.stub().callFn((m) => { + expect(m).toEqual(unmutedModule); + }); + + LogService.setLogger({info: logSpy, warn: logSpy, error: logSpy, debug: logSpy, trace: logSpy}); + LogService.setLevel(LogLevel.TRACE); + LogService.muteModule(mutedModule); + + LogService.trace(mutedModule, "test"); + LogService.debug(mutedModule, "test"); + LogService.info(mutedModule, "test"); + LogService.warn(mutedModule, "test"); + LogService.error(mutedModule, "test"); + + LogService.trace(unmutedModule, "test"); + LogService.debug(unmutedModule, "test"); + LogService.info(unmutedModule, "test"); + LogService.warn(unmutedModule, "test"); + LogService.error(unmutedModule, "test"); + + expect(logSpy.callCount).toBe(5); + }); }); diff --git a/test/models/events/EncryptionEventTest.ts b/test/models/events/EncryptionEventTest.ts new file mode 100644 index 00000000..699de1ef --- /dev/null +++ b/test/models/events/EncryptionEventTest.ts @@ -0,0 +1,31 @@ +import * as expect from "expect"; +import { createMinimalEvent } from "./EventTest"; +import { EncryptionEvent, RoomEncryptionAlgorithm } from "../../../src"; + +describe("EncryptionEvent", () => { + it("should return the right fields", () => { + const ev = createMinimalEvent(); + ev.content['algorithm'] = RoomEncryptionAlgorithm.MegolmV1AesSha2; + ev.content['rotation_period_ms'] = 12; + ev.content['rotation_period_msgs'] = 14; + const obj = new EncryptionEvent(ev); + + expect(obj.algorithm).toEqual(ev.content['algorithm']); + expect(obj.rotationPeriodMs).toEqual(ev.content['rotation_period_ms']); + expect(obj.rotationPeriodMessages).toEqual(ev.content['rotation_period_msgs']); + }); + + it("should default to a rotation period of 1 week", () => { + const ev = createMinimalEvent(); + const obj = new EncryptionEvent(ev); + + expect(obj.rotationPeriodMs).toEqual(604800000); // 1 week + }); + + it("should default to a rotation period of 100 messages", () => { + const ev = createMinimalEvent(); + const obj = new EncryptionEvent(ev); + + expect(obj.rotationPeriodMessages).toEqual(100); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0269d376..d1eef3b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -403,6 +403,11 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/simple-mock@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@types/simple-mock/-/simple-mock-0.8.2.tgz#84acf2b5b10f3f92923e3022cc62488312d8f48a" + integrity sha512-qBnFpNNlMyiHN9X7U3g50o1ADDkniYGIqhkWSkDSpJMvu5XzYO9PPvvyazEDrXwhByPXDxlpAaQXGAjZmvwLYQ== + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" From 1ab0779eb92ddf3f7b31f84b891bebd1388faac0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 28 Jul 2021 14:58:36 -0600 Subject: [PATCH 04/26] Add another round of tests --- src/e2ee/CryptoClient.ts | 24 +- test/MatrixClientTest.ts | 2 +- test/encryption/CryptoClientTest.ts | 410 ++++++++++++++++++++++++++++ 3 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 test/encryption/CryptoClientTest.ts diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 27ef73a6..bac54155 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -126,7 +126,7 @@ export class CryptoClient { [`${DeviceKeyAlgorithm.Ed25119}:${this.deviceId}`]: this.deviceEd25519, [`${DeviceKeyAlgorithm.Curve25519}:${this.deviceId}`]: this.deviceCurve25519, }); - await this.tryOtkUpload(counts); + await this.updateCounts(counts); } else { account.unpickle(pickleKey, pickled); this.pickleKey = pickleKey; @@ -135,35 +135,32 @@ export class CryptoClient { this.deviceCurve25519 = deviceC25519; this.maxOTKs = account.max_number_of_one_time_keys(); this.ready = true; - await this.tryOtkUpload(await this.client.checkOneTimeKeyCounts()); + await this.updateCounts(await this.client.checkOneTimeKeyCounts()); } } finally { account.free(); } } - /** - * Updates the One Time Key counts, potentially triggering an async upload of more - * one time keys. - * @param {OTKCounts} counts The current counts to work within. - */ - public updateCounts(counts: OTKCounts) { - // noinspection JSIgnoredPromiseFromCall - this.tryOtkUpload(counts); - } - /** * Checks if a room is encrypted. * @param {string} roomId The room ID to check. * @returns {Promise} Resolves to true if encrypted, false otherwise. */ + @requiresReady() public async isRoomEncrypted(roomId: string): Promise { const config = await this.roomTracker.getRoomCryptoConfig(roomId); return !!config?.algorithm; } + /** + * Updates the One Time Key counts, potentially triggering an async upload of more + * one time keys. + * @param {OTKCounts} counts The current counts to work within. + * @returns {Promise} Resolves when complete. + */ @requiresReady() - private async tryOtkUpload(counts: OTKCounts) { + public async updateCounts(counts: OTKCounts) { const have = counts[OTKAlgorithm.Signed] || 0; const need = Math.floor(this.maxOTKs / 2) - have; if (need <= 0) return; @@ -195,6 +192,7 @@ export class CryptoClient { */ @requiresReady() public async sign(obj: object): Promise { + obj = JSON.parse(JSON.stringify(obj)); const existingSignatures = obj['signatures'] || {}; delete obj['signatures']; diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index b531f8a9..14136e24 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -5226,7 +5226,7 @@ describe('MatrixClient', () => { const { client, http } = createTestClient(null, userId, true); client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); - (client.crypto).tryOtkUpload = () => Promise.resolve(); // private member access + client.crypto.updateCounts = () => Promise.resolve(); client.checkOneTimeKeyCounts = () => Promise.resolve({}); await feedOlmAccount(client); await client.crypto.prepare([]); diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts new file mode 100644 index 00000000..560704dc --- /dev/null +++ b/test/encryption/CryptoClientTest.ts @@ -0,0 +1,410 @@ +import * as expect from "expect"; +import * as simple from "simple-mock"; +import { + C25519_STORAGE_KEY, + DEVICE_ID_STORAGE_KEY, E25519_STORAGE_KEY, + EncryptionEventContent, + MatrixClient, OLM_ACCOUNT_STORAGE_KEY, OTKAlgorithm, OTKCounts, PICKLE_STORAGE_KEY, + RoomEncryptionAlgorithm, + RoomTracker +} from "../../src"; +import { createTestClient, TEST_DEVICE_ID } from "../MatrixClientTest"; +import { cryptoDescribe } from "../isCryptoCapableTest"; +import { feedOlmAccount } from "../TestUtils"; + +cryptoDescribe('CryptoClient', () => { + it('should not have a device ID or be ready until prepared', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + expect(client.crypto).toBeDefined(); + expect(client.crypto.clientDeviceId).toBeFalsy(); + expect(client.crypto.isReady).toEqual(false); + + await client.crypto.prepare([]); + + expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID); + expect(client.crypto.isReady).toEqual(true); + }); + + describe('prepare', () => { + it('should prepare the room tracker', async () => { + const userId = "@alice:example.org"; + const roomIds = ["!a:example.org", "!b:example.org"]; + const { client } = createTestClient(null, userId, true); + + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + const prepareSpy = simple.stub().callFn((rids: string[]) => { + expect(rids).toBe(roomIds); + return Promise.resolve(); + }); + + (client.crypto).roomTracker.prepare = prepareSpy; // private member access + + await client.crypto.prepare(roomIds); + expect(prepareSpy.callCount).toEqual(1); + }); + + it('should use a stored device ID', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + + const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: "wrong" })); + client.getWhoAmI = whoamiSpy; + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await client.crypto.prepare([]); + expect(whoamiSpy.callCount).toEqual(0); + expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID); + }); + + it('should create new keys if any of the properties are missing', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + + const deviceKeySpy = simple.stub().callFn(() => Promise.resolve({})); + const otkSpy = simple.stub().callFn(() => Promise.resolve({})); + client.uploadDeviceKeys = deviceKeySpy; + client.uploadDeviceOneTimeKeys = otkSpy; + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await client.crypto.prepare([]); + expect(deviceKeySpy.callCount).toEqual(1); + expect(otkSpy.callCount).toEqual(1); + + // NEXT STAGE: Missing Olm Account + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, null)); + + await client.crypto.prepare([]); + expect(deviceKeySpy.callCount).toEqual(2); + expect(otkSpy.callCount).toEqual(2); + + // NEXT STAGE: Missing Pickle + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(PICKLE_STORAGE_KEY, null)); + + await client.crypto.prepare([]); + expect(deviceKeySpy.callCount).toEqual(3); + expect(otkSpy.callCount).toEqual(3); + + // NEXT STAGE: Missing Ed25519 + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(E25519_STORAGE_KEY, null)); + + await client.crypto.prepare([]); + expect(deviceKeySpy.callCount).toEqual(4); + expect(otkSpy.callCount).toEqual(4); + + // NEXT STAGE: Missing Curve25519 + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(C25519_STORAGE_KEY, null)); + + await client.crypto.prepare([]); + expect(deviceKeySpy.callCount).toEqual(5); + expect(otkSpy.callCount).toEqual(5); + }); + + it('should use given values if they are all present', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + + const deviceKeySpy = simple.stub().callFn(() => Promise.resolve({})); + const otkSpy = simple.stub().callFn(() => Promise.resolve({})); + const checkSpy = simple.stub().callFn(() => Promise.resolve({})); + client.uploadDeviceKeys = deviceKeySpy; + client.uploadDeviceOneTimeKeys = otkSpy; + client.checkOneTimeKeyCounts = checkSpy; + + await client.crypto.prepare([]); + expect(deviceKeySpy.callCount).toEqual(0); + expect(otkSpy.callCount).toEqual(1); + expect(checkSpy.callCount).toEqual(1); + }); + }); + + describe('isRoomEncrypted', () => { + it('should fail when the crypto has not been prepared', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + // await client.crypto.prepare([]); // deliberately commented + + try { + await client.crypto.isRoomEncrypted("!new:example.org"); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should return false for unknown rooms', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + client.getRoomStateEvent = () => Promise.reject("return value not used"); + await client.crypto.prepare([]); + + const result = await client.crypto.isRoomEncrypted("!new:example.org"); + expect(result).toEqual(false); + }); + + it('should return false for unencrypted rooms', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + client.getRoomStateEvent = () => Promise.reject("implying 404"); + await client.crypto.prepare([]); + + const result = await client.crypto.isRoomEncrypted("!new:example.org"); + expect(result).toEqual(false); + }); + + it('should return true for encrypted rooms (redacted state)', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + client.getRoomStateEvent = () => Promise.resolve({}); + await client.crypto.prepare([]); + + const result = await client.crypto.isRoomEncrypted("!new:example.org"); + expect(result).toEqual(true); + }); + + it('should return true for encrypted rooms', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + client.getRoomStateEvent = () => Promise.resolve({ algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2 }); + await client.crypto.prepare([]); + + const result = await client.crypto.isRoomEncrypted("!new:example.org"); + expect(result).toEqual(true); + }); + }); + + describe('updateCounts', () => { + it('should imply zero keys when no known counts are given', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + const expectedUpload = 50; + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + const uploadSpy = simple.stub().callFn((signed) => { + expect(Object.keys(signed).length).toEqual(expectedUpload); + return Promise.resolve({}); + }); + client.uploadDeviceOneTimeKeys = uploadSpy; + + await client.crypto.updateCounts({}); + expect(uploadSpy.callCount).toEqual(1); + }); + + it('should create signed OTKs', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + const counts: OTKCounts = { [OTKAlgorithm.Signed]: 0, [OTKAlgorithm.Unsigned]: 5 }; + const expectedUpload = 50; + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + const uploadSpy = simple.stub().callFn((signed) => { + expect(Object.keys(signed).length).toEqual(expectedUpload); + expect(Object.keys(signed).every(k => k.startsWith(OTKAlgorithm.Signed + ":"))).toEqual(true); + return Promise.resolve({}); + }); + client.uploadDeviceOneTimeKeys = uploadSpy; + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(1); + }); + + it('should create the needed amount of OTKs', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + const counts: OTKCounts = { [OTKAlgorithm.Signed]: 0, [OTKAlgorithm.Unsigned]: 5 }; + const expectedUpload = 50; + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + const uploadSpy = simple.stub().callFn((signed) => { + expect(Object.keys(signed).length).toEqual(expectedUpload); + expect(Object.keys(signed).every(k => k.startsWith(OTKAlgorithm.Signed + ":"))).toEqual(true); + return Promise.resolve({}); + }); + client.uploadDeviceOneTimeKeys = uploadSpy; + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(1); + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(2); + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(3); + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(4); + }); + + it('should not create OTKs if there are enough remaining', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + const counts: OTKCounts = { [OTKAlgorithm.Signed]: 14, [OTKAlgorithm.Unsigned]: 5 }; + const expectedUpload = 50 - counts[OTKAlgorithm.Signed]; + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + const uploadSpy = simple.stub().callFn((signed) => { + expect(Object.keys(signed).length).toEqual(expectedUpload); + expect(Object.keys(signed).every(k => k.startsWith(OTKAlgorithm.Signed + ":"))).toEqual(true); + return Promise.resolve({}); + }); + client.uploadDeviceOneTimeKeys = uploadSpy; + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(1); + }); + + it('should persist the Olm account after each upload', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + const counts: OTKCounts = { [OTKAlgorithm.Signed]: 0, [OTKAlgorithm.Unsigned]: 5 }; + const expectedUpload = 50; + + // noinspection ES6RedundantAwait + await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await feedOlmAccount(client); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + const uploadSpy = simple.stub().callFn((signed) => { + expect(Object.keys(signed).length).toEqual(expectedUpload); + expect(Object.keys(signed).every(k => k.startsWith(OTKAlgorithm.Signed + ":"))).toEqual(true); + return Promise.resolve({}); + }); + client.uploadDeviceOneTimeKeys = uploadSpy; + + // noinspection ES6RedundantAwait + let account = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(1); + + // noinspection ES6RedundantAwait + let newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + expect(account).not.toEqual(newAccount); + account = newAccount; + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(2); + + // noinspection ES6RedundantAwait + newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + expect(account).not.toEqual(newAccount); + account = newAccount; + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(3); + + // noinspection ES6RedundantAwait + newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + expect(account).not.toEqual(newAccount); + account = newAccount; + + await client.crypto.updateCounts(counts); + expect(uploadSpy.callCount).toEqual(4); + + // noinspection ES6RedundantAwait + newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + expect(account).not.toEqual(newAccount); + }); + }); + + describe('sign', () => { + // TODO: We should have mutation tests, signing tests, etc to make sure we're calling Olm correctly. + }); +}); From 8ce133d907e2d3d4fdee26030425f13c409878ce Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 28 Jul 2021 16:12:56 -0600 Subject: [PATCH 05/26] Docs + ability to query device lists --- src/MatrixClient.ts | 49 +++++++++++++++++++++++- src/models/Crypto.ts | 34 ++++++++++++++++ test/MatrixClientTest.ts | 83 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 7e2813fb..33a31277 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -26,7 +26,15 @@ import { Space, SpaceCreateOptions } from "./models/Spaces"; import { PowerLevelAction } from "./models/PowerLevelAction"; import { CryptoClient } from "./e2ee/CryptoClient"; import { isCryptoCapable } from "./isCryptoCapable"; -import { DeviceKeyAlgorithm, DeviceKeyLabel, EncryptionAlgorithm, OTKCounts, OTKs } from "./models/Crypto"; +import { + DeviceKeyAlgorithm, + DeviceKeyLabel, + EncryptionAlgorithm, + MultiUserDeviceListResponse, + OTKCounts, + OTKs, + UserDevice +} from "./models/Crypto"; import { requiresCrypto } from "./e2ee/decorators"; /** @@ -1593,6 +1601,12 @@ export class MatrixClient extends EventEmitter { return new Space(roomId, this); } + /** + * Uploads new identity keys for the current device. + * @param {EncryptionAlgorithm[]} algorithms The supported algorithms. + * @param {Record, string>} keys The keys for the device. + * @returns {Promise} Resolves to the current One Time Key counts when complete. + */ @timedMatrixClientFunctionCall() @requiresCrypto() public async uploadDeviceKeys(algorithms: EncryptionAlgorithm[], keys: Record, string>): Promise { @@ -1608,6 +1622,11 @@ export class MatrixClient extends EventEmitter { }).then(r => r['one_time_key_counts']); } + /** + * Uploads One Time Keys for the current device. + * @param {OTKs} keys The keys to upload. + * @returns {Promise} Resolves to the current One Time Key counts when complete. + */ @timedMatrixClientFunctionCall() @requiresCrypto() public async uploadDeviceOneTimeKeys(keys: OTKs): Promise { @@ -1616,6 +1635,10 @@ export class MatrixClient extends EventEmitter { }).then(r => r['one_time_key_counts']); } + /** + * Gets the current One Time Key counts. + * @returns {Promise} Resolves to the One Time Key counts. + */ @timedMatrixClientFunctionCall() @requiresCrypto() public async checkOneTimeKeyCounts(): Promise { @@ -1623,6 +1646,30 @@ export class MatrixClient extends EventEmitter { .then(r => r['one_time_key_counts']); } + /** + * Gets unverified device lists for the given users. The caller is expected to validate + * and verify the device lists, including that the returned devices belong to the claimed users. + * + * Failures with federation are reported in the returned object. Users which did not fail a federation + * lookup but have no devices will not appear in either the failures or in the returned devices. + * + * See https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-keys-query for more + * information. + * @param {string[]} userIds The user IDs to + * @param {number} federationTimeoutMs The default timeout for requesting devices over federation. Defaults to + * 10 seconds. + * @returns {Promise} Resolves to the device list/errors for the requested user IDs. + */ + @timedMatrixClientFunctionCall() + @requiresCrypto() + public async getUserDevices(userIds: string[], federationTimeoutMs = 10000): Promise { + const req = {}; + for (const userId of userIds) { + req[userId] = []; + } + return this.doRequest("POST", "/_matrix/client/r0/keys/query", { timeout: federationTimeoutMs }, req); + } + /** * Performs a web request to the homeserver, applying appropriate authorization headers for * this client. diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index 1070179b..104deac6 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -43,6 +43,9 @@ export type OTKs = Record, SignedCurve2551 * @category Models */ export type OTKCounts = { + /** + * The number of keys which remain unused for the algorithm. + */ [alg in OTKAlgorithm]?: number; }; @@ -69,3 +72,34 @@ export enum DeviceKeyAlgorithm { * @category Models */ export type DeviceKeyLabel = `${Algorithm}:${ID}`; + +/** + * Represents a user's device. + * @category Models + */ +export interface UserDevice { + user_id: string; + device_id: string; + algorithms: (EncryptionAlgorithm | string)[]; + keys: Record, string>; + signatures: Signatures; + unsigned?: { + [k: string]: any; + device_display_name?: string; + }; +} + +export interface MultiUserDeviceListResponse { + /** + * Federation failures, keyed by server name. The mapped object should be a standard + * error object. + */ + failures: { + [serverName: string]: any; + }; + + /** + * A map of user ID to device ID to device. + */ + device_keys: Record>; +} diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index 14136e24..ed4fe50e 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -5343,6 +5343,89 @@ describe('MatrixClient', () => { }); }); + describe('getUserDevices', () => { + notCryptoIt('it should fail', async () => { + try { + const { client } = createTestClient(); + await client.getUserDevices([]); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption is not enabled"); + } + }); + + cryptoIt('it should call the right endpoint', async () => { + const userId = "@test:example.org"; + const { client, http } = createTestClient(null, userId, true); + + const timeout = 15000; + const requestBody = { + "@alice:example.org": [], + "@bob:federated.example.org": [], + }; + const response = { + failures: { + "federated.example.org": { + error: "Failed", + }, + }, + device_keys: { + "@alice:example.org": { + [TEST_DEVICE_ID]: { + // not populated in this test + }, + }, + }, + }; + + http.when("POST", "/_matrix/client/r0/keys/query").respond(200, (path, content, req) => { + expect(req.opts.qs).toMatchObject({timeout}); + expect(content).toMatchObject(requestBody); + return response; + }); + + http.flushAllExpected(); + const result = await client.getUserDevices(Object.keys(requestBody), timeout); + expect(result).toMatchObject(response); + }); + + cryptoIt('it should call the right endpoint with a default timeout', async () => { + const userId = "@test:example.org"; + const { client, http } = createTestClient(null, userId, true); + + const requestBody = { + "@alice:example.org": [], + "@bob:federated.example.org": [], + }; + const response = { + failures: { + "federated.example.org": { + error: "Failed", + }, + }, + device_keys: { + "@alice:example.org": { + [TEST_DEVICE_ID]: { + // not populated in this test + }, + }, + }, + }; + + http.when("POST", "/_matrix/client/r0/keys/query").respond(200, (path, content, req) => { + expect(req.opts.qs).toMatchObject({timeout: 10000}); + expect(content).toMatchObject(requestBody); + return response; + }); + + http.flushAllExpected(); + const result = await client.getUserDevices(Object.keys(requestBody)); + expect(result).toMatchObject(response); + }); + }); + describe('redactObjectForLogging', () => { it('should redact multilevel objects', () => { const input = { From c69d703937c6e2da14560abc087166ccc0c7f57d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 28 Jul 2021 19:28:09 -0600 Subject: [PATCH 06/26] Set up a proper crypto store and use it to flag e2ee enablement --- examples/encryption_bot.ts | 5 +- package.json | 4 +- src/MatrixClient.ts | 20 +- src/e2ee/CryptoClient.ts | 38 +- src/e2ee/RoomTracker.ts | 17 +- src/index.ts | 3 +- src/isCryptoCapable.ts | 22 -- src/storage/ICryptoStorageProvider.ts | 64 +++ src/storage/SqliteCryptoStorageProvider.ts | 104 +++++ test/MatrixClientTest.ts | 66 +--- test/TestUtils.ts | 11 +- test/encryption/CryptoClientTest.ts | 94 ++--- test/encryption/RoomTrackerTest.ts | 51 ++- test/isCryptoCapableTest.ts | 17 - test/storage/SqliteCryptoStorageProvider.ts | 77 ++++ yarn.lock | 408 +++++++++++++++++++- 16 files changed, 751 insertions(+), 250 deletions(-) delete mode 100644 src/isCryptoCapable.ts create mode 100644 src/storage/ICryptoStorageProvider.ts create mode 100644 src/storage/SqliteCryptoStorageProvider.ts delete mode 100644 test/isCryptoCapableTest.ts create mode 100644 test/storage/SqliteCryptoStorageProvider.ts diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index a37b7331..2ea39974 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -1,5 +1,5 @@ import { LogLevel, LogService, MatrixClient, RichConsoleLogger, SimpleFsStorageProvider } from "../src"; -import { RoomEncryptionAlgorithm } from "../src/models/events/EncryptionEvent"; +import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider"; LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.TRACE); @@ -16,8 +16,9 @@ try { 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 SqliteCryptoStorageProvider("./examples/storage/encryption_bot.db"); -const client = new MatrixClient(homeserverUrl, accessToken, storage, true); +const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); (async function() { client.on("room.event", (roomId: string, event: any) => { diff --git a/package.json b/package.json index fa796a65..f46841dd 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,10 @@ "tsconfig.json" ], "optionalDependencies": { - "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.4.tgz" + "better-sqlite3": "^7.4.3" }, "dependencies": { + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.4.tgz", "@types/express": "^4.17.7", "another-json": "^0.2.0", "chalk": "^4.1.0", @@ -69,6 +70,7 @@ "sanitize-html": "^2.3.2" }, "devDependencies": { + "@types/better-sqlite3": "^5.4.3", "@types/expect": "^24.3.0", "@types/mocha": "^8.0.1", "@types/node": "10", diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 33a31277..6301cab1 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -25,7 +25,6 @@ import { MatrixProfileInfo } from "./models/MatrixProfile"; import { Space, SpaceCreateOptions } from "./models/Spaces"; import { PowerLevelAction } from "./models/PowerLevelAction"; import { CryptoClient } from "./e2ee/CryptoClient"; -import { isCryptoCapable } from "./isCryptoCapable"; import { DeviceKeyAlgorithm, DeviceKeyLabel, @@ -36,6 +35,7 @@ import { UserDevice } from "./models/Crypto"; import { requiresCrypto } from "./e2ee/decorators"; +import { ICryptoStorageProvider } from "./storage/ICryptoStorageProvider"; /** * A client that is capable of interacting with a matrix homeserver. @@ -89,19 +89,22 @@ export class MatrixClient extends EventEmitter { * @param {string} homeserverUrl The homeserver's client-server API URL * @param {string} accessToken The access token for the homeserver * @param {IStorageProvider} storage The storage provider to use. Defaults to MemoryStorageProvider. - * @param {boolean} withCrypto True to enable end-to-end encryption, false (default) otherwise. - */ - constructor(public readonly homeserverUrl: string, public readonly accessToken: string, private storage: IStorageProvider = null, withCrypto = false) { + * @param {ICryptoStorageProvider} cryptoStore Optional crypto storage provider to use. If not supplied, + * end-to-end encryption will not be functional in this client. + */ + constructor( + public readonly homeserverUrl: string, + public readonly accessToken: string, + private storage: IStorageProvider = null, + public readonly cryptoStore: ICryptoStorageProvider = null, + ) { super(); if (this.homeserverUrl.endsWith("/")) { this.homeserverUrl = this.homeserverUrl.substring(0, this.homeserverUrl.length - 1); } - const e2eeCapable = isCryptoCapable(); - if (withCrypto && !e2eeCapable) { - throw new Error("Cannot enable encryption: missing dependencies"); - } else if (withCrypto) { + if (this.cryptoStore) { if (!this.storage || this.storage instanceof MemoryStorageProvider) { LogService.warn("MatrixClientLite", "Starting an encryption-capable client with a memory store is not considered a good idea."); } @@ -1661,7 +1664,6 @@ export class MatrixClient extends EventEmitter { * @returns {Promise} Resolves to the device list/errors for the requested user IDs. */ @timedMatrixClientFunctionCall() - @requiresCrypto() public async getUserDevices(userIds: string[], federationTimeoutMs = 10000): Promise { const req = {}; for (const userId of userIds) { diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index bac54155..d405624d 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -13,13 +13,6 @@ import { import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; -export const DEVICE_ID_STORAGE_KEY = "device_id"; -export const E25519_STORAGE_KEY = "device_ed25519"; -export const C25519_STORAGE_KEY = "device_Curve25519"; -export const PICKLE_STORAGE_KEY = "device_pickle_key"; -export const OLM_ACCOUNT_STORAGE_KEY = "device_olm_account"; - -// noinspection ES6RedundantAwait /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly * rather than creating one manually. @@ -62,7 +55,7 @@ export class CryptoClient { private async storeAndFreeOlmAccount(account: Olm.Account) { const pickled = account.pickle(this.pickleKey); - await Promise.resolve(this.client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, pickled)); + await this.client.cryptoStore.setPickledAccount(pickled); account.free(); } @@ -73,7 +66,7 @@ export class CryptoClient { public async prepare(roomIds: string[]) { await this.roomTracker.prepare(roomIds); - const storedDeviceId = await Promise.resolve(this.client.storageProvider.readValue(DEVICE_ID_STORAGE_KEY)); + const storedDeviceId = await this.client.cryptoStore.getDeviceId(); if (storedDeviceId) { this.deviceId = storedDeviceId; } else { @@ -82,7 +75,7 @@ export class CryptoClient { throw new Error("Encryption not possible: server not revealing device ID"); } this.deviceId = deviceId; - await Promise.resolve(this.client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, this.deviceId)); + await this.client.cryptoStore.setDeviceId(this.deviceId); } LogService.debug("CryptoClient", "Starting with device ID:", this.deviceId); @@ -90,32 +83,23 @@ export class CryptoClient { // We should be in a ready enough shape to kick off Olm await Olm.init(); - let pickled = await (Promise.resolve(this.client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY))); - let deviceC25519 = await (Promise.resolve(this.client.storageProvider.readValue(C25519_STORAGE_KEY))); - let deviceE25519 = await (Promise.resolve(this.client.storageProvider.readValue(E25519_STORAGE_KEY))); - let pickleKey = await (Promise.resolve(this.client.storageProvider.readValue(PICKLE_STORAGE_KEY))); + let pickled = await this.client.cryptoStore.getPickledAccount(); + let pickleKey = await this.client.cryptoStore.getPickleKey(); const account = new Olm.Account(); try { - if (!pickled || !deviceC25519 || !deviceE25519 || !pickleKey) { + if (!pickled || !pickleKey) { LogService.debug("CryptoClient", "Creating new Olm account: previous session lost or not set up"); account.create(); pickleKey = crypto.randomBytes(64).toString('hex'); pickled = account.pickle(pickleKey); - await Promise.resolve(this.client.storageProvider.storeValue(PICKLE_STORAGE_KEY, pickleKey)); - await Promise.resolve(this.client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, pickled)); + await this.client.cryptoStore.setPickleKey(pickleKey); + await this.client.cryptoStore.setPickledAccount(pickled); this.pickleKey = pickleKey; this.pickledAccount = pickled; - const keys = JSON.parse(account.identity_keys()); - this.deviceCurve25519 = keys['curve25519']; - this.deviceEd25519 = keys['ed25519']; - - await Promise.resolve(this.client.storageProvider.storeValue(E25519_STORAGE_KEY, this.deviceEd25519)); - await Promise.resolve(this.client.storageProvider.storeValue(C25519_STORAGE_KEY, this.deviceCurve25519)); - this.maxOTKs = account.max_number_of_one_time_keys(); this.ready = true; @@ -131,12 +115,14 @@ export class CryptoClient { account.unpickle(pickleKey, pickled); this.pickleKey = pickleKey; this.pickledAccount = pickled; - this.deviceEd25519 = deviceE25519; - this.deviceCurve25519 = deviceC25519; this.maxOTKs = account.max_number_of_one_time_keys(); this.ready = true; await this.updateCounts(await this.client.checkOneTimeKeyCounts()); } + + const keys = JSON.parse(account.identity_keys()); + this.deviceCurve25519 = keys['curve25519']; + this.deviceEd25519 = keys['ed25519']; } finally { account.free(); } diff --git a/src/e2ee/RoomTracker.ts b/src/e2ee/RoomTracker.ts index 11b772a5..f3c35fbc 100644 --- a/src/e2ee/RoomTracker.ts +++ b/src/e2ee/RoomTracker.ts @@ -1,8 +1,6 @@ import { MatrixClient } from "../MatrixClient"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; -const ROOM_STORAGE_PREFIX = "tracked_room."; - // noinspection ES6RedundantAwait /** * Tracks room encryption status for a MatrixClient. @@ -39,11 +37,9 @@ export class RoomTracker { * @param {string} roomId The room ID to check. */ public async queueRoomCheck(roomId: string) { - const key = `${ROOM_STORAGE_PREFIX}${roomId}`; - const config = await Promise.resolve(this.client.storageProvider.readValue(key)); + const config = await this.client.cryptoStore.getRoom(roomId); if (config) { - const parsed: EncryptionEventContent = JSON.parse(config); - if (parsed.algorithm !== undefined) { + if (config.algorithm !== undefined) { return; // assume no change to encryption config } } @@ -55,7 +51,7 @@ export class RoomTracker { } catch (e) { return; // failure == no encryption } - await Promise.resolve(this.client.storageProvider.storeValue(key, JSON.stringify(encEvent))); + await this.client.cryptoStore.storeRoom(roomId, encEvent); } /** @@ -65,15 +61,14 @@ export class RoomTracker { * @returns {Promise>} Resolves to the encryption config. */ public async getRoomCryptoConfig(roomId: string): Promise> { - const key = `${ROOM_STORAGE_PREFIX}${roomId}`; - let config = await Promise.resolve(this.client.storageProvider.readValue(key)); + let config = await this.client.cryptoStore.getRoom(roomId); if (!config) { await this.queueRoomCheck(roomId); - config = await Promise.resolve(this.client.storageProvider.readValue(key)); + config = await this.client.cryptoStore.getRoom(roomId); } if (!config) { return {}; } - return JSON.parse(config); + return config; } } diff --git a/src/index.ts b/src/index.ts index a80be9a9..65fdc0ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,8 @@ export * from "./storage/IAppserviceStorageProvider"; export * from "./storage/IStorageProvider"; export * from "./storage/MemoryStorageProvider"; export * from "./storage/SimpleFsStorageProvider"; +export * from "./storage/ICryptoStorageProvider"; +//export * from "./storage/SqliteCryptoStorageProvider"; // Not exported because of optional dependency // Strategies export * from "./strategies/AppserviceJoinRoomStrategy"; @@ -97,4 +99,3 @@ export * from "./PantalaimonClient"; export * from "./SynchronousMatrixClient"; export * from "./SynapseAdminApis"; export * from "./simple-validation"; -export * from "./isCryptoCapable"; diff --git a/src/isCryptoCapable.ts b/src/isCryptoCapable.ts deleted file mode 100644 index 9af57ae4..00000000 --- a/src/isCryptoCapable.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { LogService } from "./logging/LogService"; - -let hasDependency: boolean = null; - -/** - * Determines if the project is capable of running end-to-end encryption, aside - * from solutions like Pantalaimon. - * @category Encryption - */ -export function isCryptoCapable(): boolean { - if (hasDependency !== null) return hasDependency; - - try { - require("@matrix-org/olm"); - hasDependency = true; - } catch (e) { - LogService.error("isCryptoCapable", "Failed check: ", e); - hasDependency = false; - } - - return hasDependency; -} diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts new file mode 100644 index 00000000..12c83935 --- /dev/null +++ b/src/storage/ICryptoStorageProvider.ts @@ -0,0 +1,64 @@ +import { EncryptionEventContent } from "../models/events/EncryptionEvent"; + +/** + * A storage provider capable of only providing crypto-related storage. + * @category Storage providers + */ +export interface ICryptoStorageProvider { + /** + * Sets the client's device ID. + * @param {string} deviceId The device ID. + * @returns {Promise} Resolves when complete. + */ + setDeviceId(deviceId: string): Promise; + + /** + * Gets the client's device ID, if known. + * @returns {Promise} Resolves to the device ID, or falsy if not known. + */ + getDeviceId(): Promise; + + /** + * Sets the pickle key for the client. + * @param {string} pickleKey The pickle key to store. + * @returns {Promise} Resolves when complete. + */ + setPickleKey(pickleKey: string): Promise; + + /** + * Gets the pickle key for the client. If no pickle key is set, this resolves + * to falsy. + * @returns {Promise} Resolves to the pickle key, or falsy if not set. + */ + getPickleKey(): Promise; + + /** + * Sets the pickled copy of the Olm account. This should be stored securely + * if possible. + * @param {string} pickled Encoded, pickled, copy of the Olm account. + * @returns {Promise} Resolves when complete. + */ + setPickledAccount(pickled: string): Promise; + + /** + * Gets the pickled copy of the Olm account, or falsy if not set. + * @returns {Promise} Resolves to the pickled account, or falsy if not set. + */ + getPickledAccount(): Promise; + + /** + * Stores a room's configuration. + * @param {string} roomId The room ID to store the configuration for. + * @param {Partial} config The room's encryption config. May be empty. + * @returns {Promise} Resolves when complete. + */ + storeRoom(roomId: string, config: Partial): Promise; + + /** + * Gets a room's configuration. If the room is unknown, a falsy value is returned. + * @param {string} roomId The room ID to get the configuration for. + * @returns {Promise>} Resolves to the room's configuration, or + * to falsy if the room is unknown. + */ + getRoom(roomId: string): Promise>; +} diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts new file mode 100644 index 00000000..27b5d023 --- /dev/null +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -0,0 +1,104 @@ +import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; +import { EncryptionEventContent } from "../models/events/EncryptionEvent"; +import * as Database from "better-sqlite3"; + +/** + * Sqlite crypto storage provider. Requires `better-sqlite3` package to be installed. + * @category Storage providers + */ +export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { + private readyPromise: Promise; + private db: Database.Database; + + private kvUpsert: Database.Statement; + private kvSelect: Database.Statement; + private roomUpsert: Database.Statement; + private roomOneSelect: Database.Statement; + + /** + * Creates a new Sqlite storage provider. + * @param {string} path The file path to store the database at. Use ":memory:" to + * store the database entirely in memory, or an empty string to do the equivalent + * on the disk. + */ + public constructor(path: string) { + this.db = new Database(path); + this.readyPromise = new Promise(async resolve => { + await this.db.exec("CREATE TABLE IF NOT EXISTS kv (name TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)"); + await this.db.exec("CREATE TABLE IF NOT EXISTS rooms (room_id TEXT PRIMARY KEY NOT NULL, config TEXT NOT NULL)"); + + this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value"); + this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name"); + + this.roomUpsert = this.db.prepare("INSERT INTO rooms (room_id, config) VALUES (@roomId, @config) ON CONFLICT (room_id) DO UPDATE SET config = @config"); + this.roomOneSelect = this.db.prepare("SELECT room_id, config FROM rooms WHERE room_id = @roomId"); + + resolve(); + }); + } + + public async setDeviceId(deviceId: string): Promise { + await this.readyPromise; + await this.kvUpsert.run({ + name: 'deviceId', + value: deviceId, + }); + } + + public async getDeviceId(): Promise { + await this.readyPromise; + const row = await this.kvSelect.get({name: 'deviceId'}); + return row?.value; + } + + public async setPickleKey(pickleKey: string): Promise { + await this.readyPromise; + await this.kvUpsert.run({ + name: 'pickleKey', + value: pickleKey, + }); + } + + public async getPickleKey(): Promise { + await this.readyPromise; + const row = await this.kvSelect.get({name: 'pickleKey'}); + return row?.value; + } + + public async setPickledAccount(pickled: string): Promise { + await this.readyPromise; + await this.kvUpsert.run({ + name: 'pickled', + value: pickled, + }); + } + + public async getPickledAccount(): Promise { + await this.readyPromise; + const row = await this.kvSelect.get({name: 'pickled'}); + return row?.value; + } + + public async storeRoom(roomId: string, config: Partial): Promise { + await this.readyPromise; + await this.roomUpsert.run({ + roomId: roomId, + config: JSON.stringify(config), + }); + } + + public async getRoom(roomId: string): Promise> { + await this.readyPromise; + const row = await this.roomOneSelect.get({roomId: roomId}); + const val = row?.config; + return val ? JSON.parse(val) : null; + } + + /** + * Closes the crypto store. Primarily for testing purposes. + */ + public async close() { + this.db.close(); + this.readyPromise = new Promise(() => {}); + } +} diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index ed4fe50e..f75022f5 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -1,8 +1,7 @@ import * as expect from "expect"; import { - C25519_STORAGE_KEY, DeviceKeyAlgorithm, - DeviceKeyLabel, E25519_STORAGE_KEY, + DeviceKeyLabel, EncryptionAlgorithm, EventKind, IJoinRoomStrategy, @@ -10,8 +9,12 @@ import { IStorageProvider, MatrixClient, Membership, - MemoryStorageProvider, OLM_ACCOUNT_STORAGE_KEY, - OpenIDConnectToken, OTKAlgorithm, OTKCounts, OTKs, PICKLE_STORAGE_KEY, RoomDirectoryLookupResponse, + MemoryStorageProvider, + OpenIDConnectToken, + OTKAlgorithm, + OTKCounts, + OTKs, + RoomDirectoryLookupResponse, setRequestFn, } from "../src"; import * as simple from "simple-mock"; @@ -19,7 +22,7 @@ import * as MockHttpBackend from 'matrix-mock-request'; import { expectArrayEquals, feedOlmAccount } from "./TestUtils"; import { redactObjectForLogging } from "../src/http"; import { PowerLevelAction } from "../src/models/PowerLevelAction"; -import { cryptoIt, notCryptoIt } from "./isCryptoCapableTest"; +import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider"; export const TEST_DEVICE_ID = "TEST_DEVICE"; @@ -27,7 +30,7 @@ export function createTestClient(storage: IStorageProvider = null, userId: strin const http = new MockHttpBackend(); const hsUrl = "https://localhost"; const accessToken = "s3cret"; - const client = new MatrixClient(hsUrl, accessToken, storage, crypto); + const client = new MatrixClient(hsUrl, accessToken, storage, crypto ? new SqliteCryptoStorageProvider(":memory:") : null); (client).userId = userId; // private member access setRequestFn(http.requestFn); @@ -56,11 +59,11 @@ describe('MatrixClient', () => { expect(client.accessToken).toEqual(accessToken); }); - cryptoIt('should create a crypto client when requested', () => { + it('should create a crypto client when requested', () => { const homeserverUrl = "https://example.org"; const accessToken = "example_token"; - const client = new MatrixClient(homeserverUrl, accessToken, null, true); + const client = new MatrixClient(homeserverUrl, accessToken, null, new SqliteCryptoStorageProvider(":memory:")); expect(client.crypto).toBeDefined(); }); @@ -68,23 +71,9 @@ describe('MatrixClient', () => { const homeserverUrl = "https://example.org"; const accessToken = "example_token"; - const client = new MatrixClient(homeserverUrl, accessToken, null, false); + const client = new MatrixClient(homeserverUrl, accessToken, null, null); expect(client.crypto).toBeUndefined(); }); - - notCryptoIt('should fail to create a crypto client', () => { - const homeserverUrl = "https://example.org"; - const accessToken = "example_token"; - - try { - new MatrixClient(homeserverUrl, accessToken, null, true); - - // noinspection ExceptionCaughtLocallyJS - throw new Error("Failed to fail"); - } catch (e) { - expect(e.message).toEqual("Cannot enable encryption: missing dependencies"); - } - }); }); describe("doRequest", () => { @@ -5209,7 +5198,7 @@ describe('MatrixClient', () => { }); describe('uploadDeviceKeys', () => { - notCryptoIt('it should fail', async () => { + it('it should fail when no encryption', async () => { try { const { client } = createTestClient(); await client.uploadDeviceKeys([], {}); @@ -5221,7 +5210,7 @@ describe('MatrixClient', () => { } }); - cryptoIt('it should call the right endpoint', async () => { + it('it should call the right endpoint', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); @@ -5265,7 +5254,7 @@ describe('MatrixClient', () => { }); describe('uploadDeviceOneTimeKeys', () => { - notCryptoIt('it should fail', async () => { + it('it should fail when no encryption is available', async () => { try { const { client } = createTestClient(); await client.uploadDeviceOneTimeKeys({}); @@ -5277,7 +5266,7 @@ describe('MatrixClient', () => { } }); - cryptoIt('it should call the right endpoint', async () => { + it('it should call the right endpoint', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); @@ -5311,7 +5300,7 @@ describe('MatrixClient', () => { }); describe('checkOneTimeKeyCounts', () => { - notCryptoIt('it should fail', async () => { + it('it should fail when no encryption is available', async () => { try { const { client } = createTestClient(); await client.checkOneTimeKeyCounts(); @@ -5323,7 +5312,7 @@ describe('MatrixClient', () => { } }); - cryptoIt('it should call the right endpoint', async () => { + it('it should call the right endpoint', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); @@ -5344,21 +5333,8 @@ describe('MatrixClient', () => { }); describe('getUserDevices', () => { - notCryptoIt('it should fail', async () => { - try { - const { client } = createTestClient(); - await client.getUserDevices([]); - - // noinspection ExceptionCaughtLocallyJS - throw new Error("Failed to fail"); - } catch (e) { - expect(e.message).toEqual("End-to-end encryption is not enabled"); - } - }); - - cryptoIt('it should call the right endpoint', async () => { - const userId = "@test:example.org"; - const { client, http } = createTestClient(null, userId, true); + it('it should call the right endpoint', async () => { + const { client, http } = createTestClient(); const timeout = 15000; const requestBody = { @@ -5391,7 +5367,7 @@ describe('MatrixClient', () => { expect(result).toMatchObject(response); }); - cryptoIt('it should call the right endpoint with a default timeout', async () => { + it('it should call the right endpoint with a default timeout', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); diff --git a/test/TestUtils.ts b/test/TestUtils.ts index 62f08c32..66841b43 100644 --- a/test/TestUtils.ts +++ b/test/TestUtils.ts @@ -1,10 +1,6 @@ import * as expect from "expect"; import { - C25519_STORAGE_KEY, - E25519_STORAGE_KEY, MatrixClient, - OLM_ACCOUNT_STORAGE_KEY, - PICKLE_STORAGE_KEY } from "../src"; import * as crypto from "crypto"; @@ -42,12 +38,9 @@ export async function feedOlmAccount(client: MatrixClient) { const account = new (await prepareOlm()).Account(); try { const pickled = account.pickle(pickleKey); - const keys = JSON.parse(account.identity_keys()); - await Promise.resolve(client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, pickled)); - await Promise.resolve(client.storageProvider.storeValue(PICKLE_STORAGE_KEY, pickleKey)); - await Promise.resolve(client.storageProvider.storeValue(E25519_STORAGE_KEY, keys['ed25519'])); - await Promise.resolve(client.storageProvider.storeValue(C25519_STORAGE_KEY, keys['curve25519'])); + await client.cryptoStore.setPickledAccount(pickled); + await client.cryptoStore.setPickleKey(pickleKey); } finally { account.free(); } diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 560704dc..46652f6c 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1,18 +1,14 @@ import * as expect from "expect"; import * as simple from "simple-mock"; import { - C25519_STORAGE_KEY, - DEVICE_ID_STORAGE_KEY, E25519_STORAGE_KEY, - EncryptionEventContent, - MatrixClient, OLM_ACCOUNT_STORAGE_KEY, OTKAlgorithm, OTKCounts, PICKLE_STORAGE_KEY, + OTKAlgorithm, + OTKCounts, RoomEncryptionAlgorithm, - RoomTracker } from "../../src"; import { createTestClient, TEST_DEVICE_ID } from "../MatrixClientTest"; -import { cryptoDescribe } from "../isCryptoCapableTest"; import { feedOlmAccount } from "../TestUtils"; -cryptoDescribe('CryptoClient', () => { +describe('CryptoClient', () => { it('should not have a device ID or be ready until prepared', async () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); @@ -58,8 +54,7 @@ cryptoDescribe('CryptoClient', () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: "wrong" })); client.getWhoAmI = whoamiSpy; @@ -76,8 +71,7 @@ cryptoDescribe('CryptoClient', () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); const deviceKeySpy = simple.stub().callFn(() => Promise.resolve({})); const otkSpy = simple.stub().callFn(() => Promise.resolve({})); @@ -91,47 +85,24 @@ cryptoDescribe('CryptoClient', () => { // NEXT STAGE: Missing Olm Account - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(OLM_ACCOUNT_STORAGE_KEY, null)); - + await client.cryptoStore.setPickledAccount(""); await client.crypto.prepare([]); expect(deviceKeySpy.callCount).toEqual(2); expect(otkSpy.callCount).toEqual(2); // NEXT STAGE: Missing Pickle - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(PICKLE_STORAGE_KEY, null)); - + await client.cryptoStore.setPickleKey(""); await client.crypto.prepare([]); expect(deviceKeySpy.callCount).toEqual(3); expect(otkSpy.callCount).toEqual(3); - - // NEXT STAGE: Missing Ed25519 - - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(E25519_STORAGE_KEY, null)); - - await client.crypto.prepare([]); - expect(deviceKeySpy.callCount).toEqual(4); - expect(otkSpy.callCount).toEqual(4); - - // NEXT STAGE: Missing Curve25519 - - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(C25519_STORAGE_KEY, null)); - - await client.crypto.prepare([]); - expect(deviceKeySpy.callCount).toEqual(5); - expect(otkSpy.callCount).toEqual(5); }); it('should use given values if they are all present', async () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); const deviceKeySpy = simple.stub().callFn(() => Promise.resolve({})); @@ -153,8 +124,7 @@ cryptoDescribe('CryptoClient', () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceKeys = () => Promise.resolve({}); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); @@ -175,8 +145,7 @@ cryptoDescribe('CryptoClient', () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceKeys = () => Promise.resolve({}); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); @@ -192,8 +161,7 @@ cryptoDescribe('CryptoClient', () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceKeys = () => Promise.resolve({}); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); @@ -209,8 +177,7 @@ cryptoDescribe('CryptoClient', () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceKeys = () => Promise.resolve({}); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); @@ -226,8 +193,7 @@ cryptoDescribe('CryptoClient', () => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, true); - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); client.checkOneTimeKeyCounts = () => Promise.resolve({}); @@ -246,8 +212,7 @@ cryptoDescribe('CryptoClient', () => { const expectedUpload = 50; - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); client.checkOneTimeKeyCounts = () => Promise.resolve({}); @@ -270,8 +235,7 @@ cryptoDescribe('CryptoClient', () => { const counts: OTKCounts = { [OTKAlgorithm.Signed]: 0, [OTKAlgorithm.Unsigned]: 5 }; const expectedUpload = 50; - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); client.checkOneTimeKeyCounts = () => Promise.resolve({}); @@ -295,8 +259,7 @@ cryptoDescribe('CryptoClient', () => { const counts: OTKCounts = { [OTKAlgorithm.Signed]: 0, [OTKAlgorithm.Unsigned]: 5 }; const expectedUpload = 50; - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); client.checkOneTimeKeyCounts = () => Promise.resolve({}); @@ -329,8 +292,7 @@ cryptoDescribe('CryptoClient', () => { const counts: OTKCounts = { [OTKAlgorithm.Signed]: 14, [OTKAlgorithm.Unsigned]: 5 }; const expectedUpload = 50 - counts[OTKAlgorithm.Signed]; - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); client.checkOneTimeKeyCounts = () => Promise.resolve({}); @@ -354,8 +316,7 @@ cryptoDescribe('CryptoClient', () => { const counts: OTKCounts = { [OTKAlgorithm.Signed]: 0, [OTKAlgorithm.Unsigned]: 5 }; const expectedUpload = 50; - // noinspection ES6RedundantAwait - await Promise.resolve(client.storageProvider.storeValue(DEVICE_ID_STORAGE_KEY, TEST_DEVICE_ID)); + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); await feedOlmAccount(client); client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); client.checkOneTimeKeyCounts = () => Promise.resolve({}); @@ -368,38 +329,29 @@ cryptoDescribe('CryptoClient', () => { }); client.uploadDeviceOneTimeKeys = uploadSpy; - // noinspection ES6RedundantAwait - let account = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + let account = await client.cryptoStore.getPickledAccount(); await client.crypto.updateCounts(counts); expect(uploadSpy.callCount).toEqual(1); - - // noinspection ES6RedundantAwait - let newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + let newAccount = await client.cryptoStore.getPickledAccount(); expect(account).not.toEqual(newAccount); account = newAccount; await client.crypto.updateCounts(counts); expect(uploadSpy.callCount).toEqual(2); - - // noinspection ES6RedundantAwait - newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + newAccount = await client.cryptoStore.getPickledAccount(); expect(account).not.toEqual(newAccount); account = newAccount; await client.crypto.updateCounts(counts); expect(uploadSpy.callCount).toEqual(3); - - // noinspection ES6RedundantAwait - newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + newAccount = await client.cryptoStore.getPickledAccount(); expect(account).not.toEqual(newAccount); account = newAccount; await client.crypto.updateCounts(counts); expect(uploadSpy.callCount).toEqual(4); - - // noinspection ES6RedundantAwait - newAccount = await Promise.resolve(client.storageProvider.readValue(OLM_ACCOUNT_STORAGE_KEY)); + newAccount = await client.cryptoStore.getPickledAccount(); expect(account).not.toEqual(newAccount); }); }); diff --git a/test/encryption/RoomTrackerTest.ts b/test/encryption/RoomTrackerTest.ts index 86ab28ae..8c424b7a 100644 --- a/test/encryption/RoomTrackerTest.ts +++ b/test/encryption/RoomTrackerTest.ts @@ -4,9 +4,9 @@ import { EncryptionEventContent, MatrixClient, RoomEncryptionAlgorithm, RoomTrac import { createTestClient } from "../MatrixClientTest"; function prepareQueueSpies(client: MatrixClient, roomId: string, content: Partial = {}, storedContent: Partial = null): simple.Stub[] { - const readSpy = simple.stub().callFn((key: string) => { - expect(key).toEqual("tracked_room." + roomId); - return Promise.resolve(storedContent ? JSON.stringify(storedContent) : null); + const readSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); + return Promise.resolve(storedContent); }); const stateSpy = simple.stub().callFn((rid: string, eventType: string, stateKey: string) => { @@ -16,18 +16,17 @@ function prepareQueueSpies(client: MatrixClient, roomId: string, content: Partia return Promise.resolve(content); }); - const storeSpy = simple.stub().callFn((key: string, s: string) => { - expect(key).toEqual("tracked_room." + roomId); - const tryStoreContent = JSON.parse(s); - expect(tryStoreContent).toMatchObject({ + const storeSpy = simple.stub().callFn((rid: string, c: Partial) => { + expect(rid).toEqual(roomId); + expect(c).toMatchObject({ ...content, algorithm: content['algorithm'] ?? 'UNKNOWN', }); return Promise.resolve(); }); - client.storageProvider.readValue = readSpy; - client.storageProvider.storeValue = storeSpy; + client.cryptoStore.getRoom = readSpy; + client.cryptoStore.storeRoom = storeSpy; client.getRoomStateEvent = stateSpy; return [readSpy, stateSpy, storeSpy]; @@ -109,7 +108,7 @@ describe('RoomTracker', () => { const roomId = "!b:example.org"; const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; - const { client } = createTestClient(); + const { client } = createTestClient(null, "@user:example.org", true); const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, content); @@ -124,7 +123,7 @@ describe('RoomTracker', () => { const roomId = "!b:example.org"; const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; - const { client } = createTestClient(); + const { client } = createTestClient(null, "@user:example.org", true); const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, { algorithm: "no" }, content); @@ -139,7 +138,7 @@ describe('RoomTracker', () => { const roomId = "!b:example.org"; const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; - const { client } = createTestClient(); + const { client } = createTestClient(null, "@user:example.org", true); const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, content); client.getRoomStateEvent = async (rid: string, et: string, sk: string) => { @@ -160,14 +159,14 @@ describe('RoomTracker', () => { const roomId = "!a:example.org"; const content: Partial = {algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2}; - const { client } = createTestClient(); + const { client } = createTestClient(null, "@user:example.org", true); - const readSpy = simple.stub().callFn((key: string) => { - expect(key).toEqual("tracked_room." + roomId); - return Promise.resolve(JSON.stringify(content)); + const readSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); + return Promise.resolve(content); }); - client.storageProvider.readValue = readSpy; + client.cryptoStore.getRoom = readSpy; const tracker = new RoomTracker(client); const config = await tracker.getRoomCryptoConfig(roomId); @@ -179,19 +178,19 @@ describe('RoomTracker', () => { const roomId = "!a:example.org"; const content: Partial = {algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2}; - const { client } = createTestClient(); + const { client } = createTestClient(null, "@user:example.org", true); - const readSpy = simple.stub().callFn((key: string) => { - expect(key).toEqual("tracked_room." + roomId); + const readSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); if (readSpy.callCount === 1) return Promise.resolve(null); - return Promise.resolve(JSON.stringify(content)); + return Promise.resolve(content); }); const queueSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); return Promise.resolve(); }); - client.storageProvider.readValue = readSpy; + client.cryptoStore.getRoom = readSpy; const tracker = new RoomTracker(client); tracker.queueRoomCheck = queueSpy; @@ -204,10 +203,10 @@ describe('RoomTracker', () => { it('should return empty for unencrypted rooms', async () => { const roomId = "!a:example.org"; - const { client } = createTestClient(); + const { client } = createTestClient(null, "@user:example.org", true); - const readSpy = simple.stub().callFn((key: string) => { - expect(key).toEqual("tracked_room." + roomId); + const readSpy = simple.stub().callFn((rid: string) => { + expect(rid).toEqual(roomId); return Promise.resolve(null); }); const queueSpy = simple.stub().callFn((rid: string) => { @@ -215,7 +214,7 @@ describe('RoomTracker', () => { return Promise.resolve(); }); - client.storageProvider.readValue = readSpy; + client.cryptoStore.getRoom = readSpy; const tracker = new RoomTracker(client); tracker.queueRoomCheck = queueSpy; diff --git a/test/isCryptoCapableTest.ts b/test/isCryptoCapableTest.ts deleted file mode 100644 index d66933e2..00000000 --- a/test/isCryptoCapableTest.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as expect from "expect"; -import { isCryptoCapable } from "../src"; - -export const IS_CRYPTO_TEST_ENV = !process.env.BOTSDK_NO_CRYPTO_TESTS; -export const cryptoDescribe = (IS_CRYPTO_TEST_ENV ? describe : describe.skip); -export const cryptoIt = (IS_CRYPTO_TEST_ENV ? it : it.skip); -export const notCryptoDescribe = (!IS_CRYPTO_TEST_ENV ? describe : describe.skip); -export const notCryptoIt = (!IS_CRYPTO_TEST_ENV ? it : it.skip); - -describe('isCryptoCapable', () => { - cryptoIt('should return true', () => { - expect(isCryptoCapable()).toEqual(true); - }); - notCryptoIt('should return false', () => { - expect(isCryptoCapable()).toEqual(false); - }); -}); diff --git a/test/storage/SqliteCryptoStorageProvider.ts b/test/storage/SqliteCryptoStorageProvider.ts new file mode 100644 index 00000000..55a11548 --- /dev/null +++ b/test/storage/SqliteCryptoStorageProvider.ts @@ -0,0 +1,77 @@ +import * as expect from "expect"; +import * as tmp from "tmp"; +import { SqliteCryptoStorageProvider } from "../../src/storage/SqliteCryptoStorageProvider"; +import { TEST_DEVICE_ID } from "../MatrixClientTest"; +import { EncryptionAlgorithm } from "../../src"; + +tmp.setGracefulCleanup(); + +describe('SqliteCryptoStorageProvider', () => { + it('should return the right device ID', async () => { + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + expect(await store.getDeviceId()).toBeFalsy(); + await store.setDeviceId(TEST_DEVICE_ID); + expect(await store.getDeviceId()).toEqual(TEST_DEVICE_ID); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getDeviceId()).toEqual(TEST_DEVICE_ID); + await store.close(); + }); + + it('should return the right pickle key', async () => { + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + expect(await store.getPickleKey()).toBeFalsy(); + await store.setPickleKey("pickle"); + expect(await store.getPickleKey()).toEqual("pickle"); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getPickleKey()).toEqual("pickle"); + await store.close(); + }); + + it('should return the right pickle account', async () => { + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + expect(await store.getPickledAccount()).toBeFalsy(); + await store.setPickledAccount("pickled"); + expect(await store.getPickledAccount()).toEqual("pickled"); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getPickledAccount()).toEqual("pickled"); + await store.close(); + }); + + it('should store rooms', async () => { + const roomId1 = "!first:example.org"; + const roomId2 = "!second:example.org"; + const roomId3 = "!no_config:example.org"; + + const config1: any = {val: "test"}; + const config2 = {algorithm: EncryptionAlgorithm.MegolmV1AesSha2}; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + expect(await store.getRoom(roomId1)).toBeFalsy(); + expect(await store.getRoom(roomId2)).toBeFalsy(); + expect(await store.getRoom(roomId3)).toBeFalsy(); + await store.storeRoom(roomId1, config1); + expect(await store.getRoom(roomId1)).toMatchObject(config1); + expect(await store.getRoom(roomId2)).toBeFalsy(); + expect(await store.getRoom(roomId3)).toBeFalsy(); + await store.storeRoom(roomId2, config2); + expect(await store.getRoom(roomId1)).toMatchObject(config1); + expect(await store.getRoom(roomId2)).toMatchObject(config2); + expect(await store.getRoom(roomId3)).toBeFalsy(); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getRoom(roomId1)).toMatchObject(config1); + expect(await store.getRoom(roomId2)).toMatchObject(config2); + expect(await store.getRoom(roomId3)).toBeFalsy(); + await store.close(); + }); +}); diff --git a/yarn.lock b/yarn.lock index d1eef3b0..9b5bcd68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -284,6 +284,13 @@ dependencies: "@types/babel-types" "*" +"@types/better-sqlite3@^5.4.3": + version "5.4.3" + resolved "https://registry.yarnpkg.com/@types/better-sqlite3/-/better-sqlite3-5.4.3.tgz#4f1142c02db111627d9c5792f8aa349fcf46d100" + integrity sha512-d4T8Htgz3sQL3u5oVwkWipZLBYUooKEA4fhU9Sp4F6VDIhifQo1NR/IDtnAIID0Y9IXV3TQnNhv6S+m8TnkEdg== + dependencies: + "@types/integer" "*" + "@types/body-parser@*": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -335,6 +342,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/integer@*": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/integer/-/integer-4.0.1.tgz#022f2c77a899e383e6d3dd374142416c22a5b9df" + integrity sha512-QQojPymFcV1hrvWXA1h0pP9RmFBFNuWikZcUEjjVsS19IyKO+jqOX24lp2ZHF4A21EmkosJhJDX7CLG67F2s7A== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -564,6 +576,11 @@ ansi-colors@4.1.1, ansi-colors@^4.1.1: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + ansi-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" @@ -610,6 +627,19 @@ anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -742,6 +772,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -783,11 +818,36 @@ better-docs@^2.3.0: vue-docgen-api "^3.22.0" vue2-ace-editor "^0.0.13" +better-sqlite3@^7.4.3: + version "7.4.3" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-7.4.3.tgz#8e45a4164bf4b4e128d97375023550f780550997" + integrity sha512-07bKjClZg/f4KMVRkzWtoIvazVPcF1gsvVKVIXlxwleC2DxuIhnra3KCMlUT1rFeRYXXckot2a46UciF2d9KLw== + dependencies: + bindings "^1.5.0" + prebuild-install "^6.0.1" + tar "^6.1.0" + binary-extensions@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9" integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.5.0: version "3.7.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de" @@ -860,6 +920,14 @@ buffer-from@^1.0.0, buffer-from@^1.1.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + builtin-modules@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" @@ -959,6 +1027,16 @@ chokidar@3.3.1: optionalDependencies: fsevents "~2.1.2" +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + class-utils@^0.3.5: version "0.3.6" resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" @@ -994,6 +1072,11 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" @@ -1053,6 +1136,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + constantinople@^3.0.1, constantinople@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-3.1.2.tgz#d45ed724f57d3d10500017a7d3a889c1381ae647" @@ -1107,7 +1195,7 @@ core-js@^2.6.5: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.10.tgz#8a5b8391f8cc7013da703411ce5b585706300d7f" integrity sha512-I39t74+4t+zau64EN1fE5v2W31Adtc/REhzWN+gWRRXg6WH5qAsZm62DHpQ1+Yhe4047T55jvzz7MUqF/dBBlA== -core-util-is@1.0.2: +core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= @@ -1164,6 +1252,18 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + deep-is@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -1208,6 +1308,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -1223,6 +1328,11 @@ destroy@~1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + diff-match-patch@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.4.tgz#6ac4b55237463761c4daf0dc603eb869124744b1" @@ -1342,6 +1452,13 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + enquirer@^2.3.5: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" @@ -1571,6 +1688,11 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expect@*: version "24.9.0" resolved "https://registry.yarnpkg.com/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca" @@ -1710,6 +1832,11 @@ file-entry-cache@^5.0.1: dependencies: flat-cache "^2.0.1" +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -1812,6 +1939,18 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1832,6 +1971,20 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + gensync@^1.0.0-beta.1: version "1.0.0-beta.1" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" @@ -1859,6 +2012,11 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + glob-parent@^5.0.0, glob-parent@~5.1.0: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -1943,6 +2101,11 @@ has-symbols@^1.0.1: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -2073,6 +2236,11 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -2099,7 +2267,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2109,6 +2277,11 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + ipaddr.js@1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65" @@ -2251,6 +2424,13 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" @@ -2358,7 +2538,7 @@ is-windows@^1.0.2: resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== -isarray@1.0.0: +isarray@1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= @@ -2851,6 +3031,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mimic-response@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -2878,11 +3063,26 @@ minimist@^1.2.0: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= -minimist@^1.2.5: +minimist@^1.2.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minipass@^3.0.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" + integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg== + dependencies: + yallist "^4.0.0" + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -2891,6 +3091,11 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -2905,7 +3110,7 @@ mkdirp@^0.5.3: dependencies: minimist "^1.2.5" -mkdirp@^1.0.4: +mkdirp@^1.0.3, mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== @@ -2989,6 +3194,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -3004,6 +3214,13 @@ neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +node-abi@^2.21.0: + version "2.30.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.30.0.tgz#8be53bf3e7945a34eea10e0fc9a5982776cf550b" + integrity sha512-g6bZh3YCKQRdwuO/tSZZYJAw622SjsRfJ2X0Iy4sSOHZ34/sPPdVBn8fev2tj7njzLwuqPw9uMtGsGkO5kIQvg== + dependencies: + semver "^5.4.1" + node-dir@^0.1.10: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -3016,6 +3233,21 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +npmlog@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + oauth-sign@~0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" @@ -3096,7 +3328,7 @@ on-headers@~1.0.2: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -once@^1.3.0: +once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= @@ -3229,6 +3461,25 @@ postcss@^8.0.2: nanoid "^3.1.22" source-map "^0.6.1" +prebuild-install@^6.0.1: + version "6.1.3" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.3.tgz#8ea1f9d7386a0b30f7ef20247e36f8b2b82825a2" + integrity sha512-iqqSR84tNYQUQHRXalSKdIaM8Ov1QxOVuBNWI7+BzZWv6Ih9k75wOnH1rGQ9WWTaaLkTpxWKIciOF0KyfM74+Q== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^2.21.0" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -3259,6 +3510,11 @@ private@^0.1.8: resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -3414,6 +3670,14 @@ pug@^2.0.3: pug-runtime "^2.0.5" pug-strip-comments "^1.0.4" +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" @@ -3451,6 +3715,16 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + react-ace@^6.5.0: version "6.6.0" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-6.6.0.tgz#a79457ef03c3b1f8d4fc598a003b1d6ad464f1a0" @@ -3497,6 +3771,28 @@ react-is@^16.8.4: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.11.0.tgz#b85dfecd48ad1ce469ff558a882ca8e8313928fa" integrity sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw== +readable-stream@^2.0.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" @@ -3655,7 +3951,7 @@ rimraf@^3.0.0: dependencies: glob "^7.1.3" -safe-buffer@5.1.2, safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -3665,7 +3961,7 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.2: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== -safe-buffer@^5.1.0: +safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -3741,7 +4037,7 @@ serve-static@1.14.1: parseurl "~1.3.3" send "0.17.1" -set-blocking@^2.0.0: +set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= @@ -3773,6 +4069,25 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +signal-exit@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" + integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-mock@^0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/simple-mock/-/simple-mock-0.8.0.tgz#49c9a223fa6eea8e2c4fd6948fe8300cd8a594f3" @@ -3925,6 +4240,15 @@ steno@^0.4.1: dependencies: graceful-fs "^4.1.3" +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + "string-width@^1.0.2 || 2": version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -3974,6 +4298,27 @@ string.prototype.trimstart@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + strip-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" @@ -4017,6 +4362,11 @@ strip-json-comments@^3.1.0: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + supports-color@7.1.0, supports-color@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" @@ -4046,6 +4396,39 @@ taffydb@2.6.2: resolved "https://registry.yarnpkg.com/taffydb/-/taffydb-2.6.2.tgz#7cbcb64b5a141b6a2efc2c5d2c67b4e150b2a268" integrity sha1-fLy2S1oUG2ou/CxdLGe04VCyomg= +tar-fs@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar@^6.1.0: + version "6.1.2" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.2.tgz#1f045a90a6eb23557a603595f41a16c57d47adc6" + integrity sha512-EwKEgqJ7nJoS+s8QfLYVGMDmAsj+StbI2AM/RTHeUSsOw6Z8bwNBRv5z3CY0m7laC5qUAqruLX5AhMuc5deY3Q== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -4321,6 +4704,11 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -4398,7 +4786,7 @@ which@2.0.2, which@^2.0.1: dependencies: isexe "^2.0.0" -wide-align@1.1.3: +wide-align@1.1.3, wide-align@^1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== From bc51f922e4ac3d8c368bf59a775246ccec032070 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 28 Jul 2021 23:02:25 -0600 Subject: [PATCH 07/26] Initial device list tracking --- src/MatrixClient.ts | 14 +++ src/e2ee/CryptoClient.ts | 32 +++++++ src/e2ee/DeviceTracker.ts | 95 +++++++++++++++++++++ src/storage/ICryptoStorageProvider.ts | 33 +++++++ src/storage/SqliteCryptoStorageProvider.ts | 86 +++++++++++++------ test/storage/SqliteCryptoStorageProvider.ts | 60 +++++++++++++ 6 files changed, 292 insertions(+), 28 deletions(-) create mode 100644 src/e2ee/DeviceTracker.ts diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 6301cab1..d1638f70 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -664,6 +664,20 @@ export class MatrixClient extends EventEmitter { this.crypto?.updateCounts(raw['device_one_time_keys_count']); } + if (raw['device_lists']) { + const changed = raw['device_lists']['changed']; + const removed = raw['device_lists']['left']; + + if (changed) { + this.crypto?.flagUsersDeviceListsOutdated(changed, true); + } + if (removed) { + // Don't resync removed device lists: they are uninteresting according to the server so we + // don't need them. When we request the user's device list again, we'll pull it all back in. + this.crypto?.flagUsersDeviceListsOutdated(removed, false); + } + } + if (raw['groups']) { const leave = raw['groups']['leave'] || {}; for (const groupId of Object.keys(leave)) { diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index d405624d..90e8a6ce 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -12,6 +12,7 @@ import { } from "../models/Crypto"; import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; +import { DeviceTracker } from "./DeviceTracker"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly @@ -27,9 +28,11 @@ export class CryptoClient { private deviceCurve25519: string; private maxOTKs: number; private roomTracker: RoomTracker; + private deviceTracker: DeviceTracker; public constructor(private client: MatrixClient) { this.roomTracker = new RoomTracker(this.client); + this.deviceTracker = new DeviceTracker(this.client); } /** @@ -197,4 +200,33 @@ export class CryptoClient { account.free(); } } + + public async verifySignature(obj: object, key: string, signature: string): Promise { + obj = JSON.parse(JSON.stringify(obj)); + + delete obj['signatures']; + delete obj['unsigned']; + + const util = new Olm.Utility(); + try { + const message = anotherJson.stringify(obj); + util.ed25519_verify(message, key, signature); + } catch (e) { + // Assume it's a verification failure + return false; + } finally { + util.free(); + } + + return true; + } + + /** + * Flags multiple user's device lists as outdated, optionally queuing an immediate update. + * @param {string} userIds The user IDs to flag the device lists of. + * @param {boolean} resync True (default) to queue an immediate update, false otherwise. + */ + public flagUsersDeviceListsOutdated(userIds: string[], resync = true) { + this.deviceTracker.flagUsersOutdated(userIds, resync); + } } diff --git a/src/e2ee/DeviceTracker.ts b/src/e2ee/DeviceTracker.ts new file mode 100644 index 00000000..34836971 --- /dev/null +++ b/src/e2ee/DeviceTracker.ts @@ -0,0 +1,95 @@ +import { MatrixClient } from "../MatrixClient"; +import { LogService } from "../logging/LogService"; +import { DeviceKeyAlgorithm, UserDevice } from "../models/Crypto"; + +/** + * Tracks user devices for encryption operations. + * @category Encryption + */ +export class DeviceTracker { + private deviceListUpdates: Record> = {}; + + public constructor(private client: MatrixClient) { + } + + /** + * Flags multiple user's device lists as outdated, optionally queuing an immediate update. + * @param {string} userIds The user IDs to flag the device lists of. + * @param {boolean} resync True (default) to queue an immediate update, false otherwise. + */ + public async flagUsersOutdated(userIds: string[], resync = true) { + await this.client.cryptoStore.flagUsersOutdated(userIds); + if (resync) { + // We don't really want to wait around for this, so let it work in the background + // noinspection ES6MissingAwait + this.updateUsersDeviceLists(userIds); + } + } + + /** + * Updates multiple user's device lists regardless of outdated flag. + * @param {string[]} userIds The user IDs to update. + * @returns {Promise} Resolves when complete. + */ + public async updateUsersDeviceLists(userIds: string[]): Promise { + // We wait for the lock, but still run through with our update just in case we are lagged. + // This can happen if the server is slow to reply to device list queries, but a user is + // changing information about their device a lot. + const existingPromises = userIds.map(u => this.deviceListUpdates[u]).filter(p => !!p); + if (existingPromises.length > 0) { + await Promise.all(existingPromises); + } + + const promise = new Promise(async resolve => { + const resp = await this.client.getUserDevices(userIds); + for (const userId of Object.keys(resp.device_keys)) { + const validated: UserDevice[] = []; + for (const deviceId of Object.keys(resp.device_keys[userId])) { + const device = resp.device_keys[userId][deviceId]; + if (device.user_id !== userId || device.device_id !== deviceId) { + LogService.warn("DeviceTracker", `Server appears to be lying about device lists: ${userId} ${deviceId} has unexpected device ${device.user_id} ${device.device_id} listed - ignoring device`); + continue; + } + + const ed25519 = device.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + const curve25519 = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`]; + + if (!ed25519 || !curve25519) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing either an Ed25519 or Curve25519 key - ignoring device`); + continue; + } + + const currentDevices = await this.client.cryptoStore.getUserDevices(userId); + const existingDevice = currentDevices.find(d => d.device_id === deviceId); + + if (existingDevice) { + const existingEd25519 = existingDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + if (existingEd25519 !== ed25519) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} appears compromised: Ed25519 key changed - ignoring device`); + continue; + } + } + + const signature = device.signatures?.[userId]?.[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + if (!signature) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing a signature - ignoring device`); + continue; + } + + const validSignature = await this.client.crypto.verifySignature(device, ed25519, signature); + if (!validSignature) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} has an invalid signature - ignoring device`); + continue; + } + + validated.push(device); + } + + await this.client.cryptoStore.setUserDevices(userId, validated); + } + resolve(); + }); + userIds.forEach(u => this.deviceListUpdates[u] = promise); + await promise; + } +} diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index 12c83935..3240caf3 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,4 +1,5 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; +import { UserDevice } from "../models/Crypto"; /** * A storage provider capable of only providing crypto-related storage. @@ -61,4 +62,36 @@ export interface ICryptoStorageProvider { * to falsy if the room is unknown. */ getRoom(roomId: string): Promise>; + + /** + * Sets the user's stored devices to the given array. All devices not in this set will be deleted. + * This will clear the user's outdated flag, if set. + * @param {string} userId The user ID to set the devices for. + * @param {UserDevice[]} devices The devices to set for the user. + * @returns {Promise} Resolves when complete. + */ + setUserDevices(userId: string, devices: UserDevice[]): Promise; + + /** + * Gets the user's stored devices. If no devices are stored, an empty array is returned. + * @param {string} userId The user ID to get devices for. + * @returns {Promise} Resolves to the array of devices for the user. If no + * devices are known, the array will be empty. + */ + getUserDevices(userId: string): Promise; + + /** + * Flags multiple user's device lists as outdated. + * @param {string} userIds The user IDs to flag. + * @returns {Promise} Resolves when complete. + */ + flagUsersOutdated(userIds: string[]): Promise; + + /** + * Checks to see if a user's device list is flagged as outdated. If the user is not known + * then they will be considered outdated. + * @param {string} userId The user ID to check. + * @returns {Promise} Resolves to true if outdated, false otherwise. + */ + isUserOutdated(userId: string): Promise; } diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index 27b5d023..ae3bd8c0 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -1,19 +1,24 @@ import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import * as Database from "better-sqlite3"; +import { UserDevice } from "../models/Crypto"; /** * Sqlite crypto storage provider. Requires `better-sqlite3` package to be installed. * @category Storage providers */ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { - private readyPromise: Promise; private db: Database.Database; private kvUpsert: Database.Statement; private kvSelect: Database.Statement; private roomUpsert: Database.Statement; - private roomOneSelect: Database.Statement; + private roomSelect: Database.Statement; + private userUpsert: Database.Statement; + private userSelect: Database.Statement; + private userDeviceUpsert: Database.Statement; + private userDevicesDelete: Database.Statement; + private userDevicesSelect: Database.Statement; /** * Creates a new Sqlite storage provider. @@ -23,82 +28,107 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { */ public constructor(path: string) { this.db = new Database(path); - this.readyPromise = new Promise(async resolve => { - await this.db.exec("CREATE TABLE IF NOT EXISTS kv (name TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)"); - await this.db.exec("CREATE TABLE IF NOT EXISTS rooms (room_id TEXT PRIMARY KEY NOT NULL, config TEXT NOT NULL)"); + this.db.exec("CREATE TABLE IF NOT EXISTS kv (name TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)"); + this.db.exec("CREATE TABLE IF NOT EXISTS rooms (room_id TEXT PRIMARY KEY NOT NULL, config TEXT NOT NULL)"); + this.db.exec("CREATE TABLE IF NOT EXISTS users (user_id TEXT PRIMARY KEY NOT NULL, outdated TINYINT NOT NULL)"); + this.db.exec("CREATE TABLE IF NOT EXISTS user_devices (user_id TEXT NOT NULL, device_id TEXT NOT NULL, device TEXT NOT NULL, PRIMARY KEY (user_id, device_id))"); - this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value"); - this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name"); + this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value"); + this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name"); - this.roomUpsert = this.db.prepare("INSERT INTO rooms (room_id, config) VALUES (@roomId, @config) ON CONFLICT (room_id) DO UPDATE SET config = @config"); - this.roomOneSelect = this.db.prepare("SELECT room_id, config FROM rooms WHERE room_id = @roomId"); + this.roomUpsert = this.db.prepare("INSERT INTO rooms (room_id, config) VALUES (@roomId, @config) ON CONFLICT (room_id) DO UPDATE SET config = @config"); + this.roomSelect = this.db.prepare("SELECT room_id, config FROM rooms WHERE room_id = @roomId"); - resolve(); - }); + this.userUpsert = this.db.prepare("INSERT INTO users (user_id, outdated) VALUES (@userId, @outdated) ON CONFLICT (user_id) DO UPDATE SET outdated = @outdated"); + this.userSelect = this.db.prepare("SELECT user_id, outdated FROM users WHERE user_id = @userId"); + + this.userDeviceUpsert = this.db.prepare("INSERT INTO user_devices (user_id, device_id, device) VALUES (@userId, @deviceId, @device) ON CONFLICT (user_id, device_id) DO UPDATE SET device = @device"); + this.userDevicesDelete = this.db.prepare("DELETE FROM user_devices WHERE user_id = @userId"); + this.userDevicesSelect = this.db.prepare("SELECT user_id, device_id, device FROM user_devices WHERE user_id = @userId"); } public async setDeviceId(deviceId: string): Promise { - await this.readyPromise; - await this.kvUpsert.run({ + this.kvUpsert.run({ name: 'deviceId', value: deviceId, }); } public async getDeviceId(): Promise { - await this.readyPromise; - const row = await this.kvSelect.get({name: 'deviceId'}); + const row = this.kvSelect.get({name: 'deviceId'}); return row?.value; } public async setPickleKey(pickleKey: string): Promise { - await this.readyPromise; - await this.kvUpsert.run({ + this.kvUpsert.run({ name: 'pickleKey', value: pickleKey, }); } public async getPickleKey(): Promise { - await this.readyPromise; - const row = await this.kvSelect.get({name: 'pickleKey'}); + const row = this.kvSelect.get({name: 'pickleKey'}); return row?.value; } public async setPickledAccount(pickled: string): Promise { - await this.readyPromise; - await this.kvUpsert.run({ + this.kvUpsert.run({ name: 'pickled', value: pickled, }); } public async getPickledAccount(): Promise { - await this.readyPromise; - const row = await this.kvSelect.get({name: 'pickled'}); + const row = this.kvSelect.get({name: 'pickled'}); return row?.value; } public async storeRoom(roomId: string, config: Partial): Promise { - await this.readyPromise; - await this.roomUpsert.run({ + this.roomUpsert.run({ roomId: roomId, config: JSON.stringify(config), }); } public async getRoom(roomId: string): Promise> { - await this.readyPromise; - const row = await this.roomOneSelect.get({roomId: roomId}); + const row = this.roomSelect.get({roomId: roomId}); const val = row?.config; return val ? JSON.parse(val) : null; } + public async setUserDevices(userId: string, devices: UserDevice[]): Promise { + this.db.transaction(() => { + this.userUpsert.run({userId: userId, outdated: 0}); + this.userDevicesDelete.run({userId: userId}); + for (const device of devices) { + this.userDeviceUpsert.run({userId: userId, deviceId: device.device_id, device: JSON.stringify(device)}); + } + })(); + } + + public async getUserDevices(userId: string): Promise { + const results = this.userDevicesSelect.all({userId: userId}) + if (!results) return []; + return results.map(d => JSON.parse(d.device)); + } + + public async flagUsersOutdated(userIds: string[]): Promise { + this.db.transaction(() => { + for (const userId of userIds) { + this.userUpsert.run({userId: userId, outdated: 1}); + } + })(); + } + + public async isUserOutdated(userId: string): Promise { + const user = this.userSelect.get({userId: userId}); + return user ? Boolean(user.outdated) : true; + } + /** * Closes the crypto store. Primarily for testing purposes. */ public async close() { this.db.close(); - this.readyPromise = new Promise(() => {}); } } diff --git a/test/storage/SqliteCryptoStorageProvider.ts b/test/storage/SqliteCryptoStorageProvider.ts index 55a11548..1b881cf1 100644 --- a/test/storage/SqliteCryptoStorageProvider.ts +++ b/test/storage/SqliteCryptoStorageProvider.ts @@ -74,4 +74,64 @@ describe('SqliteCryptoStorageProvider', () => { expect(await store.getRoom(roomId3)).toBeFalsy(); await store.close(); }); + + it('should flag users as outdated by default', async () => { + const userId = "@user:example.org"; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + expect(await store.isUserOutdated(userId)).toEqual(true); + await store.flagUsersOutdated([userId]); + expect(await store.isUserOutdated(userId)).toEqual(true); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.isUserOutdated(userId)).toEqual(true); + await store.setUserDevices(userId, []); + expect(await store.isUserOutdated(userId)).toEqual(false); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.isUserOutdated(userId)).toEqual(false); + await store.close(); + }); + + it('should track multiple users', async () => { + const userId1 = "@user:example.org"; + const userId2 = "@two:example.org"; + + // Not real UserDevices, but this is a test. + const devices1: any = [{device_id: "one"}, {device_id: "two"}]; + const devices2: any = [{device_id: "three"}, {device_id: "four"}]; + + const deviceSortFn = (a, b) => a.device_id < b.device_id ? -1 : (a.device_id === b.device_id ? 0 : 1); + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + expect(await store.isUserOutdated(userId1)).toEqual(true); + expect(await store.isUserOutdated(userId2)).toEqual(true); + await store.setUserDevices(userId1, devices1); + await store.setUserDevices(userId2, devices2); + expect(await store.isUserOutdated(userId1)).toEqual(false); + expect(await store.isUserOutdated(userId2)).toEqual(false); + expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.isUserOutdated(userId1)).toEqual(false); + expect(await store.isUserOutdated(userId2)).toEqual(false); + expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + await store.flagUsersOutdated([userId1, userId2]); + expect(await store.isUserOutdated(userId1)).toEqual(true); + expect(await store.isUserOutdated(userId2)).toEqual(true); + expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.isUserOutdated(userId1)).toEqual(true); + expect(await store.isUserOutdated(userId2)).toEqual(true); + expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + await store.close(); + }); }); From e42a44b1b3868c5889b326d67ae9a282e3fce18e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 3 Aug 2021 21:44:51 -0600 Subject: [PATCH 08/26] WIP for sending encrypted events --- examples/encryption_bot.ts | 47 ++++++++++- src/MatrixClient.ts | 2 +- src/e2ee/CryptoClient.ts | 71 +++++++++++++++++ src/e2ee/DeviceTracker.ts | 23 ++++++ src/models/Crypto.ts | 17 ++++ src/storage/ICryptoStorageProvider.ts | 55 ++++++++++++- src/storage/SqliteCryptoStorageProvider.ts | 90 +++++++++++++++++++++- 7 files changed, 301 insertions(+), 4 deletions(-) diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index 2ea39974..17b5f233 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -1,4 +1,11 @@ -import { LogLevel, LogService, MatrixClient, RichConsoleLogger, SimpleFsStorageProvider } from "../src"; +import { + EncryptionAlgorithm, + LogLevel, + LogService, + MatrixClient, + RichConsoleLogger, + SimpleFsStorageProvider +} from "../src"; import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider"; LogService.setLogger(new RichConsoleLogger()); @@ -13,6 +20,7 @@ try { // ignore } +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"); @@ -21,10 +29,47 @@ const crypto = new SqliteCryptoStorageProvider("./examples/storage/encryption_bo const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); (async function() { + let encryptedRoomId: string; + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); // 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; + break; + } + } + if (!encryptedRoomId) { + encryptedRoomId = await client.createRoom({ + invite: [dmTarget], + is_direct: true, + visibility: "private", + preset: "trusted_private_chat", + initial_state: [ + {type: "m.room.encryption", state_key: "", content: {algorithm: EncryptionAlgorithm.MegolmV1AesSha2}}, + {type: "m.room.guest_access", state_key: "", content: {guest_access: "can_join"}}, + ], + }); + } + await sendEncryptedNotice(encryptedRoomId, "This is an encrypted room"); + client.on("room.event", (roomId: string, event: any) => { + if (roomId !== encryptedRoomId) return; LogService.debug("index", `${roomId}`, event); }); LogService.info("index", "Starting bot..."); await client.start(); })(); + +async function sendEncryptedNotice(roomId: string, text: string) { + const payload = { + room_id: roomId, + type: "m.room.message", + content: { + msgtype: "m.notice", + body: text, + }, + }; + + +} diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index d1638f70..a352df83 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -1156,7 +1156,7 @@ export class MatrixClient extends EventEmitter { * @returns {Promise} resolves to the event ID that represents the event */ @timedMatrixClientFunctionCall() - public sendEvent(roomId: string, eventType: string, content: any): Promise { + public async sendEvent(roomId: string, eventType: string, content: any): Promise { const txnId = (new Date().getTime()) + "__inc" + (++this.requestId); return this.doRequest("PUT", "/_matrix/client/r0/rooms/" + encodeURIComponent(roomId) + "/send/" + encodeURIComponent(eventType) + "/" + encodeURIComponent(txnId), null, content).then(response => { return response['event_id']; diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 90e8a6ce..32a5231c 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -13,6 +13,7 @@ import { import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; import { DeviceTracker } from "./DeviceTracker"; +import { EncryptionEvent } from "../models/events/EncryptionEvent"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly @@ -227,6 +228,76 @@ export class CryptoClient { * @param {boolean} resync True (default) to queue an immediate update, false otherwise. */ public flagUsersDeviceListsOutdated(userIds: string[], resync = true) { + // noinspection JSIgnoredPromiseFromCall this.deviceTracker.flagUsersOutdated(userIds, resync); } + + /** + * Encrypts the details of a room event, returning an encrypted payload to be sent in an + * `m.room.encrypted` event to the room. If needed, this function will send decryption keys + * to the appropriate devices in the room (this happens when the Megolm session rotates or + * gets created). + * @param {string} roomId The room ID to encrypt within. If the room is not encrypted, an + * error is thrown. + * @param {string} eventType The event type being encrypted. + * @param {any} content The event content being encrypted. + * @returns {Promise} Resolves to the encrypted content for an `m.room.encrypted` event. + */ + public async encryptEvent(roomId: string, eventType: string, content: any): Promise { + if (!(await this.isRoomEncrypted(roomId))) { + throw new Error("Room is not encrypted"); + } + + const now = (new Date()).getTime(); + + let currentSession = await this.client.cryptoStore.getCurrentOutboundGroupSession(roomId); + if (currentSession && (currentSession.expiresTs <= now || currentSession.usesLeft <= 0)) { + currentSession = null; // force rotation + } + if (!currentSession) { + // Make a new session, either because we don't have one or it rotated. + const roomConfig = new EncryptionEvent({ + type: "m.room.encryption", + state_key: "", + content: await this.roomTracker.getRoomCryptoConfig(roomId), + }); + + const session = new Olm.OutboundGroupSession(); + try { + session.create(); + const pickled = session.pickle(this.pickleKey); + currentSession = { + sessionId: session.session_id(), + roomId: roomId, + pickled: pickled, + isCurrent: true, + usesLeft: roomConfig.rotationPeriodMessages, + expiresTs: now + roomConfig.rotationPeriodMs, + }; + await this.client.cryptoStore.storeOutboundGroupSession(currentSession); + // TODO: Store as inbound session too + + } finally { + session.free(); + } + } + + // TODO: Include invited members? + const memberUserIds = await this.client.getJoinedRoomMembers(roomId); + const devices = await this.deviceTracker.getDevicesFor(memberUserIds); + + const session = new Olm.OutboundGroupSession(); + try { + session.unpickle(this.pickleKey, currentSession.pickled); + + for (const userId of Object.keys(devices)) { + for (const deviceId of Object.keys(devices[userId])) { + const device = devices[userId][deviceId]; + // TODO: Olm session management + } + } + } finally { + session.free(); + } + } } diff --git a/src/e2ee/DeviceTracker.ts b/src/e2ee/DeviceTracker.ts index 34836971..fc5ccf56 100644 --- a/src/e2ee/DeviceTracker.ts +++ b/src/e2ee/DeviceTracker.ts @@ -12,6 +12,29 @@ export class DeviceTracker { public constructor(private client: MatrixClient) { } + /** + * Gets the device lists for the given user IDs. Outdated device lists will be updated before + * returning. + * @param {string[]} userIds The user IDs to get the device lists of. + * @returns {Promise>} Resolves to a map of user ID to device list. + * If a user has no devices, they may be excluded from the result or appear as an empty array. + */ + public async getDevicesFor(userIds: string[]): Promise> { + const outdatedUserIds: string[] = []; + for (const userId of userIds) { + const isOutdated = await this.client.cryptoStore.isUserOutdated(userId); + if (isOutdated) outdatedUserIds.push(userId); + } + + await this.updateUsersDeviceLists(outdatedUserIds); + + const userDeviceMap: Record = {}; + for (const userId of userIds) { + userDeviceMap[userId] = await this.client.cryptoStore.getUserDevices(userId); + } + return userDeviceMap; + } + /** * Flags multiple user's device lists as outdated, optionally queuing an immediate update. * @param {string} userIds The user IDs to flag the device lists of. diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index 104deac6..439affed 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -89,6 +89,10 @@ export interface UserDevice { }; } +/** + * Device list response for a multi-user query. + * @category Models + */ export interface MultiUserDeviceListResponse { /** * Federation failures, keyed by server name. The mapped object should be a standard @@ -103,3 +107,16 @@ export interface MultiUserDeviceListResponse { */ device_keys: Record>; } + +/** + * An outbound group session. + * @category Models + */ +export interface IOutboundGroupSession { + sessionId: string; + roomId: string; + pickled: string; + isCurrent: boolean; + usesLeft: number; + expiresTs: number; +} diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index 3240caf3..8faed089 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,5 +1,5 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; -import { UserDevice } from "../models/Crypto"; +import { IOutboundGroupSession, UserDevice } from "../models/Crypto"; /** * A storage provider capable of only providing crypto-related storage. @@ -94,4 +94,57 @@ export interface ICryptoStorageProvider { * @returns {Promise} Resolves to true if outdated, false otherwise. */ isUserOutdated(userId: string): Promise; + + /** + * Stores a pickled outbound group session. If the session is flagged as current, all other sessions + * for the room ID will be flagged as not-current. + * @param {IOutboundGroupSession} session The session to store. + * @returns {Promise} Resolves when complete. + */ + storeOutboundGroupSession(session: IOutboundGroupSession): Promise; + + /** + * Gets a previously stored outbound group session. If the session ID is not known, a falsy value + * will be returned. + * @param {string} sessionId The session ID. + * @param {string} roomId The room ID where the session is stored. + * @returns {Promise} Resolves to the session, or falsy if not known. + */ + getOutboundGroupSession(sessionId: string, roomId: string): Promise; + + /** + * Gets the current outbound group session for a room. If the room does not have a current session, + * a falsy value will be returned. + * @param {string} roomId The room ID. + * @returns {Promise} Resolves to the current session, or falsy if not known. + */ + getCurrentOutboundGroupSession(roomId: string): Promise; + + /** + * Decrements the available usages for an outbound group session. + * @param {string} sessionId The session ID. + * @param {string} roomId The room ID. + * @returns {Promise} Resolves when complete. + */ + useOutboundGroupSession(sessionId: string, roomId: string): Promise; + + /** + * Stores a session as sent to a user's device. + * @param {IOutboundGroupSession} session The session that was sent. + * @param {number} index The session index. + * @param {UserDevice} device The device the session was sent to. + * @returns {Promise} Resolves when complete. + */ + storeSentOutboundGroupSession(session: IOutboundGroupSession, index: number, device: UserDevice): Promise; + + /** + * Gets the last sent session that was sent to a user's device. If none is recorded, + * a falsy value is returned. + * @param {string} userId The user ID to look for. + * @param {string} deviceId The device ID to look for. + * @param {string} roomId The room ID to look in. + * @returns {Promise<{sessionId: string, index: number}>} Resolves to the last session + * sent, or falsy if not known. + */ + getLastSentOutboundGroupSession(userId: string, deviceId: string, roomId: string): Promise<{sessionId: string, index: number}>; } diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index ae3bd8c0..76f22eec 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -1,7 +1,7 @@ import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import * as Database from "better-sqlite3"; -import { UserDevice } from "../models/Crypto"; +import { IOutboundGroupSession, UserDevice } from "../models/Crypto"; /** * Sqlite crypto storage provider. Requires `better-sqlite3` package to be installed. @@ -19,6 +19,13 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { private userDeviceUpsert: Database.Statement; private userDevicesDelete: Database.Statement; private userDevicesSelect: Database.Statement; + private obGroupSessionUpsert: Database.Statement; + private obGroupSessionSelect: Database.Statement; + private obGroupCurrentSessionSelect: Database.Statement; + private obGroupSessionMarkUsage: Database.Statement; + private obGroupSessionMarkAllInactive: Database.Statement; + private obSentGroupSessionUpsert: Database.Statement; + private obSentSelectLastSent: Database.Statement; /** * Creates a new Sqlite storage provider. @@ -32,6 +39,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.db.exec("CREATE TABLE IF NOT EXISTS rooms (room_id TEXT PRIMARY KEY NOT NULL, config TEXT NOT NULL)"); this.db.exec("CREATE TABLE IF NOT EXISTS users (user_id TEXT PRIMARY KEY NOT NULL, outdated TINYINT NOT NULL)"); this.db.exec("CREATE TABLE IF NOT EXISTS user_devices (user_id TEXT NOT NULL, device_id TEXT NOT NULL, device TEXT NOT NULL, PRIMARY KEY (user_id, device_id))"); + this.db.exec("CREATE TABLE IF NOT EXISTS outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, current TINYINT NOT NULL, pickled TEXT NOT NULL, uses_left NUMBER NOT NULL, expires_ts NUMBER NOT NULL, PRIMARY KEY (session_id, room_id))"); + this.db.exec("CREATE TABLE IF NOT EXISTS sent_outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, index INT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id, index))"); this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value"); this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name"); @@ -45,6 +54,15 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.userDeviceUpsert = this.db.prepare("INSERT INTO user_devices (user_id, device_id, device) VALUES (@userId, @deviceId, @device) ON CONFLICT (user_id, device_id) DO UPDATE SET device = @device"); this.userDevicesDelete = this.db.prepare("DELETE FROM user_devices WHERE user_id = @userId"); this.userDevicesSelect = this.db.prepare("SELECT user_id, device_id, device FROM user_devices WHERE user_id = @userId"); + + this.obGroupSessionUpsert = this.db.prepare("INSERT INTO outbound_group_sessions (session_id, room_id, current, pickled, uses_left, expires_ts) VALUES (@sessionId, @roomId, @current, @pickled, @usesLeft, @expiresTs) ON CONFLICT (session_id, room_id) DO UPDATE SET pickled = @pickled, current = @current, uses_left = @usesLeft, expires_ts = @expiresTs"); + this.obGroupSessionSelect = this.db.prepare("SELECT session_id, room_id, current, pickled, uses_left, expires_ts FROM outbound_group_sessions WHERE session_id = @sessionId AND room_id = @roomId"); + this.obGroupCurrentSessionSelect = this.db.prepare("SELECT session_id, room_id, current, pickled, uses_left, expires_ts FROM outbound_group_sessions WHERE room_id = @roomId AND current = 1"); + this.obGroupSessionMarkUsage = this.db.prepare("UPDATE outbound_group_sessions SET uses_left = uses_left - 1 WHERE session_id = @sessionId and room_id = @roomId"); + this.obGroupSessionMarkAllInactive = this.db.prepare("UPDATE outbound_group_sessions SET current = 0 WHERE room_id = @roomId"); + + this.obSentGroupSessionUpsert = this.db.prepare("INSERT INTO sent_outbound_group_sessions (session_id, room_id, index, user_id, device_id) VALUES (@sessionId, @roomId, @index, @userId, @deviceId) ON CONFLICT (session_id, room_id, user_id, device_id, index) DO NOTHING"); + this.obSentSelectLastSent = this.db.prepare("SELECT session_id, room_id, index, user_id, device_id FROM sent_outbound_group_sessions WHERE user_id = @userId AND device_id = @deviceId AND room_id = @roomId"); } public async setDeviceId(deviceId: string): Promise { @@ -125,6 +143,76 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { return user ? Boolean(user.outdated) : true; } + public async storeOutboundGroupSession(session: IOutboundGroupSession): Promise { + this.db.transaction(() => { + if (session.isCurrent) { + this.obGroupSessionMarkAllInactive.run({ + roomId: session.roomId, + }); + } + this.obGroupSessionUpsert.run({ + sessionId: session.sessionId, + roomId: session.roomId, + pickled: session.pickled, + current: session.isCurrent ? 1 : 0, + usesLeft: session.usesLeft, + expiresTs: session.expiresTs, + }); + }); + } + + public async getOutboundGroupSession(sessionId: string, roomId: string): Promise { + const result = this.obGroupSessionSelect.get({sessionId: sessionId, roomId: roomId}); + if (result) { + return { + sessionId: result.session_id, + roomId: result.room_id, + pickled: result.pickled, + isCurrent: result.current === 1, + usesLeft: result.usesLeft, + expiresTs: result.expiresTs, + }; + } + return null; + } + + public async getCurrentOutboundGroupSession(roomId: string): Promise { + const result = this.obGroupCurrentSessionSelect.get({roomId: roomId}); + if (result) { + return { + sessionId: result.session_id, + roomId: result.room_id, + pickled: result.pickled, + isCurrent: result.current === 1, + usesLeft: result.usesLeft, + expiresTs: result.expiresTs, + }; + } + return null; + } + + public async useOutboundGroupSession(sessionId: string, roomId: string): Promise { + this.obGroupSessionMarkUsage.run({sessionId: sessionId, roomId: roomId}); + } + + public async storeSentOutboundGroupSession(session: IOutboundGroupSession, index: number, device: UserDevice): Promise { + this.obSentGroupSessionUpsert.run({ + sessionId: session.sessionId, + roomId: session.roomId, + index: index, + userId: device.user_id, + deviceId: device.device_id, + }); + } + + public async getLastSentOutboundGroupSession(userId: string, deviceId: string, roomId: string): Promise<{sessionId: string, index: number}> { + const result = this.obSentSelectLastSent.get({userId: userId, deviceId: deviceId, roomId: roomId}); + if (result) { + return {sessionId: result.session_id, index: result.index}; + } + return null; + } + /** * Closes the crypto store. Primarily for testing purposes. */ From bb79d522fd13961ba77513dae07803355a86ea7e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Aug 2021 22:26:00 -0600 Subject: [PATCH 09/26] Finish primary send path --- examples/encryption_bot.ts | 15 +- src/MatrixClient.ts | 45 ++++- src/e2ee/CryptoClient.ts | 198 +++++++++++++++++++-- src/models/Crypto.ts | 91 ++++++++++ src/storage/ICryptoStorageProvider.ts | 27 ++- src/storage/SqliteCryptoStorageProvider.ts | 46 ++++- 6 files changed, 391 insertions(+), 31 deletions(-) diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index 17b5f233..1eb02a92 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -62,14 +62,9 @@ const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); })(); async function sendEncryptedNotice(roomId: string, text: string) { - const payload = { - room_id: roomId, - type: "m.room.message", - content: { - msgtype: "m.notice", - body: text, - }, - }; - - + const encrypted = await client.crypto.encryptRoomEvent(roomId, "m.room.message", { + msgtype: "m.notice", + body: text, + }); + await client.sendEvent(roomId, "m.room.encrypted", encrypted); } diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index a352df83..88d2764f 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -28,8 +28,8 @@ import { CryptoClient } from "./e2ee/CryptoClient"; import { DeviceKeyAlgorithm, DeviceKeyLabel, - EncryptionAlgorithm, - MultiUserDeviceListResponse, + EncryptionAlgorithm, IDeviceMessage, + MultiUserDeviceListResponse, OTKAlgorithm, OTKClaimResponse, OTKCounts, OTKs, UserDevice @@ -1672,7 +1672,7 @@ export class MatrixClient extends EventEmitter { * * See https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-keys-query for more * information. - * @param {string[]} userIds The user IDs to + * @param {string[]} userIds The user IDs to query. * @param {number} federationTimeoutMs The default timeout for requesting devices over federation. Defaults to * 10 seconds. * @returns {Promise} Resolves to the device list/errors for the requested user IDs. @@ -1683,7 +1683,44 @@ export class MatrixClient extends EventEmitter { for (const userId of userIds) { req[userId] = []; } - return this.doRequest("POST", "/_matrix/client/r0/keys/query", { timeout: federationTimeoutMs }, req); + return this.doRequest("POST", "/_matrix/client/r0/keys/query", {}, { + timeout: federationTimeoutMs, + device_keys: req, + }); + } + + /** + * Claims One Time Keys for a set of user devices, returning those keys. The caller is expected to verify + * and validate the returned keys. + * + * Failures with federation are reported in the returned object. + * @param {Record>} userDeviceMap The map of user IDs to device IDs to + * OTKAlgorithm to request a claim for. + * @param {number} federationTimeoutMs The default timeout for claiming keys over federation. Defaults to + * 10 seconds. + */ + @timedMatrixClientFunctionCall() + @requiresCrypto() + public async claimOneTimeKeys(userDeviceMap: Record>, federationTimeoutMs = 10000): Promise { + return this.doRequest("POST", "/_matrix/client/r0/keys/claim", {}, { + timeout: federationTimeoutMs, + one_time_keys: userDeviceMap, + }); + } + + /** + * Sends to-device messages to the respective users/devices. + * @param {string} type The message type being sent. + * @param {Record>} messages The messages to send, mapped as user ID to + * device ID (or "*" to denote all of the user's devices) to message payload (content). + * @returns {Promise} Resolves when complete. + */ + @timedMatrixClientFunctionCall() + public async sendToDevices(type: string, messages: Record>): Promise { + const txnId = (new Date().getTime()) + "_TDEV__inc" + (++this.requestId); + return this.doRequest("PUT", `/_matrix/client/r0/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, { + messages: messages, + }); } /** diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 32a5231c..c0c0f63c 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -6,9 +6,14 @@ import * as anotherJson from "another-json"; import { DeviceKeyAlgorithm, EncryptionAlgorithm, + IOlmEncrypted, + IOlmPayload, + IOlmSession, OTKAlgorithm, - OTKCounts, OTKs, + OTKCounts, + OTKs, Signatures, + UserDevice, } from "../models/Crypto"; import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; @@ -105,6 +110,11 @@ export class CryptoClient { this.pickledAccount = pickled; this.maxOTKs = account.max_number_of_one_time_keys(); + + const keys = JSON.parse(account.identity_keys()); + this.deviceCurve25519 = keys['curve25519']; + this.deviceEd25519 = keys['ed25519']; + this.ready = true; const counts = await this.client.uploadDeviceKeys([ @@ -120,13 +130,14 @@ export class CryptoClient { this.pickleKey = pickleKey; this.pickledAccount = pickled; this.maxOTKs = account.max_number_of_one_time_keys(); + + const keys = JSON.parse(account.identity_keys()); + this.deviceCurve25519 = keys['curve25519']; + this.deviceEd25519 = keys['ed25519']; + this.ready = true; await this.updateCounts(await this.client.checkOneTimeKeyCounts()); } - - const keys = JSON.parse(account.identity_keys()); - this.deviceCurve25519 = keys['curve25519']; - this.deviceEd25519 = keys['ed25519']; } finally { account.free(); } @@ -211,7 +222,7 @@ export class CryptoClient { const util = new Olm.Utility(); try { const message = anotherJson.stringify(obj); - util.ed25519_verify(message, key, signature); + util.ed25519_verify(key, message, signature); } catch (e) { // Assume it's a verification failure return false; @@ -232,6 +243,142 @@ export class CryptoClient { this.deviceTracker.flagUsersOutdated(userIds, resync); } + /** + * Gets or creates Olm sessions for the given users and devices. Where sessions cannot be created, + * the user/device will be excluded from the returned map. + * @param {Record} userDeviceMap Map of user IDs to device IDs + * @returns {Promise>>} Resolves to a map of user ID to device + * ID to session. Users/devices which cannot have sessions made will not be included, thus the object + * may be empty. + */ + public async getOrCreateOlmSessions(userDeviceMap: Record): Promise>> { + const otkClaimRequest: Record> = {}; + const userDeviceSessionIds: Record> = {}; + + const myUserId = await this.client.getUserId(); + const myDeviceId = this.clientDeviceId; + for (const userId of Object.keys(userDeviceMap)) { + for (const deviceId of userDeviceMap[userId]) { + if (userId === myUserId && deviceId === myDeviceId) { + // Skip creating a session for our own device + continue; + } + + const existingSession = await this.client.cryptoStore.getCurrentOlmSession(userId, deviceId); + if (existingSession) { + if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {}; + userDeviceSessionIds[userId][deviceId] = existingSession; + } else { + if (!otkClaimRequest[userId]) otkClaimRequest[userId] = {}; + otkClaimRequest[userId][deviceId] = OTKAlgorithm.Signed; + } + } + } + + const claimed = await this.client.claimOneTimeKeys(otkClaimRequest); + for (const userId of Object.keys(claimed.one_time_keys)) { + const storedDevices = await this.client.cryptoStore.getUserDevices(userId); + for (const deviceId of Object.keys(claimed.one_time_keys[userId])) { + try { + const device = storedDevices.find(d => d.user_id === userId && d.device_id === deviceId); + if (!device) { + LogService.warn("CryptoClient", `Failed to handle claimed OTK: unable to locate stored device for user: ${userId} ${deviceId}`); + continue; + } + + const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25119}:${deviceId}`; + + const keyId = Object.keys(claimed.one_time_keys[userId][deviceId])[0]; + const signedKey = claimed.one_time_keys[userId][deviceId][keyId]; + const signature = signedKey?.signatures?.[userId]?.[deviceKeyLabel]; + if (!signature) { + LogService.warn("CryptoClient", `Failed to find appropriate signature for claimed OTK ${userId} ${deviceId}`); + continue; + } + + const verified = await this.verifySignature(signedKey, device.keys[deviceKeyLabel], signature); + if (!verified) { + LogService.warn("CryptoClient", `Invalid signature for claimed OTK ${userId} ${deviceId}`); + continue; + } + + // TODO: Handle spec rate limiting + // Clients should rate-limit the number of sessions it creates per device that it receives a message + // from. Clients should not create a new session with another device if it has already created one + // for that given device in the past 1 hour. + + // Finally, we can create a session. We do this on each loop just in case something goes wrong given + // we don't have app-level transaction support here. We want to persist as many outbound sessions as + // we can before exploding. + const account = await this.getOlmAccount(); + const session = new Olm.Session(); + try { + const curveDeviceKey = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`]; + session.create_outbound(account, curveDeviceKey, signedKey.key); + const storedSession: IOlmSession = { + sessionId: session.session_id(), + lastDecryptionTs: Date.now(), + pickled: session.pickle(this.pickleKey), + }; + await this.client.cryptoStore.storeOlmSession(userId, deviceId, storedSession); + + if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {}; + userDeviceSessionIds[userId][deviceId] = storedSession; + + // Send a dummy event so the device can prepare the session. + // await this.encryptAndSendOlmMessage(device, storedSession, "m.dummy", {}); + } finally { + session.free(); + await this.storeAndFreeOlmAccount(account); + } + } catch (e) { + LogService.warn("CryptoClient", `Unable to verify signature of claimed OTK ${userId} ${deviceId}:`, e); + } + } + } + + return userDeviceSessionIds; + } + + private async encryptAndSendOlmMessage(device: UserDevice, session: IOlmSession, type: string, content: any): Promise { + const olmSession = new Olm.Session(); + try { + olmSession.unpickle(this.pickleKey, session.pickled); + const payload: IOlmPayload = { + keys: { + ed25519: this.deviceEd25519, + }, + recipient_keys: { + ed25519: device.keys[`${DeviceKeyAlgorithm.Ed25119}:${device.device_id}`], + }, + recipient: device.user_id, + sender: await this.client.getUserId(), + content: content, + type: type, + }; + const encrypted = olmSession.encrypt(JSON.stringify(payload)); + await this.client.cryptoStore.storeOlmSession(device.user_id, device.device_id, { + pickled: olmSession.pickle(this.pickleKey), + lastDecryptionTs: session.lastDecryptionTs, + sessionId: olmSession.session_id(), + }); + const message: IOlmEncrypted = { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2, + ciphertext: { + [device.keys[`${DeviceKeyAlgorithm.Curve25519}:${device.device_id}`]]: encrypted as any, + }, + sender_key: this.deviceCurve25519, + }; + await this.client.sendToDevices("m.room.encrypted", { + [device.user_id]: { + [device.device_id]: message, + }, + }); + } finally { + olmSession.free(); + } + } + /** * Encrypts the details of a room event, returning an encrypted payload to be sent in an * `m.room.encrypted` event to the room. If needed, this function will send decryption keys @@ -243,7 +390,7 @@ export class CryptoClient { * @param {any} content The event content being encrypted. * @returns {Promise} Resolves to the encrypted content for an `m.room.encrypted` event. */ - public async encryptEvent(roomId: string, eventType: string, content: any): Promise { + public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise { if (!(await this.isRoomEncrypted(roomId))) { throw new Error("Room is not encrypted"); } @@ -290,14 +437,45 @@ export class CryptoClient { try { session.unpickle(this.pickleKey, currentSession.pickled); + const neededSessions: Record = {}; for (const userId of Object.keys(devices)) { - for (const deviceId of Object.keys(devices[userId])) { - const device = devices[userId][deviceId]; - // TODO: Olm session management + neededSessions[userId] = devices[userId].map(d => d.device_id); + } + const olmSessions = await this.getOrCreateOlmSessions(neededSessions); + + for (const userId of Object.keys(devices)) { + for (const device of devices[userId]) { + const olmSession = olmSessions[userId]?.[device.device_id]; + if (!olmSession) { + LogService.warn("CryptoClient", `Unable to send Megolm session to ${userId} ${device.device_id}: No Olm session`); + continue; + } + await this.encryptAndSendOlmMessage(device, olmSession, "m.room_key", { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: roomId, + session_id: session.session_id(), + session_key: session.session_key(), + }); } } + + const encrypted = session.encrypt(JSON.stringify({ + type: eventType, + content: content, + room_id: roomId, + })); + + return { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + sender_key: this.deviceCurve25519, + ciphertext: encrypted, + session_id: session.session_id(), + device_id: this.clientDeviceId, + }; } finally { session.free(); } + + } } diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index 439affed..202edf91 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -108,6 +108,25 @@ export interface MultiUserDeviceListResponse { device_keys: Record>; } +/** + * One Time Key claim response. + * @category Models + */ +export interface OTKClaimResponse { + /** + * Federation failures, keyed by server name. The mapped object should be a standard + * error object. + */ + failures: { + [serverName: string]: any; + }; + + /** + * The claimed One Time Keys, as a map from user ID to device ID to key ID to OTK. + */ + one_time_keys: Record>; +} + /** * An outbound group session. * @category Models @@ -120,3 +139,75 @@ export interface IOutboundGroupSession { usesLeft: number; expiresTs: number; } + +/** + * An Olm session. + * @category Models + */ +export interface IOlmSession { + sessionId: string; + pickled: string; + lastDecryptionTs: number; +} + +/** + * An Olm payload (plaintext). + * @category Models + */ +export interface IOlmPayload { + type: string; + content: any; + sender: string; + recipient: string; // user ID + recipient_keys: { + ed25519: string; // our key + }; + keys: { + ed25519: string; // their key + }; +} + +/** + * An encrypted Olm payload. + * @category Models + */ +export interface IOlmEncrypted { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2; + sender_key: string; + ciphertext: { + [deviceCurve25519Key: string]: { + type: number; + body: string; // base64 + }; + }; +} + +/** + * The kind of payload which is sent encrypted from an Olm device. + * @category Models + */ +export enum OlmPayloadKind { + CanSetUpSession = 0, + RequiresKnownSession = 1, +} + +/** + * A device message. + * @category Models + */ +export interface IDeviceMessage { + /** + * The recipient user ID. + */ + targetUserId: string; + + /** + * The recipient device ID. May be "*" to denote all of the user's devices. + */ + targetDeviceId: string; + + /** + * The payload. + */ + content: any; +} diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index 8faed089..4df29dfc 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,5 +1,5 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; -import { IOutboundGroupSession, UserDevice } from "../models/Crypto"; +import { IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; /** * A storage provider capable of only providing crypto-related storage. @@ -80,6 +80,14 @@ export interface ICryptoStorageProvider { */ getUserDevices(userId: string): Promise; + /** + * Gets a user's stored device. If the device is not known or active, falsy is returned. + * @param {string} userId The user ID. + * @param {string} deviceId The device ID. + * @returns {Promise} Resolves to the user's device, or falsy if not known. + */ + getUserDevice(userId: string, deviceId: string): Promise; + /** * Flags multiple user's device lists as outdated. * @param {string} userIds The user IDs to flag. @@ -147,4 +155,21 @@ export interface ICryptoStorageProvider { * sent, or falsy if not known. */ getLastSentOutboundGroupSession(userId: string, deviceId: string, roomId: string): Promise<{sessionId: string, index: number}>; + + /** + * Stores/updates an Olm session for a user's device. + * @param {string} userId The user ID. + * @param {string} deviceId The device ID. + * @param {IOlmSession} session The session. + * @returns {Promise} Resolves when complete. + */ + storeOlmSession(userId: string, deviceId: string, session: IOlmSession): Promise; + + /** + * Gets the most current Olm session for the user's device. If none is present, a falsy value is returned. + * @param {string} userId The user ID. + * @param {string} deviceId The device ID. + * @returns {Promise} Resolves to the Olm session, or falsy if none found. + */ + getCurrentOlmSession(userId: string, deviceId: string): Promise; } diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index 76f22eec..bf749981 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -1,7 +1,7 @@ import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import * as Database from "better-sqlite3"; -import { IOutboundGroupSession, UserDevice } from "../models/Crypto"; +import { IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; /** * Sqlite crypto storage provider. Requires `better-sqlite3` package to be installed. @@ -19,6 +19,7 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { private userDeviceUpsert: Database.Statement; private userDevicesDelete: Database.Statement; private userDevicesSelect: Database.Statement; + private userDeviceSelect: Database.Statement; private obGroupSessionUpsert: Database.Statement; private obGroupSessionSelect: Database.Statement; private obGroupCurrentSessionSelect: Database.Statement; @@ -26,6 +27,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { private obGroupSessionMarkAllInactive: Database.Statement; private obSentGroupSessionUpsert: Database.Statement; private obSentSelectLastSent: Database.Statement; + private olmSessionUpsert: Database.Statement; + private olmSessionCurrentSelect: Database.Statement; /** * Creates a new Sqlite storage provider. @@ -40,7 +43,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.db.exec("CREATE TABLE IF NOT EXISTS users (user_id TEXT PRIMARY KEY NOT NULL, outdated TINYINT NOT NULL)"); this.db.exec("CREATE TABLE IF NOT EXISTS user_devices (user_id TEXT NOT NULL, device_id TEXT NOT NULL, device TEXT NOT NULL, PRIMARY KEY (user_id, device_id))"); this.db.exec("CREATE TABLE IF NOT EXISTS outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, current TINYINT NOT NULL, pickled TEXT NOT NULL, uses_left NUMBER NOT NULL, expires_ts NUMBER NOT NULL, PRIMARY KEY (session_id, room_id))"); - this.db.exec("CREATE TABLE IF NOT EXISTS sent_outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, index INT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id, index))"); + this.db.exec("CREATE TABLE IF NOT EXISTS sent_outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, session_index INT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id, session_index))"); + this.db.exec("CREATE TABLE IF NOT EXISTS olm_sessions (user_id TEXT NOT NULL, device_id TEXT NOT NULL, session_id TEXT NOT NULL, last_decryption_ts NUMBER NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (user_id, device_id, session_id))"); this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value"); this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name"); @@ -54,6 +58,7 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.userDeviceUpsert = this.db.prepare("INSERT INTO user_devices (user_id, device_id, device) VALUES (@userId, @deviceId, @device) ON CONFLICT (user_id, device_id) DO UPDATE SET device = @device"); this.userDevicesDelete = this.db.prepare("DELETE FROM user_devices WHERE user_id = @userId"); this.userDevicesSelect = this.db.prepare("SELECT user_id, device_id, device FROM user_devices WHERE user_id = @userId"); + this.userDeviceSelect = this.db.prepare("SELECT user_id, device_id, device FROM user_devices WHERE user_id = @userId AND device_id = @deviceId"); this.obGroupSessionUpsert = this.db.prepare("INSERT INTO outbound_group_sessions (session_id, room_id, current, pickled, uses_left, expires_ts) VALUES (@sessionId, @roomId, @current, @pickled, @usesLeft, @expiresTs) ON CONFLICT (session_id, room_id) DO UPDATE SET pickled = @pickled, current = @current, uses_left = @usesLeft, expires_ts = @expiresTs"); this.obGroupSessionSelect = this.db.prepare("SELECT session_id, room_id, current, pickled, uses_left, expires_ts FROM outbound_group_sessions WHERE session_id = @sessionId AND room_id = @roomId"); @@ -61,8 +66,11 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.obGroupSessionMarkUsage = this.db.prepare("UPDATE outbound_group_sessions SET uses_left = uses_left - 1 WHERE session_id = @sessionId and room_id = @roomId"); this.obGroupSessionMarkAllInactive = this.db.prepare("UPDATE outbound_group_sessions SET current = 0 WHERE room_id = @roomId"); - this.obSentGroupSessionUpsert = this.db.prepare("INSERT INTO sent_outbound_group_sessions (session_id, room_id, index, user_id, device_id) VALUES (@sessionId, @roomId, @index, @userId, @deviceId) ON CONFLICT (session_id, room_id, user_id, device_id, index) DO NOTHING"); - this.obSentSelectLastSent = this.db.prepare("SELECT session_id, room_id, index, user_id, device_id FROM sent_outbound_group_sessions WHERE user_id = @userId AND device_id = @deviceId AND room_id = @roomId"); + this.obSentGroupSessionUpsert = this.db.prepare("INSERT INTO sent_outbound_group_sessions (session_id, room_id, session_index, user_id, device_id) VALUES (@sessionId, @roomId, @sessionIndex, @userId, @deviceId) ON CONFLICT (session_id, room_id, user_id, device_id, session_index) DO NOTHING"); + this.obSentSelectLastSent = this.db.prepare("SELECT session_id, room_id, session_index, user_id, device_id FROM sent_outbound_group_sessions WHERE user_id = @userId AND device_id = @deviceId AND room_id = @roomId"); + + this.olmSessionUpsert = this.db.prepare("INSERT INTO olm_sessions (user_id, device_id, session_id, last_decryption_ts, pickled) VALUES (@userId, @deviceId, @sessionId, @lastDecryptionTs, @pickled) ON CONFLICT (user_id, device_id, session_id) DO UPDATE SET last_decryption_ts = @lastDecryptionTs, pickled = @pickled"); + this.olmSessionCurrentSelect = this.db.prepare("SELECT user_id, device_id, session_id, last_decryption_ts, pickled FROM olm_sessions WHERE user_id = @userId AND device_id = @deviceId ORDER BY last_decryption_ts DESC LIMIT 1"); } public async setDeviceId(deviceId: string): Promise { @@ -130,6 +138,12 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { return results.map(d => JSON.parse(d.device)); } + public async getUserDevice(userId: string, deviceId: string): Promise { + const result = this.userDeviceSelect.get({userId: userId, deviceId: deviceId}); + if (!result) return null; + return JSON.parse(result.device); + } + public async flagUsersOutdated(userIds: string[]): Promise { this.db.transaction(() => { for (const userId of userIds) { @@ -199,7 +213,7 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.obSentGroupSessionUpsert.run({ sessionId: session.sessionId, roomId: session.roomId, - index: index, + sessionIndex: index, userId: device.user_id, deviceId: device.device_id, }); @@ -208,11 +222,31 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { public async getLastSentOutboundGroupSession(userId: string, deviceId: string, roomId: string): Promise<{sessionId: string, index: number}> { const result = this.obSentSelectLastSent.get({userId: userId, deviceId: deviceId, roomId: roomId}); if (result) { - return {sessionId: result.session_id, index: result.index}; + return {sessionId: result.session_id, index: result.session_index}; } return null; } + public async storeOlmSession(userId: string, deviceId: string, session: IOlmSession): Promise { + this.olmSessionUpsert.run({ + userId: userId, + deviceId: deviceId, + sessionId: session.sessionId, + lastDecryptionTs: session.lastDecryptionTs, + pickled: session.pickled, + }); + } + + public async getCurrentOlmSession(userId: string, deviceId: string): Promise { + const result = this.olmSessionCurrentSelect.get({userId: userId, deviceId: deviceId}); + if (!result) return null; + return { + sessionId: result.session_id, + pickled: result.pickled, + lastDecryptionTs: result.last_decryption_ts, + }; + } + /** * Closes the crypto store. Primarily for testing purposes. */ From 075f053d773d24c66875f9cad4574a141ab2930e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 8 Aug 2021 20:24:26 -0600 Subject: [PATCH 10/26] Add tests for recent work + fix related bugs --- src/MatrixClient.ts | 2 +- src/e2ee/CryptoClient.ts | 143 +- src/e2ee/DeviceTracker.ts | 84 +- src/storage/SqliteCryptoStorageProvider.ts | 10 +- test/MatrixClientTest.ts | 149 ++- test/TestUtils.ts | 58 +- test/encryption/CryptoClientTest.ts | 1338 ++++++++++++++++++- test/encryption/DeviceTrackerTest.ts | 626 +++++++++ test/storage/SqliteCryptoStorageProvider.ts | 399 +++++- 9 files changed, 2685 insertions(+), 124 deletions(-) create mode 100644 test/encryption/DeviceTrackerTest.ts diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 88d2764f..1fb4af9e 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -111,7 +111,7 @@ export class MatrixClient extends EventEmitter { this.crypto = new CryptoClient(this); LogService.debug("MatrixClientLite", "End-to-end encryption client created"); } else { - LogService.trace("MatrixClientLite", "Not setting up encryption"); + // LogService.trace("MatrixClientLite", "Not setting up encryption"); } if (!this.storage) this.storage = new MemoryStorageProvider(); diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index c0c0f63c..0bcf9151 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -203,16 +203,24 @@ export class CryptoClient { try { const sig = account.sign(anotherJson.stringify(obj)); return { - ...existingSignatures, [await this.client.getUserId()]: { [`${DeviceKeyAlgorithm.Ed25119}:${this.deviceId}`]: sig, }, + ...existingSignatures, }; } finally { account.free(); } } + /** + * Verifies a signature on an object. + * @param {object} obj The signed object. + * @param {string} key The key which has supposedly signed the object. + * @param {string} signature The advertised signature. + * @returns {Promise} Resolves to true if a valid signature, false otherwise. + */ + @requiresReady() public async verifySignature(obj: object, key: string, signature: string): Promise { obj = JSON.parse(JSON.stringify(obj)); @@ -237,10 +245,12 @@ export class CryptoClient { * Flags multiple user's device lists as outdated, optionally queuing an immediate update. * @param {string} userIds The user IDs to flag the device lists of. * @param {boolean} resync True (default) to queue an immediate update, false otherwise. + * @returns {Promise} Resolves when the device lists have been flagged. Will also wait + * for the resync if one was requested. */ - public flagUsersDeviceListsOutdated(userIds: string[], resync = true) { - // noinspection JSIgnoredPromiseFromCall - this.deviceTracker.flagUsersOutdated(userIds, resync); + @requiresReady() + public flagUsersDeviceListsOutdated(userIds: string[], resync = true): Promise { + return this.deviceTracker.flagUsersOutdated(userIds, resync); } /** @@ -251,6 +261,7 @@ export class CryptoClient { * ID to session. Users/devices which cannot have sessions made will not be included, thus the object * may be empty. */ + @requiresReady() public async getOrCreateOlmSessions(userDeviceMap: Record): Promise>> { const otkClaimRequest: Record> = {}; const userDeviceSessionIds: Record> = {}; @@ -275,64 +286,72 @@ export class CryptoClient { } } - const claimed = await this.client.claimOneTimeKeys(otkClaimRequest); - for (const userId of Object.keys(claimed.one_time_keys)) { - const storedDevices = await this.client.cryptoStore.getUserDevices(userId); - for (const deviceId of Object.keys(claimed.one_time_keys[userId])) { - try { - const device = storedDevices.find(d => d.user_id === userId && d.device_id === deviceId); - if (!device) { - LogService.warn("CryptoClient", `Failed to handle claimed OTK: unable to locate stored device for user: ${userId} ${deviceId}`); - continue; - } - - const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25119}:${deviceId}`; - - const keyId = Object.keys(claimed.one_time_keys[userId][deviceId])[0]; - const signedKey = claimed.one_time_keys[userId][deviceId][keyId]; - const signature = signedKey?.signatures?.[userId]?.[deviceKeyLabel]; - if (!signature) { - LogService.warn("CryptoClient", `Failed to find appropriate signature for claimed OTK ${userId} ${deviceId}`); - continue; - } - - const verified = await this.verifySignature(signedKey, device.keys[deviceKeyLabel], signature); - if (!verified) { - LogService.warn("CryptoClient", `Invalid signature for claimed OTK ${userId} ${deviceId}`); - continue; - } - - // TODO: Handle spec rate limiting - // Clients should rate-limit the number of sessions it creates per device that it receives a message - // from. Clients should not create a new session with another device if it has already created one - // for that given device in the past 1 hour. - - // Finally, we can create a session. We do this on each loop just in case something goes wrong given - // we don't have app-level transaction support here. We want to persist as many outbound sessions as - // we can before exploding. - const account = await this.getOlmAccount(); - const session = new Olm.Session(); + if (Object.keys(otkClaimRequest).length > 0) { + const claimed = await this.client.claimOneTimeKeys(otkClaimRequest); + for (const userId of Object.keys(claimed.one_time_keys)) { + if (!otkClaimRequest[userId]) { + LogService.warn("CryptoClient", `Server injected unexpected user: ${userId} - not claiming keys`); + continue; + } + const storedDevices = await this.client.cryptoStore.getUserDevices(userId); + for (const deviceId of Object.keys(claimed.one_time_keys[userId])) { try { - const curveDeviceKey = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`]; - session.create_outbound(account, curveDeviceKey, signedKey.key); - const storedSession: IOlmSession = { - sessionId: session.session_id(), - lastDecryptionTs: Date.now(), - pickled: session.pickle(this.pickleKey), - }; - await this.client.cryptoStore.storeOlmSession(userId, deviceId, storedSession); - - if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {}; - userDeviceSessionIds[userId][deviceId] = storedSession; - - // Send a dummy event so the device can prepare the session. - // await this.encryptAndSendOlmMessage(device, storedSession, "m.dummy", {}); - } finally { - session.free(); - await this.storeAndFreeOlmAccount(account); + if (!otkClaimRequest[userId][deviceId]) { + LogService.warn("CryptoClient", `Server provided an unexpected device in claim response (skipping): ${userId} ${deviceId}`); + continue; + } + + const device = storedDevices.find(d => d.user_id === userId && d.device_id === deviceId); + if (!device) { + LogService.warn("CryptoClient", `Failed to handle claimed OTK: unable to locate stored device for user: ${userId} ${deviceId}`); + continue; + } + + const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25119}:${deviceId}`; + + const keyId = Object.keys(claimed.one_time_keys[userId][deviceId])[0]; + const signedKey = claimed.one_time_keys[userId][deviceId][keyId]; + const signature = signedKey?.signatures?.[userId]?.[deviceKeyLabel]; + if (!signature) { + LogService.warn("CryptoClient", `Failed to find appropriate signature for claimed OTK ${userId} ${deviceId}`); + continue; + } + + const verified = await this.verifySignature(signedKey, device.keys[deviceKeyLabel], signature); + if (!verified) { + LogService.warn("CryptoClient", `Invalid signature for claimed OTK ${userId} ${deviceId}`); + continue; + } + + // TODO: Handle spec rate limiting + // Clients should rate-limit the number of sessions it creates per device that it receives a message + // from. Clients should not create a new session with another device if it has already created one + // for that given device in the past 1 hour. + + // Finally, we can create a session. We do this on each loop just in case something goes wrong given + // we don't have app-level transaction support here. We want to persist as many outbound sessions as + // we can before exploding. + const account = await this.getOlmAccount(); + const session = new Olm.Session(); + try { + const curveDeviceKey = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`]; + session.create_outbound(account, curveDeviceKey, signedKey.key); + const storedSession: IOlmSession = { + sessionId: session.session_id(), + lastDecryptionTs: Date.now(), + pickled: session.pickle(this.pickleKey), + }; + await this.client.cryptoStore.storeOlmSession(userId, deviceId, storedSession); + + if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {}; + userDeviceSessionIds[userId][deviceId] = storedSession; + } finally { + session.free(); + await this.storeAndFreeOlmAccount(account); + } + } catch (e) { + LogService.warn("CryptoClient", `Unable to verify signature of claimed OTK ${userId} ${deviceId}:`, e); } - } catch (e) { - LogService.warn("CryptoClient", `Unable to verify signature of claimed OTK ${userId} ${deviceId}:`, e); } } } @@ -340,6 +359,7 @@ export class CryptoClient { return userDeviceSessionIds; } + @requiresReady() private async encryptAndSendOlmMessage(device: UserDevice, session: IOlmSession, type: string, content: any): Promise { const olmSession = new Olm.Session(); try { @@ -390,6 +410,7 @@ export class CryptoClient { * @param {any} content The event content being encrypted. * @returns {Promise} Resolves to the encrypted content for an `m.room.encrypted` event. */ + @requiresReady() public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise { if (!(await this.isRoomEncrypted(roomId))) { throw new Error("Room is not encrypted"); @@ -475,7 +496,5 @@ export class CryptoClient { } finally { session.free(); } - - } } diff --git a/src/e2ee/DeviceTracker.ts b/src/e2ee/DeviceTracker.ts index fc5ccf56..08636789 100644 --- a/src/e2ee/DeviceTracker.ts +++ b/src/e2ee/DeviceTracker.ts @@ -39,13 +39,13 @@ export class DeviceTracker { * Flags multiple user's device lists as outdated, optionally queuing an immediate update. * @param {string} userIds The user IDs to flag the device lists of. * @param {boolean} resync True (default) to queue an immediate update, false otherwise. + * @returns {Promise} Resolves when the flagging has completed. Will wait for the resync + * if requested too. */ - public async flagUsersOutdated(userIds: string[], resync = true) { + public async flagUsersOutdated(userIds: string[], resync = true): Promise { await this.client.cryptoStore.flagUsersOutdated(userIds); if (resync) { - // We don't really want to wait around for this, so let it work in the background - // noinspection ES6MissingAwait - this.updateUsersDeviceLists(userIds); + await this.updateUsersDeviceLists(userIds); } } @@ -63,52 +63,62 @@ export class DeviceTracker { await Promise.all(existingPromises); } - const promise = new Promise(async resolve => { - const resp = await this.client.getUserDevices(userIds); - for (const userId of Object.keys(resp.device_keys)) { - const validated: UserDevice[] = []; - for (const deviceId of Object.keys(resp.device_keys[userId])) { - const device = resp.device_keys[userId][deviceId]; - if (device.user_id !== userId || device.device_id !== deviceId) { - LogService.warn("DeviceTracker", `Server appears to be lying about device lists: ${userId} ${deviceId} has unexpected device ${device.user_id} ${device.device_id} listed - ignoring device`); + const promise = new Promise(async (resolve, reject) => { + try { + const resp = await this.client.getUserDevices(userIds); + for (const userId of Object.keys(resp.device_keys)) { + if (!userIds.includes(userId)) { + LogService.warn("DeviceTracker", `Server returned unexpected user ID: ${userId} - ignoring user`); continue; } - const ed25519 = device.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; - const curve25519 = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`]; + const validated: UserDevice[] = []; + for (const deviceId of Object.keys(resp.device_keys[userId])) { + const device = resp.device_keys[userId][deviceId]; + if (device.user_id !== userId || device.device_id !== deviceId) { + LogService.warn("DeviceTracker", `Server appears to be lying about device lists: ${userId} ${deviceId} has unexpected device ${device.user_id} ${device.device_id} listed - ignoring device`); + continue; + } - if (!ed25519 || !curve25519) { - LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing either an Ed25519 or Curve25519 key - ignoring device`); - continue; - } + const ed25519 = device.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + const curve25519 = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`]; + + if (!ed25519 || !curve25519) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing either an Ed25519 or Curve25519 key - ignoring device`); + continue; + } + + const currentDevices = await this.client.cryptoStore.getUserDevices(userId); + const existingDevice = currentDevices.find(d => d.device_id === deviceId); - const currentDevices = await this.client.cryptoStore.getUserDevices(userId); - const existingDevice = currentDevices.find(d => d.device_id === deviceId); + if (existingDevice) { + const existingEd25519 = existingDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + if (existingEd25519 !== ed25519) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} appears compromised: Ed25519 key changed - ignoring device`); + continue; + } + } - if (existingDevice) { - const existingEd25519 = existingDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; - if (existingEd25519 !== ed25519) { - LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} appears compromised: Ed25519 key changed - ignoring device`); + const signature = device.signatures?.[userId]?.[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + if (!signature) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing a signature - ignoring device`); continue; } - } - const signature = device.signatures?.[userId]?.[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; - if (!signature) { - LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing a signature - ignoring device`); - continue; - } + const validSignature = await this.client.crypto.verifySignature(device, ed25519, signature); + if (!validSignature) { + LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} has an invalid signature - ignoring device`); + continue; + } - const validSignature = await this.client.crypto.verifySignature(device, ed25519, signature); - if (!validSignature) { - LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} has an invalid signature - ignoring device`); - continue; + validated.push(device); } - validated.push(device); + await this.client.cryptoStore.setUserDevices(userId, validated); } - - await this.client.cryptoStore.setUserDevices(userId, validated); + } catch (e) { + LogService.error("DeviceTracker", "Error updating device lists:", e); + // return reject(e); } resolve(); }); diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index bf749981..0dea2569 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -172,7 +172,7 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { usesLeft: session.usesLeft, expiresTs: session.expiresTs, }); - }); + })(); } public async getOutboundGroupSession(sessionId: string, roomId: string): Promise { @@ -183,8 +183,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { roomId: result.room_id, pickled: result.pickled, isCurrent: result.current === 1, - usesLeft: result.usesLeft, - expiresTs: result.expiresTs, + usesLeft: result.uses_left, + expiresTs: result.expires_ts, }; } return null; @@ -198,8 +198,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { roomId: result.room_id, pickled: result.pickled, isCurrent: result.current === 1, - usesLeft: result.usesLeft, - expiresTs: result.expiresTs, + usesLeft: result.uses_left, + expiresTs: result.expires_ts, }; } return null; diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index f75022f5..33479755 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -5198,7 +5198,7 @@ describe('MatrixClient', () => { }); describe('uploadDeviceKeys', () => { - it('it should fail when no encryption', async () => { + it('should fail when no encryption', async () => { try { const { client } = createTestClient(); await client.uploadDeviceKeys([], {}); @@ -5210,7 +5210,7 @@ describe('MatrixClient', () => { } }); - it('it should call the right endpoint', async () => { + it('should call the right endpoint', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); @@ -5254,7 +5254,7 @@ describe('MatrixClient', () => { }); describe('uploadDeviceOneTimeKeys', () => { - it('it should fail when no encryption is available', async () => { + it('should fail when no encryption is available', async () => { try { const { client } = createTestClient(); await client.uploadDeviceOneTimeKeys({}); @@ -5266,7 +5266,7 @@ describe('MatrixClient', () => { } }); - it('it should call the right endpoint', async () => { + it('should call the right endpoint', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); @@ -5300,7 +5300,7 @@ describe('MatrixClient', () => { }); describe('checkOneTimeKeyCounts', () => { - it('it should fail when no encryption is available', async () => { + it('should fail when no encryption is available', async () => { try { const { client } = createTestClient(); await client.checkOneTimeKeyCounts(); @@ -5312,7 +5312,7 @@ describe('MatrixClient', () => { } }); - it('it should call the right endpoint', async () => { + it('should call the right endpoint', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); @@ -5333,7 +5333,7 @@ describe('MatrixClient', () => { }); describe('getUserDevices', () => { - it('it should call the right endpoint', async () => { + it('should call the right endpoint', async () => { const { client, http } = createTestClient(); const timeout = 15000; @@ -5357,8 +5357,7 @@ describe('MatrixClient', () => { }; http.when("POST", "/_matrix/client/r0/keys/query").respond(200, (path, content, req) => { - expect(req.opts.qs).toMatchObject({timeout}); - expect(content).toMatchObject(requestBody); + expect(content).toMatchObject({ timeout, device_keys: requestBody }); return response; }); @@ -5367,7 +5366,7 @@ describe('MatrixClient', () => { expect(result).toMatchObject(response); }); - it('it should call the right endpoint with a default timeout', async () => { + it('should call the right endpoint with a default timeout', async () => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, true); @@ -5391,8 +5390,7 @@ describe('MatrixClient', () => { }; http.when("POST", "/_matrix/client/r0/keys/query").respond(200, (path, content, req) => { - expect(req.opts.qs).toMatchObject({timeout: 10000}); - expect(content).toMatchObject(requestBody); + expect(content).toMatchObject({ timeout: 10000, device_keys: requestBody }); return response; }); @@ -5402,6 +5400,133 @@ describe('MatrixClient', () => { }); }); + describe('claimOneTimeKeys', () => { + it('should fail when no encryption is available', async () => { + try { + const { client } = createTestClient(); + await client.claimOneTimeKeys({}); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption is not enabled"); + } + }); + + it('should call the right endpoint', async () => { + const userId = "@test:example.org"; + const { client, http } = createTestClient(null, userId, true); + + const request = { + "@alice:example.org": { + [TEST_DEVICE_ID]: OTKAlgorithm.Signed, + }, + "@bob:federated.example.org": { + [TEST_DEVICE_ID + "_2ND"]: OTKAlgorithm.Unsigned, + }, + }; + const response = { + failures: { + "federated.example.org": { + error: "Failed", + }, + }, + one_time_keys: { + "@alice:example.org": { + [TEST_DEVICE_ID]: { + // not populated in this test + }, + }, + }, + }; + + http.when("POST", "/_matrix/client/r0/keys/claim").respond(200, (path, content) => { + expect(content).toMatchObject({ + timeout: 10000, + one_time_keys: request, + }); + return response; + }); + + http.flushAllExpected(); + const result = await client.claimOneTimeKeys(request); + expect(result).toMatchObject(response); + }); + + it('should use the timeout parameter', async () => { + const userId = "@test:example.org"; + const { client, http } = createTestClient(null, userId, true); + + const request = { + "@alice:example.org": { + [TEST_DEVICE_ID]: OTKAlgorithm.Signed, + }, + "@bob:federated.example.org": { + [TEST_DEVICE_ID + "_2ND"]: OTKAlgorithm.Unsigned, + }, + }; + const response = { + failures: { + "federated.example.org": { + error: "Failed", + }, + }, + one_time_keys: { + "@alice:example.org": { + [TEST_DEVICE_ID]: { + // not populated in this test + }, + }, + }, + }; + + const timeout = 60; + + http.when("POST", "/_matrix/client/r0/keys/claim").respond(200, (path, content) => { + expect(content).toMatchObject({ + timeout: timeout, + one_time_keys: request, + }); + return response; + }); + + http.flushAllExpected(); + const result = await client.claimOneTimeKeys(request, timeout); + expect(result).toMatchObject(response); + }); + }); + + describe('sendToDevices', () => { + it('should call the right endpoint', async () => { + const userId = "@test:example.org"; + const { client, http, hsUrl } = createTestClient(null, userId, true); + + const type = "org.example.message"; + const messages = { + [userId]: { + "*": { + isContent: true, + }, + }, + "@alice:example.org": { + [TEST_DEVICE_ID]: { + moreContent: true, + }, + }, + }; + + http.when("PUT", "/_matrix/client/r0/sendToDevice").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/sendToDevice/${encodeURIComponent(type)}/`); + expect(idx).toBe(0); + expect(content).toMatchObject({messages}); + return {}; + }); + + http.flushAllExpected(); + await client.sendToDevices(type, messages); + }); + }); + describe('redactObjectForLogging', () => { it('should redact multilevel objects', () => { const input = { diff --git a/test/TestUtils.ts b/test/TestUtils.ts index 66841b43..76626521 100644 --- a/test/TestUtils.ts +++ b/test/TestUtils.ts @@ -1,8 +1,7 @@ import * as expect from "expect"; -import { - MatrixClient, -} from "../src"; +import { EncryptionAlgorithm, IOlmSession, IOutboundGroupSession, MatrixClient, UserDevice, } from "../src"; import * as crypto from "crypto"; +import * as anotherJson from "another-json"; export function expectArrayEquals(expected: any[], actual: any[]) { expect(expected).toBeDefined(); @@ -37,6 +36,7 @@ export async function feedOlmAccount(client: MatrixClient) { const pickleKey = crypto.randomBytes(64).toString('hex'); const account = new (await prepareOlm()).Account(); try { + account.create(); const pickled = account.pickle(pickleKey); await client.cryptoStore.setPickledAccount(pickled); @@ -45,3 +45,55 @@ export async function feedOlmAccount(client: MatrixClient) { account.free(); } } + +const STATIC_PICKLED_ACCOUNT = "TevMpI7cI4ijCFuRQJOpH4f6VunsywE7PXmKigI5x/Vwnes+hSUEHs3aoMsfptbAEOulbGF5o+m5jRdjKl5mhw0VixOgHTkcJTXtXXldyBYjOWey6YCMcV/Dph5OgBCP3uLyrCT/JSuKhiuxohiqKHENZgeTSQ1/rtZkgR20UOpKdAqPkEjuI4YeLQbV1yDw1Po+JLVz9aRKeZX05rL6kPuIhu+nST++OV06hdAKzr7IDGw0K/xU+2VZIi7y4jct3tjE/QXfr1j7J3ja16xaDA1QLx+/5czZsqPFkJ5kxVetTtlHQ2PdnA9CEKlQugKA02mfD++qG0EZMMT0XqqWJQcBT1zRuQSuE08CHbDFcyq/F/6OQot9wgs9xLCkti7L+vHNHbJQVv+sboM7d2hX0sm5UJUdtnTETZDo1CldhedfDlvPQdC6IQ"; +export const STATIC_PICKLE_KEY = "do not use in production"; +export async function feedStaticOlmAccount(client: MatrixClient) { + await client.cryptoStore.setPickledAccount(STATIC_PICKLED_ACCOUNT); + await client.cryptoStore.setPickleKey(STATIC_PICKLE_KEY); +} + +const OLM_ACCOUNT_RECEIVER_INIT = "6sF6fXEVU52nQxDuXkIX2NWIjff0PDMjhANo5ct7pv60R8A9ntaJbIGlc2YnFGDzLThQKS6sM7cW29jMjXdiYAFJsiU/IwBiUlFW1/eG0pRnbyvnRHI91GkO1MxBgmkNxrHVKwI3ITw9VyE4pXMDrptm+rH0nil+28Z7/PM43qD1LRsNZ6P2FqmdlVvLi+oiNepcAJUA+88ZOombVAXKatBdzTpR+H4ygjpn9Co+atIlxZeNyhngaI47xBtwn69wQfk9Y+3OyKAW9ZTvmbWoPk+Xy57yfhFqgYCcyEeias8GMJlZvK6EDMJFNaAbFvn30QBw6PU9KYMJ1ubTnLOpdw1mzD1T170mXcg4IvRAXStMtHs+5K0qP74C6Lz1FLbZTWVt1SLEGc/k/2fevnHbAchiJA4EdgJsdOgNy5So8yh/OHo8Lh2tLA"; + +export const RECEIVER_DEVICE: UserDevice = { + user_id: "@receiver:example.org", + device_id: "A", + unsigned: { + device_display_name: "Test Device", + }, + keys: { + "curve25519:A": "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE", + "ed25519:A": "2rSewpuevZRemFhBB/0pa6bU66RwhP8l8bQnUP6j204", + }, + algorithms: [EncryptionAlgorithm.MegolmV1AesSha2, EncryptionAlgorithm.OlmV1Curve25519AesSha2], + signatures: { + "@receiver:example.org": { + "ed25519:A": "+xcZ+TKWhtV6JFy1RB532+BHMSQC7g9MC0Ane7X/OP2sH0ioJFWGcbKt0iBZOIluD7+EgadW7YAyY/33wCbvCg", + }, + }, +}; + +export const RECEIVER_OLM_SESSION: IOlmSession = { + sessionId: "KmlD4H4gK+NukCgsha1mIpjbSd63dH0ZEgTrFFVYHj0", + pickled: "qHo1lPr3YRQLUwvPgTYnYUmLEMAXB/Xh/iBv3on2xvMjn2brZVb42hfkqPRyUW1KMUVRwwzBY+lp1vNx8JTx7EBCLP8/MziQzF+UtDErSNqdVi4TsY6o5vAA+A5BpBKhKiCo3zHO5FXqb36auf1d0Ynj1HTKldMsa2WBCsM6+R1KrY0WAWLi1i7QtlF9lYpk4ZzxhTY9MNMwQ9+h+1+FYxfUSAzQCAbX0WQpI04mq+c6N3bQdrdFVkGndI9c8oegAJDeomBwQI5c2sGFeU4yBLDIL1Cto6K5mO1dM9JW4b8tMJfoE5/lr7Iar+WuCy/AquOwigO1aDn3JsBrtSFyOKbX2nGxkvOh", + lastDecryptionTs: Date.now(), +}; + +export const STATIC_OUTBOUND_SESSION: IOutboundGroupSession = { + sessionId: "5IvzkqqphReuELs8KzYSVmqaWUqrLIJ6d4JFVj8qyBY", + pickled: "gsO94I8oWrkm/zJefnr1/08CMX7qZnOBoPGM7b/ZshjN7UM/Y9y6zRNNY3hGHw+7uP7oYxF1EH60YXa/ClMX0mCEtupqkQlGBKcp78CQj18WURtoATXnV2lEPElx/y1tQfQ1hqRYjd0UXzZtnwGjM78D5vVEoxfpCJ5Gm9kk3aEwOg6EYqirvpciaLCNopnbgh3ngqfmabZJpaafFWRYUkqw4WuzvNVGnzTOmbHq4uWVeZzUTvIC/6AGEq1eLQOEbIoP4GaJDDn+XC+V1HKQ6jmMWuy3439xEfh/FUSI1iHu8oCBcxneSAcmwKUztLkeI3MGu9+1hCA", + roomId: "!test:example.org", + isCurrent: true, + usesLeft: 100, + expiresTs: Date.now() + 3600000, +}; + +export async function temp() { + const session = new (await prepareOlm()).OutboundGroupSession(); + try { + session.unpickle(STATIC_PICKLE_KEY, STATIC_OUTBOUND_SESSION.pickled); + throw session.session_id(); + } finally { + session.free(); + } +} diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 46652f6c..c24a082b 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1,12 +1,23 @@ import * as expect from "expect"; import * as simple from "simple-mock"; import { + DeviceKeyAlgorithm, + EncryptionAlgorithm, IOlmSession, MatrixClient, OTKAlgorithm, OTKCounts, - RoomEncryptionAlgorithm, + RoomEncryptionAlgorithm, UserDevice, } from "../../src"; import { createTestClient, TEST_DEVICE_ID } from "../MatrixClientTest"; -import { feedOlmAccount } from "../TestUtils"; +import { + feedOlmAccount, + feedStaticOlmAccount, + RECEIVER_DEVICE, + RECEIVER_OLM_SESSION, + STATIC_OUTBOUND_SESSION, + temp +} from "../TestUtils"; +import { DeviceTracker } from "../../src/e2ee/DeviceTracker"; +import { STATIC_TEST_DEVICES } from "./DeviceTrackerTest"; describe('CryptoClient', () => { it('should not have a device ID or be ready until prepared', async () => { @@ -357,6 +368,1327 @@ describe('CryptoClient', () => { }); describe('sign', () => { - // TODO: We should have mutation tests, signing tests, etc to make sure we're calling Olm correctly. + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.sign({doesnt: "matter"}); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should sign the object while retaining signatures without mutation', async () => { + await client.crypto.prepare([]); + + const obj = { + sign: "me", + signatures: { + "@another:example.org": { + "ed25519:DEVICE": "signature goes here", + }, + }, + unsigned: { + not: "included", + }, + }; + + const signatures = await client.crypto.sign(obj); + expect(signatures).toMatchObject({ + [userId]: { + [`ed25519:${TEST_DEVICE_ID}`]: "zb/gbMjWCxfVrN5ASjvKr+leUWdaX026pccfiul+TzE7tABjWqnzjZiy6ox2MQk85IWD+DpR8Mo65a5o+/m4Cw", + }, + ...obj.signatures, + }); + expect(obj['signatures']).toBeDefined(); + expect(obj['unsigned']).toBeDefined(); + }); + }); + + describe('verifySignature', () => { + let signed: object; + let key: string; + let signature: string; + let client: MatrixClient; + + beforeEach(async () => { + signed = { + algorithms: [EncryptionAlgorithm.OlmV1Curve25519AesSha2, EncryptionAlgorithm.MegolmV1AesSha2], + device_id: "NTTFKSVBSI", + keys: { + "curve25519:NTTFKSVBSI": "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + "ed25519:NTTFKSVBSI": "2tVcG/+sE7hq4z+E/x6UrMuVEAzc4CknYIGbg3cQg/4", + }, + signatures: { + "@ping:localhost": { + "ed25519:NTTFKSVBSI": "CLm1TOPFFIygs68amMsnywQoLz2evo/O28BVQGPKC986yFt0OpDKcyMUTsRFiRcdLstqtWkhy1p+UTW2/FPEDw", + "ed25519:7jeU3P5Fb8wS+LmhXNhiDSBrPMBI+uBZItlRJnpoHtE": "vx1bb8n1xWIJ+5ZkOrQ91msZbEU/p2wZGdxbnQAQDr/ZhZqwKwvY6G5bkhjvtQTdVRspPC/mFKyH0UW9D30IDA", + }, + }, + user_id: "@ping:localhost", + unsigned: { + device_display_name: "localhost:8080 (Edge, Windows)", + }, + }; + key = "2tVcG/+sE7hq4z+E/x6UrMuVEAzc4CknYIGbg3cQg/4"; + signature = "CLm1TOPFFIygs68amMsnywQoLz2evo/O28BVQGPKC986yFt0OpDKcyMUTsRFiRcdLstqtWkhy1p+UTW2/FPEDw"; + + const userId = "@alice:example.org"; + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.verifySignature(signed, key, signature); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should return true for valid signatures', async () => { + await client.crypto.prepare([]); + + const result = await client.crypto.verifySignature(signed, key, signature); + expect(result).toBe(true); + }); + + it('should return false for invalid signatures', async () => { + await client.crypto.prepare([]); + + let result = await client.crypto.verifySignature(signed, "wrong key", signature); + expect(result).toBe(false); + result = await client.crypto.verifySignature(signed, key, "wrong signature"); + expect(result).toBe(false); + result = await client.crypto.verifySignature({wrong: "object"}, key, signature); + expect(result).toBe(false); + }); + + it('should not mutate the provided object', async () => { + await client.crypto.prepare([]); + + const result = await client.crypto.verifySignature(signed, key, signature); + expect(result).toBe(true); + expect(signed["signatures"]).toBeDefined(); + expect(signed["unsigned"]).toBeDefined(); + }); + }); + + describe('flagUsersDeviceListsOutdated', () => { + it('should fail when the crypto has not been prepared', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + // await client.crypto.prepare([]); // deliberately commented + + try { + await client.crypto.flagUsersDeviceListsOutdated(["@new:example.org"]); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should pass through to the device tracker (resync=true)', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + client.getRoomStateEvent = () => Promise.reject("return value not used"); + await client.crypto.prepare([]); + + const userIds = ["@first:example.org", "@second:example.org"]; + const resync = true; + + const tracker: DeviceTracker = (client.crypto).deviceTracker; // private member access + const flagSpy = simple.stub().callFn(async (uids, rsyc) => { + expect(uids).toMatchObject(userIds); + expect(uids.length).toBe(userIds.length); + expect(rsyc).toEqual(resync); + }); + tracker.flagUsersOutdated = flagSpy; + + await client.crypto.flagUsersDeviceListsOutdated(userIds, resync); + expect(flagSpy.callCount).toBe(1); + }); + + it('should pass through to the device tracker (resync=false)', async () => { + const userId = "@alice:example.org"; + const { client } = createTestClient(null, userId, true); + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + client.getRoomStateEvent = () => Promise.reject("return value not used"); + await client.crypto.prepare([]); + + const userIds = ["@first:example.org", "@second:example.org"]; + const resync = false; + + const tracker: DeviceTracker = (client.crypto).deviceTracker; // private member access + const flagSpy = simple.stub().callFn(async (uids, rsyc) => { + expect(uids).toMatchObject(userIds); + expect(uids.length).toBe(userIds.length); + expect(rsyc).toEqual(resync); + }); + tracker.flagUsersOutdated = flagSpy; + + await client.crypto.flagUsersDeviceListsOutdated(userIds, resync); + expect(flagSpy.callCount).toBe(1); + }); + }); + + describe('getOrCreateOlmSessions', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.getOrCreateOlmSessions({}); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should skip our own user and device', async () => { + await client.crypto.prepare([]); + + const claimSpy = simple.stub().callFn(async (req) => { + expect(Object.keys(req).length).toBe(0); + return {one_time_keys: {}, failures: {}}; + }); + client.claimOneTimeKeys = claimSpy; + + const result = await client.crypto.getOrCreateOlmSessions({ + [userId]: [TEST_DEVICE_ID], + }); + expect(Object.keys(result).length).toBe(0); + expect(claimSpy.callCount).toBe(0); // no reason it should be called + }); + + it('should use existing sessions if present', async () => { + await client.crypto.prepare([]); + + const targetUserId = "@target:example.org"; + const targetDeviceId = "TARGET"; + + const session: IOlmSession = { + sessionId: "test_session", + lastDecryptionTs: Date.now(), + pickled: "pickled", + }; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(Object.keys(req).length).toBe(0); + return {one_time_keys: {}, failures: {}}; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + expect(uid).toEqual(targetUserId); + expect(did).toEqual(targetDeviceId); + return session; + }; + + const result = await client.crypto.getOrCreateOlmSessions({ + [targetUserId]: [targetDeviceId], + }); + expect(result).toMatchObject({ + [targetUserId]: { + [targetDeviceId]: session, + }, + }); + expect(claimSpy.callCount).toBe(0); // no reason it should be called + }); + + it('should support mixing of OTK claims and existing sessions', async () => { + await client.crypto.prepare([]); + + const targetUserId = "@target:example.org"; + const targetDeviceId = "TARGET"; + + const claimUserId = "@claim:example.org"; + const claimDeviceId = "CLAIM_ME"; + + const session: IOlmSession = { + sessionId: "test_session", + lastDecryptionTs: Date.now(), + pickled: "pickled", + }; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + [claimUserId]: { + [claimDeviceId]: { + [`${OTKAlgorithm.Signed}:${claimDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + if (uid === targetUserId) { + expect(did).toEqual(targetDeviceId); + } else if (uid === claimUserId) { + expect(did).toEqual(claimDeviceId); + return null; + } else { + throw new Error("Unexpected user"); + } + return session; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(claimUserId); + return [{ + user_id: claimUserId, + device_id: claimDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + // Skip signature verification for this test + client.crypto.verifySignature = () => Promise.resolve(true); + + const result = await client.crypto.getOrCreateOlmSessions({ + [targetUserId]: [targetDeviceId], + [claimUserId]: [claimDeviceId], + }); + expect(result).toMatchObject({ + [targetUserId]: { + [targetDeviceId]: session, + }, + [claimUserId]: { + [claimDeviceId]: { + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + pickled: expect.any(String), + }, + }, + }); + expect(claimSpy.callCount).toBe(1); + }); + + it('should ensure the server is not injecting users in claim requests', async () => { + await client.crypto.prepare([]); + + const targetUserId = "@target:example.org"; + const targetDeviceId = "TARGET"; + + const claimUserId = "@claim:example.org"; + const claimDeviceId = "CLAIM_ME"; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + // Injected user/device + [targetUserId]: { + [targetDeviceId]: { + [`${OTKAlgorithm.Signed}:${targetDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [targetUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${targetDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + [claimUserId]: { + [claimDeviceId]: { + [`${OTKAlgorithm.Signed}:${claimDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + expect(uid).toEqual(claimUserId); + expect(did).toEqual(claimDeviceId); + return null; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(claimUserId); + return [{ + user_id: claimUserId, + device_id: claimDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + // Skip signature verification for this test + client.crypto.verifySignature = () => Promise.resolve(true); + + const result = await client.crypto.getOrCreateOlmSessions({ + [claimUserId]: [claimDeviceId], + }); + expect(result).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: { + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + pickled: expect.any(String), + }, + }, + }); + expect(claimSpy.callCount).toBe(1); + }); + + it('should ensure the server is not injecting devices in claim requests', async () => { + await client.crypto.prepare([]); + + const targetUserId = "@target:example.org"; + const targetDeviceId = "TARGET"; + + const claimUserId = "@claim:example.org"; + const claimDeviceId = "CLAIM_ME"; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + [claimUserId]: { + // Injected device + [targetDeviceId]: { + [`${OTKAlgorithm.Signed}:${targetDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [targetUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${targetDeviceId}`]: "Definitely real", + }, + }, + }, + }, + [claimDeviceId]: { + [`${OTKAlgorithm.Signed}:${claimDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + expect(uid).toEqual(claimUserId); + expect(did).toEqual(claimDeviceId); + return null; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(claimUserId); + return [{ + user_id: claimUserId, + device_id: claimDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + // Skip signature verification for this test + client.crypto.verifySignature = () => Promise.resolve(true); + + const result = await client.crypto.getOrCreateOlmSessions({ + [claimUserId]: [claimDeviceId], + }); + expect(result).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: { + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + pickled: expect.any(String), + }, + }, + }); + expect(claimSpy.callCount).toBe(1); + }); + + it('should ensure the device is known to verify the Curve25519 key', async () => { + await client.crypto.prepare([]); + + const targetUserId = "@target:example.org"; + const targetDeviceId = "TARGET"; + + const claimUserId = "@claim:example.org"; + const claimDeviceId = "CLAIM_ME"; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + [claimUserId]: { + [targetDeviceId]: { + [`${OTKAlgorithm.Signed}:${targetDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [targetUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${targetDeviceId}`]: "Definitely real", + }, + }, + }, + }, + [claimDeviceId]: { + [`${OTKAlgorithm.Signed}:${claimDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + expect(uid).toEqual(claimUserId); + expect([claimDeviceId, targetDeviceId].includes(did)).toBe(true); + return null; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(claimUserId); + return [{ + user_id: claimUserId, + device_id: claimDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + // Skip signature verification for this test + client.crypto.verifySignature = () => Promise.resolve(true); + + const result = await client.crypto.getOrCreateOlmSessions({ + [claimUserId]: [claimDeviceId, targetDeviceId], // ask for an unknown device + }); + expect(result).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: { + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + pickled: expect.any(String), + }, + }, + }); + expect(claimSpy.callCount).toBe(1); + }); + + it('should ensure a signature is present on the claim response', async () => { + await client.crypto.prepare([]); + + const claimUserId = "@claim:example.org"; + const claimDeviceId = "CLAIM_ME"; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + [claimUserId]: { + [claimDeviceId]: { + [`${OTKAlgorithm.Signed}:${claimDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures_MISSING: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + expect(uid).toEqual(claimUserId); + expect(did).toEqual(claimDeviceId); + return null; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(claimUserId); + return [{ + user_id: claimUserId, + device_id: claimDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + // Skip signature verification for this test + client.crypto.verifySignature = () => Promise.resolve(true); + + const result = await client.crypto.getOrCreateOlmSessions({ + [claimUserId]: [claimDeviceId], + }); + expect(Object.keys(result).length).toBe(0); + expect(claimSpy.callCount).toBe(1); + }); + + it('should verify the signature of the claimed key', async () => { + await client.crypto.prepare([]); + + const claimUserId = "@claim:example.org"; + const claimDeviceId = "CLAIM_ME"; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + [claimUserId]: { + [claimDeviceId]: { + [`${OTKAlgorithm.Signed}:${claimDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + expect(uid).toEqual(claimUserId); + expect(did).toEqual(claimDeviceId); + return null; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(claimUserId); + return [{ + user_id: claimUserId, + device_id: claimDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "ED25519 KEY GOES HERE", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + const verifySpy = simple.stub().callFn(async (signed, dkey, sig) => { + expect(signed).toMatchObject({ + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }); + expect(dkey).toEqual("ED25519 KEY GOES HERE"); + expect(sig).toEqual("Definitely real"); + return true; + }); + client.crypto.verifySignature = verifySpy; + + const result = await client.crypto.getOrCreateOlmSessions({ + [claimUserId]: [claimDeviceId], + }); + expect(result).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: { + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + pickled: expect.any(String), + }, + }, + }); + expect(claimSpy.callCount).toBe(1); + expect(verifySpy.callCount).toBe(1); + }); + + it('should create a new outbound olm session', async () => { + await client.crypto.prepare([]); + + const claimUserId = "@claim:example.org"; + const claimDeviceId = "CLAIM_ME"; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + [claimUserId]: { + [claimDeviceId]: { + [`${OTKAlgorithm.Signed}:${claimDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [claimUserId]: { + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(claimUserId); + return [{ + user_id: claimUserId, + device_id: claimDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "ED25519 KEY GOES HERE", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + // Skip signature verification for this test + client.crypto.verifySignature = () => Promise.resolve(true); + + const result = await client.crypto.getOrCreateOlmSessions({ + [claimUserId]: [claimDeviceId], + }); + expect(result).toMatchObject({ + [claimUserId]: { + [claimDeviceId]: { + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + pickled: expect.any(String), + }, + }, + }); + expect(claimSpy.callCount).toBe(1); + + const session = result[claimUserId][claimDeviceId]; + expect(await client.cryptoStore.getCurrentOlmSession(claimUserId, claimDeviceId)).toMatchObject(session as any); + }); + }); + + describe('encryptAndSendOlmMessage', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await (client.crypto).encryptAndSendOlmMessage(null, null, null, null); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should work', async () => { + await client.crypto.prepare([]); + + const device = RECEIVER_DEVICE; + const session = RECEIVER_OLM_SESSION; + const type = "org.example.test"; + const content = { + isTest: true, + val: "hello world", + n: 42, + }; + + const sendSpy = simple.stub().callFn(async (t, m) => { + expect(t).toEqual("m.room.encrypted"); + expect(m).toMatchObject({ + "@receiver:example.org": { + "A": { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { + type: 0, + body: "Awog+jA+wNz5Wnpw5isETy9LFDw0hoao06f7ewAhY0+yRGsSIJS/3l725T7pqoV3FKZY/cPH/2dV8W8yZeIWl1DKpaQlGiAFnYCGBRA+tqaR3SpDqbqtwgz1wzA0TV+Mjvzixbd1IyLQAgMKIAIldXBMsoIngiQkuLAvUYrz6QCFAwPeFb6hKlRKcBlTEAAioAKgrDGnYPaJv4asMwVsbNSXQOxRCE/sB0VZrYKH9OKwbZuP+jqHUPa6mtVBu3Sll2ROWJ94YtPycZXX45B4pT8XMvLL/jE6fH4gXZuheb6Q5iYV0XrHMNuIzyODjzbOzpvi7GXTFvb7YMFRskb2k965vfd9NRTpuUT9eb7vkLoIgCb9gK5WApEuS5/4lOIWHKdhqB1m4ViZ4W+eEo9TzniRvAMCfeX0G+OpCv5X9h1UomZl87Kh/q5ZSluuocWFOgG8sGvyLttl3AR3Vc500+9xUt9xvYz5p5hv9UWrnhL2tmKIvVAGCE+GLUDg+eHHSdu6wft5u6qg4ko69tYEmfMbJZc2MU6vmrFKkk3ZJJ27IX4qx8DPaeUWKao169D+982mMWbeZ6lsAQ", + }, + }, + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + }, + }, + }); + }); + client.sendToDevices = sendSpy; + + const storeSpy = simple.stub().callFn(async (uid, did, s) => { + expect(uid).toEqual(device.user_id); + expect(did).toEqual(device.device_id); + expect(s).toMatchObject({ + lastDecryptionTs: session.lastDecryptionTs, + sessionId: session.sessionId, + pickled: "qHo1lPr3YRQLUwvPgTYnYUmLEMAXB/Xh/iBv3on2xvMjn2brZVb42hfkqPRyUW1KMUVRwwzBY+lp1vNx8JTx7EBCLP8/MziQzF+UtDErSNqdVi4TsY6o5vAA+A5BpBKhKiCo3zHO5FXqb36auf1d0Ynj1HTKldMsa2WBCsM6+R1KrY0WAWLi1i7QtlF9lYpk4ZzxhTY9MNMwQ9+h+1+FYxfUSAzQCAbX0WQpI04mq+c6N3bQdrdFVkGndI9c8oegFOR0vO920pYgK9479AFoA5D7IkOUwnZ8C8EqYKtYKBd0cs4+cTR9n5jHSvMfba59FYcv5xoWC2slIKez6bKWKfK/0N9psBdq", + }); + }); + client.cryptoStore.storeOlmSession = storeSpy; + + await (client.crypto).encryptAndSendOlmMessage(device, session, type, content); + expect(sendSpy.callCount).toBe(1); + expect(storeSpy.callCount).toBe(1); + }); + }); + + describe('encryptRoomEvent', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.encryptRoomEvent("!room:example.org", "org.example", {}); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should fail in unencrypted rooms', async () => { + await client.crypto.prepare([]); + + // Force unencrypted rooms + client.crypto.isRoomEncrypted = async () => false; + + try { + await client.crypto.encryptRoomEvent("!room:example.org", "type", {}); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Room is not encrypted"); + } + }); + + it('should use existing outbound sessions', async () => { + await client.crypto.prepare([]); + + const deviceMap = { + [RECEIVER_DEVICE.user_id]: [RECEIVER_DEVICE], + }; + const roomId = "!test:example.org"; + + // For this test, force all rooms to be encrypted + client.crypto.isRoomEncrypted = async () => true; + + await client.cryptoStore.storeOlmSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, RECEIVER_OLM_SESSION); + + const getSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return STATIC_OUTBOUND_SESSION; + }); + client.cryptoStore.getCurrentOutboundGroupSession = getSpy; + + const joinedSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return Object.keys(deviceMap); + }); + client.getJoinedRoomMembers = joinedSpy; + + const devicesSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(Object.keys(deviceMap)); + return deviceMap; + }); + (client.crypto).deviceTracker.getDevicesFor = devicesSpy; + + // We watch for the to-device messages to make sure we pass through the internal functions correctly + const toDeviceSpy = simple.stub().callFn(async (t, m) => { + expect(t).toEqual("m.room.encrypted"); + expect(m).toMatchObject({ + [RECEIVER_DEVICE.user_id]: { + [RECEIVER_DEVICE.device_id]: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { + type: 0, + body: "Awog+jA+wNz5Wnpw5isETy9LFDw0hoao06f7ewAhY0+yRGsSIJS/3l725T7pqoV3FKZY/cPH/2dV8W8yZeIWl1DKpaQlGiAFnYCGBRA+tqaR3SpDqbqtwgz1wzA0TV+Mjvzixbd1IyLgBQMKIAIldXBMsoIngiQkuLAvUYrz6QCFAwPeFb6hKlRKcBlTEAAisAWgrDGnYPaJv4asMwVsbNSXQOxRCE/sB0VZrYKH9OKwbZuP+jqHUPa6mtVBu3Sll2ROWJ94YtPycZXX45B4pT8XMvLL/jE6fH4gXZuheb6Q5iYV0XrHMNuIzyODjzbOzpvi7GXTFvb7YMFRskb2k965vfd9NRTpuUT9eb7vkLoIgCb9gK5WApEuS5/4lOIWHKdhqB1m4ViZ4W+eEo9TzniRvAMCfeX0G+OpCv5X9h1UomZl87Kh/q5ZSluuocWFOgG8sGvyLttl3AR3Vc500+9xc0u7GT6lNvJo9Z1kH1xPcCce4oHWByFgGvdIMHYrB7SFZ/AtbiQDt/BUTgxsLd8gysHqjiiOKblz3iN3kx//f2MCTrjKgWDtmCeTRnb1Z8Rn9hdPbkpX2+yvkrmdMYYXKfQXB6PAY+6gRFqGREFXaKq8n0NPN7mN//sp7CJGmMU+DIyq7cPWcmW7zLTBdyoafn8YkJRqjIVbA271imw77cFvDdU1uWFT14275u7Z0qtOrXZiuDLPQyaARbitv8Cc4VfFB1XwWG0V8+fR3oJvIcCba4Q7ALO6TJqpurETU6eT4BAZBmugWObL2kDxdmuJYWpKvKbPdGhLTfbFFn0Sl1lgNaMrGjDoF+LVx/1Oiq9s0DnKPf9gamGIYr2voiSQvibC5m4UgMKLkiZVbAVs20fSV3TD5XMJYman6Rk8mNHBd+6fXW+C2buXd8WStiZ2/hVNalvV/MJPqdzJDHRz3avjwJryunbO48syLMud0y+6K2e8RJV/974lyfQ6BvJ/C7pN/rY3Rh5F4NtG0pSL9ghBzKuQQvKuVGf7U8L9w52iRQrPso+UhUkn8kpLD6AWklU7o9NenWO7eQLhz33i/A0DnM3ILw0c5XyQrX7/UgIRHkLAeVMHLmYC4IBaY1Y24ToFuVKXdb0", + }, + }, + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + }, + }, + }); + }); + client.sendToDevices = toDeviceSpy; + + const result = await client.crypto.encryptRoomEvent(roomId, "org.example.test", { + isTest: true, + hello: "world", + n: 42, + }); + expect(getSpy.callCount).toBe(1); + expect(joinedSpy.callCount).toBe(1); + expect(devicesSpy.callCount).toBe(1); + expect(toDeviceSpy.callCount).toBe(1); + expect(result).toMatchObject({ + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + ciphertext: "AwgAEnB4om5XWmKYTMTlDUK16C1v7GEXWl0JlNJZVXJYGEhEZIm+Hep8I2l4dFzchv3JdMKnBofYpLjXd6jEP144MsHfATu7g6qu3m/B+gpxsJ6fi0BTsO7GvXwwYsdsqGp8p9O+RvRP2JfUO7dBgW6uCPwQHcExXrA+csPHq/ItVNjnCBW3cAkXc34dZXeGn2LV5JGozaFI/2WEFEEP6r5SLqAPzia3khcL84nko5qtGh57VqG32H3H4v0G", + session_id: STATIC_OUTBOUND_SESSION.sessionId, + device_id: TEST_DEVICE_ID, + }); + }); + + it('should rotate outbound sessions based on time', async () => { + await client.crypto.prepare([]); + + const deviceMap = { + [RECEIVER_DEVICE.user_id]: [RECEIVER_DEVICE], + }; + const roomId = "!test:example.org"; + const rotationIntervals = 200; + const rotationMs = 50000; + + await client.cryptoStore.storeRoom(roomId, { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + rotation_period_msgs: rotationIntervals, + rotation_period_ms: rotationMs, + }); + + await client.cryptoStore.storeOlmSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, RECEIVER_OLM_SESSION); + + const getSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return { + ...STATIC_OUTBOUND_SESSION, + expiresTs: Date.now() / 2, // force expiry + }; + }); + client.cryptoStore.getCurrentOutboundGroupSession = getSpy; + + const storeSpy = simple.stub().callFn(async (s) => { + expect(s.sessionId).not.toEqual(STATIC_OUTBOUND_SESSION.sessionId); + expect(s.roomId).toEqual(roomId); + expect(s.pickled).toBeDefined(); + expect(s.isCurrent).toBe(true); + expect(s.usesLeft).toBe(rotationIntervals); + expect(s.expiresTs - Date.now()).toBeLessThanOrEqual(rotationMs + 1000); + expect(s.expiresTs - Date.now()).toBeGreaterThanOrEqual(rotationMs - 1000); + }); + client.cryptoStore.storeOutboundGroupSession = storeSpy; + + const joinedSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return Object.keys(deviceMap); + }); + client.getJoinedRoomMembers = joinedSpy; + + const devicesSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(Object.keys(deviceMap)); + return deviceMap; + }); + (client.crypto).deviceTracker.getDevicesFor = devicesSpy; + + // We watch for the to-device messages to make sure we pass through the internal functions correctly + const toDeviceSpy = simple.stub().callFn(async (t, m) => { + expect(t).toEqual("m.room.encrypted"); + expect(m).toMatchObject({ + [RECEIVER_DEVICE.user_id]: { + [RECEIVER_DEVICE.device_id]: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { + type: 0, + body: expect.any(String), + }, + }, + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + }, + }, + }); + }); + client.sendToDevices = toDeviceSpy; + + const result = await client.crypto.encryptRoomEvent(roomId, "org.example.test", { + isTest: true, + hello: "world", + n: 42, + }); + expect(getSpy.callCount).toBe(1); + expect(joinedSpy.callCount).toBe(1); + expect(devicesSpy.callCount).toBe(1); + expect(toDeviceSpy.callCount).toBe(1); + expect(storeSpy.callCount).toBe(1); + expect(result).toMatchObject({ + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + ciphertext: expect.any(String), + session_id: expect.any(String), + device_id: TEST_DEVICE_ID, + }); + expect(result.session_id).not.toEqual(STATIC_OUTBOUND_SESSION.sessionId); + }); + + it('should rotate outbound sessions based on uses', async () => { + await client.crypto.prepare([]); + + const deviceMap = { + [RECEIVER_DEVICE.user_id]: [RECEIVER_DEVICE], + }; + const roomId = "!test:example.org"; + const rotationIntervals = 200; + const rotationMs = 50000; + + await client.cryptoStore.storeRoom(roomId, { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + rotation_period_msgs: rotationIntervals, + rotation_period_ms: rotationMs, + }); + + await client.cryptoStore.storeOlmSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, RECEIVER_OLM_SESSION); + + const getSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return { + ...STATIC_OUTBOUND_SESSION, + usesLeft: 0, + }; + }); + client.cryptoStore.getCurrentOutboundGroupSession = getSpy; + + const storeSpy = simple.stub().callFn(async (s) => { + expect(s.sessionId).not.toEqual(STATIC_OUTBOUND_SESSION.sessionId); + expect(s.roomId).toEqual(roomId); + expect(s.pickled).toBeDefined(); + expect(s.isCurrent).toBe(true); + expect(s.usesLeft).toBe(rotationIntervals); + expect(s.expiresTs - Date.now()).toBeLessThanOrEqual(rotationMs + 1000); + expect(s.expiresTs - Date.now()).toBeGreaterThanOrEqual(rotationMs - 1000); + }); + client.cryptoStore.storeOutboundGroupSession = storeSpy; + + const joinedSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return Object.keys(deviceMap); + }); + client.getJoinedRoomMembers = joinedSpy; + + const devicesSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(Object.keys(deviceMap)); + return deviceMap; + }); + (client.crypto).deviceTracker.getDevicesFor = devicesSpy; + + // We watch for the to-device messages to make sure we pass through the internal functions correctly + const toDeviceSpy = simple.stub().callFn(async (t, m) => { + expect(t).toEqual("m.room.encrypted"); + expect(m).toMatchObject({ + [RECEIVER_DEVICE.user_id]: { + [RECEIVER_DEVICE.device_id]: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { + type: 0, + body: expect.any(String), + }, + }, + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + }, + }, + }); + }); + client.sendToDevices = toDeviceSpy; + + const result = await client.crypto.encryptRoomEvent(roomId, "org.example.test", { + isTest: true, + hello: "world", + n: 42, + }); + expect(getSpy.callCount).toBe(1); + expect(joinedSpy.callCount).toBe(1); + expect(devicesSpy.callCount).toBe(1); + expect(toDeviceSpy.callCount).toBe(1); + expect(storeSpy.callCount).toBe(1); + expect(result).toMatchObject({ + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + ciphertext: expect.any(String), + session_id: expect.any(String), + device_id: TEST_DEVICE_ID, + }); + expect(result.session_id).not.toEqual(STATIC_OUTBOUND_SESSION.sessionId); + }); + + it('should create new outbound sessions', async () => { + await client.crypto.prepare([]); + + const deviceMap = { + [RECEIVER_DEVICE.user_id]: [RECEIVER_DEVICE], + }; + const roomId = "!test:example.org"; + const rotationIntervals = 200; + const rotationMs = 50000; + + await client.cryptoStore.storeRoom(roomId, { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + rotation_period_msgs: rotationIntervals, + rotation_period_ms: rotationMs, + }); + + await client.cryptoStore.storeOlmSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, RECEIVER_OLM_SESSION); + + const getSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return null; // none for this test + }); + client.cryptoStore.getCurrentOutboundGroupSession = getSpy; + + const storeSpy = simple.stub().callFn(async (s) => { + expect(s.roomId).toEqual(roomId); + expect(s.pickled).toBeDefined(); + expect(s.isCurrent).toBe(true); + expect(s.usesLeft).toBe(rotationIntervals); + expect(s.expiresTs - Date.now()).toBeLessThanOrEqual(rotationMs + 1000); + expect(s.expiresTs - Date.now()).toBeGreaterThanOrEqual(rotationMs - 1000); + }); + client.cryptoStore.storeOutboundGroupSession = storeSpy; + + const joinedSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return Object.keys(deviceMap); + }); + client.getJoinedRoomMembers = joinedSpy; + + const devicesSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(Object.keys(deviceMap)); + return deviceMap; + }); + (client.crypto).deviceTracker.getDevicesFor = devicesSpy; + + // We watch for the to-device messages to make sure we pass through the internal functions correctly + const toDeviceSpy = simple.stub().callFn(async (t, m) => { + expect(t).toEqual("m.room.encrypted"); + expect(m).toMatchObject({ + [RECEIVER_DEVICE.user_id]: { + [RECEIVER_DEVICE.device_id]: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { + type: 0, + body: expect.any(String), + }, + }, + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + }, + }, + }); + }); + client.sendToDevices = toDeviceSpy; + + const result = await client.crypto.encryptRoomEvent(roomId, "org.example.test", { + isTest: true, + hello: "world", + n: 42, + }); + expect(getSpy.callCount).toBe(1); + expect(joinedSpy.callCount).toBe(1); + expect(devicesSpy.callCount).toBe(1); + expect(toDeviceSpy.callCount).toBe(1); + expect(storeSpy.callCount).toBe(1); + expect(result).toMatchObject({ + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + ciphertext: expect.any(String), + session_id: expect.any(String), + device_id: TEST_DEVICE_ID, + }); + }); + + it.skip('should store created outbound sessions as inbound sessions', async () => { + // TODO: Merge into above test when functionality exists. + }); + + it.skip('should get devices for invited members', async () => { + // TODO: Support invited members, if history visibility would allow. + }); }); }); diff --git a/test/encryption/DeviceTrackerTest.ts b/test/encryption/DeviceTrackerTest.ts new file mode 100644 index 00000000..b4d3db3d --- /dev/null +++ b/test/encryption/DeviceTrackerTest.ts @@ -0,0 +1,626 @@ +import * as expect from "expect"; +import * as simple from "simple-mock"; +import { + EncryptionAlgorithm, + EncryptionEventContent, + MatrixClient, + RoomEncryptionAlgorithm, + RoomTracker, UserDevice +} from "../../src"; +import { createTestClient, TEST_DEVICE_ID } from "../MatrixClientTest"; +import { DeviceTracker } from "../../src/e2ee/DeviceTracker"; + +const STATIC_TEST_USER = "@ping:localhost"; +export const STATIC_TEST_DEVICES = { + "NTTFKSVBSI": { + "algorithms": [EncryptionAlgorithm.OlmV1Curve25519AesSha2, EncryptionAlgorithm.MegolmV1AesSha2], + "device_id": "NTTFKSVBSI", + "keys": { + "curve25519:NTTFKSVBSI": "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + "ed25519:NTTFKSVBSI": "2tVcG/+sE7hq4z+E/x6UrMuVEAzc4CknYIGbg3cQg/4", + }, + "signatures": { + "@ping:localhost": { + "ed25519:NTTFKSVBSI": "CLm1TOPFFIygs68amMsnywQoLz2evo/O28BVQGPKC986yFt0OpDKcyMUTsRFiRcdLstqtWkhy1p+UTW2/FPEDw", + "ed25519:7jeU3P5Fb8wS+LmhXNhiDSBrPMBI+uBZItlRJnpoHtE": "vx1bb8n1xWIJ+5ZkOrQ91msZbEU/p2wZGdxbnQAQDr/ZhZqwKwvY6G5bkhjvtQTdVRspPC/mFKyH0UW9D30IDA", + }, + }, + "user_id": "@ping:localhost", + "unsigned": { "device_display_name": "localhost:8080 (Edge, Windows)" }, + }, + "HCDJLDXQHQ": { + "algorithms": [EncryptionAlgorithm.OlmV1Curve25519AesSha2, EncryptionAlgorithm.MegolmV1AesSha2], + "device_id": "HCDJLDXQHQ", + "keys": { + "curve25519:HCDJLDXQHQ": "c20OI51bT8iiX9t40g5g7FcBCHORIbep+6SbkrD3FRU", + "ed25519:HCDJLDXQHQ": "hTxK3DSJit7N7eqGuOnuDIeAdj4P7S57DOMKj6ruQok", + }, + "signatures": { + "@ping:localhost": { + "ed25519:HCDJLDXQHQ": "2CzR6Vfru6wZYaeF9MuHNrHuOh5iZ/jaw0dgRmyuMOsJwmuWZEeyit/csjg53oY10H3xfC4tOTKXc5SU5NIdBQ", + }, + }, + "user_id": "@ping:localhost", + "unsigned": { "device_display_name": "localhost:8080 (Edge, Windows)" }, + }, +}; + +describe('DeviceTracker', () => { + describe('updateUsersDeviceLists', () => { + it('should perform updates', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: STATIC_TEST_DEVICES, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + STATIC_TEST_DEVICES["HCDJLDXQHQ"], + ]); + expect(validated.length).toBe(2); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should wait for existing requests to complete first', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + const fetchedOrder: string[] = []; + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject(fetchedOrder.length === 0 ? [STATIC_TEST_USER, "@other:example.org"] : [STATIC_TEST_USER, "@another:example.org"]); + fetchedOrder.push(...userIds); + return { + device_keys: { + [userIds[0]]: STATIC_TEST_DEVICES, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + STATIC_TEST_DEVICES["HCDJLDXQHQ"], + ]); + expect(validated.length).toBe(2); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + tracker.updateUsersDeviceLists([STATIC_TEST_USER, "@other:example.org"]).then(() => fetchedOrder.push("----")); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER, "@another:example.org"]); + expect(storeSpy.callCount).toBe(2); + expect(fetchedOrder).toMatchObject([ + STATIC_TEST_USER, + "@other:example.org", + "----", // inserted by finished call to update device lists + STATIC_TEST_USER, + "@another:example.org", + ]); + }); + + it('should check for servers changing device IDs', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: { + "HCDJLDXQHQ": { + device_id: "WRONG_DEVICE", + ...STATIC_TEST_DEVICES['HCDJLDXQHQ'], + }, + ...STATIC_TEST_DEVICES, + }, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should check for servers changing user IDs', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: { + "HCDJLDXQHQ": { + user_id: "@wrong:example.org", + ...STATIC_TEST_DEVICES['HCDJLDXQHQ'], + }, + ...STATIC_TEST_DEVICES, + }, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should ensure all devices have Curve25519 keys', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: { + "HCDJLDXQHQ": { + keys: { + "ed25519:HCDJLDXQHQ": "hTxK3DSJit7N7eqGuOnuDIeAdj4P7S57DOMKj6ruQok", + // no curve25519 key for test + }, + ...STATIC_TEST_DEVICES['HCDJLDXQHQ'], + }, + ...STATIC_TEST_DEVICES, + }, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should ensure all devices have Ed25519 keys', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: { + "HCDJLDXQHQ": { + keys: { + "curve25519:HCDJLDXQHQ": "c20OI51bT8iiX9t40g5g7FcBCHORIbep+6SbkrD3FRU", + // no ed25519 key for test + }, + ...STATIC_TEST_DEVICES['HCDJLDXQHQ'], + }, + ...STATIC_TEST_DEVICES, + }, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should ensure all devices have signatures', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: { + "HCDJLDXQHQ": { + signatures: {}, + ...STATIC_TEST_DEVICES['HCDJLDXQHQ'], + }, + ...STATIC_TEST_DEVICES, + }, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should ensure all devices have device signatures', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: { + "HCDJLDXQHQ": { + signatures: { + "@ping:localhost": { + "ed25519:NOT_THIS_DEVICE": "2CzR6Vfru6wZYaeF9MuHNrHuOh5iZ/jaw0dgRmyuMOsJwmuWZEeyit/csjg53oY10H3xfC4tOTKXc5SU5NIdBQ", + }, + }, + ...STATIC_TEST_DEVICES['HCDJLDXQHQ'], + }, + ...STATIC_TEST_DEVICES, + }, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should validate the signature of a device', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: { + "HCDJLDXQHQ": { + signatures: { + "@ping:localhost": { + "ed25519:HCDJLDXQHQ": "WRONG", + }, + }, + ...STATIC_TEST_DEVICES['HCDJLDXQHQ'], + }, + ...STATIC_TEST_DEVICES, + }, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return []; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + + it('should protect against device reuse', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + await client.crypto.prepare([]); + + client.getUserDevices = async (userIds) => { + expect(userIds).toMatchObject([STATIC_TEST_USER]); + return { + device_keys: { + [userIds[0]]: STATIC_TEST_DEVICES, + ["@should_be_ignored:example.org"]: STATIC_TEST_DEVICES, + }, + failures: {}, + }; + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(STATIC_TEST_USER); + return [{ + device_id: "HCDJLDXQHQ", + user_id: STATIC_TEST_USER, + algorithms: [EncryptionAlgorithm.OlmV1Curve25519AesSha2, EncryptionAlgorithm.MegolmV1AesSha2], + keys: { + "curve25519:HCDJLDXQHQ": "LEGACY_KEY", + "ed25519:HCDJLDXQHQ": "LEGACY_KEY", + }, + signatures: { + "@ping:localhost": { + "ed25519:HCDJLDXQHQ": "FAKE_SIGNED", + }, + }, + unsigned: { + device_display_name: "Injected Device", + }, + }]; + }; + + const storeSpy = simple.stub().callFn(async (uid, validated) => { + expect(uid).toEqual(STATIC_TEST_USER); + expect(validated).toMatchObject([ + STATIC_TEST_DEVICES["NTTFKSVBSI"], + //STATIC_TEST_DEVICES["HCDJLDXQHQ"], // falsified by server + ]); + expect(validated.length).toBe(1); + }); + client.cryptoStore.setUserDevices = storeSpy; + + const tracker = new DeviceTracker(client); + await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); + expect(storeSpy.callCount).toBe(1); + }); + }); + + describe('flagUsersOutdated', () => { + it('should flag devices as outdated appropriately', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + const targetUserIds = ["@one:example.org", "@two:example.org"]; + + const flagSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(targetUserIds); + expect(uids.length).toBe(targetUserIds.length); + }); + client.cryptoStore.flagUsersOutdated = flagSpy; + + const deviceTracker = new DeviceTracker(client); + + const updateSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(targetUserIds); + expect(uids.length).toBe(targetUserIds.length); + }); + deviceTracker.updateUsersDeviceLists = updateSpy; + + await deviceTracker.flagUsersOutdated(targetUserIds, false); + expect(updateSpy.callCount).toBe(0); + expect(flagSpy.callCount).toBe(1); + }); + + it('should resync the devices if requested', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + const targetUserIds = ["@one:example.org", "@two:example.org"]; + + const flagSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(targetUserIds); + expect(uids.length).toBe(targetUserIds.length); + }); + client.cryptoStore.flagUsersOutdated = flagSpy; + + const deviceTracker = new DeviceTracker(client); + + const updateSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(targetUserIds); + expect(uids.length).toBe(targetUserIds.length); + }); + deviceTracker.updateUsersDeviceLists = updateSpy; + + await deviceTracker.flagUsersOutdated(targetUserIds, true); + expect(updateSpy.callCount).toBe(1); + expect(flagSpy.callCount).toBe(1); + }); + }); + + describe('getDevicesFor', () => { + it('should update devices if needed', async () => { + const userId = "@user:example.org"; + const { client } = createTestClient(null, userId, true); + const targetUserIds = ["@one:example.org", "@two:example.org", "@three:example.org", "@four:example.org"]; + const fakeOutdatedUsers = ["@two:example.org", "@three:example.org"]; + const deviceMaps = { + [targetUserIds[0]]: [{device: 1}, {device: 2}] as any as UserDevice[], + [targetUserIds[1]]: [{device: 33}, {device: 44}] as any as UserDevice[], + [targetUserIds[2]]: [{device: "A"}, {device: "B"}] as any as UserDevice[], + [targetUserIds[3]]: [{device: "B1"}, {device: "C1"}] as any as UserDevice[], + }; + + const checkSpy = simple.stub().callFn(async (uid) => { + expect(uid).toEqual(targetUserIds[checkSpy.callCount - 1]); + return fakeOutdatedUsers.includes(uid); + }); + client.cryptoStore.isUserOutdated = checkSpy; + + const getSpy = simple.stub().callFn(async (uid) => { + expect(updateSpy.callCount).toBe(1); + expect(uid).toEqual(targetUserIds[getSpy.callCount - 1]); + return deviceMaps[uid]; + }); + client.cryptoStore.getUserDevices = getSpy; + + const deviceTracker = new DeviceTracker(client); + + const updateSpy = simple.stub().callFn(async (uids) => { + expect(checkSpy.callCount).toBe(targetUserIds.length); + expect(uids).toMatchObject(fakeOutdatedUsers); + expect(uids.length).toBe(fakeOutdatedUsers.length); + }); + deviceTracker.updateUsersDeviceLists = updateSpy; + + const results = await deviceTracker.getDevicesFor(targetUserIds); + expect(checkSpy.callCount).toBe(targetUserIds.length); + expect(updateSpy.callCount).toBe(1); + expect(getSpy.callCount).toBe(targetUserIds.length); + expect(results).toMatchObject(deviceMaps); + }); + }); +}); diff --git a/test/storage/SqliteCryptoStorageProvider.ts b/test/storage/SqliteCryptoStorageProvider.ts index 1b881cf1..268f3001 100644 --- a/test/storage/SqliteCryptoStorageProvider.ts +++ b/test/storage/SqliteCryptoStorageProvider.ts @@ -2,7 +2,7 @@ import * as expect from "expect"; import * as tmp from "tmp"; import { SqliteCryptoStorageProvider } from "../../src/storage/SqliteCryptoStorageProvider"; import { TEST_DEVICE_ID } from "../MatrixClientTest"; -import { EncryptionAlgorithm } from "../../src"; +import { EncryptionAlgorithm, IOlmSession } from "../../src"; tmp.setGracefulCleanup(); @@ -134,4 +134,401 @@ describe('SqliteCryptoStorageProvider', () => { expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); await store.close(); }); + + it('should track current outbound sessions', async () => { + const sessionIds = ["one", "two", "3"]; + const roomIds = ["!one:example.org", "!one:example.org", "!one:example.org"]; // all the same room ID intentionally + const pickles = ["p1", "p2", "p3"]; + const expiresTs = Date.now(); + const usesLeft = 101; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + await store.storeOutboundGroupSession({ + sessionId: sessionIds[0], + roomId: roomIds[0], + pickled: pickles[0], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + await store.storeOutboundGroupSession({ + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.storeOutboundGroupSession({ + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getOutboundGroupSession(sessionIds[0], roomIds[0])).toMatchObject({ + sessionId: sessionIds[0], + roomId: roomIds[0], + pickled: pickles[0], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getOutboundGroupSession(sessionIds[1], roomIds[1])).toMatchObject({ + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + expect(await store.getOutboundGroupSession(sessionIds[2], roomIds[2])).toMatchObject({ + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getCurrentOutboundGroupSession(roomIds[0])).toMatchObject({ // just testing the flag + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getOutboundGroupSession(sessionIds[0], roomIds[0])).toMatchObject({ + sessionId: sessionIds[0], + roomId: roomIds[0], + pickled: pickles[0], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getOutboundGroupSession(sessionIds[1], roomIds[1])).toMatchObject({ + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + expect(await store.getOutboundGroupSession(sessionIds[2], roomIds[2])).toMatchObject({ + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getCurrentOutboundGroupSession(roomIds[0])).toMatchObject({ // just testing the flag + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.close(); + }); + + it('should overwrite any previously current outbound sessions', async () => { + const sessionIds = ["one", "two", "3"]; + const roomIds = ["!one:example.org", "!one:example.org", "!one:example.org"]; // all the same room ID intentionally + const pickles = ["p1", "p2", "p3"]; + const expiresTs = Date.now(); + const usesLeft = 101; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + await store.storeOutboundGroupSession({ + sessionId: sessionIds[0], + roomId: roomIds[0], + pickled: pickles[0], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.storeOutboundGroupSession({ + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.storeOutboundGroupSession({ + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + expect(await store.getOutboundGroupSession(sessionIds[0], roomIds[0])).toMatchObject({ + sessionId: sessionIds[0], + roomId: roomIds[0], + pickled: pickles[0], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getOutboundGroupSession(sessionIds[1], roomIds[1])).toMatchObject({ + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getOutboundGroupSession(sessionIds[2], roomIds[2])).toMatchObject({ + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + expect(await store.getCurrentOutboundGroupSession(roomIds[0])).toMatchObject({ // just testing the flag + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getOutboundGroupSession(sessionIds[0], roomIds[0])).toMatchObject({ + sessionId: sessionIds[0], + roomId: roomIds[0], + pickled: pickles[0], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getOutboundGroupSession(sessionIds[1], roomIds[1])).toMatchObject({ + sessionId: sessionIds[1], + roomId: roomIds[1], + pickled: pickles[1], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: false, + }); + expect(await store.getOutboundGroupSession(sessionIds[2], roomIds[2])).toMatchObject({ + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + expect(await store.getCurrentOutboundGroupSession(roomIds[0])).toMatchObject({ // just testing the flag + sessionId: sessionIds[2], + roomId: roomIds[2], + pickled: pickles[2], + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.close(); + }); + + it('should count usages of outbound sessions', async () => { + const sessionId = "session"; + const roomId = "!room:example.org"; + const usesLeft = 100; + const expiresTs = Date.now(); + const pickle = "pickled"; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + await store.storeOutboundGroupSession({ + sessionId: sessionId, + roomId: roomId, + pickled: pickle, + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + expect(await store.getOutboundGroupSession(sessionId, roomId)).toMatchObject({ + sessionId: sessionId, + roomId: roomId, + pickled: pickle, + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }); + await store.useOutboundGroupSession(sessionId, roomId); + expect(await store.getOutboundGroupSession(sessionId, roomId)).toMatchObject({ + sessionId: sessionId, + roomId: roomId, + pickled: pickle, + expiresTs: expiresTs, + usesLeft: usesLeft - 1, + isCurrent: true, + }); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getOutboundGroupSession(sessionId, roomId)).toMatchObject({ + sessionId: sessionId, + roomId: roomId, + pickled: pickle, + expiresTs: expiresTs, + usesLeft: usesLeft - 1, + isCurrent: true, + }); + await store.close(); + }); + + it('should track sent outbound sessions', async () => { + const sessionId = "session"; + const roomId = "!room:example.org"; + const usesLeft = 100; + const expiresTs = Date.now(); + const pickle = "pickled"; + const userId = "@user:example.org"; + const index = 1; + const deviceId = "DEVICE"; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + await store.storeSentOutboundGroupSession({ + sessionId: sessionId, + roomId: roomId, + pickled: pickle, + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }, index, { + device_id: deviceId, + keys: {}, + user_id: userId, + signatures: {}, + algorithms: [EncryptionAlgorithm.MegolmV1AesSha2], + unsigned: {}, + }); + await store.storeSentOutboundGroupSession({ + sessionId: sessionId, + roomId: roomId, + pickled: pickle, + expiresTs: expiresTs, + usesLeft: usesLeft, + isCurrent: true, + }, index, { + device_id: deviceId + "_NOTUSED", + keys: {}, + user_id: userId, + signatures: {}, + algorithms: [EncryptionAlgorithm.MegolmV1AesSha2], + unsigned: {}, + }); + expect(await store.getLastSentOutboundGroupSession(userId, deviceId, roomId)).toMatchObject({ + sessionId: sessionId, + index: index, + }); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getLastSentOutboundGroupSession(userId, deviceId, roomId)).toMatchObject({ + sessionId: sessionId, + index: index, + }); + await store.close(); + }); + + it('should fetch single user devices', async () => { + const userId1 = "@user:example.org"; + const userId2 = "@two:example.org"; + + // Not real UserDevices, but this is a test. + const devices1: any = [{device_id: "one"}, {device_id: "two"}]; + const devices2: any = [{device_id: "three"}, {device_id: "four"}]; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + await store.setUserDevices(userId1, devices1); + await store.setUserDevices(userId2, devices2); + expect(await store.getUserDevice(userId1, devices1[0].device_id)).toMatchObject(devices1[0]); + expect(await store.getUserDevice(userId1, devices1[1].device_id)).toMatchObject(devices1[1]); + expect(await store.getUserDevice(userId2, devices2[0].device_id)).toMatchObject(devices2[0]); + expect(await store.getUserDevice(userId2, devices2[1].device_id)).toMatchObject(devices2[1]); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getUserDevice(userId1, devices1[0].device_id)).toMatchObject(devices1[0]); + expect(await store.getUserDevice(userId1, devices1[1].device_id)).toMatchObject(devices1[1]); + expect(await store.getUserDevice(userId2, devices2[0].device_id)).toMatchObject(devices2[0]); + expect(await store.getUserDevice(userId2, devices2[1].device_id)).toMatchObject(devices2[1]); + await store.close(); + }); + + it('should track current Olm sessions', async () => { + const userId1 = "@user:example.org"; + const userId2 = "@two:example.org"; + + const deviceId1 = "ONE"; + const deviceId2 = "TWO"; + + const session1: IOlmSession = { + sessionId: "SESSION_ONE", + pickled: "pickled_one", + lastDecryptionTs: Date.now(), + }; + const session2: IOlmSession = { + sessionId: "SESSION_TWO", + pickled: "pickled_two", + lastDecryptionTs: Date.now() + 5, + }; + const session3: IOlmSession = { + sessionId: "SESSION_THREE", + pickled: "pickled_three", + lastDecryptionTs: Date.now() - 10, + }; + const session4: IOlmSession = { + sessionId: "SESSION_FOUR", + pickled: "pickled_four", + lastDecryptionTs: Date.now() + 10, + }; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + await store.storeOlmSession(userId1, deviceId1, session1); + await store.storeOlmSession(userId2, deviceId2, session2); + expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session1 as any); + expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session1 as any); + expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + + // insert an updated session for the first user to ensure the lastDecryptionTs logic works + await store.storeOlmSession(userId1, deviceId1, session4); + expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); + expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); + expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + + // now test that we'll keep session 4 even after inserting session 3 (an older session) + await store.storeOlmSession(userId1, deviceId1, session3); + expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); + expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); + expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + + await store.close(); + }); }); From 343ced3f71a6ba53d5a90424adb7f299c38ddfc5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 8 Aug 2021 20:26:08 -0600 Subject: [PATCH 11/26] Organize imports --- src/MatrixClient.ts | 9 +++++---- src/identity/IdentityClient.ts | 2 +- src/metrics/Metrics.ts | 2 +- src/strategies/AppserviceJoinRoomStrategy.ts | 1 - test/TestUtils.ts | 11 ----------- test/UnstableApisTest.ts | 1 - test/encryption/CryptoClientTest.ts | 10 +++++----- test/encryption/DeviceTrackerTest.ts | 8 +------- test/metrics/decoratorsTest.ts | 6 +++--- test/simple-validationTest.ts | 2 +- 10 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 1fb4af9e..bf45edb2 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -28,11 +28,12 @@ import { CryptoClient } from "./e2ee/CryptoClient"; import { DeviceKeyAlgorithm, DeviceKeyLabel, - EncryptionAlgorithm, IDeviceMessage, - MultiUserDeviceListResponse, OTKAlgorithm, OTKClaimResponse, + EncryptionAlgorithm, + MultiUserDeviceListResponse, + OTKAlgorithm, + OTKClaimResponse, OTKCounts, - OTKs, - UserDevice + OTKs } from "./models/Crypto"; import { requiresCrypto } from "./e2ee/decorators"; import { ICryptoStorageProvider } from "./storage/ICryptoStorageProvider"; diff --git a/src/identity/IdentityClient.ts b/src/identity/IdentityClient.ts index 78fc5e29..574e129f 100644 --- a/src/identity/IdentityClient.ts +++ b/src/identity/IdentityClient.ts @@ -1,7 +1,7 @@ import { OpenIDConnectToken } from "../models/OpenIDConnect"; import { doHttpRequest } from "../http"; import { timedIdentityClientFunctionCall } from "../metrics/decorators"; -import { Policies, Policy, TranslatedPolicy } from "../models/Policies"; +import { Policies, TranslatedPolicy } from "../models/Policies"; import { Metrics } from "../metrics/Metrics"; import { Threepid } from "../models/Threepid"; import * as crypto from "crypto"; diff --git a/src/metrics/Metrics.ts b/src/metrics/Metrics.ts index 24529120..5996034b 100644 --- a/src/metrics/Metrics.ts +++ b/src/metrics/Metrics.ts @@ -1,6 +1,6 @@ import { IMetricListener } from "./IMetricListener"; import { IMetricContext } from "./contexts"; -import { Intent, LogService, MatrixClient } from ".."; +import { LogService } from ".."; /** * Tracks metrics. diff --git a/src/strategies/AppserviceJoinRoomStrategy.ts b/src/strategies/AppserviceJoinRoomStrategy.ts index 2629e95c..83318385 100644 --- a/src/strategies/AppserviceJoinRoomStrategy.ts +++ b/src/strategies/AppserviceJoinRoomStrategy.ts @@ -1,5 +1,4 @@ import { IJoinRoomStrategy } from "./JoinRoomStrategy"; -import { LogService } from ".."; import { Appservice } from "../appservice/Appservice"; /** diff --git a/test/TestUtils.ts b/test/TestUtils.ts index 76626521..10f5d1ed 100644 --- a/test/TestUtils.ts +++ b/test/TestUtils.ts @@ -1,7 +1,6 @@ import * as expect from "expect"; import { EncryptionAlgorithm, IOlmSession, IOutboundGroupSession, MatrixClient, UserDevice, } from "../src"; import * as crypto from "crypto"; -import * as anotherJson from "another-json"; export function expectArrayEquals(expected: any[], actual: any[]) { expect(expected).toBeDefined(); @@ -87,13 +86,3 @@ export const STATIC_OUTBOUND_SESSION: IOutboundGroupSession = { usesLeft: 100, expiresTs: Date.now() + 3600000, }; - -export async function temp() { - const session = new (await prepareOlm()).OutboundGroupSession(); - try { - session.unpickle(STATIC_PICKLE_KEY, STATIC_OUTBOUND_SESSION.pickled); - throw session.session_id(); - } finally { - session.free(); - } -} diff --git a/test/UnstableApisTest.ts b/test/UnstableApisTest.ts index b990bbb9..b21d0cfc 100644 --- a/test/UnstableApisTest.ts +++ b/test/UnstableApisTest.ts @@ -2,7 +2,6 @@ import * as expect from "expect"; import { GroupProfile, IStorageProvider, MatrixClient, UnstableApis } from "../src"; import * as MockHttpBackend from 'matrix-mock-request'; import { createTestClient } from "./MatrixClientTest"; -import * as simple from "simple-mock"; export function createTestUnstableClient(storage: IStorageProvider = null): { client: UnstableApis, mxClient: MatrixClient, http: MockHttpBackend, hsUrl: string, accessToken: string } { const result = createTestClient(storage); diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index c24a082b..91d67f34 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -2,10 +2,12 @@ import * as expect from "expect"; import * as simple from "simple-mock"; import { DeviceKeyAlgorithm, - EncryptionAlgorithm, IOlmSession, MatrixClient, + EncryptionAlgorithm, + IOlmSession, + MatrixClient, OTKAlgorithm, OTKCounts, - RoomEncryptionAlgorithm, UserDevice, + RoomEncryptionAlgorithm, } from "../../src"; import { createTestClient, TEST_DEVICE_ID } from "../MatrixClientTest"; import { @@ -13,11 +15,9 @@ import { feedStaticOlmAccount, RECEIVER_DEVICE, RECEIVER_OLM_SESSION, - STATIC_OUTBOUND_SESSION, - temp + STATIC_OUTBOUND_SESSION } from "../TestUtils"; import { DeviceTracker } from "../../src/e2ee/DeviceTracker"; -import { STATIC_TEST_DEVICES } from "./DeviceTrackerTest"; describe('CryptoClient', () => { it('should not have a device ID or be ready until prepared', async () => { diff --git a/test/encryption/DeviceTrackerTest.ts b/test/encryption/DeviceTrackerTest.ts index b4d3db3d..cd9f9f1b 100644 --- a/test/encryption/DeviceTrackerTest.ts +++ b/test/encryption/DeviceTrackerTest.ts @@ -1,12 +1,6 @@ import * as expect from "expect"; import * as simple from "simple-mock"; -import { - EncryptionAlgorithm, - EncryptionEventContent, - MatrixClient, - RoomEncryptionAlgorithm, - RoomTracker, UserDevice -} from "../../src"; +import { EncryptionAlgorithm, UserDevice } from "../../src"; import { createTestClient, TEST_DEVICE_ID } from "../MatrixClientTest"; import { DeviceTracker } from "../../src/e2ee/DeviceTracker"; diff --git a/test/metrics/decoratorsTest.ts b/test/metrics/decoratorsTest.ts index 63b1e9c0..3cab9b4a 100644 --- a/test/metrics/decoratorsTest.ts +++ b/test/metrics/decoratorsTest.ts @@ -1,10 +1,10 @@ import * as expect from "expect"; import * as simple from "simple-mock"; import { - timedMatrixClientFunctionCall, - timedIntentFunctionCall, Metrics, - timedIdentityClientFunctionCall + timedIdentityClientFunctionCall, + timedIntentFunctionCall, + timedMatrixClientFunctionCall } from "../../src"; class InterceptedClass { diff --git a/test/simple-validationTest.ts b/test/simple-validationTest.ts index 55b45cac..932372f4 100644 --- a/test/simple-validationTest.ts +++ b/test/simple-validationTest.ts @@ -1,5 +1,5 @@ import * as expect from "expect"; -import { getRequestFn, setRequestFn, validateSpaceOrderString } from "../src"; +import { validateSpaceOrderString } from "../src"; describe('validateSpaceOrderString', () => { it('should return true with valid identifiers', () => { From 68911aab913e8956b8942f312cd3bf589098db34 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 8 Aug 2021 21:53:23 -0600 Subject: [PATCH 12/26] Initial untested code for receiving decryption keys --- src/MatrixClient.ts | 12 ++ src/e2ee/CryptoClient.ts | 202 +++++++++++++++++++-- src/models/Crypto.ts | 59 +++--- src/storage/ICryptoStorageProvider.ts | 28 ++- src/storage/SqliteCryptoStorageProvider.ts | 51 +++++- 5 files changed, 317 insertions(+), 35 deletions(-) diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index bf45edb2..71b9d17f 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -679,6 +679,18 @@ export class MatrixClient extends EventEmitter { } } + // Always process device messages first to ensure there are decryption keys + if (raw['to_device']?.['events']) { + const inbox = raw['to_device']['events']; + for (const message of inbox) { + if (message['type'] === 'm.room.encrypted') { + await this.crypto.processInboundDeviceMessage(message); + } else { + // TODO: Emit or do something with unknown messages? + } + } + } + if (raw['groups']) { const leave = raw['groups']['leave'] || {}; for (const groupId of Object.keys(leave)) { diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 0bcf9151..2bbcb03b 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -6,9 +6,12 @@ import * as anotherJson from "another-json"; import { DeviceKeyAlgorithm, EncryptionAlgorithm, + IMegolmEncrypted, + IMRoomKey, IOlmEncrypted, IOlmPayload, IOlmSession, + IToDeviceMessage, OTKAlgorithm, OTKCounts, OTKs, @@ -257,12 +260,13 @@ export class CryptoClient { * Gets or creates Olm sessions for the given users and devices. Where sessions cannot be created, * the user/device will be excluded from the returned map. * @param {Record} userDeviceMap Map of user IDs to device IDs + * @param {boolean} force If true, force creation of a session for the referenced users. * @returns {Promise>>} Resolves to a map of user ID to device * ID to session. Users/devices which cannot have sessions made will not be included, thus the object * may be empty. */ @requiresReady() - public async getOrCreateOlmSessions(userDeviceMap: Record): Promise>> { + public async getOrCreateOlmSessions(userDeviceMap: Record, force = false): Promise>> { const otkClaimRequest: Record> = {}; const userDeviceSessionIds: Record> = {}; @@ -275,7 +279,7 @@ export class CryptoClient { continue; } - const existingSession = await this.client.cryptoStore.getCurrentOlmSession(userId, deviceId); + const existingSession = force ? null : (await this.client.cryptoStore.getCurrentOlmSession(userId, deviceId)); if (existingSession) { if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {}; userDeviceSessionIds[userId][deviceId] = existingSession; @@ -408,10 +412,10 @@ export class CryptoClient { * error is thrown. * @param {string} eventType The event type being encrypted. * @param {any} content The event content being encrypted. - * @returns {Promise} Resolves to the encrypted content for an `m.room.encrypted` event. + * @returns {Promise} Resolves to the encrypted content for an `m.room.encrypted` event. */ @requiresReady() - public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise { + public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise { if (!(await this.isRoomEncrypted(roomId))) { throw new Error("Room is not encrypted"); } @@ -430,12 +434,12 @@ export class CryptoClient { content: await this.roomTracker.getRoomCryptoConfig(roomId), }); - const session = new Olm.OutboundGroupSession(); + const newSession = new Olm.OutboundGroupSession(); try { - session.create(); - const pickled = session.pickle(this.pickleKey); + newSession.create(); + const pickled = newSession.pickle(this.pickleKey); currentSession = { - sessionId: session.session_id(), + sessionId: newSession.session_id(), roomId: roomId, pickled: pickled, isCurrent: true, @@ -443,10 +447,14 @@ export class CryptoClient { expiresTs: now + roomConfig.rotationPeriodMs, }; await this.client.cryptoStore.storeOutboundGroupSession(currentSession); - // TODO: Store as inbound session too - + await this.storeInboundGroupSession({ + room_id: roomId, + session_id: newSession.session_id(), + session_key: newSession.session_key(), + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + }, await this.client.getUserId(), this.clientDeviceId); } finally { - session.free(); + newSession.free(); } } @@ -471,7 +479,7 @@ export class CryptoClient { LogService.warn("CryptoClient", `Unable to send Megolm session to ${userId} ${device.device_id}: No Olm session`); continue; } - await this.encryptAndSendOlmMessage(device, olmSession, "m.room_key", { + await this.encryptAndSendOlmMessage(device, olmSession, "m.room_key", { algorithm: EncryptionAlgorithm.MegolmV1AesSha2, room_id: roomId, session_id: session.session_id(), @@ -497,4 +505,174 @@ export class CryptoClient { session.free(); } } + + /** + * Handles an inbound to-device message, decrypting it if needed. This will not throw + * under normal circumstances and should always resolve successfully. + * @param {IToDeviceMessage} message The message to process. + * @returns {Promise} Resolves when complete. Should never fail. + */ + public async processInboundDeviceMessage(message: IToDeviceMessage): Promise { + if (!message.content || !message.sender || !message.type) return; + try { + if (message.type === "m.room.encrypted") { + if (message.content?.['algorithm'] !== EncryptionAlgorithm.OlmV1Curve25519AesSha2) { + LogService.warn("CryptoClient", "Received encrypted message with unknown encryption algorithm"); + return; + } + + const userDevices = await this.client.cryptoStore.getUserDevices(message.sender); + const senderDevice = userDevices.find(d => d.keys[`${DeviceKeyAlgorithm.Curve25519}:${d.device_id}`] === message.content.sender_key); + if (!senderDevice) { + LogService.warn("CryptoClient", "Received encrypted message from unknown identity key (ignoring message):", message.content.sender_key); + return; + } + + const myMessage = message.content.ciphertext?.[this.deviceCurve25519]; + if (!myMessage) { + LogService.warn("CryptoClient", "Received encrypted message not intended for us (ignoring message)"); + return; + } + + if (!Number.isFinite(myMessage.type) || !myMessage.body) { + LogService.warn("CryptoClient", "Received invalid encrypted message (ignoring message)"); + return; + } + + const sessions = await this.client.cryptoStore.getOlmSessions(senderDevice.user_id, senderDevice.device_id); + let trySession: IOlmSession; + for (const storedSession of sessions) { + const session = new Olm.Session(); + try { + session.unpickle(this.pickleKey, storedSession.pickled); + if (session.matches_inbound(myMessage.body)) { + trySession = storedSession; + break; + } + } finally { + session.free(); + } + } + + if (myMessage.type === 0 && !trySession) { + // Store the session because we can + const session = new Olm.Session(); + const account = await this.getOlmAccount(); + try { + session.create_inbound_from(account, message.content.sender_key, myMessage.body); + account.remove_one_time_keys(session); + trySession = { + pickled: session.pickle(this.pickleKey), + sessionId: session.session_id(), + lastDecryptionTs: Date.now(), + }; + await this.client.cryptoStore.storeOlmSession(senderDevice.user_id, senderDevice.device_id, trySession); + } finally { + session.free(); + await this.storeAndFreeOlmAccount(account); + } + } + + if (myMessage.type !== 0 && !trySession) { + LogService.warn("CryptoClient", "Unable to find suitable session for encrypted to-device message; Establishing new session"); + await this.establishNewOlmSession(senderDevice); + return; + } + + // Try decryption (finally) + const session = new Olm.Session(); + let decrypted: IOlmPayload; + try { + session.unpickle(this.pickleKey, trySession.pickled); + decrypted = JSON.parse(session.decrypt(myMessage.type, myMessage.body)); + } catch (e) { + LogService.warn("CryptoClient", "Decryption error with to-device message, assuming corrupted session and re-establishing."); + await this.establishNewOlmSession(senderDevice); + return; + } finally { + session.free(); + } + + const wasForUs = decrypted.recipient !== (await this.client.getUserId()); + const wasFromThem = decrypted.sender === message.sender; + const hasType = typeof(decrypted.type) === 'string'; + const hasContent = typeof(decrypted.content) === 'object'; + const ourKeyMatches = decrypted.recipient_keys?.ed25519 === this.deviceEd25519; + const theirKeyMatches = decrypted.keys?.ed25519 === senderDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${senderDevice.device_id}`]; + if (!wasForUs || !wasFromThem || !hasType || !hasContent || !ourKeyMatches || !theirKeyMatches) { + LogService.warn("CryptoClient", "Successfully decrypted to-device message, but if failed validation. Ignoring message."); + return; + } + + trySession.lastDecryptionTs = Date.now(); + await this.client.cryptoStore.storeOlmSession(senderDevice.user_id, senderDevice.device_id, trySession); + + if (decrypted.type === "m.room_key") { + await this.handleInboundRoomKey(decrypted, senderDevice, message); + } else if (decrypted.type === "m.dummy") { + // success! Nothing to do. + } else { + LogService.warn("CryptoClient", `Unknown decrypted to-device message type: ${decrypted.type}`); + } + } else { + LogService.warn("CryptoClient", `Unknown to-device message type: ${message.type}`); + } + } catch (e) { + LogService.error("CryptoClient", "Non-fatal error while processing to-device message:", e); + } + } + + private async handleInboundRoomKey(message: IToDeviceMessage, device: UserDevice, original: IToDeviceMessage): Promise { + if (message.content?.algorithm !== EncryptionAlgorithm.MegolmV1AesSha2) { + LogService.warn("CryptoClient", "Ignoring m.room_key for unknown encryption algorithm"); + return; + } + if (!message.content?.room_id || !message.content?.session_id || !message.content?.session_key) { + LogService.warn("CryptoClient", "Ignoring invalid m.room_key"); + return; + } + + const deviceKey = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${device.device_id}`]; + if (deviceKey !== original.content?.sender_key) { + LogService.warn("CryptoClient", "Ignoring m.room_key message from unexpected sender"); + return; + } + + // See if we already know about this session (if we do: ignore the message) + const knownSession = await this.client.cryptoStore.getInboundGroupSession(device.user_id, device.device_id, message.content.room_id, message.content.session_id); + if (knownSession) { + return; // ignore + } + + await this.storeInboundGroupSession(message.content, device.user_id, device.device_id); + } + + private async storeInboundGroupSession(key: IMRoomKey, senderUserId: string, senderDeviceId: string): Promise { + const inboundSession = new Olm.InboundGroupSession(); + try { + inboundSession.create(key.session_key); + if (inboundSession.session_id() !== key.session_id) { + LogService.warn("CryptoClient", "Ignoring m.room_key with mismatched session_id"); + return; + } + await this.client.cryptoStore.storeInboundGroupSession({ + roomId: key.room_id, + sessionId: key.session_id, + senderDeviceId: senderDeviceId, + senderUserId: senderUserId, + pickled: inboundSession.pickle(this.pickleKey), + }); + } finally { + inboundSession.free(); + } + } + + private async establishNewOlmSession(device: UserDevice): Promise { + const olmSessions = await this.getOrCreateOlmSessions({ + [device.user_id]: [device.device_id], + }, true); + + // Share the session immediately + await this.encryptAndSendOlmMessage(device, olmSessions[device.user_id][device.device_id], "m.dummy", {}); + } } diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index 202edf91..138f1a06 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -140,6 +140,20 @@ export interface IOutboundGroupSession { expiresTs: number; } +/** + * An inbound group session. + * @category Models + */ +export interface IInboundGroupSession { + sessionId: string; + roomId: string; + senderUserId: string; + senderDeviceId: string; + pickled: string; + + // TODO: Store `keys` from the m.room_key alongside the session for "verified sender" support. +} + /** * An Olm session. * @category Models @@ -160,10 +174,10 @@ export interface IOlmPayload { sender: string; recipient: string; // user ID recipient_keys: { - ed25519: string; // our key + ed25519: string; }; keys: { - ed25519: string; // their key + ed25519: string; // sender's key }; } @@ -183,31 +197,34 @@ export interface IOlmEncrypted { } /** - * The kind of payload which is sent encrypted from an Olm device. + * A to-device message. * @category Models */ -export enum OlmPayloadKind { - CanSetUpSession = 0, - RequiresKnownSession = 1, +export interface IToDeviceMessage { + type: string; + sender: string; + content: T; } /** - * A device message. + * An m.room_key to-device message's content. * @category Models */ -export interface IDeviceMessage { - /** - * The recipient user ID. - */ - targetUserId: string; - - /** - * The recipient device ID. May be "*" to denote all of the user's devices. - */ - targetDeviceId: string; +export interface IMRoomKey { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2; + room_id: string; + session_id: string; + session_key: string; +} - /** - * The payload. - */ - content: any; +/** + * Encrypted event content for a Megolm-encrypted m.room.encrypted event + * @category Models + */ +export interface IMegolmEncrypted { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2; + sender_key: string; + ciphertext: string; + session_id: string; + device_id: string; // sender } diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index 4df29dfc..746a55a0 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,5 +1,5 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; -import { IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; +import { IInboundGroupSession, IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; /** * A storage provider capable of only providing crypto-related storage. @@ -172,4 +172,30 @@ export interface ICryptoStorageProvider { * @returns {Promise} Resolves to the Olm session, or falsy if none found. */ getCurrentOlmSession(userId: string, deviceId: string): Promise; + + /** + * Gets all the Olm sessions known for a given user's device. Note that this may not return in order, + * so callers needing to know the "current" Olm session should use the appropriate function. + * @param {string} userId The user ID. + * @param {string} deviceId The device ID. + * @returns {Promise} Resolves to the known Olm sessions, or an empty array if none are known. + */ + getOlmSessions(userId: string, deviceId: string): Promise; + + /** + * Stores an inbound group session. + * @param {IInboundGroupSession} session The session to store. + * @returns {Promise} Resolves when complete. + */ + storeInboundGroupSession(session: IInboundGroupSession): Promise; + + /** + * Gets a previously stored inbound group session. If the session is not known, a falsy value is returned. + * @param {string} senderUserId The user ID who sent the session in the first place. + * @param {string} senderDeviceId The device ID of the sender. + * @param {string} roomId The room ID where the session should belong. + * @param {string} sessionId The session ID itself. + * @returns {Promise} Resolves to the session, or falsy if not known. + */ + getInboundGroupSession(senderUserId: string, senderDeviceId: string, roomId: string, sessionId: string): Promise; } diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index 0dea2569..28a4cafe 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -1,7 +1,7 @@ import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import * as Database from "better-sqlite3"; -import { IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; +import { IInboundGroupSession, IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; /** * Sqlite crypto storage provider. Requires `better-sqlite3` package to be installed. @@ -29,6 +29,9 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { private obSentSelectLastSent: Database.Statement; private olmSessionUpsert: Database.Statement; private olmSessionCurrentSelect: Database.Statement; + private olmSessionSelect: Database.Statement; + private ibGroupSessionUpsert: Database.Statement; + private ibGroupSessionSelect: Database.Statement; /** * Creates a new Sqlite storage provider. @@ -45,6 +48,7 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.db.exec("CREATE TABLE IF NOT EXISTS outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, current TINYINT NOT NULL, pickled TEXT NOT NULL, uses_left NUMBER NOT NULL, expires_ts NUMBER NOT NULL, PRIMARY KEY (session_id, room_id))"); this.db.exec("CREATE TABLE IF NOT EXISTS sent_outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, session_index INT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id, session_index))"); this.db.exec("CREATE TABLE IF NOT EXISTS olm_sessions (user_id TEXT NOT NULL, device_id TEXT NOT NULL, session_id TEXT NOT NULL, last_decryption_ts NUMBER NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (user_id, device_id, session_id))"); + this.db.exec("CREATE TABLE IF NOT EXISTS inbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id))"); this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value"); this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name"); @@ -71,6 +75,10 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.olmSessionUpsert = this.db.prepare("INSERT INTO olm_sessions (user_id, device_id, session_id, last_decryption_ts, pickled) VALUES (@userId, @deviceId, @sessionId, @lastDecryptionTs, @pickled) ON CONFLICT (user_id, device_id, session_id) DO UPDATE SET last_decryption_ts = @lastDecryptionTs, pickled = @pickled"); this.olmSessionCurrentSelect = this.db.prepare("SELECT user_id, device_id, session_id, last_decryption_ts, pickled FROM olm_sessions WHERE user_id = @userId AND device_id = @deviceId ORDER BY last_decryption_ts DESC LIMIT 1"); + this.olmSessionSelect = this.db.prepare("SELECT user_id, device_id, session_id, last_decryption_ts, pickled FROM olm_sessions WHERE user_id = @userId AND device_id = @deviceId"); + + this.ibGroupSessionUpsert = this.db.prepare("INSERT INTO inbound_group_sessions (session_id, room_id, user_id, device_id, pickled) VALUES (@sessionId, @roomId, @userId, @deviceId, @pickled) ON CONFLICT (session_id, room_id, user_id, device_id) DO UPDATE SET pickled = @pickled"); + this.ibGroupSessionSelect = this.db.prepare("SELECT session_id, room_id, user_id, device_id, pickled FROM inbound_group_sessions WHERE session_id = @sessionId AND room_id = @roomId AND user_id = @userId AND device_id = @deviceId"); } public async setDeviceId(deviceId: string): Promise { @@ -247,6 +255,47 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { }; } + public async getOlmSessions(userId: string, deviceId: string): Promise { + const result = this.olmSessionSelect.all({ + userId: userId, + deviceId: deviceId, + }); + return (result || []).map(r => ({ + sessionId: r.session_id, + pickled: r.pickled, + lastDecryptionTs: r.last_decryption_ts, + })); + } + + public async storeInboundGroupSession(session: IInboundGroupSession): Promise { + this.ibGroupSessionUpsert.run({ + sessionId: session.sessionId, + roomId: session.roomId, + userId: session.senderUserId, + deviceId: session.senderDeviceId, + pickled: session.pickled, + }); + } + + public async getInboundGroupSession(senderUserId: string, senderDeviceId: string, roomId: string, sessionId: string): Promise { + const result = this.ibGroupSessionSelect.get({ + sessionId: sessionId, + roomId: roomId, + userId: senderUserId, + deviceId: senderDeviceId, + }); + if (result) { + return { + sessionId: result.session_id, + roomId: result.room_id, + senderUserId: result.user_id, + senderDeviceId: result.device_id, + pickled: result.pickled, + }; + } + return null; + } + /** * Closes the crypto store. Primarily for testing purposes. */ From e277656f27cad89cbc682ee6f0ef2a00dea3ab6e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 8 Aug 2021 22:00:37 -0600 Subject: [PATCH 13/26] Appease the linter --- src/e2ee/CryptoClient.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 2bbcb03b..2db9d06b 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -542,33 +542,33 @@ export class CryptoClient { const sessions = await this.client.cryptoStore.getOlmSessions(senderDevice.user_id, senderDevice.device_id); let trySession: IOlmSession; for (const storedSession of sessions) { - const session = new Olm.Session(); + const checkSession = new Olm.Session(); try { - session.unpickle(this.pickleKey, storedSession.pickled); - if (session.matches_inbound(myMessage.body)) { + checkSession.unpickle(this.pickleKey, storedSession.pickled); + if (checkSession.matches_inbound(myMessage.body)) { trySession = storedSession; break; } } finally { - session.free(); + checkSession.free(); } } if (myMessage.type === 0 && !trySession) { // Store the session because we can - const session = new Olm.Session(); + const inboundSession = new Olm.Session(); const account = await this.getOlmAccount(); try { - session.create_inbound_from(account, message.content.sender_key, myMessage.body); - account.remove_one_time_keys(session); + inboundSession.create_inbound_from(account, message.content.sender_key, myMessage.body); + account.remove_one_time_keys(inboundSession); trySession = { - pickled: session.pickle(this.pickleKey), - sessionId: session.session_id(), + pickled: inboundSession.pickle(this.pickleKey), + sessionId: inboundSession.session_id(), lastDecryptionTs: Date.now(), }; await this.client.cryptoStore.storeOlmSession(senderDevice.user_id, senderDevice.device_id, trySession); } finally { - session.free(); + inboundSession.free(); await this.storeAndFreeOlmAccount(account); } } From 028635c71b351b38ae54e10bfdf2a076317099c4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Aug 2021 14:58:05 -0600 Subject: [PATCH 14/26] Support decryption --- examples/encryption_bot.ts | 35 +++++++------ src/e2ee/CryptoClient.ts | 57 ++++++++++++++++++++- src/index.ts | 1 + src/models/events/EncryptedRoomEvent.ts | 58 ++++++++++++++++++++++ src/storage/ICryptoStorageProvider.ts | 30 ++++++++++- src/storage/SqliteCryptoStorageProvider.ts | 21 ++++++++ 6 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 src/models/events/EncryptedRoomEvent.ts diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index 1eb02a92..cc844e89 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -1,9 +1,10 @@ import { + EncryptedRoomEvent, EncryptionAlgorithm, LogLevel, LogService, - MatrixClient, - RichConsoleLogger, + MatrixClient, MessageEvent, + RichConsoleLogger, RichReply, SimpleFsStorageProvider } from "../src"; import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider"; @@ -50,21 +51,27 @@ const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); ], }); } - await sendEncryptedNotice(encryptedRoomId, "This is an encrypted room"); - client.on("room.event", (roomId: string, event: any) => { - if (roomId !== encryptedRoomId) return; - LogService.debug("index", `${roomId}`, event); + client.on("room.event", async (roomId: string, event: any) => { + if (roomId !== encryptedRoomId || event['type'] !== "m.room.encrypted") return; + + try { + const decrypted = await client.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId); + if (decrypted.type === "m.room.message") { + const message = new MessageEvent(decrypted.raw); + if (message.messageType !== "m.text") return; + if (message.textBody.startsWith("!ping")) { + const reply = RichReply.createFor(roomId, message.raw, "Pong", "Pong"); + reply['msgtype'] = "m.notice"; + const encrypted = await client.crypto.encryptRoomEvent(roomId, "m.room.message", reply); + await client.sendEvent(roomId, "m.room.encrypted", encrypted); + } + } + } catch (e) { + LogService.error("index", e); + } }); LogService.info("index", "Starting bot..."); await client.start(); })(); - -async function sendEncryptedNotice(roomId: string, text: string) { - const encrypted = await client.crypto.encryptRoomEvent(roomId, "m.room.message", { - msgtype: "m.notice", - body: text, - }); - await client.sendEvent(roomId, "m.room.encrypted", encrypted); -} diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 2db9d06b..aa654805 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -22,6 +22,8 @@ import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; import { DeviceTracker } from "./DeviceTracker"; import { EncryptionEvent } from "../models/events/EncryptionEvent"; +import { EncryptedRoomEvent } from "../models/events/EncryptedRoomEvent"; +import { RoomEvent } from "../models/events/RoomEvent"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly @@ -506,6 +508,57 @@ export class CryptoClient { } } + /** + * Decrypts a room event. Currently only supports Megolm-encrypted events (default for this SDK). + * @param {EncryptedRoomEvent} event The encrypted event. + * @param {string} roomId The room ID where the event was sent. + * @returns {Promise>} Resolves to a decrypted room event, or rejects/throws with + * an error if the event is undecryptable. + */ + public async decryptRoomEvent(event: EncryptedRoomEvent, roomId: string): Promise> { + if (event.algorithm !== EncryptionAlgorithm.MegolmV1AesSha2) { + throw new Error("Unable to decrypt: Unknown algorithm"); + } + + const encrypted = event.megolmProperties; + const senderDevice = await this.client.cryptoStore.getUserDevice(event.sender, encrypted.device_id); + if (!senderDevice) { + throw new Error("Unable to decrypt: Unknown device for sender"); + } + + if (senderDevice.keys[`${DeviceKeyAlgorithm.Curve25519}:${senderDevice.device_id}`] !== encrypted.sender_key) { + throw new Error("Unable to decrypt: Device key mismatch"); + } + + const storedSession = await this.client.cryptoStore.getInboundGroupSession(event.sender, encrypted.device_id, roomId, encrypted.session_id); + if (!storedSession) { + throw new Error("Unable to decrypt: Unknown inbound session ID"); + } + + const session = new Olm.InboundGroupSession(); + try { + session.unpickle(this.pickleKey, storedSession.pickled); + const cleartext = session.decrypt(encrypted.ciphertext) as { plaintext: string, message_index: number }; + const eventBody = JSON.parse(cleartext.plaintext); + const messageIndex = cleartext.message_index; + + const existingEventId = await this.client.cryptoStore.getEventForMessageIndex(roomId, storedSession.sessionId, messageIndex); + if (existingEventId && existingEventId !== event.eventId) { + throw new Error("Unable to decrypt: Message replay attack"); + } + + await this.client.cryptoStore.setMessageIndexForEvent(roomId, event.eventId, storedSession.sessionId, messageIndex); + + return new RoomEvent({ + ...event.raw, + type: eventBody.type || "io.t2bot.unknown", + content: (typeof(eventBody.content) === 'object') ? eventBody.content : {}, + }); + } finally { + session.free(); + } + } + /** * Handles an inbound to-device message, decrypting it if needed. This will not throw * under normal circumstances and should always resolve successfully. @@ -593,14 +646,14 @@ export class CryptoClient { session.free(); } - const wasForUs = decrypted.recipient !== (await this.client.getUserId()); + const wasForUs = decrypted.recipient === (await this.client.getUserId()); const wasFromThem = decrypted.sender === message.sender; const hasType = typeof(decrypted.type) === 'string'; const hasContent = typeof(decrypted.content) === 'object'; const ourKeyMatches = decrypted.recipient_keys?.ed25519 === this.deviceEd25519; const theirKeyMatches = decrypted.keys?.ed25519 === senderDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${senderDevice.device_id}`]; if (!wasForUs || !wasFromThem || !hasType || !hasContent || !ourKeyMatches || !theirKeyMatches) { - LogService.warn("CryptoClient", "Successfully decrypted to-device message, but if failed validation. Ignoring message."); + LogService.warn("CryptoClient", "Successfully decrypted to-device message, but it failed validation. Ignoring message."); return; } diff --git a/src/index.ts b/src/index.ts index 65fdc0ff..43f0b95b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ export * from "./models/events/RoomNameEvent"; export * from "./models/events/RoomTopicEvent"; export * from "./models/events/SpaceChildEvent"; export * from "./models/events/EncryptionEvent"; +export * from "./models/events/EncryptedRoomEvent"; // Preprocessors export * from "./preprocessors/IPreprocessor"; diff --git a/src/models/events/EncryptedRoomEvent.ts b/src/models/events/EncryptedRoomEvent.ts new file mode 100644 index 00000000..3c764ba3 --- /dev/null +++ b/src/models/events/EncryptedRoomEvent.ts @@ -0,0 +1,58 @@ +import { RoomEvent } from "./RoomEvent"; +import { EncryptionAlgorithm, IMegolmEncrypted } from "../Crypto"; + +/** + * The content definition for m.room.encrypted events + * @category Matrix event contents + * @see EncryptedRoomEvent + */ +export interface EncryptedRoomEventContent { + algorithm: EncryptionAlgorithm; + + /** + * For m.megolm.v1.aes-sha2 messages. The sender's Curve25519 key. + */ + sender_key?: string; + + /** + * For m.megolm.v1.aes-sha2 messages. The session ID established by the sender. + */ + session_id?: string; + + /** + * For m.megolm.v1.aes-sha2 messages. The encrypted payload. + */ + ciphertext?: string; + + /** + * For m.megolm.v1.aes-sha2 messages. The sender's device ID. + */ + device_id?: string; + + // Other algorithms not supported at the moment +} + +/** + * Represents an m.room.encrypted room event + * @category Matrix events + */ +export class EncryptedRoomEvent extends RoomEvent { + constructor(event: any) { + super(event); + } + + /** + * The encryption algorithm used on the event. Should match the m.room.encryption + * state config. + */ + public get algorithm(): EncryptionAlgorithm { + return this.content.algorithm; + } + + /** + * The Megolm encrypted payload information. + */ + public get megolmProperties(): IMegolmEncrypted { + return this.content as IMegolmEncrypted; + } +} diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index 746a55a0..001796c1 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,5 +1,10 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; -import { IInboundGroupSession, IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; +import { + IInboundGroupSession, + IOlmSession, + IOutboundGroupSession, + UserDevice, +} from "../models/Crypto"; /** * A storage provider capable of only providing crypto-related storage. @@ -198,4 +203,27 @@ export interface ICryptoStorageProvider { * @returns {Promise} Resolves to the session, or falsy if not known. */ getInboundGroupSession(senderUserId: string, senderDeviceId: string, roomId: string, sessionId: string): Promise; + + /** + * Sets the successfully decrypted message index for an event. Useful for tracking replay attacks. + * @param {string} roomId The room ID where the event was sent. + * @param {string} eventId The event ID. + * @param {string} sessionId The inbound group session ID for the event. + * @param {number} messageIndex The message index, as reported after decryption. + * @returns {Promise} Resolves when complete. + */ + setMessageIndexForEvent(roomId: string, eventId: string, sessionId: string, messageIndex: number): Promise; + + /** + * Gets the event ID for a previously successful decryption from a session and message index. If + * no event ID is known, this will return falsy. The caller can use this function to determine if + * a replay attack is being performed by checking the returned event ID, if present, against the + * event ID of the event it is decrypting. If the event IDs do not match but are truthy then the + * session may have been inappropriately re-used. + * @param {string} roomId The room ID. + * @param {string} sessionId The inbound group session ID. + * @param {number} messageIndex The message index. + * @returns {Promise} Resolves to the event ID of the matching event, or falsy if not known. + */ + getEventForMessageIndex(roomId: string, sessionId: string, messageIndex: number): Promise; } diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index 28a4cafe..cb9a5a73 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -32,6 +32,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { private olmSessionSelect: Database.Statement; private ibGroupSessionUpsert: Database.Statement; private ibGroupSessionSelect: Database.Statement; + private deMetadataUpsert: Database.Statement; + private deMetadataSelect: Database.Statement; /** * Creates a new Sqlite storage provider. @@ -49,6 +51,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.db.exec("CREATE TABLE IF NOT EXISTS sent_outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, session_index INT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id, session_index))"); this.db.exec("CREATE TABLE IF NOT EXISTS olm_sessions (user_id TEXT NOT NULL, device_id TEXT NOT NULL, session_id TEXT NOT NULL, last_decryption_ts NUMBER NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (user_id, device_id, session_id))"); this.db.exec("CREATE TABLE IF NOT EXISTS inbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id))"); + this.db.exec("CREATE TABLE IF NOT EXISTS decrypted_event_metadata (room_id TEXT NOT NULL, event_id TEXT NOT NULL, session_id TEXT NOT NULL, message_index INT NOT NULL, PRIMARY KEY (room_id, event_id))"); + this.db.exec("CREATE INDEX IF NOT EXISTS idx_decrypted_event_metadata_by_message_index ON decrypted_event_metadata (room_id, session_id, message_index)"); this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value"); this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name"); @@ -79,6 +83,9 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.ibGroupSessionUpsert = this.db.prepare("INSERT INTO inbound_group_sessions (session_id, room_id, user_id, device_id, pickled) VALUES (@sessionId, @roomId, @userId, @deviceId, @pickled) ON CONFLICT (session_id, room_id, user_id, device_id) DO UPDATE SET pickled = @pickled"); this.ibGroupSessionSelect = this.db.prepare("SELECT session_id, room_id, user_id, device_id, pickled FROM inbound_group_sessions WHERE session_id = @sessionId AND room_id = @roomId AND user_id = @userId AND device_id = @deviceId"); + + this.deMetadataUpsert = this.db.prepare("INSERT INTO decrypted_event_metadata (room_id, event_id, session_id, message_index) VALUES (@roomId, @eventId, @sessionId, @messageIndex) ON CONFLICT (room_id, event_id) DO UPDATE SET message_index = @messageIndex, session_id = @sessionId"); + this.deMetadataSelect = this.db.prepare("SELECT room_id, event_id, session_id, message_index FROM decrypted_event_metadata WHERE room_id = @roomId AND session_id = @sessionId AND message_index = @messageIndex LIMIT 1"); } public async setDeviceId(deviceId: string): Promise { @@ -296,6 +303,20 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { return null; } + public async setMessageIndexForEvent(roomId: string, eventId: string, sessionId: string, messageIndex: number): Promise { + this.deMetadataUpsert.run({ + roomId: roomId, + eventId: eventId, + sessionId: sessionId, + messageIndex: messageIndex, + }); + } + + public async getEventForMessageIndex(roomId: string, sessionId: string, messageIndex: number): Promise { + const result = this.deMetadataSelect.get({roomId: roomId, sessionId: sessionId, messageIndex: messageIndex}); + return result?.event_id; + } + /** * Closes the crypto store. Primarily for testing purposes. */ From d96a873b79a855ad5319fd793870f7d04b8961a5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Aug 2021 18:26:47 -0600 Subject: [PATCH 15/26] Encrypt and decrypt by default when possible --- examples/encryption_bot.ts | 31 ++++------ src/MatrixClient.ts | 67 ++++++++++++++++++--- src/e2ee/CryptoClient.ts | 25 +++++++- src/storage/ICryptoStorageProvider.ts | 8 --- src/storage/SqliteCryptoStorageProvider.ts | 6 -- test/storage/SqliteCryptoStorageProvider.ts | 48 --------------- 6 files changed, 95 insertions(+), 90 deletions(-) diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index cc844e89..a1280b39 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -1,10 +1,9 @@ import { - EncryptedRoomEvent, EncryptionAlgorithm, LogLevel, LogService, MatrixClient, MessageEvent, - RichConsoleLogger, RichReply, + RichConsoleLogger, SimpleFsStorageProvider } from "../src"; import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider"; @@ -52,23 +51,19 @@ const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); }); } - client.on("room.event", async (roomId: string, event: any) => { - if (roomId !== encryptedRoomId || event['type'] !== "m.room.encrypted") return; + client.on("room.message", async (roomId: string, event: any) => { + if (roomId !== encryptedRoomId) return; - try { - const decrypted = await client.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId); - if (decrypted.type === "m.room.message") { - const message = new MessageEvent(decrypted.raw); - if (message.messageType !== "m.text") return; - if (message.textBody.startsWith("!ping")) { - const reply = RichReply.createFor(roomId, message.raw, "Pong", "Pong"); - reply['msgtype'] = "m.notice"; - const encrypted = await client.crypto.encryptRoomEvent(roomId, "m.room.message", reply); - await client.sendEvent(roomId, "m.room.encrypted", encrypted); - } - } - } catch (e) { - LogService.error("index", e); + const message = new MessageEvent(event); + + if (message.sender === (await client.getUserId())) { + // yay, we decrypted our own message. Communicate that back for testing purposes. + return await client.unstableApis.addReactionToEvent(roomId, message.eventId, '🔐'); + } + + if (message.messageType !== "m.text") return; + if (message.textBody.startsWith("!ping")) { + await client.replyNotice(roomId, event, "Pong"); } }); diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 71b9d17f..feb87084 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -37,6 +37,7 @@ import { } from "./models/Crypto"; import { requiresCrypto } from "./e2ee/decorators"; import { ICryptoStorageProvider } from "./storage/ICryptoStorageProvider"; +import { EncryptedRoomEvent } from "./models/events/EncryptedRoomEvent"; /** * A client that is capable of interacting with a matrix homeserver. @@ -799,6 +800,17 @@ export class MatrixClient extends EventEmitter { for (let event of room['timeline']['events']) { event = await this.processEvent(event); + if (event['type'] === 'm.room.encrypted' && await this.crypto?.isRoomEncrypted(roomId)) { + await emitFn("room.encrypted_event", roomId, event); + try { + event = (await this.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId)).raw; + event = await this.processEvent(event); + await emitFn("room.decrypted_event", roomId, event); + } catch (e) { + LogService.error("MatrixClientLite", `Decryption error on ${roomId} ${event['event_id']}`, e); + await emitFn("room.failed_decryption", roomId, event); + } + } if (event['type'] === 'm.room.message') { await emitFn("room.message", roomId, event); } @@ -814,6 +826,21 @@ export class MatrixClient extends EventEmitter { } } + /** + * Gets an event for a room. If the event is encrypted, and the client supports encryption, + * and the room is encrypted, then this will return a decrypted event. + * @param {string} roomId the room ID to get the event in + * @param {string} eventId the event ID to look up + * @returns {Promise} resolves to the found event + */ + @timedMatrixClientFunctionCall() + public async getEvent(roomId: string, eventId: string): Promise { + const event = await this.getRawEvent(roomId, eventId); + if (event['type'] === 'm.room.encrypted' && await this.crypto?.isRoomEncrypted(roomId)) { + return this.processEvent((await this.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId)).raw); + } + } + /** * Gets an event for a room. Returned as a raw event. * @param {string} roomId the room ID to get the event in @@ -821,7 +848,7 @@ export class MatrixClient extends EventEmitter { * @returns {Promise} resolves to the found event */ @timedMatrixClientFunctionCall() - public getEvent(roomId: string, eventId: string): Promise { + public getRawEvent(roomId: string, eventId: string): Promise { return this.doRequest("GET", "/_matrix/client/r0/rooms/" + encodeURIComponent(roomId) + "/event/" + encodeURIComponent(eventId)) .then(ev => this.processEvent(ev)); } @@ -1030,6 +1057,7 @@ export class MatrixClient extends EventEmitter { /** * Replies to a given event with the given text. The event is sent with a msgtype of m.text. + * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} text the text to reply with @@ -1046,6 +1074,7 @@ export class MatrixClient extends EventEmitter { /** * Replies to a given event with the given HTML. The event is sent with a msgtype of m.text. + * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} html the HTML to reply with. @@ -1060,6 +1089,7 @@ export class MatrixClient extends EventEmitter { /** * Replies to a given event with the given text. The event is sent with a msgtype of m.notice. + * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} text the text to reply with @@ -1077,6 +1107,7 @@ export class MatrixClient extends EventEmitter { /** * Replies to a given event with the given HTML. The event is sent with a msgtype of m.notice. + * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} html the HTML to reply with. @@ -1091,7 +1122,8 @@ export class MatrixClient extends EventEmitter { } /** - * Sends a notice to the given room + * Sends a notice to the given room. The message will be encrypted if the client supports + * encryption and the room is encrypted. * @param {string} roomId the room ID to send the notice to * @param {string} text the text to send * @returns {Promise} resolves to the event ID that represents the message @@ -1105,7 +1137,8 @@ export class MatrixClient extends EventEmitter { } /** - * Sends a notice to the given room with HTML content + * Sends a notice to the given room with HTML content. The message will be encrypted if the client supports + * encryption and the room is encrypted. * @param {string} roomId the room ID to send the notice to * @param {string} html the HTML to send * @returns {Promise} resolves to the event ID that represents the message @@ -1121,7 +1154,8 @@ export class MatrixClient extends EventEmitter { } /** - * Sends a text message to the given room + * Sends a text message to the given room. The message will be encrypted if the client supports + * encryption and the room is encrypted. * @param {string} roomId the room ID to send the text to * @param {string} text the text to send * @returns {Promise} resolves to the event ID that represents the message @@ -1135,7 +1169,8 @@ export class MatrixClient extends EventEmitter { } /** - * Sends a text message to the given room with HTML content + * Sends a text message to the given room with HTML content. The message will be encrypted if the client supports + * encryption and the room is encrypted. * @param {string} roomId the room ID to send the text to * @param {string} html the HTML to send * @returns {Promise} resolves to the event ID that represents the message @@ -1151,7 +1186,8 @@ export class MatrixClient extends EventEmitter { } /** - * Sends a message to the given room + * Sends a message to the given room. The message will be encrypted if the client supports + * encryption and the room is encrypted. * @param {string} roomId the room ID to send the message to * @param {object} content the event content to send * @returns {Promise} resolves to the event ID that represents the message @@ -1162,7 +1198,8 @@ export class MatrixClient extends EventEmitter { } /** - * Sends an event to the given room + * Sends an event to the given room. This will encrypt the event before sending if the room is + * encrypted and the client supports encryption. Use sendRawEvent() to avoid this behaviour. * @param {string} roomId the room ID to send the event to * @param {string} eventType the type of event to send * @param {string} content the event body to send @@ -1170,6 +1207,22 @@ export class MatrixClient extends EventEmitter { */ @timedMatrixClientFunctionCall() public async sendEvent(roomId: string, eventType: string, content: any): Promise { + if (await this.crypto?.isRoomEncrypted(roomId)) { + content = await this.crypto.encryptRoomEvent(roomId, eventType, content); + eventType = "m.room.encrypted"; + } + return this.sendRawEvent(roomId, eventType, content); + } + + /** + * Sends an event to the given room. + * @param {string} roomId the room ID to send the event to + * @param {string} eventType the type of event to send + * @param {string} content the event body to send + * @returns {Promise} resolves to the event ID that represents the event + */ + @timedMatrixClientFunctionCall() + public async sendRawEvent(roomId: string, eventType: string, content: any): Promise { const txnId = (new Date().getTime()) + "__inc" + (++this.requestId); return this.doRequest("PUT", "/_matrix/client/r0/rooms/" + encodeURIComponent(roomId) + "/send/" + encodeURIComponent(eventType) + "/" + encodeURIComponent(txnId), null, content).then(response => { return response['event_id']; diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index aa654805..210db30e 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -422,6 +422,9 @@ export class CryptoClient { throw new Error("Room is not encrypted"); } + const relatesTo = JSON.parse(JSON.stringify(content['m.relates_to'])); + delete content['m.relates_to']; + const now = (new Date()).getTime(); let currentSession = await this.client.cryptoStore.getCurrentOutboundGroupSession(roomId); @@ -448,7 +451,10 @@ export class CryptoClient { usesLeft: roomConfig.rotationPeriodMessages, expiresTs: now + roomConfig.rotationPeriodMs, }; - await this.client.cryptoStore.storeOutboundGroupSession(currentSession); + + // Store the session as an inbound session up front. This is to ensure that we have the + // earliest possible ratchet available to our own decryption functions. We don't store + // the outbound session here as it is stored earlier on. await this.storeInboundGroupSession({ room_id: roomId, session_id: newSession.session_id(), @@ -496,13 +502,23 @@ export class CryptoClient { room_id: roomId, })); - return { - algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + currentSession.pickled = session.pickle(this.pickleKey); + currentSession.usesLeft--; + await this.client.cryptoStore.storeOutboundGroupSession(currentSession); + + const body = { sender_key: this.deviceCurve25519, ciphertext: encrypted, session_id: session.session_id(), device_id: this.clientDeviceId, }; + if (relatesTo) { + body['m.relates_to'] = relatesTo; + } + return { + ...body, + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + }; } finally { session.free(); } @@ -549,6 +565,9 @@ export class CryptoClient { await this.client.cryptoStore.setMessageIndexForEvent(roomId, event.eventId, storedSession.sessionId, messageIndex); + storedSession.pickled = session.pickle(this.pickleKey); + await this.client.cryptoStore.storeInboundGroupSession(storedSession); + return new RoomEvent({ ...event.raw, type: eventBody.type || "io.t2bot.unknown", diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index 001796c1..f1237cc0 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -133,14 +133,6 @@ export interface ICryptoStorageProvider { */ getCurrentOutboundGroupSession(roomId: string): Promise; - /** - * Decrements the available usages for an outbound group session. - * @param {string} sessionId The session ID. - * @param {string} roomId The room ID. - * @returns {Promise} Resolves when complete. - */ - useOutboundGroupSession(sessionId: string, roomId: string): Promise; - /** * Stores a session as sent to a user's device. * @param {IOutboundGroupSession} session The session that was sent. diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index cb9a5a73..830c9b99 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -23,7 +23,6 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { private obGroupSessionUpsert: Database.Statement; private obGroupSessionSelect: Database.Statement; private obGroupCurrentSessionSelect: Database.Statement; - private obGroupSessionMarkUsage: Database.Statement; private obGroupSessionMarkAllInactive: Database.Statement; private obSentGroupSessionUpsert: Database.Statement; private obSentSelectLastSent: Database.Statement; @@ -71,7 +70,6 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.obGroupSessionUpsert = this.db.prepare("INSERT INTO outbound_group_sessions (session_id, room_id, current, pickled, uses_left, expires_ts) VALUES (@sessionId, @roomId, @current, @pickled, @usesLeft, @expiresTs) ON CONFLICT (session_id, room_id) DO UPDATE SET pickled = @pickled, current = @current, uses_left = @usesLeft, expires_ts = @expiresTs"); this.obGroupSessionSelect = this.db.prepare("SELECT session_id, room_id, current, pickled, uses_left, expires_ts FROM outbound_group_sessions WHERE session_id = @sessionId AND room_id = @roomId"); this.obGroupCurrentSessionSelect = this.db.prepare("SELECT session_id, room_id, current, pickled, uses_left, expires_ts FROM outbound_group_sessions WHERE room_id = @roomId AND current = 1"); - this.obGroupSessionMarkUsage = this.db.prepare("UPDATE outbound_group_sessions SET uses_left = uses_left - 1 WHERE session_id = @sessionId and room_id = @roomId"); this.obGroupSessionMarkAllInactive = this.db.prepare("UPDATE outbound_group_sessions SET current = 0 WHERE room_id = @roomId"); this.obSentGroupSessionUpsert = this.db.prepare("INSERT INTO sent_outbound_group_sessions (session_id, room_id, session_index, user_id, device_id) VALUES (@sessionId, @roomId, @sessionIndex, @userId, @deviceId) ON CONFLICT (session_id, room_id, user_id, device_id, session_index) DO NOTHING"); @@ -220,10 +218,6 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { return null; } - public async useOutboundGroupSession(sessionId: string, roomId: string): Promise { - this.obGroupSessionMarkUsage.run({sessionId: sessionId, roomId: roomId}); - } - public async storeSentOutboundGroupSession(session: IOutboundGroupSession, index: number, device: UserDevice): Promise { this.obSentGroupSessionUpsert.run({ sessionId: session.sessionId, diff --git a/test/storage/SqliteCryptoStorageProvider.ts b/test/storage/SqliteCryptoStorageProvider.ts index 268f3001..8fce09d0 100644 --- a/test/storage/SqliteCryptoStorageProvider.ts +++ b/test/storage/SqliteCryptoStorageProvider.ts @@ -341,54 +341,6 @@ describe('SqliteCryptoStorageProvider', () => { await store.close(); }); - it('should count usages of outbound sessions', async () => { - const sessionId = "session"; - const roomId = "!room:example.org"; - const usesLeft = 100; - const expiresTs = Date.now(); - const pickle = "pickled"; - - const name = tmp.fileSync().name; - let store = new SqliteCryptoStorageProvider(name); - - await store.storeOutboundGroupSession({ - sessionId: sessionId, - roomId: roomId, - pickled: pickle, - expiresTs: expiresTs, - usesLeft: usesLeft, - isCurrent: true, - }); - expect(await store.getOutboundGroupSession(sessionId, roomId)).toMatchObject({ - sessionId: sessionId, - roomId: roomId, - pickled: pickle, - expiresTs: expiresTs, - usesLeft: usesLeft, - isCurrent: true, - }); - await store.useOutboundGroupSession(sessionId, roomId); - expect(await store.getOutboundGroupSession(sessionId, roomId)).toMatchObject({ - sessionId: sessionId, - roomId: roomId, - pickled: pickle, - expiresTs: expiresTs, - usesLeft: usesLeft - 1, - isCurrent: true, - }); - await store.close(); - store = new SqliteCryptoStorageProvider(name); - expect(await store.getOutboundGroupSession(sessionId, roomId)).toMatchObject({ - sessionId: sessionId, - roomId: roomId, - pickled: pickle, - expiresTs: expiresTs, - usesLeft: usesLeft - 1, - isCurrent: true, - }); - await store.close(); - }); - it('should track sent outbound sessions', async () => { const sessionId = "session"; const roomId = "!room:example.org"; From 2569dd6e067dd2020dbf7e5eae6ae3f2e7d6d585 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 11 Aug 2021 20:52:54 -0600 Subject: [PATCH 16/26] Fix immediate tests --- src/MatrixClient.ts | 1 + src/e2ee/CryptoClient.ts | 7 +++++-- test/encryption/CryptoClientTest.ts | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index feb87084..e4928914 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -839,6 +839,7 @@ export class MatrixClient extends EventEmitter { if (event['type'] === 'm.room.encrypted' && await this.crypto?.isRoomEncrypted(roomId)) { return this.processEvent((await this.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId)).raw); } + return event; } /** diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 210db30e..7f895055 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -422,8 +422,11 @@ export class CryptoClient { throw new Error("Room is not encrypted"); } - const relatesTo = JSON.parse(JSON.stringify(content['m.relates_to'])); - delete content['m.relates_to']; + let relatesTo: any; + if (content['m.relates_to']) { + relatesTo = JSON.parse(JSON.stringify(content['m.relates_to'])); + delete content['m.relates_to']; + } const now = (new Date()).getTime(); diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 91d67f34..d58204ff 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1450,7 +1450,7 @@ describe('CryptoClient', () => { expect(s.roomId).toEqual(roomId); expect(s.pickled).toBeDefined(); expect(s.isCurrent).toBe(true); - expect(s.usesLeft).toBe(rotationIntervals); + expect(s.usesLeft).toBe(rotationIntervals - 1); expect(s.expiresTs - Date.now()).toBeLessThanOrEqual(rotationMs + 1000); expect(s.expiresTs - Date.now()).toBeGreaterThanOrEqual(rotationMs - 1000); }); @@ -1540,7 +1540,7 @@ describe('CryptoClient', () => { expect(s.roomId).toEqual(roomId); expect(s.pickled).toBeDefined(); expect(s.isCurrent).toBe(true); - expect(s.usesLeft).toBe(rotationIntervals); + expect(s.usesLeft).toBe(rotationIntervals - 1); expect(s.expiresTs - Date.now()).toBeLessThanOrEqual(rotationMs + 1000); expect(s.expiresTs - Date.now()).toBeGreaterThanOrEqual(rotationMs - 1000); }); @@ -1626,7 +1626,7 @@ describe('CryptoClient', () => { expect(s.roomId).toEqual(roomId); expect(s.pickled).toBeDefined(); expect(s.isCurrent).toBe(true); - expect(s.usesLeft).toBe(rotationIntervals); + expect(s.usesLeft).toBe(rotationIntervals - 1); expect(s.expiresTs - Date.now()).toBeLessThanOrEqual(rotationMs + 1000); expect(s.expiresTs - Date.now()).toBeGreaterThanOrEqual(rotationMs - 1000); }); From a0b3c7c5a60b4c10ef75c4b7b75e366f15f9b941 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 13 Aug 2021 21:00:02 -0600 Subject: [PATCH 17/26] Add tests for missed code --- src/MatrixClient.ts | 2 +- src/e2ee/CryptoClient.ts | 79 +- src/e2ee/DeviceTracker.ts | 6 +- src/models/Crypto.ts | 2 +- test/MatrixClientTest.ts | 1199 ++++++++++++++- test/encryption/CryptoClientTest.ts | 1407 +++++++++++++++++- test/models/events/EncryptedRoomEventTest.ts | 16 + test/storage/SqliteCryptoStorageProvider.ts | 57 +- 8 files changed, 2657 insertions(+), 111 deletions(-) create mode 100644 test/models/events/EncryptedRoomEventTest.ts diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index e4928914..31c304a4 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -808,7 +808,7 @@ export class MatrixClient extends EventEmitter { await emitFn("room.decrypted_event", roomId, event); } catch (e) { LogService.error("MatrixClientLite", `Decryption error on ${roomId} ${event['event_id']}`, e); - await emitFn("room.failed_decryption", roomId, event); + await emitFn("room.failed_decryption", roomId, event, e); } } if (event['type'] === 'm.room.message') { diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 7f895055..403c76c4 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -34,7 +34,6 @@ export class CryptoClient { private ready = false; private deviceId: string; private pickleKey: string; - private pickledAccount: string; private deviceEd25519: string; private deviceCurve25519: string; private maxOTKs: number; @@ -63,7 +62,7 @@ export class CryptoClient { private async getOlmAccount(): Promise { const account = new Olm.Account(); - account.unpickle(this.pickleKey, this.pickledAccount); + account.unpickle(this.pickleKey, await this.client.cryptoStore.getPickledAccount()); return account; } @@ -101,6 +100,17 @@ export class CryptoClient { let pickleKey = await this.client.cryptoStore.getPickleKey(); const account = new Olm.Account(); + + const makeReady = () => { + const keys = JSON.parse(account.identity_keys()); + this.deviceCurve25519 = keys['curve25519']; + this.deviceEd25519 = keys['ed25519']; + + this.pickleKey = pickleKey; + this.maxOTKs = account.max_number_of_one_time_keys(); + this.ready = true; + }; + try { if (!pickled || !pickleKey) { LogService.debug("CryptoClient", "Creating new Olm account: previous session lost or not set up"); @@ -111,36 +121,19 @@ export class CryptoClient { await this.client.cryptoStore.setPickleKey(pickleKey); await this.client.cryptoStore.setPickledAccount(pickled); - this.pickleKey = pickleKey; - this.pickledAccount = pickled; - - this.maxOTKs = account.max_number_of_one_time_keys(); - - const keys = JSON.parse(account.identity_keys()); - this.deviceCurve25519 = keys['curve25519']; - this.deviceEd25519 = keys['ed25519']; - - this.ready = true; + makeReady(); const counts = await this.client.uploadDeviceKeys([ EncryptionAlgorithm.MegolmV1AesSha2, EncryptionAlgorithm.OlmV1Curve25519AesSha2, ], { - [`${DeviceKeyAlgorithm.Ed25119}:${this.deviceId}`]: this.deviceEd25519, + [`${DeviceKeyAlgorithm.Ed25519}:${this.deviceId}`]: this.deviceEd25519, [`${DeviceKeyAlgorithm.Curve25519}:${this.deviceId}`]: this.deviceCurve25519, }); await this.updateCounts(counts); } else { account.unpickle(pickleKey, pickled); - this.pickleKey = pickleKey; - this.pickledAccount = pickled; - this.maxOTKs = account.max_number_of_one_time_keys(); - - const keys = JSON.parse(account.identity_keys()); - this.deviceCurve25519 = keys['curve25519']; - this.deviceEd25519 = keys['ed25519']; - - this.ready = true; + makeReady(); await this.updateCounts(await this.client.checkOneTimeKeyCounts()); } } finally { @@ -209,7 +202,7 @@ export class CryptoClient { const sig = account.sign(anotherJson.stringify(obj)); return { [await this.client.getUserId()]: { - [`${DeviceKeyAlgorithm.Ed25119}:${this.deviceId}`]: sig, + [`${DeviceKeyAlgorithm.Ed25519}:${this.deviceId}`]: sig, }, ...existingSignatures, }; @@ -313,7 +306,7 @@ export class CryptoClient { continue; } - const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25119}:${deviceId}`; + const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25519}:${deviceId}`; const keyId = Object.keys(claimed.one_time_keys[userId][deviceId])[0]; const signedKey = claimed.one_time_keys[userId][deviceId][keyId]; @@ -375,7 +368,7 @@ export class CryptoClient { ed25519: this.deviceEd25519, }, recipient_keys: { - ed25519: device.keys[`${DeviceKeyAlgorithm.Ed25119}:${device.device_id}`], + ed25519: device.keys[`${DeviceKeyAlgorithm.Ed25519}:${device.device_id}`], }, recipient: device.user_id, sender: await this.client.getUserId(), @@ -534,6 +527,7 @@ export class CryptoClient { * @returns {Promise>} Resolves to a decrypted room event, or rejects/throws with * an error if the event is undecryptable. */ + @requiresReady() public async decryptRoomEvent(event: EncryptedRoomEvent, roomId: string): Promise> { if (event.algorithm !== EncryptionAlgorithm.MegolmV1AesSha2) { throw new Error("Unable to decrypt: Unknown algorithm"); @@ -587,8 +581,12 @@ export class CryptoClient { * @param {IToDeviceMessage} message The message to process. * @returns {Promise} Resolves when complete. Should never fail. */ + @requiresReady() public async processInboundDeviceMessage(message: IToDeviceMessage): Promise { - if (!message.content || !message.sender || !message.type) return; + if (!message?.content || !message?.sender || !message?.type) { + LogService.warn("CryptoClient", "Received invalid encrypted message"); + return; + } try { if (message.type === "m.room.encrypted") { if (message.content?.['algorithm'] !== EncryptionAlgorithm.OlmV1Curve25519AesSha2) { @@ -596,13 +594,6 @@ export class CryptoClient { return; } - const userDevices = await this.client.cryptoStore.getUserDevices(message.sender); - const senderDevice = userDevices.find(d => d.keys[`${DeviceKeyAlgorithm.Curve25519}:${d.device_id}`] === message.content.sender_key); - if (!senderDevice) { - LogService.warn("CryptoClient", "Received encrypted message from unknown identity key (ignoring message):", message.content.sender_key); - return; - } - const myMessage = message.content.ciphertext?.[this.deviceCurve25519]; if (!myMessage) { LogService.warn("CryptoClient", "Received encrypted message not intended for us (ignoring message)"); @@ -614,6 +605,13 @@ export class CryptoClient { return; } + const userDevices = await this.client.cryptoStore.getUserDevices(message.sender); + const senderDevice = userDevices.find(d => d.keys[`${DeviceKeyAlgorithm.Curve25519}:${d.device_id}`] === message.content.sender_key); + if (!senderDevice) { + LogService.warn("CryptoClient", "Received encrypted message from unknown identity key (ignoring message):", message.content.sender_key); + return; + } + const sessions = await this.client.cryptoStore.getOlmSessions(senderDevice.user_id, senderDevice.device_id); let trySession: IOlmSession; for (const storedSession of sessions) { @@ -661,7 +659,7 @@ export class CryptoClient { session.unpickle(this.pickleKey, trySession.pickled); decrypted = JSON.parse(session.decrypt(myMessage.type, myMessage.body)); } catch (e) { - LogService.warn("CryptoClient", "Decryption error with to-device message, assuming corrupted session and re-establishing."); + LogService.warn("CryptoClient", "Decryption error with to-device message, assuming corrupted session and re-establishing.", e); await this.establishNewOlmSession(senderDevice); return; } finally { @@ -671,11 +669,18 @@ export class CryptoClient { const wasForUs = decrypted.recipient === (await this.client.getUserId()); const wasFromThem = decrypted.sender === message.sender; const hasType = typeof(decrypted.type) === 'string'; - const hasContent = typeof(decrypted.content) === 'object'; + const hasContent = !!decrypted.content && typeof(decrypted.content) === 'object'; const ourKeyMatches = decrypted.recipient_keys?.ed25519 === this.deviceEd25519; - const theirKeyMatches = decrypted.keys?.ed25519 === senderDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${senderDevice.device_id}`]; + const theirKeyMatches = decrypted.keys?.ed25519 === senderDevice.keys[`${DeviceKeyAlgorithm.Ed25519}:${senderDevice.device_id}`]; if (!wasForUs || !wasFromThem || !hasType || !hasContent || !ourKeyMatches || !theirKeyMatches) { - LogService.warn("CryptoClient", "Successfully decrypted to-device message, but it failed validation. Ignoring message."); + LogService.warn("CryptoClient", "Successfully decrypted to-device message, but it failed validation. Ignoring message.", { + wasForUs, + wasFromThem, + hasType, + hasContent, + ourKeyMatches, + theirKeyMatches, + }); return; } diff --git a/src/e2ee/DeviceTracker.ts b/src/e2ee/DeviceTracker.ts index 08636789..5239812d 100644 --- a/src/e2ee/DeviceTracker.ts +++ b/src/e2ee/DeviceTracker.ts @@ -80,7 +80,7 @@ export class DeviceTracker { continue; } - const ed25519 = device.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + const ed25519 = device.keys[`${DeviceKeyAlgorithm.Ed25519}:${deviceId}`]; const curve25519 = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`]; if (!ed25519 || !curve25519) { @@ -92,14 +92,14 @@ export class DeviceTracker { const existingDevice = currentDevices.find(d => d.device_id === deviceId); if (existingDevice) { - const existingEd25519 = existingDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + const existingEd25519 = existingDevice.keys[`${DeviceKeyAlgorithm.Ed25519}:${deviceId}`]; if (existingEd25519 !== ed25519) { LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} appears compromised: Ed25519 key changed - ignoring device`); continue; } } - const signature = device.signatures?.[userId]?.[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`]; + const signature = device.signatures?.[userId]?.[`${DeviceKeyAlgorithm.Ed25519}:${deviceId}`]; if (!signature) { LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing a signature - ignoring device`); continue; diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index 138f1a06..7bb86831 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -63,7 +63,7 @@ export enum EncryptionAlgorithm { * @category Models */ export enum DeviceKeyAlgorithm { - Ed25119 = "ed25519", + Ed25519 = "ed25519", Curve25519 = "curve25519", } diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index 33479755..2a96f657 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -14,12 +14,12 @@ import { OTKAlgorithm, OTKCounts, OTKs, - RoomDirectoryLookupResponse, + RoomDirectoryLookupResponse, RoomEvent, setRequestFn, } from "../src"; import * as simple from "simple-mock"; import * as MockHttpBackend from 'matrix-mock-request'; -import { expectArrayEquals, feedOlmAccount } from "./TestUtils"; +import { expectArrayEquals, feedOlmAccount, feedStaticOlmAccount } from "./TestUtils"; import { redactObjectForLogging } from "../src/http"; import { PowerLevelAction } from "../src/models/PowerLevelAction"; import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider"; @@ -37,6 +37,20 @@ export function createTestClient(storage: IStorageProvider = null, userId: strin return {http, hsUrl, accessToken, client}; } +export async function createPreparedCryptoTestClient(userId: string): Promise<{ client: MatrixClient, http: MockHttpBackend, hsUrl: string, accessToken: string }> { + const r = createTestClient(null, userId, true); + + await r.client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(r.client); + r.client.uploadDeviceKeys = () => Promise.resolve({}); + r.client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + r.client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await r.client.crypto.prepare([]); + + return r; +} + describe('MatrixClient', () => { describe("constructor", () => { it('should pass through the homeserver URL and access token', () => { @@ -2049,6 +2063,122 @@ describe('MatrixClient', () => { expect(messageSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(5); }); + + it('should handle to_device messages', async () => { + const { client } = createTestClient(null, "@user:example.org", true); + const syncClient = (client); + + const deviceMessage = { + type: "m.room.encrypted", + content: { + hello: "world", + }, + }; + const wrongMessage = { + type: "not-m.room.encrypted", + content: { + wrong: true, + }, + }; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await client.crypto.prepare([]); + + const processSpy = simple.stub().callFn(async (message) => { + expect(message).toMatchObject(deviceMessage); + }); + client.crypto.processInboundDeviceMessage = processSpy; + + await syncClient.processSync({ + to_device: { + events: [wrongMessage, deviceMessage], + }, + }); + expect(processSpy.callCount).toBe(1); + }); + + it('should decrypt timeline events', async () => { + const {client: realClient} = await createPreparedCryptoTestClient("@alice:example.org"); + const client = (realClient); + + const userId = "@syncing:example.org"; + const roomId = "!testing:example.org"; + const events = [ + { + type: "m.room.encrypted", + content: {newType: "m.room.message"}, + }, + { + type: "m.room.encrypted", + content: {newType: "m.room.not_message"}, + }, + ]; + + realClient.crypto.isRoomEncrypted = async () => true; // for the purposes of this test + + const decryptSpy = simple.stub().callFn(async (ev, rid) => { + expect(events).toContain(ev.raw); + expect(rid).toEqual(roomId); + return new RoomEvent({ + ...ev.raw, + type: ev.content.newType, + }); + }); + realClient.crypto.decryptRoomEvent = decryptSpy; + + const processSpy = simple.stub().callFn(async (ev) => { + if (ev['type'] === 'm.room.encrypted' && (processSpy.callCount%2 !== 0)) { + expect(events).toContain(ev); + } else if (ev['type'] === 'm.room.message') { + expect(ev.content).toMatchObject(events[0].content); + } else { + expect(ev.content).toMatchObject(events[1].content); + } + return ev; + }); + (realClient).processEvent = processSpy; + + client.userId = userId; + + const encryptedSpy = simple.stub(); + const decryptedSpy = simple.stub(); + const failedSpy = simple.stub(); + const messageSpy = simple.stub().callFn((rid, ev) => { + expect(rid).toEqual(roomId); + expect(ev["type"]).toEqual("m.room.message"); + expect(ev.content).toMatchObject(events[0].content); + }); + const eventSpy = simple.stub().callFn((rid, ev) => { + expect(rid).toEqual(roomId); + + if (ev['type'] === 'm.room.message') { + expect(ev.content).toMatchObject(events[0].content); + } else { + expect(ev.content).toMatchObject(events[1].content); + } + }); + realClient.on("room.encrypted_event", encryptedSpy); + realClient.on("room.decrypted_event", decryptedSpy); + realClient.on("room.failed_decryption", failedSpy); + realClient.on("room.message", messageSpy); + realClient.on("room.event", eventSpy); + + const roomsObj = {}; + roomsObj[roomId] = {timeline: {events: events}, invite_state: {events: events}}; + await client.processSync({rooms: {join: roomsObj, leave: roomsObj, invite: roomsObj}}); + expect(encryptedSpy.callCount).toBe(2); + expect(decryptedSpy.callCount).toBe(2); + expect(failedSpy.callCount).toBe(0); + expect(messageSpy.callCount).toBe(1); + expect(eventSpy.callCount).toBe(2); + expect(decryptSpy.callCount).toBe(2); + expect(processSpy.callCount).toBe(4); // 2 for encrypted, 2 for decrypted + }); }); describe('getEvent', () => { @@ -2095,6 +2225,184 @@ describe('MatrixClient', () => { expect(result).toMatchObject(event); expect(result["processed"]).toBeTruthy(); }); + + it('should try decryption', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!abc123:example.org"; + const eventId = "$example:matrix.org"; + const event = {type: "m.room.encrypted", content: {encrypted: true}}; + const decrypted = {type: "m.room.message", content: {hello: "world"}}; + + const isEncSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return true; + }); + client.crypto.isRoomEncrypted = isEncSpy; + + const decryptSpy = simple.stub().callFn(async (ev, rid) => { + expect(ev.raw).toMatchObject(event); + expect(rid).toEqual(roomId); + return new RoomEvent(decrypted); + }); + client.crypto.decryptRoomEvent = decryptSpy; + + const processSpy = simple.stub().callFn(async (ev) => { + if (ev['type'] === 'm.room.encrypted' && (processSpy.callCount % 2 !== 0)) { + expect(ev).toMatchObject(event); + } else { + expect(ev).toMatchObject(decrypted); + } + return ev; + }); + (client).processEvent = processSpy; + + http.when("GET", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + expect(path).toEqual(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); + return event; + }); + + http.flushAllExpected(); + const result = await client.getEvent(roomId, eventId); + expect(result).toMatchObject(decrypted); + expect(processSpy.callCount).toBe(2); + expect(isEncSpy.callCount).toBe(1); + expect(decryptSpy.callCount).toBe(1); + }); + + it('should not try decryption in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!abc123:example.org"; + const eventId = "$example:matrix.org"; + const event = {type: "m.room.encrypted", content: {encrypted: true}}; + const decrypted = {type: "m.room.message", content: {hello: "world"}}; + + const isEncSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return false; + }); + client.crypto.isRoomEncrypted = isEncSpy; + + const decryptSpy = simple.stub().callFn(async (ev, rid) => { + expect(ev.raw).toMatchObject(event); + expect(rid).toEqual(roomId); + return new RoomEvent(decrypted); + }); + client.crypto.decryptRoomEvent = decryptSpy; + + const processSpy = simple.stub().callFn(async (ev) => { + if (ev['type'] === 'm.room.encrypted' && (processSpy.callCount % 2 !== 0)) { + expect(ev).toMatchObject(event); + } else { + expect(ev).toMatchObject(decrypted); + } + return ev; + }); + (client).processEvent = processSpy; + + http.when("GET", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + expect(path).toEqual(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); + return event; + }); + + http.flushAllExpected(); + const result = await client.getEvent(roomId, eventId); + expect(result).toMatchObject(event); + expect(processSpy.callCount).toBe(1); + expect(isEncSpy.callCount).toBe(1); + expect(decryptSpy.callCount).toBe(0); + }); + }); + + describe('getRawEvent', () => { + it('should call the right endpoint', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!abc123:example.org"; + const eventId = "$example:matrix.org"; + const event = {type: "m.room.message"}; + + http.when("GET", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + expect(path).toEqual(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); + return event; + }); + + http.flushAllExpected(); + const result = await client.getRawEvent(roomId, eventId); + expect(result).toMatchObject(event); + }); + + it('should process events', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!abc123:example.org"; + const eventId = "$example:matrix.org"; + const event = {type: "m.room.message"}; + const processor = { + processEvent: (ev, procClient, kind?) => { + expect(kind).toEqual(EventKind.RoomEvent); + ev["processed"] = true; + }, + getSupportedEventTypes: () => ["m.room.message"], + }; + + client.addPreprocessor(processor); + + http.when("GET", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + expect(path).toEqual(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); + return event; + }); + + http.flushAllExpected(); + const result = await client.getRawEvent(roomId, eventId); + expect(result).toMatchObject(event); + expect(result["processed"]).toBeTruthy(); + }); + + it('should not try decryption in any rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!abc123:example.org"; + const eventId = "$example:matrix.org"; + const event = {type: "m.room.encrypted", content: {encrypted: true}}; + const decrypted = {type: "m.room.message", content: {hello: "world"}}; + + const isEncSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return false; + }); + client.crypto.isRoomEncrypted = isEncSpy; + + const decryptSpy = simple.stub().callFn(async (ev, rid) => { + expect(ev.raw).toMatchObject(event); + expect(rid).toEqual(roomId); + return new RoomEvent(decrypted); + }); + client.crypto.decryptRoomEvent = decryptSpy; + + const processSpy = simple.stub().callFn(async (ev) => { + if (ev['type'] === 'm.room.encrypted' && (processSpy.callCount % 2 !== 0)) { + expect(ev).toMatchObject(event); + } else { + expect(ev).toMatchObject(decrypted); + } + return ev; + }); + (client).processEvent = processSpy; + + http.when("GET", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + expect(path).toEqual(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); + return event; + }); + + http.flushAllExpected(); + const result = await client.getRawEvent(roomId, eventId); + expect(result).toMatchObject(event); + expect(processSpy.callCount).toBe(1); + expect(isEncSpy.callCount).toBe(0); + expect(decryptSpy.callCount).toBe(0); + }); }); describe('getRoomState', () => { @@ -2794,8 +3102,8 @@ describe('MatrixClient', () => { expect(result).toEqual(eventId); }); - it('should use encoded plain text as the HTML component', async () => { - const {client, http, hsUrl} = createTestClient(); + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; @@ -2810,7 +3118,7 @@ describe('MatrixClient', () => { const replyText = ""; const replyHtml = "<testing1234>"; - const expectedContent = { + const expectedPlainContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, @@ -2822,22 +3130,32 @@ describe('MatrixClient', () => { formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; + const expectedContent = { + encrypted: true, + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(expectedPlainContent); + return expectedContent as any; + }; + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { - const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return {event_id: eventId}; }); http.flushAllExpected(); - const result = await client.replyText(roomId, originalEvent, replyText); + const result = await client.replyText(roomId, originalEvent, replyText, replyHtml); expect(result).toEqual(eventId); }); - }); - describe('replyHtmlText', () => { - it('should call the right endpoint', async () => { - const {client, http, hsUrl} = createTestClient(); + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; @@ -2849,8 +3167,8 @@ describe('MatrixClient', () => { sender: "@abc:example.org", event_id: "$abc:example.org", }; - const replyText = "HELLO WORLD"; // expected - const replyHtml = "

Hello World

"; + const replyText = ""; + const replyHtml = "<testing1234>"; const expectedContent = { "m.relates_to": { @@ -2864,6 +3182,8 @@ describe('MatrixClient', () => { formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; + client.crypto.isRoomEncrypted = async () => false; // for this test + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); @@ -2872,13 +3192,11 @@ describe('MatrixClient', () => { }); http.flushAllExpected(); - const result = await client.replyHtmlText(roomId, originalEvent, replyHtml); + const result = await client.replyText(roomId, originalEvent, replyText, replyHtml); expect(result).toEqual(eventId); }); - }); - describe('replyNotice', () => { - it('should call the right endpoint', async () => { + it('should use encoded plain text as the HTML component', async () => { const {client, http, hsUrl} = createTestClient(); const roomId = "!testing:example.org"; @@ -2900,7 +3218,7 @@ describe('MatrixClient', () => { "event_id": originalEvent.event_id, }, }, - msgtype: "m.notice", + msgtype: "m.text", format: "org.matrix.custom.html", body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, @@ -2914,11 +3232,13 @@ describe('MatrixClient', () => { }); http.flushAllExpected(); - const result = await client.replyNotice(roomId, originalEvent, replyText, replyHtml); + const result = await client.replyText(roomId, originalEvent, replyText); expect(result).toEqual(eventId); }); + }); - it('should use encoded plain text as the HTML component', async () => { + describe('replyHtmlText', () => { + it('should call the right endpoint', async () => { const {client, http, hsUrl} = createTestClient(); const roomId = "!testing:example.org"; @@ -2931,8 +3251,8 @@ describe('MatrixClient', () => { sender: "@abc:example.org", event_id: "$abc:example.org", }; - const replyText = ""; - const replyHtml = "<testing1234>"; + const replyText = "HELLO WORLD"; // expected + const replyHtml = "

Hello World

"; const expectedContent = { "m.relates_to": { @@ -2940,7 +3260,7 @@ describe('MatrixClient', () => { "event_id": originalEvent.event_id, }, }, - msgtype: "m.notice", + msgtype: "m.text", format: "org.matrix.custom.html", body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, @@ -2954,14 +3274,12 @@ describe('MatrixClient', () => { }); http.flushAllExpected(); - const result = await client.replyNotice(roomId, originalEvent, replyText); + const result = await client.replyHtmlText(roomId, originalEvent, replyHtml); expect(result).toEqual(eventId); }); - }); - describe('replyHtmlNotice', () => { - it('should call the right endpoint', async () => { - const {client, http, hsUrl} = createTestClient(); + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; @@ -2976,58 +3294,544 @@ describe('MatrixClient', () => { const replyText = "HELLO WORLD"; // expected const replyHtml = "

Hello World

"; - const expectedContent = { + const expectedPlainContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, - msgtype: "m.notice", + msgtype: "m.text", format: "org.matrix.custom.html", body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; + const expectedContent = { + encrypted: true, + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(expectedPlainContent); + return expectedContent as any; + }; + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { - const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return {event_id: eventId}; }); http.flushAllExpected(); - const result = await client.replyHtmlNotice(roomId, originalEvent, replyHtml); + const result = await client.replyHtmlText(roomId, originalEvent, replyHtml); expect(result).toEqual(eventId); }); - }); - describe('sendNotice', () => { - it('should call the right endpoint', async () => { - const {client, http, hsUrl} = createTestClient(); + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; - const eventContent = { - body: "Hello World", - msgtype: "m.notice", + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", }; + const replyText = "HELLO WORLD"; // expected + const replyHtml = "

Hello World

"; - http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { - const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); - expect(idx).toBe(0); + const expectedContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.text", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyHtmlText(roomId, originalEvent, replyHtml); + expect(result).toEqual(eventId); + }); + }); + + describe('replyNotice', () => { + it('should call the right endpoint', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", + }; + const replyText = ""; + const replyHtml = "<testing1234>"; + + const expectedContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyNotice(roomId, originalEvent, replyText, replyHtml); + expect(result).toEqual(eventId); + }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", + }; + const replyText = ""; + const replyHtml = "<testing1234>"; + + const expectedPlainContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + const expectedContent = { + encrypted: true, + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(expectedPlainContent); + return expectedContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyNotice(roomId, originalEvent, replyText, replyHtml); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", + }; + const replyText = ""; + const replyHtml = "<testing1234>"; + + const expectedContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyNotice(roomId, originalEvent, replyText, replyHtml); + expect(result).toEqual(eventId); + }); + + it('should use encoded plain text as the HTML component', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", + }; + const replyText = ""; + const replyHtml = "<testing1234>"; + + const expectedContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyNotice(roomId, originalEvent, replyText); + expect(result).toEqual(eventId); + }); + }); + + describe('replyHtmlNotice', () => { + it('should call the right endpoint', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", + }; + const replyText = "HELLO WORLD"; // expected + const replyHtml = "

Hello World

"; + + const expectedContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyHtmlNotice(roomId, originalEvent, replyHtml); + expect(result).toEqual(eventId); + }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", + }; + const replyText = "HELLO WORLD"; // expected + const replyHtml = "

Hello World

"; + + const expectedPlainContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + const expectedContent = { + encrypted: true, + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(expectedPlainContent); + return expectedContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyHtmlNotice(roomId, originalEvent, replyHtml); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const originalEvent = { + content: { + body: "*Hello World*", + formatted_body: "Hello World", + }, + sender: "@abc:example.org", + event_id: "$abc:example.org", + }; + const replyText = "HELLO WORLD"; // expected + const replyHtml = "

Hello World

"; + + const expectedContent = { + "m.relates_to": { + "m.in_reply_to": { + "event_id": originalEvent.event_id, + }, + }, + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, + formatted_body: `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(expectedContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.replyHtmlNotice(roomId, originalEvent, replyHtml); + expect(result).toEqual(eventId); + }); + }); + + describe('sendNotice', () => { + it('should call the right endpoint', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventContent = { + body: "Hello World", + msgtype: "m.notice", + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendNotice(roomId, eventContent.body); + expect(result).toEqual(eventId); + }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventPlainContent = { + body: "Hello World", + msgtype: "m.notice", + }; + + const eventContent = { + encrypted: true, + body: "Hello World", + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(eventPlainContent); + return eventContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendNotice(roomId, eventContent.body); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventContent = { + body: "Hello World", + msgtype: "m.notice", + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendNotice(roomId, eventContent.body); + expect(result).toEqual(eventId); + }); + }); + + describe('sendHtmlNotice', () => { + it('should call the right endpoint', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventContent = { + body: "HELLO WORLD", + msgtype: "m.notice", + format: "org.matrix.custom.html", + formatted_body: "

Hello World

", + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendHtmlNotice(roomId, eventContent.formatted_body); + expect(result).toEqual(eventId); + }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventPlainContent = { + body: "HELLO WORLD", + msgtype: "m.notice", + format: "org.matrix.custom.html", + formatted_body: "

Hello World

", + }; + + const eventContent = { + encrypted: true, + formatted_body: "

Hello World

", + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(eventPlainContent); + return eventContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); + expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return {event_id: eventId}; }); http.flushAllExpected(); - const result = await client.sendNotice(roomId, eventContent.body); + const result = await client.sendHtmlNotice(roomId, eventContent.formatted_body); expect(result).toEqual(eventId); }); - }); - describe('sendHtmlNotice', () => { - it('should call the right endpoint', async () => { - const {client, http, hsUrl} = createTestClient(); + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; @@ -3038,6 +3842,8 @@ describe('MatrixClient', () => { formatted_body: "

Hello World

", }; + client.crypto.isRoomEncrypted = async () => false; // for this test + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); @@ -3073,6 +3879,65 @@ describe('MatrixClient', () => { const result = await client.sendText(roomId, eventContent.body); expect(result).toEqual(eventId); }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventPlainContent = { + body: "Hello World", + msgtype: "m.text", + }; + + const eventContent = { + encrypted: true, + body: "Hello World", + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(eventPlainContent); + return eventContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendText(roomId, eventContent.body); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventContent = { + body: "Hello World", + msgtype: "m.text", + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendText(roomId, eventContent.body); + expect(result).toEqual(eventId); + }); }); describe('sendHtmlText', () => { @@ -3099,6 +3964,69 @@ describe('MatrixClient', () => { const result = await client.sendHtmlText(roomId, eventContent.formatted_body); expect(result).toEqual(eventId); }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventPlainContent = { + body: "HELLO WORLD", + msgtype: "m.text", + format: "org.matrix.custom.html", + formatted_body: "

Hello World

", + }; + + const eventContent = { + encrypted: true, + formatted_body: "

Hello World

", + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(eventPlainContent); + return eventContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendHtmlText(roomId, eventContent.formatted_body); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventContent = { + body: "HELLO WORLD", + msgtype: "m.text", + format: "org.matrix.custom.html", + formatted_body: "

Hello World

", + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendHtmlText(roomId, eventContent.formatted_body); + expect(result).toEqual(eventId); + }); }); describe('sendMessage', () => { @@ -3124,6 +4052,67 @@ describe('MatrixClient', () => { const result = await client.sendMessage(roomId, eventContent); expect(result).toEqual(eventId); }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventPlainContent = { + body: "Hello World", + msgtype: "m.text", + sample: true, + }; + + const eventContent = { + encrypted: true, + body: "Hello World", + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual("m.room.message"); + expect(c).toMatchObject(eventPlainContent); + return eventContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendMessage(roomId, eventPlainContent); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventContent = { + body: "Hello World", + msgtype: "m.text", + sample: true, + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendMessage(roomId, eventContent); + expect(result).toEqual(eventId); + }); }); describe('sendEvent', () => { @@ -3149,6 +4138,118 @@ describe('MatrixClient', () => { const result = await client.sendEvent(roomId, eventType, eventContent); expect(result).toEqual(eventId); }); + + it('should try to encrypt in encrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventType = "io.t2bot.test"; + const sEventType = "m.room.encrypted"; + const eventPlainContent = { + testing: "hello world", + sample: true, + }; + + const eventContent = { + encrypted: true, + body: "Hello World", + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + client.crypto.encryptRoomEvent = async (rid, t, c) => { + expect(rid).toEqual(roomId); + expect(t).toEqual(eventType); + expect(c).toMatchObject(eventPlainContent); + return eventContent as any; + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(sEventType)}/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendEvent(roomId, eventType, eventPlainContent); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in unencrypted rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventType = "io.t2bot.test"; + const eventContent = { + testing: "hello world", + sample: true, + }; + + client.crypto.isRoomEncrypted = async () => false; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendEvent(roomId, eventType, eventContent); + expect(result).toEqual(eventId); + }); + }); + + describe('sendRawEvent', () => { + it('should call the right endpoint', async () => { + const {client, http, hsUrl} = createTestClient(); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventType = "io.t2bot.test"; + const eventContent = { + testing: "hello world", + sample: true, + }; + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendEvent(roomId, eventType, eventContent); + expect(result).toEqual(eventId); + }); + + it('should not try to encrypt in any rooms', async () => { + const {client, http, hsUrl} = await createPreparedCryptoTestClient("@alice:example.org"); + + const roomId = "!testing:example.org"; + const eventId = "$something:example.org"; + const eventType = "io.t2bot.test"; + const eventContent = { + testing: "hello world", + sample: true, + }; + + client.crypto.isRoomEncrypted = async () => true; // for this test + + http.when("PUT", "/_matrix/client/r0/rooms").respond(200, (path, content) => { + const idx = path.indexOf(`${hsUrl}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); + expect(idx).toBe(0); + expect(content).toMatchObject(eventContent); + return {event_id: eventId}; + }); + + http.flushAllExpected(); + const result = await client.sendRawEvent(roomId, eventType, eventContent); + expect(result).toEqual(eventId); + }); }); describe('sendStateEvent', () => { @@ -5223,7 +6324,7 @@ describe('MatrixClient', () => { const algorithms = [EncryptionAlgorithm.MegolmV1AesSha2, EncryptionAlgorithm.OlmV1Curve25519AesSha2]; const keys: Record, string> = { [DeviceKeyAlgorithm.Curve25519 + ":" + TEST_DEVICE_ID]: "key1", - [DeviceKeyAlgorithm.Ed25119 + ":" + TEST_DEVICE_ID]: "key2", + [DeviceKeyAlgorithm.Ed25519 + ":" + TEST_DEVICE_ID]: "key2", }; const counts: OTKCounts = { [OTKAlgorithm.Signed]: 12, @@ -5239,7 +6340,7 @@ describe('MatrixClient', () => { keys: keys, signatures: { [userId]: { - [DeviceKeyAlgorithm.Ed25119 + ":" + TEST_DEVICE_ID]: expect.any(String), + [DeviceKeyAlgorithm.Ed25519 + ":" + TEST_DEVICE_ID]: expect.any(String), }, }, }, diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index d58204ff..2d9d365b 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1,23 +1,33 @@ import * as expect from "expect"; import * as simple from "simple-mock"; import { - DeviceKeyAlgorithm, + ConsoleLogger, + DeviceKeyAlgorithm, EncryptedRoomEvent, EncryptionAlgorithm, + ILogger, + IMRoomKey, + IOlmEncrypted, + IOlmPayload, IOlmSession, + IToDeviceMessage, + LogService, MatrixClient, OTKAlgorithm, OTKCounts, RoomEncryptionAlgorithm, + UserDevice, } from "../../src"; import { createTestClient, TEST_DEVICE_ID } from "../MatrixClientTest"; import { feedOlmAccount, feedStaticOlmAccount, + prepareOlm, RECEIVER_DEVICE, RECEIVER_OLM_SESSION, - STATIC_OUTBOUND_SESSION + STATIC_OUTBOUND_SESSION, STATIC_PICKLE_KEY } from "../TestUtils"; import { DeviceTracker } from "../../src/e2ee/DeviceTracker"; +import { STATIC_TEST_DEVICES } from "./DeviceTrackerTest"; describe('CryptoClient', () => { it('should not have a device ID or be ready until prepared', async () => { @@ -386,7 +396,7 @@ describe('CryptoClient', () => { it('should fail when the crypto has not been prepared', async () => { try { - await client.crypto.sign({doesnt: "matter"}); + await client.crypto.sign({ doesnt: "matter" }); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); @@ -488,7 +498,7 @@ describe('CryptoClient', () => { expect(result).toBe(false); result = await client.crypto.verifySignature(signed, key, "wrong signature"); expect(result).toBe(false); - result = await client.crypto.verifySignature({wrong: "object"}, key, signature); + result = await client.crypto.verifySignature({ wrong: "object" }, key, signature); expect(result).toBe(false); }); @@ -612,7 +622,7 @@ describe('CryptoClient', () => { const claimSpy = simple.stub().callFn(async (req) => { expect(Object.keys(req).length).toBe(0); - return {one_time_keys: {}, failures: {}}; + return { one_time_keys: {}, failures: {} }; }); client.claimOneTimeKeys = claimSpy; @@ -637,7 +647,7 @@ describe('CryptoClient', () => { const claimSpy = simple.stub().callFn(async (req) => { expect(Object.keys(req).length).toBe(0); - return {one_time_keys: {}, failures: {}}; + return { one_time_keys: {}, failures: {} }; }); client.claimOneTimeKeys = claimSpy; @@ -658,6 +668,83 @@ describe('CryptoClient', () => { expect(claimSpy.callCount).toBe(0); // no reason it should be called }); + it('should not use existing sessions if asked to force new sessions', async () => { + await client.crypto.prepare([]); + + const targetUserId = "@target:example.org"; + const targetDeviceId = "TARGET"; + + const session: IOlmSession = { + sessionId: "test_session", + lastDecryptionTs: Date.now(), + pickled: "pickled", + }; + + const claimSpy = simple.stub().callFn(async (req) => { + expect(req).toMatchObject({ + [targetUserId]: { + [targetDeviceId]: OTKAlgorithm.Signed, + }, + }); + return { + one_time_keys: { + [targetUserId]: { + [targetDeviceId]: { + [`${OTKAlgorithm.Signed}:${targetDeviceId}`]: { + key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + signatures: { + [targetUserId]: { + [`${DeviceKeyAlgorithm.Ed25519}:${targetDeviceId}`]: "Definitely real", + }, + }, + }, + }, + }, + }, + failures: {}, + }; + }); + client.claimOneTimeKeys = claimSpy; + + client.cryptoStore.getCurrentOlmSession = async (uid, did) => { + throw new Error("Not called appropriately"); + }; + + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(targetUserId); + return [{ + user_id: targetUserId, + device_id: targetDeviceId, + keys: { + [`${DeviceKeyAlgorithm.Curve25519}:${targetDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", + }, + + // We don't end up using a lot of this in this test + unsigned: {}, + signatures: {}, + algorithms: [], + }]; + }; + + // Skip signature verification for this test + client.crypto.verifySignature = () => Promise.resolve(true); + + const result = await client.crypto.getOrCreateOlmSessions({ + [targetUserId]: [targetDeviceId], + }, true); // FORCE!! + expect(result).toMatchObject({ + [targetUserId]: { + [targetDeviceId]: { + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + pickled: expect.any(String), + }, + }, + }); + expect(result[targetUserId][targetDeviceId].sessionId).not.toEqual(session.sessionId); + expect(claimSpy.callCount).toBe(1); + }); + it('should support mixing of OTK claims and existing sessions', async () => { await client.crypto.prepare([]); @@ -687,7 +774,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }, @@ -773,7 +860,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [targetUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${targetDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${targetDeviceId}`]: "Definitely real", }, }, }, @@ -785,7 +872,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }, @@ -861,7 +948,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [targetUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${targetDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${targetDeviceId}`]: "Definitely real", }, }, }, @@ -871,7 +958,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }, @@ -946,7 +1033,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [targetUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${targetDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${targetDeviceId}`]: "Definitely real", }, }, }, @@ -956,7 +1043,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }, @@ -1028,7 +1115,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures_MISSING: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }, @@ -1092,7 +1179,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }, @@ -1117,7 +1204,7 @@ describe('CryptoClient', () => { device_id: claimDeviceId, keys: { [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "ED25519 KEY GOES HERE", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "ED25519 KEY GOES HERE", }, // We don't end up using a lot of this in this test @@ -1132,7 +1219,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }); @@ -1178,7 +1265,7 @@ describe('CryptoClient', () => { key: "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", signatures: { [claimUserId]: { - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "Definitely real", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "Definitely real", }, }, }, @@ -1197,7 +1284,7 @@ describe('CryptoClient', () => { device_id: claimDeviceId, keys: { [`${DeviceKeyAlgorithm.Curve25519}:${claimDeviceId}`]: "zPsrUlEM3DKRcBYKMHgZTLmYJU1FJDzBRnH6DsTxHH8", - [`${DeviceKeyAlgorithm.Ed25119}:${claimDeviceId}`]: "ED25519 KEY GOES HERE", + [`${DeviceKeyAlgorithm.Ed25519}:${claimDeviceId}`]: "ED25519 KEY GOES HERE", }, // We don't end up using a lot of this in this test @@ -1690,5 +1777,1287 @@ describe('CryptoClient', () => { it.skip('should get devices for invited members', async () => { // TODO: Support invited members, if history visibility would allow. }); + + it('should preserve m.relates_to', async () => { + await client.crypto.prepare([]); + + const deviceMap = { + [RECEIVER_DEVICE.user_id]: [RECEIVER_DEVICE], + }; + const roomId = "!test:example.org"; + + // For this test, force all rooms to be encrypted + client.crypto.isRoomEncrypted = async () => true; + + await client.cryptoStore.storeOlmSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, RECEIVER_OLM_SESSION); + + const getSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return STATIC_OUTBOUND_SESSION; + }); + client.cryptoStore.getCurrentOutboundGroupSession = getSpy; + + const joinedSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return Object.keys(deviceMap); + }); + client.getJoinedRoomMembers = joinedSpy; + + const devicesSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(Object.keys(deviceMap)); + return deviceMap; + }); + (client.crypto).deviceTracker.getDevicesFor = devicesSpy; + + // We watch for the to-device messages to make sure we pass through the internal functions correctly + const toDeviceSpy = simple.stub().callFn(async (t, m) => { + expect(t).toEqual("m.room.encrypted"); + expect(m).toMatchObject({ + [RECEIVER_DEVICE.user_id]: { + [RECEIVER_DEVICE.device_id]: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { + type: 0, + body: expect.any(String), + }, + }, + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + }, + }, + }); + }); + client.sendToDevices = toDeviceSpy; + + const result = await client.crypto.encryptRoomEvent(roomId, "org.example.test", { + "m.relates_to": { + test: true, + }, + isTest: true, + hello: "world", + n: 42, + }); + expect(getSpy.callCount).toBe(1); + expect(joinedSpy.callCount).toBe(1); + expect(devicesSpy.callCount).toBe(1); + expect(toDeviceSpy.callCount).toBe(1); + expect(result).toMatchObject({ + "m.relates_to": { + test: true, + }, + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + ciphertext: expect.any(String), + session_id: STATIC_OUTBOUND_SESSION.sessionId, + device_id: TEST_DEVICE_ID, + }); + }); + }); + + describe('processInboundDeviceMessage', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + afterEach(async () => { + LogService.setLogger(new ConsoleLogger()); + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.processInboundDeviceMessage(null); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should ignore invalid formats', async () => { + await client.crypto.prepare([]); + + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Received invalid encrypted message"); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + await client.crypto.processInboundDeviceMessage(null); + await client.crypto.processInboundDeviceMessage(undefined); + await client.crypto.processInboundDeviceMessage({ + content: null, + type: "m.room.encrypted", + sender: "@bob:example.org" + }); + await client.crypto.processInboundDeviceMessage({ + type: "m.room.encrypted", + sender: "@bob:example.org" + } as any); + await client.crypto.processInboundDeviceMessage({ + content: { msg: true }, + type: null, + sender: "@bob:example.org" + }); + await client.crypto.processInboundDeviceMessage({ + content: { msg: true }, + sender: "@bob:example.org" + } as any); + await client.crypto.processInboundDeviceMessage({ + content: { msg: true }, + type: "m.room.encrypted", + sender: null + }); + await client.crypto.processInboundDeviceMessage({ + content: { msg: true }, + type: "m.room.encrypted" + } as any); + expect(logSpy.callCount).toBe(8); + }); + + it('should ignore invalid message types', async () => { + await client.crypto.prepare([]); + + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Unknown to-device message type: org.example"); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + await client.crypto.processInboundDeviceMessage({ + content: { test: true }, + type: "org.example", + sender: "@bob:example.org" + }); + expect(logSpy.callCount).toBe(1); + }); + + it('should ignore unknown algorithms', async () => { + await client.crypto.prepare([]); + + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Received encrypted message with unknown encryption algorithm"); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + await client.crypto.processInboundDeviceMessage({ + content: { + algorithm: "wrong", + ciphertext: { + "recv_key": { + type: 0, + body: "encrypted", + }, + }, + sender_key: "missing", + }, + type: "m.room.encrypted", + sender: "@bob:example.org", + }); + expect(logSpy.callCount).toBe(1); + }); + + it('should ignore messages not intended for us', async () => { + await client.crypto.prepare([]); + + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Received encrypted message not intended for us (ignoring message)"); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + await client.crypto.processInboundDeviceMessage({ + content: { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2, + ciphertext: { + "wrong_receive_key": { + type: 0, + body: "encrypted", + }, + }, + sender_key: "missing", + }, + type: "m.room.encrypted", + sender: "@bob:example.org", + }); + expect(logSpy.callCount).toBe(1); + }); + + it('should ignore messages with invalid ciphertext', async () => { + await client.crypto.prepare([]); + + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Received invalid encrypted message (ignoring message)"); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + await client.crypto.processInboundDeviceMessage({ + content: { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2, + ciphertext: { + [(client.crypto).deviceCurve25519]: { + type: "test", // !! + body: "encrypted", + }, + }, + sender_key: "missing", + }, + type: "m.room.encrypted", + sender: "@bob:example.org", + }); + await client.crypto.processInboundDeviceMessage({ + content: { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2, + ciphertext: { + [(client.crypto).deviceCurve25519]: { + type: 0, + body: null, // !! + }, + }, + sender_key: "missing", + }, + type: "m.room.encrypted", + sender: "@bob:example.org", + }); + expect(logSpy.callCount).toBe(2); + }); + + it('should ignore messages from unknown devices', async () => { + await client.crypto.prepare([]); + + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Received encrypted message from unknown identity key (ignoring message):"); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + const sender = "@bob:example.org"; + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(sender); + return [STATIC_TEST_DEVICES["NTTFKSVBSI"]]; + }; + + await client.crypto.processInboundDeviceMessage({ + content: { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2, + ciphertext: { + [(client.crypto).deviceCurve25519]: { + type: 0, + body: "encrypted", + }, + }, + sender_key: "missing", + }, + type: "m.room.encrypted", + sender: sender, + }); + expect(logSpy.callCount).toBe(1); + }); + + describe('decryption', () => { + const senderDevice: UserDevice = { + user_id: "@bob:example.org", + device_id: "TEST_DEVICE_FOR_SENDER", + keys: {}, + signatures: {}, + algorithms: [EncryptionAlgorithm.MegolmV1AesSha2, EncryptionAlgorithm.OlmV1Curve25519AesSha2], + unsigned: {}, + }; + const session: IOlmSession = { + sessionId: "", + pickled: "", + lastDecryptionTs: Date.now(), + }; + const altSession: IOlmSession = { // not used in encryption + sessionId: "", + pickled: "", + lastDecryptionTs: Date.now(), + }; + + async function makeMessage(payload: IOlmPayload, inbounds = true): Promise> { + const senderAccount = new (await prepareOlm()).Account(); + const receiverAccount = await (client.crypto).getOlmAccount(); + const session1 = new (await prepareOlm()).Session(); + const session2 = new (await prepareOlm()).Session(); + const session3 = new (await prepareOlm()).Session(); + const session4 = new (await prepareOlm()).Session(); + try { + senderAccount.create(); + + const keys = JSON.parse(senderAccount.identity_keys()); + senderDevice.keys[`${DeviceKeyAlgorithm.Curve25519}:${senderDevice.device_id}`] = keys['curve25519']; + senderDevice.keys[`${DeviceKeyAlgorithm.Ed25519}:${senderDevice.device_id}`] = keys['ed25519']; + + receiverAccount.generate_one_time_keys(2); + const { curve25519: otks } = JSON.parse(receiverAccount.one_time_keys()); + const keyIds = Object.keys(otks); + const key1 = otks[keyIds[keyIds.length - 2]]; + const key2 = otks[keyIds[keyIds.length - 1]]; + receiverAccount.mark_keys_as_published(); + + session1.create_outbound(senderAccount, JSON.parse(receiverAccount.identity_keys())['curve25519'], key1); + session2.create_outbound(senderAccount, JSON.parse(receiverAccount.identity_keys())['curve25519'], key2); + + if (payload.keys?.ed25519 === "populated") { + payload.keys.ed25519 = keys['ed25519']; + } + + const encrypted1 = session1.encrypt(JSON.stringify(payload)); + const encrypted2 = session2.encrypt(JSON.stringify(payload)); + + if (inbounds) { + session3.create_inbound_from(receiverAccount, keys['curve25519'], encrypted1.body); + session4.create_inbound_from(receiverAccount, keys['curve25519'], encrypted2.body); + + session.sessionId = session3.session_id(); + session.pickled = session3.pickle((client.crypto).pickleKey); + altSession.sessionId = session4.session_id(); + altSession.pickled = session4.pickle((client.crypto).pickleKey); + + receiverAccount.remove_one_time_keys(session3); + receiverAccount.remove_one_time_keys(session4); + } + + return { + type: "m.room.encrypted", + content: { + algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2, + sender_key: keys['curve25519'], + ciphertext: { + [JSON.parse(receiverAccount.identity_keys())['curve25519']]: encrypted1, + }, + }, + sender: senderDevice.user_id, + }; + } finally { + senderAccount.free(); + session1.free(); + session2.free(); + session3.free(); + session4.free(); + await (client.crypto).storeAndFreeOlmAccount(receiverAccount); + } + } + + beforeEach(async () => { + await client.crypto.prepare([]); + client.cryptoStore.getUserDevices = async (uid) => { + expect(uid).toEqual(senderDevice.user_id); + return [senderDevice]; + }; + client.cryptoStore.getOlmSessions = async (uid, did) => { + expect(uid).toEqual(senderDevice.user_id); + expect(did).toEqual(senderDevice.device_id); + return [altSession, session]; + }; + + session.pickled = ""; + session.sessionId = ""; + altSession.pickled = ""; + altSession.sessionId = ""; + + LogService.setLogger({ + error: (mod, msg, ...rest) => { + console.error(mod, msg, ...rest); + expect(mod).toEqual("CryptoClient"); + expect(msg).not.toEqual("Non-fatal error while processing to-device message:"); + }, + warn: (...rest) => console.warn(...rest), + } as any as ILogger); + }); + + afterEach(async () => { + LogService.setLogger(new ConsoleLogger()); + }); + + it('should decrypt with a known Olm session', async () => { + const plaintext = { + keys: { + ed25519: "populated", + }, + recipient_keys: { + ed25519: (client.crypto).deviceEd25519, + }, + recipient: await client.getUserId(), + sender: senderDevice.user_id, + content: { + tests: true, + }, + type: "m.room_key", + }; + const deviceMessage = await makeMessage(plaintext); + + const handleSpy = simple.stub().callFn(async (d, dev, m) => { + expect(d).toMatchObject(plaintext); + expect(dev).toMatchObject(senderDevice as any); + expect(m).toMatchObject(deviceMessage as any); + }); + (client.crypto).handleInboundRoomKey = handleSpy; + + await client.crypto.processInboundDeviceMessage(deviceMessage); + expect(handleSpy.callCount).toBe(1); + }); + + it('should decrypt with an unknown but storable Olm session', async () => { + const plaintext = { + keys: { + ed25519: "populated", + }, + recipient_keys: { + ed25519: (client.crypto).deviceEd25519, + }, + recipient: await client.getUserId(), + sender: senderDevice.user_id, + content: { + tests: true, + }, + type: "m.room_key", + }; + const deviceMessage = await makeMessage(plaintext, false); + + const handleSpy = simple.stub().callFn(async (d, dev, m) => { + expect(d).toMatchObject(plaintext); + expect(dev).toMatchObject(senderDevice as any); + expect(m).toMatchObject(deviceMessage as any); + }); + (client.crypto).handleInboundRoomKey = handleSpy; + + const storeSpy = simple.stub().callFn(async (uid, did, s) => { + expect(uid).toEqual(senderDevice.user_id); + expect(did).toEqual(senderDevice.device_id); + expect(s).toMatchObject({ + pickled: expect.any(String), + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + }); + }); + client.cryptoStore.storeOlmSession = storeSpy; + + client.cryptoStore.getOlmSessions = async (uid, did) => { + expect(uid).toEqual(senderDevice.user_id); + expect(did).toEqual(senderDevice.device_id); + return []; + }; + + await client.crypto.processInboundDeviceMessage(deviceMessage); + expect(handleSpy.callCount).toBe(1); + expect(storeSpy.callCount).toBe(2); // once for the inbound session, once after decrypt + }); + + it('should try to create a new Olm session with an unknown type 1 message', async () => { + const plaintext = { + keys: { + ed25519: "populated", + }, + recipient_keys: { + ed25519: (client.crypto).deviceEd25519, + }, + recipient: await client.getUserId(), + sender: senderDevice.user_id, + content: { + tests: true, + }, + type: "m.room_key", + }; + const deviceMessage = await makeMessage(plaintext, false); + const ciphertext = deviceMessage['content']['ciphertext']; + ciphertext[Object.keys(ciphertext)[0]]['type'] = 1; + + const handleSpy = simple.stub().callFn(async (d, dev, m) => { + expect(d).toMatchObject(plaintext); + expect(dev).toMatchObject(senderDevice as any); + expect(m).toMatchObject(deviceMessage as any); + }); + (client.crypto).handleInboundRoomKey = handleSpy; + + const storeSpy = simple.stub().callFn(async (uid, did, s) => { + expect(uid).toEqual(senderDevice.user_id); + expect(did).toEqual(senderDevice.device_id); + expect(s).toMatchObject({ + pickled: expect.any(String), + sessionId: expect.any(String), + lastDecryptionTs: expect.any(Number), + }); + }); + client.cryptoStore.storeOlmSession = storeSpy; + + const establishSpy = simple.stub().callFn(async (d) => { + expect(d).toMatchObject(senderDevice as any); + }); + (client.crypto).establishNewOlmSession = establishSpy; + + client.cryptoStore.getOlmSessions = async (uid, did) => { + expect(uid).toEqual(senderDevice.user_id); + expect(did).toEqual(senderDevice.device_id); + return []; + }; + + await client.crypto.processInboundDeviceMessage(deviceMessage); + expect(handleSpy.callCount).toBe(0); + expect(storeSpy.callCount).toBe(0); + expect(establishSpy.callCount).toBe(1); + }); + + it('should fail decryption if the message validation failed', async () => { + let expectedAddl: any; + let warnCalled = false; + LogService.setLogger({ + error: (mod, msg, ...rest) => { + console.error(mod, msg, ...rest); + expect(mod).toEqual("CryptoClient"); + expect(msg).not.toEqual("Non-fatal error while processing to-device message:"); + }, + warn: (mod, msg, addl, ...rest) => { + console.warn(mod, msg, addl, ...rest); + warnCalled = true; + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Successfully decrypted to-device message, but it failed validation. Ignoring message."); + expect(addl).toMatchObject(expectedAddl); + }, + } as any as ILogger); + + const plainTemplate = { + keys: { + ed25519: "populated", + }, + recipient_keys: { + ed25519: (client.crypto).deviceEd25519, + }, + recipient: await client.getUserId(), + sender: senderDevice.user_id, + content: { + tests: true, + }, + type: "m.room_key", + }; + const addlTemplate = { + wasForUs: true, + wasFromThem: true, + hasType: true, + hasContent: true, + ourKeyMatches: true, + theirKeyMatches: true, + }; + + const makeTestCase = (p: Partial, a: Partial) => { + return [ + JSON.parse(JSON.stringify({ ...plainTemplate, ...p })), + JSON.parse(JSON.stringify({ ...addlTemplate, ...a })), + ]; + }; + + const cases = [ + makeTestCase({ recipient: "@wrong:example.org" }, { wasForUs: false }), + makeTestCase({ sender: "@wrong:example.org" }, { wasFromThem: false }), + makeTestCase({ type: 12 } as any, { hasType: false }), + makeTestCase({ type: null } as any, { hasType: false }), + makeTestCase({ content: 12 } as any, { hasContent: false }), + makeTestCase({ content: null } as any, { hasContent: false }), + makeTestCase({ content: "wrong" } as any, { hasContent: false }), + makeTestCase({ recipient_keys: null }, { ourKeyMatches: false }), + makeTestCase({ recipient_keys: {} } as any, { ourKeyMatches: false }), + makeTestCase({ recipient_keys: { ed25519: "wrong" } }, { ourKeyMatches: false }), + makeTestCase({ keys: null }, { theirKeyMatches: false }), + makeTestCase({ keys: {} } as any, { theirKeyMatches: false }), + makeTestCase({ keys: { ed25519: "wrong" } }, { theirKeyMatches: false }), + ]; + for (let i = 0; i < cases.length; i++) { + const testCase = cases[i]; + const plaintext = testCase[0]; + expectedAddl = testCase[1]; + warnCalled = false; + + console.log(JSON.stringify({ i, testCase }, null, 2)); + + const deviceMessage = await makeMessage(plaintext as any); + + const handleSpy = simple.stub().callFn(async (d, dev, m) => { + expect(d).toMatchObject(plaintext); + expect(dev).toMatchObject(senderDevice as any); + expect(m).toMatchObject(deviceMessage as any); + }); + (client.crypto).handleInboundRoomKey = handleSpy; + + await client.crypto.processInboundDeviceMessage(deviceMessage); + expect(handleSpy.callCount).toBe(0); + expect(warnCalled).toBe(true); + } + }); + }); + }); + + describe('handleInboundRoomKey', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await client.crypto.prepare([]); + }); + + afterEach(async () => { + LogService.setLogger(new ConsoleLogger()); + }); + + it('should validate the incoming key', async () => { + let expectedMessage = ""; + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual(expectedMessage); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + const expectLogCall = () => { + expect(logSpy.callCount).toBe(1); + logSpy.reset(); + }; + + expectedMessage = "Ignoring m.room_key for unknown encryption algorithm"; + await (client.crypto).handleInboundRoomKey({ content: { algorithm: "wrong" } }, null, null); + expectLogCall(); + await (client.crypto).handleInboundRoomKey({ content: null }, null, null); + expectLogCall(); + + expectedMessage = "Ignoring invalid m.room_key"; + await (client.crypto).handleInboundRoomKey({ content: { algorithm: EncryptionAlgorithm.MegolmV1AesSha2 } }, null, null); + expectLogCall(); + await (client.crypto).handleInboundRoomKey({ + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: "test" + } + }, null, null); + expectLogCall(); + await (client.crypto).handleInboundRoomKey({ + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: "test", + session_id: "test" + } + }, null, null); + expectLogCall(); + + expectedMessage = "Ignoring m.room_key message from unexpected sender"; + await (client.crypto).handleInboundRoomKey({ + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: "test", + session_id: "test", + session_key: "test" + } + }, { + device_id: "DEVICE", + keys: { + "curve25519:DEVICE": "key_goes_here", + }, + }, { + content: { + sender_key: "wrong", + }, + }); + expectLogCall(); + }); + + it('should not store known inbound sessions', async () => { + const storeSpy = simple.stub().callFn(async () => { + throw new Error("Called wrongly"); + }); + (client.crypto).storeInboundGroupSession = storeSpy; + + const readSpy = simple.stub().callFn(async (uid, did, rid, sid) => { + expect(uid).toEqual("@user:example.org"); + expect(did).toEqual("DEVICE"); + expect(rid).toEqual("!test:example.org"); + expect(sid).toEqual("test_session"); + return { testing: true } as any; // return value not important + }); + client.cryptoStore.getInboundGroupSession = readSpy; + + await (client.crypto).handleInboundRoomKey({ + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: "!test:example.org", + session_id: "test_session", + session_key: "session_key_goes_here", + }, + }, { + device_id: "DEVICE", + user_id: "@user:example.org", + keys: { + "curve25519:DEVICE": "key_goes_here", + }, + }, { + content: { + sender_key: "key_goes_here", + }, + }); + expect(storeSpy.callCount).toBe(0); + expect(readSpy.callCount).toBe(1); + }); + + it('should store unknown inbound sessions', async () => { + const content = { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: "!test:example.org", + session_id: "test_session", + session_key: "session_key_goes_here", + }; + const storeSpy = simple.stub().callFn(async (c, uid, did) => { + expect(uid).toEqual("@user:example.org"); + expect(did).toEqual("DEVICE"); + expect(c).toMatchObject(content); + }); + (client.crypto).storeInboundGroupSession = storeSpy; + + const readSpy = simple.stub().callFn(async (uid, did, rid, sid) => { + expect(uid).toEqual("@user:example.org"); + expect(did).toEqual("DEVICE"); + expect(rid).toEqual("!test:example.org"); + expect(sid).toEqual("test_session"); + return null; // assume not known + }); + client.cryptoStore.getInboundGroupSession = readSpy; + + await (client.crypto).handleInboundRoomKey({ + content: content, + }, { + device_id: "DEVICE", + user_id: "@user:example.org", + keys: { + "curve25519:DEVICE": "key_goes_here", + }, + }, { + content: { + sender_key: "key_goes_here", + }, + }); + expect(storeSpy.callCount).toBe(1); + expect(readSpy.callCount).toBe(1); + }); + }); + + describe('storeInboundGroupSession', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await client.crypto.prepare([]); + }); + + afterEach(async () => { + LogService.setLogger(new ConsoleLogger()); + }); + + it('should ignore mismatched session IDs', async () => { + const session = new (await prepareOlm()).OutboundGroupSession(); + try { + session.create(); + + const key: IMRoomKey = { + session_key: session.session_key(), + session_id: "wrong", + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: "!room:example.org", + }; + + const storeSpy = simple.stub().callFn(async (s) => { + expect(s).toMatchObject({ + roomId: key.room_id, + sessionId: key.session_id, + senderDeviceId: TEST_DEVICE_ID, + senderUserId: userId, + pickled: expect.any(String), + }); + }); + client.cryptoStore.storeInboundGroupSession = storeSpy; + + const logSpy = simple.stub().callFn((mod, msg) => { + expect(mod).toEqual("CryptoClient"); + expect(msg).toEqual("Ignoring m.room_key with mismatched session_id"); + }); + LogService.setLogger({ warn: logSpy } as any as ILogger); + + await (client.crypto).storeInboundGroupSession(key, userId, TEST_DEVICE_ID); + expect(logSpy.callCount).toBe(1); + expect(storeSpy.callCount).toBe(0); + } finally { + session.free(); + } + }); + + it('should store sessions', async () => { + const session = new (await prepareOlm()).OutboundGroupSession(); + try { + session.create(); + + const key: IMRoomKey = { + session_key: session.session_key(), + session_id: session.session_id(), + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: "!room:example.org", + }; + + const storeSpy = simple.stub().callFn(async (s) => { + expect(s).toMatchObject({ + roomId: key.room_id, + sessionId: key.session_id, + senderDeviceId: TEST_DEVICE_ID, + senderUserId: userId, + pickled: expect.any(String), + }); + }); + client.cryptoStore.storeInboundGroupSession = storeSpy; + + await (client.crypto).storeInboundGroupSession(key, userId, TEST_DEVICE_ID); + expect(storeSpy.callCount).toBe(1); + } finally { + session.free(); + } + }); + }); + + describe('establishNewOlmSession', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await client.crypto.prepare([]); + }); + + it('should force new session creation', async () => { + const session = { + test: true, + }; + + const device = { + user_id: userId, + device_id: TEST_DEVICE_ID, + // rest don't matter + }; + + const genSpy = simple.stub().callFn(async (m, f) => { + expect(m).toMatchObject({[userId]: [TEST_DEVICE_ID]}); + expect(f).toBe(true); + return { + [userId]: { + [TEST_DEVICE_ID]: session, + }, + }; + }); + (client.crypto).getOrCreateOlmSessions = genSpy; + + const sendSpy = simple.stub().callFn(async (d, s, t, c) => { + expect(d).toMatchObject(device); + expect(s).toMatchObject(session); + expect(t).toEqual("m.dummy"); + expect(JSON.stringify(c)).toEqual("{}"); + }); + (client.crypto).encryptAndSendOlmMessage = sendSpy; + + await (client.crypto).establishNewOlmSession(device); + expect(genSpy.callCount).toBe(1); + expect(sendSpy.callCount).toBe(1); + }); + }); + + describe('decryptRoomEvent', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + afterEach(async () => { + LogService.setLogger(new ConsoleLogger()); + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.decryptRoomEvent(null, null); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should fail if the algorithm is not known', async () => { + await client.crypto.prepare([]); + + const event = new EncryptedRoomEvent({ + content: { + algorithm: "wrong", + }, + }); + const roomId = "!room:example.org"; + + try { + await client.crypto.decryptRoomEvent(event, roomId); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Unable to decrypt: Unknown algorithm"); + } + }); + + it('should fail if the sending device is unknown', async () => { + await client.crypto.prepare([]); + + const event = new EncryptedRoomEvent({ + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + sender_key: "sender", + ciphertext: "cipher", + session_id: "session", + device_id: TEST_DEVICE_ID, + }, + sender: userId, + }); + const roomId = "!room:example.org"; + + const getSpy = simple.stub().callFn(async (uid, did) => { + expect(uid).toEqual(userId); + expect(did).toEqual(event.content.device_id); + return null; + }); + client.cryptoStore.getUserDevice = getSpy; + + try { + await client.crypto.decryptRoomEvent(event, roomId); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Unable to decrypt: Unknown device for sender"); + } + + expect(getSpy.callCount).toBe(1); + }); + + it('should fail if the sending device has a key mismatch', async () => { + await client.crypto.prepare([]); + + const event = new EncryptedRoomEvent({ + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + sender_key: "wrong", + ciphertext: "cipher", + session_id: "session", + device_id: TEST_DEVICE_ID, + }, + sender: userId, + }); + const roomId = "!room:example.org"; + + const getSpy = simple.stub().callFn(async (uid, did) => { + expect(uid).toEqual(userId); + expect(did).toEqual(event.content.device_id); + return RECEIVER_DEVICE; + }); + client.cryptoStore.getUserDevice = getSpy; + + try { + await client.crypto.decryptRoomEvent(event, roomId); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Unable to decrypt: Device key mismatch"); + } + + expect(getSpy.callCount).toBe(1); + }); + + it('should fail if the session is unknown', async () => { + await client.crypto.prepare([]); + + const event = new EncryptedRoomEvent({ + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + sender_key: RECEIVER_DEVICE.keys[`${DeviceKeyAlgorithm.Curve25519}:${RECEIVER_DEVICE.device_id}`], + ciphertext: "cipher", + session_id: "test", + device_id: TEST_DEVICE_ID, + }, + sender: userId, + }); + const roomId = "!room:example.org"; + + const getDeviceSpy = simple.stub().callFn(async (uid, did) => { + expect(uid).toEqual(userId); + expect(did).toEqual(event.content.device_id); + return RECEIVER_DEVICE; + }); + client.cryptoStore.getUserDevice = getDeviceSpy; + + const getSessionSpy = simple.stub().callFn(async (uid, did, rid, sid) => { + expect(uid).toEqual(userId); + expect(did).toEqual(event.content.device_id); + expect(rid).toEqual(roomId); + expect(sid).toEqual(event.content.session_id); + return null; + }); + client.cryptoStore.getInboundGroupSession = getSessionSpy; + + try { + await client.crypto.decryptRoomEvent(event, roomId); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Unable to decrypt: Unknown inbound session ID"); + } + + expect(getDeviceSpy.callCount).toBe(1); + expect(getSessionSpy.callCount).toBe(1); + }); + + it('should fail the decryption looks like a replay attack', async () => { + await client.crypto.prepare([]); + + await client.cryptoStore.setUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); + + // Make an encrypted event, and store the outbound keys as inbound + const plainType = "org.example.plain"; + const plainContent = { + tests: true, + hello: "world", + }; + let event: EncryptedRoomEvent; + const roomId = "!room:example.org"; + const outboundSession = new (await prepareOlm()).OutboundGroupSession(); + try { + outboundSession.create(); + await (client.crypto).storeInboundGroupSession({ + room_id: roomId, + session_id: outboundSession.session_id(), + session_key: outboundSession.session_key(), + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + }, RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id); + event = new EncryptedRoomEvent({ + sender: RECEIVER_DEVICE.user_id, + type: "m.room.encrypted", + event_id: "$sent", + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + sender_key: RECEIVER_DEVICE.keys[`${DeviceKeyAlgorithm.Curve25519}:${RECEIVER_DEVICE.device_id}`], + ciphertext: outboundSession.encrypt(JSON.stringify({ + type: plainType, + content: plainContent, + room_id: roomId, + })), + session_id: outboundSession.session_id(), + device_id: RECEIVER_DEVICE.device_id, + }, + }); + } finally { + outboundSession.free(); + } + + const getIndexSpy = simple.stub().callFn(async (rid, sid, idx) => { + expect(rid).toEqual(roomId); + expect(sid).toEqual(event.content.session_id); + expect(idx).toBe(0); + return "$wrong"; + }); + client.cryptoStore.getEventForMessageIndex = getIndexSpy; + + try { + await client.crypto.decryptRoomEvent(event, roomId); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Unable to decrypt: Message replay attack"); + } + + expect(getIndexSpy.callCount).toBe(1); + }); + + it('should succeed at re-decryption (valid replay)', async () => { + await client.crypto.prepare([]); + + await client.cryptoStore.setUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); + + // Make an encrypted event, and store the outbound keys as inbound + const plainType = "org.example.plain"; + const plainContent = { + tests: true, + hello: "world", + }; + let event: EncryptedRoomEvent; + const roomId = "!room:example.org"; + const outboundSession = new (await prepareOlm()).OutboundGroupSession(); + try { + outboundSession.create(); + await (client.crypto).storeInboundGroupSession({ + room_id: roomId, + session_id: outboundSession.session_id(), + session_key: outboundSession.session_key(), + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + }, RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id); + event = new EncryptedRoomEvent({ + sender: RECEIVER_DEVICE.user_id, + type: "m.room.encrypted", + event_id: "$sent", + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + sender_key: RECEIVER_DEVICE.keys[`${DeviceKeyAlgorithm.Curve25519}:${RECEIVER_DEVICE.device_id}`], + ciphertext: outboundSession.encrypt(JSON.stringify({ + type: plainType, + content: plainContent, + room_id: roomId, + })), + session_id: outboundSession.session_id(), + device_id: RECEIVER_DEVICE.device_id, + }, + }); + } finally { + outboundSession.free(); + } + + const getIndexSpy = simple.stub().callFn(async (rid, sid, idx) => { + expect(rid).toEqual(roomId); + expect(sid).toEqual(event.content.session_id); + expect(idx).toBe(0); + return event.eventId; + }); + client.cryptoStore.getEventForMessageIndex = getIndexSpy; + + const setIndexSpy = simple.stub().callFn(async (rid, eid, sid, idx) => { + expect(rid).toEqual(roomId); + expect(eid).toEqual(event.eventId); + expect(sid).toEqual(event.content.session_id); + expect(idx).toBe(0); + }); + client.cryptoStore.setMessageIndexForEvent = setIndexSpy; + + const result = await client.crypto.decryptRoomEvent(event, roomId); + expect(result).toBeDefined(); + expect(result.type).toEqual(plainType); + expect(result.content).toMatchObject(plainContent); + expect(result.raw).toMatchObject(Object.assign({}, event.raw, {type: plainType, content: plainContent})); + expect(getIndexSpy.callCount).toBe(1); + expect(setIndexSpy.callCount).toBe(1); + }); + + it('should succeed at decryption', async () => { + await client.crypto.prepare([]); + + await client.cryptoStore.setUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); + + // Make an encrypted event, and store the outbound keys as inbound + const plainType = "org.example.plain"; + const plainContent = { + tests: true, + hello: "world", + }; + const roomId = "!room:example.org"; + let event: EncryptedRoomEvent; + const outboundSession = new (await prepareOlm()).OutboundGroupSession(); + try { + outboundSession.create(); + await (client.crypto).storeInboundGroupSession({ + room_id: roomId, + session_id: outboundSession.session_id(), + session_key: outboundSession.session_key(), + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + }, RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id); + event = new EncryptedRoomEvent({ + sender: RECEIVER_DEVICE.user_id, + type: "m.room.encrypted", + event_id: "$sent", + content: { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + sender_key: RECEIVER_DEVICE.keys[`${DeviceKeyAlgorithm.Curve25519}:${RECEIVER_DEVICE.device_id}`], + ciphertext: outboundSession.encrypt(JSON.stringify({ + type: plainType, + content: plainContent, + room_id: roomId, + })), + session_id: outboundSession.session_id(), + device_id: RECEIVER_DEVICE.device_id, + }, + }); + } finally { + outboundSession.free(); + } + + const getIndexSpy = simple.stub().callFn(async (rid, sid, idx) => { + expect(rid).toEqual(roomId); + expect(sid).toEqual(event.content.session_id); + expect(idx).toBe(0); + return null; // assume not known + }); + client.cryptoStore.getEventForMessageIndex = getIndexSpy; + + const setIndexSpy = simple.stub().callFn(async (rid, eid, sid, idx) => { + expect(rid).toEqual(roomId); + expect(eid).toEqual(event.eventId); + expect(sid).toEqual(event.content.session_id); + expect(idx).toBe(0); + }); + client.cryptoStore.setMessageIndexForEvent = setIndexSpy; + + const result = await client.crypto.decryptRoomEvent(event, roomId); + expect(result).toBeDefined(); + expect(result.type).toEqual(plainType); + expect(result.content).toMatchObject(plainContent); + expect(result.raw).toMatchObject(Object.assign({}, event.raw, {type: plainType, content: plainContent})); + expect(getIndexSpy.callCount).toBe(1); + expect(setIndexSpy.callCount).toBe(1); + }); }); }); diff --git a/test/models/events/EncryptedRoomEventTest.ts b/test/models/events/EncryptedRoomEventTest.ts new file mode 100644 index 00000000..19a3b57c --- /dev/null +++ b/test/models/events/EncryptedRoomEventTest.ts @@ -0,0 +1,16 @@ +import * as expect from "expect"; +import { createMinimalEvent } from "./EventTest"; +import { EncryptedRoomEvent, RoomEncryptionAlgorithm } from "../../../src"; + +describe("EncryptedRoomEvent", () => { + it("should return the right fields", () => { + const ev = createMinimalEvent(); + ev.content['algorithm'] = RoomEncryptionAlgorithm.MegolmV1AesSha2; + ev.content['rotation_period_ms'] = 12; + ev.content['rotation_period_msgs'] = 14; + const obj = new EncryptedRoomEvent(ev); + + expect(obj.algorithm).toEqual(ev.content['algorithm']); + expect(obj.megolmProperties).toBe(ev.content); // XXX: implementation detail that we know about + }); +}); diff --git a/test/storage/SqliteCryptoStorageProvider.ts b/test/storage/SqliteCryptoStorageProvider.ts index 8fce09d0..63be482a 100644 --- a/test/storage/SqliteCryptoStorageProvider.ts +++ b/test/storage/SqliteCryptoStorageProvider.ts @@ -2,7 +2,7 @@ import * as expect from "expect"; import * as tmp from "tmp"; import { SqliteCryptoStorageProvider } from "../../src/storage/SqliteCryptoStorageProvider"; import { TEST_DEVICE_ID } from "../MatrixClientTest"; -import { EncryptionAlgorithm, IOlmSession } from "../../src"; +import { EncryptionAlgorithm, IInboundGroupSession, IOlmSession } from "../../src"; tmp.setGracefulCleanup(); @@ -454,32 +454,87 @@ describe('SqliteCryptoStorageProvider', () => { const name = tmp.fileSync().name; let store = new SqliteCryptoStorageProvider(name); + const sessionSortFn = (a, b) => a.sessionId < b.sessionId ? -1 : (a.sessionId === b.sessionId ? 0 : 1); + await store.storeOlmSession(userId1, deviceId1, session1); await store.storeOlmSession(userId2, deviceId2, session2); expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session1 as any); expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + expect((await store.getOlmSessions(userId1, deviceId1)).sort(sessionSortFn)).toMatchObject([session1].sort(sessionSortFn)); + expect((await store.getOlmSessions(userId2, deviceId2)).sort(sessionSortFn)).toMatchObject([session2].sort(sessionSortFn)); await store.close(); store = new SqliteCryptoStorageProvider(name); expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session1 as any); expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + expect((await store.getOlmSessions(userId1, deviceId1)).sort(sessionSortFn)).toMatchObject([session1].sort(sessionSortFn)); + expect((await store.getOlmSessions(userId2, deviceId2)).sort(sessionSortFn)).toMatchObject([session2].sort(sessionSortFn)); // insert an updated session for the first user to ensure the lastDecryptionTs logic works await store.storeOlmSession(userId1, deviceId1, session4); expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + expect((await store.getOlmSessions(userId1, deviceId1)).sort(sessionSortFn)).toMatchObject([session1, session4].sort(sessionSortFn)); + expect((await store.getOlmSessions(userId2, deviceId2)).sort(sessionSortFn)).toMatchObject([session2].sort(sessionSortFn)); await store.close(); store = new SqliteCryptoStorageProvider(name); expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + expect((await store.getOlmSessions(userId1, deviceId1)).sort(sessionSortFn)).toMatchObject([session1, session4].sort(sessionSortFn)); + expect((await store.getOlmSessions(userId2, deviceId2)).sort(sessionSortFn)).toMatchObject([session2].sort(sessionSortFn)); // now test that we'll keep session 4 even after inserting session 3 (an older session) await store.storeOlmSession(userId1, deviceId1, session3); expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + expect((await store.getOlmSessions(userId1, deviceId1)).sort(sessionSortFn)).toMatchObject([session1, session4, session3].sort(sessionSortFn)); + expect((await store.getOlmSessions(userId2, deviceId2)).sort(sessionSortFn)).toMatchObject([session2].sort(sessionSortFn)); await store.close(); store = new SqliteCryptoStorageProvider(name); expect(await store.getCurrentOlmSession(userId1, deviceId1)).toMatchObject(session4 as any); expect(await store.getCurrentOlmSession(userId2, deviceId2)).toMatchObject(session2 as any); + expect((await store.getOlmSessions(userId1, deviceId1)).sort(sessionSortFn)).toMatchObject([session1, session4, session3].sort(sessionSortFn)); + expect((await store.getOlmSessions(userId2, deviceId2)).sort(sessionSortFn)).toMatchObject([session2].sort(sessionSortFn)); + + await store.close(); + }); + + it('should store inbound group sessions', async () => { + const session: IInboundGroupSession = { + sessionId: "ID", + roomId: "!room:example.org", + pickled: "pickled_text", + senderDeviceId: "SENDER", + senderUserId: "@sender:example.org", + }; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + expect(await store.getInboundGroupSession(session.senderUserId, session.senderDeviceId, session.roomId, session.sessionId)).toBeFalsy(); + await store.storeInboundGroupSession(session); + expect(await store.getInboundGroupSession(session.senderUserId, session.senderDeviceId, session.roomId, session.sessionId)).toMatchObject(session as any); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getInboundGroupSession(session.senderUserId, session.senderDeviceId, session.roomId, session.sessionId)).toMatchObject(session as any); + + await store.close(); + }); + + it('should store message indices', async () => { + const roomId = "!room:example.org"; + const eventId = "$event"; + const sessionId = "session_id_here"; + const messageIndex = 12; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + expect(await store.getEventForMessageIndex(roomId, sessionId, messageIndex)).toBeFalsy(); + await store.setMessageIndexForEvent(roomId, eventId, sessionId, messageIndex); + expect(await store.getEventForMessageIndex(roomId, sessionId, messageIndex)).toEqual(eventId); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getEventForMessageIndex(roomId, sessionId, messageIndex)).toEqual(eventId); await store.close(); }); From 59e2627647afda5ae3852a528638fd5cdc59ddf9 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 13 Aug 2021 21:01:13 -0600 Subject: [PATCH 18/26] Organize imports --- src/storage/ICryptoStorageProvider.ts | 7 +------ test/MatrixClientTest.ts | 3 ++- test/encryption/CryptoClientTest.ts | 5 +++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index f1237cc0..b74f13cb 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,10 +1,5 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; -import { - IInboundGroupSession, - IOlmSession, - IOutboundGroupSession, - UserDevice, -} from "../models/Crypto"; +import { IInboundGroupSession, IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; /** * A storage provider capable of only providing crypto-related storage. diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index 2a96f657..aff4fd5b 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -14,7 +14,8 @@ import { OTKAlgorithm, OTKCounts, OTKs, - RoomDirectoryLookupResponse, RoomEvent, + RoomDirectoryLookupResponse, + RoomEvent, setRequestFn, } from "../src"; import * as simple from "simple-mock"; diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 2d9d365b..4fb91a71 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -2,7 +2,8 @@ import * as expect from "expect"; import * as simple from "simple-mock"; import { ConsoleLogger, - DeviceKeyAlgorithm, EncryptedRoomEvent, + DeviceKeyAlgorithm, + EncryptedRoomEvent, EncryptionAlgorithm, ILogger, IMRoomKey, @@ -24,7 +25,7 @@ import { prepareOlm, RECEIVER_DEVICE, RECEIVER_OLM_SESSION, - STATIC_OUTBOUND_SESSION, STATIC_PICKLE_KEY + STATIC_OUTBOUND_SESSION, } from "../TestUtils"; import { DeviceTracker } from "../../src/e2ee/DeviceTracker"; import { STATIC_TEST_DEVICES } from "./DeviceTrackerTest"; From f4fecf9b16ea79c83fa0ca9398b0cd6a2a2eb3f2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 15 Aug 2021 17:57:07 -0600 Subject: [PATCH 19/26] Add support for media encryption/decryption --- examples/encryption_bot.ts | 38 +++- examples/static/it-works.png | Bin 0 -> 17496 bytes src/MatrixClient.ts | 6 +- src/b64.ts | 58 +++++++ src/e2ee/CryptoClient.ts | 122 +++++++++++++ src/index.ts | 1 + src/models/events/MessageEvent.ts | 35 +++- test/b64Test.ts | 35 ++++ test/encryption/CryptoClientTest.ts | 257 ++++++++++++++++++++++++++++ 9 files changed, 545 insertions(+), 7 deletions(-) create mode 100644 examples/static/it-works.png create mode 100644 src/b64.ts create mode 100644 test/b64Test.ts diff --git a/examples/encryption_bot.ts b/examples/encryption_bot.ts index a1280b39..636c0023 100644 --- a/examples/encryption_bot.ts +++ b/examples/encryption_bot.ts @@ -1,12 +1,14 @@ import { EncryptionAlgorithm, + FileMessageEventContent, LogLevel, LogService, MatrixClient, MessageEvent, RichConsoleLogger, - SimpleFsStorageProvider + SimpleFsStorageProvider, } from "../src"; import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider"; +import * as fs from "fs"; LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.TRACE); @@ -25,6 +27,7 @@ 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 SqliteCryptoStorageProvider("./examples/storage/encryption_bot.db"); +const worksImage = fs.readFileSync("./examples/static/it-works.png"); const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); @@ -51,14 +54,43 @@ const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); }); } + client.on("room.failed_decryption", async (roomId: string, event: any, e: Error) => { + LogService.error("index", `Failed to decrypt ${roomId} ${event['event_id']} because `, e); + }); + client.on("room.message", async (roomId: string, event: any) => { if (roomId !== encryptedRoomId) return; const message = new MessageEvent(event); - if (message.sender === (await client.getUserId())) { + if (message.sender === (await client.getUserId()) && message.messageType === "m.notice") { // yay, we decrypted our own message. Communicate that back for testing purposes. - return await client.unstableApis.addReactionToEvent(roomId, message.eventId, '🔐'); + const encrypted = await client.crypto.encryptMedia(Buffer.from(worksImage)); + const mxc = await client.uploadContent(encrypted.buffer); + await client.sendMessage(roomId, { + msgtype: "m.image", + body: "it-works.png", + info: { + // XXX: We know these details, so have hardcoded them. + w: 256, + h: 256, + mimetype: "image/png", + size: worksImage.length, + }, + file: { + url: mxc, + ...encrypted.file, + }, + }); + return; + } + + if (message.messageType === "m.image") { + const fileEvent = new MessageEvent(message.raw); + const decrypted = await client.crypto.decryptMedia(fileEvent.content.file); + fs.writeFileSync('./examples/storage/decrypted.png', decrypted); + await client.unstableApis.addReactionToEvent(roomId, fileEvent.eventId, 'Decrypted'); + return; } if (message.messageType !== "m.text") return; diff --git a/examples/static/it-works.png b/examples/static/it-works.png new file mode 100644 index 0000000000000000000000000000000000000000..968ca41aba3b41ae2edde5f7dcb02a7ca494f693 GIT binary patch literal 17496 zcmeIZbzEG{vMxNhYaqeh-Q9u{+&$>v&H#f$fFQvkxRZq75Zv9}T@nZcceiiI`|iEp zea|`je&^nE|J(e23~O~&Jyrd5b*=8zL%52P3>q>4G5`QTlarNH0|1~Pmrwvic*q|E zm#-E80Dhd8h7L&0#DmPq#nHmr4oC*_bOMqA!PXEVcyT6O*PW;#Uh=6j;w|)+2|wPL zr{J%jSKQ__^xx>h8CtSwic`$du>i}<@RI%ePY+}4PhHxTwP`7DMwl&!wCyTyMp zT6-UM?3zW5j1F(ky3V!^w8zZW*@fO(J>FD~oZTO0`PXgBE$=-zB6vOi5NP{+^~%d% zL{vmXGz;PU;w&(W67kU4qA80MOhR|P9rk16=!Qtup~fd=F>Q>qZ(IH1;R!EdjJ^r` z2ET=wn%A#cID|*h&ebP zB}DZ6bM{Y`=Ff?!fpN9l;f=of!=!UteMP=E7n#<=CN3t%exWhh9r@?mXWvim?*dPk zHbTF(W_j#Poqa#xdh80@y-R(Fq0~F5c{A%*w##3fRdtlExX;eHeV;LH%W%cJ%2%y- zWtMu>`4ql3^x)v5y>tL8Ff=bGgZ5}Os=l$2r(f2zap{}kz|sAyj#bD@w6HO&cslO} zU*qhn<3==VZU`KSPl_@1akW3;ho)`&E6|jtQybCId2|r}_uUJ7zxxfx)+gs2*J;kt zh1*)K$RU&oREdvM%BxbZmp#kqak)LhSC+(|u0MCeGo&1HXw%FefQd}0uA)nG1d8uo ztWAhCE`7WZ0?2naya;j*M4nfWXxO(qY31t^(Au5CZ@`$qu>9gmf73(fi%PCe)EMA; zmipt++xIl4z(g52PW%fmBit?yV=t`Fn_wILefm!+YJ-KJ()4!u&E0fHsXx5;MxsvA zaH0R4q~(RiJ*(l$Tr;cXea4JuCBS`KIWwzwlu_-vIa(li^vm}eOPa`fQ&pPS{v652 zBEx!1*CNyY@~1Z22kEz-A2qf0eVfn{-IuMm4c%AluVAK?_)b10DDz)bPpuVQmh3`? zt!%lxcq>cutWKrlIKNplszBb8xHDWS-bT8|2c*&b6&kQuY*E{Y*TTU2iy-K59Cv_o z@Y4^bf(X@)`b_1%mYKsA7o*vc?%VR+Em7^!ub`)>LHv)0>OUWK>sX6wso>AF_T6(E zKG?t<%*iXDGqU+CG&J3Xo*#P0Y$=N#Ya`?VmJk>a!@%a}{Ck#>3Z7XtqbC+2&2t17 zn-wz1({-2Z;(4O36YuJ9>E;ZI?^LJw6C*HamfXHBbU$*=nYH?v1W#Ead#gKUQY22{ zsRK6 z+vxB{pPfaF?oVs_WHYIvJodpBru8EwJ*G;kHQ6P`Q|eV z^H7=PK0FSaFOmGd1<$}87-UFhGSyYpyyXq_} zao^&5Y@krlE^c$QKWpkywd!}_ufuOOASoT?c`_i1+F=>1ds*)|;N=`4vjXls`V>AY)99BoO2lVcx#%Fu zgIY<8rAbraZGq)OYqN{RQO{3$NK7zgr+3#j%Y$({+m&jcXwvz|-xKv*;2ys#rNk5d znRI%IsP8mV1^a=Tfj^X6U(_VSroCZ^@Kk1Bh#};u+i-J2mh5qH1)Z0p>D#2P)xn{{ zi_$IQp`H;F`2?}uWQ7d{H7d*Y!aTu@gp*Ah>pFE5-<{h{GQXc6mS1+|-`sa6$K*}l8V?(Lin$zJ`*@)- zr?C=DDu$^_1T#nXnqz6tV>vX*SSVQHp!5iV89VYOn2u8sUYZ@Oo^~s3)l^1=!gV=k ztRQ-ys|dt}vjm9K;Tg|&t$JbvRO!pyVbB!ajwn+x)5NqXi=Zx2;b!`IkYh42Teaf6 zsdpl3(G;Kryr@jd^d)EnQ*UDFk_m~?;eCdlc2aPV@F0Wl8fJJQ$z(s;r!>S@u)?~` zj?-Rdx6#|P0`4MyGd*{?{@w0=K0tb9)%iy44AXm(7Rg^ze)Ctis6u`55?fl0KiJ>8 zLNyJ6a8u>`J^@PYzEWjKw`CIrnFpF z#$E)<0~HI+`S#AzzC*GmBaIbtbF>u3B|EM7`WrjnM(~go20q&(!Ft9*IVPhW?=K$y zb}`!Py1KY%*~ACvBaFrG#SUywUx-`1xSl_E;m^+8f7REIQlJ0H*X);Ht!*!U183#X?Gc>h0YX0`S-e9#n?_^<+%5MnQc$_ttC>~B zw)*ka1Td7hLiN&T)Fq}o$SHbYUWUBQeIrEm0$@74?^KJ1=#G8##+LqGVY7_Isi|2A zo%^El0V;UJB_MSOyc_bGjU%cuVyTQo(A9Q{Kj0S|eOv{fOpnCp%gT?VH6dZL;#e;k zl!FnGpv;fG4WpMJ{OI2H-x#z0wN zDL0%_z4#Yctdt*;-qcHN37ew*YTos4GeC1&FAV*@7I=D~C!VqPPx=fA8WiDtkVwk@ zda9{>_I5mG^>x~v`qgodSBeqSIB8+{4Wa9WEv~0NnR5RYYVkIEm}W6$%Q_TnB!@T6 zd+so1E~hOS4%ouYnkMep(6_M>^Pa)_F1}^Bt8~1I7xf$@DMzpF4zV=*dQDQQtW(sv zvitg+s;_CHiuWWoEVdg?VJ&+l<8^Jc5_!RQEP=F!g!~iCe6&K%61R>)u&bYZ%L(-b`>Z`sHv-9|RD4S&l1gAfZNC_=2ugS`vo;ejyT7*KY;`Xc>cqWG=8{z8@fF7OfzEX# zR7~vPV)qZS%LcH%+E?_1wk0<#Tj^~ix0WJLE=_MM>S1^MhBO>!Mc9eJP1|CuMm%du z6qt>VCSfV!seCo(^Kt?FY`^HfGsRhnljNq+;-CjJ=9{FS%|A7NOYiB0c49LHhli+@ z{4|_!IFl{gl2rMQr_-sy<7oCvY}9y5rB=b%p@oArk13zd$LD=B!(dMdBgKnl@;N<_ zXo4ytcIKy4X!Wa=qjpnAI<|sN12MvVIvS*F5HGQc*+ELk1{La(Vnu)R61ke@ct^a3 z<%CjNk|=xlOGM9V+@bzK0Pu@abw{lv{^r5`$aYFR5d-of4{?qn`F+PA7ez<8d1Q>4 z&1+BQ>)vBwlG*~voD{W_X{qFdq=BK?@Id|QAjC;pqiUIkW0?=JZ78gs_|EqiHEx8q zGvvzevMHq;K5X0KmdhVVNA7$fA=0W<7qlH%2O%rI$0VK`>Jp`ngilz;BGplXHIE#k zL{ReiIbb7*px_Y==JV0dA&lx3$x2iR<}NDb;%9qDR^dMm z4bRx4g3o6QP4-HC6xmX)0$#|LHr7yrRou+-rFD-u@Jg)MMcRmcXf>YQm)74NSYh|A z9wP@GvBTY%IeUPBn8L#RB194xEswe1zDkg*(Z*GJhNMtnVqyK-sx)fO9VJQ)};7RbT$ zS-%&%WgvBoW>!;sro3H}EF+Ie5>L1GMbUA?#$!KC@r!?U`;7{c)n^UZs59xoICNSp8D$uF{6ajoTtU6@ ziXpLu2t)ASw4r|T)NRhlG`@hv%MXqcAI@!h5dgCs+Eqdih47M`v|a#p3f>nhJ(C;J zn;#YjXZjF6PG0L}OBsqb+>8bGtjvJ}`YlWUOJ1Qu*unLiZxZD-C~%Y!0W_~|m3&~4 zSo2lK1<%MSmtN+k`V!?)FOr{*JJx-se+r&nemBs!q8!wwmPs-d$fRQ2XM-RfQ82^N zzgeL`{p05ZX*uW1<$5=GCtUwJr}VO6%5t_xw{y937`RENas2zVKJg@$+mBzZV*?~< z@GJ|gh1SEevexANd~hQDJ}JsCpniMpLMCiI^r|J-nEAFDy@(B-C~POyGtQz;?%Txr z<+?Tw4*+cb0DsXNYDnB|-0}F@!{X~jzQ-as{C*zmaNFG6QW|IF%NQQH3FWR=e(n1c z{b6L__xW)RS0&Dv(Bas8v|pgo4Epn^&+|y3fjAUErsmGIG+UjQ zWs@jjnLIg$s~>79wJ{EM_;v{Br#@_TT1Bu>!Jf9r^ywfgmq_KiW#XK<$Or6Rj#@{| zDbd_;ifIQG{p^w{E-TPn*W=re_P~QiR>u6SnnK(qm500v%~j}9J9duvAv9Q^*5m4y zz+zUI<@33-j$xhkF%}F_-$`!ed=1p->l$~7B`7FI0X2(3fUfEcneOOB9}7>?h6YNO zcJBx5PA@04`^boIot|9bYS;t_CrS9z*u~1Y4IcTzo4v76gcY@9A%QuhQ>NAQ5B6~( zpl{bT5x8tZHNa!!QpXUd1ryK+#e|ZyaXA9uq%Ea5TziR+8ozDjjYK72q5jo{%_xrZ zO$`PQ>#kO%1xqNivrfz9i;WjL&apmnSUT#{rF*DIj(N7%CgQWW5hx(sFl*IU$Y@33 z;tuWakI8d1S=*teouR{#=p~@2;28$rSa%eAFbg8f=vug&9vYwq7?w66Wky$*v^6$= zPaU{^<>2i8iKH?ivn)rrQ+_xd1A(rlR)f&5=Z8(qe6rhk4OqSlhYk(1DclA?_ej<_ zTuIg67#vBc@DgkOD}Du)&Co&``4;Qfi^K{O2dK4LmA(-DU!zm^a$ABD%#ZBM|ew=2jc2R-X`fAE(a@<9c8XT51|9o5EgosLlqNfmb0S2HmE z#Pva+)YPQPN5sPj4H~?TUpKx|+$bf6X>k?dv5F$%4n2({h~ai}-s>!0kEn#hTj51> znu@`5k48Q5r1E)-w14IG==_TyyGOyyM-CvP8Z_lqQWPmlpuufaz8_q^JykJnf$7$511Wl#6?Q75NZHP89+r$#asG+}z7cfApx zmWdmwzPekIJFAhJREf$CY8`zeRFJj%Q^s zPvFE;!N*<Pde-2go*`~fO35psR4)xkOd?ApVXE|f0#2C9# zF1(aV7A5=X>y(K=3LNDQy54Nb8XPYFsS(=ki!$RaDgI;yIeEq8H=|iK37=Imzb;Al z@QA%_uknx%_@o9u%lge6*tY<(o_CLWfw3LURk?~&#E2Pp&tQ8*u!>V{EK^DOxqBZ; zuW%RoLx0%n0X5sE0=k5W9I)F))+pJ1RFNgP5B;_K01Sd!1VY)h)VIrF>2z#hLQu^E zbPctZadw^+Px|2$^uld#70F6ZkQY}84C%Nu;_Ulc=EX536*|+TpYJ7ksq`uI+Vk?D z`Id86tQz(7Mb{C(4G!q9*u9&?RQzyxD)$Y3+o!7gr)sdD)b7-HFI{)rptutvS7LR(KI+cJzE$0?R|AAP^rld!0iu z&dEAg5<^MC=IpeDRfcer=}ONAFY0wSA?WWDZU^jDTQB|i+ap~OJT<2 zWlhA(&Ml1<6ZbSQoHTsZE-a;zP}_kxE(&urckBma{f>^zT#et&EF4oVPpZ?TiU1%@4?w)vprc;>j+> zr#3*Cdxa10rqV`(d+Bo#al1A5pQ?mbiR_t7Pvb8caExj<^*vlE12nw@Y!kc-MWyF) zKj0qmtjhsx;@^NJ3#7u(ZYFSz?X@B$Yc*JGrK&vyp+Xkc8fGSZw!aJzeMU#l3aWl% z8DEq8NcL-;^lOLimV=003Z}=JFpbjFBw%-pjP6HTZ2FA%IPiF5fJaE4zS_vTm`Fp4 zukYJJ_fL>2mUkp-{7)ttSoM0h{J7ejb@hF$b3GaIr}#XSI0yqSk$sxZOpGZ7*g6l$w7qZMeR2k;u@>K9&u!uL zH;KGXI4||DVxteRO%bv8d2xF%tmUofR7HRVJ8WXi9%((fqPUpZQxiDz$yOyOG2oHU za24TE{t^l$ezwJH5>#b6ZYYdLLJ^{i8PtQ6Lr0cBllpC?rjZCFALQ-GG}%+AMg53KFz-ED4dY3DiS(KmY2vN%nAH${`Ht9!l`@Ja zu;Pkt`#DUab)C5-8@EujsO09)9Yd{S;z58kd}k;a-paPEUO1xl9N9>l0U}$7{mtT_+Led-2<4zN)t6!L z5()%dD}pM0DFugiGZyD9hDly8LP1CT$9;=Z1lE(ZVbZgg*3lW?V|lF{+xjZLd@n-K z#kb3Lmmtwp*HXFrJZo-Uqv*lwu~P@_k%uI++@lUxlQ2Lh%fpsOEtCT%J#CW_4wO(0 zoz~3!TnTl2kt^2y1uSFs&=f?}br=RRsK`l3{OxcC zawL=C8z(H=B}UX^r2kopk(h~sB0g6@7ZcYxk>O>BxHV(-uCdF`D=uqUH3K5JxpzUN zA2bnOSsD@>z`Z_(=^h!8n~-{QZ=`00wb9`$y1L@M+yc`ag{&S~h9st{D2y9}w6I*X z?k@!(N6mRfNx@ky>BSB+cjAVDVRV!yH1x2Xov%Dfh)@yY3i+WFo%}gX)DL&Mq$+Ze=2xml4EFZfuM@$R}LRl3E&rR~E!c{Ep6A z4BWGgwEiY+DLzp=k7Ml-%lIQP>3Km?U* zzy71KRL;BoCpz2pr|?EAd6BeLaVW58zT+U&L!H=I+LXR6X#s}V`$oN1D0K9|N(x|k zQ?uas!cv>1-opdHti>gl!)B!E`dvR*DgbuT3iGcQ{+esc;@5o95- z00h7u2r?l9+uJ#~3V?+ve&Y&2uAiG(Dad}SfNX^+bQD#{Bph9UWLzvR2CGaMAZv?% z4A4LI=UMU}0)e>uJN`du|BbKTvHa#$K+@67?b)fEq%g&EyaMKqX4d8czc0-!*v;7Z z*tnT_OiZ|#x%hd^nN3Z&Oqea$OieiXxj0QZE!h47CFkG@GI1~iK0`skS*#&AY`mOY zCg$ec%xo5X9L!u?7F^6GW~O}1JUsm7>?Z6yX6&XWe}PbTv4+e96T81g^$cYWfnw+2 z=Vj;S0WzDgbMY{9ney^6^Kn8_lOG5KTJW&3vGMcp{DyiS2Z7fra>5iGEPurJyG6y$ z1Z3gpVh@>K)(+;59})I?9KR*zW&+X>OcO}DS=*af0$H6LEPo502SxzW9SF51&$9!9{JR~}8vzLypb5y) zMZ?k2PMG33HnL~P-;;_==+EgQpx|is+wiwC(ENGE{W<5vO)Ob|-wLt*E9L)yq;BQt z;qd!e{x!~jru=s#HAuB{1-W?2{ck$;zvv15k+8B5 zTSphqzxY=NI{$I^M@h1?{!JAb+3zwSU}E+M{jMhNK=a>20O9cuk(rf=gC!7BZ~vTZ ze`~k?PZF6O$j@%d4M|^fGag7HvjaJqO@Ms7%qINYCKeVPX1u&?X8(-t>SzJ-FmVCC zwuIyWWSk+B>-RX5(fyf!^#82yVFi3nE;bH!W=I`m=hk5772xC);N+$FW3MDY&<1#2^)gRA4e3jMDtf6(%m2N5LZKiVJ4cVTMYcSl>ePw|EBA|#lU|{`QO>~KSmewKepUJ z2gth51G1s+rHb)}Y{ubD5V)DuoaAc_@Zv$H zzsAQ4;=VPqu=qSV3bK9-xR01arq-jNM{%}^Ocl)aw-6MFf3VcEFc&YI{;H08dF>rAdZM6hDZ*D_B($a1Z)+|Od!nti{zdBI?ocR;t@# z_csh;NMaz3-WbvtTt+Q29-i5~^=vkicgQNxn8Y6=BT$^<{v{F$$6x z-E{~onSk^$t6p{g-a0D_i`KC`;D-z1V}HMF`P(|@4QYieAze5+NStq^rKLXviBeKZ z{Jtf+mrr6k5xC~Ho6lKKhA@o!c(FHUHke5NJNDl7vEgA2s5gMTd=BeLDyFy1^ALOn z1~mwN9wn#W{q;-5BW4B$aV@Q+KYHc7(fj?Q_S@GWP_=eBBNLOxADR}!cuah3W-G0BXR5D!#B6R}!mHbV>?ddC=2Vx}(+~zFanCH#axIzML`d-a*5)!ICY$ zgHWEBl=LG`hQxa6N={k1ueiK50tyBme9?G+@1u~;qi8e@e0PYjZrNd^q7slR5lTT# z4F_11gr$^@6mxK3`SIgNGy3EHzMZeC4OCby;^Y1Oz2mDXL?on+`)I)Y#y{ zqZ9pt3`(|$p;00vT8d{v*SW5JsRK7R%}WfMX7h2qaqh7LtY{(KJ>sl<`~dmhlPcSnV685wcgs4N0W<+8a+oE%@i0GhKTd(mDzlS z$ylSKS86eqzS+@R1YbL;8^g~eDTeRj!fjUFxU!Ipt6|S@uwy!GuMaSn6C1qev zPTHC4AjI2yXY|FuUL%$?_IFkC zzU9%|!|i?DL(6e85qMON`819F#gF}HUO*d^#NALs$!)a%a zcY%ZsPW}p>o<1?_#_MZOwGT`qYfX^vdwv%7zQ&IxfrCefeUGu-rP|0>d@!s$15V%0X4=pKuTK^s$EP@XQJS9-}HfVHsPa<0?LM28=Y2|iQIaWq<=YqR)%%lL`~us54Z8>5*e=$;~O zH^yBop7p(EwgK?1>BxPNyk`3IbX9?m?OoeB6WAy^Rx(xTnB8y%ulDX2i>8f0bdlCMURkg zYz~b8UvQ2~B%=}b0>InE#^1#s4}K=6793OS*FpF6EJsJdS&YGBzgXb1w=>^c??$SH zed}H7JGhzYG5pCM#FgF_9$sP(0}WQN991FnzT_L5$`BeDOG+CgU%3YRm2DmIg1#PD z;&Jsitk0HjUqA`D*D&_26o zX0;qD&&$(H=+h%9go0`xP8HL|#{i#Mh;(++y|3rval1V?@gt+^uCkc+e;PWN$Pe^B zti%{*EH_8bmBCmapvAY6iTT$45aPNGHpN%mK_J}>rn7b7;!DXU=bEko_-N7;D8qbs z=`6^B4gN(=CbaA>y3%xpg#~5cUAi|09usa?47qobAd;0Rwz&U7Xkc%z0Ayx1&2N3zqHUyyO!7QeMb9}u~T~uSOrqN9^ zVHN|`c7t}jL0Xl>%;>tJ$p;>{!ye1nY1C3u>ac*a8l?x{mFlpsj6wctYJkDorJ5- zv7yIPiI%RPr?TEQdGC~%d(cyUF>UEmAWB2*x~1hS0QhVZFj$)d#15SO+M+VG}K%|=+wS9ivy??luG+t*lK3W;25l|u?%F&qx1{@8 zDD(R+w&?+Ssq!l-X2%GxG*j{JA2W zjX0gVVRn36WUZ#a>~uE3aisEwu6)8T874H;)yHFwWwE?E5#Q1lZDA7Mv$Q%#oq|G$ zki(Tq%qV;SQtTDnYL(V)SiQ~xtxvu*L#y5~iffM#3j}P@eQ7c_%xgYoPlnaF((8m2 zPN8n23ge&K2-Dc;3YlWPeyB**ybpKjl+cXYE?FH(%ob;B z+BGKXFOGhW3yGELz^n@7*HG)oP@$8$KPXw^topn!?aCa71++cR_#7(o@~?(+FQjCTxo}>d?G$V^!z#P#*R2j7tTcIIO$EXT)qbJbqAV)n zOC7?5Z1u{vIY9M9VybbaE6dJnzN2?OFKAS#(s?GfMrmSn8lL=~u*H&D+U@I7Uj>#J z4uw*7b}sWC_-fMV>h2dOC08}rpuLodB-*(n8ZXh^8kP>&__3;DgwcHb?ryuG5wG9t z(0}2b!_z|{fmXd)(EV7Dx7EXvKa=!voE1Bu)E26{_jJh4Z{RDVnmYgE5yQq( z4P$xA?L>^wlBH9{)3Vw48v~+(rACJAC5Me_O^@p$1#yW$qlZCvzFGXZIQxW81R~4J zO3GJ$$R17=d+Bws2(G6|#X~XP7tnrp`fI*l##NNwSH1OE->(wAudvjHx%EX^FDmA{ za2~{=X%ub^NMV`;IGK;&w4K?0)!O%^$a&?q!PUoJ z4ZE$pzP=VKfSZ)VIv?FSfQf z4GHnrD`5Gttc>ph4n*a)^K~($C12^39@!z=KgEy*l0y8=O|I8rm!=1LZ%grB_R6~p z6Bl@Zs!*MgngHV9p`9sIzkr0!MX&g1$Towt(zRg2ARrdd4IB+Y*^=$=4)^GJ2h^il*>D^sC&wBKdMg>IaUqih>;3CjDS7H@+I>=QAj=1ldzGL6 z@r6sL5t16#b;{N1#t%%ut4Al$cKR2ZRc7!K&$^}P2C*0M$@kD@1OSu4FCVBVPD^m; zvM}Tyk{&`(no(TeARkHZ+03l$+8D12Ik4)q&vEgb!jlNPJ5_#2p!H0*Hw(m8Zg7Iq zsYRuu|9;f=)Uu%;m?_ZH^y_d9LqqFSX`r&Q*#olFI|38o@4S+?mIHKhS5|Iao@_+J zIh|4kk*>QenDs#iq7#^fqMvMsVT}g0H4}j!J@0y?&`Gyip^Og=jEC|1jwUi#Ti2rm z)61jHmPh*32?**b;<4_}wiXcl)nCjB`E23$0jteIZ|*Yqvq~+=r~>38+}H;xWr)_y zRvW`MA0Kd}%s(BtLh8Zd2Y}N-P4rrE2`YG({;IFqi9FeAXwc=Mxm3Sle|Dj&lrgAR zdbP7DC!lT3zik6IU~rHKp^dUc-VX(--En6&3gvBL;xVj;hV93X3y*%sIV}LC%pX4( z0uD^`;c1oT;m3rpRB&3nD!+)jzIV~BkA-Tn!+-TvR>|FOA5wX?_ZRHk4BS3meF!pW z?tg&n)BK-)vJ#h1sv9}7U?pV?|NNGh-!Q0TO!o9Nqit}$Q8gxTC|>Co9UovHSh;vm zW{BFo^>7i7?aEIdFmmw4!oz~|9ce`}O#A1WC^(+&k&R6EuC9hH17k9&*er)8vc202 zR29olU%p8>pN%ld%kWt*25ki|6xKp}6% zuZbQ_d&p!grQ>6i{@~pa3~Sr)LH2!c@tJ`9vTkzN8BZHzXwVZ7to&S#R_$xCy{>p1 z;|z7J@0xw@GOnH=OEcRi$o40DnNyD{VA~eUYpB+K>sQ~32h2ua)9B*WZqFhyK&u20 z@qvDbW+h(>`q!`EU;sXTZ6GROdkki8cHwewYU;*yPwUJRvUfRZ-D`ewRGbV6Z4%8| zZLx16YqgkTS$Fi`xICzXNuX&U{f2^UTGnBFVoHOuJ!ZLQG4zr~o{#_}QbYa*x)xXB zJsZB3S|@o)nPKX1Q8~gFB_KLrU56KNf8>uEw%VfGMR>kv+2&{~y__&(QEEGJCy@Sl z(wA{(e0;O*gP}b8Kq!zn)U&+}L+abzrm1;if=M_8`~CQP8bZ!XF@zBqyobYwyBkjC zky?1(_x+LIJ6K!U?yjPHpGpV#^BLEXZJBrXsN15}d8Gw;9O1RfFPpbW_+q)As>Yx( zge7$eN+~1|l|_)6A8JSGYXAb6zIWf;%gPa=i8qdVcln&1x6YuU161?pLs=Nj6fLI% z<_tE#Yb#CZOSDS$qDkrn!v`e7K2N%&zKMZ8J-9@VjC48mUz9HFPF2MKLj{n{-8|di z=3aSV1oHalV#B6ytf)@XMsD0vY<@zRFD;Bvpxb++_VWqPV2LiC zqO^i0&i$W!=OnxXQo*3v&sJzxt0+{|?BfNB8%|X1-K$$8%xg!u+A8loR;?dL7P+yp z1J*dfZ2o@daSQjP?46IESXlcQFhtnPP033TLZbYhN_3Z3TUU4j=Q9T1tk=xdXJ#Gh zPLAtO=*H|_F6pYnWCUyIEjB*r1M$C3X+r;!#WULt#i){o1;<6_(;O@EI(tu0aOxOx z&y`l~W%(wRhA!$|wy?g1Rm%PXCh_d{d8?(hgrI6gXGu*^npTj_^K;HXp}cL>7Qd*N zf3G|@uqyx!?Y<@Noq9ynLu2DtIXZ;(WY2(c74)S{VU$Rsfd%_$JgzX&s83l{k8N&i zXIrUVj`B&z-y;{Q43%Lnsaw2Vk&icGsumu%MbAtWBYC*lb$`*jZZB?$K#IJdvJ+c5 zij#&dFD=I;5jJS%sm^7^t^LT)GF#nhRM~&9ThALkUrIVH8Gml>tP#$(lY^?82-!{h zJ%*66J0k>v7}>Ve7VUTow9|P9jmHTstT=1vine`pw9q93q^8BDrb@YjaRSM?c*n-J zhYKqUb>PRbHn)?vRj|3oWO+xsQbrS*L)z>Ho_YZ~swv!u*6lkN4@YyC|3S`7lLJN=dRz-1y!92Pcr$u>b%7 literal 0 HcmV?d00001 diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index 31c304a4..c027f5a0 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -1428,7 +1428,8 @@ export class MatrixClient extends EventEmitter { } /** - * Uploads data to the homeserver's media repository. + * Uploads data to the homeserver's media repository. Note that this will not automatically encrypt + * media as it cannot determine if the media should be encrypted. * @param {Buffer} data the content to upload. * @param {string} contentType the content type of the file. Defaults to application/octet-stream * @param {string} filename the name of the file. Optional. @@ -1442,7 +1443,8 @@ export class MatrixClient extends EventEmitter { } /** - * Download content from the homeserver's media repository. + * Download content from the homeserver's media repository. Note that this will not automatically decrypt + * media as it cannot determine if the media is encrypted. * @param {string} mxcUrl The MXC URI for the content. * @param {string} allowRemote Indicates to the server that it should not attempt to fetch the * media if it is deemed remote. This is to prevent routing loops where the server contacts itself. diff --git a/src/b64.ts b/src/b64.ts new file mode 100644 index 00000000..2e49e42d --- /dev/null +++ b/src/b64.ts @@ -0,0 +1,58 @@ +/** + * Encodes Base64. + * @category Utilities + * @param {ArrayBuffer | Uint8Array} b The buffer to encode. + * @returns {string} The Base64 string. + */ +export function encodeBase64(b: ArrayBuffer | Uint8Array): string { + return Buffer.from(b).toString('base64'); +} + +/** + * Encodes Unpadded Base64. + * @category Utilities + * @param {ArrayBuffer | Uint8Array} b The buffer to encode. + * @returns {string} The Base64 string. + */ +export function encodeUnpaddedBase64(b: ArrayBuffer | Uint8Array): string { + return encodeBase64(b).replace(/=+/g, ''); +} +/** + * Encodes URL-Safe Unpadded Base64. + * @category Utilities + * @param {ArrayBuffer | Uint8Array} b The buffer to encode. + * @returns {string} The Base64 string. + */ +export function encodeUnpaddedUrlSafeBase64(b: ArrayBuffer | Uint8Array): string { + return encodeUnpaddedBase64(b).replace(/\+/g, '-').replace(/\//g, '_'); +} + +/** + * Decodes Base64. + * @category Utilities + * @param {string} s The Base64 string. + * @returns {Uint8Array} The encoded data as a buffer. + */ +export function decodeBase64(s: string): Uint8Array { + return Buffer.from(s, 'base64'); +} + +/** + * Decodes Unpadded Base64. + * @category Utilities + * @param {string} s The Base64 string. + * @returns {Uint8Array} The encoded data as a buffer. + */ +export function decodeUnpaddedBase64(s: string): Uint8Array { + return decodeBase64(s); // yay, it's the same +} + +/** + * Decodes URL-Safe Unpadded Base64. + * @category Utilities + * @param {string} s The Base64 string. + * @returns {Uint8Array} The encoded data as a buffer. + */ +export function decodeUnpaddedUrlSafeBase64(s: string): Uint8Array { + return decodeUnpaddedBase64(s.replace(/-/g, '+').replace(/_/g, '/')); +} diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 403c76c4..d938d1bf 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -24,6 +24,14 @@ import { DeviceTracker } from "./DeviceTracker"; import { EncryptionEvent } from "../models/events/EncryptionEvent"; import { EncryptedRoomEvent } from "../models/events/EncryptedRoomEvent"; import { RoomEvent } from "../models/events/RoomEvent"; +import { EncryptedFile } from "../models/events/MessageEvent"; +import { + decodeUnpaddedBase64, + decodeUnpaddedUrlSafeBase64, + encodeUnpaddedBase64, + encodeUnpaddedUrlSafeBase64, +} from "../b64"; +import { PassThrough } from "stream"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly @@ -755,4 +763,118 @@ export class CryptoClient { // Share the session immediately await this.encryptAndSendOlmMessage(device, olmSessions[device.user_id][device.device_id], "m.dummy", {}); } + + /** + * Encrypts a file for uploading in a room, returning the encrypted data and information + * to include in a message event (except media URL) for sending. + * @param {Buffer} file The file to encrypt. + * @returns {{buffer: Buffer, file: Omit}} Resolves to the encrypted + * contents and file information. + */ + @requiresReady() + public async encryptMedia(file: Buffer): Promise<{buffer: Buffer, file: Omit}> { + const key = crypto.randomBytes(32); + const iv = new Uint8Array(16); + crypto.randomBytes(8).forEach((v, i) => iv[i] = v); // only fill high side to avoid 64bit overflow + + const cipher = crypto.createCipheriv("aes-256-ctr", key, iv); + + const buffers: Buffer[] = []; + cipher.on('data', b => { + buffers.push(b); + }); + + const stream = new PassThrough(); + stream.pipe(cipher); + stream.end(file); + + const finishPromise = new Promise(resolve => { + cipher.end(() => { + resolve(Buffer.concat(buffers)); + }); + }); + + const cipheredContent = await finishPromise; + + let sha256: string; + const util = new Olm.Utility(); + try { + const arr = new Uint8Array(cipheredContent); + sha256 = util.sha256(arr); + } finally { + util.free(); + } + + return { + buffer: Buffer.from(cipheredContent), + file: { + hashes: { + sha256: sha256, + }, + key: { + alg: "A256CTR", + ext: true, + key_ops: ['encrypt', 'decrypt'], + kty: "oct", + k: encodeUnpaddedUrlSafeBase64(key), + }, + iv: encodeUnpaddedBase64(iv), + v: 'v2', + }, + }; + } + + /** + * Decrypts a previously-uploaded encrypted file, validating the fields along the way. + * @param {EncryptedFile} file The file to decrypt. + * @returns {Promise} Resolves to the decrypted file contents. + */ + public async decryptMedia(file: EncryptedFile): Promise { + if (file.v !== "v2") { + throw new Error("Unknown encrypted file version"); + } + if (file.key?.kty !== "oct" || file.key?.alg !== "A256CTR" || file.key?.ext !== true) { + throw new Error("Improper JWT: Missing or invalid fields"); + } + if (!file.key.key_ops.includes("encrypt") || !file.key.key_ops.includes("decrypt")) { + throw new Error("Missing required key_ops"); + } + if (!file.hashes?.sha256) { + throw new Error("Missing SHA256 hash"); + } + + const key = decodeUnpaddedUrlSafeBase64(file.key.k); + const iv = decodeUnpaddedBase64(file.iv); + const ciphered = (await this.client.downloadContent(file.url)).data; + + let sha256: string; + const util = new Olm.Utility(); + try { + const arr = new Uint8Array(ciphered); + sha256 = util.sha256(arr); + } finally { + util.free(); + } + + if (sha256 !== file.hashes.sha256) { + throw new Error("SHA256 mismatch"); + } + + const decipher = crypto.createDecipheriv("aes-256-ctr", key, iv); + + const buffers: Buffer[] = []; + decipher.on('data', b => { + buffers.push(b); + }); + + const stream = new PassThrough(); + stream.pipe(decipher); + stream.end(ciphered); + + return new Promise(resolve => { + decipher.end(() => { + resolve(Buffer.concat(buffers)); + }); + }); + } } diff --git a/src/index.ts b/src/index.ts index 43f0b95b..b8531394 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,3 +100,4 @@ export * from "./PantalaimonClient"; export * from "./SynchronousMatrixClient"; export * from "./SynapseAdminApis"; export * from "./simple-validation"; +export * from "./b64"; diff --git a/src/models/events/MessageEvent.ts b/src/models/events/MessageEvent.ts index 45441331..6337ceaa 100644 --- a/src/models/events/MessageEvent.ts +++ b/src/models/events/MessageEvent.ts @@ -68,10 +68,15 @@ export interface ThumbnailInfo { */ export interface ThumbnailedFileInfo { /** - * A URL to a thumbnail for the file. + * A URL to a thumbnail for the file, if unencrypted. */ thumbnail_url?: string; + /** + * The encrypted thumbnail file information, if encrypted. + */ + thumbnail_file?: EncryptedFile; + /** * Information about the thumbnail. Optionally included if a thumbnail_url is specified. */ @@ -172,9 +177,35 @@ export interface FileMessageEventContent extends MessageEventContent { info?: FileWithThumbnailInfo; /** - * URL to the file. + * URL to the file, if unencrypted. + */ + url: string; + + /** + * The encrypted file, if encrypted. */ + file: EncryptedFile; +} + +/** + * An encrypted file. + * @category Matrix event contents + * @see MessageEvent + */ +export interface EncryptedFile { url: string; + key: { + kty: "oct"; + key_ops: string[]; + alg: "A256CTR"; + k: string; + ext: true; + }; + iv: string; + hashes: { + sha256: string; + }; + v: "v2"; } /** diff --git a/test/b64Test.ts b/test/b64Test.ts new file mode 100644 index 00000000..e8e3878b --- /dev/null +++ b/test/b64Test.ts @@ -0,0 +1,35 @@ +import * as expect from "expect"; +import { + decodeBase64, + decodeUnpaddedBase64, + decodeUnpaddedUrlSafeBase64, + encodeBase64, + encodeUnpaddedBase64, + encodeUnpaddedUrlSafeBase64, +} from "../src"; + +function sb(s: string): ArrayBuffer { + return Buffer.from(s); +} + +describe('b64', () => { + it('should be symmetrical', () => { + expect(decodeBase64(encodeBase64(sb("test"))).toString()).toBe("test"); + expect(decodeUnpaddedBase64(encodeUnpaddedBase64(sb("test"))).toString()).toBe("test"); + expect(decodeUnpaddedBase64(encodeBase64(sb("test"))).toString()).toBe("test"); + expect(decodeBase64(encodeUnpaddedBase64(sb("test"))).toString()).toBe("test"); + expect(decodeUnpaddedUrlSafeBase64(encodeUnpaddedUrlSafeBase64(sb("test"))).toString()).toBe("test"); + }); + + it('should encode', () => { + expect(encodeBase64(sb("test"))).toBe("dGVzdA=="); + expect(encodeUnpaddedBase64(sb("test"))).toBe("dGVzdA"); + expect(encodeUnpaddedUrlSafeBase64(Buffer.from([901231, 123123]))).toBe("b_M"); + }); + + it('should decode', () => { + expect(decodeBase64("dGVzdA==").toString()).toBe("test"); + expect(decodeUnpaddedBase64("dGVzdA").toString()).toBe("test"); + expect(decodeUnpaddedUrlSafeBase64("b_M").join('')).toBe("111243"); + }); +}); diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 4fb91a71..f7ced907 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -3,6 +3,7 @@ import * as simple from "simple-mock"; import { ConsoleLogger, DeviceKeyAlgorithm, + EncryptedFile, EncryptedRoomEvent, EncryptionAlgorithm, ILogger, @@ -3061,4 +3062,260 @@ describe('CryptoClient', () => { expect(setIndexSpy.callCount).toBe(1); }); }); + + describe('encryptMedia', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + afterEach(async () => { + LogService.setLogger(new ConsoleLogger()); + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.encryptMedia(null); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should encrypt media', async () => { + await client.crypto.prepare([]); + + const inputBuffer = Buffer.from("test"); + const inputStr = inputBuffer.join(''); + + const result = await client.crypto.encryptMedia(inputBuffer); + expect(result).toBeDefined(); + expect(result.buffer).toBeDefined(); + expect(result.buffer.join('')).not.toEqual(inputStr); + expect(result.file).toBeDefined(); + expect(result.file.hashes).toBeDefined(); + expect(result.file.hashes.sha256).not.toEqual("n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg"); + expect(result.file).toMatchObject({ + hashes: { + sha256: expect.any(String), + }, + key: { + alg: "A256CTR", + ext: true, + key_ops: ['encrypt', 'decrypt'], + kty: "oct", + k: expect.any(String), + }, + iv: expect.any(String), + v: "v2", + }); + }); + }); + + describe('decryptMedia', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + // Created from Element Web + const testFileContents = "THIS IS A TEST FILE."; + const mediaFileContents = Buffer.from("eB15hJlkw8WwgYxwY2mu8vS250s=", "base64"); + const testFile: EncryptedFile = { + v: "v2", + key: { + alg: "A256CTR", + ext: true, + k: "l3OtQ3IJzfJa85j2WMsqNu7J--C-I1hzPxFvinR48mM", + key_ops: [ + "encrypt", + "decrypt" + ], + kty: "oct" + }, + iv: "KJQOebQS1wwAAAAAAAAAAA", + hashes: { + sha256: "Qe4YzmVoPaEcLQeZwFZ4iMp/dlgeFph6mi5DmCaCOzg" + }, + url: "mxc://localhost/uiWuISEVWixompuiiYyUoGrx", + }; + + function copyOfTestFile(): EncryptedFile { + return JSON.parse(JSON.stringify(testFile)); + } + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + afterEach(async () => { + LogService.setLogger(new ConsoleLogger()); + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.encryptMedia(null); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should be symmetrical', async () => { + await client.crypto.prepare([]); + + const mxc = "mxc://example.org/test"; + const inputBuffer = Buffer.from("test"); + const encrypted = await client.crypto.encryptMedia(inputBuffer); + + const downloadSpy = simple.stub().callFn(async (u) => { + expect(u).toEqual(mxc); + return {data: encrypted.buffer, contentType: "application/octet-stream"}; + }); + client.downloadContent = downloadSpy; + + const result = await client.crypto.decryptMedia({ + url: mxc, + ...encrypted.file, + }); + expect(result.join('')).toEqual(inputBuffer.join('')); + expect(downloadSpy.callCount).toBe(1); + }); + + it('should fail on unknown or invalid fields', async () => { + await client.crypto.prepare([]); + + try { + const f = copyOfTestFile(); + // @ts-ignore + f.v = "wrong"; + await client.crypto.decryptMedia(f); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Unknown encrypted file version"); + } + + try { + const f = copyOfTestFile(); + // @ts-ignore + f.key.kty = "wrong"; + await client.crypto.decryptMedia(f); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Improper JWT: Missing or invalid fields"); + } + + try { + const f = copyOfTestFile(); + // @ts-ignore + f.key.alg = "wrong"; + await client.crypto.decryptMedia(f); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Improper JWT: Missing or invalid fields"); + } + + try { + const f = copyOfTestFile(); + // @ts-ignore + f.key.ext = "wrong"; + await client.crypto.decryptMedia(f); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Improper JWT: Missing or invalid fields"); + } + + try { + const f = copyOfTestFile(); + // @ts-ignore + f.key.key_ops = ["wrong"]; + await client.crypto.decryptMedia(f); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Missing required key_ops"); + } + + try { + const f = copyOfTestFile(); + // @ts-ignore + f.hashes = {}; + await client.crypto.decryptMedia(f); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Missing SHA256 hash"); + } + }); + + it('should fail on mismatched SHA256 hashes', async () => { + await client.crypto.prepare([]); + + const downloadSpy = simple.stub().callFn(async (u) => { + expect(u).toEqual(testFile.url); + return {data: Buffer.from(mediaFileContents), contentType: "application/octet-stream"}; + }); + client.downloadContent = downloadSpy; + + try { + const f = copyOfTestFile(); + f.hashes.sha256 = "wrong"; + await client.crypto.decryptMedia(f); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("SHA256 mismatch"); + } + + expect(downloadSpy.callCount).toBe(1); + }); + + it('should decrypt', async () => { + await client.crypto.prepare([]); + + const downloadSpy = simple.stub().callFn(async (u) => { + expect(u).toEqual(testFile.url); + return {data: Buffer.from(mediaFileContents), contentType: "application/octet-stream"}; + }); + client.downloadContent = downloadSpy; + + const f = copyOfTestFile(); + const result = await client.crypto.decryptMedia(f); + expect(result.toString()).toEqual(testFileContents); + expect(downloadSpy.callCount).toBe(1); + }); + }); }); From f58d7ea6e24d1db8b9b8009dea4cd97cbff54d0c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 15 Aug 2021 21:27:54 -0600 Subject: [PATCH 20/26] Don't spam logs with buffer contents --- src/http.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/http.ts b/src/http.ts index 55ee401d..9a3c40b5 100644 --- a/src/http.ts +++ b/src/http.ts @@ -80,13 +80,15 @@ export function doHttpRequest(baseUrl: string, method: "GET"|"POST"|"PUT"|"DELET } } + const respIsBuffer = (response.body instanceof Buffer); + // Don't log the body unless we're in debug mode. They can be large. if (LogService.level.includes(LogLevel.TRACE)) { - const redactedBody = redactObjectForLogging(response.body); + const redactedBody = respIsBuffer ? '' : redactObjectForLogging(response.body); LogService.trace("MatrixHttpClient (REQ-" + requestId + " RESP-H" + response.statusCode + ")", redactedBody); } if (response.statusCode < 200 || response.statusCode >= 300) { - const redactedBody = redactObjectForLogging(response.body); + const redactedBody = respIsBuffer ? '' : redactObjectForLogging(response.body); LogService.error("MatrixHttpClient (REQ-" + requestId + ")", redactedBody); reject(response); } else resolve(raw ? response : resBody); From 9cfd907d1673c03ee5c403ded4864c141d27fab4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 15 Aug 2021 21:28:09 -0600 Subject: [PATCH 21/26] Actually use sent_outbound_sessions table --- src/e2ee/CryptoClient.ts | 33 +++++---- test/encryption/CryptoClientTest.ts | 101 +++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index d938d1bf..d55f6879 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -478,6 +478,15 @@ export class CryptoClient { try { session.unpickle(this.pickleKey, currentSession.pickled); + const encrypted = session.encrypt(JSON.stringify({ + type: eventType, + content: content, + room_id: roomId, + })); + + currentSession.pickled = session.pickle(this.pickleKey); + currentSession.usesLeft--; + const neededSessions: Record = {}; for (const userId of Object.keys(devices)) { neededSessions[userId] = devices[userId].map(d => d.device_id); @@ -491,23 +500,19 @@ export class CryptoClient { LogService.warn("CryptoClient", `Unable to send Megolm session to ${userId} ${device.device_id}: No Olm session`); continue; } - await this.encryptAndSendOlmMessage(device, olmSession, "m.room_key", { - algorithm: EncryptionAlgorithm.MegolmV1AesSha2, - room_id: roomId, - session_id: session.session_id(), - session_key: session.session_key(), - }); + const lastSession = await this.client.cryptoStore.getLastSentOutboundGroupSession(userId, device.device_id, roomId); + if (lastSession?.sessionId !== session.session_id() || session.message_index() <= (lastSession?.index ?? Number.MAX_SAFE_INTEGER)) { + await this.encryptAndSendOlmMessage(device, olmSession, "m.room_key", { + algorithm: EncryptionAlgorithm.MegolmV1AesSha2, + room_id: roomId, + session_id: session.session_id(), + session_key: session.session_key(), + }); + await this.client.cryptoStore.storeSentOutboundGroupSession(currentSession, session.message_index(), device); + } } } - const encrypted = session.encrypt(JSON.stringify({ - type: eventType, - content: content, - room_id: roomId, - })); - - currentSession.pickled = session.pickle(this.pickleKey); - currentSession.usesLeft--; await this.client.cryptoStore.storeOutboundGroupSession(currentSession); const body = { diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index f7ced907..84bb7611 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1479,7 +1479,7 @@ describe('CryptoClient', () => { ciphertext: { "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { type: 0, - body: "Awog+jA+wNz5Wnpw5isETy9LFDw0hoao06f7ewAhY0+yRGsSIJS/3l725T7pqoV3FKZY/cPH/2dV8W8yZeIWl1DKpaQlGiAFnYCGBRA+tqaR3SpDqbqtwgz1wzA0TV+Mjvzixbd1IyLgBQMKIAIldXBMsoIngiQkuLAvUYrz6QCFAwPeFb6hKlRKcBlTEAAisAWgrDGnYPaJv4asMwVsbNSXQOxRCE/sB0VZrYKH9OKwbZuP+jqHUPa6mtVBu3Sll2ROWJ94YtPycZXX45B4pT8XMvLL/jE6fH4gXZuheb6Q5iYV0XrHMNuIzyODjzbOzpvi7GXTFvb7YMFRskb2k965vfd9NRTpuUT9eb7vkLoIgCb9gK5WApEuS5/4lOIWHKdhqB1m4ViZ4W+eEo9TzniRvAMCfeX0G+OpCv5X9h1UomZl87Kh/q5ZSluuocWFOgG8sGvyLttl3AR3Vc500+9xc0u7GT6lNvJo9Z1kH1xPcCce4oHWByFgGvdIMHYrB7SFZ/AtbiQDt/BUTgxsLd8gysHqjiiOKblz3iN3kx//f2MCTrjKgWDtmCeTRnb1Z8Rn9hdPbkpX2+yvkrmdMYYXKfQXB6PAY+6gRFqGREFXaKq8n0NPN7mN//sp7CJGmMU+DIyq7cPWcmW7zLTBdyoafn8YkJRqjIVbA271imw77cFvDdU1uWFT14275u7Z0qtOrXZiuDLPQyaARbitv8Cc4VfFB1XwWG0V8+fR3oJvIcCba4Q7ALO6TJqpurETU6eT4BAZBmugWObL2kDxdmuJYWpKvKbPdGhLTfbFFn0Sl1lgNaMrGjDoF+LVx/1Oiq9s0DnKPf9gamGIYr2voiSQvibC5m4UgMKLkiZVbAVs20fSV3TD5XMJYman6Rk8mNHBd+6fXW+C2buXd8WStiZ2/hVNalvV/MJPqdzJDHRz3avjwJryunbO48syLMud0y+6K2e8RJV/974lyfQ6BvJ/C7pN/rY3Rh5F4NtG0pSL9ghBzKuQQvKuVGf7U8L9w52iRQrPso+UhUkn8kpLD6AWklU7o9NenWO7eQLhz33i/A0DnM3ILw0c5XyQrX7/UgIRHkLAeVMHLmYC4IBaY1Y24ToFuVKXdb0", + body: "Awog+jA+wNz5Wnpw5isETy9LFDw0hoao06f7ewAhY0+yRGsSIJS/3l725T7pqoV3FKZY/cPH/2dV8W8yZeIWl1DKpaQlGiAFnYCGBRA+tqaR3SpDqbqtwgz1wzA0TV+Mjvzixbd1IyLgBQMKIAIldXBMsoIngiQkuLAvUYrz6QCFAwPeFb6hKlRKcBlTEAAisAWgrDGnYPaJv4asMwVsbNSXQOxRCE/sB0VZrYKH9OKwbZuP+jqHUPa6mtVBu3Sll2ROWJ94YtPycZXX45B4pT8XMvLL/jE6fH4gXZuheb6Q5iYV0XrHMNuIzyODjzbOzpvi7GXTFvb7YMFRskb2k965vfd9NRTpuUT9eb7vkLoIgCb9gK5WApEuS5/4lOIWHKdhqB1m4ViZ4W+eEo9TzniRvAMCfeX0G+OpCv5X9h1UomZl87Kh/q5ZSluuocWFOgG8sGvyLttl3AR3Vc500+9xc0u7GT6lNvJo9Z1kH1xPcCce4oHWByFgGvdIMHYrB7SFZ/AtbiQDt/BUTgxsLd8gysHqjiiOKblz3iN3kx//f2MCTrjKgWDtmCeTRnb1Z8Rn9hdPbkpX2+yvkrmdMYYXKfQXB6PAY+6gRFqGREFXaKq8n0NPN7mN//sp7CJGmMU+DIyq7cPWcmW7zLTBdyoak0/EBQdCIXabvl9B3kfK32xEvn6BH7kFt1ayXUAGl6W/e8uzdKnkRvmnAT7yG147iKOT4DgW6a+msibvSZ2bOzzUxoMbYrdrX7OCBjS92e6IKDJ9mD8yi5apvcMnwS4AGw2U64hkG83U7lpp55tN2kPxLHpAmauQ51cNOZAt5bVPKOgUHCQD02Z1XgptdBjPOCCLaKDyoUawLDLKb8mWojiPZ+2/c6+ODeybYzCrDA2b681wo0WpvcROL0DuOb+1r1Po7AKy/tKUz2VJXTFGGergopp1XJwf7hMeur95J4hBdaCaMTSqWHvkNaIWrj/AZVFeVEZREKgl5x5DycMP6tzv5dX9M3gAcJcfvcU+ws4kqMyM+RsqI7ztB7tKu1CmQYNemHXH53ExuRz1FhBpgS6T/j2RQswLYLxVRGAgGrvi0FWTI8aBrAjUd6FyzDcanHUP2utinWs", }, }, sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", @@ -1854,6 +1854,105 @@ describe('CryptoClient', () => { device_id: TEST_DEVICE_ID, }); }); + + it('should not spam room keys for multiple calls', async () => { + await client.crypto.prepare([]); + + const deviceMap = { + [RECEIVER_DEVICE.user_id]: [RECEIVER_DEVICE], + }; + const roomId = "!test:example.org"; + + // For this test, force all rooms to be encrypted + client.crypto.isRoomEncrypted = async () => true; + + await client.cryptoStore.storeOlmSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, RECEIVER_OLM_SESSION); + + const getSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return STATIC_OUTBOUND_SESSION; + }); + client.cryptoStore.getCurrentOutboundGroupSession = getSpy; + + const joinedSpy = simple.stub().callFn(async (rid) => { + expect(rid).toEqual(roomId); + return Object.keys(deviceMap); + }); + client.getJoinedRoomMembers = joinedSpy; + + const devicesSpy = simple.stub().callFn(async (uids) => { + expect(uids).toMatchObject(Object.keys(deviceMap)); + return deviceMap; + }); + (client.crypto).deviceTracker.getDevicesFor = devicesSpy; + + // We watch for the to-device messages to make sure we pass through the internal functions correctly + const toDeviceSpy = simple.stub().callFn(async (t, m) => { + expect(t).toEqual("m.room.encrypted"); + expect(m).toMatchObject({ + [RECEIVER_DEVICE.user_id]: { + [RECEIVER_DEVICE.device_id]: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { + type: 0, + body: expect.any(String), + }, + }, + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + }, + }, + }); + }); + client.sendToDevices = toDeviceSpy; + + const result = await client.crypto.encryptRoomEvent(roomId, "org.example.test", { + isTest: true, + hello: "world", + n: 42, + }); + expect(getSpy.callCount).toBe(1); + expect(joinedSpy.callCount).toBe(1); + expect(devicesSpy.callCount).toBe(1); + expect(toDeviceSpy.callCount).toBe(1); + expect(result).toMatchObject({ + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + ciphertext: expect.any(String), + session_id: STATIC_OUTBOUND_SESSION.sessionId, + device_id: TEST_DEVICE_ID, + }); + + const lastSent = await client.cryptoStore.getLastSentOutboundGroupSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, roomId); + expect(lastSent).toMatchObject({ + sessionId: STATIC_OUTBOUND_SESSION.sessionId, + index: expect.any(Number), + }); + + const result2 = await client.crypto.encryptRoomEvent(roomId, "org.example.test", { + isTest: true, + hello: "world", + n: 42, + }); + expect(getSpy.callCount).toBe(2); + expect(joinedSpy.callCount).toBe(2); + expect(devicesSpy.callCount).toBe(2); + expect(toDeviceSpy.callCount).toBe(1); + expect(result2).toMatchObject({ + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", + ciphertext: expect.any(String), + session_id: STATIC_OUTBOUND_SESSION.sessionId, + device_id: TEST_DEVICE_ID, + }); + + const lastSent2 = await client.cryptoStore.getLastSentOutboundGroupSession(RECEIVER_DEVICE.user_id, RECEIVER_DEVICE.device_id, roomId); + expect(lastSent2).toMatchObject({ + sessionId: STATIC_OUTBOUND_SESSION.sessionId, + index: expect.any(Number), + }); + expect(lastSent2.index).toEqual(lastSent.index); + }); }); describe('processInboundDeviceMessage', () => { From 5798099ce1ebbc77b09a9df1f28cb925c8e19212 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 15 Aug 2021 21:29:57 -0600 Subject: [PATCH 22/26] Store outbound group session as soon as possible --- src/e2ee/CryptoClient.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index d55f6879..9b43d517 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -486,6 +486,7 @@ export class CryptoClient { currentSession.pickled = session.pickle(this.pickleKey); currentSession.usesLeft--; + await this.client.cryptoStore.storeOutboundGroupSession(currentSession); const neededSessions: Record = {}; for (const userId of Object.keys(devices)) { @@ -513,8 +514,6 @@ export class CryptoClient { } } - await this.client.cryptoStore.storeOutboundGroupSession(currentSession); - const body = { sender_key: this.deviceCurve25519, ciphertext: encrypted, From 161829b1b8b29dd7542321839088cc780f76e472 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 15 Aug 2021 21:35:05 -0600 Subject: [PATCH 23/26] Fix first message being undecryptable after 9cfd907 --- src/e2ee/CryptoClient.ts | 23 ++++++++++++----------- test/encryption/CryptoClientTest.ts | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 9b43d517..83bdc3d3 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -478,16 +478,6 @@ export class CryptoClient { try { session.unpickle(this.pickleKey, currentSession.pickled); - const encrypted = session.encrypt(JSON.stringify({ - type: eventType, - content: content, - room_id: roomId, - })); - - currentSession.pickled = session.pickle(this.pickleKey); - currentSession.usesLeft--; - await this.client.cryptoStore.storeOutboundGroupSession(currentSession); - const neededSessions: Record = {}; for (const userId of Object.keys(devices)) { neededSessions[userId] = devices[userId].map(d => d.device_id); @@ -502,7 +492,7 @@ export class CryptoClient { continue; } const lastSession = await this.client.cryptoStore.getLastSentOutboundGroupSession(userId, device.device_id, roomId); - if (lastSession?.sessionId !== session.session_id() || session.message_index() <= (lastSession?.index ?? Number.MAX_SAFE_INTEGER)) { + if (lastSession?.sessionId !== session.session_id() || session.message_index() < (lastSession?.index ?? Number.MAX_SAFE_INTEGER)) { await this.encryptAndSendOlmMessage(device, olmSession, "m.room_key", { algorithm: EncryptionAlgorithm.MegolmV1AesSha2, room_id: roomId, @@ -514,6 +504,17 @@ export class CryptoClient { } } + // Encrypt after to avoid UNKNOWN_MESSAGE_INDEX errors on remote end + const encrypted = session.encrypt(JSON.stringify({ + type: eventType, + content: content, + room_id: roomId, + })); + + currentSession.pickled = session.pickle(this.pickleKey); + currentSession.usesLeft--; + await this.client.cryptoStore.storeOutboundGroupSession(currentSession); + const body = { sender_key: this.deviceCurve25519, ciphertext: encrypted, diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 84bb7611..05136a4a 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1479,7 +1479,7 @@ describe('CryptoClient', () => { ciphertext: { "30KcbZc4ZmLxnLu3MraQ9vIrAjwtjR8uYmwCU/sViDE": { type: 0, - body: "Awog+jA+wNz5Wnpw5isETy9LFDw0hoao06f7ewAhY0+yRGsSIJS/3l725T7pqoV3FKZY/cPH/2dV8W8yZeIWl1DKpaQlGiAFnYCGBRA+tqaR3SpDqbqtwgz1wzA0TV+Mjvzixbd1IyLgBQMKIAIldXBMsoIngiQkuLAvUYrz6QCFAwPeFb6hKlRKcBlTEAAisAWgrDGnYPaJv4asMwVsbNSXQOxRCE/sB0VZrYKH9OKwbZuP+jqHUPa6mtVBu3Sll2ROWJ94YtPycZXX45B4pT8XMvLL/jE6fH4gXZuheb6Q5iYV0XrHMNuIzyODjzbOzpvi7GXTFvb7YMFRskb2k965vfd9NRTpuUT9eb7vkLoIgCb9gK5WApEuS5/4lOIWHKdhqB1m4ViZ4W+eEo9TzniRvAMCfeX0G+OpCv5X9h1UomZl87Kh/q5ZSluuocWFOgG8sGvyLttl3AR3Vc500+9xc0u7GT6lNvJo9Z1kH1xPcCce4oHWByFgGvdIMHYrB7SFZ/AtbiQDt/BUTgxsLd8gysHqjiiOKblz3iN3kx//f2MCTrjKgWDtmCeTRnb1Z8Rn9hdPbkpX2+yvkrmdMYYXKfQXB6PAY+6gRFqGREFXaKq8n0NPN7mN//sp7CJGmMU+DIyq7cPWcmW7zLTBdyoak0/EBQdCIXabvl9B3kfK32xEvn6BH7kFt1ayXUAGl6W/e8uzdKnkRvmnAT7yG147iKOT4DgW6a+msibvSZ2bOzzUxoMbYrdrX7OCBjS92e6IKDJ9mD8yi5apvcMnwS4AGw2U64hkG83U7lpp55tN2kPxLHpAmauQ51cNOZAt5bVPKOgUHCQD02Z1XgptdBjPOCCLaKDyoUawLDLKb8mWojiPZ+2/c6+ODeybYzCrDA2b681wo0WpvcROL0DuOb+1r1Po7AKy/tKUz2VJXTFGGergopp1XJwf7hMeur95J4hBdaCaMTSqWHvkNaIWrj/AZVFeVEZREKgl5x5DycMP6tzv5dX9M3gAcJcfvcU+ws4kqMyM+RsqI7ztB7tKu1CmQYNemHXH53ExuRz1FhBpgS6T/j2RQswLYLxVRGAgGrvi0FWTI8aBrAjUd6FyzDcanHUP2utinWs", + body: "Awog+jA+wNz5Wnpw5isETy9LFDw0hoao06f7ewAhY0+yRGsSIJS/3l725T7pqoV3FKZY/cPH/2dV8W8yZeIWl1DKpaQlGiAFnYCGBRA+tqaR3SpDqbqtwgz1wzA0TV+Mjvzixbd1IyLgBQMKIAIldXBMsoIngiQkuLAvUYrz6QCFAwPeFb6hKlRKcBlTEAAisAWgrDGnYPaJv4asMwVsbNSXQOxRCE/sB0VZrYKH9OKwbZuP+jqHUPa6mtVBu3Sll2ROWJ94YtPycZXX45B4pT8XMvLL/jE6fH4gXZuheb6Q5iYV0XrHMNuIzyODjzbOzpvi7GXTFvb7YMFRskb2k965vfd9NRTpuUT9eb7vkLoIgCb9gK5WApEuS5/4lOIWHKdhqB1m4ViZ4W+eEo9TzniRvAMCfeX0G+OpCv5X9h1UomZl87Kh/q5ZSluuocWFOgG8sGvyLttl3AR3Vc500+9xc0u7GT6lNvJo9Z1kH1xPcCce4oHWByFgGvdIMHYrB7SFZ/AtbiQDt/BUTgxsLd8gysHqjiiOKblz3iN3kx//f2MCTrjKgWDtmCeTRnb1Z8Rn9hdPbkpX2+yvkrmdMYYXKfQXB6PAY+6gRFqGREFXaKq8n0NPN7mN//sp7CJGmMU+DIyq7cPWcmW7zLTBdyoafn8YkJRqjIVbA271imw77cFvDdU1uWFT14275u7Z0qtOrXZiuDLPQyaARbitv8Cc4VfFB1XwWG0V8+fR3oJvIcCba4Q7ALO6TJqpurETU6eT4BAZBmugWObL2kDxdmuJYWpKvKbPdGhLTfbFFn0Sl1lgNaMrGjDoF+LVx/1Oiq9s0DnKPf9gamGIYr2voiSQvibC5m4UgMKLkiZVbAVs20fSV3TD5XMJYman6Rk8mNHBd+6fXW+C2buXd8WStiZ2/hVNalvV/MJPqdzJDHRz3avjwJryunbO48syLMud0y+6K2e8RJV/974lyfQ6BvJ/C7pN/rY3Rh5F4NtG0pSL9ghBzKuQQvKuVGf7U8L9w52iRQrPso+UhUkn8kpLD6AWklU7o9NenWO7eQLhz33i/A0DnM3ILw0c5XyQrX7/UgIRHkLAeVMHLmYC4IBaY1Y24ToFuVKXdb0", }, }, sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", From 8e2188d86061898265463eaeeeb1af360a196a96 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 16 Aug 2021 20:20:55 -0600 Subject: [PATCH 24/26] Add support for fallback keys MSC: https://github.com/matrix-org/matrix-doc/pull/2732 --- src/MatrixClient.ts | 34 +++++++++++++++++- src/e2ee/CryptoClient.ts | 32 +++++++++++++++++ src/models/Crypto.ts | 10 ++++++ test/MatrixClientTest.ts | 44 ++++++++++++++++++++++++ test/encryption/CryptoClientTest.ts | 53 +++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/MatrixClient.ts b/src/MatrixClient.ts index c027f5a0..5abdbce7 100644 --- a/src/MatrixClient.ts +++ b/src/MatrixClient.ts @@ -29,11 +29,12 @@ import { DeviceKeyAlgorithm, DeviceKeyLabel, EncryptionAlgorithm, + FallbackKey, MultiUserDeviceListResponse, OTKAlgorithm, OTKClaimResponse, OTKCounts, - OTKs + OTKs, } from "./models/Crypto"; import { requiresCrypto } from "./e2ee/decorators"; import { ICryptoStorageProvider } from "./storage/ICryptoStorageProvider"; @@ -666,6 +667,19 @@ export class MatrixClient extends EventEmitter { this.crypto?.updateCounts(raw['device_one_time_keys_count']); } + let unusedFallbacks: string[] = null; + if (raw['org.matrix.msc2732.device_unused_fallback_key_types']) { + unusedFallbacks = raw['org.matrix.msc2732.device_unused_fallback_key_types']; + } else if (raw['device_unused_fallback_key_types']) { + unusedFallbacks = raw['device_unused_fallback_key_types']; + } + + // XXX: We should be able to detect the presence of the array, but Synapse doesn't tell us about + // feature support if we didn't upload one, so assume we're on a latest version of Synapse at least. + if (!unusedFallbacks?.includes(OTKAlgorithm.Signed)) { + await this.crypto?.updateFallbackKey(); + } + if (raw['device_lists']) { const changed = raw['device_lists']['changed']; const removed = raw['device_lists']['left']; @@ -1732,6 +1746,24 @@ export class MatrixClient extends EventEmitter { .then(r => r['one_time_key_counts']); } + /** + * Uploads a fallback One Time Key to the server for usage. This will replace the existing fallback + * key. + * @param {FallbackKey} fallbackKey The fallback key. + * @returns {Promise} Resolves to the One Time Key counts. + */ + @timedMatrixClientFunctionCall() + @requiresCrypto() + public async uploadFallbackKey(fallbackKey: FallbackKey): Promise { + const keyObj = { + [`${OTKAlgorithm.Signed}:${fallbackKey.keyId}`]: fallbackKey.key, + }; + return this.doRequest("POST", "/_matrix/client/r0/keys/upload", null, { + "org.matrix.msc2732.fallback_keys": keyObj, + "fallback_keys": keyObj, + }).then(r => r['one_time_key_counts']); + } + /** * Gets unverified device lists for the given users. The caller is expected to validate * and verify the device lists, including that the returned devices belong to the claimed users. diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index 83bdc3d3..f81ba634 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -6,6 +6,7 @@ import * as anotherJson from "another-json"; import { DeviceKeyAlgorithm, EncryptionAlgorithm, + FallbackKey, IMegolmEncrypted, IMRoomKey, IOlmEncrypted, @@ -16,6 +17,7 @@ import { OTKCounts, OTKs, Signatures, + SignedCurve25519OTK, UserDevice, } from "../models/Crypto"; import { requiresReady } from "./decorators"; @@ -192,6 +194,36 @@ export class CryptoClient { } } + /** + * Updates the client's fallback key. + * @returns {Promise} Resolves when complete. + */ + @requiresReady() + public async updateFallbackKey(): Promise { + const account = await this.getOlmAccount(); + try { + account.generate_fallback_key(); + + const key = JSON.parse(account.fallback_key()); + const keyId = Object.keys(key[OTKAlgorithm.Unsigned])[0]; + const obj: Partial = { + key: key[OTKAlgorithm.Unsigned][keyId], + fallback: true, + }; + const signatures = await this.sign(obj); + const fallback: FallbackKey = { + keyId: keyId, + key: { + ...obj, + signatures: signatures, + } as SignedCurve25519OTK & {fallback: true}, + }; + await this.client.uploadFallbackKey(fallback); + } finally { + await this.storeAndFreeOlmAccount(account); + } + } + /** * Signs an object using the device keys. * @param {object} obj The object to sign. diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index 7bb86831..e4b6e27b 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -30,6 +30,16 @@ export interface Signatures { export interface SignedCurve25519OTK { key: string; signatures: Signatures; + fallback?: boolean; +} + +/** + * A fallback key. + * @category Models + */ +export interface FallbackKey { + keyId: string; + key: SignedCurve25519OTK & {fallback: true}; } /** diff --git a/test/MatrixClientTest.ts b/test/MatrixClientTest.ts index aff4fd5b..abadf240 100644 --- a/test/MatrixClientTest.ts +++ b/test/MatrixClientTest.ts @@ -2069,6 +2069,9 @@ describe('MatrixClient', () => { const { client } = createTestClient(null, "@user:example.org", true); const syncClient = (client); + // Override to fix test as we aren't testing this here. + client.crypto.updateFallbackKey = async () => null; + const deviceMessage = { type: "m.room.encrypted", content: { @@ -2103,10 +2106,51 @@ describe('MatrixClient', () => { expect(processSpy.callCount).toBe(1); }); + it('should handle fallback key updates', async () => { + const { client } = createTestClient(null, "@user:example.org", true); + const syncClient = (client); + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + await client.crypto.prepare([]); + + const updateSpy = simple.stub(); + client.crypto.updateFallbackKey = updateSpy; + + // Test workaround for https://github.com/matrix-org/synapse/issues/10618 + await syncClient.processSync({ + // no content + }); + expect(updateSpy.callCount).toBe(1); + updateSpy.reset(); + + // Test "no more fallback keys" state + await syncClient.processSync({ + "org.matrix.msc2732.device_unused_fallback_key_types": [], + "device_unused_fallback_key_types": [], + }); + expect(updateSpy.callCount).toBe(1); + updateSpy.reset(); + + // Test "has remaining fallback keys" + await syncClient.processSync({ + "org.matrix.msc2732.device_unused_fallback_key_types": ["signed_curve25519"], + "device_unused_fallback_key_types": ["signed_curve25519"], + }); + expect(updateSpy.callCount).toBe(0); + }); + it('should decrypt timeline events', async () => { const {client: realClient} = await createPreparedCryptoTestClient("@alice:example.org"); const client = (realClient); + // Override to fix test as we aren't testing this here. + realClient.crypto.updateFallbackKey = async () => null; + const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 05136a4a..45e9282f 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -3417,4 +3417,57 @@ describe('CryptoClient', () => { expect(downloadSpy.callCount).toBe(1); }); }); + + describe('updateFallbackKey', () => { + const userId = "@alice:example.org"; + let client: MatrixClient; + + beforeEach(async () => { + const { client: mclient } = createTestClient(null, userId, true); + client = mclient; + + await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); + await feedStaticOlmAccount(client); + client.uploadDeviceKeys = () => Promise.resolve({}); + client.uploadDeviceOneTimeKeys = () => Promise.resolve({}); + client.checkOneTimeKeyCounts = () => Promise.resolve({}); + + // client crypto not prepared for the one test which wants that state + }); + + it('should fail when the crypto has not been prepared', async () => { + try { + await client.crypto.updateFallbackKey(); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("End-to-end encryption has not initialized"); + } + }); + + it('should create new keys', async () => { + await client.crypto.prepare([]); + + const uploadSpy = simple.stub().callFn(async (k) => { + expect(k).toMatchObject({ + keyId: expect.any(String), + key: { + key: expect.any(String), + fallback: true, + signatures: { + [userId]: { + [`${DeviceKeyAlgorithm.Ed25519}:${TEST_DEVICE_ID}`]: expect.any(String), + }, + }, + }, + }); + return null; // return not used + }); + client.uploadFallbackKey = uploadSpy; + + await client.crypto.updateFallbackKey(); + expect(uploadSpy.callCount).toBe(1); + }); + }); }); From 19b09bfb89282a4e037ef933b7d53be963beba71 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 16 Aug 2021 21:00:44 -0600 Subject: [PATCH 25/26] Protect against double device reuse Fixes https://github.com/turt2live/matrix-bot-sdk/issues/131 --- src/e2ee/CryptoClient.ts | 6 +- src/e2ee/DeviceTracker.ts | 6 +- src/models/Crypto.ts | 12 ++++ src/storage/ICryptoStorageProvider.ts | 27 ++++++-- src/storage/SqliteCryptoStorageProvider.ts | 40 +++++++---- test/encryption/CryptoClientTest.ts | 32 ++++----- test/encryption/DeviceTrackerTest.ts | 43 ++++++------ test/storage/SqliteCryptoStorageProvider.ts | 77 +++++++++++++++------ 8 files changed, 160 insertions(+), 83 deletions(-) diff --git a/src/e2ee/CryptoClient.ts b/src/e2ee/CryptoClient.ts index f81ba634..870a9220 100644 --- a/src/e2ee/CryptoClient.ts +++ b/src/e2ee/CryptoClient.ts @@ -332,7 +332,7 @@ export class CryptoClient { LogService.warn("CryptoClient", `Server injected unexpected user: ${userId} - not claiming keys`); continue; } - const storedDevices = await this.client.cryptoStore.getUserDevices(userId); + const storedDevices = await this.client.cryptoStore.getActiveUserDevices(userId); for (const deviceId of Object.keys(claimed.one_time_keys[userId])) { try { if (!otkClaimRequest[userId][deviceId]) { @@ -579,7 +579,7 @@ export class CryptoClient { } const encrypted = event.megolmProperties; - const senderDevice = await this.client.cryptoStore.getUserDevice(event.sender, encrypted.device_id); + const senderDevice = await this.client.cryptoStore.getActiveUserDevice(event.sender, encrypted.device_id); if (!senderDevice) { throw new Error("Unable to decrypt: Unknown device for sender"); } @@ -650,7 +650,7 @@ export class CryptoClient { return; } - const userDevices = await this.client.cryptoStore.getUserDevices(message.sender); + const userDevices = await this.client.cryptoStore.getActiveUserDevices(message.sender); const senderDevice = userDevices.find(d => d.keys[`${DeviceKeyAlgorithm.Curve25519}:${d.device_id}`] === message.content.sender_key); if (!senderDevice) { LogService.warn("CryptoClient", "Received encrypted message from unknown identity key (ignoring message):", message.content.sender_key); diff --git a/src/e2ee/DeviceTracker.ts b/src/e2ee/DeviceTracker.ts index 5239812d..b68c2586 100644 --- a/src/e2ee/DeviceTracker.ts +++ b/src/e2ee/DeviceTracker.ts @@ -30,7 +30,7 @@ export class DeviceTracker { const userDeviceMap: Record = {}; for (const userId of userIds) { - userDeviceMap[userId] = await this.client.cryptoStore.getUserDevices(userId); + userDeviceMap[userId] = await this.client.cryptoStore.getActiveUserDevices(userId); } return userDeviceMap; } @@ -88,7 +88,7 @@ export class DeviceTracker { continue; } - const currentDevices = await this.client.cryptoStore.getUserDevices(userId); + const currentDevices = await this.client.cryptoStore.getAllUserDevices(userId); const existingDevice = currentDevices.find(d => d.device_id === deviceId); if (existingDevice) { @@ -114,7 +114,7 @@ export class DeviceTracker { validated.push(device); } - await this.client.cryptoStore.setUserDevices(userId, validated); + await this.client.cryptoStore.setActiveUserDevices(userId, validated); } } catch (e) { LogService.error("DeviceTracker", "Error updating device lists:", e); diff --git a/src/models/Crypto.ts b/src/models/Crypto.ts index e4b6e27b..f0f7a5e6 100644 --- a/src/models/Crypto.ts +++ b/src/models/Crypto.ts @@ -99,6 +99,18 @@ export interface UserDevice { }; } +/** + * Represents a user's stored device. + * @category Models + */ +export interface StoredUserDevice extends UserDevice { + unsigned: { + [k: string]: any; + device_display_name?: string; + bsdkIsActive: boolean; + }; +} + /** * Device list response for a multi-user query. * @category Models diff --git a/src/storage/ICryptoStorageProvider.ts b/src/storage/ICryptoStorageProvider.ts index b74f13cb..41acd1f1 100644 --- a/src/storage/ICryptoStorageProvider.ts +++ b/src/storage/ICryptoStorageProvider.ts @@ -1,5 +1,11 @@ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; -import { IInboundGroupSession, IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; +import { + IInboundGroupSession, + IOlmSession, + IOutboundGroupSession, + StoredUserDevice, + UserDevice, +} from "../models/Crypto"; /** * A storage provider capable of only providing crypto-related storage. @@ -70,23 +76,32 @@ export interface ICryptoStorageProvider { * @param {UserDevice[]} devices The devices to set for the user. * @returns {Promise} Resolves when complete. */ - setUserDevices(userId: string, devices: UserDevice[]): Promise; + setActiveUserDevices(userId: string, devices: UserDevice[]): Promise; /** - * Gets the user's stored devices. If no devices are stored, an empty array is returned. + * Gets the user's active stored devices. If no devices are stored, an empty array is returned. * @param {string} userId The user ID to get devices for. * @returns {Promise} Resolves to the array of devices for the user. If no * devices are known, the array will be empty. */ - getUserDevices(userId: string): Promise; + getActiveUserDevices(userId: string): Promise; /** - * Gets a user's stored device. If the device is not known or active, falsy is returned. + * Gets a user's active stored device. If the device is not known or active, falsy is returned. * @param {string} userId The user ID. * @param {string} deviceId The device ID. * @returns {Promise} Resolves to the user's device, or falsy if not known. */ - getUserDevice(userId: string, deviceId: string): Promise; + getActiveUserDevice(userId: string, deviceId: string): Promise; + + /** + * Gets all of the user's devices, regardless of whether or not they are active. The active flag + * will be stored in the unsigned portion of the returned device. + * @param {string} userId The user ID to get devices for. + * @returns {Promise} Resolves to the array of devices for the user, or empty + * if no devices are known. + */ + getAllUserDevices(userId: string): Promise; /** * Flags multiple user's device lists as outdated. diff --git a/src/storage/SqliteCryptoStorageProvider.ts b/src/storage/SqliteCryptoStorageProvider.ts index 830c9b99..df8a9d1d 100644 --- a/src/storage/SqliteCryptoStorageProvider.ts +++ b/src/storage/SqliteCryptoStorageProvider.ts @@ -1,7 +1,13 @@ import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import * as Database from "better-sqlite3"; -import { IInboundGroupSession, IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto"; +import { + IInboundGroupSession, + IOlmSession, + IOutboundGroupSession, + StoredUserDevice, + UserDevice, +} from "../models/Crypto"; /** * Sqlite crypto storage provider. Requires `better-sqlite3` package to be installed. @@ -19,7 +25,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { private userDeviceUpsert: Database.Statement; private userDevicesDelete: Database.Statement; private userDevicesSelect: Database.Statement; - private userDeviceSelect: Database.Statement; + private userActiveDevicesSelect: Database.Statement; + private userActiveDeviceSelect: Database.Statement; private obGroupSessionUpsert: Database.Statement; private obGroupSessionSelect: Database.Statement; private obGroupCurrentSessionSelect: Database.Statement; @@ -45,7 +52,7 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.db.exec("CREATE TABLE IF NOT EXISTS kv (name TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)"); this.db.exec("CREATE TABLE IF NOT EXISTS rooms (room_id TEXT PRIMARY KEY NOT NULL, config TEXT NOT NULL)"); this.db.exec("CREATE TABLE IF NOT EXISTS users (user_id TEXT PRIMARY KEY NOT NULL, outdated TINYINT NOT NULL)"); - this.db.exec("CREATE TABLE IF NOT EXISTS user_devices (user_id TEXT NOT NULL, device_id TEXT NOT NULL, device TEXT NOT NULL, PRIMARY KEY (user_id, device_id))"); + this.db.exec("CREATE TABLE IF NOT EXISTS user_devices (user_id TEXT NOT NULL, device_id TEXT NOT NULL, device TEXT NOT NULL, active TINYINT NOT NULL, PRIMARY KEY (user_id, device_id))"); this.db.exec("CREATE TABLE IF NOT EXISTS outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, current TINYINT NOT NULL, pickled TEXT NOT NULL, uses_left NUMBER NOT NULL, expires_ts NUMBER NOT NULL, PRIMARY KEY (session_id, room_id))"); this.db.exec("CREATE TABLE IF NOT EXISTS sent_outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, session_index INT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id, session_index))"); this.db.exec("CREATE TABLE IF NOT EXISTS olm_sessions (user_id TEXT NOT NULL, device_id TEXT NOT NULL, session_id TEXT NOT NULL, last_decryption_ts NUMBER NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (user_id, device_id, session_id))"); @@ -62,10 +69,11 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { this.userUpsert = this.db.prepare("INSERT INTO users (user_id, outdated) VALUES (@userId, @outdated) ON CONFLICT (user_id) DO UPDATE SET outdated = @outdated"); this.userSelect = this.db.prepare("SELECT user_id, outdated FROM users WHERE user_id = @userId"); - this.userDeviceUpsert = this.db.prepare("INSERT INTO user_devices (user_id, device_id, device) VALUES (@userId, @deviceId, @device) ON CONFLICT (user_id, device_id) DO UPDATE SET device = @device"); - this.userDevicesDelete = this.db.prepare("DELETE FROM user_devices WHERE user_id = @userId"); - this.userDevicesSelect = this.db.prepare("SELECT user_id, device_id, device FROM user_devices WHERE user_id = @userId"); - this.userDeviceSelect = this.db.prepare("SELECT user_id, device_id, device FROM user_devices WHERE user_id = @userId AND device_id = @deviceId"); + this.userDeviceUpsert = this.db.prepare("INSERT INTO user_devices (user_id, device_id, device, active) VALUES (@userId, @deviceId, @device, @active) ON CONFLICT (user_id, device_id) DO UPDATE SET device = @device, active = @active"); + this.userDevicesDelete = this.db.prepare("UPDATE user_devices SET active = 0 WHERE user_id = @userId"); + this.userDevicesSelect = this.db.prepare("SELECT user_id, device_id, device, active FROM user_devices WHERE user_id = @userId"); + this.userActiveDevicesSelect = this.db.prepare("SELECT user_id, device_id, device, active FROM user_devices WHERE user_id = @userId AND active = 1"); + this.userActiveDeviceSelect = this.db.prepare("SELECT user_id, device_id, device, active FROM user_devices WHERE user_id = @userId AND device_id = @deviceId AND active = 1"); this.obGroupSessionUpsert = this.db.prepare("INSERT INTO outbound_group_sessions (session_id, room_id, current, pickled, uses_left, expires_ts) VALUES (@sessionId, @roomId, @current, @pickled, @usesLeft, @expiresTs) ON CONFLICT (session_id, room_id) DO UPDATE SET pickled = @pickled, current = @current, uses_left = @usesLeft, expires_ts = @expiresTs"); this.obGroupSessionSelect = this.db.prepare("SELECT session_id, room_id, current, pickled, uses_left, expires_ts FROM outbound_group_sessions WHERE session_id = @sessionId AND room_id = @roomId"); @@ -135,28 +143,34 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider { return val ? JSON.parse(val) : null; } - public async setUserDevices(userId: string, devices: UserDevice[]): Promise { + public async setActiveUserDevices(userId: string, devices: UserDevice[]): Promise { this.db.transaction(() => { this.userUpsert.run({userId: userId, outdated: 0}); this.userDevicesDelete.run({userId: userId}); for (const device of devices) { - this.userDeviceUpsert.run({userId: userId, deviceId: device.device_id, device: JSON.stringify(device)}); + this.userDeviceUpsert.run({userId: userId, deviceId: device.device_id, device: JSON.stringify(device), active: 1}); } })(); } - public async getUserDevices(userId: string): Promise { - const results = this.userDevicesSelect.all({userId: userId}) + public async getActiveUserDevices(userId: string): Promise { + const results = this.userActiveDevicesSelect.all({userId: userId}) if (!results) return []; return results.map(d => JSON.parse(d.device)); } - public async getUserDevice(userId: string, deviceId: string): Promise { - const result = this.userDeviceSelect.get({userId: userId, deviceId: deviceId}); + public async getActiveUserDevice(userId: string, deviceId: string): Promise { + const result = this.userActiveDeviceSelect.get({userId: userId, deviceId: deviceId}); if (!result) return null; return JSON.parse(result.device); } + public async getAllUserDevices(userId: string): Promise { + const results = this.userDevicesSelect.all({userId: userId}) + if (!results) return []; + return results.map(d => Object.assign({}, JSON.parse(d.device), {unsigned: {bsdkIsActive: d.active === 1}})); + } + public async flagUsersOutdated(userIds: string[]): Promise { this.db.transaction(() => { for (const userId of userIds) { diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index 45e9282f..d2d28d8e 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -712,7 +712,7 @@ describe('CryptoClient', () => { throw new Error("Not called appropriately"); }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(targetUserId); return [{ user_id: targetUserId, @@ -800,7 +800,7 @@ describe('CryptoClient', () => { return session; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(claimUserId); return [{ user_id: claimUserId, @@ -892,7 +892,7 @@ describe('CryptoClient', () => { return null; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(claimUserId); return [{ user_id: claimUserId, @@ -978,7 +978,7 @@ describe('CryptoClient', () => { return null; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(claimUserId); return [{ user_id: claimUserId, @@ -1063,7 +1063,7 @@ describe('CryptoClient', () => { return null; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(claimUserId); return [{ user_id: claimUserId, @@ -1135,7 +1135,7 @@ describe('CryptoClient', () => { return null; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(claimUserId); return [{ user_id: claimUserId, @@ -1199,7 +1199,7 @@ describe('CryptoClient', () => { return null; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(claimUserId); return [{ user_id: claimUserId, @@ -1279,7 +1279,7 @@ describe('CryptoClient', () => { }); client.claimOneTimeKeys = claimSpy; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(claimUserId); return [{ user_id: claimUserId, @@ -2147,7 +2147,7 @@ describe('CryptoClient', () => { LogService.setLogger({ warn: logSpy } as any as ILogger); const sender = "@bob:example.org"; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(sender); return [STATIC_TEST_DEVICES["NTTFKSVBSI"]]; }; @@ -2256,7 +2256,7 @@ describe('CryptoClient', () => { beforeEach(async () => { await client.crypto.prepare([]); - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(senderDevice.user_id); return [senderDevice]; }; @@ -2873,7 +2873,7 @@ describe('CryptoClient', () => { expect(did).toEqual(event.content.device_id); return null; }); - client.cryptoStore.getUserDevice = getSpy; + client.cryptoStore.getActiveUserDevice = getSpy; try { await client.crypto.decryptRoomEvent(event, roomId); @@ -2907,7 +2907,7 @@ describe('CryptoClient', () => { expect(did).toEqual(event.content.device_id); return RECEIVER_DEVICE; }); - client.cryptoStore.getUserDevice = getSpy; + client.cryptoStore.getActiveUserDevice = getSpy; try { await client.crypto.decryptRoomEvent(event, roomId); @@ -2941,7 +2941,7 @@ describe('CryptoClient', () => { expect(did).toEqual(event.content.device_id); return RECEIVER_DEVICE; }); - client.cryptoStore.getUserDevice = getDeviceSpy; + client.cryptoStore.getActiveUserDevice = getDeviceSpy; const getSessionSpy = simple.stub().callFn(async (uid, did, rid, sid) => { expect(uid).toEqual(userId); @@ -2968,7 +2968,7 @@ describe('CryptoClient', () => { it('should fail the decryption looks like a replay attack', async () => { await client.crypto.prepare([]); - await client.cryptoStore.setUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); + await client.cryptoStore.setActiveUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); // Make an encrypted event, and store the outbound keys as inbound const plainType = "org.example.plain"; @@ -3030,7 +3030,7 @@ describe('CryptoClient', () => { it('should succeed at re-decryption (valid replay)', async () => { await client.crypto.prepare([]); - await client.cryptoStore.setUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); + await client.cryptoStore.setActiveUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); // Make an encrypted event, and store the outbound keys as inbound const plainType = "org.example.plain"; @@ -3097,7 +3097,7 @@ describe('CryptoClient', () => { it('should succeed at decryption', async () => { await client.crypto.prepare([]); - await client.cryptoStore.setUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); + await client.cryptoStore.setActiveUserDevices(RECEIVER_DEVICE.user_id, [RECEIVER_DEVICE]); // Make an encrypted event, and store the outbound keys as inbound const plainType = "org.example.plain"; diff --git a/test/encryption/DeviceTrackerTest.ts b/test/encryption/DeviceTrackerTest.ts index cd9f9f1b..1aa3d610 100644 --- a/test/encryption/DeviceTrackerTest.ts +++ b/test/encryption/DeviceTrackerTest.ts @@ -61,7 +61,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -74,7 +74,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(2); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -104,7 +104,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -117,7 +117,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(2); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); tracker.updateUsersDeviceLists([STATIC_TEST_USER, "@other:example.org"]).then(() => fetchedOrder.push("----")); @@ -158,7 +158,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -171,7 +171,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -204,7 +204,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -217,7 +217,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -253,7 +253,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -266,7 +266,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -302,7 +302,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -315,7 +315,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -348,7 +348,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -361,7 +361,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -398,7 +398,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -411,7 +411,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -448,7 +448,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getActiveUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return []; }; @@ -461,7 +461,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -488,7 +488,7 @@ describe('DeviceTracker', () => { }; }; - client.cryptoStore.getUserDevices = async (uid) => { + client.cryptoStore.getAllUserDevices = async (uid) => { expect(uid).toEqual(STATIC_TEST_USER); return [{ device_id: "HCDJLDXQHQ", @@ -505,6 +505,7 @@ describe('DeviceTracker', () => { }, unsigned: { device_display_name: "Injected Device", + bsdkIsActive: false, // specifically inactive to test that the code doesn't care }, }]; }; @@ -517,7 +518,7 @@ describe('DeviceTracker', () => { ]); expect(validated.length).toBe(1); }); - client.cryptoStore.setUserDevices = storeSpy; + client.cryptoStore.setActiveUserDevices = storeSpy; const tracker = new DeviceTracker(client); await tracker.updateUsersDeviceLists([STATIC_TEST_USER]); @@ -599,7 +600,7 @@ describe('DeviceTracker', () => { expect(uid).toEqual(targetUserIds[getSpy.callCount - 1]); return deviceMaps[uid]; }); - client.cryptoStore.getUserDevices = getSpy; + client.cryptoStore.getActiveUserDevices = getSpy; const deviceTracker = new DeviceTracker(client); diff --git a/test/storage/SqliteCryptoStorageProvider.ts b/test/storage/SqliteCryptoStorageProvider.ts index 63be482a..a31f6ddd 100644 --- a/test/storage/SqliteCryptoStorageProvider.ts +++ b/test/storage/SqliteCryptoStorageProvider.ts @@ -86,7 +86,7 @@ describe('SqliteCryptoStorageProvider', () => { await store.close(); store = new SqliteCryptoStorageProvider(name); expect(await store.isUserOutdated(userId)).toEqual(true); - await store.setUserDevices(userId, []); + await store.setActiveUserDevices(userId, []); expect(await store.isUserOutdated(userId)).toEqual(false); await store.close(); store = new SqliteCryptoStorageProvider(name); @@ -109,29 +109,29 @@ describe('SqliteCryptoStorageProvider', () => { expect(await store.isUserOutdated(userId1)).toEqual(true); expect(await store.isUserOutdated(userId2)).toEqual(true); - await store.setUserDevices(userId1, devices1); - await store.setUserDevices(userId2, devices2); + await store.setActiveUserDevices(userId1, devices1); + await store.setActiveUserDevices(userId2, devices2); expect(await store.isUserOutdated(userId1)).toEqual(false); expect(await store.isUserOutdated(userId2)).toEqual(false); - expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); - expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); await store.close(); store = new SqliteCryptoStorageProvider(name); expect(await store.isUserOutdated(userId1)).toEqual(false); expect(await store.isUserOutdated(userId2)).toEqual(false); - expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); - expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); await store.flagUsersOutdated([userId1, userId2]); expect(await store.isUserOutdated(userId1)).toEqual(true); expect(await store.isUserOutdated(userId2)).toEqual(true); - expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); - expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); await store.close(); store = new SqliteCryptoStorageProvider(name); expect(await store.isUserOutdated(userId1)).toEqual(true); expect(await store.isUserOutdated(userId2)).toEqual(true); - expect((await store.getUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); - expect((await store.getUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId1)).sort(deviceSortFn)).toEqual(devices1.sort(deviceSortFn)); + expect((await store.getActiveUserDevices(userId2)).sort(deviceSortFn)).toEqual(devices2.sort(deviceSortFn)); await store.close(); }); @@ -408,18 +408,53 @@ describe('SqliteCryptoStorageProvider', () => { const name = tmp.fileSync().name; let store = new SqliteCryptoStorageProvider(name); - await store.setUserDevices(userId1, devices1); - await store.setUserDevices(userId2, devices2); - expect(await store.getUserDevice(userId1, devices1[0].device_id)).toMatchObject(devices1[0]); - expect(await store.getUserDevice(userId1, devices1[1].device_id)).toMatchObject(devices1[1]); - expect(await store.getUserDevice(userId2, devices2[0].device_id)).toMatchObject(devices2[0]); - expect(await store.getUserDevice(userId2, devices2[1].device_id)).toMatchObject(devices2[1]); + await store.setActiveUserDevices(userId1, devices1); + await store.setActiveUserDevices(userId2, devices2); + expect(await store.getActiveUserDevice(userId1, devices1[0].device_id)).toMatchObject(devices1[0]); + expect(await store.getActiveUserDevice(userId1, devices1[1].device_id)).toMatchObject(devices1[1]); + expect(await store.getActiveUserDevice(userId2, devices2[0].device_id)).toMatchObject(devices2[0]); + expect(await store.getActiveUserDevice(userId2, devices2[1].device_id)).toMatchObject(devices2[1]); await store.close(); store = new SqliteCryptoStorageProvider(name); - expect(await store.getUserDevice(userId1, devices1[0].device_id)).toMatchObject(devices1[0]); - expect(await store.getUserDevice(userId1, devices1[1].device_id)).toMatchObject(devices1[1]); - expect(await store.getUserDevice(userId2, devices2[0].device_id)).toMatchObject(devices2[0]); - expect(await store.getUserDevice(userId2, devices2[1].device_id)).toMatchObject(devices2[1]); + expect(await store.getActiveUserDevice(userId1, devices1[0].device_id)).toMatchObject(devices1[0]); + expect(await store.getActiveUserDevice(userId1, devices1[1].device_id)).toMatchObject(devices1[1]); + expect(await store.getActiveUserDevice(userId2, devices2[0].device_id)).toMatchObject(devices2[0]); + expect(await store.getActiveUserDevice(userId2, devices2[1].device_id)).toMatchObject(devices2[1]); + await store.close(); + }); + + it('should track user devices as inactive when considered removed', async () => { + const userId = "@user:example.org"; + // Not real UserDevices, but this is a test. + const devices: any = [{device_id: "one"}, {device_id: "two"}]; + + const name = tmp.fileSync().name; + let store = new SqliteCryptoStorageProvider(name); + + expect((await store.getAllUserDevices(userId)).length).toBe(0); + await store.setActiveUserDevices(userId, [devices[0]]); + expect(await store.getAllUserDevices(userId)).toMatchObject([ + Object.assign({}, devices[0], {unsigned: {bsdkIsActive: true}}), + ]); + expect(await store.getActiveUserDevices(userId)).toMatchObject([devices[0]]); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getAllUserDevices(userId)).toMatchObject([ + Object.assign({}, devices[0], {unsigned: {bsdkIsActive: true}}), + ]); + await store.setActiveUserDevices(userId, [devices[1]]); + expect(await store.getAllUserDevices(userId)).toMatchObject([ + Object.assign({}, devices[0], {unsigned: {bsdkIsActive: false}}), + Object.assign({}, devices[1], {unsigned: {bsdkIsActive: true}}), + ]); + expect(await store.getActiveUserDevices(userId)).toMatchObject([devices[1]]); + await store.close(); + store = new SqliteCryptoStorageProvider(name); + expect(await store.getAllUserDevices(userId)).toMatchObject([ + Object.assign({}, devices[0], {unsigned: {bsdkIsActive: false}}), + Object.assign({}, devices[1], {unsigned: {bsdkIsActive: true}}), + ]); + expect(await store.getActiveUserDevices(userId)).toMatchObject([devices[1]]); await store.close(); }); From e07b0f4675a1b8a9da2c2ac9ca5b44192b659690 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 16 Aug 2021 21:06:20 -0600 Subject: [PATCH 26/26] Test to ensure CryptoClient stores outbound group sessions as inbound too --- test/encryption/CryptoClientTest.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/encryption/CryptoClientTest.ts b/test/encryption/CryptoClientTest.ts index d2d28d8e..84fad4dd 100644 --- a/test/encryption/CryptoClientTest.ts +++ b/test/encryption/CryptoClientTest.ts @@ -1721,6 +1721,15 @@ describe('CryptoClient', () => { }); client.cryptoStore.storeOutboundGroupSession = storeSpy; + const ibStoreSpy = simple.stub().callFn(async (s) => { + expect(s.sessionId).toBeDefined(); + expect(s.roomId).toEqual(roomId); + expect(s.senderUserId).toEqual(userId); + expect(s.senderDeviceId).toEqual(TEST_DEVICE_ID); + expect(s.pickled).toBeDefined(); + }); + client.cryptoStore.storeInboundGroupSession = ibStoreSpy; + const joinedSpy = simple.stub().callFn(async (rid) => { expect(rid).toEqual(roomId); return Object.keys(deviceMap); @@ -1763,6 +1772,7 @@ describe('CryptoClient', () => { expect(devicesSpy.callCount).toBe(1); expect(toDeviceSpy.callCount).toBe(1); expect(storeSpy.callCount).toBe(1); + expect(ibStoreSpy.callCount).toBe(1); expect(result).toMatchObject({ algorithm: "m.megolm.v1.aes-sha2", sender_key: "BZ2AhgUQPramkd0qQ6m6rcIM9cMwNE1fjI784sW3dSM", @@ -1772,10 +1782,6 @@ describe('CryptoClient', () => { }); }); - it.skip('should store created outbound sessions as inbound sessions', async () => { - // TODO: Merge into above test when functionality exists. - }); - it.skip('should get devices for invited members', async () => { // TODO: Support invited members, if history visibility would allow. });