forked from mystiq/hydrogen-web
Merge pull request #252 from vector-im/bwindels/track-unknown-sessions
Track event ids of missing megolm sessions
This commit is contained in:
commit
ae5f1050ac
11 changed files with 343 additions and 322 deletions
|
@ -248,6 +248,7 @@ export class Sync {
|
||||||
return this._storage.readTxn([
|
return this._storage.readTxn([
|
||||||
storeNames.olmSessions,
|
storeNames.olmSessions,
|
||||||
storeNames.inboundGroupSessions,
|
storeNames.inboundGroupSessions,
|
||||||
|
storeNames.timelineEvents // to read events that can now be decrypted
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,40 +1,41 @@
|
||||||
## Integratation within the sync lifetime cycle
|
## Integratation within the sync lifetime cycle
|
||||||
|
|
||||||
### prepareSync
|
### session.prepareSync
|
||||||
|
|
||||||
|
Decrypt any device messages, and turn them into RoomKey instances.
|
||||||
|
Any rooms that are not in the sync response but for which we receive keys will be included in the rooms to sync.
|
||||||
|
|
||||||
|
Runs before any room.prepareSync, so the new room keys can be passed to each room prepareSync to use in decryption.
|
||||||
|
|
||||||
|
### room.prepareSync
|
||||||
|
|
||||||
The session can start its own read/write transactions here, rooms only read from a shared transaction
|
The session can start its own read/write transactions here, rooms only read from a shared transaction
|
||||||
|
|
||||||
- session
|
|
||||||
- device handler
|
|
||||||
- txn
|
|
||||||
- write pending encrypted
|
|
||||||
- txn
|
|
||||||
- olm decryption read
|
|
||||||
- olm async decryption
|
|
||||||
- dispatch to worker
|
|
||||||
- txn
|
|
||||||
- olm decryption write / remove pending encrypted
|
|
||||||
- rooms (with shared read txn)
|
- rooms (with shared read txn)
|
||||||
- megolm decryption read
|
- megolm decryption read using any new keys decrypted by the session.
|
||||||
|
|
||||||
### afterPrepareSync
|
### room.afterPrepareSync
|
||||||
|
|
||||||
- rooms
|
- rooms
|
||||||
- megolm async decryption
|
- megolm async decryption
|
||||||
- dispatch to worker
|
- dispatch to worker
|
||||||
|
|
||||||
### writeSync
|
### room.writeSync
|
||||||
|
|
||||||
- rooms (with shared readwrite txn)
|
- rooms (with shared readwrite txn)
|
||||||
- megolm decryption write, yielding decrypted events
|
- megolm decryption write, yielding decrypted events
|
||||||
- use decrypted events to write room summary
|
- use decrypted events to write room summary
|
||||||
|
|
||||||
### afterSync
|
### session.writeSync
|
||||||
|
|
||||||
|
- writes any room keys that were received
|
||||||
|
|
||||||
|
### room.afterSync
|
||||||
|
|
||||||
- rooms
|
- rooms
|
||||||
- emit changes
|
- emit changes
|
||||||
|
|
||||||
### afterSyncCompleted
|
### room.afterSyncCompleted
|
||||||
|
|
||||||
- session
|
- session
|
||||||
- e2ee account
|
- e2ee account
|
||||||
|
|
|
@ -15,8 +15,9 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
|
import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
|
||||||
import {groupBy} from "../../utils/groupBy.js";
|
import {groupEventsBySession} from "./megolm/decryption/utils.js";
|
||||||
import {mergeMap} from "../../utils/mergeMap.js";
|
import {mergeMap} from "../../utils/mergeMap.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";
|
||||||
|
@ -25,15 +26,6 @@ const ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
// note that encrypt could still create a new session
|
// note that encrypt could still create a new session
|
||||||
const MIN_PRESHARE_INTERVAL = 60 * 1000; // 1min
|
const MIN_PRESHARE_INTERVAL = 60 * 1000; // 1min
|
||||||
|
|
||||||
function encodeMissingSessionKey(senderKey, sessionId) {
|
|
||||||
return `${senderKey}|${sessionId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeMissingSessionKey(key) {
|
|
||||||
const [senderKey, sessionId] = key.split("|");
|
|
||||||
return {senderKey, sessionId};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RoomEncryption {
|
export class RoomEncryption {
|
||||||
constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, sessionBackup, notifyMissingMegolmSession, clock}) {
|
constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, sessionBackup, notifyMissingMegolmSession, clock}) {
|
||||||
this._room = room;
|
this._room = room;
|
||||||
|
@ -43,32 +35,41 @@ export class RoomEncryption {
|
||||||
this._megolmDecryption = megolmDecryption;
|
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._megolmBackfillCache = this._megolmDecryption.createSessionCache();
|
||||||
this._megolmSyncCache = this._megolmDecryption.createSessionCache(1);
|
this._megolmSyncCache = this._megolmDecryption.createSessionCache(1);
|
||||||
// session => event ids of messages we tried to decrypt and the session was missing
|
// caches devices to verify events
|
||||||
this._missingSessions = new SessionToEventIdsMap();
|
|
||||||
// sessions that may or may not be missing, but that while
|
|
||||||
// looking for a particular session came up as a candidate and were
|
|
||||||
// added to the cache to prevent further lookups from storage
|
|
||||||
this._missingSessionCandidates = new SessionToEventIdsMap();
|
|
||||||
this._senderDeviceCache = new Map();
|
this._senderDeviceCache = new Map();
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
this._sessionBackup = sessionBackup;
|
this._sessionBackup = sessionBackup;
|
||||||
this._notifyMissingMegolmSession = notifyMissingMegolmSession;
|
this._notifyMissingMegolmSession = notifyMissingMegolmSession;
|
||||||
this._clock = clock;
|
this._clock = clock;
|
||||||
this._disposed = false;
|
|
||||||
this._isFlushingRoomKeyShares = false;
|
this._isFlushingRoomKeyShares = false;
|
||||||
this._lastKeyPreShareTime = null;
|
this._lastKeyPreShareTime = null;
|
||||||
|
this._disposed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableSessionBackup(sessionBackup) {
|
enableSessionBackup(sessionBackup) {
|
||||||
if (this._sessionBackup) {
|
if (this._sessionBackup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._sessionBackup = sessionBackup;
|
this._sessionBackup = sessionBackup;
|
||||||
for(const {senderKey, sessionId} of this._missingSessions.getSessions()) {
|
}
|
||||||
await this._requestMissingSessionFromBackup(senderKey, sessionId, null);
|
|
||||||
|
async restoreMissingSessionsFromBackup(entries) {
|
||||||
|
const events = entries.filter(e => e.isEncrypted && !e.isDecrypted && e.event).map(e => e.event);
|
||||||
|
const eventsBySession = groupEventsBySession(events);
|
||||||
|
const groups = Array.from(eventsBySession.values());
|
||||||
|
const txn = this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
|
||||||
|
const hasSessions = await Promise.all(groups.map(async group => {
|
||||||
|
return this._megolmDecryption.hasSession(this._room.id, group.senderKey, group.sessionId, txn);
|
||||||
|
}));
|
||||||
|
const missingSessions = groups.filter((_, i) => !hasSessions[i]);
|
||||||
|
if (missingSessions.length) {
|
||||||
|
// start with last sessions which should be for the last items in the timeline
|
||||||
|
for (var i = missingSessions.length - 1; i >= 0; i--) {
|
||||||
|
const session = missingSessions[i];
|
||||||
|
await this._requestMissingSessionFromBackup(session.senderKey, session.sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,21 +80,27 @@ export class RoomEncryption {
|
||||||
this._senderDeviceCache = new Map(); // purge the sender device cache
|
this._senderDeviceCache = new Map(); // purge the sender device cache
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeMemberChanges(memberChanges, txn) {
|
async writeMemberChanges(memberChanges, txn, log) {
|
||||||
|
let shouldFlush;
|
||||||
const memberChangesArray = Array.from(memberChanges.values());
|
const memberChangesArray = Array.from(memberChanges.values());
|
||||||
if (memberChangesArray.some(m => m.hasLeft)) {
|
if (memberChangesArray.some(m => m.hasLeft)) {
|
||||||
|
log.log({
|
||||||
|
l: "discardOutboundSession",
|
||||||
|
leftUsers: memberChangesArray.filter(m => m.hasLeft).map(m => m.userId),
|
||||||
|
});
|
||||||
this._megolmEncryption.discardOutboundSession(this._room.id, txn);
|
this._megolmEncryption.discardOutboundSession(this._room.id, txn);
|
||||||
}
|
}
|
||||||
if (memberChangesArray.some(m => m.hasJoined)) {
|
if (memberChangesArray.some(m => m.hasJoined)) {
|
||||||
await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn);
|
shouldFlush = await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log);
|
||||||
}
|
}
|
||||||
await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
|
await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
|
||||||
|
return shouldFlush;
|
||||||
}
|
}
|
||||||
|
|
||||||
// this happens before entries exists, as they are created by the syncwriter
|
// this happens before entries exists, as they are created by the syncwriter
|
||||||
// but we want to be able to map it back to something in the timeline easily
|
// but we want to be able to map it back to something in the timeline easily
|
||||||
// when retrying decryption.
|
// when retrying decryption.
|
||||||
async prepareDecryptAll(events, newKeys, source, isTimelineOpen, txn) {
|
async prepareDecryptAll(events, newKeys, source, txn) {
|
||||||
const errors = new Map();
|
const errors = new Map();
|
||||||
const validEvents = [];
|
const validEvents = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
@ -126,24 +133,51 @@ export class RoomEncryption {
|
||||||
if (customCache) {
|
if (customCache) {
|
||||||
customCache.dispose();
|
customCache.dispose();
|
||||||
}
|
}
|
||||||
return new DecryptionPreparation(preparation, errors, {isTimelineOpen, source}, this, events);
|
return new DecryptionPreparation(preparation, errors, source, this, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _processDecryptionResults(events, results, errors, flags, txn) {
|
async _processDecryptionResults(events, results, errors, source, txn) {
|
||||||
for (const event of events) {
|
const missingSessionEvents = events.filter(event => {
|
||||||
const error = errors.get(event.event_id);
|
const error = errors.get(event.event_id);
|
||||||
if (error?.code === "MEGOLM_NO_SESSION") {
|
return error?.code === "MEGOLM_NO_SESSION";
|
||||||
this._addMissingSessionEvent(event, flags.source);
|
});
|
||||||
} else {
|
if (!missingSessionEvents.length) {
|
||||||
this._missingSessions.removeEvent(event);
|
return;
|
||||||
this._missingSessionCandidates.removeEvent(event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (flags.isTimelineOpen) {
|
const eventsBySession = groupEventsBySession(events);
|
||||||
for (const result of results.values()) {
|
if (source === DecryptionSource.Sync) {
|
||||||
await this._verifyDecryptionResult(result, txn);
|
await Promise.all(Array.from(eventsBySession.values()).map(async group => {
|
||||||
}
|
const eventIds = group.events.map(e => e.event_id);
|
||||||
|
return this._megolmDecryption.addMissingKeyEventIds(
|
||||||
|
this._room.id, group.senderKey, group.sessionId, eventIds, txn);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: do proper logging here
|
||||||
|
// run detached
|
||||||
|
Promise.resolve().then(async () => {
|
||||||
|
// if the message came from sync, wait 10s to see if the room key arrives late,
|
||||||
|
// and only after that proceed to request from backup
|
||||||
|
if (source === DecryptionSource.Sync) {
|
||||||
|
await this._clock.createTimeout(10000).elapsed();
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// now check which sessions have been received already
|
||||||
|
const txn = this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
|
||||||
|
await Promise.all(Array.from(eventsBySession).map(async ([key, group]) => {
|
||||||
|
if (await this._megolmDecryption.hasSession(this._room.id, group.senderKey, group.sessionId, txn)) {
|
||||||
|
eventsBySession.delete(key);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from(eventsBySession.values()).map(group => {
|
||||||
|
return this._requestMissingSessionFromBackup(group.senderKey, group.sessionId);
|
||||||
|
}));
|
||||||
|
}).catch(err => {
|
||||||
|
console.log("failed to fetch missing session from key backup");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _verifyDecryptionResult(result, txn) {
|
async _verifyDecryptionResult(result, txn) {
|
||||||
|
@ -159,24 +193,7 @@ export class RoomEncryption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_addMissingSessionEvent(event, source) {
|
async _requestMissingSessionFromBackup(senderKey, sessionId) {
|
||||||
const isNewSession = this._missingSessions.addEvent(event);
|
|
||||||
if (isNewSession) {
|
|
||||||
const senderKey = event.content?.["sender_key"];
|
|
||||||
const sessionId = event.content?.["session_id"];
|
|
||||||
this._requestMissingSessionFromBackup(senderKey, sessionId, source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _requestMissingSessionFromBackup(senderKey, sessionId, source) {
|
|
||||||
// if the message came from sync, wait 10s to see if the room key arrives,
|
|
||||||
// and only after that proceed to request from backup
|
|
||||||
if (source === DecryptionSource.Sync) {
|
|
||||||
await this._clock.createTimeout(10000).elapsed();
|
|
||||||
if (this._disposed || !this._missingSessions.hasSession(senderKey, sessionId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// show prompt to enable secret storage
|
// show prompt to enable secret storage
|
||||||
if (!this._sessionBackup) {
|
if (!this._sessionBackup) {
|
||||||
this._notifyMissingMegolmSession();
|
this._notifyMissingMegolmSession();
|
||||||
|
@ -216,43 +233,19 @@ export class RoomEncryption {
|
||||||
console.info(`Backed-up session of unknown algorithm: ${session.algorithm}`);
|
console.info(`Backed-up session of unknown algorithm: ${session.algorithm}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Could not get session ${sessionId} from backup`, err);
|
if (!(err.name === "HomeServerError" && err.errcode === "M_NOT_FOUND")) {
|
||||||
|
console.error(`Could not get session ${sessionId} from backup`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {RoomKey} roomKeys
|
* @param {RoomKey} roomKeys
|
||||||
* @return {Array<string>} the event ids that should be retried to decrypt
|
* @param {Transaction} txn
|
||||||
|
* @return {Promise<Array<string>>} the event ids that should be retried to decrypt
|
||||||
*/
|
*/
|
||||||
getEventIdsForRoomKey(roomKey) {
|
getEventIdsForMissingKey(roomKey, txn) {
|
||||||
// TODO: we could concat both results here, and only put stuff in
|
return this._megolmDecryption.getEventIdsForMissingKey(this._room.id, roomKey.senderKey, roomKey.sessionId, txn);
|
||||||
// candidates if it is not in missing sessions to use a bit less memory
|
|
||||||
let eventIds = this._missingSessions.getEventIds(roomKey.senderKey, roomKey.sessionId);
|
|
||||||
if (!eventIds) {
|
|
||||||
eventIds = this._missingSessionCandidates.getEventIds(roomKey.senderKey, roomKey.sessionId);
|
|
||||||
}
|
|
||||||
return eventIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* caches mapping of session to event id of all encrypted candidates
|
|
||||||
* and filters to return only the candidates for the given room key
|
|
||||||
*/
|
|
||||||
findAndCacheEntriesForRoomKey(roomKey, candidateEntries) {
|
|
||||||
const matches = [];
|
|
||||||
|
|
||||||
for (const entry of candidateEntries) {
|
|
||||||
if (entry.eventType === ENCRYPTED_TYPE) {
|
|
||||||
this._missingSessionCandidates.addEvent(entry.event);
|
|
||||||
const senderKey = entry.event?.content?.["sender_key"];
|
|
||||||
const sessionId = entry.event?.content?.["session_id"];
|
|
||||||
if (senderKey === roomKey.senderKey && sessionId === roomKey.sessionId) {
|
|
||||||
matches.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** shares the encryption key for the next message if needed */
|
/** shares the encryption key for the next message if needed */
|
||||||
|
@ -322,13 +315,20 @@ export class RoomEncryption {
|
||||||
await removeOpTxn.complete();
|
await removeOpTxn.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn) {
|
async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log) {
|
||||||
const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId);
|
const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId);
|
||||||
const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage(
|
const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage(
|
||||||
this._room.id, txn);
|
this._room.id, txn);
|
||||||
if (roomKeyMessage) {
|
if (roomKeyMessage) {
|
||||||
|
log.log({
|
||||||
|
l: "share key for new members", userIds,
|
||||||
|
id: roomKeyMessage.session_id,
|
||||||
|
chain_index: roomKeyMessage.chain_index
|
||||||
|
});
|
||||||
this._writeRoomKeyShareOperation(roomKeyMessage, userIds, txn);
|
this._writeRoomKeyShareOperation(roomKeyMessage, userIds, txn);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeRoomKeyShareOperation(roomKeyMessage, userIds, txn) {
|
_writeRoomKeyShareOperation(roomKeyMessage, userIds, txn) {
|
||||||
|
@ -400,13 +400,15 @@ export class RoomEncryption {
|
||||||
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
|
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
|
||||||
}
|
}
|
||||||
|
|
||||||
filterEventEntriesForKeys(entries, keys) {
|
filterUndecryptedEventEntriesForKeys(entries, keys) {
|
||||||
return entries.filter(entry => {
|
return entries.filter(entry => {
|
||||||
const {event} = entry;
|
if (entry.isEncrypted && !entry.isDecrypted) {
|
||||||
if (event) {
|
const {event} = entry;
|
||||||
const senderKey = event.content?.["sender_key"];
|
if (event) {
|
||||||
const sessionId = event.content?.["session_id"];
|
const senderKey = event.content?.["sender_key"];
|
||||||
return keys.some(key => senderKey === key.senderKey && sessionId === key.sessionId);
|
const sessionId = event.content?.["session_id"];
|
||||||
|
return keys.some(key => senderKey === key.senderKey && sessionId === key.sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
@ -424,10 +426,10 @@ export class RoomEncryption {
|
||||||
* the decryption results before turning them
|
* the decryption results before turning them
|
||||||
*/
|
*/
|
||||||
class DecryptionPreparation {
|
class DecryptionPreparation {
|
||||||
constructor(megolmDecryptionPreparation, extraErrors, flags, roomEncryption, events) {
|
constructor(megolmDecryptionPreparation, extraErrors, source, roomEncryption, events) {
|
||||||
this._megolmDecryptionPreparation = megolmDecryptionPreparation;
|
this._megolmDecryptionPreparation = megolmDecryptionPreparation;
|
||||||
this._extraErrors = extraErrors;
|
this._extraErrors = extraErrors;
|
||||||
this._flags = flags;
|
this._source = source;
|
||||||
this._roomEncryption = roomEncryption;
|
this._roomEncryption = roomEncryption;
|
||||||
this._events = events;
|
this._events = events;
|
||||||
}
|
}
|
||||||
|
@ -436,7 +438,7 @@ class DecryptionPreparation {
|
||||||
return new DecryptionChanges(
|
return new DecryptionChanges(
|
||||||
await this._megolmDecryptionPreparation.decrypt(),
|
await this._megolmDecryptionPreparation.decrypt(),
|
||||||
this._extraErrors,
|
this._extraErrors,
|
||||||
this._flags,
|
this._source,
|
||||||
this._roomEncryption,
|
this._roomEncryption,
|
||||||
this._events);
|
this._events);
|
||||||
}
|
}
|
||||||
|
@ -447,10 +449,10 @@ class DecryptionPreparation {
|
||||||
}
|
}
|
||||||
|
|
||||||
class DecryptionChanges {
|
class DecryptionChanges {
|
||||||
constructor(megolmDecryptionChanges, extraErrors, flags, roomEncryption, events) {
|
constructor(megolmDecryptionChanges, extraErrors, source, roomEncryption, events) {
|
||||||
this._megolmDecryptionChanges = megolmDecryptionChanges;
|
this._megolmDecryptionChanges = megolmDecryptionChanges;
|
||||||
this._extraErrors = extraErrors;
|
this._extraErrors = extraErrors;
|
||||||
this._flags = flags;
|
this._source = source;
|
||||||
this._roomEncryption = roomEncryption;
|
this._roomEncryption = roomEncryption;
|
||||||
this._events = events;
|
this._events = events;
|
||||||
}
|
}
|
||||||
|
@ -458,15 +460,16 @@ class DecryptionChanges {
|
||||||
async write(txn) {
|
async write(txn) {
|
||||||
const {results, errors} = await this._megolmDecryptionChanges.write(txn);
|
const {results, errors} = await this._megolmDecryptionChanges.write(txn);
|
||||||
mergeMap(this._extraErrors, errors);
|
mergeMap(this._extraErrors, errors);
|
||||||
await this._roomEncryption._processDecryptionResults(this._events, results, errors, this._flags, txn);
|
await this._roomEncryption._processDecryptionResults(this._events, results, errors, this._source, txn);
|
||||||
return new BatchDecryptionResult(results, errors);
|
return new BatchDecryptionResult(results, errors, this._roomEncryption);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BatchDecryptionResult {
|
class BatchDecryptionResult {
|
||||||
constructor(results, errors) {
|
constructor(results, errors, roomEncryption) {
|
||||||
this.results = results;
|
this.results = results;
|
||||||
this.errors = errors;
|
this.errors = errors;
|
||||||
|
this._roomEncryption = roomEncryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyToEntries(entries) {
|
applyToEntries(entries) {
|
||||||
|
@ -482,59 +485,10 @@ class BatchDecryptionResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class SessionToEventIdsMap {
|
verifySenders(txn) {
|
||||||
constructor() {
|
return Promise.all(Array.from(this.results.values()).map(result => {
|
||||||
this._eventIdsBySession = new Map();
|
return this._roomEncryption._verifyDecryptionResult(result, txn);
|
||||||
}
|
}));
|
||||||
|
|
||||||
addEvent(event) {
|
|
||||||
let isNewSession = false;
|
|
||||||
const senderKey = event.content?.["sender_key"];
|
|
||||||
const sessionId = event.content?.["session_id"];
|
|
||||||
const key = encodeMissingSessionKey(senderKey, sessionId);
|
|
||||||
let eventIds = this._eventIdsBySession.get(key);
|
|
||||||
// new missing session
|
|
||||||
if (!eventIds) {
|
|
||||||
eventIds = new Set();
|
|
||||||
this._eventIdsBySession.set(key, eventIds);
|
|
||||||
isNewSession = true;
|
|
||||||
}
|
|
||||||
eventIds.add(event.event_id);
|
|
||||||
return isNewSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
getEventIds(senderKey, sessionId) {
|
|
||||||
const key = encodeMissingSessionKey(senderKey, sessionId);
|
|
||||||
const entriesForSession = this._eventIdsBySession.get(key);
|
|
||||||
if (entriesForSession) {
|
|
||||||
return [...entriesForSession];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getSessions() {
|
|
||||||
return Array.from(this._eventIdsBySession.keys()).map(decodeMissingSessionKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSession(senderKey, sessionId) {
|
|
||||||
return this._eventIdsBySession.has(encodeMissingSessionKey(senderKey, sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEvent(event) {
|
|
||||||
let hasRemovedSession = false;
|
|
||||||
const senderKey = event.content?.["sender_key"];
|
|
||||||
const sessionId = event.content?.["session_id"];
|
|
||||||
const key = encodeMissingSessionKey(senderKey, sessionId);
|
|
||||||
let eventIds = this._eventIdsBySession.get(key);
|
|
||||||
if (eventIds) {
|
|
||||||
if (eventIds.delete(event.event_id)) {
|
|
||||||
if (!eventIds.length) {
|
|
||||||
this._eventIdsBySession.delete(key);
|
|
||||||
hasRemovedSession = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasRemovedSession;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,25 +15,13 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {DecryptionError} from "../common.js";
|
import {DecryptionError} from "../common.js";
|
||||||
import {groupBy} from "../../../utils/groupBy.js";
|
|
||||||
import * as RoomKey from "./decryption/RoomKey.js";
|
import * as RoomKey from "./decryption/RoomKey.js";
|
||||||
import {SessionInfo} from "./decryption/SessionInfo.js";
|
import {SessionInfo} from "./decryption/SessionInfo.js";
|
||||||
import {DecryptionPreparation} from "./decryption/DecryptionPreparation.js";
|
import {DecryptionPreparation} from "./decryption/DecryptionPreparation.js";
|
||||||
import {SessionDecryption} from "./decryption/SessionDecryption.js";
|
import {SessionDecryption} from "./decryption/SessionDecryption.js";
|
||||||
import {SessionCache} from "./decryption/SessionCache.js";
|
import {SessionCache} from "./decryption/SessionCache.js";
|
||||||
import {MEGOLM_ALGORITHM} from "../common.js";
|
import {MEGOLM_ALGORITHM} from "../common.js";
|
||||||
|
import {validateEvent, groupEventsBySession} from "./decryption/utils.js";
|
||||||
function getSenderKey(event) {
|
|
||||||
return event.content?.["sender_key"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionId(event) {
|
|
||||||
return event.content?.["session_id"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCiphertext(event) {
|
|
||||||
return event.content?.ciphertext;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Decryption {
|
export class Decryption {
|
||||||
constructor({pickleKey, olm, olmWorker}) {
|
constructor({pickleKey, olm, olmWorker}) {
|
||||||
|
@ -46,6 +34,37 @@ export class Decryption {
|
||||||
return new SessionCache(size);
|
return new SessionCache(size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addMissingKeyEventIds(roomId, senderKey, sessionId, eventIds, txn) {
|
||||||
|
let sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
|
||||||
|
// we never want to overwrite an existing key
|
||||||
|
if (sessionEntry?.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sessionEntry) {
|
||||||
|
const uniqueEventIds = new Set(sessionEntry.eventIds);
|
||||||
|
for (const id of eventIds) {
|
||||||
|
uniqueEventIds.add(id);
|
||||||
|
}
|
||||||
|
sessionEntry.eventIds = Array.from(uniqueEventIds);
|
||||||
|
} else {
|
||||||
|
sessionEntry = {roomId, senderKey, sessionId, eventIds};
|
||||||
|
}
|
||||||
|
txn.inboundGroupSessions.set(sessionEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEventIdsForMissingKey(roomId, senderKey, sessionId, txn) {
|
||||||
|
const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
|
||||||
|
if (sessionEntry && !sessionEntry.session) {
|
||||||
|
return sessionEntry.eventIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasSession(roomId, senderKey, sessionId, txn) {
|
||||||
|
const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
|
||||||
|
const isValidSession = typeof sessionEntry?.session === "string";
|
||||||
|
return isValidSession;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads all the state from storage to be able to decrypt the given events.
|
* Reads all the state from storage to be able to decrypt the given events.
|
||||||
* Decryption can then happen outside of a storage transaction.
|
* Decryption can then happen outside of a storage transaction.
|
||||||
|
@ -61,28 +80,22 @@ export class Decryption {
|
||||||
const validEvents = [];
|
const validEvents = [];
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const isValid = typeof getSenderKey(event) === "string" &&
|
if (validateEvent(event)) {
|
||||||
typeof getSessionId(event) === "string" &&
|
|
||||||
typeof getCiphertext(event) === "string";
|
|
||||||
if (isValid) {
|
|
||||||
validEvents.push(event);
|
validEvents.push(event);
|
||||||
} else {
|
} else {
|
||||||
errors.set(event.event_id, new DecryptionError("MEGOLM_INVALID_EVENT", event))
|
errors.set(event.event_id, new DecryptionError("MEGOLM_INVALID_EVENT", event))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsBySession = groupBy(validEvents, event => {
|
const eventsBySession = groupEventsBySession(validEvents);
|
||||||
return `${getSenderKey(event)}|${getSessionId(event)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionDecryptions = [];
|
const sessionDecryptions = [];
|
||||||
await Promise.all(Array.from(eventsBySession.values()).map(async eventsForSession => {
|
await Promise.all(Array.from(eventsBySession.values()).map(async group => {
|
||||||
const firstEvent = eventsForSession[0];
|
const sessionInfo = await this._getSessionInfo(roomId, group.senderKey, group.sessionId, newKeys, sessionCache, txn);
|
||||||
const sessionInfo = await this._getSessionInfoForEvent(roomId, firstEvent, newKeys, sessionCache, txn);
|
|
||||||
if (sessionInfo) {
|
if (sessionInfo) {
|
||||||
sessionDecryptions.push(new SessionDecryption(sessionInfo, eventsForSession, this._olmWorker));
|
sessionDecryptions.push(new SessionDecryption(sessionInfo, group.events, this._olmWorker));
|
||||||
} else {
|
} else {
|
||||||
for (const event of eventsForSession) {
|
for (const event of group.events) {
|
||||||
errors.set(event.event_id, new DecryptionError("MEGOLM_NO_SESSION", event));
|
errors.set(event.event_id, new DecryptionError("MEGOLM_NO_SESSION", event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -91,9 +104,7 @@ export class Decryption {
|
||||||
return new DecryptionPreparation(roomId, sessionDecryptions, errors);
|
return new DecryptionPreparation(roomId, sessionDecryptions, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _getSessionInfoForEvent(roomId, event, newKeys, sessionCache, txn) {
|
async _getSessionInfo(roomId, senderKey, sessionId, newKeys, sessionCache, txn) {
|
||||||
const senderKey = getSenderKey(event);
|
|
||||||
const sessionId = getSessionId(event);
|
|
||||||
let sessionInfo;
|
let sessionInfo;
|
||||||
if (newKeys) {
|
if (newKeys) {
|
||||||
const key = newKeys.find(k => k.roomId === roomId && k.senderKey === senderKey && k.sessionId === sessionId);
|
const key = newKeys.find(k => k.roomId === roomId && k.senderKey === senderKey && k.sessionId === sessionId);
|
||||||
|
@ -110,7 +121,7 @@ export class Decryption {
|
||||||
}
|
}
|
||||||
if (!sessionInfo) {
|
if (!sessionInfo) {
|
||||||
const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
|
const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
|
||||||
if (sessionEntry) {
|
if (sessionEntry && sessionEntry.session) {
|
||||||
let session = new this._olm.InboundGroupSession();
|
let session = new this._olm.InboundGroupSession();
|
||||||
try {
|
try {
|
||||||
session.unpickle(this._pickleKey, sessionEntry.session);
|
session.unpickle(this._pickleKey, sessionEntry.session);
|
||||||
|
|
|
@ -33,7 +33,7 @@ export class BaseRoomKey {
|
||||||
async _isBetterThanKnown(session, olm, pickleKey, txn) {
|
async _isBetterThanKnown(session, olm, pickleKey, txn) {
|
||||||
let isBetter = true;
|
let isBetter = true;
|
||||||
const existingSessionEntry = await txn.inboundGroupSessions.get(this.roomId, this.senderKey, this.sessionId);
|
const existingSessionEntry = await txn.inboundGroupSessions.get(this.roomId, this.senderKey, this.sessionId);
|
||||||
if (existingSessionEntry) {
|
if (existingSessionEntry?.session) {
|
||||||
const existingSession = new olm.InboundGroupSession();
|
const existingSession = new olm.InboundGroupSession();
|
||||||
try {
|
try {
|
||||||
existingSession.unpickle(pickleKey, existingSessionEntry.session);
|
existingSession.unpickle(pickleKey, existingSessionEntry.session);
|
||||||
|
|
57
src/matrix/e2ee/megolm/decryption/utils.js
Normal file
57
src/matrix/e2ee/megolm/decryption/utils.js
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
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 {groupByWithCreator} from "../../../../utils/groupBy.js";
|
||||||
|
|
||||||
|
function getSenderKey(event) {
|
||||||
|
return event.content?.["sender_key"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionId(event) {
|
||||||
|
return event.content?.["session_id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCiphertext(event) {
|
||||||
|
return event.content?.ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEvent(event) {
|
||||||
|
return typeof getSenderKey(event) === "string" &&
|
||||||
|
typeof getSessionId(event) === "string" &&
|
||||||
|
typeof getCiphertext(event) === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionKeyGroup {
|
||||||
|
constructor() {
|
||||||
|
this.events = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get senderKey() {
|
||||||
|
return getSenderKey(this.events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
get sessionId() {
|
||||||
|
return getSessionId(this.events[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupEventsBySession(events) {
|
||||||
|
return groupByWithCreator(events,
|
||||||
|
event => `${getSenderKey(event)}|${getSessionId(event)}`,
|
||||||
|
() => new SessionKeyGroup(),
|
||||||
|
(group, event) => group.events.push(event)
|
||||||
|
);
|
||||||
|
}
|
|
@ -18,7 +18,6 @@ import {EventEmitter} from "../../utils/EventEmitter.js";
|
||||||
import {RoomSummary} from "./RoomSummary.js";
|
import {RoomSummary} from "./RoomSummary.js";
|
||||||
import {SyncWriter} from "./timeline/persistence/SyncWriter.js";
|
import {SyncWriter} from "./timeline/persistence/SyncWriter.js";
|
||||||
import {GapWriter} from "./timeline/persistence/GapWriter.js";
|
import {GapWriter} from "./timeline/persistence/GapWriter.js";
|
||||||
import {readRawTimelineEntriesWithTxn} from "./timeline/persistence/TimelineReader.js";
|
|
||||||
import {Timeline} from "./timeline/Timeline.js";
|
import {Timeline} from "./timeline/Timeline.js";
|
||||||
import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js";
|
import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js";
|
||||||
import {SendQueue} from "./sending/SendQueue.js";
|
import {SendQueue} from "./sending/SendQueue.js";
|
||||||
|
@ -27,8 +26,6 @@ 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 {EventEntry} from "./timeline/entries/EventEntry.js";
|
||||||
import {EventKey} from "./timeline/EventKey.js";
|
|
||||||
import {Direction} from "./timeline/Direction.js";
|
|
||||||
import {ObservedEventMap} from "./ObservedEventMap.js";
|
import {ObservedEventMap} from "./ObservedEventMap.js";
|
||||||
import {AttachmentUpload} from "./AttachmentUpload.js";
|
import {AttachmentUpload} from "./AttachmentUpload.js";
|
||||||
import {DecryptionSource} from "../e2ee/common.js";
|
import {DecryptionSource} from "../e2ee/common.js";
|
||||||
|
@ -58,56 +55,36 @@ export class Room extends EventEmitter {
|
||||||
this._observedEvents = null;
|
this._observedEvents = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_readRetryDecryptCandidateEntries(sinceEventKey, txn) {
|
async _getRetryDecryptEntriesForKey(roomKey, txn) {
|
||||||
if (sinceEventKey) {
|
const retryEventIds = await this._roomEncryption.getEventIdsForMissingKey(roomKey, txn);
|
||||||
return readRawTimelineEntriesWithTxn(this._roomId, sinceEventKey,
|
const retryEntries = [];
|
||||||
Direction.Forward, Number.MAX_SAFE_INTEGER, this._fragmentIdComparer, txn);
|
|
||||||
} else {
|
|
||||||
// all messages for room ...
|
|
||||||
// if you haven't decrypted any message in a room yet,
|
|
||||||
// it's unlikely you will have tons of them.
|
|
||||||
// so this should be fine as a last resort
|
|
||||||
return readRawTimelineEntriesWithTxn(this._roomId, this._syncWriter.lastMessageKey,
|
|
||||||
Direction.Backward, Number.MAX_SAFE_INTEGER, this._fragmentIdComparer, txn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async notifyRoomKey(roomKey) {
|
|
||||||
if (!this._roomEncryption) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const retryEventIds = this._roomEncryption.getEventIdsForRoomKey(roomKey);
|
|
||||||
const stores = [
|
|
||||||
this._storage.storeNames.timelineEvents,
|
|
||||||
this._storage.storeNames.inboundGroupSessions,
|
|
||||||
];
|
|
||||||
let txn;
|
|
||||||
let retryEntries;
|
|
||||||
if (retryEventIds) {
|
if (retryEventIds) {
|
||||||
retryEntries = [];
|
|
||||||
txn = this._storage.readTxn(stores);
|
|
||||||
for (const eventId of retryEventIds) {
|
for (const eventId of retryEventIds) {
|
||||||
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
|
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
|
||||||
if (storageEntry) {
|
if (storageEntry) {
|
||||||
retryEntries.push(new EventEntry(storageEntry, this._fragmentIdComparer));
|
retryEntries.push(new EventEntry(storageEntry, this._fragmentIdComparer));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// we only look for messages since lastDecryptedEventKey because
|
|
||||||
// the timeline must be closed (otherwise getEventIdsForRoomKey would have found the event ids)
|
|
||||||
// and to update the summary we only care about events since lastDecryptedEventKey
|
|
||||||
const key = this._summary.data.lastDecryptedEventKey;
|
|
||||||
// key might be missing if we haven't decrypted any events in this room
|
|
||||||
const sinceEventKey = key && new EventKey(key.fragmentId, key.entryIndex);
|
|
||||||
// check we have not already decrypted the most recent event in the room
|
|
||||||
// otherwise we know that the messages for this room key will not update the room summary
|
|
||||||
if (!sinceEventKey || !sinceEventKey.equals(this._syncWriter.lastMessageKey)) {
|
|
||||||
txn = this._storage.readTxn(stores.concat(this._storage.storeNames.timelineFragments));
|
|
||||||
const candidateEntries = await this._readRetryDecryptCandidateEntries(sinceEventKey, txn);
|
|
||||||
retryEntries = this._roomEncryption.findAndCacheEntriesForRoomKey(roomKey, candidateEntries);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (retryEntries?.length) {
|
return retryEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for keys received from other sources than sync, like key backup.
|
||||||
|
* @internal
|
||||||
|
* @param {RoomKey} roomKey
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
async notifyRoomKey(roomKey) {
|
||||||
|
if (!this._roomEncryption) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const txn = this._storage.readTxn([
|
||||||
|
this._storage.storeNames.timelineEvents,
|
||||||
|
this._storage.storeNames.inboundGroupSessions,
|
||||||
|
]);
|
||||||
|
const retryEntries = this._getRetryDecryptEntriesForKey(roomKey, txn);
|
||||||
|
if (retryEntries.length) {
|
||||||
const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn);
|
const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn);
|
||||||
// this will close txn while awaiting decryption
|
// this will close txn while awaiting decryption
|
||||||
await decryptRequest.complete();
|
await decryptRequest.complete();
|
||||||
|
@ -147,13 +124,13 @@ export class Room extends EventEmitter {
|
||||||
const events = entries.filter(entry => {
|
const events = entries.filter(entry => {
|
||||||
return entry.eventType === EVENT_ENCRYPTED_TYPE;
|
return entry.eventType === EVENT_ENCRYPTED_TYPE;
|
||||||
}).map(entry => entry.event);
|
}).map(entry => entry.event);
|
||||||
const isTimelineOpen = this._isTimelineOpen;
|
r.preparation = await this._roomEncryption.prepareDecryptAll(events, null, source, inboundSessionTxn);
|
||||||
r.preparation = await this._roomEncryption.prepareDecryptAll(events, null, source, isTimelineOpen, inboundSessionTxn);
|
|
||||||
if (r.cancelled) return;
|
if (r.cancelled) return;
|
||||||
const changes = await r.preparation.decrypt();
|
const changes = await r.preparation.decrypt();
|
||||||
r.preparation = null;
|
r.preparation = null;
|
||||||
if (r.cancelled) return;
|
if (r.cancelled) return;
|
||||||
const stores = [this._storage.storeNames.groupSessionDecryptions];
|
const stores = [this._storage.storeNames.groupSessionDecryptions];
|
||||||
|
const isTimelineOpen = this._isTimelineOpen;
|
||||||
if (isTimelineOpen) {
|
if (isTimelineOpen) {
|
||||||
// read to fetch devices if timeline is open
|
// read to fetch devices if timeline is open
|
||||||
stores.push(this._storage.storeNames.deviceIdentities);
|
stores.push(this._storage.storeNames.deviceIdentities);
|
||||||
|
@ -162,6 +139,9 @@ export class Room extends EventEmitter {
|
||||||
let decryption;
|
let decryption;
|
||||||
try {
|
try {
|
||||||
decryption = await changes.write(writeTxn);
|
decryption = await changes.write(writeTxn);
|
||||||
|
if (isTimelineOpen) {
|
||||||
|
await decryption.verifySenders(writeTxn);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
writeTxn.abort();
|
writeTxn.abort();
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -176,6 +156,26 @@ export class Room extends EventEmitter {
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _getSyncRetryDecryptEntries(newKeys, txn) {
|
||||||
|
const entriesPerKey = await Promise.all(newKeys.map(key => this._getRetryDecryptEntriesForKey(key, txn)));
|
||||||
|
let retryEntries = entriesPerKey.reduce((allEntries, entries) => allEntries.concat(entries), []);
|
||||||
|
// If we have the timeline open, see if there are more entries for the new keys
|
||||||
|
// as we only store missing session information for synced events, not backfilled.
|
||||||
|
// We want to decrypt all events we can though if the user is looking
|
||||||
|
// at them when the timeline is open
|
||||||
|
if (this._timeline) {
|
||||||
|
let retryTimelineEntries = this._roomEncryption.filterUndecryptedEventEntriesForKeys(this._timeline.remoteEntries, newKeys);
|
||||||
|
// filter out any entries already in retryEntries so we don't decrypt them twice
|
||||||
|
const existingIds = retryEntries.reduce((ids, e) => {ids.add(e.id); return ids;}, new Set());
|
||||||
|
retryTimelineEntries = retryTimelineEntries.filter(e => !existingIds.has(e.id));
|
||||||
|
// make copies so we don't modify the original entry in writeSync, before the afterSync stage
|
||||||
|
const retryTimelineEntriesCopies = retryTimelineEntries.map(e => e.clone());
|
||||||
|
// add to other retry entries
|
||||||
|
retryEntries = retryEntries.concat(retryTimelineEntriesCopies);
|
||||||
|
}
|
||||||
|
return retryEntries;
|
||||||
|
}
|
||||||
|
|
||||||
async prepareSync(roomResponse, membership, newKeys, txn, log) {
|
async prepareSync(roomResponse, membership, newKeys, txn, log) {
|
||||||
log.set("id", this.id);
|
log.set("id", this.id);
|
||||||
if (newKeys) {
|
if (newKeys) {
|
||||||
|
@ -192,25 +192,21 @@ export class Room extends EventEmitter {
|
||||||
let retryEntries;
|
let retryEntries;
|
||||||
let decryptPreparation;
|
let decryptPreparation;
|
||||||
if (roomEncryption) {
|
if (roomEncryption) {
|
||||||
// also look for events in timeline here
|
let eventsToDecrypt = roomResponse?.timeline?.events || [];
|
||||||
let events = roomResponse?.timeline?.events || [];
|
// when new keys arrive, also see if any older events can now be retried to decrypt
|
||||||
// when new keys arrive, also see if any events currently loaded in the timeline
|
if (newKeys) {
|
||||||
// can now be retried to decrypt
|
retryEntries = await this._getSyncRetryDecryptEntries(newKeys, txn);
|
||||||
if (this._timeline && newKeys) {
|
|
||||||
retryEntries = roomEncryption.filterEventEntriesForKeys(
|
|
||||||
this._timeline.remoteEntries, newKeys);
|
|
||||||
if (retryEntries.length) {
|
if (retryEntries.length) {
|
||||||
log.set("retry", retryEntries.length);
|
log.set("retry", retryEntries.length);
|
||||||
events = events.concat(retryEntries.map(entry => entry.event));
|
eventsToDecrypt = eventsToDecrypt.concat(retryEntries.map(entry => entry.event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eventsToDecrypt = eventsToDecrypt.filter(event => {
|
||||||
if (events.length) {
|
return event?.type === EVENT_ENCRYPTED_TYPE;
|
||||||
const eventsToDecrypt = events.filter(event => {
|
});
|
||||||
return event?.type === EVENT_ENCRYPTED_TYPE;
|
if (eventsToDecrypt.length) {
|
||||||
});
|
|
||||||
decryptPreparation = await roomEncryption.prepareDecryptAll(
|
decryptPreparation = await roomEncryption.prepareDecryptAll(
|
||||||
eventsToDecrypt, newKeys, DecryptionSource.Sync, this._isTimelineOpen, txn);
|
eventsToDecrypt, newKeys, DecryptionSource.Sync, txn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,7 +221,7 @@ export class Room extends EventEmitter {
|
||||||
|
|
||||||
async afterPrepareSync(preparation, parentLog) {
|
async afterPrepareSync(preparation, parentLog) {
|
||||||
if (preparation.decryptPreparation) {
|
if (preparation.decryptPreparation) {
|
||||||
await parentLog.wrap("afterPrepareSync decrypt", async log => {
|
await parentLog.wrap("decrypt", async log => {
|
||||||
log.set("id", this.id);
|
log.set("id", this.id);
|
||||||
preparation.decryptChanges = await preparation.decryptPreparation.decrypt();
|
preparation.decryptChanges = await preparation.decryptPreparation.decrypt();
|
||||||
preparation.decryptPreparation = null;
|
preparation.decryptPreparation = null;
|
||||||
|
@ -236,28 +232,37 @@ export class Room extends EventEmitter {
|
||||||
/** @package */
|
/** @package */
|
||||||
async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) {
|
async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) {
|
||||||
log.set("id", this.id);
|
log.set("id", this.id);
|
||||||
const {entries, newLiveKey, memberChanges} =
|
const {entries: newEntries, newLiveKey, memberChanges} =
|
||||||
await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, txn, log), log.level.Detail);
|
await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, txn, log), log.level.Detail);
|
||||||
|
let allEntries = newEntries;
|
||||||
if (decryptChanges) {
|
if (decryptChanges) {
|
||||||
const decryption = await decryptChanges.write(txn);
|
const decryption = await decryptChanges.write(txn);
|
||||||
if (retryEntries?.length) {
|
log.set("decryptionResults", decryption.results.size);
|
||||||
// TODO: this will modify existing timeline entries (which we should not do in writeSync),
|
log.set("decryptionErrors", decryption.errors.size);
|
||||||
// but it is a temporary way of reattempting decryption while timeline is open
|
if (this._isTimelineOpen) {
|
||||||
// won't need copies when tracking missing sessions properly
|
await decryption.verifySenders(txn);
|
||||||
// prepend the retried entries, as we know they are older (not that it should matter much for the summary)
|
}
|
||||||
entries.unshift(...retryEntries);
|
decryption.applyToEntries(newEntries);
|
||||||
|
if (retryEntries?.length) {
|
||||||
|
decryption.applyToEntries(retryEntries);
|
||||||
|
allEntries = retryEntries.concat(allEntries);
|
||||||
}
|
}
|
||||||
decryption.applyToEntries(entries);
|
|
||||||
}
|
}
|
||||||
|
log.set("allEntries", allEntries.length);
|
||||||
|
let shouldFlushKeyShares = false;
|
||||||
// pass member changes to device tracker
|
// pass member changes to device tracker
|
||||||
if (roomEncryption && this.isTrackingMembers && memberChanges?.size) {
|
if (roomEncryption && this.isTrackingMembers && memberChanges?.size) {
|
||||||
await roomEncryption.writeMemberChanges(memberChanges, txn);
|
shouldFlushKeyShares = await roomEncryption.writeMemberChanges(memberChanges, txn, log);
|
||||||
|
log.set("shouldFlushKeyShares", shouldFlushKeyShares);
|
||||||
}
|
}
|
||||||
// also apply (decrypted) timeline entries to the summary changes
|
// also apply (decrypted) timeline entries to the summary changes
|
||||||
summaryChanges = summaryChanges.applyTimelineEntries(
|
summaryChanges = summaryChanges.applyTimelineEntries(
|
||||||
entries, isInitialSync, !this._isTimelineOpen, this._user.id);
|
allEntries, isInitialSync, !this._isTimelineOpen, this._user.id);
|
||||||
// write summary changes, and unset if nothing was actually changed
|
// write summary changes, and unset if nothing was actually changed
|
||||||
summaryChanges = this._summary.writeData(summaryChanges, txn);
|
summaryChanges = this._summary.writeData(summaryChanges, txn);
|
||||||
|
if (summaryChanges) {
|
||||||
|
log.set("summaryChanges", summaryChanges.diff(this._summary.data));
|
||||||
|
}
|
||||||
// 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;
|
||||||
|
@ -275,11 +280,13 @@ export class Room extends EventEmitter {
|
||||||
return {
|
return {
|
||||||
summaryChanges,
|
summaryChanges,
|
||||||
roomEncryption,
|
roomEncryption,
|
||||||
newAndUpdatedEntries: entries,
|
newEntries,
|
||||||
|
updatedEntries: retryEntries || [],
|
||||||
newLiveKey,
|
newLiveKey,
|
||||||
removedPendingEvents,
|
removedPendingEvents,
|
||||||
memberChanges,
|
memberChanges,
|
||||||
heroChanges,
|
heroChanges,
|
||||||
|
shouldFlushKeyShares,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -288,7 +295,12 @@ export class Room extends EventEmitter {
|
||||||
* Called with the changes returned from `writeSync` to apply them and emit changes.
|
* Called with the changes returned from `writeSync` to apply them and emit changes.
|
||||||
* No storage or network operations should be done here.
|
* No storage or network operations should be done here.
|
||||||
*/
|
*/
|
||||||
afterSync({summaryChanges, newAndUpdatedEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges, roomEncryption}, log) {
|
afterSync(changes, log) {
|
||||||
|
const {
|
||||||
|
summaryChanges, newEntries, updatedEntries, newLiveKey,
|
||||||
|
removedPendingEvents, memberChanges,
|
||||||
|
heroChanges, roomEncryption
|
||||||
|
} = changes;
|
||||||
log.set("id", this.id);
|
log.set("id", this.id);
|
||||||
this._syncWriter.afterSync(newLiveKey);
|
this._syncWriter.afterSync(newLiveKey);
|
||||||
this._setEncryption(roomEncryption);
|
this._setEncryption(roomEncryption);
|
||||||
|
@ -321,18 +333,21 @@ export class Room extends EventEmitter {
|
||||||
this._emitUpdate();
|
this._emitUpdate();
|
||||||
}
|
}
|
||||||
if (this._timeline) {
|
if (this._timeline) {
|
||||||
this._timeline.appendLiveEntries(newAndUpdatedEntries);
|
// these should not be added if not already there
|
||||||
|
this._timeline.replaceEntries(updatedEntries);
|
||||||
|
this._timeline.addOrReplaceEntries(newEntries);
|
||||||
}
|
}
|
||||||
if (this._observedEvents) {
|
if (this._observedEvents) {
|
||||||
this._observedEvents.updateEvents(newAndUpdatedEntries);
|
this._observedEvents.updateEvents(updatedEntries);
|
||||||
|
this._observedEvents.updateEvents(newEntries);
|
||||||
}
|
}
|
||||||
if (removedPendingEvents) {
|
if (removedPendingEvents) {
|
||||||
this._sendQueue.emitRemovals(removedPendingEvents);
|
this._sendQueue.emitRemovals(removedPendingEvents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
needsAfterSyncCompleted({memberChanges}) {
|
needsAfterSyncCompleted({shouldFlushKeyShares}) {
|
||||||
return this._roomEncryption?.needsToShareKeys(memberChanges);
|
return shouldFlushKeyShares;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -483,7 +498,7 @@ export class Room extends EventEmitter {
|
||||||
this._sendQueue.emitRemovals(removedPendingEvents);
|
this._sendQueue.emitRemovals(removedPendingEvents);
|
||||||
}
|
}
|
||||||
if (this._timeline) {
|
if (this._timeline) {
|
||||||
this._timeline.addGapEntries(gapResult.entries);
|
this._timeline.addOrReplaceEntries(gapResult.entries);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -548,6 +563,10 @@ export class Room extends EventEmitter {
|
||||||
|
|
||||||
enableSessionBackup(sessionBackup) {
|
enableSessionBackup(sessionBackup) {
|
||||||
this._roomEncryption?.enableSessionBackup(sessionBackup);
|
this._roomEncryption?.enableSessionBackup(sessionBackup);
|
||||||
|
// TODO: do we really want to do this every time you open the app?
|
||||||
|
if (this._timeline) {
|
||||||
|
this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isTrackingMembers() {
|
get isTrackingMembers() {
|
||||||
|
|
|
@ -122,36 +122,6 @@ function processTimelineEvent(data, eventEntry, isInitialSync, canMarkUnread, ow
|
||||||
data = data.cloneIfNeeded();
|
data = data.cloneIfNeeded();
|
||||||
data.isUnread = true;
|
data.isUnread = true;
|
||||||
}
|
}
|
||||||
const {content} = eventEntry;
|
|
||||||
const body = content?.body;
|
|
||||||
const msgtype = content?.msgtype;
|
|
||||||
if (msgtype === "m.text" && !eventEntry.isEncrypted) {
|
|
||||||
data = data.cloneIfNeeded();
|
|
||||||
data.lastMessageBody = body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// store the event key of the last decrypted event so when decryption does succeed,
|
|
||||||
// we can attempt to re-decrypt from this point to update the room summary
|
|
||||||
if (!!data.encryption && eventEntry.isEncrypted && eventEntry.isDecrypted) {
|
|
||||||
let hasLargerEventKey = true;
|
|
||||||
if (data.lastDecryptedEventKey) {
|
|
||||||
try {
|
|
||||||
hasLargerEventKey = eventEntry.compare(data.lastDecryptedEventKey) > 0;
|
|
||||||
} catch (err) {
|
|
||||||
// TODO: load the fragments in between here?
|
|
||||||
// this could happen if an earlier event gets decrypted that
|
|
||||||
// is in a fragment different from the live one and the timeline is not open.
|
|
||||||
// In this case, we will just read too many events once per app load
|
|
||||||
// and then keep the mapping in memory. When eventually an event is decrypted in
|
|
||||||
// the live fragment, this should stop failing and the event key will be written.
|
|
||||||
hasLargerEventKey = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasLargerEventKey) {
|
|
||||||
data = data.cloneIfNeeded();
|
|
||||||
const {fragmentId, entryIndex} = eventEntry;
|
|
||||||
data.lastDecryptedEventKey = {fragmentId, entryIndex};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
@ -182,12 +152,9 @@ class SummaryData {
|
||||||
constructor(copy, roomId) {
|
constructor(copy, roomId) {
|
||||||
this.roomId = copy ? copy.roomId : roomId;
|
this.roomId = copy ? copy.roomId : roomId;
|
||||||
this.name = copy ? copy.name : null;
|
this.name = copy ? copy.name : null;
|
||||||
this.lastMessageBody = copy ? copy.lastMessageBody : null;
|
|
||||||
this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
|
this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
|
||||||
this.isUnread = copy ? copy.isUnread : false;
|
this.isUnread = copy ? copy.isUnread : false;
|
||||||
this.encryption = copy ? copy.encryption : null;
|
this.encryption = copy ? copy.encryption : null;
|
||||||
this.lastDecryptedEventKey = copy ? copy.lastDecryptedEventKey : null;
|
|
||||||
this.isDirectMessage = copy ? copy.isDirectMessage : false;
|
|
||||||
this.membership = copy ? copy.membership : null;
|
this.membership = copy ? copy.membership : null;
|
||||||
this.inviteCount = copy ? copy.inviteCount : 0;
|
this.inviteCount = copy ? copy.inviteCount : 0;
|
||||||
this.joinCount = copy ? copy.joinCount : 0;
|
this.joinCount = copy ? copy.joinCount : 0;
|
||||||
|
@ -202,6 +169,18 @@ class SummaryData {
|
||||||
this.cloned = copy ? true : false;
|
this.cloned = copy ? true : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diff(other) {
|
||||||
|
const props = Object.getOwnPropertyNames(this);
|
||||||
|
return props.reduce((diff, prop) => {
|
||||||
|
if (prop !== "cloned") {
|
||||||
|
if (this[prop] !== other[prop]) {
|
||||||
|
diff[prop] = this[prop];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
cloneIfNeeded() {
|
cloneIfNeeded() {
|
||||||
if (this.cloned) {
|
if (this.cloned) {
|
||||||
return this;
|
return this;
|
||||||
|
|
|
@ -60,16 +60,8 @@ export class Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: should we rather have generic methods for
|
|
||||||
// - adding new entries
|
|
||||||
// - updating existing entries (redaction, relations)
|
|
||||||
/** @package */
|
/** @package */
|
||||||
appendLiveEntries(newEntries) {
|
addOrReplaceEntries(newEntries) {
|
||||||
this._remoteEntries.setManySorted(newEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @package */
|
|
||||||
addGapEntries(newEntries) {
|
|
||||||
this._remoteEntries.setManySorted(newEntries);
|
this._remoteEntries.setManySorted(newEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,13 @@ export class EventEntry extends BaseEntry {
|
||||||
this._decryptionResult = null;
|
this._decryptionResult = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clone() {
|
||||||
|
const clone = new EventEntry(this._eventEntry, this._fragmentIdComparer);
|
||||||
|
clone._decryptionResult = this._decryptionResult;
|
||||||
|
clone._decryptionError = this._decryptionError;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
get event() {
|
get event() {
|
||||||
return this._eventEntry.event;
|
return this._eventEntry.event;
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ class ReaderRequest {
|
||||||
* Raw because it doesn't do decryption and in the future it should not read relations either.
|
* Raw because it doesn't do decryption and in the future it should not read relations either.
|
||||||
* It is just about reading entries and following fragment links
|
* It is just about reading entries and following fragment links
|
||||||
*/
|
*/
|
||||||
export async function readRawTimelineEntriesWithTxn(roomId, eventKey, direction, amount, fragmentIdComparer, txn) {
|
async function readRawTimelineEntriesWithTxn(roomId, eventKey, direction, amount, fragmentIdComparer, txn) {
|
||||||
let entries = [];
|
let entries = [];
|
||||||
const timelineStore = txn.timelineEvents;
|
const timelineStore = txn.timelineEvents;
|
||||||
const fragmentStore = txn.timelineFragments;
|
const fragmentStore = txn.timelineFragments;
|
||||||
|
|
Loading…
Reference in a new issue