This repository has been archived on 2022-08-19. You can view files and clone it, but cannot push or open issues or pull requests.
hydrogen-web/src/matrix/e2ee/olm/Decryption.js
2020-09-01 17:59:39 +02:00

188 lines
7.4 KiB
JavaScript

/*
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;
}
}