Merge pull request #84 from vector-im/bwindels/megolm-decrypt

Implement megolm decryption and hooking up decryption in the room
This commit is contained in:
Bruno Windels 2020-09-04 14:50:46 +00:00 committed by GitHub
commit b8ba4c5771
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 464 additions and 58 deletions

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./e2ee/common.js"; import {OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./e2ee/common.js";
import {groupBy} from "../utils/groupBy.js";
// key to store in session store // key to store in session store
const PENDING_ENCRYPTED_EVENTS = "pendingEncryptedDeviceEvents"; const PENDING_ENCRYPTED_EVENTS = "pendingEncryptedDeviceEvents";
@ -44,21 +45,23 @@ export class DeviceMessageHandler {
const megOlmRoomKeysPayloads = payloads.filter(p => { const megOlmRoomKeysPayloads = payloads.filter(p => {
return p.event?.type === "m.room_key" && p.event.content?.algorithm === MEGOLM_ALGORITHM; return p.event?.type === "m.room_key" && p.event.content?.algorithm === MEGOLM_ALGORITHM;
}); });
let megolmChanges; let roomKeys;
if (megOlmRoomKeysPayloads.length) { if (megOlmRoomKeysPayloads.length) {
megolmChanges = await this._megolmDecryption.addRoomKeys(megOlmRoomKeysPayloads, txn); roomKeys = await this._megolmDecryption.addRoomKeys(megOlmRoomKeysPayloads, txn);
} }
return {megolmChanges}; return {roomKeys};
} }
_applyDecryptChanges({megolmChanges}) { _applyDecryptChanges(rooms, {roomKeys}) {
if (megolmChanges) { const roomKeysByRoom = groupBy(roomKeys, s => s.roomId);
this._megolmDecryption.applyRoomKeyChanges(megolmChanges); for (const [roomId, roomKeys] of roomKeysByRoom) {
const room = rooms.get(roomId);
room?.notifyRoomKeys(roomKeys);
} }
} }
// not safe to call multiple times without awaiting first call // not safe to call multiple times without awaiting first call
async decryptPending() { async decryptPending(rooms) {
if (!this._olmDecryption) { if (!this._olmDecryption) {
return; return;
} }
@ -89,7 +92,7 @@ export class DeviceMessageHandler {
throw err; throw err;
} }
await txn.complete(); await txn.complete();
this._applyDecryptChanges(changes); this._applyDecryptChanges(rooms, changes);
} }
async _getPendingEvents(txn) { async _getPendingEvents(txn) {

View File

@ -49,6 +49,9 @@ export class Session {
this._e2eeAccount = null; this._e2eeAccount = null;
this._deviceTracker = null; this._deviceTracker = null;
this._olmEncryption = null; this._olmEncryption = null;
this._megolmEncryption = null;
this._megolmDecryption = null;
if (olm) { if (olm) {
this._olmUtil = new olm.Utility(); this._olmUtil = new olm.Utility();
this._deviceTracker = new DeviceTracker({ this._deviceTracker = new DeviceTracker({
@ -92,9 +95,12 @@ export class Session {
storage: this._storage, storage: this._storage,
now: this._clock.now, now: this._clock.now,
ownDeviceId: this._sessionInfo.deviceId, ownDeviceId: this._sessionInfo.deviceId,
}) });
const megolmDecryption = new MegOlmDecryption({pickleKey: PICKLE_KEY, olm: this._olm}); this._megolmDecryption = new MegOlmDecryption({
this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption}); pickleKey: PICKLE_KEY,
olm: this._olm,
});
this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption});
} }
_createRoomEncryption(room, encryptionParams) { _createRoomEncryption(room, encryptionParams) {
@ -118,6 +124,7 @@ export class Session {
deviceTracker: this._deviceTracker, deviceTracker: this._deviceTracker,
olmEncryption: this._olmEncryption, olmEncryption: this._olmEncryption,
megolmEncryption: this._megolmEncryption, megolmEncryption: this._megolmEncryption,
megolmDecryption: this._megolmDecryption,
encryptionParams encryptionParams
}); });
} }
@ -150,7 +157,7 @@ export class Session {
} }
await this._e2eeAccount.generateOTKsIfNeeded(this._storage); await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
await this._e2eeAccount.uploadKeys(this._storage); await this._e2eeAccount.uploadKeys(this._storage);
await this._deviceMessageHandler.decryptPending(); await this._deviceMessageHandler.decryptPending(this.rooms);
} }
} }
@ -285,7 +292,7 @@ export class Session {
async afterSyncCompleted() { async afterSyncCompleted() {
const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage); const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
const promises = [this._deviceMessageHandler.decryptPending()]; const promises = [this._deviceMessageHandler.decryptPending(this.rooms)];
if (needsToUploadOTKs) { if (needsToUploadOTKs) {
// TODO: we could do this in parallel with sync if it proves to be too slow // TODO: we could do this in parallel with sync if it proves to be too slow
// but I'm not sure how to not swallow errors in that case // but I'm not sure how to not swallow errors in that case

View File

@ -133,6 +133,8 @@ export class Sync {
storeNames.timelineFragments, storeNames.timelineFragments,
storeNames.pendingEvents, storeNames.pendingEvents,
storeNames.userIdentities, storeNames.userIdentities,
storeNames.inboundGroupSessions,
storeNames.groupSessionDecryptions,
]); ]);
const roomChanges = []; const roomChanges = [];
let sessionChanges; let sessionChanges;

View File

@ -14,25 +14,77 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MEGOLM_ALGORITHM} from "./common.js";
import {groupBy} from "../../utils/groupBy.js"; import {groupBy} from "../../utils/groupBy.js";
import {makeTxnId} from "../common.js"; import {makeTxnId} from "../common.js";
const ENCRYPTED_TYPE = "m.room.encrypted"; const ENCRYPTED_TYPE = "m.room.encrypted";
export class RoomEncryption { export class RoomEncryption {
constructor({room, deviceTracker, olmEncryption, megolmEncryption, encryptionParams}) { constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams}) {
this._room = room; this._room = room;
this._deviceTracker = deviceTracker; this._deviceTracker = deviceTracker;
this._olmEncryption = olmEncryption; this._olmEncryption = olmEncryption;
this._megolmEncryption = megolmEncryption; this._megolmEncryption = megolmEncryption;
this._megolmDecryption = megolmDecryption;
// content of the m.room.encryption event // content of the m.room.encryption event
this._encryptionParams = encryptionParams; this._encryptionParams = encryptionParams;
this._megolmBackfillCache = this._megolmDecryption.createSessionCache();
this._megolmSyncCache = this._megolmDecryption.createSessionCache();
// not `event_id`, but an internal event id passed in to the decrypt methods
this._eventIdsByMissingSession = new Map();
}
notifyTimelineClosed() {
// empty the backfill cache when closing the timeline
this._megolmBackfillCache.dispose();
this._megolmBackfillCache = this._megolmDecryption.createSessionCache();
} }
async writeMemberChanges(memberChanges, txn) { async writeMemberChanges(memberChanges, txn) {
return await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); return await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
} }
async decrypt(event, isSync, retryData, txn) {
if (event.content?.algorithm !== MEGOLM_ALGORITHM) {
throw new Error("Unsupported algorithm: " + event.content?.algorithm);
}
let sessionCache = isSync ? this._megolmSyncCache : this._megolmBackfillCache;
const payload = await this._megolmDecryption.decrypt(
this._room.id, event, sessionCache, txn);
if (!payload) {
this._addMissingSessionEvent(event, isSync, retryData);
}
return payload;
}
_addMissingSessionEvent(event, isSync, data) {
const senderKey = event.content?.["sender_key"];
const sessionId = event.content?.["session_id"];
const key = `${senderKey}|${sessionId}`;
let eventIds = this._eventIdsByMissingSession.get(key);
if (!eventIds) {
eventIds = new Map();
this._eventIdsByMissingSession.set(key, eventIds);
}
eventIds.set(event.event_id, {data, isSync});
}
applyRoomKeys(roomKeys) {
// retry decryption with the new sessions
const retryEntries = [];
for (const roomKey of roomKeys) {
const key = `${roomKey.senderKey}|${roomKey.sessionId}`;
const entriesForSession = this._eventIdsByMissingSession.get(key);
if (entriesForSession) {
this._eventIdsByMissingSession.delete(key);
retryEntries.push(...entriesForSession.values());
}
}
return retryEntries;
}
async encrypt(type, content, hsApi) { async encrypt(type, content, hsApi) {
const megolmResult = await this._megolmEncryption.encrypt(this._room.id, type, content, this._encryptionParams); const megolmResult = await this._megolmEncryption.encrypt(this._room.id, type, content, this._encryptionParams);
// share the new megolm session if needed // share the new megolm session if needed

View File

@ -14,12 +14,87 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {DecryptionError} from "../common.js";
const CACHE_MAX_SIZE = 10;
export class Decryption { export class Decryption {
constructor({pickleKey, olm}) { constructor({pickleKey, olm}) {
this._pickleKey = pickleKey; this._pickleKey = pickleKey;
this._olm = olm; this._olm = olm;
} }
createSessionCache() {
return new SessionCache();
}
async decrypt(roomId, event, sessionCache, txn) {
const senderKey = event.content?.["sender_key"];
const sessionId = event.content?.["session_id"];
const ciphertext = event.content?.ciphertext;
if (
typeof senderKey !== "string" ||
typeof sessionId !== "string" ||
typeof ciphertext !== "string"
) {
throw new DecryptionError("MEGOLM_INVALID_EVENT", event);
}
let session = sessionCache.get(roomId, senderKey, sessionId);
if (!session) {
const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
if (sessionEntry) {
session = new this._olm.InboundGroupSession();
try {
session.unpickle(this._pickleKey, sessionEntry.session);
} catch (err) {
session.free();
throw err;
}
sessionCache.add(roomId, senderKey, session);
}
}
if (!session) {
return;
}
const {plaintext, message_index: messageIndex} = session.decrypt(ciphertext);
let payload;
try {
payload = JSON.parse(plaintext);
} catch (err) {
throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
}
if (payload.room_id !== roomId) {
throw new DecryptionError("MEGOLM_WRONG_ROOM", event,
{encryptedRoomId: payload.room_id, eventRoomId: roomId});
}
await this._handleReplayAttack(roomId, sessionId, messageIndex, event, txn);
// TODO: verify event came from said senderKey
return payload;
}
async _handleReplayAttack(roomId, sessionId, messageIndex, event, txn) {
const eventId = event.event_id;
const timestamp = event.origin_server_ts;
const decryption = await txn.groupSessionDecryptions.get(roomId, sessionId, messageIndex);
if (decryption && decryption.eventId !== eventId) {
// the one with the newest timestamp should be the attack
const decryptedEventIsBad = decryption.timestamp < timestamp;
const badEventId = decryptedEventIsBad ? eventId : decryption.eventId;
throw new DecryptionError("MEGOLM_REPLAYED_INDEX", event, {badEventId, otherEventId: decryption.eventId});
}
if (!decryption) {
txn.groupSessionDecryptions.set({
roomId,
sessionId,
messageIndex,
eventId,
timestamp
});
}
}
async addRoomKeys(payloads, txn) { async addRoomKeys(payloads, txn) {
const newSessions = []; const newSessions = [];
for (const {senderKey, event} of payloads) { for (const {senderKey, event} of payloads) {
@ -36,6 +111,7 @@ export class Decryption {
return; return;
} }
// TODO: compare first_known_index to see which session to keep
const hasSession = await txn.inboundGroupSessions.has(roomId, senderKey, sessionId); const hasSession = await txn.inboundGroupSessions.has(roomId, senderKey, sessionId);
if (!hasSession) { if (!hasSession) {
const session = new this._olm.InboundGroupSession(); const session = new this._olm.InboundGroupSession();
@ -56,13 +132,49 @@ export class Decryption {
} }
} }
// this will be passed to the Room in notifyRoomKeys
return newSessions; return newSessions;
} }
}
applyRoomKeyChanges(newSessions) { class SessionCache {
// retry decryption with the new sessions constructor() {
if (newSessions.length) { this._sessions = [];
console.log(`I have ${newSessions.length} new inbound group sessions`, newSessions) }
get(roomId, senderKey, sessionId) {
const idx = this._sessions.findIndex(s => {
return s.roomId === roomId &&
s.senderKey === senderKey &&
sessionId === s.session.session_id();
});
if (idx !== -1) {
const entry = this._sessions[idx];
// move to top
if (idx > 0) {
this._sessions.splice(idx, 1);
this._sessions.unshift(entry);
}
return entry.session;
} }
} }
add(roomId, senderKey, session) {
// add new at top
this._sessions.unshift({roomId, senderKey, session});
if (this._sessions.length > CACHE_MAX_SIZE) {
// free sessions we're about to remove
for (let i = CACHE_MAX_SIZE; i < this._sessions.length; i += 1) {
this._sessions[i].session.free();
}
this._sessions = this._sessions.slice(0, CACHE_MAX_SIZE);
}
}
dispose() {
for (const entry of this._sessions) {
entry.session.free();
}
}
} }

View File

@ -36,13 +36,14 @@ export class Encryption {
let roomKeyMessage; let roomKeyMessage;
let encryptedContent; let encryptedContent;
try { try {
// TODO: we could consider keeping the session in memory for the current room
let sessionEntry = await txn.outboundGroupSessions.get(roomId); let sessionEntry = await txn.outboundGroupSessions.get(roomId);
if (sessionEntry) { if (sessionEntry) {
session.unpickle(this._pickleKey, sessionEntry.session); session.unpickle(this._pickleKey, sessionEntry.session);
} }
if (!sessionEntry || this._needsToRotate(session, sessionEntry.createdAt, encryptionParams)) { if (!sessionEntry || this._needsToRotate(session, sessionEntry.createdAt, encryptionParams)) {
// in the case of rotating, recreate a session as we already unpickled into it // in the case of rotating, recreate a session as we already unpickled into it
if (session) { if (sessionEntry) {
session.free(); session.free();
session = new this._olm.OutboundGroupSession(); session = new this._olm.OutboundGroupSession();
} }
@ -114,6 +115,11 @@ export class Encryption {
session_id: session.session_id(), session_id: session.session_id(),
session_key: session.session_key(), session_key: session.session_key(),
algorithm: MEGOLM_ALGORITHM, algorithm: MEGOLM_ALGORITHM,
// if we need to do this, do we need to create
// the room key message after or before having encrypted
// with the new session? I guess before as we do now
// because the chain_index is where you should start decrypting?
//
// chain_index: session.message_index() // chain_index: session.message_index()
} }
} }

View File

@ -115,13 +115,13 @@ export class Decryption {
try { try {
payload = JSON.parse(plaintext); payload = JSON.parse(plaintext);
} catch (err) { } catch (err) {
throw new DecryptionError("Could not JSON decode plaintext", event, {plaintext, err}); throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
} }
this._validatePayload(payload, event); this._validatePayload(payload, event);
return {event: payload, senderKey}; return {event: payload, senderKey};
} else { } else {
throw new DecryptionError("Didn't find any session to decrypt with", event, throw new DecryptionError("OLM_NO_MATCHING_SESSION", event,
{sessionIds: senderKeyDecryption.sessions.map(s => s.id)}); {knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)});
} }
} }

