From 5fee7fedc3dab376194cc585b8b912acc39b1f86 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 1 Sep 2020 17:59:39 +0200 Subject: [PATCH] implement olm decryption algorithm --- src/matrix/e2ee/Account.js | 27 ++++- src/matrix/e2ee/common.js | 8 ++ src/matrix/e2ee/olm/Decryption.js | 187 ++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 src/matrix/e2ee/olm/Decryption.js diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index 4905bbb6..0478112b 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -31,7 +31,7 @@ export class Account { account.unpickle(pickleKey, pickledAccount); const serverOTKCount = await txn.session.get(SERVER_OTK_COUNT_SESSION_KEY); return new Account({pickleKey, hsApi, account, userId, - deviceId, areDeviceKeysUploaded, serverOTKCount}); + deviceId, areDeviceKeysUploaded, serverOTKCount, olm}); } } @@ -47,10 +47,11 @@ export class Account { await txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded); await txn.session.add(SERVER_OTK_COUNT_SESSION_KEY, 0); return new Account({pickleKey, hsApi, account, userId, - deviceId, areDeviceKeysUploaded, serverOTKCount: 0}); + deviceId, areDeviceKeysUploaded, serverOTKCount: 0, olm}); } - constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount}) { + constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount, olm}) { + this._olm = olm; this._pickleKey = pickleKey; this._hsApi = hsApi; this._account = account; @@ -58,6 +59,11 @@ export class Account { this._deviceId = deviceId; this._areDeviceKeysUploaded = areDeviceKeysUploaded; this._serverOTKCount = serverOTKCount; + this._identityKeys = JSON.parse(this._account.identity_keys()); + } + + get identityKeys() { + return this._identityKeys; } async uploadKeys(storage) { @@ -118,6 +124,21 @@ export class Account { return false; } + createInboundOlmSession(senderKey, body) { + const newSession = new this._olm.Session(); + newSession.create_inbound_from(this._account, senderKey, body); + return newSession; + } + + writeRemoveOneTimeKey(session, txn) { + // this is side-effecty and will have applied the change if the txn fails, + // but don't want to clone the account for now + // and it is not the worst thing to think we have used a OTK when + // decrypting the message that actually used it threw for some reason. + this._account.remove_one_time_keys(session); + txn.session.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey)); + } + writeSync(deviceOneTimeKeysCount, txn) { // we only upload signed_curve25519 otks const otkCount = deviceOneTimeKeysCount.signed_curve25519; diff --git a/src/matrix/e2ee/common.js b/src/matrix/e2ee/common.js index 82709051..ef758feb 100644 --- a/src/matrix/e2ee/common.js +++ b/src/matrix/e2ee/common.js @@ -18,3 +18,11 @@ limitations under the License. export const SESSION_KEY_PREFIX = "e2ee:"; export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; + +export class DecryptionError extends Error { + constructor(code, detailsObj = null) { + super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`); + this.code = code; + this.details = detailsObj; + } +} diff --git a/src/matrix/e2ee/olm/Decryption.js b/src/matrix/e2ee/olm/Decryption.js new file mode 100644 index 00000000..582f96d2 --- /dev/null +++ b/src/matrix/e2ee/olm/Decryption.js @@ -0,0 +1,187 @@ +/* +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. +*/ + +import {DecryptionError} from "../common.js"; + +const SESSION_LIMIT_PER_SENDER_KEY = 4; + +function isPreKeyMessage(message) { + return message.type === 0; +} + +export class Decryption { + constructor({account, pickleKey, now, ownUserId, storage, olm}) { + this._account = account; + this._pickleKey = pickleKey; + this._now = now; + this._ownUserId = ownUserId; + this._storage = storage; + this._olm = olm; + this._createOutboundSessionPromise = null; + } + + // we can't run this in the sync txn because decryption will be async ... + // should we store the encrypted events in the sync loop and then pop them from there? + // it would be good in any case to run the (next) sync request in parallel with decryption + async decrypt(event) { + const senderKey = event.content?.["sender_key"]; + const ciphertext = event.content?.ciphertext; + if (!ciphertext) { + throw new DecryptionError("OLM_MISSING_CIPHERTEXT"); + } + const message = ciphertext?.[this._account.identityKeys.curve25519]; + if (!message) { + // TODO: use same error messages as element-web + throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS"); + } + const sortedSessionIds = await this._getSortedSessionIds(senderKey); + let plaintext; + for (const sessionId of sortedSessionIds) { + try { + plaintext = await this._attemptDecryption(senderKey, sessionId, message); + } catch (err) { + throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", {senderKey, error: err.message}); + } + if (typeof plaintext === "string") { + break; + } + } + if (typeof plaintext !== "string" && isPreKeyMessage(message)) { + plaintext = await this._createOutboundSessionAndDecrypt(senderKey, message, sortedSessionIds); + } + if (typeof plaintext === "string") { + return this._parseAndValidatePayload(plaintext, event); + } + } + + async _getSortedSessionIds(senderKey) { + const readTxn = await this._storage.readTxn([this._storage.storeNames.olmSessions]); + const sortedSessions = await readTxn.olmSessions.getAll(senderKey); + // sort most recent used sessions first + sortedSessions.sort((a, b) => { + return b.lastUsed - a.lastUsed; + }); + return sortedSessions.map(s => s.sessionId); + } + + async _createOutboundSessionAndDecrypt(senderKey, message, sortedSessionIds) { + // serialize calls so the account isn't written from multiple + // sessions at once + while (this._createOutboundSessionPromise) { + await this._createOutboundSessionPromise; + } + this._createOutboundSessionPromise = (async () => { + try { + return await this._createOutboundSessionAndDecryptImpl(senderKey, message, sortedSessionIds); + } finally { + this._createOutboundSessionPromise = null; + } + })(); + return await this._createOutboundSessionPromise; + } + + // this could internally dispatch to a web-worker + async _createOutboundSessionAndDecryptImpl(senderKey, message, sortedSessionIds) { + let plaintext; + const session = this._account.createInboundOlmSession(senderKey, message.body); + try { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.session, + this._storage.storeNames.olmSessions, + ]); + try { + // do this before removing the OTK removal, so we know decryption succeeded beforehand, + // as we don't have a way of undoing the OTK removal atm. + plaintext = session.decrypt(message.type, message.body); + this._account.writeRemoveOneTimeKey(session, txn); + // remove oldest session if we reach the limit including the new session + if (sortedSessionIds.length >= SESSION_LIMIT_PER_SENDER_KEY) { + // given they are sorted, the oldest one is the last one + const oldestSessionId = sortedSessionIds[sortedSessionIds.length - 1]; + txn.olmSessions.remove(senderKey, oldestSessionId); + } + txn.olmSessions.set({ + session: session.pickle(this._pickleKey), + sessionId: session.session_id(), + senderKey, + lastUsed: this._now(), + }); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + } finally { + session.free(); + } + return plaintext; + } + + // this could internally dispatch to a web-worker + async _attemptDecryption(senderKey, sessionId, message) { + const txn = await this._storage.readWriteTxn([this._storage.storeNames.olmSessions]); + const session = new this._olm.Session(); + let plaintext; + try { + const sessionEntry = await txn.olmSessions.get(senderKey, sessionId); + session.unpickle(this._pickleKey, sessionEntry.session); + if (isPreKeyMessage(message) && !session.matches_inbound(message.body)) { + return; + } + try { + plaintext = session.decrypt(message.type, message.body); + } catch (err) { + if (isPreKeyMessage(message)) { + throw new Error(`Error decrypting prekey message with existing session id ${sessionId}: ${err.message}`); + } + // decryption failed, bail out + return; + } + sessionEntry.session = session.pickle(this._pickleKey); + sessionEntry.lastUsed = this._now(); + txn.olmSessions.set(sessionEntry); + } catch(err) { + txn.abort(); + throw err; + } finally { + session.free(); + } + await txn.complete(); + return plaintext; + } + + _parseAndValidatePayload(plaintext, event) { + const payload = JSON.parse(plaintext); + + if (payload.sender !== event.sender) { + throw new DecryptionError("OLM_FORWARDED_MESSAGE", {sentBy: event.sender, encryptedBy: payload.sender}); + } + if (payload.recipient !== this._ownUserId) { + throw new DecryptionError("OLM_BAD_RECIPIENT", {recipient: payload.recipient}); + } + if (payload.recipient_keys?.ed25519 !== this._account.identityKeys.ed25519) { + throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", {key: payload.recipient_keys?.ed25519}); + } + // TODO: check room_id + if (!payload.type) { + throw new Error("missing type on payload"); + } + if (!payload.content) { + throw new Error("missing content on payload"); + } + return payload; + } +}