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));
+ }
+}