diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index a7f42e07b..e95d625f6 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -105,7 +105,7 @@ describe("MSC3089TreeSpace", () => { return Promise.resolve(); }); client.invite = fn; - await tree.invite(target, false); + await tree.invite(target, false, false); expect(fn).toHaveBeenCalledTimes(1); }); @@ -118,7 +118,7 @@ describe("MSC3089TreeSpace", () => { return Promise.resolve(); }); client.invite = fn; - await tree.invite(target, false); + await tree.invite(target, false, false); expect(fn).toHaveBeenCalledTimes(2); }); @@ -131,7 +131,7 @@ describe("MSC3089TreeSpace", () => { }); client.invite = fn; try { - await tree.invite(target, false); + await tree.invite(target, false, false); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); @@ -159,10 +159,61 @@ describe("MSC3089TreeSpace", () => { { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, ]; - await tree.invite(target, true); + await tree.invite(target, true, false); expect(fn).toHaveBeenCalledTimes(4); }); + it('should share keys with invitees', async () => { + const target = targetUser; + const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { + expect(inviteRoomId).toEqual(roomId); + expect(userIds).toMatchObject([target]); + return Promise.resolve(); + }); + client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.sendSharedHistoryKeys = sendKeysFn; + + // Mock the history check as best as possible + const historyVis = "shared"; + const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => { + // We're not expecting a super rigid test: the function that calls this internally isn't + // really being tested here. + expect(eventType).toEqual(EventType.RoomHistoryVisibility); + expect(stateKey).toEqual(""); + return {getContent: () => ({history_visibility: historyVis})}; // eslint-disable-line camelcase + }); + room.currentState.getStateEvents = historyFn; + + // Note: inverse test is implicit from other tests, which disable the call stack of this + // test in order to pass. + await tree.invite(target, false, true); + expect(sendKeysFn).toHaveBeenCalledTimes(1); + expect(historyFn).toHaveBeenCalledTimes(1); + }); + + it('should not share keys with invitees if inappropriate history visibility', async () => { + const target = targetUser; + const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { + expect(inviteRoomId).toEqual(roomId); + expect(userIds).toMatchObject([target]); + return Promise.resolve(); + }); + client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.sendSharedHistoryKeys = sendKeysFn; + + const historyVis = "joined"; // NOTE: Changed. + const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => { + expect(eventType).toEqual(EventType.RoomHistoryVisibility); + expect(stateKey).toEqual(""); + return {getContent: () => ({history_visibility: historyVis})}; // eslint-disable-line camelcase + }); + room.currentState.getStateEvents = historyFn; + + await tree.invite(target, false, true); + expect(sendKeysFn).toHaveBeenCalledTimes(0); + expect(historyFn).toHaveBeenCalledTimes(1); + }); + async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { makePowerLevels(pls); const fn = jest.fn() diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 138566892..e6d6e0b62 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -37,7 +37,7 @@ import { import { WITHHELD_MESSAGES } from '../OlmDevice'; // determine whether the key can be shared with invitees -function isRoomSharedHistory(room) { +export function isRoomSharedHistory(room) { const visibilityEvent = room.currentState && room.currentState.getStateEvents("m.room.history_visibility", ""); // NOTE: if the room visibility is unset, it would normally default to diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 5b4771b27..5a51e16ce 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -29,6 +29,7 @@ import { } from "../utils"; import { MSC3089Branch } from "./MSC3089Branch"; import promiseRetry from "p-retry"; +import { isRoomSharedHistory } from "../crypto/algorithms/megolm"; /** * The recommended defaults for a tree space's power levels. Note that this @@ -120,15 +121,28 @@ export class MSC3089TreeSpace { * @param {string} userId The user ID to invite. * @param {boolean} andSubspaces True (default) to invite the user to all * directories/subspaces too, recursively. + * @param {boolean} shareHistoryKeys True (default) to share encryption keys + * with the invited user. This will allow them to decrypt the events (files) + * in the tree. Keys will not be shared if the room is lacking appropriate + * history visibility (by default, history visibility is "shared" in trees, + * which is an appropriate visibility for these purposes). * @returns {Promise} Resolves when complete. */ - public invite(userId: string, andSubspaces = true): Promise { - // TODO: [@@TR] Share keys + public invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise { const promises: Promise[] = [this.retryInvite(userId)]; if (andSubspaces) { - promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces))); + promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces, shareHistoryKeys))); } - return Promise.all(promises).then(); // .then() to coerce types + return Promise.all(promises).then(() => { + // Note: key sharing is default on because for file trees it is relatively important that the invite + // target can actually decrypt the files. The implied use case is that by inviting a user to the tree + // it means the sender would like the receiver to view/download the files contained within, much like + // sharing a folder in other circles. + if (shareHistoryKeys && isRoomSharedHistory(this.room)) { + // noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails. + this.client.sendSharedHistoryKeys(this.roomId, [userId]); + } + }); } private retryInvite(userId: string): Promise {