diff --git a/src/matrix/SendScheduler.js b/src/matrix/SendScheduler.js index 6e6196d3..e7627c81 100644 --- a/src/matrix/SendScheduler.js +++ b/src/matrix/SendScheduler.js @@ -121,7 +121,7 @@ export class SendScheduler { } this._sendRequests = []; } - console.error("error for request", request); + console.error("error for request", err); request.reject(err); break; } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 652ac18e..1d0ac73e 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -18,11 +18,14 @@ import {Room} from "./room/Room.js"; import { ObservableMap } from "../observable/index.js"; import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js"; import {User} from "./User.js"; -import {Account as E2EEAccount} from "./e2ee/Account.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; +import {Account as E2EEAccount} from "./e2ee/Account.js"; import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js"; import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption.js"; +import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js"; +import {MEGOLM_ALGORITHM} from "./e2ee/common.js"; +import {RoomEncryption} from "./e2ee/RoomEncryption.js"; import {DeviceTracker} from "./e2ee/DeviceTracker.js"; import {LockMap} from "../utils/LockMap.js"; @@ -56,6 +59,7 @@ export class Session { ownDeviceId: sessionInfo.deviceId, }); } + this._createRoomEncryption = this._createRoomEncryption.bind(this); } // called once this._e2eeAccount is assigned @@ -65,26 +69,59 @@ export class Session { const olmDecryption = new OlmDecryption({ account: this._e2eeAccount, pickleKey: PICKLE_KEY, + olm: this._olm, + storage: this._storage, now: this._clock.now, ownUserId: this._user.id, - storage: this._storage, - olm: this._olm, senderKeyLock }); this._olmEncryption = new OlmEncryption({ account: this._e2eeAccount, pickleKey: PICKLE_KEY, + olm: this._olm, + storage: this._storage, now: this._clock.now, ownUserId: this._user.id, - storage: this._storage, - olm: this._olm, olmUtil: this._olmUtil, senderKeyLock }); + this._megolmEncryption = new MegOlmEncryption({ + account: this._e2eeAccount, + pickleKey: PICKLE_KEY, + olm: this._olm, + storage: this._storage, + now: this._clock.now, + ownDeviceId: this._sessionInfo.deviceId, + }) const megolmDecryption = new MegOlmDecryption({pickleKey: PICKLE_KEY, olm: this._olm}); this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption}); } + _createRoomEncryption(room, encryptionParams) { + // TODO: this will actually happen when users start using the e2ee version for the first time + + // this should never happen because either a session was already synced once + // and thus an e2ee account was created as well and _setupEncryption is called from load + // OR + // this is a new session and loading it will load zero rooms, thus not calling this method. + // in this case _setupEncryption is called from beforeFirstSync, right after load, + // so any incoming synced rooms won't be there yet + if (!this._olmEncryption) { + throw new Error("creating room encryption before encryption got globally enabled"); + } + // only support megolm + if (encryptionParams.algorithm !== MEGOLM_ALGORITHM) { + return null; + } + return new RoomEncryption({ + room, + deviceTracker: this._deviceTracker, + olmEncryption: this._olmEncryption, + megolmEncryption: this._megolmEncryption, + encryptionParams + }); + } + // called after load async beforeFirstSync(isNewLogin) { if (this._olm) { @@ -202,6 +239,7 @@ export class Session { sendScheduler: this._sendScheduler, pendingEvents, user: this._user, + createRoomEncryption: this._createRoomEncryption }); this._rooms.add(roomId, room); return room; @@ -222,12 +260,6 @@ export class Session { changes.syncInfo = syncInfo; } if (this._deviceTracker) { - for (const {room, changes} of roomChanges) { - // TODO: move this so the room passes this to it's "encryption" object in its own writeSync method? - if (room.isTrackingMembers && changes.memberChanges?.size) { - await this._deviceTracker.writeMemberChanges(room, changes.memberChanges, txn); - } - } const deviceLists = syncResponse.device_lists; if (deviceLists) { await this._deviceTracker.writeDeviceChanges(deviceLists, txn); diff --git a/src/matrix/common.js b/src/matrix/common.js new file mode 100644 index 00000000..3c893234 --- /dev/null +++ b/src/matrix/common.js @@ -0,0 +1,22 @@ +/* +Copyright 2020 Bruno Windels +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. +*/ + +export function makeTxnId() { + const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + const str = n.toString(16); + return "t" + "0".repeat(14 - str.length) + str; +} \ No newline at end of file diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js new file mode 100644 index 00000000..5f0c4cc1 --- /dev/null +++ b/src/matrix/e2ee/RoomEncryption.js @@ -0,0 +1,66 @@ +/* +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 {groupBy} from "../../utils/groupBy.js"; +import {makeTxnId} from "../common.js"; + +const ENCRYPTED_TYPE = "m.room.encrypted"; + +export class RoomEncryption { + constructor({room, deviceTracker, olmEncryption, megolmEncryption, encryptionParams}) { + this._room = room; + this._deviceTracker = deviceTracker; + this._olmEncryption = olmEncryption; + this._megolmEncryption = megolmEncryption; + // content of the m.room.encryption event + this._encryptionParams = encryptionParams; + } + + async writeMemberChanges(memberChanges, txn) { + return await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); + } + + async encrypt(type, content, hsApi) { + const megolmResult = await this._megolmEncryption.encrypt(this._room.id, type, content, this._encryptionParams); + // share the new megolm session if needed + if (megolmResult.roomKeyMessage) { + await this._deviceTracker.trackRoom(this._room); + const devices = await this._deviceTracker.deviceIdentitiesForTrackedRoom(this._room.id, hsApi); + const messages = await this._olmEncryption.encrypt( + "m.room_key", megolmResult.roomKeyMessage, devices, hsApi); + await this._sendMessagesToDevices(ENCRYPTED_TYPE, messages, hsApi); + } + return { + type: ENCRYPTED_TYPE, + content: megolmResult.content + }; + } + + async _sendMessagesToDevices(type, messages, hsApi) { + const messagesByUser = groupBy(messages, message => message.device.userId); + const payload = { + messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => { + userMap[userId] = messages.reduce((deviceMap, message) => { + deviceMap[message.device.deviceId] = message.content; + return deviceMap; + }, {}); + return userMap; + }, {}) + }; + const txnId = makeTxnId(); + await hsApi.sendToDevice(type, payload, txnId).response(); + } +} diff --git a/src/matrix/e2ee/megolm/Decryption.js b/src/matrix/e2ee/megolm/Decryption.js index bb5103e6..68bca6fe 100644 --- a/src/matrix/e2ee/megolm/Decryption.js +++ b/src/matrix/e2ee/megolm/Decryption.js @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// senderKey is a curve25519 key export class Decryption { constructor({pickleKey, olm}) { this._pickleKey = pickleKey; diff --git a/src/matrix/e2ee/megolm/Encryption.js b/src/matrix/e2ee/megolm/Encryption.js new file mode 100644 index 00000000..0b374f48 --- /dev/null +++ b/src/matrix/e2ee/megolm/Encryption.js @@ -0,0 +1,147 @@ +/* +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 {MEGOLM_ALGORITHM} from "../common.js"; + +export class Encryption { + constructor({pickleKey, olm, account, storage, now, ownDeviceId}) { + this._pickleKey = pickleKey; + this._olm = olm; + this._account = account; + this._storage = storage; + this._now = now; + this._ownDeviceId = ownDeviceId; + } + + async encrypt(roomId, type, content, encryptionParams) { + let session = new this._olm.OutboundGroupSession(); + try { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.inboundGroupSessions, + this._storage.storeNames.outboundGroupSessions, + ]); + let roomKeyMessage; + let encryptedContent; + try { + let sessionEntry = await txn.outboundGroupSessions.get(roomId); + if (sessionEntry) { + session.unpickle(this._pickleKey, sessionEntry.session); + } + if (!sessionEntry || this._needsToRotate(session, sessionEntry.createdAt, encryptionParams)) { + // in the case of rotating, recreate a session as we already unpickled into it + if (session) { + session.free(); + session = new this._olm.OutboundGroupSession(); + } + session.create(); + roomKeyMessage = this._createRoomKeyMessage(session, roomId); + this._storeAsInboundSession(session, roomId, txn); + // TODO: we could tell the Decryption here that we have a new session so it can add it to its cache + } + encryptedContent = this._encryptContent(roomId, session, type, content); + txn.outboundGroupSessions.set({ + roomId, + session: session.pickle(this._pickleKey), + createdAt: sessionEntry?.createdAt || this._now(), + }); + + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + return new EncryptionResult(encryptedContent, roomKeyMessage); + } finally { + if (session) { + session.free(); + } + } + } + + _needsToRotate(session, createdAt, encryptionParams) { + let rotationPeriodMs = 604800000; // default + if (Number.isSafeInteger(encryptionParams?.rotation_period_ms)) { + rotationPeriodMs = encryptionParams?.rotation_period_ms; + } + let rotationPeriodMsgs = 100; // default + if (Number.isSafeInteger(encryptionParams?.rotation_period_msgs)) { + rotationPeriodMsgs = encryptionParams?.rotation_period_msgs; + } + + if (this._now() > (createdAt + rotationPeriodMs)) { + return true; + } + if (session.message_index() >= rotationPeriodMsgs) { + return true; + } + } + + _encryptContent(roomId, session, type, content) { + const plaintext = JSON.stringify({ + room_id: roomId, + type, + content + }); + const ciphertext = session.encrypt(plaintext); + + const encryptedContent = { + algorithm: MEGOLM_ALGORITHM, + sender_key: this._account.identityKeys.curve25519, + ciphertext, + session_id: session.session_id(), + device_id: this._ownDeviceId + }; + + return encryptedContent; + } + + _createRoomKeyMessage(session, roomId) { + return { + room_id: roomId, + session_id: session.session_id(), + session_key: session.session_key(), + algorithm: MEGOLM_ALGORITHM, + // chain_index: session.message_index() + } + } + + _storeAsInboundSession(outboundSession, roomId, txn) { + const {identityKeys} = this._account; + const claimedKeys = {ed25519: identityKeys.ed25519}; + const session = new this._olm.InboundGroupSession(); + try { + session.create(outboundSession.session_key()); + const sessionEntry = { + roomId, + senderKey: identityKeys.curve25519, + sessionId: session.session_id(), + session: session.pickle(this._pickleKey), + claimedKeys, + }; + txn.inboundGroupSessions.set(sessionEntry); + return sessionEntry; + } finally { + session.free(); + } + } +} + +class EncryptionResult { + constructor(content, roomKeyMessage) { + this.content = content; + this.roomKeyMessage = roomKeyMessage; + } +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 9ea7dc26..b1a89634 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -172,6 +172,10 @@ export class HomeServerApi { return this._post("/keys/claim", null, payload, options); } + sendToDevice(type, payload, txnId, options = null) { + return this._put(`/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, payload, options); + } + get mediaRepository() { return this._mediaRepository; } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 98cde3a5..d2272b3d 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -27,7 +27,7 @@ import {MemberList} from "./members/MemberList.js"; import {Heroes} from "./members/Heroes.js"; export class Room extends EventEmitter { - constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) { + constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption}) { super(); this._roomId = roomId; this._storage = storage; @@ -41,6 +41,8 @@ export class Room extends EventEmitter { this._user = user; this._changedMembersDuringSync = null; this._memberList = null; + this._createRoomEncryption = createRoomEncryption; + this._roomEncryption = null; } /** @package */ @@ -62,6 +64,10 @@ export class Room extends EventEmitter { } heroChanges = await this._heroes.calculateChanges(summaryChanges.heroes, memberChanges, txn); } + // pass member changes to device tracker + if (this._roomEncryption && this.isTrackingMembers && memberChanges?.size) { + await this._roomEncryption.writeMemberChanges(memberChanges, txn); + } let removedPendingEvents; if (roomResponse.timeline && roomResponse.timeline.events) { removedPendingEvents = this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn); @@ -79,6 +85,13 @@ export class Room extends EventEmitter { /** @package */ afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) { this._syncWriter.afterSync(newLiveKey); + // encryption got enabled + if (!this._summary.encryption && summaryChanges.encryption && !this._roomEncryption) { + this._roomEncryption = this._createRoomEncryption(this, summaryChanges.encryption); + if (this._roomEncryption) { + this._sendQueue.enableEncryption(this._roomEncryption); + } + } if (memberChanges.size) { if (this._changedMembersDuringSync) { for (const [userId, memberChange] of memberChanges.entries()) { @@ -125,6 +138,12 @@ export class Room extends EventEmitter { async load(summary, txn) { try { this._summary.load(summary); + if (this._summary.encryption) { + this._roomEncryption = this._createRoomEncryption(this, this._summary.encryption); + if (this._roomEncryption) { + this._sendQueue.enableEncryption(this._roomEncryption); + } + } // need to load members for name? if (this._summary.needsHeroes) { this._heroes = new Heroes(this._roomId); diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 2b4f7477..fb2d1a47 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -26,5 +26,12 @@ export class PendingEvent { get remoteId() { return this._data.remoteId; } set remoteId(value) { this._data.remoteId = value; } get content() { return this._data.content; } + get needsEncryption() { return this._data.needsEncryption; } get data() { return this._data; } + + setEncrypted(type, content) { + this._data.eventType = type; + this._data.content = content; + this._data.needsEncryption = false; + } } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index ba215e04..fe7afe77 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -17,12 +17,7 @@ limitations under the License. import {SortedArray} from "../../../observable/list/SortedArray.js"; import {ConnectionError} from "../../error.js"; import {PendingEvent} from "./PendingEvent.js"; - -function makeTxnId() { - const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); - const str = n.toString(16); - return "t" + "0".repeat(14 - str.length) + str; -} +import {makeTxnId} from "../../common.js"; export class SendQueue { constructor({roomId, storage, sendScheduler, pendingEvents}) { @@ -38,6 +33,11 @@ export class SendQueue { this._isSending = false; this._offline = false; this._amountSent = 0; + this._roomEncryption = null; + } + + enableEncryption(roomEncryption) { + this._roomEncryption = roomEncryption; } async _sendLoop() { @@ -50,6 +50,13 @@ export class SendQueue { if (pendingEvent.remoteId) { continue; } + if (pendingEvent.needsEncryption) { + const {type, content} = await this._sendScheduler.request(async hsApi => { + return await this._roomEncryption.encrypt(pendingEvent.eventType, pendingEvent.content, hsApi); + }); + pendingEvent.setEncrypted(type, content); + await this._tryUpdateEvent(pendingEvent); + } console.log("really sending now"); const response = await this._sendScheduler.request(hsApi => { console.log("got sendScheduler slot"); @@ -161,7 +168,8 @@ export class SendQueue { queueIndex, eventType, content, - txnId: makeTxnId() + txnId: makeTxnId(), + needsEncryption: !!this._roomEncryption }); console.log("_createAndStoreEvent: adding to pendingEventsStore"); pendingEventsStore.add(pendingEvent.data); diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index 76a60e66..473b8eb6 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -26,6 +26,7 @@ export const STORE_NAMES = Object.freeze([ "deviceIdentities", "olmSessions", "inboundGroupSessions", + "outboundGroupSessions", ]); 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 fa862c08..8b42b0f7 100644 --- a/src/matrix/storage/idb/Transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -28,6 +28,7 @@ import {UserIdentityStore} from "./stores/UserIdentityStore.js"; import {DeviceIdentityStore} from "./stores/DeviceIdentityStore.js"; import {OlmSessionStore} from "./stores/OlmSessionStore.js"; import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore.js"; +import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore.js"; export class Transaction { constructor(txn, allowedStoreNames) { @@ -100,7 +101,10 @@ export class Transaction { get inboundGroupSessions() { return this._store("inboundGroupSessions", idbStore => new InboundGroupSessionStore(idbStore)); } - + + get outboundGroupSessions() { + return this._store("outboundGroupSessions", idbStore => new OutboundGroupSessionStore(idbStore)); + } complete() { return txnAsPromise(this._txn); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 81a56991..2060cb39 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -12,6 +12,7 @@ export const schema = [ createIdentityStores, createOlmSessionStore, createInboundGroupSessionsStore, + createOutboundGroupSessionsStore, ]; // TODO: how to deal with git merge conflicts of this array? @@ -82,3 +83,9 @@ function createOlmSessionStore(db) { function createInboundGroupSessionsStore(db) { db.createObjectStore("inboundGroupSessions", {keyPath: "key"}); } + +//v7 +function createOutboundGroupSessionsStore(db) { + db.createObjectStore("outboundGroupSessions", {keyPath: "roomId"}); +} + diff --git a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.js b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.js new file mode 100644 index 00000000..ef9224be --- /dev/null +++ b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.js @@ -0,0 +1,29 @@ +/* +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. +*/ + +export class OutboundGroupSessionStore { + constructor(store) { + this._store = store; + } + + get(roomId) { + return this._store.get(roomId); + } + + set(session) { + this._store.put(session); + } +}