View File

@ -25,6 +25,8 @@ import {WrappedError} from "../error.js"
import {fetchOrLoadMembers} from "./members/load.js"; import {fetchOrLoadMembers} from "./members/load.js";
import {MemberList} from "./members/MemberList.js"; import {MemberList} from "./members/MemberList.js";
import {Heroes} from "./members/Heroes.js"; import {Heroes} from "./members/Heroes.js";
import {EventEntry} from "./timeline/entries/EventEntry.js";
import {EventKey} from "./timeline/EventKey.js";
export class Room extends EventEmitter { export class Room extends EventEmitter {
constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption}) { constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption}) {
@ -45,6 +47,79 @@ export class Room extends EventEmitter {
this._roomEncryption = null; this._roomEncryption = null;
} }
async notifyRoomKeys(roomKeys) {
if (this._roomEncryption) {
// array of {data, source}
let retryEntries = this._roomEncryption.applyRoomKeys(roomKeys);
let decryptedEntries = [];
if (retryEntries.length) {
// groupSessionDecryptions can be written, the other stores not
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions,
]);
try {
for (const retryEntry of retryEntries) {
const {data: eventKey} = retryEntry;
let entry = this._timeline?.findEntry(eventKey);
if (!entry) {
const storageEntry = await txn.timelineEvents.get(this._roomId, eventKey.fragmentId, eventKey.entryIndex);
if (storageEntry) {
entry = new EventEntry(storageEntry, this._fragmentIdComparer);
}
}
if (entry) {
entry = await this._decryptEntry(entry, txn, retryEntry.isSync);
decryptedEntries.push(entry);
}
}
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
}
if (this._timeline) {
// only adds if already present
this._timeline.replaceEntries(decryptedEntries);
}
// pass decryptedEntries to roomSummary
}
}
_enableEncryption(encryptionParams) {
this._roomEncryption = this._createRoomEncryption(this, encryptionParams);
if (this._roomEncryption) {
this._sendQueue.enableEncryption(this._roomEncryption);
if (this._timeline) {
this._timeline.enableEncryption(this._decryptEntries.bind(this));
}
}
}
async _decryptEntry(entry, txn, isSync) {
if (entry.eventType === "m.room.encrypted") {
try {
const {fragmentId, entryIndex} = entry;
const key = new EventKey(fragmentId, entryIndex);
const decryptedEvent = await this._roomEncryption.decrypt(
entry.event, isSync, key, txn);
if (decryptedEvent) {
entry.replaceWithDecrypted(decryptedEvent);
}
} catch (err) {
console.warn("event decryption error", err, entry.event);
entry.setDecryptionError(err);
}
}
return entry;
}
async _decryptEntries(entries, txn, isSync = false) {
return await Promise.all(entries.map(async e => this._decryptEntry(e, txn, isSync)));
}
/** @package */ /** @package */
async writeSync(roomResponse, membership, isInitialSync, txn) { async writeSync(roomResponse, membership, isInitialSync, txn) {
const isTimelineOpen = !!this._timeline; const isTimelineOpen = !!this._timeline;
@ -53,7 +128,13 @@ export class Room extends EventEmitter {
membership, membership,
isInitialSync, isTimelineOpen, isInitialSync, isTimelineOpen,
txn); txn);
const {entries, newLiveKey, memberChanges} = await this._syncWriter.writeSync(roomResponse, txn); const {entries: encryptedEntries, newLiveKey, memberChanges} =
await this._syncWriter.writeSync(roomResponse, txn);
// decrypt if applicable
let entries = encryptedEntries;
if (this._roomEncryption) {
entries = await this._decryptEntries(encryptedEntries, txn, true);
}
// fetch new members while we have txn open, // fetch new members while we have txn open,
// but don't make any in-memory changes yet // but don't make any in-memory changes yet
let heroChanges; let heroChanges;
@ -85,12 +166,8 @@ export class Room extends EventEmitter {
/** @package */ /** @package */
afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) { afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) {
this._syncWriter.afterSync(newLiveKey); this._syncWriter.afterSync(newLiveKey);
// encryption got enabled
if (!this._summary.encryption && summaryChanges.encryption && !this._roomEncryption) { if (!this._summary.encryption && summaryChanges.encryption && !this._roomEncryption) {
this._roomEncryption = this._createRoomEncryption(this, summaryChanges.encryption); this._enableEncryption(summaryChanges.encryption);
if (this._roomEncryption) {
this._sendQueue.enableEncryption(this._roomEncryption);
}
} }
if (memberChanges.size) { if (memberChanges.size) {
if (this._changedMembersDuringSync) { if (this._changedMembersDuringSync) {
@ -139,10 +216,7 @@ export class Room extends EventEmitter {
try { try {
this._summary.load(summary); this._summary.load(summary);
if (this._summary.encryption) { if (this._summary.encryption) {
this._roomEncryption = this._createRoomEncryption(this, this._summary.encryption); this._enableEncryption(this._summary.encryption);
if (this._roomEncryption) {
this._sendQueue.enableEncryption(this._roomEncryption);
}
} }
// need to load members for name? // need to load members for name?
if (this._summary.needsHeroes) { if (this._summary.needsHeroes) {
@ -200,11 +274,18 @@ export class Room extends EventEmitter {
} }
}).response(); }).response();
const txn = await this._storage.readWriteTxn([ let stores = [
this._storage.storeNames.pendingEvents, this._storage.storeNames.pendingEvents,
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments, this._storage.storeNames.timelineFragments,
]); ];
if (this._roomEncryption) {
stores = stores.concat([
this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions,
]);
}
const txn = await this._storage.readWriteTxn(stores);
let removedPendingEvents; let removedPendingEvents;
let gapResult; let gapResult;
try { try {
@ -214,9 +295,12 @@ export class Room extends EventEmitter {
const gapWriter = new GapWriter({ const gapWriter = new GapWriter({
roomId: this._roomId, roomId: this._roomId,
storage: this._storage, storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer fragmentIdComparer: this._fragmentIdComparer,
}); });
gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn); gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn);
if (this._roomEncryption) {
gapResult.entries = await this._decryptEntries(gapResult.entries, txn, false);
}
} catch (err) { } catch (err) {
txn.abort(); txn.abort();
throw err; throw err;
@ -341,9 +425,15 @@ export class Room extends EventEmitter {
closeCallback: () => { closeCallback: () => {
console.log(`closing the timeline for ${this._roomId}`); console.log(`closing the timeline for ${this._roomId}`);
this._timeline = null; this._timeline = null;
if (this._roomEncryption) {
this._roomEncryption.notifyTimelineClosed();
}
}, },
user: this._user, user: this._user,
}); });
if (this._roomEncryption) {
this._timeline.enableEncryption(this._decryptEntries.bind(this));
}
await this._timeline.load(); await this._timeline.load();
return this._timeline; return this._timeline;
} }

View File

@ -18,6 +18,7 @@ import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js"
import {Direction} from "./Direction.js"; import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js"; import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js";
import {EventEntry} from "./entries/EventEntry.js";
export class Timeline { export class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) {
@ -45,6 +46,27 @@ export class Timeline {
this._remoteEntries.setManySorted(entries); this._remoteEntries.setManySorted(entries);
} }
findEntry(eventKey) {
// a storage event entry has a fragmentId and eventIndex property, used for sorting,
// just like an EventKey, so this will work, but perhaps a bit brittle.
const entry = new EventEntry(eventKey, this._fragmentIdComparer);
try {
const idx = this._remoteEntries.indexOf(entry);
if (idx !== -1) {
return this._remoteEntries.get(idx);
}
} catch (err) {
// fragmentIdComparer threw, ignore
return;
}
}
replaceEntries(entries) {
for (const entry of entries) {
this._remoteEntries.replace(entry);
}
}
// TODO: should we rather have generic methods for // TODO: should we rather have generic methods for
// - adding new entries // - adding new entries
// - updating existing entries (redaction, relations) // - updating existing entries (redaction, relations)
@ -84,4 +106,8 @@ export class Timeline {
this._closeCallback = null; this._closeCallback = null;
} }
} }
enableEncryption(decryptEntries) {
this._timelineReader.enableEncryption(decryptEntries);
}
} }

