diff --git a/package.json b/package.json index a5d28feef..2243878b2 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", + "@types/bs58": "^4.0.1", "@types/jest": "^26.0.20", "@types/node": "12", "@types/request": "^2.48.5", diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.js b/spec/unit/crypto/outgoing-room-key-requests.spec.js index a1fa62a70..24b9325b4 100644 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.js +++ b/spec/unit/crypto/outgoing-room-key-requests.spec.js @@ -21,25 +21,23 @@ import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store import 'fake-indexeddb/auto'; import 'jest-localstorage-mock'; -import { - ROOM_KEY_REQUEST_STATES, -} from '../../../src/crypto/OutgoingRoomKeyRequestManager'; +import { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager'; const requests = [ { requestId: "A", requestBody: { session_id: "A", room_id: "A" }, - state: ROOM_KEY_REQUEST_STATES.SENT, + state: RoomKeyRequestState.Sent, }, { requestId: "B", requestBody: { session_id: "B", room_id: "B" }, - state: ROOM_KEY_REQUEST_STATES.SENT, + state: RoomKeyRequestState.Sent, }, { requestId: "C", requestBody: { session_id: "C", room_id: "C" }, - state: ROOM_KEY_REQUEST_STATES.UNSENT, + state: RoomKeyRequestState.Unsent, }, ]; @@ -68,9 +66,9 @@ describe.each([ it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", async () => { const r = await - store.getAllOutgoingRoomKeyRequestsByState(ROOM_KEY_REQUEST_STATES.SENT); + store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); expect(r).toHaveLength(2); - requests.filter((e) => e.state == ROOM_KEY_REQUEST_STATES.SENT).forEach((e) => { + requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { expect(r).toContainEqual(e); }); }); @@ -78,10 +76,10 @@ describe.each([ test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => { const r = - await store.getOutgoingRoomKeyRequestByState([ROOM_KEY_REQUEST_STATES.SENT]); + await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); expect(r).not.toBeNull(); expect(r).not.toBeUndefined(); - expect(r.state).toEqual(ROOM_KEY_REQUEST_STATES.SENT); + expect(r.state).toEqual(RoomKeyRequestState.Sent); expect(requests).toContainEqual(r); }); }); diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 2f4164e03..dc39bd730 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,7 +15,7 @@ limitations under the License. */ // this is needed to tell TS about global.Olm -import * as Olm from "@matrix-org/olm"; // eslint-disable-line @typescript-eslint/no-unused-vars +import "@matrix-org/olm"; export {}; @@ -34,6 +34,10 @@ declare global { getDesktopCapturerSources(options: GetSourcesOptions): Promise>; } + interface Crypto { + webkitSubtle?: Window["crypto"]["subtle"]; + } + interface MediaDevices { // This is experimental and types don't know about it yet // https://github.com/microsoft/TypeScript/issues/33232 diff --git a/src/client.ts b/src/client.ts index 595ca1b6d..a0b8cb9e2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -47,7 +47,7 @@ import { PREFIX_UNSTABLE, retryNetworkOperation, } from "./http-api"; -import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, isCryptoAvailable } from './crypto'; +import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, IMegolmSessionData, isCryptoAvailable } from './crypto'; import { DeviceInfo, IDevice } from "./crypto/deviceinfo"; import { decodeRecoveryKey } from './crypto/recoverykey'; import { keyFromAuthData } from './crypto/key_passphrase'; @@ -59,7 +59,7 @@ import { IKeyBackupPrepareOpts, IKeyBackupRestoreOpts, IKeyBackupRestoreResult, - IKeyBackupVersion, + IKeyBackupInfo, } from "./crypto/keybackup"; import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; import type Request from "request"; @@ -114,8 +114,9 @@ import url from "url"; import { randomString } from "./randomstring"; import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; -import { BackupManager, IKeyBackupCheck, TrustInfo } from "./crypto/backup"; +import { BackupManager, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; +import { ISignatures } from "./@types/signed"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; export type SessionStore = WebStorageSessionStore; @@ -375,6 +376,39 @@ interface ICapabilities { "m.room_versions"?: IRoomVersionsCapability; } +/* eslint-disable camelcase */ +export interface ICrossSigningKey { + keys: { [algorithm: string]: string }; + signatures?: ISignatures; + usage: string[]; + user_id: string; +} + +enum CrossSigningKeyType { + MasterKey = "master_key", + SelfSigningKey = "self_signing_key", + UserSigningKey = "user_signing_key", +} + +export type CrossSigningKeys = Record; + +export interface ISignedKey { + keys: Record; + signatures: ISignatures; + user_id: string; + algorithms: string[]; + device_id: string; +} +/* eslint-enable camelcase */ + +export type KeySignatures = Record>; +interface IUploadKeySignaturesResponse { + failures: Record>; +} + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used @@ -2062,7 +2096,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} a promise which resolves when the keys * have been imported */ - public importRoomKeys(keys: any[], opts: IImportRoomKeysOpts): Promise { + public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2086,7 +2120,7 @@ export class MatrixClient extends EventEmitter { * Get information about the current key backup. * @returns {Promise} Information object from API or null */ - public getKeyBackupVersion(): Promise { + public getKeyBackupVersion(): Promise { return this.http.authedRequest( undefined, "GET", "/room_keys/version", undefined, undefined, { prefix: PREFIX_UNSTABLE }, @@ -2120,7 +2154,7 @@ export class MatrixClient extends EventEmitter { * ] * } */ - public isKeyBackupTrusted(info: IKeyBackupVersion): Promise { + public isKeyBackupTrusted(info: IKeyBackupInfo): Promise { return this.crypto.backupManager.isKeyBackupTrusted(info); } @@ -2143,7 +2177,7 @@ export class MatrixClient extends EventEmitter { * @param {object} info Backup information object as returned by getKeyBackupVersion * @returns {Promise} Resolves when complete. */ - public enableKeyBackup(info: IKeyBackupVersion): Promise { + public enableKeyBackup(info: IKeyBackupInfo): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2180,7 +2214,7 @@ export class MatrixClient extends EventEmitter { public async prepareKeyBackupVersion( password: string, opts: IKeyBackupPrepareOpts = { secureSecretStorage: false }, - ): Promise { + ): Promise> { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2198,7 +2232,7 @@ export class MatrixClient extends EventEmitter { algorithm, auth_data, recovery_key, - } as any; // TODO: Types + }; } /** @@ -2219,7 +2253,7 @@ export class MatrixClient extends EventEmitter { * @returns {Promise} Object with 'version' param indicating the version created */ // TODO: Fix types - public async createKeyBackupVersion(info: IKeyBackupVersion): Promise { + public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2313,7 +2347,7 @@ export class MatrixClient extends EventEmitter { * Back up session keys to the homeserver. * @param {string} roomId ID of the room that the keys are for Optional. * @param {string} sessionId ID of the session that the keys are for Optional. - * @param {integer} version backup version Optional. + * @param {number} version backup version Optional. * @param {object} data Object keys to send * @return {Promise} a promise that will resolve when the keys * are uploaded @@ -2375,7 +2409,7 @@ export class MatrixClient extends EventEmitter { * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @return {Promise} key backup key */ - public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupVersion): Promise { + public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise { return keyFromAuthData(backupInfo.auth_data, password); } @@ -2410,7 +2444,7 @@ export class MatrixClient extends EventEmitter { password: string, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts: IKeyBackupRestoreOpts, ): Promise { const privKey = await keyFromAuthData(backupInfo.auth_data, password); @@ -2434,7 +2468,7 @@ export class MatrixClient extends EventEmitter { */ // TODO: Types public async restoreKeyBackupWithSecretStorage( - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, targetRoomId?: string, targetSessionId?: string, opts?: IKeyBackupRestoreOpts, @@ -2474,20 +2508,18 @@ export class MatrixClient extends EventEmitter { recoveryKey: string, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts: IKeyBackupRestoreOpts, ): Promise { const privKey = decodeRecoveryKey(recoveryKey); - return this.restoreKeyBackup( - privKey, targetRoomId, targetSessionId, backupInfo, opts, - ); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); } // TODO: Types public async restoreKeyBackupWithCache( targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts?: IKeyBackupRestoreOpts, ): Promise { const privKey = await this.crypto.getSessionBackupPrivateKey(); @@ -2498,10 +2530,10 @@ export class MatrixClient extends EventEmitter { } private async restoreKeyBackup( - privKey: Uint8Array, + privKey: ArrayLike, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts?: IKeyBackupRestoreOpts, ): Promise { const cacheCompleteCallback = opts?.cacheCompleteCallback; @@ -7092,7 +7124,7 @@ export class MatrixClient extends EventEmitter { return this.http.authedRequest(callback, "POST", "/keys/upload", undefined, content); } - public uploadKeySignatures(content: any): Promise { // TODO: Types + public uploadKeySignatures(content: KeySignatures): Promise { return this.http.authedRequest( undefined, "POST", '/keys/signatures/upload', undefined, content, { @@ -7191,7 +7223,7 @@ export class MatrixClient extends EventEmitter { return this.http.authedRequest(undefined, "GET", path, qps, undefined); } - public uploadDeviceSigningKeys(auth: any, keys: any): Promise { // TODO: Lots of types + public uploadDeviceSigningKeys(auth: any, keys: CrossSigningKeys): Promise<{}> { // TODO: types const data = Object.assign({}, keys); if (auth) Object.assign(data, { auth }); return this.http.authedRequest( @@ -7603,7 +7635,11 @@ export class MatrixClient extends EventEmitter { * supplied. * @return {Promise} Resolves to the result object */ - public sendToDevice(eventType: string, contentMap: any, txnId?: string): Promise { // TODO: Types + public sendToDevice( + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: Record; } }, + txnId?: string, + ): Promise<{}> { const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", { $eventType: eventType, $txnId: txnId ? txnId : this.makeTxnId(), diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 2d983c2e5..720a3f5bd 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -28,26 +28,27 @@ import { decryptAES, encryptAES } from './aes'; import { PkSigning } from "@matrix-org/olm"; import { DeviceInfo } from "./deviceinfo"; import { SecretStorage } from "./SecretStorage"; -import { CryptoStore, MatrixClient } from "../client"; +import { CryptoStore, ICrossSigningKey, ISignedKey, MatrixClient } from "../client"; import { OlmDevice } from "./OlmDevice"; import { ICryptoCallbacks } from "../matrix"; +import { ISignatures } from "../@types/signed"; const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; -function publicKeyFromKeyInfo(keyInfo: any): any { // TODO types +function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string { // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } // We assume only a single key, and we want the bare form without type // prefix, so we select the values. return Object.values(keyInfo.keys)[0]; } -interface ICacheCallbacks { +export interface ICacheCallbacks { getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise; storeCrossSigningKeyCache?(type: string, key: Uint8Array): Promise; } export class CrossSigningInfo extends EventEmitter { - public keys: Record = {}; // TODO types + public keys: Record = {}; public firstUse = true; // This tracks whether we've ever verified this user with any identity. // When you verify a user, any devices online at the time that receive @@ -296,7 +297,7 @@ export class CrossSigningInfo extends EventEmitter { } const privateKeys: Record = {}; - const keys: Record = {}; + const keys: Record = {}; // TODO types let masterSigning; let masterPub; @@ -368,8 +369,8 @@ export class CrossSigningInfo extends EventEmitter { this.keys = {}; } - public setKeys(keys: Record): void { - const signingKeys: Record = {}; + public setKeys(keys: Record): void { + const signingKeys: Record = {}; if (keys.master) { if (keys.master.user_id !== this.userId) { const error = "Mismatched user ID " + keys.master.user_id + @@ -448,7 +449,7 @@ export class CrossSigningInfo extends EventEmitter { } } - public async signObject(data: T, type: string): Promise { + public async signObject(data: T, type: string): Promise { if (!this.keys[type]) { throw new Error( "Attempted to sign with " + type + " key but no such key present", @@ -457,13 +458,13 @@ export class CrossSigningInfo extends EventEmitter { const [pubkey, signing] = await this.getCrossSigningKey(type); try { pkSign(data, signing, this.userId, pubkey); - return data; + return data as T & { signatures: ISignatures }; } finally { signing.free(); } } - public async signUser(key: CrossSigningInfo): Promise { + public async signUser(key: CrossSigningInfo): Promise { if (!this.keys.user_signing) { logger.info("No user signing key: not signing user"); return; @@ -471,7 +472,7 @@ export class CrossSigningInfo extends EventEmitter { return this.signObject(key.keys.master, "user_signing"); } - public async signDevice(userId: string, device: DeviceInfo): Promise { + public async signDevice(userId: string, device: DeviceInfo): Promise { if (userId !== this.userId) { throw new Error( `Trying to sign ${userId}'s device; can only sign our own device`, @@ -481,7 +482,7 @@ export class CrossSigningInfo extends EventEmitter { logger.info("No self signing key: not signing device"); return; } - return this.signObject( + return this.signObject>( { algorithms: device.algorithms, keys: device.keys, @@ -719,12 +720,12 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O ); } const pickleKey = Buffer.from(olmDevice._pickleKey); - key = await encryptAES(encodeBase64(key), pickleKey, type); + const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type); return store.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - store.storeSecretStorePrivateKey(txn, type, key); + store.storeSecretStorePrivateKey(txn, type, encryptedKey); }, ); }, diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index f5ec71d1a..71d3d364d 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -59,7 +59,7 @@ enum TrackingStatus { UpToDate, } -type DeviceInfoMap = Record>; +export type DeviceInfoMap = Record>; /** * @alias module:crypto/DeviceList @@ -70,7 +70,7 @@ export class DeviceList extends EventEmitter { // [device info] // } // } - private devices: DeviceInfoMap = {}; + private devices: Record> = {}; // userId -> { // [key info] @@ -315,7 +315,7 @@ export class DeviceList extends EventEmitter { * @return {Object} userId->deviceId->{@link module:crypto/deviceinfo|DeviceInfo}. */ private getDevicesFromStore(userIds: string[]): DeviceInfoMap { - const stored = {}; + const stored: DeviceInfoMap = {}; userIds.map((u) => { stored[u] = {}; const devices = this.getStoredDevicesForUser(u) || []; @@ -463,27 +463,11 @@ export class DeviceList extends EventEmitter { /** * Replaces the list of devices for a user with the given device list * - * @param {string} u The user ID - * @param {Object} devs New device info for user + * @param {string} userId The user ID + * @param {Object} devices New device info for user */ - public storeDevicesForUser(u: string, devs: Record): void { - // remove previous devices from userByIdentityKey - if (this.devices[u] !== undefined) { - for (const [deviceId, dev] of Object.entries(this.devices[u])) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - delete this.userByIdentityKey[identityKey]; - } - } - - this.devices[u] = devs; - - // add new ones - for (const [deviceId, dev] of Object.entries(devs)) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - this.userByIdentityKey[identityKey] = u; - } + public storeDevicesForUser(userId: string, devices: Record): void { + this.setRawStoredDevicesForUser(userId, devices); this.dirty = true; } @@ -859,7 +843,7 @@ class DeviceListUpdateSerialiser { ); // put the updates into the object that will be returned as our results - const storage = {}; + const storage: Record = {}; Object.keys(userStore).forEach((deviceId) => { storage[deviceId] = userStore[deviceId].toStorage(); }); diff --git a/src/crypto/EncryptionSetup.js b/src/crypto/EncryptionSetup.ts similarity index 61% rename from src/crypto/EncryptionSetup.js rename to src/crypto/EncryptionSetup.ts index 1a7fcf36a..140c4cadb 100644 --- a/src/crypto/EncryptionSetup.js +++ b/src/crypto/EncryptionSetup.ts @@ -1,11 +1,24 @@ import { logger } from "../logger"; import { MatrixEvent } from "../models/event"; import { EventEmitter } from "events"; -import { createCryptoStoreCacheCallbacks } from "./CrossSigning"; +import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { PREFIX_UNSTABLE } from "../http-api"; +import { Crypto, IBootstrapCrossSigningOpts } from "./index"; import { - PREFIX_UNSTABLE, -} from "../http-api"; + CrossSigningKeys, + ICrossSigningKey, + ICryptoCallbacks, + ISecretStorageKeyInfo, + ISignedKey, + KeySignatures, +} from "../matrix"; +import { IKeyBackupInfo } from "./keybackup"; + +interface ICrossSigningKeys { + authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; + keys: Record; +} /** * Builds an EncryptionSetupOperation by calling any of the add.. methods. @@ -17,18 +30,23 @@ import { * more than once. */ export class EncryptionSetupBuilder { + public readonly accountDataClientAdapter: AccountDataClientAdapter; + public readonly crossSigningCallbacks: CrossSigningCallbacks; + public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks; + + private crossSigningKeys: ICrossSigningKeys = null; + private keySignatures: KeySignatures = null; + private keyBackupInfo: IKeyBackupInfo = null; + private sessionBackupPrivateKey: Uint8Array; + /** * @param {Object.} accountData pre-existing account data, will only be read, not written. * @param {CryptoCallbacks} delegateCryptoCallbacks crypto callbacks to delegate to if the key isn't in cache yet */ - constructor(accountData, delegateCryptoCallbacks) { + constructor(accountData: Record, delegateCryptoCallbacks: ICryptoCallbacks) { this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); this.crossSigningCallbacks = new CrossSigningCallbacks(); this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks); - - this._crossSigningKeys = null; - this._keySignatures = null; - this._keyBackupInfo = null; } /** @@ -42,8 +60,8 @@ export class EncryptionSetupBuilder { * an empty authDict, to obtain the flows. * @param {Object} keys the new keys */ - addCrossSigningKeys(authUpload, keys) { - this._crossSigningKeys = { authUpload, keys }; + public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void { + this.crossSigningKeys = { authUpload, keys }; } /** @@ -54,8 +72,8 @@ export class EncryptionSetupBuilder { * * @param {Object} keyBackupInfo as received from/sent to the server */ - addSessionBackup(keyBackupInfo) { - this._keyBackupInfo = keyBackupInfo; + public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void { + this.keyBackupInfo = keyBackupInfo; } /** @@ -65,8 +83,8 @@ export class EncryptionSetupBuilder { * * @param {Uint8Array} privateKey */ - addSessionBackupPrivateKeyToCache(privateKey) { - this._sessionBackupPrivateKey = privateKey; + public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void { + this.sessionBackupPrivateKey = privateKey; } /** @@ -75,14 +93,14 @@ export class EncryptionSetupBuilder { * * @param {String} userId * @param {String} deviceId - * @param {String} signature + * @param {Object} signature */ - addKeySignature(userId, deviceId, signature) { - if (!this._keySignatures) { - this._keySignatures = {}; + public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void { + if (!this.keySignatures) { + this.keySignatures = {}; } - const userSignatures = this._keySignatures[userId] || {}; - this._keySignatures[userId] = userSignatures; + const userSignatures = this.keySignatures[userId] || {}; + this.keySignatures[userId] = userSignatures; userSignatures[deviceId] = signature; } @@ -91,7 +109,7 @@ export class EncryptionSetupBuilder { * @param {Object} content * @return {Promise} */ - setAccountData(type, content) { + public setAccountData(type: string, content: object): Promise { return this.accountDataClientAdapter.setAccountData(type, content); } @@ -99,13 +117,13 @@ export class EncryptionSetupBuilder { * builds the operation containing all the parts that have been added to the builder * @return {EncryptionSetupOperation} */ - buildOperation() { - const accountData = this.accountDataClientAdapter._values; + public buildOperation(): EncryptionSetupOperation { + const accountData = this.accountDataClientAdapter.values; return new EncryptionSetupOperation( accountData, - this._crossSigningKeys, - this._keyBackupInfo, - this._keySignatures, + this.crossSigningKeys, + this.keyBackupInfo, + this.keySignatures, ); } @@ -118,9 +136,9 @@ export class EncryptionSetupBuilder { * @param {Crypto} crypto * @return {Promise} */ - async persist(crypto) { + public async persist(crypto: Crypto): Promise { // store private keys in cache - if (this._crossSigningKeys) { + if (this.crossSigningKeys) { const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice); for (const type of ["master", "self_signing", "user_signing"]) { logger.log(`Cache ${type} cross-signing private key locally`); @@ -132,13 +150,13 @@ export class EncryptionSetupBuilder { 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { crypto.cryptoStore.storeCrossSigningKeys( - txn, this._crossSigningKeys.keys); + txn, this.crossSigningKeys.keys); }, ); } // store session backup key in cache - if (this._sessionBackupPrivateKey) { - await crypto.storeSessionBackupPrivateKey(this._sessionBackupPrivateKey); + if (this.sessionBackupPrivateKey) { + await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey); } } } @@ -156,58 +174,58 @@ export class EncryptionSetupOperation { * @param {Object} keyBackupInfo * @param {Object} keySignatures */ - constructor(accountData, crossSigningKeys, keyBackupInfo, keySignatures) { - this._accountData = accountData; - this._crossSigningKeys = crossSigningKeys; - this._keyBackupInfo = keyBackupInfo; - this._keySignatures = keySignatures; - } + constructor( + private readonly accountData: Map, + private readonly crossSigningKeys: ICrossSigningKeys, + private readonly keyBackupInfo: IKeyBackupInfo, + private readonly keySignatures: KeySignatures, + ) {} /** * Runs the (remaining part of, in the future) operation by sending requests to the server. - * @param {Crypto} crypto + * @param {Crypto} crypto */ - async apply(crypto) { + public async apply(crypto: Crypto): Promise { const baseApis = crypto.baseApis; // upload cross-signing keys - if (this._crossSigningKeys) { - const keys = {}; - for (const [name, key] of Object.entries(this._crossSigningKeys.keys)) { + if (this.crossSigningKeys) { + const keys: Partial = {}; + for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { keys[name + "_key"] = key; } // We must only call `uploadDeviceSigningKeys` from inside this auth // helper to ensure we properly handle auth errors. - await this._crossSigningKeys.authUpload(authDict => { - return baseApis.uploadDeviceSigningKeys(authDict, keys); + await this.crossSigningKeys.authUpload(authDict => { + return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys); }); // pass the new keys to the main instance of our own CrossSigningInfo. - crypto.crossSigningInfo.setKeys(this._crossSigningKeys.keys); + crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys); } // set account data - if (this._accountData) { - for (const [type, content] of this._accountData) { + if (this.accountData) { + for (const [type, content] of this.accountData) { await baseApis.setAccountData(type, content); } } // upload first cross-signing signatures with the new key // (e.g. signing our own device) - if (this._keySignatures) { - await baseApis.uploadKeySignatures(this._keySignatures); + if (this.keySignatures) { + await baseApis.uploadKeySignatures(this.keySignatures); } // need to create/update key backup info - if (this._keyBackupInfo) { - if (this._keyBackupInfo.version) { + if (this.keyBackupInfo) { + if (this.keyBackupInfo.version) { // session backup signature // The backup is trusted because the user provided the private key. // Sign the backup with the cross signing key so the key backup can // be trusted via cross-signing. await baseApis.http.authedRequest( - undefined, "PUT", "/room_keys/version/" + this._keyBackupInfo.version, + undefined, "PUT", "/room_keys/version/" + this.keyBackupInfo.version, undefined, { - algorithm: this._keyBackupInfo.algorithm, - auth_data: this._keyBackupInfo.auth_data, + algorithm: this.keyBackupInfo.algorithm, + auth_data: this.keyBackupInfo.auth_data, }, { prefix: PREFIX_UNSTABLE }, ); @@ -215,7 +233,7 @@ export class EncryptionSetupOperation { // add new key backup await baseApis.http.authedRequest( undefined, "POST", "/room_keys/version", - undefined, this._keyBackupInfo, + undefined, this.keyBackupInfo, { prefix: PREFIX_UNSTABLE }, ); } @@ -228,20 +246,20 @@ export class EncryptionSetupOperation { * implementing the methods related to account data in MatrixClient */ class AccountDataClientAdapter extends EventEmitter { + public readonly values = new Map(); + /** - * @param {Object.} accountData existing account data + * @param {Object.} existingValues existing account data */ - constructor(accountData) { + constructor(private readonly existingValues: Record) { super(); - this._existingValues = accountData; - this._values = new Map(); } /** * @param {String} type * @return {Promise} the content of the account data */ - getAccountDataFromServer(type) { + public getAccountDataFromServer(type: string): Promise { return Promise.resolve(this.getAccountData(type)); } @@ -249,12 +267,12 @@ class AccountDataClientAdapter extends EventEmitter { * @param {String} type * @return {Object} the content of the account data */ - getAccountData(type) { - const modifiedValue = this._values.get(type); + public getAccountData(type: string): object { + const modifiedValue = this.values.get(type); if (modifiedValue) { return modifiedValue; } - const existingValue = this._existingValues[type]; + const existingValue = this.existingValues[type]; if (existingValue) { return existingValue.getContent(); } @@ -266,9 +284,9 @@ class AccountDataClientAdapter extends EventEmitter { * @param {Object} content * @return {Promise} */ - setAccountData(type, content) { - const lastEvent = this._values.get(type); - this._values.set(type, content); + public setAccountData(type: string, content: object): Promise { + const lastEvent = this.values.get(type); + this.values.set(type, content); // ensure accountData is emitted on the next tick, // as SecretStorage listens for it while calling this method // and it seems to rely on this. @@ -284,27 +302,25 @@ class AccountDataClientAdapter extends EventEmitter { * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks. * See CrossSigningInfo constructor */ -class CrossSigningCallbacks { - constructor() { - this.privateKeys = new Map(); - } +class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks { + public readonly privateKeys = new Map(); // cache callbacks - getCrossSigningKeyCache(type, expectedPublicKey) { + public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise { return this.getCrossSigningKey(type, expectedPublicKey); } - storeCrossSigningKeyCache(type, key) { + public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise { this.privateKeys.set(type, key); return Promise.resolve(); } // non-cache callbacks - getCrossSigningKey(type, _expectedPubkey) { + public getCrossSigningKey(type: string, expectedPubkey: string): Promise { return Promise.resolve(this.privateKeys.get(type)); } - saveCrossSigningKeys(privateKeys) { + public saveCrossSigningKeys(privateKeys: Record) { for (const [type, privateKey] of Object.entries(privateKeys)) { this.privateKeys.set(type, privateKey); } @@ -316,39 +332,36 @@ class CrossSigningCallbacks { * the SecretStorage crypto callbacks */ class SSSSCryptoCallbacks { - constructor(delegateCryptoCallbacks) { - this._privateKeys = new Map(); - this._delegateCryptoCallbacks = delegateCryptoCallbacks; - } + private readonly privateKeys = new Map(); - async getSecretStorageKey({ keys }, name) { + constructor(private readonly delegateCryptoCallbacks: ICryptoCallbacks) {} + + public async getSecretStorageKey( + { keys }: { keys: Record }, + name: string, + ): Promise<[string, Uint8Array]> { for (const keyId of Object.keys(keys)) { - const privateKey = this._privateKeys.get(keyId); + const privateKey = this.privateKeys.get(keyId); if (privateKey) { return [keyId, privateKey]; } } // if we don't have the key cached yet, ask // for it to the general crypto callbacks and cache it - if (this._delegateCryptoCallbacks) { - const result = await this._delegateCryptoCallbacks. + if (this.delegateCryptoCallbacks) { + const result = await this.delegateCryptoCallbacks. getSecretStorageKey({ keys }, name); if (result) { const [keyId, privateKey] = result; - this._privateKeys.set(keyId, privateKey); + this.privateKeys.set(keyId, privateKey); } return result; } } - addPrivateKey(keyId, keyInfo, privKey) { - this._privateKeys.set(keyId, privKey); + public addPrivateKey(keyId: string, keyInfo: ISecretStorageKeyInfo, privKey: Uint8Array): void { + this.privateKeys.set(keyId, privKey); // Also pass along to application to cache if it wishes - if ( - this._delegateCryptoCallbacks && - this._delegateCryptoCallbacks.cacheSecretStorageKey - ) { - this._delegateCryptoCallbacks.cacheSecretStorageKey(keyId, keyInfo, privKey); - } + this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); } } diff --git a/src/crypto/OutgoingRoomKeyRequestManager.js b/src/crypto/OutgoingRoomKeyRequestManager.ts similarity index 54% rename from src/crypto/OutgoingRoomKeyRequestManager.js rename to src/crypto/OutgoingRoomKeyRequestManager.ts index 7f64b5313..a684b2c71 100644 --- a/src/crypto/OutgoingRoomKeyRequestManager.js +++ b/src/crypto/OutgoingRoomKeyRequestManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,6 +15,10 @@ limitations under the License. */ import { logger } from '../logger'; +import { CryptoStore, MatrixClient } from "../client"; +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index"; +import { OutgoingRoomKeyRequest } from './store/base'; +import { EventType } from "../@types/event"; /** * Internal module. Management of outgoing room key requests. @@ -57,61 +61,58 @@ const SEND_KEY_REQUESTS_DELAY_MS = 500; * * @enum {number} */ -export const ROOM_KEY_REQUEST_STATES = { +export enum RoomKeyRequestState { /** request not yet sent */ - UNSENT: 0, - + Unsent, /** request sent, awaiting reply */ - SENT: 1, - + Sent, /** reply received, cancellation not yet sent */ - CANCELLATION_PENDING: 2, - + CancellationPending, /** * Cancellation not yet sent and will transition to UNSENT instead of * being deleted once the cancellation has been sent. */ - CANCELLATION_PENDING_AND_WILL_RESEND: 3, -}; + CancellationPendingAndWillResend, +} export class OutgoingRoomKeyRequestManager { - constructor(baseApis, deviceId, cryptoStore) { - this._baseApis = baseApis; - this._deviceId = deviceId; - this._cryptoStore = cryptoStore; + // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null + // if the callback has been set, or if it is still running. + private sendOutgoingRoomKeyRequestsTimer: NodeJS.Timeout = null; - // handle for the delayed call to _sendOutgoingRoomKeyRequests. Non-null - // if the callback has been set, or if it is still running. - this._sendOutgoingRoomKeyRequestsTimer = null; + // sanity check to ensure that we don't end up with two concurrent runs + // of sendOutgoingRoomKeyRequests + private sendOutgoingRoomKeyRequestsRunning = false; - // sanity check to ensure that we don't end up with two concurrent runs - // of _sendOutgoingRoomKeyRequests - this._sendOutgoingRoomKeyRequestsRunning = false; + private clientRunning = false; - this._clientRunning = false; - } + constructor( + private readonly baseApis: MatrixClient, + private readonly deviceId: string, + private readonly cryptoStore: CryptoStore, + ) {} /** * Called when the client is started. Sets background processes running. */ - start() { - this._clientRunning = true; + public start(): void { + this.clientRunning = true; } /** * Called when the client is stopped. Stops any running background processes. */ - stop() { + public stop(): void { logger.log('stopping OutgoingRoomKeyRequestManager'); // stop the timer on the next run - this._clientRunning = false; + this.clientRunning = false; } /** * Send any requests that have been queued */ - sendQueuedRequests() { - this._startTimer(); + public sendQueuedRequests(): void { + this.startTimer(); } /** @@ -131,95 +132,99 @@ export class OutgoingRoomKeyRequestManager { * pending list (or we have established that a similar request already * exists) */ - async queueRoomKeyRequest(requestBody, recipients, resend=false) { - const req = await this._cryptoStore.getOutgoingRoomKeyRequest( + public async queueRoomKeyRequest( + requestBody: IRoomKeyRequestBody, + recipients: IRoomKeyRequestRecipient[], + resend = false, + ): Promise { + const req = await this.cryptoStore.getOutgoingRoomKeyRequest( requestBody, ); if (!req) { - await this._cryptoStore.getOrAddOutgoingRoomKeyRequest({ + await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({ requestBody: requestBody, recipients: recipients, - requestId: this._baseApis.makeTxnId(), - state: ROOM_KEY_REQUEST_STATES.UNSENT, + requestId: this.baseApis.makeTxnId(), + state: RoomKeyRequestState.Unsent, }); } else { switch (req.state) { - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: - case ROOM_KEY_REQUEST_STATES.UNSENT: - // nothing to do here, since we're going to send a request anyways - return; + case RoomKeyRequestState.CancellationPendingAndWillResend: + case RoomKeyRequestState.Unsent: + // nothing to do here, since we're going to send a request anyways + return; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: { - // existing request is about to be cancelled. If we want to - // resend, then change the state so that it resends after - // cancelling. Otherwise, just cancel the cancellation. - const state = resend ? - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND : - ROOM_KEY_REQUEST_STATES.SENT; - await this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, { - state, - cancellationTxnId: this._baseApis.makeTxnId(), - }, - ); - break; - } - case ROOM_KEY_REQUEST_STATES.SENT: { - // a request has already been sent. If we don't want to - // resend, then do nothing. If we do want to, then cancel the - // existing request and send a new one. - if (resend) { - const state = - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND; - const updatedReq = - await this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.SENT, { - state, - cancellationTxnId: this._baseApis.makeTxnId(), - // need to use a new transaction ID so that - // the request gets sent - requestTxnId: this._baseApis.makeTxnId(), - }, - ); - if (!updatedReq) { - // updateOutgoingRoomKeyRequest couldn't find the request - // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have - // raced with another tab to mark the request cancelled. - // Try again, to make sure the request is resent. - return await this.queueRoomKeyRequest( - requestBody, recipients, resend, - ); - } - - // We don't want to wait for the timer, so we send it - // immediately. (We might actually end up racing with the timer, - // but that's ok: even if we make the request twice, we'll do it - // with the same transaction_id, so only one message will get - // sent). - // - // (We also don't want to wait for the response from the server - // here, as it will slow down processing of received keys if we - // do.) - try { - await this._sendOutgoingRoomKeyRequestCancellation( - updatedReq, - true, - ); - } catch (e) { - logger.error( - "Error sending room key request cancellation;" - + " will retry later.", e, - ); - } - // The request has transitioned from - // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We - // still need to resend the request which is now UNSENT, so - // start the timer if it isn't already started. + case RoomKeyRequestState.CancellationPending: { + // existing request is about to be cancelled. If we want to + // resend, then change the state so that it resends after + // cancelling. Otherwise, just cancel the cancellation. + const state = resend ? + RoomKeyRequestState.CancellationPendingAndWillResend : + RoomKeyRequestState.Sent; + await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.CancellationPending, { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + }, + ); + break; } - break; - } - default: - throw new Error('unhandled state: ' + req.state); + case RoomKeyRequestState.Sent: { + // a request has already been sent. If we don't want to + // resend, then do nothing. If we do want to, then cancel the + // existing request and send a new one. + if (resend) { + const state = + RoomKeyRequestState.CancellationPendingAndWillResend; + const updatedReq = + await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Sent, { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + // need to use a new transaction ID so that + // the request gets sent + requestTxnId: this.baseApis.makeTxnId(), + }, + ); + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the request + // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have + // raced with another tab to mark the request cancelled. + // Try again, to make sure the request is resent. + return await this.queueRoomKeyRequest( + requestBody, recipients, resend, + ); + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + try { + await this.sendOutgoingRoomKeyRequestCancellation( + updatedReq, + true, + ); + } catch (e) { + logger.error( + "Error sending room key request cancellation;" + + " will retry later.", e, + ); + } + // The request has transitioned from + // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We + // still need to resend the request which is now UNSENT, so + // start the timer if it isn't already started. + } + break; + } + default: + throw new Error('unhandled state: ' + req.state); } } } @@ -232,8 +237,8 @@ export class OutgoingRoomKeyRequestManager { * @returns {Promise} resolves when the request has been updated in our * pending list. */ - cancelRoomKeyRequest(requestBody) { - return this._cryptoStore.getOutgoingRoomKeyRequest( + public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { + return this.cryptoStore.getOutgoingRoomKeyRequest( requestBody, ).then((req) => { if (!req) { @@ -241,12 +246,12 @@ export class OutgoingRoomKeyRequestManager { return; } switch (req.state) { - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: + case RoomKeyRequestState.CancellationPending: + case RoomKeyRequestState.CancellationPendingAndWillResend: // nothing to do here return; - case ROOM_KEY_REQUEST_STATES.UNSENT: + case RoomKeyRequestState.Unsent: // just delete it // FIXME: ghahah we may have attempted to send it, and @@ -258,16 +263,16 @@ export class OutgoingRoomKeyRequestManager { 'deleting unnecessary room key request for ' + stringifyRequestBody(requestBody), ); - return this._cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, + return this.cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Unsent, ); - case ROOM_KEY_REQUEST_STATES.SENT: { + case RoomKeyRequestState.Sent: { // send a cancellation. - return this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.SENT, { - state: ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, - cancellationTxnId: this._baseApis.makeTxnId(), + return this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Sent, { + state: RoomKeyRequestState.CancellationPending, + cancellationTxnId: this.baseApis.makeTxnId(), }, ).then((updatedReq) => { if (!updatedReq) { @@ -294,14 +299,14 @@ export class OutgoingRoomKeyRequestManager { // (We also don't want to wait for the response from the server // here, as it will slow down processing of received keys if we // do.) - this._sendOutgoingRoomKeyRequestCancellation( + this.sendOutgoingRoomKeyRequestCancellation( updatedReq, ).catch((e) => { logger.error( "Error sending room key request cancellation;" + " will retry later.", e, ); - this._startTimer(); + this.startTimer(); }); }); } @@ -320,10 +325,8 @@ export class OutgoingRoomKeyRequestManager { * @return {Promise} resolves to a list of all the * {@link module:crypto/store/base~OutgoingRoomKeyRequest} */ - getOutgoingSentRoomKeyRequest(userId, deviceId) { - return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget( - userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT], - ); + public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): OutgoingRoomKeyRequest[] { + return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]); } /** @@ -333,29 +336,27 @@ export class OutgoingRoomKeyRequestManager { * For example, after initialization or self-verification. * @return {Promise} An array of `queueRoomKeyRequest` outputs. */ - async cancelAndResendAllOutgoingRequests() { - const outgoings = await this._cryptoStore.getAllOutgoingRoomKeyRequestsByState( - ROOM_KEY_REQUEST_STATES.SENT, - ); + public async cancelAndResendAllOutgoingRequests(): Promise { + const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); return Promise.all(outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true))); } // start the background timer to send queued requests, if the timer isn't // already running - _startTimer() { - if (this._sendOutgoingRoomKeyRequestsTimer) { + private startTimer(): void { + if (this.sendOutgoingRoomKeyRequestsTimer) { return; } const startSendingOutgoingRoomKeyRequests = () => { - if (this._sendOutgoingRoomKeyRequestsRunning) { + if (this.sendOutgoingRoomKeyRequestsRunning) { throw new Error("RoomKeyRequestSend already in progress!"); } - this._sendOutgoingRoomKeyRequestsRunning = true; + this.sendOutgoingRoomKeyRequestsRunning = true; - this._sendOutgoingRoomKeyRequests().finally(() => { - this._sendOutgoingRoomKeyRequestsRunning = false; + this.sendOutgoingRoomKeyRequests().finally(() => { + this.sendOutgoingRoomKeyRequestsRunning = false; }).catch((e) => { // this should only happen if there is an indexeddb error, // in which case we're a bit stuffed anyway. @@ -365,7 +366,7 @@ export class OutgoingRoomKeyRequestManager { }); }; - this._sendOutgoingRoomKeyRequestsTimer = global.setTimeout( + this.sendOutgoingRoomKeyRequestsTimer = global.setTimeout( startSendingOutgoingRoomKeyRequests, SEND_KEY_REQUESTS_DELAY_MS, ); @@ -374,47 +375,47 @@ export class OutgoingRoomKeyRequestManager { // look for and send any queued requests. Runs itself recursively until // there are no more requests, or there is an error (in which case, the // timer will be restarted before the promise resolves). - _sendOutgoingRoomKeyRequests() { - if (!this._clientRunning) { - this._sendOutgoingRoomKeyRequestsTimer = null; + private sendOutgoingRoomKeyRequests(): Promise { + if (!this.clientRunning) { + this.sendOutgoingRoomKeyRequestsTimer = null; return Promise.resolve(); } - return this._cryptoStore.getOutgoingRoomKeyRequestByState([ - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, - ROOM_KEY_REQUEST_STATES.UNSENT, - ]).then((req) => { + return this.cryptoStore.getOutgoingRoomKeyRequestByState([ + RoomKeyRequestState.CancellationPending, + RoomKeyRequestState.CancellationPendingAndWillResend, + RoomKeyRequestState.Unsent, + ]).then((req: OutgoingRoomKeyRequest) => { if (!req) { - this._sendOutgoingRoomKeyRequestsTimer = null; + this.sendOutgoingRoomKeyRequestsTimer = null; return; } let prom; switch (req.state) { - case ROOM_KEY_REQUEST_STATES.UNSENT: - prom = this._sendOutgoingRoomKeyRequest(req); + case RoomKeyRequestState.Unsent: + prom = this.sendOutgoingRoomKeyRequest(req); break; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: - prom = this._sendOutgoingRoomKeyRequestCancellation(req); + case RoomKeyRequestState.CancellationPending: + prom = this.sendOutgoingRoomKeyRequestCancellation(req); break; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: - prom = this._sendOutgoingRoomKeyRequestCancellation(req, true); + case RoomKeyRequestState.CancellationPendingAndWillResend: + prom = this.sendOutgoingRoomKeyRequestCancellation(req, true); break; } return prom.then(() => { // go around the loop again - return this._sendOutgoingRoomKeyRequests(); + return this.sendOutgoingRoomKeyRequests(); }).catch((e) => { logger.error("Error sending room key request; will retry later.", e); - this._sendOutgoingRoomKeyRequestsTimer = null; + this.sendOutgoingRoomKeyRequestsTimer = null; }); }); } // given a RoomKeyRequest, send it and update the request record - _sendOutgoingRoomKeyRequest(req) { + private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise { logger.log( `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + ` from ${stringifyRecipientList(req.recipients)}` + @@ -423,24 +424,24 @@ export class OutgoingRoomKeyRequestManager { const requestMessage = { action: "request", - requesting_device_id: this._deviceId, + requesting_device_id: this.deviceId, request_id: req.requestId, body: req.requestBody, }; - return this._sendMessageToDevices( + return this.sendMessageToDevices( requestMessage, req.recipients, req.requestTxnId || req.requestId, ).then(() => { - return this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, - { state: ROOM_KEY_REQUEST_STATES.SENT }, + return this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Unsent, + { state: RoomKeyRequestState.Sent }, ); }); } // Given a RoomKeyRequest, cancel it and delete the request record unless // andResend is set, in which case transition to UNSENT. - _sendOutgoingRoomKeyRequestCancellation(req, andResend) { + private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise { logger.log( `Sending cancellation for key request for ` + `${stringifyRequestBody(req.requestBody)} to ` + @@ -450,30 +451,30 @@ export class OutgoingRoomKeyRequestManager { const requestMessage = { action: "request_cancellation", - requesting_device_id: this._deviceId, + requesting_device_id: this.deviceId, request_id: req.requestId, }; - return this._sendMessageToDevices( + return this.sendMessageToDevices( requestMessage, req.recipients, req.cancellationTxnId, ).then(() => { if (andResend) { // We want to resend, so transition to UNSENT - return this._cryptoStore.updateOutgoingRoomKeyRequest( + return this.cryptoStore.updateOutgoingRoomKeyRequest( req.requestId, - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, - { state: ROOM_KEY_REQUEST_STATES.UNSENT }, + RoomKeyRequestState.CancellationPendingAndWillResend, + { state: RoomKeyRequestState.Unsent }, ); } - return this._cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, + return this.cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.CancellationPending, ); }); } // send a RoomKeyRequest to a list of recipients - _sendMessageToDevices(message, recipients, txnId) { - const contentMap = {}; + private sendMessageToDevices(message, recipients, txnId: string): Promise<{}> { + const contentMap: Record>> = {}; for (const recip of recipients) { if (!contentMap[recip.userId]) { contentMap[recip.userId] = {}; @@ -481,9 +482,7 @@ export class OutgoingRoomKeyRequestManager { contentMap[recip.userId][recip.deviceId] = message; } - return this._baseApis.sendToDevice( - 'm.room_key_request', contentMap, txnId, - ); + return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId); } } diff --git a/src/crypto/aes.js b/src/crypto/aes.ts similarity index 74% rename from src/crypto/aes.js rename to src/crypto/aes.ts index c121b6e2b..1c370ecc4 100644 --- a/src/crypto/aes.js +++ b/src/crypto/aes.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { BinaryLike } from "crypto"; + import { getCrypto } from '../utils'; import { decodeBase64, encodeBase64 } from './olmlib'; @@ -21,7 +23,13 @@ const subtleCrypto = (typeof window !== "undefined" && window.crypto) ? (window.crypto.subtle || window.crypto.webkitSubtle) : null; // salt for HKDF, with 8 bytes of zeros -const zerosalt = new Uint8Array(8); +const zeroSalt = new Uint8Array(8); + +export interface IEncryptedPayload { + iv: string; + ciphertext: string; + mac: string; +} /** * encrypt a string in Node.js @@ -31,7 +39,7 @@ const zerosalt = new Uint8Array(8); * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptNode(data, key, name, ivStr) { +async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -52,15 +60,17 @@ async function encryptNode(data, key, name, ivStr) { const [aesKey, hmacKey] = deriveKeysNode(key, name); const cipher = crypto.createCipheriv("aes-256-ctr", aesKey, iv); - const ciphertext = cipher.update(data, "utf-8", "base64") - + cipher.final("base64"); + const ciphertext = Buffer.concat([ + cipher.update(data, "utf8"), + cipher.final(), + ]); const hmac = crypto.createHmac("sha256", hmacKey) - .update(ciphertext, "base64").digest("base64"); + .update(ciphertext).digest("base64"); return { iv: encodeBase64(iv), - ciphertext: ciphertext, + ciphertext: ciphertext.toString("base64"), mac: hmac, }; } @@ -75,7 +85,7 @@ async function encryptNode(data, key, name, ivStr) { * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptNode(data, key, name) { +async function decryptNode(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -84,7 +94,8 @@ async function decryptNode(data, key, name) { const [aesKey, hmacKey] = deriveKeysNode(key, name); const hmac = crypto.createHmac("sha256", hmacKey) - .update(data.ciphertext, "base64").digest("base64").replace(/=+$/g, ''); + .update(Buffer.from(data.ciphertext, "base64")) + .digest("base64").replace(/=+$/g, ''); if (hmac !== data.mac.replace(/=+$/g, '')) { throw new Error(`Error decrypting secret ${name}: bad MAC`); @@ -93,21 +104,20 @@ async function decryptNode(data, key, name) { const decipher = crypto.createDecipheriv( "aes-256-ctr", aesKey, decodeBase64(data.iv), ); - return decipher.update(data.ciphertext, "base64", "utf-8") - + decipher.final("utf-8"); + return decipher.update(data.ciphertext, "base64", "utf8") + + decipher.final("utf8"); } -function deriveKeysNode(key, name) { +function deriveKeysNode(key: BinaryLike, name: string): [Buffer, Buffer] { const crypto = getCrypto(); - const prk = crypto.createHmac("sha256", zerosalt) - .update(key).digest(); + const prk = crypto.createHmac("sha256", zeroSalt).update(key).digest(); const b = Buffer.alloc(1, 1); const aesKey = crypto.createHmac("sha256", prk) - .update(name, "utf-8").update(b).digest(); + .update(name, "utf8").update(b).digest(); b[0] = 2; const hmacKey = crypto.createHmac("sha256", prk) - .update(aesKey).update(name, "utf-8").update(b).digest(); + .update(aesKey).update(name, "utf8").update(b).digest(); return [aesKey, hmacKey]; } @@ -120,7 +130,7 @@ function deriveKeysNode(key, name) { * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptBrowser(data, key, name, ivStr) { +async function encryptBrowser(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { let iv; if (ivStr) { iv = decodeBase64(ivStr); @@ -170,7 +180,7 @@ async function encryptBrowser(data, key, name, ivStr) { * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptBrowser(data, key, name) { +async function decryptBrowser(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { const [aesKey, hmacKey] = await deriveKeysBrowser(key, name); const ciphertext = decodeBase64(data.ciphertext); @@ -197,7 +207,7 @@ async function decryptBrowser(data, key, name) { return new TextDecoder().decode(new Uint8Array(plaintext)); } -async function deriveKeysBrowser(key, name) { +async function deriveKeysBrowser(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { const hkdfkey = await subtleCrypto.importKey( 'raw', key, @@ -208,7 +218,9 @@ async function deriveKeysBrowser(key, name) { const keybits = await subtleCrypto.deriveBits( { name: "HKDF", - salt: zerosalt, + salt: zeroSalt, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 info: (new TextEncoder().encode(name)), hash: "SHA-256", }, @@ -241,11 +253,11 @@ async function deriveKeysBrowser(key, name) { return await Promise.all([aesProm, hmacProm]); } -export function encryptAES(...args) { - return subtleCrypto ? encryptBrowser(...args) : encryptNode(...args); +export function encryptAES(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { + return subtleCrypto ? encryptBrowser(data, key, name, ivStr) : encryptNode(data, key, name, ivStr); } -export function decryptAES(...args) { - return subtleCrypto ? decryptBrowser(...args) : decryptNode(...args); +export function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { + return subtleCrypto ? decryptBrowser(data, key, name) : decryptNode(data, key, name); } diff --git a/src/crypto/algorithms/base.js b/src/crypto/algorithms/base.ts similarity index 61% rename from src/crypto/algorithms/base.js rename to src/crypto/algorithms/base.ts index 87b8a82c0..7f687774b 100644 --- a/src/crypto/algorithms/base.js +++ b/src/crypto/algorithms/base.ts @@ -1,5 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,13 +20,22 @@ limitations under the License. * @module */ +import { MatrixClient } from "../../client"; +import { Room } from "../../models/room"; +import { OlmDevice } from "../OlmDevice"; +import { MatrixEvent, RoomMember } from "../.."; +import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; +import { DeviceInfo } from "../deviceinfo"; + /** * map of registered encryption algorithm classes. A map from string to {@link * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class * * @type {Object.} */ -export const ENCRYPTION_CLASSES = {}; +export const ENCRYPTION_CLASSES: Record EncryptionAlgorithm> = {}; + +type DecryptionClassParams = Omit; /** * map of registered encryption algorithm classes. Map from string to {@link @@ -34,7 +43,17 @@ export const ENCRYPTION_CLASSES = {}; * * @type {Object.} */ -export const DECRYPTION_CLASSES = {}; +export const DECRYPTION_CLASSES: Record DecryptionAlgorithm> = {}; + +interface IParams { + userId: string; + deviceId: string; + crypto: Crypto; + olmDevice: OlmDevice; + baseApis: MatrixClient; + roomId: string; + config: object; +} /** * base type for encryption implementations @@ -50,14 +69,21 @@ export const DECRYPTION_CLASSES = {}; * @param {string} params.roomId The ID of the room we will be sending to * @param {object} params.config The body of the m.room.encryption event */ -export class EncryptionAlgorithm { - constructor(params) { - this._userId = params.userId; - this._deviceId = params.deviceId; - this._crypto = params.crypto; - this._olmDevice = params.olmDevice; - this._baseApis = params.baseApis; - this._roomId = params.roomId; +export abstract class EncryptionAlgorithm { + protected readonly userId: string; + protected readonly deviceId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId: string; + + constructor(params: IParams) { + this.userId = params.userId; + this.deviceId = params.deviceId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; } /** @@ -66,21 +92,22 @@ export class EncryptionAlgorithm { * * @param {module:models/room} room the room the event is in */ - prepareToEncrypt(room) { - } + public prepareToEncrypt(room: Room): void {} /** * Encrypt a message event * * @method module:crypto/algorithms/base.EncryptionAlgorithm.encryptMessage + * @public * @abstract * * @param {module:models/room} room * @param {string} eventType - * @param {object} plaintext event content + * @param {object} content event content * * @return {Promise} Promise which resolves to the new event body */ + public abstract encryptMessage(room: Room, eventType: string, content: object): Promise; /** * Called when the membership of a member of the room changes. @@ -89,9 +116,18 @@ export class EncryptionAlgorithm { * @param {module:models/room-member} member user whose membership changed * @param {string=} oldMembership previous membership * @public + * @abstract */ - onRoomMembership(event, member, oldMembership) { - } + public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} + + public reshareKeyWithDevice?( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise; + + public forceDiscardSession?(): void; } /** @@ -106,13 +142,19 @@ export class EncryptionAlgorithm { * @param {string=} params.roomId The ID of the room we will be receiving * from. Null for to-device events. */ -export class DecryptionAlgorithm { - constructor(params) { - this._userId = params.userId; - this._crypto = params.crypto; - this._olmDevice = params.olmDevice; - this._baseApis = params.baseApis; - this._roomId = params.roomId; +export abstract class DecryptionAlgorithm { + protected readonly userId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId: string; + + constructor(params: DecryptionClassParams) { + this.userId = params.userId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; } /** @@ -127,6 +169,7 @@ export class DecryptionAlgorithm { * resolves once we have finished decrypting. Rejects with an * `algorithms.DecryptionError` if there is a problem decrypting the event. */ + public abstract decryptEvent(event: MatrixEvent): Promise; /** * Handle a key event @@ -135,7 +178,7 @@ export class DecryptionAlgorithm { * * @param {module:models/event.MatrixEvent} params event key event */ - onRoomKeyEvent(params) { + public onRoomKeyEvent(params: MatrixEvent): void { // ignore by default } @@ -143,8 +186,9 @@ export class DecryptionAlgorithm { * Import a room key * * @param {module:crypto/OlmDevice.MegolmSessionData} session + * @param {object} opts object */ - importRoomKey(session) { + public async importRoomKey(session: IMegolmSessionData, opts: object): Promise { // ignore by default } @@ -155,7 +199,7 @@ export class DecryptionAlgorithm { * @return {Promise} true if we have the keys and could (theoretically) share * them; else false. */ - hasKeysForKeyRequest(keyRequest) { + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { return Promise.resolve(false); } @@ -164,7 +208,7 @@ export class DecryptionAlgorithm { * * @param {module:crypto~IncomingRoomKeyRequest} keyRequest */ - shareKeysWithDevice(keyRequest) { + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); } @@ -174,9 +218,13 @@ export class DecryptionAlgorithm { * * @param {string} senderKey the sender's key */ - async retryDecryptionFromSender(senderKey) { + public async retryDecryptionFromSender(senderKey: string): Promise { // ignore by default + return false; } + + public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise; + public sendSharedHistoryInboundSessions?(devicesByUser: Record): Promise; } /** @@ -191,22 +239,21 @@ export class DecryptionAlgorithm { * @extends Error */ export class DecryptionError extends Error { - constructor(code, msg, details) { + public readonly detailedString: string; + + constructor(public readonly code: string, msg: string, details?: Record) { super(msg); this.code = code; this.name = 'DecryptionError'; - this.detailedString = _detailedStringForDecryptionError(this, details); + this.detailedString = detailedStringForDecryptionError(this, details); } } -function _detailedStringForDecryptionError(err, details) { +function detailedStringForDecryptionError(err: DecryptionError, details?: Record): string { let result = err.name + '[msg: ' + err.message; if (details) { - result += ', ' + - Object.keys(details).map( - (k) => k + ': ' + details[k], - ).join(', '); + result += ', ' + Object.keys(details).map((k) => k + ': ' + details[k]).join(', '); } result += ']'; @@ -224,7 +271,7 @@ function _detailedStringForDecryptionError(err, details) { * @extends Error */ export class UnknownDeviceError extends Error { - constructor(msg, devices) { + constructor(msg: string, public readonly devices: Record>) { super(msg); this.name = "UnknownDeviceError"; this.devices = devices; @@ -244,7 +291,11 @@ export class UnknownDeviceError extends Error { * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} * implementation */ -export function registerAlgorithm(algorithm, encryptor, decryptor) { +export function registerAlgorithm( + algorithm: string, + encryptor: new (params: IParams) => EncryptionAlgorithm, + decryptor: new (params: Omit) => DecryptionAlgorithm, +): void { ENCRYPTION_CLASSES[algorithm] = encryptor; DECRYPTION_CLASSES[algorithm] = decryptor; } diff --git a/src/crypto/algorithms/index.js b/src/crypto/algorithms/index.ts similarity index 88% rename from src/crypto/algorithms/index.js rename to src/crypto/algorithms/index.ts index 0fb646cfe..3dd1158a0 100644 --- a/src/crypto/algorithms/index.js +++ b/src/crypto/algorithms/index.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js deleted file mode 100644 index f457e6e6d..000000000 --- a/src/crypto/algorithms/megolm.js +++ /dev/null @@ -1,1788 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/megolm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, - UnknownDeviceError, -} from "./base"; - -import { WITHHELD_MESSAGES } from '../OlmDevice'; - -// determine whether the key can be shared with invitees -export function isRoomSharedHistory(room) { - const visibilityEvent = room.currentState && - room.currentState.getStateEvents("m.room.history_visibility", ""); - // NOTE: if the room visibility is unset, it would normally default to - // "world_readable". - // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) - // But we will be paranoid here, and treat it as a situation where the room - // is not shared-history - const visibility = visibilityEvent && visibilityEvent.getContent() && - visibilityEvent.getContent().history_visibility; - return ["world_readable", "shared"].includes(visibility); -} - -/** - * @private - * @constructor - * - * @param {string} sessionId - * @param {boolean} sharedHistory whether the session can be freely shared with - * other group members, according to the room history visibility settings - * - * @property {string} sessionId - * @property {Number} useCount number of times this session has been used - * @property {Number} creationTime when the session was created (ms since the epoch) - * - * @property {object} sharedWithDevices - * devices with which we have shared the session key - * userId -> {deviceId -> msgindex} - */ -function OutboundSessionInfo(sessionId, sharedHistory = false) { - this.sessionId = sessionId; - this.useCount = 0; - this.creationTime = new Date().getTime(); - this.sharedWithDevices = {}; - this.blockedDevicesNotified = {}; - this.sharedHistory = sharedHistory; -} - -/** - * Check if it's time to rotate the session - * - * @param {Number} rotationPeriodMsgs - * @param {Number} rotationPeriodMs - * @return {Boolean} - */ -OutboundSessionInfo.prototype.needsRotation = function( - rotationPeriodMsgs, rotationPeriodMs, -) { - const sessionLifetime = new Date().getTime() - this.creationTime; - - if (this.useCount >= rotationPeriodMsgs || - sessionLifetime >= rotationPeriodMs - ) { - logger.log( - "Rotating megolm session after " + this.useCount + - " messages, " + sessionLifetime + "ms", - ); - return true; - } - - return false; -}; - -OutboundSessionInfo.prototype.markSharedWithDevice = function( - userId, deviceId, chainIndex, -) { - if (!this.sharedWithDevices[userId]) { - this.sharedWithDevices[userId] = {}; - } - this.sharedWithDevices[userId][deviceId] = chainIndex; -}; - -OutboundSessionInfo.prototype.markNotifiedBlockedDevice = function( - userId, deviceId, -) { - if (!this.blockedDevicesNotified[userId]) { - this.blockedDevicesNotified[userId] = {}; - } - this.blockedDevicesNotified[userId][deviceId] = true; -}; - -/** - * Determine if this session has been shared with devices which it shouldn't - * have been. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - * - * @return {Boolean} true if we have shared the session with devices which aren't - * in devicesInRoom. - */ -OutboundSessionInfo.prototype.sharedWithTooManyDevices = function( - devicesInRoom, -) { - for (const userId in this.sharedWithDevices) { - if (!this.sharedWithDevices.hasOwnProperty(userId)) { - continue; - } - - if (!devicesInRoom.hasOwnProperty(userId)) { - logger.log("Starting new megolm session because we shared with " + userId); - return true; - } - - for (const deviceId in this.sharedWithDevices[userId]) { - if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { - continue; - } - - if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { - logger.log( - "Starting new megolm session because we shared with " + - userId + ":" + deviceId, - ); - return true; - } - } - } -}; - -/** - * Megolm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function MegolmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - - // the most recent attempt to set up a session. This is used to serialise - // the session setups, so that we have a race-free view of which session we - // are using, and which devices we have shared the keys with. It resolves - // with an OutboundSessionInfo (or undefined, for the first message in the - // room). - this._setupPromise = Promise.resolve(); - - // Map of outbound sessions by sessions ID. Used if we need a particular - // session (the session we're currently using to send is always obtained - // using _setupPromise). - this._outboundSessions = {}; - - // default rotation periods - this._sessionRotationPeriodMsgs = 100; - this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; - - if (params.config.rotation_period_ms !== undefined) { - this._sessionRotationPeriodMs = params.config.rotation_period_ms; - } - - if (params.config.rotation_period_msgs !== undefined) { - this._sessionRotationPeriodMsgs = params.config.rotation_period_msgs; - } -} -utils.inherits(MegolmEncryption, EncryptionAlgorithm); - -/** - * @private - * - * @param {module:models/room} room - * @param {Object} devicesInRoom The devices in this room, indexed by user ID - * @param {Object} blocked The devices that are blocked, indexed by user ID - * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm - * session creation - * - * @return {Promise} Promise which resolves to the - * OutboundSessionInfo when setup is complete. - */ -MegolmEncryption.prototype._ensureOutboundSession = async function( - room, devicesInRoom, blocked, singleOlmCreationPhase, -) { - let session; - - // takes the previous OutboundSessionInfo, and considers whether to create - // a new one. Also shares the key with any (new) devices in the room. - // Updates `session` to hold the final OutboundSessionInfo. - // - // returns a promise which resolves once the keyshare is successful. - const prepareSession = async (oldSession) => { - session = oldSession; - - const sharedHistory = isRoomSharedHistory(room); - - // history visibility changed - if (session && sharedHistory !== session.sharedHistory) { - session = null; - } - - // need to make a brand new session? - if (session && session.needsRotation(this._sessionRotationPeriodMsgs, - this._sessionRotationPeriodMs) - ) { - logger.log("Starting new megolm session because we need to rotate."); - session = null; - } - - // determine if we have shared with anyone we shouldn't have - if (session && session.sharedWithTooManyDevices(devicesInRoom)) { - session = null; - } - - if (!session) { - logger.log(`Starting new megolm session for room ${this._roomId}`); - session = await this._prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + - `for room ${this._roomId}`); - this._outboundSessions[session.sessionId] = session; - } - - // now check if we need to share with any devices - const shareMap = {}; - - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { - const key = deviceInfo.getIdentityKey(); - if (key == this._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - - if ( - !session.sharedWithDevices[userId] || - session.sharedWithDevices[userId][deviceId] === undefined - ) { - shareMap[userId] = shareMap[userId] || []; - shareMap[userId].push(deviceInfo); - } - } - } - - const key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId); - const payload = { - type: "m.room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": session.sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "org.matrix.msc3061.shared_history": sharedHistory, - }, - }; - const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this._olmDevice, this._baseApis, shareMap, - ); - - await Promise.all([ - (async () => { - // share keys with devices that we already have a session for - logger.debug(`Sharing keys with existing Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions( - session, key, payload, olmSessions, - ); - logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this._roomId}`); - const errorDevices = []; - - // meanwhile, establish olm sessions for devices that we don't - // already have a session for, and share keys with them. If - // we're doing two phases of olm session creation, use a - // shorter timeout when fetching one-time keys for the first - // phase. - const start = Date.now(); - const failedServers = []; - await this._shareKeyWithDevices( - session, key, payload, devicesWithoutSession, errorDevices, - singleOlmCreationPhase ? 10000 : 2000, failedServers, - ); - logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this._roomId}`); - - if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { - // perform the second phase of olm session creation if requested, - // and if the first phase didn't take too long - (async () => { - // Retry sending keys to devices that we were unable to establish - // an olm session for. This time, we use a longer timeout, but we - // do this in the background and don't block anything else while we - // do this. We only need to retry users from servers that didn't - // respond the first time. - const retryDevices = {}; - const failedServerMap = new Set; - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices = []; - for (const { userId, deviceInfo } of errorDevices) { - const userHS = userId.slice(userId.indexOf(":") + 1); - if (failedServerMap.has(userHS)) { - retryDevices[userId] = retryDevices[userId] || []; - retryDevices[userId].push(deviceInfo); - } else { - // if we aren't going to retry, then handle it - // as a failed device - failedDevices.push({ userId, deviceInfo }); - } - } - - logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this._roomId}`); - await this._shareKeyWithDevices( - session, key, payload, retryDevices, failedDevices, 30000, - ); - logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this._roomId}`); - - await this._notifyFailedOlmDevices(session, key, failedDevices); - })(); - } else { - await this._notifyFailedOlmDevices(session, key, errorDevices); - } - logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Notifying blocked devices in ${this._roomId}`); - // also, notify blocked devices that they're blocked - const blockedMap = {}; - let blockedCount = 0; - for (const [userId, userBlockedDevices] of Object.entries(blocked)) { - for (const [deviceId, device] of Object.entries(userBlockedDevices)) { - if ( - !session.blockedDevicesNotified[userId] || - session.blockedDevicesNotified[userId][deviceId] === undefined - ) { - blockedMap[userId] = blockedMap[userId] || {}; - blockedMap[userId][deviceId] = { device }; - blockedCount++; - } - } - } - - await this._notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified ${blockedCount} blocked devices in ${this._roomId}`); - })(), - ]); - }; - - // helper which returns the session prepared by prepareSession - function returnSession() { - return session; - } - - // first wait for the previous share to complete - const prom = this._setupPromise.then(prepareSession); - - // Ensure any failures are logged for debugging - prom.catch(e => { - logger.error(`Failed to ensure outbound session in ${this._roomId}`, e); - }); - - // _setupPromise resolves to `session` whether or not the share succeeds - this._setupPromise = prom.then(returnSession, returnSession); - - // but we return a promise which only resolves if the share was successful. - return prom.then(returnSession); -}; - -/** - * @private - * - * @param {boolean} sharedHistory - * - * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session - */ -MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) { - const sessionId = this._olmDevice.createOutboundGroupSession(); - const key = this._olmDevice.getOutboundGroupSessionKey(sessionId); - - await this._olmDevice.addInboundGroupSession( - this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, - key.key, { ed25519: this._olmDevice.deviceEd25519Key }, false, - { sharedHistory: sharedHistory }, - ); - - // don't wait for it to complete - this._crypto.backupManager.backupGroupSession( - this._olmDevice.deviceCurve25519Key, sessionId, - ); - - return new OutboundSessionInfo(sessionId, sharedHistory); -}; - -/** - * Determines what devices in devicesByUser don't have an olm session as given - * in devicemap. - * - * @private - * - * @param {object} devicemap the devices that have olm sessions, as returned by - * olmlib.ensureOlmSessionsForDevices. - * @param {object} devicesByUser a map of user IDs to array of deviceInfo - * @param {array} [noOlmDevices] an array to fill with devices that don't have - * olm sessions - * - * @return {array} an array of devices that don't have olm sessions. If - * noOlmDevices is specified, then noOlmDevices will be returned. - */ -MegolmEncryption.prototype._getDevicesWithoutSessions = function( - devicemap, devicesByUser, noOlmDevices, -) { - noOlmDevices = noOlmDevices || []; - - for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { - const sessionResults = devicemap[userId]; - - for (const deviceInfo of devicesToShareWith) { - const deviceId = deviceInfo.deviceId; - - const sessionResult = sessionResults[deviceId]; - if (!sessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - - noOlmDevices.push({ userId, deviceInfo }); - delete sessionResults[deviceId]; - - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - continue; - } - } - } - - return noOlmDevices; -}; - -/** - * Splits the user device map into multiple chunks to reduce the number of - * devices we encrypt to per API call. - * - * @private - * - * @param {object} devicesByUser map from userid to list of devices - * - * @return {array>} the blocked devices, split into chunks - */ -MegolmEncryption.prototype._splitDevices = function(devicesByUser) { - const maxDevicesPerRequest = 20; - - // use an array where the slices of a content map gets stored - let currentSlice = []; - const mapSlices = [currentSlice]; - - for (const [userId, userDevices] of Object.entries(devicesByUser)) { - for (const deviceInfo of Object.values(userDevices)) { - currentSlice.push({ - userId: userId, - deviceInfo: deviceInfo.device, - }); - } - - // We do this in the per-user loop as we prefer that all messages to the - // same user end up in the same API call to make it easier for the - // server (e.g. only have to send one EDU if a remote user, etc). This - // does mean that if a user has many devices we may go over the desired - // limit, but its not a hard limit so that is fine. - if (currentSlice.length > maxDevicesPerRequest) { - // the current slice is filled up. Start inserting into the next slice - currentSlice = []; - mapSlices.push(currentSlice); - } - } - if (currentSlice.length === 0) { - mapSlices.pop(); - } - return mapSlices; -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {number} chainIndex current chain index - * - * @param {object} userDeviceMap - * mapping from userId to deviceInfo - * - * @param {object} payload fields to include in the encrypted payload - * - * @return {Promise} Promise which resolves once the key sharing - * for the given userDeviceMap is generated and has been sent. - */ -MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( - session, chainIndex, userDeviceMap, payload, -) { - const contentMap = {}; - - const promises = []; - for (let i = 0; i < userDeviceMap.length; i++) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - const val = userDeviceMap[i]; - const userId = val.userId; - const deviceInfo = val.deviceInfo; - const deviceId = deviceInfo.deviceId; - - if (!contentMap[userId]) { - contentMap[userId] = {}; - } - contentMap[userId][deviceId] = encryptedContent; - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - - return Promise.all(promises).then(() => { - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - return this._baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { - // store that we successfully uploaded the keys of the current slice - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - session.markSharedWithDevice( - userId, deviceId, chainIndex, - ); - } - } - }); - }); -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {array} userDeviceMap list of blocked devices to notify - * - * @param {object} payload fields to include in the notification payload - * - * @return {Promise} Promise which resolves once the notifications - * for the given userDeviceMap is generated and has been sent. - */ -MegolmEncryption.prototype._sendBlockedNotificationsToDevices = async function( - session, userDeviceMap, payload, -) { - const contentMap = {}; - - for (const val of userDeviceMap) { - const userId = val.userId; - const blockedInfo = val.deviceInfo; - const deviceInfo = blockedInfo.deviceInfo; - const deviceId = deviceInfo.deviceId; - - const message = Object.assign({}, payload); - message.code = blockedInfo.code; - message.reason = blockedInfo.reason; - if (message.code === "m.no_olm") { - delete message.room_id; - delete message.session_id; - } - - if (!contentMap[userId]) { - contentMap[userId] = {}; - } - contentMap[userId][deviceId] = message; - } - - await this._baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); - - // store that we successfully uploaded the keys of the current slice - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - session.markNotifiedBlockedDevice(userId, deviceId); - } - } -}; - -/** - * Re-shares a megolm session key with devices if the key has already been - * sent to them. - * - * @param {string} senderKey The key of the originating device for the session - * @param {string} sessionId ID of the outbound session to share - * @param {string} userId ID of the user who owns the target device - * @param {module:crypto/deviceinfo} device The target device - */ -MegolmEncryption.prototype.reshareKeyWithDevice = async function( - senderKey, sessionId, userId, device, -) { - const obSessionInfo = this._outboundSessions[sessionId]; - if (!obSessionInfo) { - logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); - return; - } - - // The chain index of the key we previously sent this device - if (obSessionInfo.sharedWithDevices[userId] === undefined) { - logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); - return; - } - const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; - if (sentChainIndex === undefined) { - logger.debug( - "megolm session ID " + sessionId + " never shared with device " + - userId + ":" + device.deviceId, - ); - return; - } - - // get the key from the inbound session: the outbound one will already - // have been ratcheted to the next chain index. - const key = await this._olmDevice.getInboundGroupSessionKey( - this._roomId, senderKey, sessionId, sentChainIndex, - ); - - if (!key) { - logger.warn( - `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, - ); - return; - } - - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [device], - }, - ); - - const payload = { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - device, - payload, - ); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [userId]: { - [device.deviceId]: encryptedContent, - }, - }); - logger.debug(`Re-shared key for megolm session ${sessionId} ` + - `with ${userId}:${device.deviceId}`); -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key the session key as returned by - * OlmDevice.getOutboundGroupSessionKey - * - * @param {object} payload the base to-device message payload for sharing keys - * - * @param {object} devicesByUser - * map from userid to list of devices - * - * @param {array} errorDevices - * array that will be populated with the devices that we can't get an - * olm session for - * - * @param {Number} [otkTimeout] The timeout in milliseconds when requesting - * one-time keys for establishing new olm sessions. - * - * @param {Array} [failedServers] An array to fill with remote servers that - * failed to respond to one-time-key requests. - */ -MegolmEncryption.prototype._shareKeyWithDevices = async function( - session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers, -) { - logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`); - const devicemap = await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, - logger.withPrefix(`[${this._roomId}]`), - ); - logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`); - - this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); - - logger.debug(`Sharing keys with Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions(session, key, payload, devicemap); - logger.debug(`Shared keys with Olm sessions in ${this._roomId}`); -}; - -MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( - session, key, payload, devicemap, -) { - const userDeviceMaps = this._splitDevices(devicemap); - - for (let i = 0; i < userDeviceMaps.length; i++) { - const taskDetail = - `megolm keys for ${session.sessionId} ` + - `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`; - try { - logger.debug(`Sharing ${taskDetail}`); - await this._encryptAndSendKeysToDevices( - session, key.chain_index, userDeviceMaps[i], payload, - ); - logger.debug(`Shared ${taskDetail}`); - } catch (e) { - logger.error(`Failed to share ${taskDetail}`); - throw e; - } - } -}; - -/** - * Notify devices that we weren't able to create olm sessions. - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key - * - * @param {Array} failedDevices the devices that we were unable to - * create olm sessions for, as returned by _shareKeyWithDevices - */ -MegolmEncryption.prototype._notifyFailedOlmDevices = async function( - session, key, failedDevices, -) { - logger.debug( - `Notifying ${failedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); - - // mark the devices that failed as "handled" because we don't want to try - // to claim a one-time-key for dead devices on every message. - for (const { userId, deviceInfo } of failedDevices) { - const deviceId = deviceInfo.deviceId; - - session.markSharedWithDevice( - userId, deviceId, key.chain_index, - ); - } - - const filteredFailedDevices = - await this._olmDevice.filterOutNotifiedErrorDevices( - failedDevices, - ); - logger.debug( - `Filtered down to ${filteredFailedDevices.length} error devices ` + - `in ${this._roomId}`, - ); - const blockedMap = {}; - for (const { userId, deviceInfo } of filteredFailedDevices) { - blockedMap[userId] = blockedMap[userId] || {}; - // we use a similar format to what - // olmlib.ensureOlmSessionsForDevices returns, so that - // we can use the same function to split - blockedMap[userId][deviceInfo.deviceId] = { - device: { - code: "m.no_olm", - reason: WITHHELD_MESSAGES["m.no_olm"], - deviceInfo, - }, - }; - } - - // send the notifications - await this._notifyBlockedDevices(session, blockedMap); - logger.debug( - `Notified ${filteredFailedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); -}; - -/** - * Notify blocked devices that they have been blocked. - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} devicesByUser - * map from userid to device ID to blocked data - */ -MegolmEncryption.prototype._notifyBlockedDevices = async function( - session, devicesByUser, -) { - const payload = { - room_id: this._roomId, - session_id: session.sessionId, - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - }; - - const userDeviceMaps = this._splitDevices(devicesByUser); - - for (let i = 0; i < userDeviceMaps.length; i++) { - try { - await this._sendBlockedNotificationsToDevices( - session, userDeviceMaps[i], payload, - ); - logger.log(`Completed blacklist notification for ${session.sessionId} ` - + `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`); - } catch (e) { - logger.log(`blacklist notification for ${session.sessionId} in ` - + `${this._roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); - - throw e; - } - } -}; - -/** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param {module:models/room} room the room the event is in - */ -MegolmEncryption.prototype.prepareToEncrypt = function(room) { - if (this.encryptionPreparation) { - // We're already preparing something, so don't do anything else. - // FIXME: check if we need to restart - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; - logger.debug( - `Already started preparing to encrypt for ${this._roomId} ` + - `${elapsedTime} ms ago, skipping`, - ); - return; - } - - logger.debug(`Preparing to encrypt events for ${this._roomId}`); - - this.encryptionPreparationMetadata = { - startTime: Date.now(), - }; - this.encryptionPreparation = (async () => { - try { - logger.debug(`Getting devices in ${this._roomId}`); - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - // Drop unknown devices for now. When the message gets sent, we'll - // throw an error, but we'll still be prepared to send to the known - // devices. - this._removeUnknownDevices(devicesInRoom); - } - - logger.debug(`Ensuring outbound session in ${this._roomId}`); - await this._ensureOutboundSession(room, devicesInRoom, blocked, true); - - logger.debug(`Ready to encrypt events for ${this._roomId}`); - } catch (e) { - logger.error(`Failed to prepare to encrypt events for ${this._roomId}`, e); - } finally { - delete this.encryptionPreparationMetadata; - delete this.encryptionPreparation; - } - })(); -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -MegolmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - logger.log(`Starting to encrypt event for ${this._roomId}`); - - if (this.encryptionPreparation) { - // If we started sending keys, wait for it to be done. - // FIXME: check if we need to cancel - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - try { - await this.encryptionPreparation; - } catch (e) { - // ignore any errors -- if the preparation failed, we'll just - // restart everything here - } - } - - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - // check if any of these devices are not yet known to the user. - // if so, warn the user so they can verify or ignore. - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - this._checkForUnknownDevices(devicesInRoom); - } - - const session = await this._ensureOutboundSession(room, devicesInRoom, blocked); - const payloadJson = { - room_id: this._roomId, - type: eventType, - content: content, - }; - - const ciphertext = this._olmDevice.encryptGroupMessage( - session.sessionId, JSON.stringify(payloadJson), - ); - const encryptedContent = { - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: ciphertext, - session_id: session.sessionId, - // Include our device ID so that recipients can send us a - // m.new_device message if they don't have our session key. - // XXX: Do we still need this now that m.new_device messages - // no longer exist since #483? - device_id: this._deviceId, - }; - - session.useCount++; - return encryptedContent; -}; - -/** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * This should not normally be necessary. - */ -MegolmEncryption.prototype.forceDiscardSession = function() { - this._setupPromise = this._setupPromise.then(() => null); -}; - -/** - * Checks the devices we're about to send to and see if any are entirely - * unknown to the user. If so, warn the user, and mark them as known to - * give the user a chance to go verify them before re-sending this message. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) { - const unknownDevices = {}; - - Object.keys(devicesInRoom).forEach((userId)=>{ - Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ - const device = devicesInRoom[userId][deviceId]; - if (device.isUnverified() && !device.isKnown()) { - if (!unknownDevices[userId]) { - unknownDevices[userId] = {}; - } - unknownDevices[userId][deviceId] = device; - } - }); - }); - - if (Object.keys(unknownDevices).length) { - // it'd be kind to pass unknownDevices up to the user in this error - throw new UnknownDeviceError( - "This room contains unknown devices which have not been verified. " + - "We strongly recommend you verify them before continuing.", unknownDevices); - } -}; - -/** - * Remove unknown devices from a set of devices. The devicesInRoom parameter - * will be modified. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._removeUnknownDevices = function(devicesInRoom) { - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, device] of Object.entries(userDevices)) { - if (device.isUnverified() && !device.isKnown()) { - delete userDevices[deviceId]; - } - } - - if (Object.keys(userDevices).length === 0) { - delete devicesInRoom[userId]; - } - } -}; - -/** - * Get the list of unblocked devices for all users in the room - * - * @param {module:models/room} room - * - * @return {Promise} Promise which resolves to an array whose - * first element is a map from userId to deviceId to deviceInfo indicating - * the devices that messages should be encrypted to, and whose second - * element is a map from userId to deviceId to data indicating the devices - * that are in the room but that have been blocked - */ -MegolmEncryption.prototype._getDevicesInRoom = async function(room) { - const members = await room.getEncryptionTargetMembers(); - const roomMembers = members.map(function(u) { - return u.userId; - }); - - // The global value is treated as a default for when rooms don't specify a value. - let isBlacklisting = this._crypto.getGlobalBlacklistUnverifiedDevices(); - if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { - isBlacklisting = room.getBlacklistUnverifiedDevices(); - } - - // We are happy to use a cached version here: we assume that if we already - // have a list of the user's devices, then we already share an e2e room - // with them, which means that they will have announced any new devices via - // device_lists in their /sync response. This cache should then be maintained - // using all the device_lists changes and left fields. - // See https://github.com/vector-im/element-web/issues/2305 for details. - const devices = await this._crypto.downloadKeys(roomMembers, false); - const blocked = {}; - // remove any blocked devices - for (const userId in devices) { - if (!devices.hasOwnProperty(userId)) { - continue; - } - - const userDevices = devices[userId]; - for (const deviceId in userDevices) { - if (!userDevices.hasOwnProperty(deviceId)) { - continue; - } - - const deviceTrust = this._crypto.checkDeviceTrust(userId, deviceId); - - if (userDevices[deviceId].isBlocked() || - (!deviceTrust.isVerified() && isBlacklisting) - ) { - if (!blocked[userId]) { - blocked[userId] = {}; - } - const blockedInfo = userDevices[deviceId].isBlocked() - ? { - code: "m.blacklisted", - reason: WITHHELD_MESSAGES["m.blacklisted"], - } - : { - code: "m.unverified", - reason: WITHHELD_MESSAGES["m.unverified"], - }; - blockedInfo.deviceInfo = userDevices[deviceId]; - blocked[userId][deviceId] = blockedInfo; - delete userDevices[deviceId]; - } - } - } - - return [devices, blocked]; -}; - -/** - * Megolm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function MegolmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); - - // events which we couldn't decrypt due to unknown sessions / indexes: map from - // senderKey|sessionId to Set of MatrixEvents - this._pendingEvents = {}; - - // this gets stubbed out by the unit tests. - this.olmlib = olmlib; -} -utils.inherits(MegolmDecryption, DecryptionAlgorithm); - -const PROBLEM_DESCRIPTIONS = { - no_olm: "The sender was unable to establish a secure channel.", - unknown: "The secure channel with the sender was corrupted.", -}; - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting, or rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -MegolmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - - if (!content.sender_key || !content.session_id || - !content.ciphertext - ) { - throw new DecryptionError( - "MEGOLM_MISSING_FIELDS", - "Missing fields in input", - ); - } - - // we add the event to the pending list *before* we start decryption. - // - // then, if the key turns up while decryption is in progress (and - // decryption fails), we will schedule a retry. - // (fixes https://github.com/vector-im/element-web/issues/5001) - this._addEventToPendingList(event); - - let res; - try { - res = await this._olmDevice.decryptGroupMessage( - event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, - event.getId(), event.getTs(), - ); - } catch (e) { - if (e.name === "DecryptionError") { - // re-throw decryption errors as-is - throw e; - } - - let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; - - if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { - this._requestKeysForEvent(event); - - errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; - } - - throw new DecryptionError( - errorCode, - e ? e.toString() : "Unknown Error: Error is undefined", { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - if (res === null) { - // We've got a message for a session we don't have. - // - // (XXX: We might actually have received this key since we started - // decrypting, in which case we'll have scheduled a retry, and this - // request will be redundant. We could probably check to see if the - // event is still in the pending list; if not, a retry will have been - // scheduled, so we needn't send out the request here.) - this._requestKeysForEvent(event); - - // See if there was a problem with the olm session at the time the - // event was sent. Use a fuzz factor of 2 minutes. - const problem = await this._olmDevice.sessionMayHaveProblems( - content.sender_key, event.getTs() - 120000, - ); - if (problem) { - let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] - || PROBLEM_DESCRIPTIONS.unknown; - if (problem.fixed) { - problemDescription += - " Trying to create a new secure channel and re-requesting the keys."; - } - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - problemDescription, - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - "The sender's device has not sent us the keys for this message.", - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - // success. We can remove the event from the pending list, if that hasn't - // already happened. - this._removeEventFromPendingList(event); - - const payload = JSON.parse(res.result); - - // belt-and-braces check that the room id matches that indicated by the HS - // (this is somewhat redundant, since the megolm session is scoped to the - // room, so neither the sender nor a MITM can lie about the room_id). - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "MEGOLM_BAD_ROOM", - "Message intended for room " + payload.room_id, - ); - } - - return { - clearEvent: payload, - senderCurve25519Key: res.senderKey, - claimedEd25519Key: res.keysClaimed.ed25519, - forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, - untrusted: res.untrusted, - }; -}; - -MegolmDecryption.prototype._requestKeysForEvent = function(event) { - const wireContent = event.getWireContent(); - - const recipients = event.getKeyRequestRecipients(this._userId); - - this._crypto.requestRoomKey({ - room_id: event.getRoomId(), - algorithm: wireContent.algorithm, - sender_key: wireContent.sender_key, - session_id: wireContent.session_id, - }, recipients); -}; - -/** - * Add an event to the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._addEventToPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - if (!this._pendingEvents[senderKey]) { - this._pendingEvents[senderKey] = new Map(); - } - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents.has(sessionId)) { - senderPendingEvents.set(sessionId, new Set()); - } - senderPendingEvents.get(sessionId).add(event); -}; - -/** - * Remove an event from the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._removeEventFromPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - const senderPendingEvents = this._pendingEvents[senderKey]; - const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); - if (!pendingEvents) { - return; - } - - pendingEvents.delete(event); - if (pendingEvents.size === 0) { - senderPendingEvents.delete(senderKey); - } - if (senderPendingEvents.size === 0) { - delete this._pendingEvents[senderKey]; - } -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyEvent = function(event) { - const content = event.getContent(); - const sessionId = content.session_id; - let senderKey = event.getSenderKey(); - let forwardingKeyChain = []; - let exportFormat = false; - let keysClaimed; - - if (!content.room_id || - !sessionId || - !content.session_key - ) { - logger.error("key event is missing fields"); - return; - } - - if (!senderKey) { - logger.error("key event has no sender key (not encrypted?)"); - return; - } - - if (event.getType() == "m.forwarded_room_key") { - exportFormat = true; - forwardingKeyChain = content.forwarding_curve25519_key_chain; - if (!Array.isArray(forwardingKeyChain)) { - forwardingKeyChain = []; - } - - // copy content before we modify it - forwardingKeyChain = forwardingKeyChain.slice(); - forwardingKeyChain.push(senderKey); - - senderKey = content.sender_key; - if (!senderKey) { - logger.error("forwarded_room_key event is missing sender_key field"); - return; - } - - const ed25519Key = content.sender_claimed_ed25519_key; - if (!ed25519Key) { - logger.error( - `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, - ); - return; - } - - keysClaimed = { - ed25519: ed25519Key, - }; - } else { - keysClaimed = event.getKeysClaimed(); - } - - const extraSessionData = {}; - if (content["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - content.room_id, senderKey, forwardingKeyChain, sessionId, - content.session_key, keysClaimed, - exportFormat, extraSessionData, - ).then(() => { - // have another go at decrypting events sent with this session. - this._retryDecryption(senderKey, sessionId) - .then((success) => { - // cancel any outstanding room key requests for this session. - // Only do this if we managed to decrypt every message in the - // session, because if we didn't, we leave the other key - // requests in the hopes that someone sends us a key that - // includes an earlier index. - if (success) { - this._crypto.cancelRoomKeyRequest({ - algorithm: content.algorithm, - room_id: content.room_id, - session_id: content.session_id, - sender_key: senderKey, - }); - } - }); - }).then(() => { - // don't wait for the keys to be backed up for the server - this._crypto.backupManager.backupGroupSession(senderKey, content.session_id); - }).catch((e) => { - logger.error(`Error handling m.room_key_event: ${e}`); - }); -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { - const content = event.getContent(); - const senderKey = content.sender_key; - - if (content.code === "m.no_olm") { - const sender = event.getSender(); - logger.warn( - `${sender}:${senderKey} was unable to establish an olm session with us`, - ); - // if the sender says that they haven't been able to establish an olm - // session, let's proactively establish one - - // Note: after we record that the olm session has had a problem, we - // trigger retrying decryption for all the messages from the sender's - // key, so that we can update the error message to indicate the olm - // session problem. - - if (await this._olmDevice.getSessionIdForDevice(senderKey)) { - // a session has already been established, so we don't need to - // create a new one. - logger.debug("New session already created. Not creating a new one."); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - return; - } - let device = this._crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this._crypto.downloadKeys([sender], false); - device = this._crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - logger.info( - "Couldn't find device for identity key " + senderKey + - ": not establishing session", - ); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", false); - this.retryDecryptionFromSender(senderKey); - return; - } - } - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { [sender]: [device] }, false, - ); - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [sender]: { - [device.deviceId]: encryptedContent, - }, - }); - } else { - await this._olmDevice.addInboundGroupSessionWithheld( - content.room_id, senderKey, content.session_id, content.code, - content.reason, - ); - } -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.hasKeysForKeyRequest = function(keyRequest) { - const body = keyRequest.requestBody; - - return this._olmDevice.hasInboundSessionKeys( - body.room_id, - body.sender_key, - body.session_id, - // TODO: ratchet index - ); -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.shareKeysWithDevice = function(keyRequest) { - const userId = keyRequest.userId; - const deviceId = keyRequest.deviceId; - const deviceInfo = this._crypto.getStoredDevice(userId, deviceId); - const body = keyRequest.requestBody; - - this.olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [deviceInfo], - }, - ).then((devicemap) => { - const olmSessionResult = devicemap[userId][deviceId]; - if (!olmSessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - // - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - return null; - } - - logger.log( - "sharing keys for session " + body.sender_key + "|" - + body.session_id + " with device " - + userId + ":" + deviceId, - ); - - return this._buildKeyForwardingMessage( - body.room_id, body.sender_key, body.session_id, - ); - }).then((payload) => { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - return this.olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ).then(() => { - const contentMap = { - [userId]: { - [deviceId]: encryptedContent, - }, - }; - - // TODO: retries - return this._baseApis.sendToDevice("m.room.encrypted", contentMap); - }); - }); -}; - -MegolmDecryption.prototype._buildKeyForwardingMessage = async function( - roomId, senderKey, sessionId, -) { - const key = await this._olmDevice.getInboundGroupSessionKey( - roomId, senderKey, sessionId, - ); - - return { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": roomId, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; -}; - -/** - * @inheritdoc - * - * @param {module:crypto/OlmDevice.MegolmSessionData} session - * @param {object} [opts={}] options for the import - * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted - * @param {string} [opts.source] where the key came from - */ -MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { - const extraSessionData = {}; - if (opts.untrusted) { - extraSessionData.untrusted = true; - } - if (session["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - session.room_id, - session.sender_key, - session.forwarding_curve25519_key_chain, - session.session_id, - session.session_key, - session.sender_claimed_keys, - true, - extraSessionData, - ).then(() => { - if (opts.source !== "backup") { - // don't wait for it to complete - this._crypto.backupManager.backupGroupSession( - session.sender_key, session.session_id, - ).catch((e) => { - // This throws if the upload failed, but this is fine - // since it will have written it to the db and will retry. - logger.log("Failed to back up megolm session", e); - }); - } - // have another go at decrypting events sent with this session. - this._retryDecryption(session.sender_key, session.session_id); - }); -}; - -/** - * Have another go at decrypting events after we receive a key. Resolves once - * decryption has been re-attempted on all events. - * - * @private - * @param {String} senderKey - * @param {String} sessionId - * - * @return {Boolean} whether all messages were successfully decrypted - */ -MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionId) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - const pending = senderPendingEvents.get(sessionId); - if (!pending) { - return true; - } - - logger.debug("Retrying decryption on events", [...pending]); - - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto, { isRetry: true }); - } catch (e) { - // don't die if something goes wrong - } - })); - - // If decrypted successfully, they'll have been removed from _pendingEvents - return !((this._pendingEvents[senderKey] || {})[sessionId]); -}; - -MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - delete this._pendingEvents[senderKey]; - - await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto); - } catch (e) { - // don't die if something goes wrong - } - })); - })); - - return !this._pendingEvents[senderKey]; -}; - -MegolmDecryption.prototype.sendSharedHistoryInboundSessions = async function(devicesByUser) { - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, - ); - - logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); - - const sharedHistorySessions = - await this._olmDevice.getSharedHistoryInboundGroupSessions( - this._roomId, - ); - logger.log("shared-history sessions", sharedHistorySessions); - for (const [senderKey, sessionId] of sharedHistorySessions) { - const payload = await this._buildKeyForwardingMessage( - this._roomId, senderKey, sessionId, - ); - - const promises = []; - const contentMap = {}; - for (const [userId, devices] of Object.entries(devicesByUser)) { - contentMap[userId] = {}; - for (const deviceInfo of devices) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - contentMap[userId][deviceInfo.deviceId] = encryptedContent; - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - } - await Promise.all(promises); - - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - await this._baseApis.sendToDevice("m.room.encrypted", contentMap); - } -}; - -registerAlgorithm( - olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption, -); diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts new file mode 100644 index 000000000..e111703a8 --- /dev/null +++ b/src/crypto/algorithms/megolm.ts @@ -0,0 +1,1833 @@ +/* +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/megolm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, + UnknownDeviceError, +} from "./base"; +import { WITHHELD_MESSAGES } from '../OlmDevice'; +import { Room } from '../../models/room'; +import { DeviceInfo } from "../deviceinfo"; +import { IOlmSessionResult } from "../olmlib"; +import { DeviceInfoMap } from "../DeviceList"; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; + +// determine whether the key can be shared with invitees +export function isRoomSharedHistory(room: Room): boolean { + const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", ""); + // NOTE: if the room visibility is unset, it would normally default to + // "world_readable". + // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) + // But we will be paranoid here, and treat it as a situation where the room + // is not shared-history + const visibility = visibilityEvent?.getContent()?.history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + +interface IBlockedDevice { + code: string; + reason: string; + deviceInfo: DeviceInfo; +} + +interface IBlockedMap { + [userId: string]: { + [deviceId: string]: IBlockedDevice; + }; +} + +interface IOlmDevice { + userId: string; + deviceInfo: T; +} + +/* eslint-disable camelcase */ +interface IOutboundGroupSessionKey { + chain_index: number; + key: string; +} + +interface IMessage { + type: string; + content: { + algorithm: string; + room_id: string; + sender_key?: string; + sender_claimed_ed25519_key?: string; + session_id: string; + session_key: string; + chain_index: number; + forwarding_curve25519_key_chain?: string[]; + "org.matrix.msc3061.shared_history": boolean; + }; +} + +interface IKeyForwardingMessage extends IMessage { + type: "m.forwarded_room_key"; +} + +interface IPayload extends Partial { + code?: string; + reason?: string; + room_id?: string; + session_id?: string; + algorithm?: string; + sender_key?: string; +} +/* eslint-enable camelcase */ + +/** + * @private + * @constructor + * + * @param {string} sessionId + * @param {boolean} sharedHistory whether the session can be freely shared with + * other group members, according to the room history visibility settings + * + * @property {string} sessionId + * @property {Number} useCount number of times this session has been used + * @property {Number} creationTime when the session was created (ms since the epoch) + * + * @property {object} sharedWithDevices + * devices with which we have shared the session key + * userId -> {deviceId -> msgindex} + */ +class OutboundSessionInfo { + public useCount = 0; + public creationTime: number; + public sharedWithDevices: Record> = {}; + public blockedDevicesNotified: Record> = {}; + + constructor(public readonly sessionId: string, public readonly sharedHistory = false) { + this.creationTime = new Date().getTime(); + } + + /** + * Check if it's time to rotate the session + * + * @param {Number} rotationPeriodMsgs + * @param {Number} rotationPeriodMs + * @return {Boolean} + */ + public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { + const sessionLifetime = new Date().getTime() - this.creationTime; + + if (this.useCount >= rotationPeriodMsgs || + sessionLifetime >= rotationPeriodMs + ) { + logger.log( + "Rotating megolm session after " + this.useCount + + " messages, " + sessionLifetime + "ms", + ); + return true; + } + + return false; + } + + public markSharedWithDevice(userId: string, deviceId: string, chainIndex: number): void { + if (!this.sharedWithDevices[userId]) { + this.sharedWithDevices[userId] = {}; + } + this.sharedWithDevices[userId][deviceId] = chainIndex; + } + + public markNotifiedBlockedDevice(userId: string, deviceId: string): void { + if (!this.blockedDevicesNotified[userId]) { + this.blockedDevicesNotified[userId] = {}; + } + this.blockedDevicesNotified[userId][deviceId] = true; + } + + /** + * Determine if this session has been shared with devices which it shouldn't + * have been. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + * + * @return {Boolean} true if we have shared the session with devices which aren't + * in devicesInRoom. + */ + public sharedWithTooManyDevices(devicesInRoom: Record>): boolean { + for (const userId in this.sharedWithDevices) { + if (!this.sharedWithDevices.hasOwnProperty(userId)) { + continue; + } + + if (!devicesInRoom.hasOwnProperty(userId)) { + logger.log("Starting new megolm session because we shared with " + userId); + return true; + } + + for (const deviceId in this.sharedWithDevices[userId]) { + if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { + continue; + } + + if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { + logger.log( + "Starting new megolm session because we shared with " + + userId + ":" + deviceId, + ); + return true; + } + } + } + } +} + +/** + * Megolm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class MegolmEncryption extends EncryptionAlgorithm { + // the most recent attempt to set up a session. This is used to serialise + // the session setups, so that we have a race-free view of which session we + // are using, and which devices we have shared the keys with. It resolves + // with an OutboundSessionInfo (or undefined, for the first message in the + // room). + private setupPromise = Promise.resolve(undefined); + + // Map of outbound sessions by sessions ID. Used if we need a particular + // session (the session we're currently using to send is always obtained + // using setupPromise). + private outboundSessions: Record = {}; + + private readonly sessionRotationPeriodMsgs: number; + private readonly sessionRotationPeriodMs: number; + private encryptionPreparation: Promise; + private encryptionPreparationMetadata: { + startTime: number; + }; + + constructor(params) { + super(params); + + this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; + this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; + } + + /** + * @private + * + * @param {module:models/room} room + * @param {Object} devicesInRoom The devices in this room, indexed by user ID + * @param {Object} blocked The devices that are blocked, indexed by user ID + * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm + * session creation + * + * @return {Promise} Promise which resolves to the + * OutboundSessionInfo when setup is complete. + */ + private async ensureOutboundSession( + room: Room, + devicesInRoom: DeviceInfoMap, + blocked: IBlockedMap, + singleOlmCreationPhase = false, + ): Promise { + let session; + + // takes the previous OutboundSessionInfo, and considers whether to create + // a new one. Also shares the key with any (new) devices in the room. + // Updates `session` to hold the final OutboundSessionInfo. + // + // returns a promise which resolves once the keyshare is successful. + const prepareSession = async (oldSession: OutboundSessionInfo) => { + session = oldSession; + + const sharedHistory = isRoomSharedHistory(room); + + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + + // need to make a brand new session? + if (session && session.needsRotation(this.sessionRotationPeriodMsgs, + this.sessionRotationPeriodMs) + ) { + logger.log("Starting new megolm session because we need to rotate."); + session = null; + } + + // determine if we have shared with anyone we shouldn't have + if (session && session.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } + + if (!session) { + logger.log(`Starting new megolm session for room ${this.roomId}`); + session = await this.prepareNewSession(sharedHistory); + logger.log(`Started new megolm session ${session.sessionId} ` + + `for room ${this.roomId}`); + this.outboundSessions[session.sessionId] = session; + } + + // now check if we need to share with any devices + const shareMap = {}; + + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + + if ( + !session.sharedWithDevices[userId] || + session.sharedWithDevices[userId][deviceId] === undefined + ) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } + } + + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload: IPayload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, + }, + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( + this.olmDevice, this.baseApis, shareMap, + ); + + await Promise.all([ + (async () => { + // share keys with devices that we already have a session for + logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`); + const errorDevices = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers = []; + await this.shareKeyWithDevices( + session, key, payload, devicesWithoutSession, errorDevices, + singleOlmCreationPhase ? 10000 : 2000, failedServers, + ); + logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); + + if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async () => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices = {}; + const failedServerMap = new Set; + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices = []; + for (const { userId, deviceInfo } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices[userId] = retryDevices[userId] || []; + retryDevices[userId].push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ userId, deviceInfo }); + } + } + + logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); + await this.shareKeyWithDevices( + session, key, payload, retryDevices, failedDevices, 30000, + ); + logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); + + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Notifying blocked devices in ${this.roomId}`); + // also, notify blocked devices that they're blocked + const blockedMap: Record> = {}; + let blockedCount = 0; + for (const [userId, userBlockedDevices] of Object.entries(blocked)) { + for (const [deviceId, device] of Object.entries(userBlockedDevices)) { + if ( + !session.blockedDevicesNotified[userId] || + session.blockedDevicesNotified[userId][deviceId] === undefined + ) { + blockedMap[userId] = blockedMap[userId] || {}; + blockedMap[userId][deviceId] = { device }; + blockedCount++; + } + } + } + + await this.notifyBlockedDevices(session, blockedMap); + logger.debug(`Notified ${blockedCount} blocked devices in ${this.roomId}`); + })(), + ]); + }; + + // helper which returns the session prepared by prepareSession + function returnSession() { + return session; + } + + // first wait for the previous share to complete + const prom = this.setupPromise.then(prepareSession); + + // Ensure any failures are logged for debugging + prom.catch(e => { + logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); + }); + + // setupPromise resolves to `session` whether or not the share succeeds + this.setupPromise = prom.then(returnSession, returnSession); + + // but we return a promise which only resolves if the share was successful. + return prom.then(returnSession); + } + + /** + * @private + * + * @param {boolean} sharedHistory + * + * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session + */ + private async prepareNewSession(sharedHistory: boolean): Promise { + const sessionId = this.olmDevice.createOutboundGroupSession(); + const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); + + await this.olmDevice.addInboundGroupSession( + this.roomId, this.olmDevice.deviceCurve25519Key, [], sessionId, + key.key, { ed25519: this.olmDevice.deviceEd25519Key }, false, + { sharedHistory }, + ); + + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key, sessionId); + + return new OutboundSessionInfo(sessionId, sharedHistory); + } + + /** + * Determines what devices in devicesByUser don't have an olm session as given + * in devicemap. + * + * @private + * + * @param {object} devicemap the devices that have olm sessions, as returned by + * olmlib.ensureOlmSessionsForDevices. + * @param {object} devicesByUser a map of user IDs to array of deviceInfo + * @param {array} [noOlmDevices] an array to fill with devices that don't have + * olm sessions + * + * @return {array} an array of devices that don't have olm sessions. If + * noOlmDevices is specified, then noOlmDevices will be returned. + */ + private getDevicesWithoutSessions( + devicemap: Record>, + devicesByUser: Record, + noOlmDevices: IOlmDevice[] = [], + ): IOlmDevice[] { + for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { + const sessionResults = devicemap[userId]; + + for (const deviceInfo of devicesToShareWith) { + const deviceId = deviceInfo.deviceId; + + const sessionResult = sessionResults[deviceId]; + if (!sessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + + noOlmDevices.push({ userId, deviceInfo }); + delete sessionResults[deviceId]; + + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + continue; + } + } + } + + return noOlmDevices; + } + + /** + * Splits the user device map into multiple chunks to reduce the number of + * devices we encrypt to per API call. + * + * @private + * + * @param {object} devicesByUser map from userid to list of devices + * + * @return {array>} the blocked devices, split into chunks + */ + private splitDevices( + devicesByUser: Record>, + ): IOlmDevice[][] { + const maxDevicesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice: IOlmDevice[] = []; + const mapSlices = [currentSlice]; + + for (const [userId, userDevices] of Object.entries(devicesByUser)) { + for (const deviceInfo of Object.values(userDevices)) { + currentSlice.push({ + userId: userId, + deviceInfo: deviceInfo.device, + }); + } + + // We do this in the per-user loop as we prefer that all messages to the + // same user end up in the same API call to make it easier for the + // server (e.g. only have to send one EDU if a remote user, etc). This + // does mean that if a user has many devices we may go over the desired + // limit, but its not a hard limit so that is fine. + if (currentSlice.length > maxDevicesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + currentSlice = []; + mapSlices.push(currentSlice); + } + } + if (currentSlice.length === 0) { + mapSlices.pop(); + } + return mapSlices; + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {number} chainIndex current chain index + * + * @param {object} userDeviceMap + * mapping from userId to deviceInfo + * + * @param {object} payload fields to include in the encrypted payload + * + * @return {Promise} Promise which resolves once the key sharing + * for the given userDeviceMap is generated and has been sent. + */ + private encryptAndSendKeysToDevices( + session: OutboundSessionInfo, + chainIndex: number, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + const promises = []; + for (let i = 0; i < userDeviceMap.length; i++) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + const val = userDeviceMap[i]; + const userId = val.userId; + const deviceInfo = val.deviceInfo; + const deviceId = deviceInfo.deviceId; + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = encryptedContent; + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + + return Promise.all(promises).then(() => { + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { + // store that we successfully uploaded the keys of the current slice + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + session.markSharedWithDevice( + userId, deviceId, chainIndex, + ); + } + } + }); + }); + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {array} userDeviceMap list of blocked devices to notify + * + * @param {object} payload fields to include in the notification payload + * + * @return {Promise} Promise which resolves once the notifications + * for the given userDeviceMap is generated and has been sent. + */ + private async sendBlockedNotificationsToDevices( + session: OutboundSessionInfo, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + for (const val of userDeviceMap) { + const userId = val.userId; + const blockedInfo = val.deviceInfo; + const deviceInfo = blockedInfo.deviceInfo; + const deviceId = deviceInfo.deviceId; + + const message = Object.assign({}, payload); + message.code = blockedInfo.code; + message.reason = blockedInfo.reason; + if (message.code === "m.no_olm") { + delete message.room_id; + delete message.session_id; + } + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = message; + } + + await this.baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); + + // store that we successfully uploaded the keys of the current slice + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + session.markNotifiedBlockedDevice(userId, deviceId); + } + } + } + + /** + * Re-shares a megolm session key with devices if the key has already been + * sent to them. + * + * @param {string} senderKey The key of the originating device for the session + * @param {string} sessionId ID of the outbound session to share + * @param {string} userId ID of the user who owns the target device + * @param {module:crypto/deviceinfo} device The target device + */ + public async reshareKeyWithDevice( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise { + const obSessionInfo = this.outboundSessions[sessionId]; + if (!obSessionInfo) { + logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); + return; + } + + // The chain index of the key we previously sent this device + if (obSessionInfo.sharedWithDevices[userId] === undefined) { + logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); + return; + } + const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; + if (sentChainIndex === undefined) { + logger.debug( + "megolm session ID " + sessionId + " never shared with device " + + userId + ":" + device.deviceId, + ); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this.olmDevice.getInboundGroupSessionKey( + this.roomId, senderKey, sessionId, sentChainIndex, + ); + + if (!key) { + logger.warn( + `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [device], + }, + ); + + const payload = { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + device, + payload, + ); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [userId]: { + [device.deviceId]: encryptedContent, + }, + }); + logger.debug(`Re-shared key for megolm session ${sessionId} with ${userId}:${device.deviceId}`); + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key the session key as returned by + * OlmDevice.getOutboundGroupSessionKey + * + * @param {object} payload the base to-device message payload for sharing keys + * + * @param {object} devicesByUser + * map from userid to list of devices + * + * @param {array} errorDevices + * array that will be populated with the devices that we can't get an + * olm session for + * + * @param {Number} [otkTimeout] The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param {Array} [failedServers] An array to fill with remote servers that + * failed to respond to one-time-key requests. + */ + private async shareKeyWithDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicesByUser: Record, + errorDevices: IOlmDevice[], + otkTimeout: number, + failedServers?: string[], + ) { + logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); + const devicemap = await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, + logger.withPrefix(`[${this.roomId}]`), + ); + logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); + + this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); + + logger.debug(`Sharing keys with Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, devicemap); + logger.debug(`Shared keys with Olm sessions in ${this.roomId}`); + } + + private async shareKeyWithOlmSessions( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicemap: Record>, + ): Promise { + const userDeviceMaps = this.splitDevices(devicemap); + + for (let i = 0; i < userDeviceMaps.length; i++) { + const taskDetail = + `megolm keys for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`; + try { + logger.debug(`Sharing ${taskDetail}`); + await this.encryptAndSendKeysToDevices( + session, key.chain_index, userDeviceMaps[i], payload, + ); + logger.debug(`Shared ${taskDetail}`); + } catch (e) { + logger.error(`Failed to share ${taskDetail}`); + throw e; + } + } + } + + /** + * Notify devices that we weren't able to create olm sessions. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key + * + * @param {Array} failedDevices the devices that we were unable to + * create olm sessions for, as returned by shareKeyWithDevices + */ + private async notifyFailedOlmDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + failedDevices: IOlmDevice[], + ): Promise { + logger.debug( + `Notifying ${failedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + + // mark the devices that failed as "handled" because we don't want to try + // to claim a one-time-key for dead devices on every message. + for (const { userId, deviceInfo } of failedDevices) { + const deviceId = deviceInfo.deviceId; + + session.markSharedWithDevice( + userId, deviceId, key.chain_index, + ); + } + + const filteredFailedDevices = + await this.olmDevice.filterOutNotifiedErrorDevices( + failedDevices, + ); + logger.debug( + `Filtered down to ${filteredFailedDevices.length} error devices ` + + `in ${this.roomId}`, + ); + const blockedMap: Record> = {}; + for (const { userId, deviceInfo } of filteredFailedDevices) { + blockedMap[userId] = blockedMap[userId] || {}; + // we use a similar format to what + // olmlib.ensureOlmSessionsForDevices returns, so that + // we can use the same function to split + blockedMap[userId][deviceInfo.deviceId] = { + device: { + code: "m.no_olm", + reason: WITHHELD_MESSAGES["m.no_olm"], + deviceInfo, + }, + }; + } + + // send the notifications + await this.notifyBlockedDevices(session, blockedMap); + logger.debug( + `Notified ${filteredFailedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + } + + /** + * Notify blocked devices that they have been blocked. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} devicesByUser + * map from userid to device ID to blocked data + */ + private async notifyBlockedDevices( + session: OutboundSessionInfo, + devicesByUser: Record>, + ): Promise { + const payload: IPayload = { + room_id: this.roomId, + session_id: session.sessionId, + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + }; + + const userDeviceMaps = this.splitDevices(devicesByUser); + + for (let i = 0; i < userDeviceMaps.length; i++) { + try { + await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); + logger.log(`Completed blacklist notification for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`); + } catch (e) { + logger.log(`blacklist notification for ${session.sessionId} in ` + + `${this.roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); + + throw e; + } + } + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param {module:models/room} room the room the event is in + */ + public prepareToEncrypt(room: Room): void { + if (this.encryptionPreparation) { + // We're already preparing something, so don't do anything else. + // FIXME: check if we need to restart + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; + logger.debug( + `Already started preparing to encrypt for ${this.roomId} ` + + `${elapsedTime} ms ago, skipping`, + ); + return; + } + + logger.debug(`Preparing to encrypt events for ${this.roomId}`); + + this.encryptionPreparationMetadata = { + startTime: Date.now(), + }; + this.encryptionPreparation = (async () => { + try { + logger.debug(`Getting devices in ${this.roomId}`); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } + + logger.debug(`Ensuring outbound session in ${this.roomId}`); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + + logger.debug(`Ready to encrypt events for ${this.roomId}`); + } catch (e) { + logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); + } finally { + delete this.encryptionPreparationMetadata; + delete this.encryptionPreparation; + } + })(); + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + logger.log(`Starting to encrypt event for ${this.roomId}`); + + if (this.encryptionPreparation) { + // If we started sending keys, wait for it to be done. + // FIXME: check if we need to cancel + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + try { + await this.encryptionPreparation; + } catch (e) { + // ignore any errors -- if the preparation failed, we'll just + // restart everything here + } + } + + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + this.checkForUnknownDevices(devicesInRoom); + } + + const session = await this.ensureOutboundSession(room, devicesInRoom, blocked); + const payloadJson = { + room_id: this.roomId, + type: eventType, + content: content, + }; + + const ciphertext = this.olmDevice.encryptGroupMessage( + session.sessionId, JSON.stringify(payloadJson), + ); + const encryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: ciphertext, + session_id: session.sessionId, + // Include our device ID so that recipients can send us a + // m.new_device message if they don't have our session key. + // XXX: Do we still need this now that m.new_device messages + // no longer exist since #483? + device_id: this.deviceId, + }; + + session.useCount++; + return encryptedContent; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * This should not normally be necessary. + */ + public forceDiscardSession(): void { + this.setupPromise = this.setupPromise.then(() => null); + } + + /** + * Checks the devices we're about to send to and see if any are entirely + * unknown to the user. If so, warn the user, and mark them as known to + * give the user a chance to go verify them before re-sending this message. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { + const unknownDevices = {}; + + Object.keys(devicesInRoom).forEach((userId)=>{ + Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ + const device = devicesInRoom[userId][deviceId]; + if (device.isUnverified() && !device.isKnown()) { + if (!unknownDevices[userId]) { + unknownDevices[userId] = {}; + } + unknownDevices[userId][deviceId] = device; + } + }); + }); + + if (Object.keys(unknownDevices).length) { + // it'd be kind to pass unknownDevices up to the user in this error + throw new UnknownDeviceError( + "This room contains unknown devices which have not been verified. " + + "We strongly recommend you verify them before continuing.", unknownDevices); + } + } + + /** + * Remove unknown devices from a set of devices. The devicesInRoom parameter + * will be modified. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, device] of Object.entries(userDevices)) { + if (device.isUnverified() && !device.isKnown()) { + delete userDevices[deviceId]; + } + } + + if (Object.keys(userDevices).length === 0) { + delete devicesInRoom[userId]; + } + } + } + + /** + * Get the list of unblocked devices for all users in the room + * + * @param {module:models/room} room + * + * @return {Promise} Promise which resolves to an array whose + * first element is a map from userId to deviceId to deviceInfo indicating + * the devices that messages should be encrypted to, and whose second + * element is a map from userId to deviceId to data indicating the devices + * that are in the room but that have been blocked + */ + private async getDevicesInRoom(room: Room): Promise<[DeviceInfoMap, IBlockedMap]> { + const members = await room.getEncryptionTargetMembers(); + const roomMembers = members.map(function(u) { + return u.userId; + }); + + // The global value is treated as a default for when rooms don't specify a value. + let isBlacklisting = this.crypto.getGlobalBlacklistUnverifiedDevices(); + if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { + isBlacklisting = room.getBlacklistUnverifiedDevices(); + } + + // We are happy to use a cached version here: we assume that if we already + // have a list of the user's devices, then we already share an e2e room + // with them, which means that they will have announced any new devices via + // device_lists in their /sync response. This cache should then be maintained + // using all the device_lists changes and left fields. + // See https://github.com/vector-im/element-web/issues/2305 for details. + const devices = await this.crypto.downloadKeys(roomMembers, false); + const blocked: IBlockedMap = {}; + // remove any blocked devices + for (const userId in devices) { + if (!devices.hasOwnProperty(userId)) { + continue; + } + + const userDevices = devices[userId]; + for (const deviceId in userDevices) { + if (!userDevices.hasOwnProperty(deviceId)) { + continue; + } + + const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); + + if (userDevices[deviceId].isBlocked() || + (!deviceTrust.isVerified() && isBlacklisting) + ) { + if (!blocked[userId]) { + blocked[userId] = {}; + } + const isBlocked = userDevices[deviceId].isBlocked(); + blocked[userId][deviceId] = { + code: isBlocked ? "m.blacklisted" : "m.unverified", + reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], + deviceInfo: userDevices[deviceId], + }; + delete userDevices[deviceId]; + } + } + } + + return [devices, blocked]; + } +} + +/** + * Megolm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class MegolmDecryption extends DecryptionAlgorithm { + // events which we couldn't decrypt due to unknown sessions / indexes: map from + // senderKey|sessionId to Set of MatrixEvents + private pendingEvents: Record>> = {}; + + // this gets stubbed out by the unit tests. + private olmlib = olmlib; + + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting, or rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + + if (!content.sender_key || !content.session_id || + !content.ciphertext + ) { + throw new DecryptionError( + "MEGOLM_MISSING_FIELDS", + "Missing fields in input", + ); + } + + // we add the event to the pending list *before* we start decryption. + // + // then, if the key turns up while decryption is in progress (and + // decryption fails), we will schedule a retry. + // (fixes https://github.com/vector-im/element-web/issues/5001) + this.addEventToPendingList(event); + + let res; + try { + res = await this.olmDevice.decryptGroupMessage( + event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, + event.getId(), event.getTs(), + ); + } catch (e) { + if (e.name === "DecryptionError") { + // re-throw decryption errors as-is + throw e; + } + + let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; + + if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { + this.requestKeysForEvent(event); + + errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; + } + + throw new DecryptionError( + errorCode, + e ? e.toString() : "Unknown Error: Error is undefined", { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + if (res === null) { + // We've got a message for a session we don't have. + // + // (XXX: We might actually have received this key since we started + // decrypting, in which case we'll have scheduled a retry, and this + // request will be redundant. We could probably check to see if the + // event is still in the pending list; if not, a retry will have been + // scheduled, so we needn't send out the request here.) + this.requestKeysForEvent(event); + + // See if there was a problem with the olm session at the time the + // event was sent. Use a fuzz factor of 2 minutes. + const problem = await this.olmDevice.sessionMayHaveProblems( + content.sender_key, event.getTs() - 120000, + ); + if (problem) { + let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] + || PROBLEM_DESCRIPTIONS.unknown; + if (problem.fixed) { + problemDescription += + " Trying to create a new secure channel and re-requesting the keys."; + } + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + problemDescription, + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + "The sender's device has not sent us the keys for this message.", + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + // success. We can remove the event from the pending list, if that hasn't + // already happened. + this.removeEventFromPendingList(event); + + const payload = JSON.parse(res.result); + + // belt-and-braces check that the room id matches that indicated by the HS + // (this is somewhat redundant, since the megolm session is scoped to the + // room, so neither the sender nor a MITM can lie about the room_id). + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "MEGOLM_BAD_ROOM", + "Message intended for room " + payload.room_id, + ); + } + + return { + clearEvent: payload, + senderCurve25519Key: res.senderKey, + claimedEd25519Key: res.keysClaimed.ed25519, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + untrusted: res.untrusted, + }; + } + + private requestKeysForEvent(event: MatrixEvent): void { + const wireContent = event.getWireContent(); + + const recipients = event.getKeyRequestRecipients(this.userId); + + this.crypto.requestRoomKey({ + room_id: event.getRoomId(), + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id, + }, recipients); + } + + /** + * Add an event to the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private addEventToPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + if (!this.pendingEvents[senderKey]) { + this.pendingEvents[senderKey] = new Map(); + } + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents.has(sessionId)) { + senderPendingEvents.set(sessionId, new Set()); + } + senderPendingEvents.get(sessionId).add(event); + } + + /** + * Remove an event from the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private removeEventFromPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.pendingEvents[senderKey]; + const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); + if (!pendingEvents) { + return; + } + + pendingEvents.delete(event); + if (pendingEvents.size === 0) { + senderPendingEvents.delete(senderKey); + } + if (senderPendingEvents.size === 0) { + delete this.pendingEvents[senderKey]; + } + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public onRoomKeyEvent(event: MatrixEvent): void { + const content = event.getContent(); + const sessionId = content.session_id; + let senderKey = event.getSenderKey(); + let forwardingKeyChain = []; + let exportFormat = false; + let keysClaimed; + + if (!content.room_id || + !sessionId || + !content.session_key + ) { + logger.error("key event is missing fields"); + return; + } + + if (!senderKey) { + logger.error("key event has no sender key (not encrypted?)"); + return; + } + + if (event.getType() == "m.forwarded_room_key") { + exportFormat = true; + forwardingKeyChain = content.forwarding_curve25519_key_chain; + if (!Array.isArray(forwardingKeyChain)) { + forwardingKeyChain = []; + } + + // copy content before we modify it + forwardingKeyChain = forwardingKeyChain.slice(); + forwardingKeyChain.push(senderKey); + + senderKey = content.sender_key; + if (!senderKey) { + logger.error("forwarded_room_key event is missing sender_key field"); + return; + } + + const ed25519Key = content.sender_claimed_ed25519_key; + if (!ed25519Key) { + logger.error( + `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, + ); + return; + } + + keysClaimed = { + ed25519: ed25519Key, + }; + } else { + keysClaimed = event.getKeysClaimed(); + } + + const extraSessionData: any = {}; + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + content.room_id, senderKey, forwardingKeyChain, sessionId, + content.session_key, keysClaimed, + exportFormat, extraSessionData, + ).then(() => { + // have another go at decrypting events sent with this session. + this.retryDecryption(senderKey, sessionId) + .then((success) => { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + if (success) { + this.crypto.cancelRoomKeyRequest({ + algorithm: content.algorithm, + room_id: content.room_id, + session_id: content.session_id, + sender_key: senderKey, + }); + } + }); + }).then(() => { + // don't wait for the keys to be backed up for the server + this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); + }).catch((e) => { + logger.error(`Error handling m.room_key_event: ${e}`); + }); + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise { + const content = event.getContent(); + const senderKey = content.sender_key; + + if (content.code === "m.no_olm") { + const sender = event.getSender(); + logger.warn( + `${sender}:${senderKey} was unable to establish an olm session with us`, + ); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + // Note: after we record that the olm session has had a problem, we + // trigger retrying decryption for all the messages from the sender's + // key, so that we can update the error message to indicate the olm + // session problem. + + if (await this.olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + logger.debug("New session already created. Not creating a new one."); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + return; + } + let device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.crypto.downloadKeys([sender], false); + device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + logger.info( + "Couldn't find device for identity key " + senderKey + + ": not establishing session", + ); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); + this.retryDecryptionFromSender(senderKey); + return; + } + } + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { [sender]: [device] }, false, + ); + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + } else { + await this.olmDevice.addInboundGroupSessionWithheld( + content.room_id, senderKey, content.session_id, content.code, + content.reason, + ); + } + } + + /** + * @inheritdoc + */ + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { + const body = keyRequest.requestBody; + + return this.olmDevice.hasInboundSessionKeys( + body.room_id, + body.sender_key, + body.session_id, + // TODO: ratchet index + ); + } + + /** + * @inheritdoc + */ + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const deviceInfo = this.crypto.getStoredDevice(userId, deviceId); + const body = keyRequest.requestBody; + + this.olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [deviceInfo], + }, + ).then((devicemap) => { + const olmSessionResult = devicemap[userId][deviceId]; + if (!olmSessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return null; + } + + logger.log( + "sharing keys for session " + body.sender_key + "|" + + body.session_id + " with device " + + userId + ":" + deviceId, + ); + + return this.buildKeyForwardingMessage( + body.room_id, body.sender_key, body.session_id, + ); + }).then((payload) => { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + return this.olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + userId, + deviceInfo, + payload, + ).then(() => { + const contentMap = { + [userId]: { + [deviceId]: encryptedContent, + }, + }; + + // TODO: retries + return this.baseApis.sendToDevice("m.room.encrypted", contentMap); + }); + }); + } + + private async buildKeyForwardingMessage( + roomId: string, + senderKey: string, + sessionId: string, + ): Promise { + const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId); + + return { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + } + + /** + * @inheritdoc + * + * @param {module:crypto/OlmDevice.MegolmSessionData} session + * @param {object} [opts={}] options for the import + * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted + * @param {string} [opts.source] where the key came from + */ + public importRoomKey(session: IMegolmSessionData, opts: any = {}): Promise { + const extraSessionData: any = {}; + if (opts.untrusted) { + extraSessionData.untrusted = true; + } + if (session["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + session.room_id, + session.sender_key, + session.forwarding_curve25519_key_chain, + session.session_id, + session.session_key, + session.sender_claimed_keys, + true, + extraSessionData, + ).then(() => { + if (opts.source !== "backup") { + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession( + session.sender_key, session.session_id, + ).catch((e) => { + // This throws if the upload failed, but this is fine + // since it will have written it to the db and will retry. + logger.log("Failed to back up megolm session", e); + }); + } + // have another go at decrypting events sent with this session. + this.retryDecryption(session.sender_key, session.session_id); + }); + } + + /** + * Have another go at decrypting events after we receive a key. Resolves once + * decryption has been re-attempted on all events. + * + * @private + * @param {String} senderKey + * @param {String} sessionId + * + * @return {Boolean} whether all messages were successfully decrypted + */ + private async retryDecryption(senderKey: string, sessionId: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + const pending = senderPendingEvents.get(sessionId); + if (!pending) { + return true; + } + + logger.debug("Retrying decryption on events", [...pending]); + + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto, { isRetry: true }); + } catch (e) { + // don't die if something goes wrong + } + })); + + // If decrypted successfully, they'll have been removed from pendingEvents + return !((this.pendingEvents[senderKey] || {})[sessionId]); + } + + public async retryDecryptionFromSender(senderKey: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + delete this.pendingEvents[senderKey]; + + await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto); + } catch (e) { + // don't die if something goes wrong + } + })); + })); + + return !this.pendingEvents[senderKey]; + } + + public async sendSharedHistoryInboundSessions(devicesByUser: Record): Promise { + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); + + logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); + + const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); + logger.log("shared-history sessions", sharedHistorySessions); + for (const [senderKey, sessionId] of sharedHistorySessions) { + const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId); + + const promises = []; + const contentMap = {}; + for (const [userId, devices] of Object.entries(devicesByUser)) { + contentMap[userId] = {}; + for (const deviceInfo of devices) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + contentMap[userId][deviceInfo.deviceId] = encryptedContent; + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + } + await Promise.all(promises); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + await this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } + } +} + +const PROBLEM_DESCRIPTIONS = { + no_olm: "The sender was unable to establish a secure channel.", + unknown: "The secure channel with the sender was corrupted.", +}; + +registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); diff --git a/src/crypto/algorithms/olm.js b/src/crypto/algorithms/olm.js deleted file mode 100644 index 74444b75a..000000000 --- a/src/crypto/algorithms/olm.js +++ /dev/null @@ -1,361 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/olm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { DeviceInfo } from "../deviceinfo"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, -} from "./base"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -/** - * Olm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function OlmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - this._sessionPrepared = false; - this._prepPromise = null; -} -utils.inherits(OlmEncryption, EncryptionAlgorithm); - -/** - * @private - - * @param {string[]} roomMembers list of currently-joined users in the room - * @return {Promise} Promise which resolves when setup is complete - */ -OlmEncryption.prototype._ensureSession = function(roomMembers) { - if (this._prepPromise) { - // prep already in progress - return this._prepPromise; - } - - if (this._sessionPrepared) { - // prep already done - return Promise.resolve(); - } - - const self = this; - this._prepPromise = self._crypto.downloadKeys(roomMembers).then(function(res) { - return self._crypto.ensureOlmSessionsForUsers(roomMembers); - }).then(function() { - self._sessionPrepared = true; - }).finally(function() { - self._prepPromise = null; - }); - return this._prepPromise; -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - // pick the list of recipients based on the membership list. - // - // TODO: there is a race condition here! What if a new user turns up - // just as you are sending a secret message? - - const members = await room.getEncryptionTargetMembers(); - - const users = members.map(function(u) { - return u.userId; - }); - - const self = this; - await this._ensureSession(users); - - const payloadFields = { - room_id: room.roomId, - type: eventType, - content: content, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: self._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - const promises = []; - - for (let i = 0; i < users.length; ++i) { - const userId = users[i]; - const devices = self._crypto.getStoredDevicesForUser(userId); - - for (let j = 0; j < devices.length; ++j) { - const deviceInfo = devices[j]; - const key = deviceInfo.getIdentityKey(); - if (key == self._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - self._userId, self._deviceId, self._olmDevice, - userId, deviceInfo, payloadFields, - ), - ); - } - } - - return await Promise.all(promises).then(() => encryptedContent); -}; - -/** - * Olm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function OlmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); -} -utils.inherits(OlmDecryption, DecryptionAlgorithm); - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting. Rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -OlmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - const deviceKey = content.sender_key; - const ciphertext = content.ciphertext; - - if (!ciphertext) { - throw new DecryptionError( - "OLM_MISSING_CIPHERTEXT", - "Missing ciphertext", - ); - } - - if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) { - throw new DecryptionError( - "OLM_NOT_INCLUDED_IN_RECIPIENTS", - "Not included in recipients", - ); - } - const message = ciphertext[this._olmDevice.deviceCurve25519Key]; - let payloadString; - - try { - payloadString = await this._decryptMessage(deviceKey, message); - } catch (e) { - throw new DecryptionError( - "OLM_BAD_ENCRYPTED_MESSAGE", - "Bad Encrypted Message", { - sender: deviceKey, - err: e, - }, - ); - } - - const payload = JSON.parse(payloadString); - - // check that we were the intended recipient, to avoid unknown-key attack - // https://github.com/vector-im/vector-web/issues/2483 - if (payload.recipient != this._userId) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT", - "Message was intented for " + payload.recipient, - ); - } - - if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT_KEY", - "Message not intended for this device", { - intended: payload.recipient_keys.ed25519, - our_key: this._olmDevice.deviceEd25519Key, - }, - ); - } - - // check that the original sender matches what the homeserver told us, to - // avoid people masquerading as others. - // (this check is also provided via the sender's embedded ed25519 key, - // which is checked elsewhere). - if (payload.sender != event.getSender()) { - throw new DecryptionError( - "OLM_FORWARDED_MESSAGE", - "Message forwarded from " + payload.sender, { - reported_sender: event.getSender(), - }, - ); - } - - // Olm events intended for a room have a room_id. - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "OLM_BAD_ROOM", - "Message intended for room " + payload.room_id, { - reported_room: event.room_id, - }, - ); - } - - const claimedKeys = payload.keys || {}; - - return { - clearEvent: payload, - senderCurve25519Key: deviceKey, - claimedEd25519Key: claimedKeys.ed25519 || null, - }; -}; - -/** - * Attempt to decrypt an Olm message - * - * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender - * @param {object} message message object, with 'type' and 'body' fields - * - * @return {string} payload, if decrypted successfully. - */ -OlmDecryption.prototype._decryptMessage = async function( - theirDeviceIdentityKey, message, -) { - // This is a wrapper that serialises decryptions of prekey messages, because - // otherwise we race between deciding we have no active sessions for the message - // and creating a new one, which we can only do once because it removes the OTK. - if (message.type !== 0) { - // not a prekey message: we can safely just try & decrypt it - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - } else { - const myPromise = this._olmDevice._olmPrekeyPromise.then(() => { - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - }); - // we want the error, but don't propagate it to the next decryption - this._olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); - return await myPromise; - } -}; - -OlmDecryption.prototype._reallyDecryptMessage = async function( - theirDeviceIdentityKey, message, -) { - const sessionIds = await this._olmDevice.getSessionIdsForDevice( - theirDeviceIdentityKey, - ); - - // try each session in turn. - const decryptionErrors = {}; - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i]; - try { - const payload = await this._olmDevice.decryptMessage( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - logger.log( - "Decrypted Olm message from " + theirDeviceIdentityKey + - " with session " + sessionId, - ); - return payload; - } catch (e) { - const foundSession = await this._olmDevice.matchesSession( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - - if (foundSession) { - // decryption failed, but it was a prekey message matching this - // session, so it should have worked. - throw new Error( - "Error decrypting prekey message with existing session id " + - sessionId + ": " + e.message, - ); - } - - // otherwise it's probably a message for another session; carry on, but - // keep a record of the error - decryptionErrors[sessionId] = e.message; - } - } - - if (message.type !== 0) { - // not a prekey message, so it should have matched an existing session, but it - // didn't work. - - if (sessionIds.length === 0) { - throw new Error("No existing sessions"); - } - - throw new Error( - "Error decrypting non-prekey message with existing sessions: " + - JSON.stringify(decryptionErrors), - ); - } - - // prekey message which doesn't match any existing sessions: make a new - // session. - - let res; - try { - res = await this._olmDevice.createInboundSession( - theirDeviceIdentityKey, message.type, message.body, - ); - } catch (e) { - decryptionErrors["(new)"] = e.message; - throw new Error( - "Error decrypting prekey message: " + - JSON.stringify(decryptionErrors), - ); - } - - logger.log( - "created new inbound Olm session ID " + - res.session_id + " with " + theirDeviceIdentityKey, - ); - return res.payload; -}; - -registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts new file mode 100644 index 000000000..d45365ba9 --- /dev/null +++ b/src/crypto/algorithms/olm.ts @@ -0,0 +1,355 @@ +/* +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/olm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { DeviceInfo } from "../deviceinfo"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, +} from "./base"; +import { Room } from '../../models/room'; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult } from "../index"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +interface IMessage { + type: number | string; + body: string; +} + +/** + * Olm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class OlmEncryption extends EncryptionAlgorithm { + private sessionPrepared = false; + private prepPromise: Promise = null; + + /** + * @private + + * @param {string[]} roomMembers list of currently-joined users in the room + * @return {Promise} Promise which resolves when setup is complete + */ + private ensureSession(roomMembers: string[]): Promise { + if (this.prepPromise) { + // prep already in progress + return this.prepPromise; + } + + if (this.sessionPrepared) { + // prep already done + return Promise.resolve(); + } + + this.prepPromise = this.crypto.downloadKeys(roomMembers).then((res) => { + return this.crypto.ensureOlmSessionsForUsers(roomMembers); + }).then(() => { + this.sessionPrepared = true; + }).finally(() => { + this.prepPromise = null; + }); + + return this.prepPromise; + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + // pick the list of recipients based on the membership list. + // + // TODO: there is a race condition here! What if a new user turns up + // just as you are sending a secret message? + + const members = await room.getEncryptionTargetMembers(); + + const users = members.map(function(u) { + return u.userId; + }); + + await this.ensureSession(users); + + const payloadFields = { + room_id: room.roomId, + type: eventType, + content: content, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + const promises = []; + + for (let i = 0; i < users.length; ++i) { + const userId = users[i]; + const devices = this.crypto.getStoredDevicesForUser(userId); + + for (let j = 0; j < devices.length; ++j) { + const deviceInfo = devices[j]; + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, this.deviceId, this.olmDevice, + userId, deviceInfo, payloadFields, + ), + ); + } + } + + return await Promise.all(promises).then(() => encryptedContent); + } +} + +/** + * Olm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class OlmDecryption extends DecryptionAlgorithm { + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting. Rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + const deviceKey = content.sender_key; + const ciphertext = content.ciphertext; + + if (!ciphertext) { + throw new DecryptionError( + "OLM_MISSING_CIPHERTEXT", + "Missing ciphertext", + ); + } + + if (!(this.olmDevice.deviceCurve25519Key in ciphertext)) { + throw new DecryptionError( + "OLM_NOT_INCLUDED_IN_RECIPIENTS", + "Not included in recipients", + ); + } + const message = ciphertext[this.olmDevice.deviceCurve25519Key]; + let payloadString; + + try { + payloadString = await this.decryptMessage(deviceKey, message); + } catch (e) { + throw new DecryptionError( + "OLM_BAD_ENCRYPTED_MESSAGE", + "Bad Encrypted Message", { + sender: deviceKey, + err: e, + }, + ); + } + + const payload = JSON.parse(payloadString); + + // check that we were the intended recipient, to avoid unknown-key attack + // https://github.com/vector-im/vector-web/issues/2483 + if (payload.recipient != this.userId) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT", + "Message was intented for " + payload.recipient, + ); + } + + if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT_KEY", + "Message not intended for this device", { + intended: payload.recipient_keys.ed25519, + our_key: this.olmDevice.deviceEd25519Key, + }, + ); + } + + // check that the original sender matches what the homeserver told us, to + // avoid people masquerading as others. + // (this check is also provided via the sender's embedded ed25519 key, + // which is checked elsewhere). + if (payload.sender != event.getSender()) { + throw new DecryptionError( + "OLM_FORWARDED_MESSAGE", + "Message forwarded from " + payload.sender, { + reported_sender: event.getSender(), + }, + ); + } + + // Olm events intended for a room have a room_id. + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "OLM_BAD_ROOM", + "Message intended for room " + payload.room_id, { + reported_room: event.getRoomId(), + }, + ); + } + + const claimedKeys = payload.keys || {}; + + return { + clearEvent: payload, + senderCurve25519Key: deviceKey, + claimedEd25519Key: claimedKeys.ed25519 || null, + }; + } + + /** + * Attempt to decrypt an Olm message + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender + * @param {object} message message object, with 'type' and 'body' fields + * + * @return {string} payload, if decrypted successfully. + */ + private async decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + // This is a wrapper that serialises decryptions of prekey messages, because + // otherwise we race between deciding we have no active sessions for the message + // and creating a new one, which we can only do once because it removes the OTK. + if (message.type !== 0) { + // not a prekey message: we can safely just try & decrypt it + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + } else { + const myPromise = this.olmDevice._olmPrekeyPromise.then(() => { + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + }); + // we want the error, but don't propagate it to the next decryption + this.olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); + return await myPromise; + } + } + + private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); + + // try each session in turn. + const decryptionErrors = {}; + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i]; + try { + const payload = await this.olmDevice.decryptMessage( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + logger.log( + "Decrypted Olm message from " + theirDeviceIdentityKey + + " with session " + sessionId, + ); + return payload; + } catch (e) { + const foundSession = await this.olmDevice.matchesSession( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + + if (foundSession) { + // decryption failed, but it was a prekey message matching this + // session, so it should have worked. + throw new Error( + "Error decrypting prekey message with existing session id " + + sessionId + ": " + e.message, + ); + } + + // otherwise it's probably a message for another session; carry on, but + // keep a record of the error + decryptionErrors[sessionId] = e.message; + } + } + + if (message.type !== 0) { + // not a prekey message, so it should have matched an existing session, but it + // didn't work. + + if (sessionIds.length === 0) { + throw new Error("No existing sessions"); + } + + throw new Error( + "Error decrypting non-prekey message with existing sessions: " + + JSON.stringify(decryptionErrors), + ); + } + + // prekey message which doesn't match any existing sessions: make a new + // session. + + let res; + try { + res = await this.olmDevice.createInboundSession( + theirDeviceIdentityKey, message.type, message.body, + ); + } catch (e) { + decryptionErrors["(new)"] = e.message; + throw new Error( + "Error decrypting prekey message: " + + JSON.stringify(decryptionErrors), + ); + } + + logger.log( + "created new inbound Olm session ID " + + res.session_id + " with " + theirDeviceIdentityKey, + ); + return res.payload; + } +} + +registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/api.ts b/src/crypto/api.ts index 39469a83a..8daafb6d7 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { DeviceInfo } from "./deviceinfo"; -import { IKeyBackupVersion } from "./keybackup"; +import { IKeyBackupInfo } from "./keybackup"; import { ISecretStorageKeyInfo } from "../matrix"; // TODO: Merge this with crypto.js once converted @@ -85,7 +85,7 @@ export interface ICreateSecretStorageOpts { * The current key backup object. If passed, * the passphrase and recovery key from this backup will be used. */ - keyBackupInfo?: IKeyBackupVersion; + keyBackupInfo?: IKeyBackupInfo; /** * If true, a new key backup version will be diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index f2b62cc01..3a8422a74 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -29,16 +29,11 @@ import { keyFromPassphrase } from './key_passphrase'; import { sleep } from "../utils"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; import { encodeRecoveryKey } from './recoverykey'; +import { IKeyBackupInfo } from "./keybackup"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; -type AuthData = Record; - -type BackupInfo = { - algorithm: string, - auth_data: AuthData, // eslint-disable-line camelcase - [properties: string]: any, -}; +type AuthData = IKeyBackupInfo["auth_data"]; type SigInfo = { deviceId: string, @@ -54,13 +49,22 @@ export type TrustInfo = { }; export interface IKeyBackupCheck { - backupInfo: BackupInfo; + backupInfo: IKeyBackupInfo; trustInfo: TrustInfo; } +/* eslint-disable camelcase */ +export interface IPreparedKeyBackupVersion { + algorithm: string; + auth_data: AuthData; + recovery_key: string; + privateKey: Uint8Array; +} +/* eslint-enable camelcase */ + /** A function used to get the secret key for a backup. */ -type GetKey = () => Promise; +type GetKey = () => Promise>; interface BackupAlgorithmClass { algorithmName: string; @@ -77,7 +81,7 @@ interface BackupAlgorithm { encryptSession(data: Record): Promise; decryptSessions(ciphertexts: Record): Promise[]>; authData: AuthData; - keyMatches(key: Uint8Array): Promise; + keyMatches(key: ArrayLike): Promise; free(): void; } @@ -86,7 +90,7 @@ interface BackupAlgorithm { */ export class BackupManager { private algorithm: BackupAlgorithm | undefined; - public backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version + public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version public checkedForBackup: boolean; // Have we checked the server for a backup we can use? private sendingBackups: boolean; // Are we currently sending backups? constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { @@ -98,7 +102,7 @@ export class BackupManager { return this.backupInfo && this.backupInfo.version; } - public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise { + public static async makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise { const Algorithm = algorithmsByName[info.algorithm]; if (!Algorithm) { throw new Error("Unknown backup algorithm"); @@ -106,7 +110,7 @@ export class BackupManager { return await Algorithm.init(info.auth_data, getKey); } - public async enableKeyBackup(info: BackupInfo): Promise { + public async enableKeyBackup(info: IKeyBackupInfo): Promise { this.backupInfo = info; if (this.algorithm) { this.algorithm.free(); @@ -145,7 +149,8 @@ export class BackupManager { public async prepareKeyBackupVersion( key?: string | Uint8Array | null, algorithm?: string | undefined, - ): Promise { + // eslint-disable-next-line camelcase + ): Promise { const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; if (!Algorithm) { throw new Error("Unknown backup algorithm"); @@ -161,7 +166,7 @@ export class BackupManager { }; } - public async createKeyBackupVersion(info: BackupInfo): Promise { + public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); } @@ -171,14 +176,14 @@ export class BackupManager { * one of the user's verified devices, start backing up * to it. */ - public async checkAndStart(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> { + public async checkAndStart(): Promise { logger.log("Checking key backup status..."); if (this.baseApis.isGuest()) { logger.log("Skipping key backup check since user is guest"); this.checkedForBackup = true; return null; } - let backupInfo: BackupInfo; + let backupInfo: IKeyBackupInfo; try { backupInfo = await this.baseApis.getKeyBackupVersion(); } catch (e) { @@ -255,7 +260,7 @@ export class BackupManager { * ] * } */ - public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise { + public async isKeyBackupTrusted(backupInfo: IKeyBackupInfo): Promise { const ret = { usable: false, trusted_locally: false, @@ -569,7 +574,7 @@ export class Curve25519 implements BackupAlgorithm { ): Promise<[Uint8Array, AuthData]> { const decryption = new global.Olm.PkDecryption(); try { - const authData: AuthData = {}; + const authData: Partial = {}; if (!key) { authData.public_key = decryption.generate_key(); } else if (key instanceof Uint8Array) { @@ -585,7 +590,7 @@ export class Curve25519 implements BackupAlgorithm { return [ decryption.get_private_key(), - authData, + authData as AuthData, ]; } finally { decryption.free(); diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index 73c3cd536..95fb77752 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -44,7 +44,7 @@ interface DeviceKeys { signatures?: Signatures; } -interface OneTimeKey { +export interface OneTimeKey { key: string; fallback?: boolean; signatures?: Signatures; diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts index d723eac4b..870899349 100644 --- a/src/crypto/deviceinfo.ts +++ b/src/crypto/deviceinfo.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { ISignatures } from "../@types/signed"; + /** * @module crypto/deviceinfo */ @@ -24,7 +26,7 @@ export interface IDevice { verified: DeviceVerification; known: boolean; unsigned?: Record; - signatures?: Record; + signatures?: ISignatures; } enum DeviceVerification { @@ -90,7 +92,7 @@ export class DeviceInfo { public verified = DeviceVerification.Unverified; public known = false; public unsigned: Record = {}; - public signatures: Record = {}; + public signatures: ISignatures = {}; constructor(public readonly deviceId: string) {} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 930f5fbb6..070f8f5d9 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -28,7 +28,7 @@ import { ReEmitter } from '../ReEmitter'; import { logger } from '../logger'; import { OlmDevice } from "./OlmDevice"; import * as olmlib from "./olmlib"; -import { DeviceList } from "./DeviceList"; +import { DeviceInfoMap, DeviceList } from "./DeviceList"; import { DeviceInfo, IDevice } from "./deviceinfo"; import * as algorithms from "./algorithms"; import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; @@ -52,10 +52,11 @@ import { IStore } from "../store"; import { Room } from "../models/room"; import { RoomMember } from "../models/room-member"; import { MatrixEvent } from "../models/event"; -import { MatrixClient, IKeysUploadResponse, SessionStore, CryptoStore } from "../client"; +import { MatrixClient, IKeysUploadResponse, SessionStore, CryptoStore, ISignedKey } from "../client"; import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base"; import type { RoomList } from "./RoomList"; import { IRecoveryKey, IEncryptedEventInfo } from "./api"; +import { IKeyBackupInfo } from "./keybackup"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -91,7 +92,7 @@ interface IInitOpts { export interface IBootstrapCrossSigningOpts { setupNewCrossSigning?: boolean; - authUploadDeviceSigningKeys?(makeRequest: (authData: any) => void): Promise; + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => {}): Promise; } interface IBootstrapSecretStorageOpts { @@ -111,18 +112,19 @@ interface IRoomKey { algorithm: string; } -interface IRoomKeyRequestBody extends IRoomKey { +export interface IRoomKeyRequestBody extends IRoomKey { session_id: string; sender_key: string } -interface IMegolmSessionData { +export interface IMegolmSessionData { sender_key: string; forwarding_curve25519_key_chain: string[]; sender_claimed_keys: Record; room_id: string; session_id: string; session_key: string; + algorithm: string; } /* eslint-enable camelcase */ @@ -138,11 +140,6 @@ interface IDeviceVerificationUpgrade { * could be established */ -interface IOlmSessionResult { - device: DeviceInfo; - sessionId?: string; -} - interface IUserOlmSession { deviceIdKey: string; sessions: { @@ -162,7 +159,7 @@ interface ISyncDeviceLists { left: string[]; } -interface IRoomKeyRequestRecipient { +export interface IRoomKeyRequestRecipient { userId: string; deviceId: string; } @@ -172,7 +169,7 @@ interface ISignableObject { unsigned?: object } -interface IEventDecryptionResult { +export interface IEventDecryptionResult { clearEvent: object; senderCurve25519Key?: string; claimedEd25519Key?: string; @@ -197,7 +194,7 @@ export class Crypto extends EventEmitter { private readonly reEmitter: ReEmitter; private readonly verificationMethods: any; // TODO types - private readonly supportedAlgorithms: DecryptionAlgorithm[]; + private readonly supportedAlgorithms: string[]; private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; private readonly toDeviceVerificationRequests: ToDeviceRequests; private readonly inRoomVerificationRequests: InRoomRequests; @@ -281,7 +278,7 @@ export class Crypto extends EventEmitter { * or a class that implements a verification method. */ constructor( - private readonly baseApis: MatrixClient, + public readonly baseApis: MatrixClient, public readonly sessionStore: SessionStore, private readonly userId: string, private readonly deviceId: string, @@ -627,7 +624,7 @@ export class Crypto extends EventEmitter { // Cross-sign own device const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); - const deviceSignature = await crossSigningInfo.signDevice(this.userId, device); + const deviceSignature = await crossSigningInfo.signDevice(this.userId, device) as ISignedKey; builder.addKeySignature(this.userId, this.deviceId, deviceSignature); // Sign message key backup with cross-signing master key @@ -937,7 +934,7 @@ export class Crypto extends EventEmitter { await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); // create keyBackupInfo object to add to builder - const data = { + const data: IKeyBackupInfo = { algorithm: info.algorithm, auth_data: info.auth_data, }; @@ -1079,17 +1076,17 @@ export class Crypto extends EventEmitter { * @param {Uint8Array} key the private key * @returns {Promise} so you can catch failures */ - public async storeSessionBackupPrivateKey(key: Uint8Array): Promise { + public async storeSessionBackupPrivateKey(key: ArrayLike): Promise { if (!(key instanceof Uint8Array)) { throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); } const pickleKey = Buffer.from(this.olmDevice._pickleKey); - key = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); + const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); return this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", key); + this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); }, ); } @@ -1926,10 +1923,7 @@ export class Crypto extends EventEmitter { * @return {Promise} A promise which resolves to a map userId->deviceId->{@link * module:crypto/deviceinfo|DeviceInfo}. */ - public downloadKeys( - userIds: string[], - forceDownload?: boolean, - ): Promise>> { + public downloadKeys(userIds: string[], forceDownload?: boolean): Promise { return this.deviceList.downloadKeys(userIds, forceDownload); } @@ -2573,7 +2567,7 @@ export class Crypto extends EventEmitter { * an Object mapping from userId to deviceId to * {@link module:crypto~OlmSessionResult} */ - ensureOlmSessionsForUsers(users: string[]): Promise { + ensureOlmSessionsForUsers(users: string[]): Promise>> { const devicesByUser = {}; for (let i = 0; i < users.length; ++i) { @@ -2598,9 +2592,7 @@ export class Crypto extends EventEmitter { } } - return olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, devicesByUser, - ); + return olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); } /** @@ -2636,7 +2628,7 @@ export class Crypto extends EventEmitter { * @param {Function} opts.progressCallback called with an object which has a stage param * @return {Promise} a promise which resolves once the keys have been imported */ - public importRoomKeys(keys: IRoomKey[], opts: any = {}): Promise { // TODO types + public importRoomKeys(keys: IMegolmSessionData[], opts: any = {}): Promise { // TODO types let successes = 0; let failures = 0; const total = keys.length; @@ -2850,8 +2842,8 @@ export class Crypto extends EventEmitter { * Re-send any outgoing key requests, eg after verification * @returns {Promise} */ - public cancelAndResendAllOutgoingKeyRequests(): Promise { - return this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); + public async cancelAndResendAllOutgoingKeyRequests(): Promise { + await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); } /** @@ -3259,9 +3251,7 @@ export class Crypto extends EventEmitter { } const devicesByUser = {}; devicesByUser[sender] = [device]; - await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, devicesByUser, true, - ); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true); this.lastNewSessionForced[sender][deviceKey] = Date.now(); @@ -3300,9 +3290,7 @@ export class Crypto extends EventEmitter { // it. This won't always be the case though so we need to re-send any that have already been sent // to avoid races. const requestsToResend = - await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( - sender, device.deviceId, - ); + await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest(sender, device.deviceId); for (const keyReq of requestsToResend) { this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); } @@ -3440,9 +3428,7 @@ export class Crypto extends EventEmitter { } try { - await encryptor.reshareKeyWithDevice( - body.sender_key, body.session_id, userId, device, - ); + await encryptor.reshareKeyWithDevice(body.sender_key, body.session_id, userId, device); } catch (e) { logger.warn( "Failed to re-share keys for session " + body.session_id + @@ -3653,7 +3639,7 @@ export function fixBackupKey(key: string): string | null { * the relevant crypto algorithm implementation to share the keys for * this request. */ -class IncomingRoomKeyRequest { +export class IncomingRoomKeyRequest { public readonly userId: string; public readonly deviceId: string; public readonly requestId: string; diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 8376245cd..123f18f76 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -32,7 +32,7 @@ export interface IKeyBackupRoomSessions { } /* eslint-disable camelcase */ -export interface IKeyBackupVersion { +export interface IKeyBackupInfo { algorithm: string; auth_data: { public_key: string; @@ -41,10 +41,9 @@ export interface IKeyBackupVersion { private_key_iterations: number; private_key_bits?: number; }; - count: number; - etag: string; - version: string; // number contained within - recovery_key: string; + count?: number; + etag?: string; + version?: string; // number contained within } /* eslint-enable camelcase */ diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.ts similarity index 85% rename from src/crypto/olmlib.js rename to src/crypto/olmlib.ts index 74120d643..7a5e3a26c 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.ts @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,24 +20,42 @@ limitations under the License. * Utilities common to olm encryption algorithms */ +import anotherjson from "another-json"; +import type { PkSigning } from "@matrix-org/olm"; +import { Logger } from "loglevel"; + +import OlmDevice from "./OlmDevice"; +import { DeviceInfo } from "./deviceinfo"; import { logger } from '../logger'; import * as utils from "../utils"; -import anotherjson from "another-json"; +import { OneTimeKey } from "./dehydration"; +import { MatrixClient } from "../client"; + +enum Algorithm { + Olm = "m.olm.v1.curve25519-aes-sha2", + Megolm = "m.megolm.v1.aes-sha2", + MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2", +} /** * matrix algorithm tag for olm */ -export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; +export const OLM_ALGORITHM = Algorithm.Olm; /** * matrix algorithm tag for megolm */ -export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; +export const MEGOLM_ALGORITHM = Algorithm.Megolm; /** * matrix algorithm tag for megolm backups */ -export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"; +export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; + +export interface IOlmSessionResult { + device: DeviceInfo; + sessionId?: string; +} /** * Encrypt an event payload for an Olm device @@ -58,9 +74,13 @@ export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"; * has been encrypted into `resultsObject` */ export async function encryptMessageForDevice( - resultsObject, - ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice, - payloadFields, + resultsObject: Record, + ourUserId: string, + ourDeviceId: string, + olmDevice: OlmDevice, + recipientUserId: string, + recipientDevice: DeviceInfo, + payloadFields: Record, ) { const deviceKey = recipientDevice.getIdentityKey(); const sessionId = await olmDevice.getSessionIdForDevice(deviceKey); @@ -77,6 +97,7 @@ export async function encryptMessageForDevice( const payload = { sender: ourUserId, + // TODO this appears to no longer be used whatsoever sender_device: ourDeviceId, // Include the Ed25519 key so that the recipient knows what @@ -129,7 +150,9 @@ export async function encryptMessageForDevice( * a map from userId to deviceId to {@link module:crypto~OlmSessionResult} */ export async function getExistingOlmSessions( - olmDevice, baseApis, devicesByUser, + olmDevice: OlmDevice, + baseApis: MatrixClient, + devicesByUser: Record, ) { const devicesWithoutSession = {}; const sessions = {}; @@ -189,23 +212,30 @@ export async function getExistingOlmSessions( * {@link module:crypto~OlmSessionResult} */ export async function ensureOlmSessionsForDevices( - olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, log, -) { + olmDevice: OlmDevice, + baseApis: MatrixClient, + devicesByUser: Record, + force = false, + otkTimeout?: number, + failedServers?: string[], + log: Logger = logger, +): Promise>> { if (typeof force === "number") { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - backwards compatibility log = failedServers; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - backwards compatibility failedServers = otkTimeout; otkTimeout = force; force = false; } - if (!log) { - log = logger; - } const devicesWithoutSession = [ // [userId, deviceId], ... ]; const result = {}; - const resolveSession = {}; + const resolveSession: Record void> = {}; // Mark all sessions this task intends to update as in progress. It is // important to do this for all devices this task cares about in a single @@ -227,9 +257,9 @@ export async function ensureOlmSessionsForDevices( // conditions. If we find that we already have a session, then // we'll resolve olmDevice._sessionsInProgress[key] = new Promise(resolve => { - resolveSession[key] = (...args) => { + resolveSession[key] = (v: any) => { delete olmDevice._sessionsInProgress[key]; - resolve(...args); + resolve(v); }; }); } @@ -375,7 +405,12 @@ export async function ensureOlmSessionsForDevices( return result; } -async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) { +async function _verifyKeyAndStartSession( + olmDevice: OlmDevice, + oneTimeKey: OneTimeKey, + userId: string, + deviceInfo: DeviceInfo, +): Promise { const deviceId = deviceInfo.deviceId; try { await verifySignature( @@ -407,6 +442,11 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn return sid; } +export interface IObject { + unsigned?: object; + signatures?: object; +} + /** * Verify the signature on an object * @@ -424,7 +464,11 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn * or rejects with an Error if it is bad. */ export async function verifySignature( - olmDevice, obj, signingUserId, signingDeviceId, signingKey, + olmDevice: OlmDevice, + obj: OneTimeKey | IObject, + signingUserId: string, + signingDeviceId: string, + signingKey: string, ) { const signKeyId = "ed25519:" + signingDeviceId; const signatures = obj.signatures || {}; @@ -434,10 +478,11 @@ export async function verifySignature( throw Error("No signature"); } - // prepare the canonical json: remove unsigned and signatures, and stringify with - // anotherjson + // prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson const mangledObj = Object.assign({}, obj); - delete mangledObj.unsigned; + if ("unsigned" in mangledObj) { + delete mangledObj.unsigned; + } delete mangledObj.signatures; const json = anotherjson.stringify(mangledObj); @@ -453,14 +498,14 @@ export async function verifySignature( * @param {Olm.PkSigning|Uint8Array} key the signing object or the private key * seed * @param {string} userId The user ID who owns the signing key - * @param {string} pubkey The public key (ignored if key is a seed) + * @param {string} pubKey The public key (ignored if key is a seed) * @returns {string} the signature for the object */ -export function pkSign(obj, key, userId, pubkey) { +export function pkSign(obj: IObject, key: PkSigning, userId: string, pubKey: string): string { let createdKey = false; if (key instanceof Uint8Array) { const keyObj = new global.Olm.PkSigning(); - pubkey = keyObj.init_with_seed(key); + pubKey = keyObj.init_with_seed(key); key = keyObj; createdKey = true; } @@ -472,7 +517,7 @@ export function pkSign(obj, key, userId, pubkey) { const mysigs = sigs[userId] || {}; sigs[userId] = mysigs; - return mysigs['ed25519:' + pubkey] = key.sign(anotherjson.stringify(obj)); + return mysigs['ed25519:' + pubKey] = key.sign(anotherjson.stringify(obj)); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; @@ -485,11 +530,11 @@ export function pkSign(obj, key, userId, pubkey) { /** * Verify a signed JSON object * @param {Object} obj Object to verify - * @param {string} pubkey The public key to use to verify + * @param {string} pubKey The public key to use to verify * @param {string} userId The user ID who signed the object */ -export function pkVerify(obj, pubkey, userId) { - const keyId = "ed25519:" + pubkey; +export function pkVerify(obj: IObject, pubKey: string, userId: string) { + const keyId = "ed25519:" + pubKey; if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { throw new Error("No signature"); } @@ -500,7 +545,7 @@ export function pkVerify(obj, pubkey, userId) { const unsigned = obj.unsigned; if (obj.unsigned) delete obj.unsigned; try { - util.ed25519_verify(pubkey, anotherjson.stringify(obj), signature); + util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; @@ -513,7 +558,7 @@ export function pkVerify(obj, pubkey, userId) { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The base64. */ -export function encodeBase64(uint8Array) { +export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { return Buffer.from(uint8Array).toString("base64"); } @@ -522,7 +567,7 @@ export function encodeBase64(uint8Array) { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The unpadded base64. */ -export function encodeUnpaddedBase64(uint8Array) { +export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { return encodeBase64(uint8Array).replace(/=+$/g, ''); } @@ -531,6 +576,6 @@ export function encodeUnpaddedBase64(uint8Array) { * @param {string} base64 The base64 to decode. * @return {Uint8Array} The decoded data. */ -export function decodeBase64(base64) { +export function decodeBase64(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } diff --git a/src/crypto/recoverykey.js b/src/crypto/recoverykey.ts similarity index 90% rename from src/crypto/recoverykey.js rename to src/crypto/recoverykey.ts index 7fb9cf44a..5c54e6085 100644 --- a/src/crypto/recoverykey.js +++ b/src/crypto/recoverykey.ts @@ -20,7 +20,7 @@ import bs58 from 'bs58'; // (which are also base58 encoded, but bitcoin's involve a lot more hashing) const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; -export function encodeRecoveryKey(key) { +export function encodeRecoveryKey(key: ArrayLike): string { const buf = new Buffer(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); buf.set(OLM_RECOVERY_KEY_PREFIX, 0); buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); @@ -35,8 +35,8 @@ export function encodeRecoveryKey(key) { return base58key.match(/.{1,4}/g).join(" "); } -export function decodeRecoveryKey(recoverykey) { - const result = bs58.decode(recoverykey.replace(/ /g, '')); +export function decodeRecoveryKey(recoveryKey: string): Uint8Array { + const result = bs58.decode(recoveryKey.replace(/ /g, '')); let parity = 0; for (const b of result) { diff --git a/src/crypto/store/base.js b/src/crypto/store/base.ts similarity index 72% rename from src/crypto/store/base.js rename to src/crypto/store/base.ts index d9d1f7a94..d76fb9ead 100644 --- a/src/crypto/store/base.js +++ b/src/crypto/store/base.ts @@ -10,6 +10,9 @@ * @interface CryptoStore */ +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; +import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; + /** * Represents an outgoing room key request * @@ -32,3 +35,11 @@ * @property {Number} state current state of this request (states are defined * in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES}) */ +export interface OutgoingRoomKeyRequest { + requestId: string; + requestTxnId?: string; + cancellationTxnId?: string; + recipients: IRoomKeyRequestRecipient[]; + requestBody: IRoomKeyRequestBody; + state: RoomKeyRequestState; +} diff --git a/src/models/event.ts b/src/models/event.ts index edcee4ad5..490988787 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -131,7 +131,7 @@ interface IDecryptionResult { } /* eslint-enable camelcase */ -interface IClearEvent { +export interface IClearEvent { type: string; content: Omit; unsigned?: IUnsigned; diff --git a/src/utils.ts b/src/utils.ts index 587d9a7f9..a46f0bdcd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -22,6 +22,7 @@ limitations under the License. import unhomoglyph from "unhomoglyph"; import promiseRetry from "p-retry"; +import type NodeCrypto from "crypto"; /** * Encode a dictionary of query parameters. @@ -500,13 +501,13 @@ export function simpleRetryOperation(promiseFn: (attempt: number) => Promise< // Matrix SDK without needing to `require("crypto")`, which will fail in // browsers. So `index.ts` will call `setCrypto` to store it, and when we need // it, we can call `getCrypto`. -let crypto: Object; +let crypto: typeof NodeCrypto; -export function setCrypto(c: Object) { +export function setCrypto(c: typeof NodeCrypto) { crypto = c; } -export function getCrypto(): Object { +export function getCrypto(): typeof NodeCrypto { return crypto; } diff --git a/yarn.lock b/yarn.lock index 43a37edbf..25f6fa7df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1232,6 +1232,13 @@ dependencies: "@types/babel-types" "*" +"@types/bs58@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/bs58/-/bs58-4.0.1.tgz#3d51222aab067786d3bc3740a84a7f5a0effaa37" + integrity sha512-yfAgiWgVLjFCmRv8zAcOIHywYATEwiTVccTLnRp6UxTNavT55M9d/uhK3T03St/+8/z/wW+CRjGKUNmEqoHHCA== + dependencies: + base-x "^3.0.6" + "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" @@ -1778,7 +1785,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base-x@^3.0.2: +base-x@^3.0.2, base-x@^3.0.6: version "3.0.8" resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d" integrity sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA==