diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 80f57507..0ec7212e 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -19,13 +19,23 @@ import {groupEventsBySession} from "./megolm/decryption/utils"; import {mergeMap} from "../../utils/mergeMap"; import {groupBy} from "../../utils/groupBy"; import {makeTxnId} from "../common.js"; +import {iterateResponseStateEvents} from "../room/common"; const ENCRYPTED_TYPE = "m.room.encrypted"; +const ROOM_HISTORY_VISIBILITY_TYPE = "m.room.history_visibility"; // how often ensureMessageKeyIsShared can check if it needs to // create a new outbound session // note that encrypt could still create a new session const MIN_PRESHARE_INTERVAL = 60 * 1000; // 1min +// Use enum when converting to TS +const HistoryVisibility = Object.freeze({ + Joined: "joined", + Invited: "invited", + WorldReadable: "world_readable", + Shared: "shared", +}); + // TODO: this class is a good candidate for splitting up into encryption and decryption, there doesn't seem to be much overlap export class RoomEncryption { constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, keyBackup, notifyMissingMegolmSession, clock}) { @@ -45,6 +55,7 @@ export class RoomEncryption { this._isFlushingRoomKeyShares = false; this._lastKeyPreShareTime = null; this._keySharePromise = null; + this._historyVisibility = undefined; this._disposed = false; } @@ -77,7 +88,13 @@ export class RoomEncryption { this._senderDeviceCache = new Map(); // purge the sender device cache } - async writeMemberChanges(memberChanges, txn, log) { + async writeSync(roomResponse, memberChanges, txn, log) { + let historyVisibility = this._historyVisibility; + iterateResponseStateEvents(roomResponse, event => { + if(event.state_key === "" && event.type === ROOM_HISTORY_VISIBILITY_TYPE) { + historyVisibility = event?.content?.history_visibility; + } + }); let shouldFlush = false; const memberChangesArray = Array.from(memberChanges.values()); // this also clears our session if we leave the room ourselves @@ -89,10 +106,35 @@ export class RoomEncryption { this._megolmEncryption.discardOutboundSession(this._room.id, txn); } if (memberChangesArray.some(m => m.hasJoined)) { - shouldFlush = await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log); + const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId); + shouldFlush = await this._addShareRoomKeyOperationForMembers(userIds, txn, log) + || shouldFlush; } + if (memberChangesArray.some(m => m.wasInvited)) { + historyVisibility = await this._loadHistoryVisibilityIfNeeded(historyVisibility, txn); + if (historyVisibility === HistoryVisibility.Invited) { + const userIds = memberChangesArray.filter(m => m.wasInvited).map(m => m.userId); + shouldFlush = await this._addShareRoomKeyOperationForMembers(userIds, txn, log) + || shouldFlush; + } + } + await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); - return shouldFlush; + return {shouldFlush, historyVisibility}; + } + + afterSync({historyVisibility}) { + this._historyVisibility = historyVisibility; + } + + async _loadHistoryVisibilityIfNeeded(historyVisibility, txn) { + if (!historyVisibility) { + const visibilityEntry = await txn.roomState.get(this.id, ROOM_HISTORY_VISIBILITY_TYPE, ""); + if (visibilityEntry) { + return event?.content?.history_visibility; + } + } + return historyVisibility; } async prepareDecryptAll(events, newKeys, source, txn) { @@ -288,8 +330,7 @@ export class RoomEncryption { await this._processShareRoomKeyOperation(operation, hsApi, log); } - async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log) { - const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId); + async _addShareRoomKeyOperationForMembers(userIds, txn, log) { const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage( this._room.id, txn); if (roomKeyMessage) { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 12c17580..8cc87845 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -139,11 +139,11 @@ export class Room extends BaseRoom { } log.set("newEntries", newEntries.length); log.set("updatedEntries", updatedEntries.length); - let shouldFlushKeyShares = false; + let encryptionChanges; // pass member changes to device tracker - if (roomEncryption && this.isTrackingMembers && memberChanges?.size) { - shouldFlushKeyShares = await roomEncryption.writeMemberChanges(memberChanges, txn, log); - log.set("shouldFlushKeyShares", shouldFlushKeyShares); + if (roomEncryption) { + encryptionChanges = await roomEncryption.writeSync(roomResponse, memberChanges, txn, log); + log.set("shouldFlushKeyShares", encryptionChanges.shouldFlush); } const allEntries = newEntries.concat(updatedEntries); // also apply (decrypted) timeline entries to the summary changes @@ -188,7 +188,7 @@ export class Room extends BaseRoom { memberChanges, heroChanges, powerLevelsEvent, - shouldFlushKeyShares, + encryptionChanges, }; } @@ -201,11 +201,14 @@ export class Room extends BaseRoom { const { summaryChanges, newEntries, updatedEntries, newLiveKey, removedPendingEvents, memberChanges, powerLevelsEvent, - heroChanges, roomEncryption + heroChanges, roomEncryption, encryptionChanges } = changes; log.set("id", this.id); this._syncWriter.afterSync(newLiveKey); this._setEncryption(roomEncryption); + if (this._roomEncryption) { + this._roomEncryption.afterSync(encryptionChanges); + } if (memberChanges.size) { if (this._changedMembersDuringSync) { for (const [userId, memberChange] of memberChanges.entries()) { @@ -288,8 +291,8 @@ export class Room extends BaseRoom { } } - needsAfterSyncCompleted({shouldFlushKeyShares}) { - return shouldFlushKeyShares; + needsAfterSyncCompleted({encryptionChanges}) { + return encryptionChanges?.shouldFlush; } /** diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 57ab7023..a03703a4 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {StateEvent} from "../storage/types"; + export function getPrevContentFromStateEvent(event) { // where to look for prev_content is a bit of a mess, // see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz @@ -40,3 +42,72 @@ export enum RoomType { Private, Public } + +type RoomResponse = { + state?: { + events?: Array + }, + timeline?: { + events?: Array + } +} + +/** iterates over any state events in a sync room response, in the order that they should be applied (from older to younger events) */ +export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => void) { + // first iterate over state events, they precede the timeline + const stateEvents = roomResponse.state?.events; + if (stateEvents) { + for (let i = 0; i < stateEvents.length; i++) { + callback(stateEvents[i]); + } + } + // now see if there are any state events within the timeline + let timelineEvents = roomResponse.timeline?.events; + if (timelineEvents) { + for (let i = 0; i < timelineEvents.length; i++) { + const event = timelineEvents[i]; + if (typeof event.state_key === "string") { + callback(event); + } + } + } +} + +export function tests() { + return { + "test iterateResponseStateEvents with both state and timeline sections": assert => { + const roomResponse = { + state: { + events: [ + {type: "m.room.member", state_key: "1"}, + {type: "m.room.member", state_key: "2", content: {a: 1}}, + ] + }, + timeline: { + events: [ + {type: "m.room.message"}, + {type: "m.room.member", state_key: "3"}, + {type: "m.room.message"}, + {type: "m.room.member", state_key: "2", content: {a: 2}}, + ] + } + } as unknown as RoomResponse; + const expectedStateKeys = ["1", "2", "3", "2"]; + const expectedAForMember2 = [1, 2]; + iterateResponseStateEvents(roomResponse, event => { + assert.strictEqual(event.type, "m.room.member"); + assert.strictEqual(expectedStateKeys.shift(), event.state_key); + if (event.state_key === "2") { + assert.strictEqual(expectedAForMember2.shift(), event.content.a); + } + }); + assert.strictEqual(expectedStateKeys.length, 0); + assert.strictEqual(expectedAForMember2.length, 0); + }, + "test iterateResponseStateEvents with empty response": assert => { + iterateResponseStateEvents({}, () => { + assert.fail("no events expected"); + }); + } + } +} diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index dabff972..8e00f5de 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -137,6 +137,10 @@ export class MemberChange { return this.member.membership; } + get wasInvited() { + return this.previousMembership === "invite" && this.membership !== "invite"; + } + get hasLeft() { return this.previousMembership === "join" && this.membership !== "join"; }