View File

@ -21,6 +21,12 @@ export class EventEntry extends BaseEntry {
constructor(eventEntry, fragmentIdComparer) { constructor(eventEntry, fragmentIdComparer) {
super(fragmentIdComparer); super(fragmentIdComparer);
this._eventEntry = eventEntry; this._eventEntry = eventEntry;
this._decryptionError = null;
this._decryptedEvent = null;
}
get event() {
return this._eventEntry.event;
} }
get fragmentId() { get fragmentId() {
@ -32,7 +38,7 @@ export class EventEntry extends BaseEntry {
} }
get content() { get content() {
return this._eventEntry.event.content; return this._decryptedEvent?.content || this._eventEntry.event.content;
} }
get prevContent() { get prevContent() {
@ -40,7 +46,7 @@ export class EventEntry extends BaseEntry {
} }
get eventType() { get eventType() {
return this._eventEntry.event.type; return this._decryptedEvent?.type || this._eventEntry.event.type;
} }
get stateKey() { get stateKey() {
@ -66,4 +72,12 @@ export class EventEntry extends BaseEntry {
get id() { get id() {
return this._eventEntry.event.event_id; return this._eventEntry.event.event_id;
} }
replaceWithDecrypted(event) {
this._decryptedEvent = event;
}
setDecryptionError(err) {
this._decryptionError = err;
}
} }

View File

@ -24,18 +24,41 @@ export class TimelineReader {
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
this._fragmentIdComparer = fragmentIdComparer; this._fragmentIdComparer = fragmentIdComparer;
this._decryptEntries = null;
}
enableEncryption(decryptEntries) {
this._decryptEntries = decryptEntries;
} }
_openTxn() { _openTxn() {
return this._storage.readTxn([ if (this._decryptEntries) {
this._storage.storeNames.timelineEvents, return this._storage.readWriteTxn([
this._storage.storeNames.timelineFragments, this._storage.storeNames.timelineEvents,
]); this._storage.storeNames.timelineFragments,
this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions,
]);
} else {
return this._storage.readTxn([
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments,
]);
}
} }
async readFrom(eventKey, direction, amount) { async readFrom(eventKey, direction, amount) {
const txn = await this._openTxn(); const txn = await this._openTxn();
return this._readFrom(eventKey, direction, amount, txn); let entries;
try {
entries = await this._readFrom(eventKey, direction, amount, txn);
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
return entries;
} }
async _readFrom(eventKey, direction, amount, txn) { async _readFrom(eventKey, direction, amount, txn) {
@ -50,7 +73,10 @@ export class TimelineReader {
} else { } else {
eventsWithinFragment = await timelineStore.eventsBefore(this._roomId, eventKey, amount); eventsWithinFragment = await timelineStore.eventsBefore(this._roomId, eventKey, amount);
} }
const eventEntries = eventsWithinFragment.map(e => new EventEntry(e, this._fragmentIdComparer)); let eventEntries = eventsWithinFragment.map(e => new EventEntry(e, this._fragmentIdComparer));
if (this._decryptEntries) {
eventEntries = await this._decryptEntries(eventEntries, txn);
}
entries = directionalConcat(entries, eventEntries, direction); entries = directionalConcat(entries, eventEntries, direction);
// prepend or append eventsWithinFragment to entries, and wrap them in EventEntry // prepend or append eventsWithinFragment to entries, and wrap them in EventEntry
@ -78,22 +104,24 @@ export class TimelineReader {
async readFromEnd(amount) { async readFromEnd(amount) {
const txn = await this._openTxn(); const txn = await this._openTxn();
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId); let entries;
// room hasn't been synced yet try {
if (!liveFragment) { const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
return []; // room hasn't been synced yet
if (!liveFragment) {
entries = [];
} else {
this._fragmentIdComparer.add(liveFragment);
const liveFragmentEntry = FragmentBoundaryEntry.end(liveFragment, this._fragmentIdComparer);
const eventKey = liveFragmentEntry.asEventKey();
entries = await this._readFrom(eventKey, Direction.Backward, amount, txn);
entries.unshift(liveFragmentEntry);
}
} catch (err) {
txn.abort();
throw err;
} }
this._fragmentIdComparer.add(liveFragment); await txn.complete();
const liveFragmentEntry = FragmentBoundaryEntry.end(liveFragment, this._fragmentIdComparer);
const eventKey = liveFragmentEntry.asEventKey();
const entries = await this._readFrom(eventKey, Direction.Backward, amount, txn);
entries.unshift(liveFragmentEntry);
return entries; return entries;
} }
// reads distance up and down from eventId
// or just expose eventIdToKey?
readAtEventId(eventId, distance) {
return null;
}
} }

View File

@ -27,6 +27,7 @@ export const STORE_NAMES = Object.freeze([
"olmSessions", "olmSessions",
"inboundGroupSessions", "inboundGroupSessions",
"outboundGroupSessions", "outboundGroupSessions",
"groupSessionDecryptions",
]); ]);
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => { export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {

View File

@ -29,6 +29,7 @@ import {DeviceIdentityStore} from "./stores/DeviceIdentityStore.js";
import {OlmSessionStore} from "./stores/OlmSessionStore.js"; import {OlmSessionStore} from "./stores/OlmSessionStore.js";
import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore.js"; import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore.js";
import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore.js"; import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore.js";
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore.js";
export class Transaction { export class Transaction {
constructor(txn, allowedStoreNames) { constructor(txn, allowedStoreNames) {
@ -105,6 +106,11 @@ export class Transaction {
get outboundGroupSessions() { get outboundGroupSessions() {
return this._store("outboundGroupSessions", idbStore => new OutboundGroupSessionStore(idbStore)); return this._store("outboundGroupSessions", idbStore => new OutboundGroupSessionStore(idbStore));
} }
get groupSessionDecryptions() {
return this._store("groupSessionDecryptions", idbStore => new GroupSessionDecryptionStore(idbStore));
}
complete() { complete() {
return txnAsPromise(this._txn); return txnAsPromise(this._txn);
} }

View File

@ -13,6 +13,7 @@ export const schema = [
createOlmSessionStore, createOlmSessionStore,
createInboundGroupSessionsStore, createInboundGroupSessionsStore,
createOutboundGroupSessionsStore, createOutboundGroupSessionsStore,
createGroupSessionDecryptions,
]; ];
// TODO: how to deal with git merge conflicts of this array? // TODO: how to deal with git merge conflicts of this array?
@ -89,3 +90,7 @@ function createOutboundGroupSessionsStore(db) {
db.createObjectStore("outboundGroupSessions", {keyPath: "roomId"}); db.createObjectStore("outboundGroupSessions", {keyPath: "roomId"});
} }
//v8
function createGroupSessionDecryptions(db) {
db.createObjectStore("groupSessionDecryptions", {keyPath: "key"});
}

View File

@ -0,0 +1,34 @@
/*
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(roomId, sessionId, messageIndex) {
return `${roomId}|${sessionId}|${messageIndex}`;
}
export class GroupSessionDecryptionStore {
constructor(store) {
this._store = store;
}
get(roomId, sessionId, messageIndex) {
return this._store.get(encodeKey(roomId, sessionId, messageIndex));
}
set(decryption) {
decryption.key = encodeKey(decryption.roomId, decryption.sessionId, decryption.messageIndex);
this._store.put(decryption);
}
}

View File

@ -29,6 +29,10 @@ export class InboundGroupSessionStore {
return key === fetchedKey; return key === fetchedKey;
} }
get(roomId, senderKey, sessionId) {
return this._store.get(encodeKey(roomId, senderKey, sessionId));
}
set(session) { set(session) {
session.key = encodeKey(session.roomId, session.senderKey, session.sessionId); session.key = encodeKey(session.roomId, session.senderKey, session.sessionId);
this._store.put(session); this._store.put(session);

View File

@ -41,6 +41,22 @@ export class SortedArray extends BaseObservableList {
} }
} }
replace(item) {
const idx = this.indexOf(item);
if (idx !== -1) {
this._items[idx] = item;
}
}
indexOf(item) {
const idx = sortedIndex(this._items, item, this._comparator);
if (idx < this._items.length && this._comparator(this._items[idx], item) === 0) {
return idx;
} else {
return -1;
}
}
set(item, updateParams = null) { set(item, updateParams = null) {
const idx = sortedIndex(this._items, item, this._comparator); const idx = sortedIndex(this._items, item, this._comparator);
if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) { if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) {