From a4fd1615ddbc0472e02825eb9a3fe3185a225c0c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 15 Feb 2022 18:21:29 +0100 Subject: [PATCH] convert decryption --- src/matrix/Session.js | 18 +- .../e2ee/olm/{Decryption.js => Decryption.ts} | 161 ++++++++++-------- src/matrix/e2ee/olm/types.ts | 43 +++++ src/utils/Lock.ts | 8 +- 4 files changed, 150 insertions(+), 80 deletions(-) rename src/matrix/e2ee/olm/{Decryption.js => Decryption.ts} (70%) create mode 100644 src/matrix/e2ee/olm/types.ts diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 83a2df02..72d8a313 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -26,7 +26,7 @@ import {User} from "./User.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {Account as E2EEAccount} from "./e2ee/Account.js"; import {uploadAccountAsDehydratedDevice} from "./e2ee/Dehydration.js"; -import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js"; +import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption"; import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader"; @@ -123,15 +123,15 @@ export class Session { // TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account // and can create RoomEncryption objects and handle encrypted to_device messages and device list changes. const senderKeyLock = new LockMap(); - const olmDecryption = new OlmDecryption({ - account: this._e2eeAccount, - pickleKey: PICKLE_KEY, - olm: this._olm, - storage: this._storage, - now: this._platform.clock.now, - ownUserId: this._user.id, + const olmDecryption = new OlmDecryption( + this._e2eeAccount, + PICKLE_KEY, + this._olm, + this._storage, + this._platform.clock.now, + this._user.id, senderKeyLock - }); + ); this._olmEncryption = new OlmEncryption({ account: this._e2eeAccount, pickleKey: PICKLE_KEY, diff --git a/src/matrix/e2ee/olm/Decryption.js b/src/matrix/e2ee/olm/Decryption.ts similarity index 70% rename from src/matrix/e2ee/olm/Decryption.js rename to src/matrix/e2ee/olm/Decryption.ts index 16e617a5..0fd4f0f9 100644 --- a/src/matrix/e2ee/olm/Decryption.js +++ b/src/matrix/e2ee/olm/Decryption.ts @@ -16,32 +16,52 @@ limitations under the License. import {DecryptionError} from "../common.js"; import {groupBy} from "../../../utils/groupBy"; -import {MultiLock} from "../../../utils/Lock"; +import {MultiLock, ILock} from "../../../utils/Lock"; import {Session} from "./Session.js"; -import {DecryptionResult} from "../DecryptionResult.js"; +import {DecryptionResult} from "../DecryptionResult"; + +import type {OlmMessage, OlmPayload} from "./types"; +import type {Account} from "../Account"; +import type {LockMap} from "../../../utils/LockMap"; +import type {Storage} from "../../storage/idb/Storage"; +import type {Transaction} from "../../storage/idb/Transaction"; +import type {OlmEncryptedEvent} from "./types"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; const SESSION_LIMIT_PER_SENDER_KEY = 4; -function isPreKeyMessage(message) { +type DecryptionResults = { + results: DecryptionResult[], + errors: DecryptionError[], + senderKeyDecryption: SenderKeyDecryption +}; + +type CreateAndDecryptResult = { + session: Session, + plaintext: string +}; + +function isPreKeyMessage(message: OlmMessage): boolean { return message.type === 0; } -function sortSessions(sessions) { +function sortSessions(sessions: Session[]) { sessions.sort((a, b) => { return b.data.lastUsed - a.data.lastUsed; }); } export class Decryption { - constructor({account, pickleKey, now, ownUserId, storage, olm, senderKeyLock}) { - this._account = account; - this._pickleKey = pickleKey; - this._now = now; - this._ownUserId = ownUserId; - this._storage = storage; - this._olm = olm; - this._senderKeyLock = senderKeyLock; - } + constructor( + private readonly account: Account, + private readonly pickleKey: string, + private readonly now: () => number, + private readonly ownUserId: string, + private readonly storage: Storage, + private readonly olm: Olm, + private readonly senderKeyLock: LockMap + ) {} // we need to lock because both encryption and decryption can't be done in one txn, // so for them not to step on each other toes, we need to lock. @@ -50,8 +70,8 @@ export class Decryption { // - decryptAll below fails (to release the lock as early as we can) // - DecryptionChanges.write succeeds // - Sync finishes the writeSync phase (or an error was thrown, in case we never get to DecryptionChanges.write) - async obtainDecryptionLock(events) { - const senderKeys = new Set(); + async obtainDecryptionLock(events: OlmEncryptedEvent[]): Promise { + const senderKeys = new Set(); for (const event of events) { const senderKey = event.content?.["sender_key"]; if (senderKey) { @@ -61,7 +81,7 @@ export class Decryption { // take a lock on all senderKeys so encryption or other calls to decryptAll (should not happen) // don't modify the sessions at the same time const locks = await Promise.all(Array.from(senderKeys).map(senderKey => { - return this._senderKeyLock.takeLock(senderKey); + return this.senderKeyLock.takeLock(senderKey); })); return new MultiLock(locks); } @@ -83,18 +103,18 @@ export class Decryption { * @param {[type]} events * @return {Promise} [description] */ - async decryptAll(events, lock, txn) { + async decryptAll(events: OlmEncryptedEvent[], lock: ILock, txn: Transaction): Promise { try { - const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]); - const timestamp = this._now(); + const eventsPerSenderKey = groupBy(events, (event: OlmEncryptedEvent) => event.content?.["sender_key"]); + const timestamp = this.now(); // decrypt events for different sender keys in parallel const senderKeyOperations = await Promise.all(Array.from(eventsPerSenderKey.entries()).map(([senderKey, events]) => { - return this._decryptAllForSenderKey(senderKey, events, timestamp, txn); + return this._decryptAllForSenderKey(senderKey!, events, timestamp, txn); })); - const results = senderKeyOperations.reduce((all, r) => all.concat(r.results), []); - const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), []); + const results = senderKeyOperations.reduce((all, r) => all.concat(r.results), [] as DecryptionResult[]); + const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), [] as DecryptionError[]); const senderKeyDecryptions = senderKeyOperations.map(r => r.senderKeyDecryption); - return new DecryptionChanges(senderKeyDecryptions, results, errors, this._account, lock); + return new DecryptionChanges(senderKeyDecryptions, results, errors, this.account, lock); } catch (err) { // make sure the locks are release if something throws // otherwise they will be released in DecryptionChanges after having written @@ -104,11 +124,11 @@ export class Decryption { } } - async _decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn) { + async _decryptAllForSenderKey(senderKey: string, events: OlmEncryptedEvent[], timestamp: number, readSessionsTxn: Transaction): Promise { const sessions = await this._getSessions(senderKey, readSessionsTxn); - const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp); - const results = []; - const errors = []; + const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this.olm, timestamp); + const results: DecryptionResult[] = []; + const errors: DecryptionError[] = []; // events for a single senderKey need to be decrypted one by one for (const event of events) { try { @@ -121,10 +141,10 @@ export class Decryption { return {results, errors, senderKeyDecryption}; } - _decryptForSenderKey(senderKeyDecryption, event, timestamp) { + _decryptForSenderKey(senderKeyDecryption: SenderKeyDecryption, event: OlmEncryptedEvent, timestamp: number): DecryptionResult { const senderKey = senderKeyDecryption.senderKey; const message = this._getMessageAndValidateEvent(event); - let plaintext; + let plaintext: string | undefined; try { plaintext = senderKeyDecryption.decrypt(message); } catch (err) { @@ -133,7 +153,7 @@ export class Decryption { } // could not decrypt with any existing session if (typeof plaintext !== "string" && isPreKeyMessage(message)) { - let createResult; + let createResult: CreateAndDecryptResult; try { createResult = this._createSessionAndDecrypt(senderKey, message, timestamp); } catch (error) { @@ -143,14 +163,14 @@ export class Decryption { plaintext = createResult.plaintext; } if (typeof plaintext === "string") { - let payload; + let payload: OlmPayload; try { payload = JSON.parse(plaintext); } catch (error) { throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, error}); } this._validatePayload(payload, event); - return new DecryptionResult(payload, senderKey, payload.keys.ed25519); + return new DecryptionResult(payload, senderKey, payload.keys!.ed25519!); } else { throw new DecryptionError("OLM_NO_MATCHING_SESSION", event, {knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)}); @@ -158,16 +178,16 @@ export class Decryption { } // only for pre-key messages after having attempted decryption with existing sessions - _createSessionAndDecrypt(senderKey, message, timestamp) { + _createSessionAndDecrypt(senderKey: string, message: OlmMessage, timestamp: number): CreateAndDecryptResult { let plaintext; // if we have multiple messages encrypted with the same new session, // this could create multiple sessions as the OTK isn't removed yet // (this only happens in DecryptionChanges.write) // This should be ok though as we'll first try to decrypt with the new session - const olmSession = this._account.createInboundOlmSession(senderKey, message.body); + const olmSession = this.account.createInboundOlmSession(senderKey, message.body); try { plaintext = olmSession.decrypt(message.type, message.body); - const session = Session.create(senderKey, olmSession, this._olm, this._pickleKey, timestamp); + const session = Session.create(senderKey, olmSession, this.olm, this.pickleKey, timestamp); session.unload(olmSession); return {session, plaintext}; } catch (err) { @@ -176,12 +196,12 @@ export class Decryption { } } - _getMessageAndValidateEvent(event) { + _getMessageAndValidateEvent(event: OlmEncryptedEvent): OlmMessage { const ciphertext = event.content?.ciphertext; if (!ciphertext) { throw new DecryptionError("OLM_MISSING_CIPHERTEXT", event); } - const message = ciphertext?.[this._account.identityKeys.curve25519]; + const message = ciphertext?.[this.account.identityKeys.curve25519]; if (!message) { throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", event); } @@ -189,22 +209,22 @@ export class Decryption { return message; } - async _getSessions(senderKey, txn) { + async _getSessions(senderKey: string, txn: Transaction): Promise { const sessionEntries = await txn.olmSessions.getAll(senderKey); // sort most recent used sessions first - const sessions = sessionEntries.map(s => new Session(s, this._pickleKey, this._olm)); + const sessions = sessionEntries.map(s => new Session(s, this.pickleKey, this.olm)); sortSessions(sessions); return sessions; } - _validatePayload(payload, event) { + _validatePayload(payload: OlmPayload, event: OlmEncryptedEvent): void { if (payload.sender !== event.sender) { throw new DecryptionError("OLM_FORWARDED_MESSAGE", event, {sentBy: event.sender, encryptedBy: payload.sender}); } - if (payload.recipient !== this._ownUserId) { + if (payload.recipient !== this.ownUserId) { throw new DecryptionError("OLM_BAD_RECIPIENT", event, {recipient: payload.recipient}); } - if (payload.recipient_keys?.ed25519 !== this._account.identityKeys.ed25519) { + if (payload.recipient_keys?.ed25519 !== this.account.identityKeys.ed25519) { throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", event, {key: payload.recipient_keys?.ed25519}); } // TODO: check room_id @@ -219,21 +239,21 @@ export class Decryption { // decryption helper for a single senderKey class SenderKeyDecryption { - constructor(senderKey, sessions, olm, timestamp) { - this.senderKey = senderKey; - this.sessions = sessions; - this._olm = olm; - this._timestamp = timestamp; - } + constructor( + public readonly senderKey: string, + public readonly sessions: Session[], + private readonly olm: Olm, + private readonly timestamp: number + ) {} - addNewSession(session) { + addNewSession(session: Session) { // add at top as it is most recent this.sessions.unshift(session); } - decrypt(message) { + decrypt(message: OlmMessage): string | undefined { for (const session of this.sessions) { - const plaintext = this._decryptWithSession(session, message); + const plaintext = this.decryptWithSession(session, message); if (typeof plaintext === "string") { // keep them sorted so will try the same session first for other messages // and so we can assume the excess ones are at the end @@ -244,11 +264,11 @@ class SenderKeyDecryption { } } - getModifiedSessions() { + getModifiedSessions(): Session[] { return this.sessions.filter(session => session.isModified); } - get hasNewSessions() { + get hasNewSessions(): boolean { return this.sessions.some(session => session.isNew); } @@ -257,7 +277,10 @@ class SenderKeyDecryption { // if this turns out to be a real cost for IE11, // we could look into adding a less expensive serialization mechanism // for olm sessions to libolm - _decryptWithSession(session, message) { + private decryptWithSession(session: Session, message: OlmMessage): string | undefined { + if (message.type === undefined || message.body === undefined) { + throw new Error("Invalid message without type or body"); + } const olmSession = session.load(); try { if (isPreKeyMessage(message) && !olmSession.matches_inbound(message.body)) { @@ -266,7 +289,7 @@ class SenderKeyDecryption { try { const plaintext = olmSession.decrypt(message.type, message.body); session.save(olmSession); - session.lastUsed = this._timestamp; + session.data.lastUsed = this.timestamp; return plaintext; } catch (err) { if (isPreKeyMessage(message)) { @@ -286,27 +309,27 @@ class SenderKeyDecryption { * @property {Array} errors see DecryptionError.event to retrieve the event that failed to decrypt. */ class DecryptionChanges { - constructor(senderKeyDecryptions, results, errors, account, lock) { - this._senderKeyDecryptions = senderKeyDecryptions; - this._account = account; - this.results = results; - this.errors = errors; - this._lock = lock; + constructor( + private readonly senderKeyDecryptions: SenderKeyDecryption[], + private readonly results: DecryptionResult[], + private readonly errors: DecryptionError[], + private readonly account: Account, + private readonly lock: ILock + ) {} + + get hasNewSessions(): boolean { + return this.senderKeyDecryptions.some(skd => skd.hasNewSessions); } - get hasNewSessions() { - return this._senderKeyDecryptions.some(skd => skd.hasNewSessions); - } - - write(txn) { + write(txn: Transaction): void { try { - for (const senderKeyDecryption of this._senderKeyDecryptions) { + for (const senderKeyDecryption of this.senderKeyDecryptions) { for (const session of senderKeyDecryption.getModifiedSessions()) { txn.olmSessions.set(session.data); if (session.isNew) { const olmSession = session.load(); try { - this._account.writeRemoveOneTimeKey(olmSession, txn); + this.account.writeRemoveOneTimeKey(olmSession, txn); } finally { session.unload(olmSession); } @@ -322,7 +345,7 @@ class DecryptionChanges { } } } finally { - this._lock.release(); + this.lock.release(); } } } diff --git a/src/matrix/e2ee/olm/types.ts b/src/matrix/e2ee/olm/types.ts new file mode 100644 index 00000000..b9e394d5 --- /dev/null +++ b/src/matrix/e2ee/olm/types.ts @@ -0,0 +1,43 @@ +/* +Copyright 2022 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. +*/ + +export type OlmMessage = { + type?: 0 | 1, + body?: string +} + +export type OlmEncryptedMessageContent = { + algorithm?: "m.olm.v1.curve25519-aes-sha2" + sender_key?: string, + ciphertext?: { + [deviceCurve25519Key: string]: OlmMessage + } +} + +export type OlmEncryptedEvent = { + type?: "m.room.encrypted", + content?: OlmEncryptedMessageContent + sender?: string +} + +export type OlmPayload = { + type?: string; + content?: Record; + sender?: string; + recipient?: string; + recipient_keys?: {ed25519?: string}; + keys?: {ed25519?: string}; +} diff --git a/src/utils/Lock.ts b/src/utils/Lock.ts index 238d88f9..ff623eba 100644 --- a/src/utils/Lock.ts +++ b/src/utils/Lock.ts @@ -14,7 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class Lock { +export interface ILock { + release(): void; +} + +export class Lock implements ILock { private _promise?: Promise; private _resolve?: (() => void); @@ -52,7 +56,7 @@ export class Lock { } } -export class MultiLock { +export class MultiLock implements ILock { constructor(public readonly locks: Lock[]) { }