Skip to content

Commit 71152f3

Browse files
kaylendogt3chguy
andauthored
Rotate the current room key when we see a member leave (#5231)
* feat: Rotate room key when a member leaves the room Signed-off-by: Skye Elliot <actuallyori@gmail.com> * test: Assert room key rotated to prevent MSC4268 leaking keys Signed-off-by: Skye Elliot <actuallyori@gmail.com> * docs: Outline key rotation scenario above discard logic * feat: Use `RoomStateEvents.Events` over membership event * docs: Correct spelling in scenario explanation Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * docs: Pull scenario explanation up to `onRoomStateEvent` Signed-off-by: Skye Elliot <actuallyori@gmail.com> * tests: Assert room key is rotated under leave va gappy sync * tests: Build sync response incrementally for gappy/ungappy sync --------- Signed-off-by: Skye Elliot <actuallyori@gmail.com> Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
1 parent bc8f670 commit 71152f3

3 files changed

Lines changed: 359 additions & 1 deletion

File tree

spec/integ/crypto/history-sharing.spec.ts

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ afterEach(() => {
6868
const ROOM_ID = "!room:example.com";
6969
const ALICE_HOMESERVER_URL = "https://alice-server.com";
7070
const BOB_HOMESERVER_URL = "https://bob-server.com";
71+
const CHARLIE_HOMESERVER_URL = "https://charlie-server.com";
7172

7273
async function createAndInitClient(homeserverUrl: string, userId: string, setupNewCrossSigning = true) {
7374
mockInitialApiRequests(homeserverUrl, userId);
@@ -91,6 +92,8 @@ describe("History Sharing", () => {
9192
let aliceSyncResponder: SyncResponder;
9293
let bobClient: MatrixClient;
9394
let bobSyncResponder: SyncResponder;
95+
let charlieClient: MatrixClient;
96+
let charlieSyncResponder: SyncResponder;
9497

9598
beforeEach(async () => {
9699
// anything that we don't have a specific matcher for silently returns a 404
@@ -100,6 +103,7 @@ describe("History Sharing", () => {
100103

101104
const aliceId = TEST_USER_ID;
102105
const bobId = "@bob:xyz";
106+
const charlieId = "@charlie:zyx";
103107

104108
const aliceKeyReceiver = new E2EKeyReceiver(ALICE_HOMESERVER_URL, "alice-");
105109
const aliceKeyResponder = new E2EKeyResponder(ALICE_HOMESERVER_URL);
@@ -108,23 +112,39 @@ describe("History Sharing", () => {
108112

109113
const bobKeyReceiver = new E2EKeyReceiver(BOB_HOMESERVER_URL, "bob-");
110114
const bobKeyResponder = new E2EKeyResponder(BOB_HOMESERVER_URL);
115+
const bobKeyClaimResponder = new E2EOTKClaimResponder(BOB_HOMESERVER_URL);
111116
bobSyncResponder = new SyncResponder(BOB_HOMESERVER_URL);
112117

118+
const charlieKeyReceiver = new E2EKeyReceiver(CHARLIE_HOMESERVER_URL, "charlie-");
119+
const charlieKeyResponder = new E2EKeyResponder(CHARLIE_HOMESERVER_URL);
120+
charlieSyncResponder = new SyncResponder(CHARLIE_HOMESERVER_URL);
121+
113122
aliceKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
114123
aliceKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
124+
aliceKeyResponder.addKeyReceiver(charlieId, charlieKeyReceiver);
115125
bobKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
116126
bobKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
127+
bobKeyResponder.addKeyReceiver(charlieId, charlieKeyReceiver);
128+
charlieKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
129+
charlieKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
130+
charlieKeyResponder.addKeyReceiver(charlieId, charlieKeyReceiver);
117131

118132
aliceClient = await createAndInitClient(ALICE_HOMESERVER_URL, aliceId);
119133
bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobId);
134+
charlieClient = await createAndInitClient(CHARLIE_HOMESERVER_URL, charlieId);
120135

121136
aliceKeyClaimResponder.addKeyReceiver(bobId, bobClient.deviceId!, bobKeyReceiver);
137+
aliceKeyClaimResponder.addKeyReceiver(charlieId, charlieClient.deviceId!, charlieKeyReceiver);
138+
bobKeyClaimResponder.addKeyReceiver(aliceId, aliceClient.deviceId!, aliceKeyReceiver);
122139

123140
aliceSyncResponder.sendOrQueueSyncResponse({});
124141
await syncPromise(aliceClient);
125142

126143
bobSyncResponder.sendOrQueueSyncResponse({});
127144
await syncPromise(bobClient);
145+
146+
charlieSyncResponder.sendOrQueueSyncResponse({});
147+
await syncPromise(charlieClient);
128148
});
129149

130150
test("Room keys are successfully shared on invite", async () => {
@@ -676,6 +696,305 @@ describe("History Sharing", () => {
676696
).toBeFalsy();
677697
});
678698

699+
test.each([false, true])(
700+
"Room key is rotated after a member joins and leaves the room (gappy sync = %s)",
701+
async (gappySync) => {
702+
// Alice and Bob are in an encrypted room
703+
let syncResponse = getSyncResponse(
704+
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
705+
HistoryVisibility.Shared,
706+
ROOM_ID,
707+
);
708+
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
709+
bobSyncResponder.sendOrQueueSyncResponse(syncResponse);
710+
711+
await syncPromise(aliceClient);
712+
await syncPromise(bobClient);
713+
714+
// Bob sends a message M1, which both he and Alice receive.
715+
let msgProm = expectSendRoomEvent(BOB_HOMESERVER_URL, "m.room.encrypted");
716+
let toDeviceMessageProm = expectSendToDeviceMessage(BOB_HOMESERVER_URL, "m.room.encrypted");
717+
await bobClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
718+
msgtype: MsgType.Text,
719+
body: "Charlie should be able to read",
720+
});
721+
const bobEventM1Content = await msgProm;
722+
let sentToDeviceRequest = await toDeviceMessageProm;
723+
expect(sentToDeviceRequest).toBeDefined();
724+
let aliceToDeviceMessage = sentToDeviceRequest[aliceClient.getSafeUserId()][aliceClient.deviceId!];
725+
726+
// Alice receives the message down sync.
727+
syncResponse = getSyncResponse(
728+
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
729+
HistoryVisibility.Shared,
730+
ROOM_ID,
731+
);
732+
syncResponse.rooms.join[ROOM_ID].timeline.events.push(
733+
mkEventCustom({
734+
type: "m.room.encrypted",
735+
sender: bobClient.getSafeUserId(),
736+
content: bobEventM1Content,
737+
event_id: "$event_id_m1",
738+
}) as any,
739+
);
740+
syncResponse.to_device = {
741+
events: [
742+
{
743+
type: "m.room.encrypted",
744+
sender: bobClient.getSafeUserId(),
745+
content: aliceToDeviceMessage,
746+
},
747+
],
748+
};
749+
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
750+
await syncPromise(aliceClient);
751+
752+
// Alice checks she can read M1.
753+
const aliceRoom = aliceClient.getRoom(ROOM_ID);
754+
const aliceM1 = aliceRoom!.getLastLiveEvent()!;
755+
await aliceM1.getDecryptionPromise();
756+
expect(aliceM1.getType()).toEqual("m.room.message");
757+
expect(aliceM1.getContent().body).toEqual("Charlie should be able to read");
758+
759+
// Alice invites and sends a key bundle to Charlie
760+
const uploadProm = expectUploadRequest();
761+
toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
762+
fetchMock.postOnce(
763+
`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`,
764+
{},
765+
);
766+
await aliceClient.invite(ROOM_ID, charlieClient.getSafeUserId(), { shareEncryptedHistory: true });
767+
const uploadedBlob = await uploadProm;
768+
sentToDeviceRequest = await toDeviceMessageProm;
769+
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
770+
const charlieToDeviceMessage = sentToDeviceRequest[charlieClient.getSafeUserId()][charlieClient.deviceId!];
771+
expect(charlieToDeviceMessage).toBeDefined();
772+
773+
/// Charlie receives the invite ...
774+
const inviteEvent = mkInviteEvent(aliceClient, charlieClient);
775+
charlieSyncResponder.sendOrQueueSyncResponse({
776+
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
777+
to_device: {
778+
events: [
779+
{
780+
type: "m.room.encrypted",
781+
sender: aliceClient.getSafeUserId(),
782+
content: charlieToDeviceMessage,
783+
},
784+
],
785+
},
786+
});
787+
await syncPromise(charlieClient);
788+
789+
const charlieRoom = charlieClient.getRoom(ROOM_ID);
790+
expect(charlieRoom).toBeTruthy();
791+
expect(charlieRoom?.getMyMembership()).toEqual(KnownMembership.Invite);
792+
793+
// ... and subsequently joins.
794+
fetchMock.postOnce(`${CHARLIE_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
795+
room_id: ROOM_ID,
796+
});
797+
fetchMock.getOnce(`begin:${CHARLIE_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
798+
body: uploadedBlob,
799+
});
800+
await charlieClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
801+
802+
// Charlie syncs to receive M1 and ensure he can read it.
803+
syncResponse = getSyncResponse(
804+
[aliceClient.getSafeUserId(), bobClient.getSafeUserId(), charlieClient.getSafeUserId()],
805+
HistoryVisibility.Shared,
806+
ROOM_ID,
807+
);
808+
syncResponse.rooms.join[ROOM_ID].timeline.events.push(
809+
mkEventCustom({
810+
type: "m.room.encrypted",
811+
sender: bobClient.getSafeUserId(),
812+
content: bobEventM1Content,
813+
event_id: "$event_id_m1",
814+
}) as any,
815+
);
816+
charlieSyncResponder.sendOrQueueSyncResponse(syncResponse);
817+
await syncPromise(charlieClient);
818+
819+
const charlieEventM1 = charlieRoom!
820+
.getLiveTimeline()
821+
.getEvents()
822+
.find((e) => e.getId() === "$event_id_m1");
823+
824+
await charlieEventM1!.getDecryptionPromise();
825+
expect(charlieEventM1!.getType()).toEqual("m.room.message");
826+
expect(charlieEventM1!.getContent().body).toEqual("Charlie should be able to read");
827+
828+
// Charlie then immediately leaves.
829+
const charlieSyncResponse = {
830+
next_batch: "1",
831+
rooms: {
832+
leave: {
833+
[ROOM_ID]: {
834+
state: { events: [] },
835+
timeline: {
836+
events: [
837+
mkEventCustom({
838+
content: { membership: KnownMembership.Leave },
839+
type: EventType.RoomMember,
840+
sender: charlieClient.getSafeUserId(),
841+
state_key: charlieClient.getSafeUserId(),
842+
}),
843+
],
844+
prev_batch: "",
845+
},
846+
account_data: { events: [] },
847+
},
848+
},
849+
invite: {},
850+
join: {},
851+
knock: {},
852+
},
853+
account_data: { events: [] },
854+
};
855+
charlieSyncResponder.sendOrQueueSyncResponse(charlieSyncResponse);
856+
await syncPromise(charlieClient);
857+
858+
syncResponse = {
859+
next_batch: "2",
860+
rooms: {
861+
join: {
862+
[ROOM_ID]: {
863+
timeline: {
864+
events: [],
865+
},
866+
},
867+
},
868+
},
869+
} as any;
870+
if (gappySync) {
871+
// In case of a gappy sync, the timeline is limited and we only see the leave event.
872+
syncResponse.rooms.join[ROOM_ID].timeline.limited = true;
873+
syncResponse.rooms.join[ROOM_ID].state = {
874+
events: [
875+
mkEventCustom({
876+
content: { membership: KnownMembership.Leave },
877+
type: EventType.RoomMember,
878+
sender: charlieClient.getSafeUserId(),
879+
state_key: charlieClient.getSafeUserId(),
880+
}) as any,
881+
],
882+
};
883+
} else {
884+
syncResponse.rooms.join[ROOM_ID].timeline.events.push(
885+
mkEventCustom({
886+
content: { membership: KnownMembership.Join },
887+
type: EventType.RoomMember,
888+
sender: charlieClient.getSafeUserId(),
889+
state_key: charlieClient.getSafeUserId(),
890+
}) as any,
891+
mkEventCustom({
892+
content: { membership: KnownMembership.Leave },
893+
type: EventType.RoomMember,
894+
sender: charlieClient.getSafeUserId(),
895+
state_key: charlieClient.getSafeUserId(),
896+
}) as any,
897+
);
898+
}
899+
// Bob syncs to learn about Charlie's leaving (and joining if non-gappy).
900+
bobSyncResponder.sendOrQueueSyncResponse(syncResponse);
901+
await syncPromise(bobClient);
902+
903+
// Bob then sends M2, sharing a new room key with Alice.
904+
msgProm = expectSendRoomEvent(BOB_HOMESERVER_URL, "m.room.encrypted");
905+
toDeviceMessageProm = expectSendToDeviceMessage(BOB_HOMESERVER_URL, "m.room.encrypted");
906+
await bobClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
907+
msgtype: MsgType.Text,
908+
body: "Charlie should not be able to read",
909+
});
910+
const bobEventM2Content = await msgProm;
911+
sentToDeviceRequest = await toDeviceMessageProm;
912+
expect(sentToDeviceRequest).toBeDefined();
913+
aliceToDeviceMessage = sentToDeviceRequest[aliceClient.getSafeUserId()][aliceClient.deviceId!];
914+
915+
// Charlie should not receive the room key
916+
expect(sentToDeviceRequest[charlieClient.getSafeUserId()]).toBeUndefined();
917+
918+
debug(`Bob sent encrypted room event: ${JSON.stringify(bobEventM2Content)}`);
919+
920+
// Sync the message to Alice along with the to-device message, and check she can decrypt it.
921+
syncResponse = {
922+
next_batch: "3",
923+
rooms: {
924+
join: {
925+
[ROOM_ID]: {
926+
timeline: {
927+
events: [
928+
mkEventCustom({
929+
type: "m.room.encrypted",
930+
sender: bobClient.getSafeUserId(),
931+
content: bobEventM2Content,
932+
event_id: "$event_id_m2",
933+
}) as any,
934+
],
935+
},
936+
},
937+
},
938+
},
939+
to_device: {
940+
events: [
941+
{
942+
type: "m.room.encrypted",
943+
sender: bobClient.getSafeUserId(),
944+
content: aliceToDeviceMessage,
945+
},
946+
],
947+
},
948+
} as any;
949+
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
950+
await syncPromise(aliceClient);
951+
952+
const aliceEventM2 = aliceRoom!.getLastLiveEvent()!;
953+
await aliceEventM2.getDecryptionPromise();
954+
expect(aliceEventM2.getType()).toEqual("m.room.message");
955+
expect(aliceEventM2.getContent().body).toEqual("Charlie should not be able to read");
956+
957+
// Charlie rejoins the room by ID, receives M2, which he should not be able to decrypt.
958+
fetchMock.postOnce(`${CHARLIE_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
959+
room_id: ROOM_ID,
960+
});
961+
await charlieClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
962+
syncResponse = {
963+
next_batch: "4",
964+
rooms: {
965+
join: {
966+
[ROOM_ID]: {
967+
timeline: {
968+
events: [
969+
mkEventCustom({
970+
type: "m.room.encrypted",
971+
sender: bobClient.getSafeUserId(),
972+
content: bobEventM2Content,
973+
event_id: "$event_id_m2",
974+
}) as any,
975+
],
976+
},
977+
},
978+
},
979+
},
980+
} as any;
981+
charlieSyncResponder.sendOrQueueSyncResponse(syncResponse);
982+
await syncPromise(charlieClient);
983+
984+
const events = charlieRoom!.getLiveTimeline().getEvents();
985+
expect(events.length).toBeGreaterThanOrEqual(2);
986+
987+
const charlieM2 = charlieRoom!
988+
.getLiveTimeline()
989+
.getEvents()
990+
.find((e) => e.getId() === "$event_id_m2");
991+
992+
await charlieM2!.getDecryptionPromise();
993+
expect(charlieM2!.isDecryptionFailure()).toBeTruthy();
994+
},
995+
60e3,
996+
);
997+
679998
afterEach(async () => {
680999
vitest.useRealTimers();
6811000
bobClient.stopClient();

src/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ import {
101101
type RoomNameState,
102102
} from "./models/room.ts";
103103
import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts";
104-
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
104+
import { RoomStateEvent, type IPowerLevelsContent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
105105
import {
106106
isSendDelayedEventRequestOpts,
107107
UpdateDelayedEventAction,
@@ -2027,6 +2027,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
20272027

20282028
// attach the event listeners needed by RustCrypto
20292029
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
2030+
this.on(RoomStateEvent.Events, rustCrypto.onRoomStateEvent.bind(rustCrypto));
20302031
this.on(ClientEvent.Event, (event) => {
20312032
rustCrypto.onLiveEventFromSync(event);
20322033
});

0 commit comments

Comments
 (0)