diff --git a/prototypes/olmtest-ie11.html b/prototypes/olmtest-ie11.html index 13d906b9..2ea3eeb8 100644 --- a/prototypes/olmtest-ie11.html +++ b/prototypes/olmtest-ie11.html @@ -61,6 +61,16 @@ JSON.parse(bob.identity_keys()).curve25519, bobOneTimeKey ); + log("alice outbound session created"); + var aliceSessionPickled = aliceSession.pickle("secret"); + log("aliceSession pickled", aliceSessionPickled); + try { + var tmp = new Olm.Session(); + tmp.unpickle("secret", aliceSessionPickled); + log("aliceSession unpickled"); + } finally { + tmp.free(); + } var message = aliceSession.encrypt("hello secret world"); log("message", message); // decrypt diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 90db1b68..4d30516a 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -35,10 +35,11 @@ export class Session { this._user = new User(sessionInfo.userId); this._olm = olm; this._e2eeAccount = null; + const olmUtil = olm ? new olm.Utility() : null; this._deviceTracker = olm ? new DeviceTracker({ storage, getSyncToken: () => this.syncToken, - olm, + olmUtil, }) : null; } 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/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 5eaa23b7..b085be80 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -36,11 +36,11 @@ function deviceKeysAsDeviceIdentity(deviceSection) { } export class DeviceTracker { - constructor({storage, getSyncToken, olm}) { + constructor({storage, getSyncToken, olmUtil}) { this._storage = storage; this._getSyncToken = getSyncToken; this._identityChangedForRoom = null; - this._olmUtil = new olm.Utility(); + this._olmUtil = olmUtil; } async writeDeviceChanges(deviceLists, txn) { 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; + } +} diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index 7d6fae09..73900af3 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -24,6 +24,7 @@ export const STORE_NAMES = Object.freeze([ "pendingEvents", "userIdentities", "deviceIdentities", + "olmSessions", ]); export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => { diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js index 921c23e2..370a5563 100644 --- a/src/matrix/storage/idb/Transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -26,6 +26,7 @@ import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js"; import {PendingEventStore} from "./stores/PendingEventStore.js"; import {UserIdentityStore} from "./stores/UserIdentityStore.js"; import {DeviceIdentityStore} from "./stores/DeviceIdentityStore.js"; +import {OlmSessionStore} from "./stores/OlmSessionStore.js"; export class Transaction { constructor(txn, allowedStoreNames) { @@ -91,6 +92,10 @@ export class Transaction { return this._store("deviceIdentities", idbStore => new DeviceIdentityStore(idbStore)); } + get olmSessions() { + return this._store("olmSessions", idbStore => new OlmSessionStore(idbStore)); + } + complete() { return txnAsPromise(this._txn); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index d8aa81cc..8e34ac27 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -10,6 +10,7 @@ export const schema = [ createMemberStore, migrateSession, createIdentityStores, + createOlmSessionStore, ]; // TODO: how to deal with git merge conflicts of this array? @@ -70,3 +71,8 @@ function createIdentityStores(db) { db.createObjectStore("userIdentities", {keyPath: "userId"}); db.createObjectStore("deviceIdentities", {keyPath: "key"}); } + +//v5 +function createOlmSessionStore(db) { + db.createObjectStore("olmSessions", {keyPath: "key"}); +} diff --git a/src/matrix/storage/idb/stores/OlmSessionStore.js b/src/matrix/storage/idb/stores/OlmSessionStore.js new file mode 100644 index 00000000..c94b3bfd --- /dev/null +++ b/src/matrix/storage/idb/stores/OlmSessionStore.js @@ -0,0 +1,45 @@ +/* +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. +*/ + +function encodeKey(senderKey, sessionId) { + return `${senderKey}|${sessionId}`; +} + +export class OlmSessionStore { + constructor(store) { + this._store = store; + } + + getAll(senderKey) { + const range = IDBKeyRange.lowerBound(encodeKey(senderKey, "")); + return this._store.selectWhile(range, session => { + return session.senderKey === senderKey; + }); + } + + get(senderKey, sessionId) { + return this._store.get(encodeKey(senderKey, sessionId)); + } + + set(session) { + session.key = encodeKey(session.senderKey, session.sessionId); + return this._store.put(session); + } + + remove(senderKey, sessionId) { + return this._store.delete(encodeKey(senderKey, sessionId)); + } +}