convert decryption

This commit is contained in:
Bruno Windels 2022-02-15 18:21:29 +01:00
parent 74c640f937
commit a4fd1615dd
4 changed files with 150 additions and 80 deletions

View file

@ -26,7 +26,7 @@ import {User} from "./User.js";
import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
import {Account as E2EEAccount} from "./e2ee/Account.js"; import {Account as E2EEAccount} from "./e2ee/Account.js";
import {uploadAccountAsDehydratedDevice} from "./e2ee/Dehydration.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 {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js";
import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption";
import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader"; 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 // 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. // and can create RoomEncryption objects and handle encrypted to_device messages and device list changes.
const senderKeyLock = new LockMap(); const senderKeyLock = new LockMap();
const olmDecryption = new OlmDecryption({ const olmDecryption = new OlmDecryption(
account: this._e2eeAccount, this._e2eeAccount,
pickleKey: PICKLE_KEY, PICKLE_KEY,
olm: this._olm, this._olm,
storage: this._storage, this._storage,
now: this._platform.clock.now, this._platform.clock.now,
ownUserId: this._user.id, this._user.id,
senderKeyLock senderKeyLock
}); );
this._olmEncryption = new OlmEncryption({ this._olmEncryption = new OlmEncryption({
account: this._e2eeAccount, account: this._e2eeAccount,
pickleKey: PICKLE_KEY, pickleKey: PICKLE_KEY,

View file

@ -16,32 +16,52 @@ limitations under the License.
import {DecryptionError} from "../common.js"; import {DecryptionError} from "../common.js";
import {groupBy} from "../../../utils/groupBy"; import {groupBy} from "../../../utils/groupBy";
import {MultiLock} from "../../../utils/Lock"; import {MultiLock, ILock} from "../../../utils/Lock";
import {Session} from "./Session.js"; 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; 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; return message.type === 0;
} }
function sortSessions(sessions) { function sortSessions(sessions: Session[]) {
sessions.sort((a, b) => { sessions.sort((a, b) => {
return b.data.lastUsed - a.data.lastUsed; return b.data.lastUsed - a.data.lastUsed;
}); });
} }
export class Decryption { export class Decryption {
constructor({account, pickleKey, now, ownUserId, storage, olm, senderKeyLock}) { constructor(
this._account = account; private readonly account: Account,
this._pickleKey = pickleKey; private readonly pickleKey: string,
this._now = now; private readonly now: () => number,
this._ownUserId = ownUserId; private readonly ownUserId: string,
this._storage = storage; private readonly storage: Storage,
this._olm = olm; private readonly olm: Olm,
this._senderKeyLock = senderKeyLock; private readonly senderKeyLock: LockMap<string>
} ) {}
// we need to lock because both encryption and decryption can't be done in one txn, // 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. // 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) // - decryptAll below fails (to release the lock as early as we can)
// - DecryptionChanges.write succeeds // - DecryptionChanges.write succeeds
// - Sync finishes the writeSync phase (or an error was thrown, in case we never get to DecryptionChanges.write) // - Sync finishes the writeSync phase (or an error was thrown, in case we never get to DecryptionChanges.write)
async obtainDecryptionLock(events) { async obtainDecryptionLock(events: OlmEncryptedEvent[]): Promise<ILock> {
const senderKeys = new Set(); const senderKeys = new Set<string>();
for (const event of events) { for (const event of events) {
const senderKey = event.content?.["sender_key"]; const senderKey = event.content?.["sender_key"];
if (senderKey) { 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) // 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 // don't modify the sessions at the same time
const locks = await Promise.all(Array.from(senderKeys).map(senderKey => { const locks = await Promise.all(Array.from(senderKeys).map(senderKey => {
return this._senderKeyLock.takeLock(senderKey); return this.senderKeyLock.takeLock(senderKey);
})); }));
return new MultiLock(locks); return new MultiLock(locks);
} }
@ -83,18 +103,18 @@ export class Decryption {
* @param {[type]} events * @param {[type]} events
* @return {Promise<DecryptionChanges>} [description] * @return {Promise<DecryptionChanges>} [description]
*/ */
async decryptAll(events, lock, txn) { async decryptAll(events: OlmEncryptedEvent[], lock: ILock, txn: Transaction): Promise<DecryptionChanges> {
try { try {
const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]); const eventsPerSenderKey = groupBy(events, (event: OlmEncryptedEvent) => event.content?.["sender_key"]);
const timestamp = this._now(); const timestamp = this.now();
// decrypt events for different sender keys in parallel // decrypt events for different sender keys in parallel
const senderKeyOperations = await Promise.all(Array.from(eventsPerSenderKey.entries()).map(([senderKey, events]) => { 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 results = senderKeyOperations.reduce((all, r) => all.concat(r.results), [] as DecryptionResult[]);
const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), []); const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), [] as DecryptionError[]);
const senderKeyDecryptions = senderKeyOperations.map(r => r.senderKeyDecryption); 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) { } catch (err) {
// make sure the locks are release if something throws // make sure the locks are release if something throws
// otherwise they will be released in DecryptionChanges after having written // 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<DecryptionResults> {
const sessions = await this._getSessions(senderKey, readSessionsTxn); const sessions = await this._getSessions(senderKey, readSessionsTxn);
const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp); const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this.olm, timestamp);
const results = []; const results: DecryptionResult[] = [];
const errors = []; const errors: DecryptionError[] = [];
// events for a single senderKey need to be decrypted one by one // events for a single senderKey need to be decrypted one by one
for (const event of events) { for (const event of events) {
try { try {
@ -121,10 +141,10 @@ export class Decryption {
return {results, errors, senderKeyDecryption}; return {results, errors, senderKeyDecryption};
} }
_decryptForSenderKey(senderKeyDecryption, event, timestamp) { _decryptForSenderKey(senderKeyDecryption: SenderKeyDecryption, event: OlmEncryptedEvent, timestamp: number): DecryptionResult {
const senderKey = senderKeyDecryption.senderKey; const senderKey = senderKeyDecryption.senderKey;
const message = this._getMessageAndValidateEvent(event); const message = this._getMessageAndValidateEvent(event);
let plaintext; let plaintext: string | undefined;
try { try {
plaintext = senderKeyDecryption.decrypt(message); plaintext = senderKeyDecryption.decrypt(message);
} catch (err) { } catch (err) {
@ -133,7 +153,7 @@ export class Decryption {
} }
// could not decrypt with any existing session // could not decrypt with any existing session
if (typeof plaintext !== "string" && isPreKeyMessage(message)) { if (typeof plaintext !== "string" && isPreKeyMessage(message)) {
let createResult; let createResult: CreateAndDecryptResult;
try { try {
createResult = this._createSessionAndDecrypt(senderKey, message, timestamp); createResult = this._createSessionAndDecrypt(senderKey, message, timestamp);
} catch (error) { } catch (error) {
@ -143,14 +163,14 @@ export class Decryption {
plaintext = createResult.plaintext; plaintext = createResult.plaintext;
} }
if (typeof plaintext === "string") { if (typeof plaintext === "string") {
let payload; let payload: OlmPayload;
try { try {
payload = JSON.parse(plaintext); payload = JSON.parse(plaintext);
} catch (error) { } catch (error) {
throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, error}); throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, error});
} }
this._validatePayload(payload, event); this._validatePayload(payload, event);
return new DecryptionResult(payload, senderKey, payload.keys.ed25519); return new DecryptionResult(payload, senderKey, payload.keys!.ed25519!);
} else { } else {
throw new DecryptionError("OLM_NO_MATCHING_SESSION", event, throw new DecryptionError("OLM_NO_MATCHING_SESSION", event,
{knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)}); {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 // 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; let plaintext;
// if we have multiple messages encrypted with the same new session, // if we have multiple messages encrypted with the same new session,
// this could create multiple sessions as the OTK isn't removed yet // this could create multiple sessions as the OTK isn't removed yet
// (this only happens in DecryptionChanges.write) // (this only happens in DecryptionChanges.write)
// This should be ok though as we'll first try to decrypt with the new session // 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 { try {
plaintext = olmSession.decrypt(message.type, message.body); 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); session.unload(olmSession);
return {session, plaintext}; return {session, plaintext};
} catch (err) { } catch (err) {
@ -176,12 +196,12 @@ export class Decryption {
} }
} }
_getMessageAndValidateEvent(event) { _getMessageAndValidateEvent(event: OlmEncryptedEvent): OlmMessage {
const ciphertext = event.content?.ciphertext; const ciphertext = event.content?.ciphertext;
if (!ciphertext) { if (!ciphertext) {
throw new DecryptionError("OLM_MISSING_CIPHERTEXT", event); throw new DecryptionError("OLM_MISSING_CIPHERTEXT", event);
} }
const message = ciphertext?.[this._account.identityKeys.curve25519]; const message = ciphertext?.[this.account.identityKeys.curve25519];
if (!message) { if (!message) {
throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", event); throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", event);
} }
@ -189,22 +209,22 @@ export class Decryption {
return message; return message;
} }
async _getSessions(senderKey, txn) { async _getSessions(senderKey: string, txn: Transaction): Promise<Session[]> {
const sessionEntries = await txn.olmSessions.getAll(senderKey); const sessionEntries = await txn.olmSessions.getAll(senderKey);
// sort most recent used sessions first // 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); sortSessions(sessions);
return sessions; return sessions;
} }
_validatePayload(payload, event) { _validatePayload(payload: OlmPayload, event: OlmEncryptedEvent): void {
if (payload.sender !== event.sender) { if (payload.sender !== event.sender) {
throw new DecryptionError("OLM_FORWARDED_MESSAGE", event, {sentBy: event.sender, encryptedBy: payload.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}); 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}); throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", event, {key: payload.recipient_keys?.ed25519});
} }
// TODO: check room_id // TODO: check room_id
@ -219,21 +239,21 @@ export class Decryption {
// decryption helper for a single senderKey // decryption helper for a single senderKey
class SenderKeyDecryption { class SenderKeyDecryption {
constructor(senderKey, sessions, olm, timestamp) { constructor(
this.senderKey = senderKey; public readonly senderKey: string,
this.sessions = sessions; public readonly sessions: Session[],
this._olm = olm; private readonly olm: Olm,
this._timestamp = timestamp; private readonly timestamp: number
} ) {}
addNewSession(session) { addNewSession(session: Session) {
// add at top as it is most recent // add at top as it is most recent
this.sessions.unshift(session); this.sessions.unshift(session);
} }
decrypt(message) { decrypt(message: OlmMessage): string | undefined {
for (const session of this.sessions) { for (const session of this.sessions) {
const plaintext = this._decryptWithSession(session, message); const plaintext = this.decryptWithSession(session, message);
if (typeof plaintext === "string") { if (typeof plaintext === "string") {
// keep them sorted so will try the same session first for other messages // 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 // 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); return this.sessions.filter(session => session.isModified);
} }
get hasNewSessions() { get hasNewSessions(): boolean {
return this.sessions.some(session => session.isNew); return this.sessions.some(session => session.isNew);
} }
@ -257,7 +277,10 @@ class SenderKeyDecryption {
// if this turns out to be a real cost for IE11, // if this turns out to be a real cost for IE11,
// we could look into adding a less expensive serialization mechanism // we could look into adding a less expensive serialization mechanism
// for olm sessions to libolm // 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(); const olmSession = session.load();
try { try {
if (isPreKeyMessage(message) && !olmSession.matches_inbound(message.body)) { if (isPreKeyMessage(message) && !olmSession.matches_inbound(message.body)) {
@ -266,7 +289,7 @@ class SenderKeyDecryption {
try { try {
const plaintext = olmSession.decrypt(message.type, message.body); const plaintext = olmSession.decrypt(message.type, message.body);
session.save(olmSession); session.save(olmSession);
session.lastUsed = this._timestamp; session.data.lastUsed = this.timestamp;
return plaintext; return plaintext;
} catch (err) { } catch (err) {
if (isPreKeyMessage(message)) { if (isPreKeyMessage(message)) {
@ -286,27 +309,27 @@ class SenderKeyDecryption {
* @property {Array<DecryptionError>} errors see DecryptionError.event to retrieve the event that failed to decrypt. * @property {Array<DecryptionError>} errors see DecryptionError.event to retrieve the event that failed to decrypt.
*/ */
class DecryptionChanges { class DecryptionChanges {
constructor(senderKeyDecryptions, results, errors, account, lock) { constructor(
this._senderKeyDecryptions = senderKeyDecryptions; private readonly senderKeyDecryptions: SenderKeyDecryption[],
this._account = account; private readonly results: DecryptionResult[],
this.results = results; private readonly errors: DecryptionError[],
this.errors = errors; private readonly account: Account,
this._lock = lock; private readonly lock: ILock
) {}
get hasNewSessions(): boolean {
return this.senderKeyDecryptions.some(skd => skd.hasNewSessions);
} }
get hasNewSessions() { write(txn: Transaction): void {
return this._senderKeyDecryptions.some(skd => skd.hasNewSessions);
}
write(txn) {
try { try {
for (const senderKeyDecryption of this._senderKeyDecryptions) { for (const senderKeyDecryption of this.senderKeyDecryptions) {
for (const session of senderKeyDecryption.getModifiedSessions()) { for (const session of senderKeyDecryption.getModifiedSessions()) {
txn.olmSessions.set(session.data); txn.olmSessions.set(session.data);
if (session.isNew) { if (session.isNew) {
const olmSession = session.load(); const olmSession = session.load();
try { try {
this._account.writeRemoveOneTimeKey(olmSession, txn); this.account.writeRemoveOneTimeKey(olmSession, txn);
} finally { } finally {
session.unload(olmSession); session.unload(olmSession);
} }
@ -322,7 +345,7 @@ class DecryptionChanges {
} }
} }
} finally { } finally {
this._lock.release(); this.lock.release();
} }
} }
} }

View file

@ -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<string, any>;
sender?: string;
recipient?: string;
recipient_keys?: {ed25519?: string};
keys?: {ed25519?: string};
}

View file

@ -14,7 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export class Lock { export interface ILock {
release(): void;
}
export class Lock implements ILock {
private _promise?: Promise<void>; private _promise?: Promise<void>;
private _resolve?: (() => void); private _resolve?: (() => void);
@ -52,7 +56,7 @@ export class Lock {
} }
} }
export class MultiLock { export class MultiLock implements ILock {
constructor(public readonly locks: Lock[]) { constructor(public readonly locks: Lock[]) {
} }